fix(test): honor shell completion test args

This commit is contained in:
Vincent Koc 2026-06-20 03:12:16 +02:00
parent e6823c3d16
commit 0cf941344c
No known key found for this signature in database
4 changed files with 214 additions and 48 deletions

View file

@ -25,10 +25,17 @@
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { confirm, isCancel } from "@clack/prompts";
import { stylePromptMessage } from "../packages/terminal-core/src/prompt-style.js";
import { theme } from "../packages/terminal-core/src/theme.js";
import { installCompletion } from "../src/cli/completion-cli.js";
import {
COMPLETION_SHELLS,
installCompletion,
isCompletionShell,
resolveCompletionProfilePath,
type CompletionShell,
} from "../src/cli/completion-runtime.js";
import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
@ -40,6 +47,7 @@ interface Options {
checkOnly: boolean;
force: boolean;
help: boolean;
shell?: CompletionShell;
}
function parseArgs(args: string[]): Options {
@ -49,19 +57,38 @@ function parseArgs(args: string[]): Options {
help: false,
};
for (const arg of args) {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--check-only") {
options.checkOnly = true;
} else if (arg === "--force") {
options.force = true;
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--shell") {
const value = args[index + 1];
options.shell = parseShellOptionValue(value);
index += 1;
} else if (arg.startsWith("--shell=")) {
options.shell = parseShellOptionValue(arg.slice("--shell=".length));
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function parseShellOptionValue(value: string | undefined): CompletionShell {
if (!value || value.startsWith("-")) {
throw new Error("--shell requires a value");
}
if (!isCompletionShell(value)) {
throw new Error(`--shell must be one of: ${COMPLETION_SHELLS.join(", ")}`);
}
return value;
}
function printHelp(): void {
console.log(`
${theme.heading("Shell Completion Test Script")}
@ -75,6 +102,7 @@ ${theme.heading("Usage (run from repo root):")}
bun scripts/test-shell-completion.ts [options]
${theme.heading("Options:")}
--shell <shell> Override shell detection (zsh, bash, fish, powershell)
--check-only Only check status, don't prompt to install
--force Skip the "already installed" check and prompt anyway
--help, -h Show this help message
@ -87,37 +115,11 @@ ${theme.heading("Behavior:")}
${theme.heading("Examples:")}
node --import tsx scripts/test-shell-completion.ts
node --import tsx scripts/test-shell-completion.ts --check-only
node --import tsx scripts/test-shell-completion.ts --shell bash
node --import tsx scripts/test-shell-completion.ts --force
`);
}
function getShellProfilePath(shell: string): string {
const home = process.env.HOME || os.homedir();
switch (shell) {
case "zsh":
return path.join(home, ".zshrc");
case "bash":
return process.platform === "darwin"
? path.join(home, ".bash_profile")
: path.join(home, ".bashrc");
case "fish":
return path.join(home, ".config", "fish", "config.fish");
case "powershell":
if (process.platform === "win32") {
return path.join(
process.env.USERPROFILE || home,
"Documents",
"PowerShell",
"Microsoft.PowerShell_profile.ps1",
);
}
return path.join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
default:
return path.join(home, ".zshrc");
}
}
async function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
@ -131,11 +133,12 @@ async function main() {
console.log("");
// Get completion status using the same function used by doctor/update/onboard
const status = await checkShellCompletionStatus(CLI_NAME);
const status = await checkShellCompletionStatus(CLI_NAME, { shell: options.shell });
const shellSource = options.shell ? "(from --shell)" : "(detected from $SHELL)";
console.log(` Shell: ${theme.accent(status.shell)} ${theme.muted("(detected from $SHELL)")}`);
console.log(` Shell: ${theme.accent(status.shell)} ${theme.muted(shellSource)}`);
console.log(` Platform: ${theme.muted(process.platform)} ${theme.muted(`(${os.release()})`)}`);
console.log(` Profile: ${theme.muted(getShellProfilePath(status.shell))}`);
console.log(` Profile: ${theme.muted(resolveCompletionProfilePath(status.shell))}`);
console.log(` Cache path: ${theme.muted(status.cachePath)}`);
console.log("");
console.log(
@ -155,7 +158,7 @@ async function main() {
// Profile uses slow dynamic pattern - upgrade to cached version
if (status.usesSlowPattern) {
console.log(theme.warn("Profile uses slow dynamic completion. Upgrading to cached version..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
if (cacheGenerated) {
await installCompletion(status.shell, false, CLI_NAME);
console.log(theme.success("Upgraded to cached completion."));
@ -168,7 +171,7 @@ async function main() {
// Profile has completion but no cache - auto-fix
if (status.profileInstalled && !status.cacheExists) {
console.log(theme.warn("Profile has completion but cache is missing. Regenerating..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
if (cacheGenerated) {
console.log(theme.success("Cache regenerated successfully."));
} else {
@ -205,7 +208,7 @@ async function main() {
// Generate cache first (required for fast shell startup)
if (!status.cacheExists) {
console.log(theme.muted("Generating completion cache..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
if (!cacheGenerated) {
console.log(theme.error("Failed to generate completion cache."));
return;
@ -217,7 +220,14 @@ async function main() {
await installCompletion(status.shell, false, CLI_NAME);
}
main().catch((err: unknown) => {
console.error(theme.error(`Error: ${String(err)}`));
process.exit(1);
});
export const testing = {
parseArgs,
};
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1] ?? "")).href) {
main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(theme.error(`Error: ${message}`));
process.exit(1);
});
}

View file

@ -1,11 +1,35 @@
// Doctor completion tests cover final doctor status summaries and completion messaging.
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
checkShellCompletionStatus,
shellCompletionStatusToHealthFindings,
shellCompletionStatusToRepairEffects,
type ShellCompletionStatus,
} from "./doctor-completion.js";
const originalEnv = {
HOME: process.env.HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
SHELL: process.env.SHELL,
};
const tempDirs: string[] = [];
afterEach(async () => {
for (const [name, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
function status(overrides: Partial<ShellCompletionStatus> = {}): ShellCompletionStatus {
return {
shell: "zsh",
@ -18,6 +42,22 @@ function status(overrides: Partial<ShellCompletionStatus> = {}): ShellCompletion
}
describe("shell completion health mapping", () => {
it("checks an explicit shell instead of the detected environment shell", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-"));
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-"));
tempDirs.push(homeDir, stateDir);
process.env.HOME = homeDir;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.SHELL = "/bin/zsh";
const current = await checkShellCompletionStatus("openclaw", { shell: "fish" });
expect(current.shell).toBe("fish");
expect(current.cachePath).toBe(path.join(stateDir, "completions", "openclaw.fish"));
expect(current.profileInstalled).toBe(false);
expect(current.cacheExists).toBe(false);
});
it("reports slow dynamic shell completion with dry-run effects", () => {
const current = status({ usesSlowPattern: true, cacheExists: false });

View file

@ -12,16 +12,19 @@ import {
resolveCompletionProfilePath,
resolveShellFromEnv,
usesSlowDynamicCompletion,
type CompletionShell,
} from "../cli/completion-runtime.js";
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
import type { RuntimeEnv } from "../runtime.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
type CompletionShell = "zsh" | "bash" | "fish" | "powershell";
const COMPLETION_CACHE_WRITE_TIMEOUT_MS = 30_000;
export type ShellCompletionStatusOptions = {
shell?: CompletionShell;
};
function resolveCompletionReloadPath(shell: CompletionShell): string {
if (shell === "powershell") {
return resolveCompletionProfilePath("powershell");
@ -38,7 +41,9 @@ function formatCompletionReloadNote(
}
/** Generate the completion cache by spawning the CLI. */
async function generateCompletionCache(): Promise<boolean> {
async function generateCompletionCache(
options: ShellCompletionStatusOptions = {},
): Promise<boolean> {
const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
@ -49,7 +54,11 @@ async function generateCompletionCache(): Promise<boolean> {
}
const binPath = path.join(root, "openclaw.mjs");
const result = spawnSync(process.execPath, [binPath, "completion", "--write-state"], {
const args = [binPath, "completion", "--write-state"];
if (options.shell) {
args.push("--shell", options.shell);
}
const result = spawnSync(process.execPath, args, {
cwd: root,
env: process.env,
encoding: "utf-8",
@ -71,8 +80,9 @@ export type ShellCompletionStatus = {
/** Check the status of shell completion for the current shell. */
export async function checkShellCompletionStatus(
binName = "openclaw",
options: ShellCompletionStatusOptions = {},
): Promise<ShellCompletionStatus> {
const shell = resolveShellFromEnv() as CompletionShell;
const shell = options.shell ?? resolveShellFromEnv();
const profileInstalled = await isCompletionInstalled(shell, binName);
const cacheExists = await completionCacheExists(shell, binName);
const cachePath = resolveCompletionCachePath(shell, binName);
@ -234,13 +244,16 @@ export async function doctorShellCompletion(
}
/** Ensures the shell completion cache exists without prompting during setup/update flows. */
export async function ensureCompletionCacheExists(binName = "openclaw"): Promise<boolean> {
const shell = resolveShellFromEnv() as CompletionShell;
export async function ensureCompletionCacheExists(
binName = "openclaw",
options: ShellCompletionStatusOptions = {},
): Promise<boolean> {
const shell = options.shell ?? resolveShellFromEnv();
const cacheExists = await completionCacheExists(shell, binName);
if (cacheExists) {
return true;
}
return generateCompletionCache();
return generateCompletionCache(options);
}

View file

@ -0,0 +1,103 @@
// Shell completion test script tests cover local diagnostic CLI argument safety.
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { testing as shellCompletionTesting } from "../../scripts/test-shell-completion.ts";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { force: true, recursive: true });
}
});
describe("test-shell-completion script", () => {
it("parses explicit shell overrides", () => {
expect(shellCompletionTesting.parseArgs(["--shell", "bash", "--check-only"])).toEqual({
checkOnly: true,
force: false,
help: false,
shell: "bash",
});
expect(shellCompletionTesting.parseArgs(["--shell=fish"])).toEqual({
checkOnly: false,
force: false,
help: false,
shell: "fish",
});
});
it("rejects unknown or malformed arguments before touching shell state", () => {
expect(() => shellCompletionTesting.parseArgs(["--wat"])).toThrow("Unknown argument: --wat");
expect(() => shellCompletionTesting.parseArgs(["--shell"])).toThrow("--shell requires a value");
expect(() => shellCompletionTesting.parseArgs(["--shell", "--check-only"])).toThrow(
"--shell requires a value",
);
expect(() => shellCompletionTesting.parseArgs(["--shell", "tcsh"])).toThrow(
"--shell must be one of:",
);
});
it("prints help without running completion status checks", () => {
const result = spawnSync(
process.execPath,
["--import", "tsx", "scripts/test-shell-completion.ts", "--help"],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("--shell <shell>");
expect(result.stdout).not.toContain("Cache path:");
});
it("fails unknown arguments before prompting or checking shell state", () => {
const result = spawnSync(
process.execPath,
["--import", "tsx", "scripts/test-shell-completion.ts", "--wat"],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("Unknown argument: --wat");
});
it("uses --shell for check-only status instead of the detected shell", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-shell-home-"));
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-shell-state-"));
tempDirs.push(homeDir, stateDir);
const result = spawnSync(
process.execPath,
["--import", "tsx", "scripts/test-shell-completion.ts", "--shell", "fish", "--check-only"],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
HOME: homeDir,
NO_COLOR: "1",
OPENCLAW_STATE_DIR: stateDir,
SHELL: "/bin/zsh",
},
},
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Shell:");
expect(result.stdout).toContain("fish");
expect(result.stdout).toContain("(from --shell)");
expect(result.stdout).not.toContain("(detected from $SHELL)");
});
});