mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(test): honor shell completion test args
This commit is contained in:
parent
e6823c3d16
commit
0cf941344c
4 changed files with 214 additions and 48 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
103
test/scripts/test-shell-completion.test.ts
Normal file
103
test/scripts/test-shell-completion.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue