mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 18:21:36 +00:00
* fix(cli): make commands, completion, and JSON output reliable * fix(cli): reconcile completion coverage with current main * test(cli): keep test routing stable across isolation lanes
965 lines
36 KiB
TypeScript
965 lines
36 KiB
TypeScript
// Completion CLI tests cover shell completion command generation and install output.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Command, Option } from "commander";
|
|
import { describe, expect, it } from "vitest";
|
|
import { getCompletionScript, registerCompletionCli } from "./completion-cli.js";
|
|
import {
|
|
createAliasedCompletionProgram,
|
|
itWithFish,
|
|
itWithPowerShell,
|
|
runGeneratedBashCompletion,
|
|
runGeneratedFishCompletion,
|
|
runGeneratedPowerShellCompletion,
|
|
} from "./completion-cli.test-support.js";
|
|
|
|
function createCompletionProgram(): Command {
|
|
const program = new Command();
|
|
program.name("openclaw");
|
|
program.description("CLI root");
|
|
program.option("-v, --verbose", "Verbose output");
|
|
program.option(
|
|
"--status-json",
|
|
"Output JSON (alias for `models status --json`) in $OPENCLAW_STATE_DIR",
|
|
);
|
|
|
|
const gateway = program.command("gateway").description("Gateway commands");
|
|
gateway.option("--force", "Force the action");
|
|
gateway.option("-t, --token <token>", "Gateway token");
|
|
|
|
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
|
|
gateway.command("restart").description("Restart gateway");
|
|
program
|
|
.command("agent")
|
|
.description("Agent commands")
|
|
.option("--verbose <on|off>", "Set verbosity");
|
|
const sessions = program.command("sessions").description("Session commands");
|
|
sessions.option("--verbose", "Verbose output");
|
|
sessions.command("cleanup").description("Clean sessions").option("--dry-run", "Preview cleanup");
|
|
|
|
return program;
|
|
}
|
|
|
|
function createDocumentedCompletionProgram(): Command {
|
|
const program = createCompletionProgram();
|
|
registerCompletionCli(program);
|
|
return program;
|
|
}
|
|
|
|
function createOptionalChoiceCompletionProgram(): Command {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--mode [mode]", "Mode").choices(["auto", "manual", "-legacy"]));
|
|
program.option("--json", "JSON output");
|
|
return program;
|
|
}
|
|
|
|
describe("completion-cli", () => {
|
|
it("generates zsh functions for nested subcommands", () => {
|
|
const script = getCompletionScript("zsh", createCompletionProgram());
|
|
|
|
expect(script).toContain("_openclaw_gateway()");
|
|
expect(script).toContain("(status) _openclaw_gateway_status ;;");
|
|
expect(script).toContain("(restart) _openclaw_gateway_restart ;;");
|
|
expect(script).toContain("--force[Force the action]");
|
|
expect(script).toContain("\\`models status --json\\`");
|
|
expect(script).toContain("\\$OPENCLAW_STATE_DIR");
|
|
});
|
|
|
|
it("escapes zsh option descriptions for double-quoted arguments specs", () => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.option("--literal", "Use $OPENCLAW_STATE_DIR with `model/list` and John's profile");
|
|
|
|
const script = getCompletionScript("zsh", program);
|
|
|
|
expect(script).toContain(
|
|
"--literal[Use \\$OPENCLAW_STATE_DIR with \\`model/list\\` and John's profile]",
|
|
);
|
|
expect(script).not.toContain("John'\\''s");
|
|
});
|
|
|
|
it("marks zsh option arguments and completes validated shell choices", () => {
|
|
const script = getCompletionScript("zsh", createDocumentedCompletionProgram());
|
|
|
|
expect(script).toContain('"[Gateway token]:token:"');
|
|
expect(script).toContain(
|
|
'"[Shell to generate completion for (default: zsh)]:shell:(zsh bash powershell fish)"',
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps zsh completion choices literal and preserves candidate boundaries",
|
|
() => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(
|
|
new Option("--value <value>", "Value").choices([
|
|
"two words",
|
|
'say "hello"',
|
|
"it's literal",
|
|
"literal $(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
|
|
"literal `printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
|
|
]),
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"zsh",
|
|
[
|
|
"-fc",
|
|
`${getCompletionScript("zsh", program)}
|
|
_arguments() { printf '%s\\n' "$@"; }
|
|
_openclaw_root_completion
|
|
`,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (result.error) {
|
|
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
return;
|
|
}
|
|
throw result.error;
|
|
}
|
|
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("two\\ words");
|
|
expect(result.stdout).toContain('say\\ \\"hello\\"');
|
|
expect(result.stdout).toContain("OPENCLAW_COMPLETION_VALUE_EXECUTED");
|
|
},
|
|
);
|
|
|
|
it("defers zsh registration until compinit is available", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const probe = spawnSync("zsh", ["-fc", "exit 0"], { encoding: "utf8" });
|
|
if (probe.error) {
|
|
if (
|
|
"code" in probe.error &&
|
|
(probe.error.code === "ENOENT" || probe.error.code === "EACCES")
|
|
) {
|
|
return;
|
|
}
|
|
throw probe.error;
|
|
}
|
|
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zsh-completion-"));
|
|
try {
|
|
const scriptPath = path.join(tempDir, "openclaw.zsh");
|
|
await fs.writeFile(scriptPath, getCompletionScript("zsh", createCompletionProgram()), "utf8");
|
|
|
|
const result = spawnSync(
|
|
"zsh",
|
|
[
|
|
"-fc",
|
|
`
|
|
source ${JSON.stringify(scriptPath)}
|
|
[[ -z "\${_comps[openclaw]-}" ]] || exit 10
|
|
[[ "\${precmd_functions[(r)_openclaw_register_completion]}" = "_openclaw_register_completion" ]] || exit 11
|
|
autoload -Uz compinit
|
|
compinit -C
|
|
_openclaw_register_completion
|
|
[[ -z "\${precmd_functions[(r)_openclaw_register_completion]}" ]] || exit 12
|
|
[[ "\${_comps[openclaw]-}" = "_openclaw_root_completion" ]]
|
|
`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
HOME: tempDir,
|
|
ZDOTDIR: tempDir,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.stderr).not.toContain("command not found: compdef");
|
|
expect(result.status).toBe(0);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("generates PowerShell command paths without the executable prefix", () => {
|
|
const script = getCompletionScript("powershell", createCompletionProgram());
|
|
|
|
expect(script).toContain("if ($commandPath -eq 'gateway') {");
|
|
expect(script).toContain("if ($commandPath -eq 'gateway status') {");
|
|
expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {");
|
|
expect(script).toContain("$completions = @('status','restart','--force','-t','--token')");
|
|
expect(script).not.toContain("'-t,'");
|
|
});
|
|
|
|
it("generates valid PowerShell root arrays when commands or options are empty", () => {
|
|
const commandsOnly = new Command().name("openclaw");
|
|
commandsOnly.command("status");
|
|
const optionsOnly = new Command().name("openclaw").option("--json", "JSON output");
|
|
const empty = new Command().name("openclaw");
|
|
|
|
expect(getCompletionScript("powershell", commandsOnly)).toContain("$completions = @('status')");
|
|
expect(getCompletionScript("powershell", optionsOnly)).toContain("$completions = @('--json')");
|
|
expect(getCompletionScript("powershell", empty)).toContain("$completions = @()");
|
|
});
|
|
|
|
it("preserves documented short and long completion flags in PowerShell", () => {
|
|
const script = getCompletionScript("powershell", createDocumentedCompletionProgram());
|
|
|
|
expect(script).toContain("'-v','--verbose'");
|
|
expect(script).toContain("'--force','-t','--token'");
|
|
expect(script).toContain("'-s','--shell','-i','--install','--write-state','-y','--yes'");
|
|
});
|
|
|
|
it("generates PowerShell value choices for both completion shell flags", () => {
|
|
const script = getCompletionScript("powershell", createDocumentedCompletionProgram());
|
|
|
|
expect(script).toContain("if ($choiceFlag -in @('-s','--shell')) {");
|
|
expect(script).toContain("@('zsh','bash','powershell','fish')");
|
|
expect(script).toContain("'ParameterValue'");
|
|
});
|
|
|
|
it("escapes apostrophes in PowerShell completion choices", () => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--profile <name>", "Profile").choices(["Jane's", "work"]));
|
|
|
|
expect(getCompletionScript("powershell", program)).toContain("@('Jane''s','work')");
|
|
});
|
|
|
|
it("matches PowerShell value prefixes literally and case-insensitively", () => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--value <value>", "Value").choices(["alpha", "a*literal"]));
|
|
|
|
expect(getCompletionScript("powershell", program)).toContain(
|
|
"StartsWith($choicePrefix, [StringComparison]::OrdinalIgnoreCase)",
|
|
);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
["a long shell flag", "openclaw completion --shell f"],
|
|
["a short shell flag", "openclaw completion -s f"],
|
|
])("completes validated values in real PowerShell after %s", (_name, commandLine) => {
|
|
expect(
|
|
runGeneratedPowerShellCompletion(createDocumentedCompletionProgram(), commandLine),
|
|
).toEqual(["fish"]);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
{
|
|
name: "an omitted optional value",
|
|
commandLine: "openclaw --mode --j",
|
|
expected: ["--json"],
|
|
},
|
|
{
|
|
name: "an inline optional value",
|
|
commandLine: "openclaw --mode=a",
|
|
expected: ["--mode=auto"],
|
|
},
|
|
{
|
|
name: "a hyphen-prefixed optional choice",
|
|
commandLine: "openclaw --mode -l",
|
|
expected: ["-legacy"],
|
|
},
|
|
])("preserves real PowerShell completion after $name", ({ commandLine, expected }) => {
|
|
expect(
|
|
runGeneratedPowerShellCompletion(createOptionalChoiceCompletionProgram(), commandLine),
|
|
).toEqual(expected);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
{
|
|
name: "an ordinary prefix",
|
|
commandLine: "openclaw --value al",
|
|
expected: ["alpha"],
|
|
},
|
|
{
|
|
name: "a literal asterisk",
|
|
commandLine: "openclaw --value a*",
|
|
expected: ["'a*literal'"],
|
|
},
|
|
{
|
|
name: "a literal opening bracket",
|
|
commandLine: "openclaw --value a[",
|
|
expected: ["'a[bracket]'"],
|
|
},
|
|
{
|
|
name: "a case-insensitive literal asterisk",
|
|
commandLine: "openclaw --value A*",
|
|
expected: ["'a*literal'"],
|
|
},
|
|
{
|
|
name: "an inline literal asterisk",
|
|
commandLine: "openclaw --value=a*",
|
|
expected: ["--value='a*literal'"],
|
|
},
|
|
])("matches real PowerShell choices with $name", ({ commandLine, expected }) => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(
|
|
new Option("--value <value>", "Value").choices(["alpha", "a*literal", "a[bracket]"]),
|
|
);
|
|
|
|
expect(runGeneratedPowerShellCompletion(program, commandLine)).toEqual(expected);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
{ name: "ordinary choices", value: "alpha", prefix: "al" },
|
|
{ name: "whitespace", value: "two words", prefix: "tw" },
|
|
{ name: "apostrophes", value: "Jane's", prefix: "Ja" },
|
|
{
|
|
name: "literal command substitution",
|
|
value: "literal $(Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED)",
|
|
prefix: "literal",
|
|
},
|
|
{
|
|
name: "literal backtick metacharacters",
|
|
value: "literal `$(Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED)",
|
|
prefix: "literal",
|
|
},
|
|
{
|
|
name: "literal statement separators",
|
|
value: "literal; Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED",
|
|
prefix: "literal",
|
|
},
|
|
])("inserts PowerShell $name as one safe argument", ({ value, prefix }) => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--value <value>", "Value").choices([value]));
|
|
const safeValue = /^[A-Za-z0-9_./:+-]+$/.test(value)
|
|
? value
|
|
: `'${value.replaceAll("'", "''")}'`;
|
|
|
|
expect(runGeneratedPowerShellCompletion(program, `openclaw --value ${prefix}`)).toEqual([
|
|
safeValue,
|
|
]);
|
|
expect(runGeneratedPowerShellCompletion(program, `openclaw --value=${prefix}`)).toEqual([
|
|
`--value=${safeValue}`,
|
|
]);
|
|
});
|
|
|
|
itWithPowerShell("completes root short and long flags in real PowerShell", () => {
|
|
const completions = runGeneratedPowerShellCompletion(
|
|
createDocumentedCompletionProgram(),
|
|
"openclaw -",
|
|
);
|
|
|
|
expect(completions).toEqual(expect.arrayContaining(["-v", "--verbose", "--status-json"]));
|
|
});
|
|
|
|
itWithPowerShell("completes documented nested short and long flags in real PowerShell", () => {
|
|
const completions = runGeneratedPowerShellCompletion(
|
|
createDocumentedCompletionProgram(),
|
|
"openclaw completion -",
|
|
);
|
|
|
|
expect(completions).toEqual(
|
|
expect.arrayContaining(["-s", "--shell", "-i", "--install", "-y", "--yes", "--write-state"]),
|
|
);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
["a long flag", "openclaw gateway --token secret st"],
|
|
["a short flag", "openclaw gateway -t secret st"],
|
|
["an inline long value", "openclaw gateway --token=secret st"],
|
|
["an inline short value", "openclaw gateway -t=secret st"],
|
|
["a preceding boolean flag", "openclaw gateway --force --token secret st"],
|
|
])("keeps real PowerShell nested completions after %s", (_name, commandLine) => {
|
|
expect(runGeneratedPowerShellCompletion(createCompletionProgram(), commandLine)).toEqual([
|
|
"status",
|
|
]);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
["-v", ["-v"]],
|
|
["--v", ["--verbose"]],
|
|
])("filters real PowerShell root flag aliases for %s", (prefix, expected) => {
|
|
expect(
|
|
runGeneratedPowerShellCompletion(createDocumentedCompletionProgram(), `openclaw ${prefix}`),
|
|
).toEqual(expected);
|
|
});
|
|
|
|
it("generates fish completions for root and nested command contexts", () => {
|
|
const script = getCompletionScript("fish", createCompletionProgram());
|
|
|
|
expect(script).toContain(
|
|
'complete -c openclaw -n "__openclaw_command_path_matches --" -a "gateway" -d \'Gateway commands\'',
|
|
);
|
|
expect(script).toContain(
|
|
'complete -c openclaw -n "__openclaw_command_path_matches gateway -- -t --token" -a "status" -d \'Show gateway status\'',
|
|
);
|
|
expect(script).toContain(
|
|
"complete -c openclaw -n \"__openclaw_command_path_matches gateway -- -t --token\" -l force -d 'Force the action'",
|
|
);
|
|
expect(script).toContain(
|
|
"complete -c openclaw -n \"__openclaw_command_path_matches gateway status -- -t --token\" -l json -d 'JSON output'",
|
|
);
|
|
expect(script).toContain("__openclaw_command_path_matches gateway -- -t --token");
|
|
expect(script).toContain("if contains -- $flag $value_options");
|
|
});
|
|
|
|
it("distinguishes Fish child command paths from positional arguments", () => {
|
|
const script = getCompletionScript("fish", createCompletionProgram());
|
|
|
|
expect(script).toContain('switch "$candidate_path"');
|
|
expect(script).toContain("'gateway status'");
|
|
});
|
|
|
|
itWithFish.each([
|
|
["a separate long root option", "openclaw --profile work g"],
|
|
["an inline long root option", "openclaw --profile=work g"],
|
|
["a separate short root option", "openclaw -p work g"],
|
|
["an inline short root option", "openclaw -p=work g"],
|
|
["an attached short root option", "openclaw -pwork g"],
|
|
["a separate log-level root option", "openclaw --log-level debug g"],
|
|
["an inline log-level root option", "openclaw --log-level=debug g"],
|
|
["a separate container root option", "openclaw --container local g"],
|
|
["an inline container root option", "openclaw --container=local g"],
|
|
["repeated root options", "openclaw --profile first --profile second g"],
|
|
[
|
|
"mixed value-taking root options",
|
|
"openclaw --profile work --log-level debug --container local g",
|
|
],
|
|
["a preceding boolean root option", "openclaw -v --profile work g"],
|
|
["a root option value named like a command", "openclaw --profile gateway g"],
|
|
])("completes root commands in real Fish after %s", (_name, commandLine) => {
|
|
const program = createCompletionProgram()
|
|
.option("-p, --profile <name>", "Profile")
|
|
.option("--log-level <level>", "Log level")
|
|
.option("--container <name>", "Container");
|
|
|
|
expect(runGeneratedFishCompletion(program, commandLine)).toContain("gateway");
|
|
});
|
|
|
|
itWithFish.each([
|
|
["a separate long root option", "openclaw --profile work --p"],
|
|
["an inline long root option", "openclaw --profile=work --p"],
|
|
["a separate short root option", "openclaw -p work --p"],
|
|
["repeated root options", "openclaw --profile first --profile second --p"],
|
|
])("completes root options in real Fish after %s", (_name, commandLine) => {
|
|
const program = createCompletionProgram().option("-p, --profile <name>", "Profile");
|
|
|
|
expect(runGeneratedFishCompletion(program, commandLine)).toContain("--profile");
|
|
});
|
|
|
|
itWithFish.each([
|
|
["the exact nested command", "openclaw gateway status -"],
|
|
["a separate long option value", "openclaw gateway --token secret status -"],
|
|
["a separate short option value", "openclaw gateway -t secret status -"],
|
|
["an inline long option value", "openclaw gateway --token=secret status -"],
|
|
["an inline short option value", "openclaw gateway -t=secret status -"],
|
|
["a parent boolean option", "openclaw gateway --force status -"],
|
|
])("keeps real Fish completions scoped after %s", (_name, commandLine) => {
|
|
expect(runGeneratedFishCompletion(createCompletionProgram(), commandLine)).toEqual(["--json"]);
|
|
});
|
|
|
|
itWithFish.each([
|
|
["a positional argument", "openclaw gateway status query -"],
|
|
["multiple positional arguments", "openclaw gateway status first second -"],
|
|
["a positional argument named like a sibling", "openclaw gateway status restart -"],
|
|
["a long option and positional argument", "openclaw gateway --token secret status query -"],
|
|
["an inline option and positional argument", "openclaw gateway --token=secret status query -"],
|
|
])("keeps real Fish leaf options after %s", (_name, commandLine) => {
|
|
const program = createCompletionProgram();
|
|
const gateway = program.commands.find((command) => command.name() === "gateway");
|
|
const status = gateway?.commands.find((command) => command.name() === "status");
|
|
if (!status) {
|
|
throw new Error("Gateway status command is unavailable");
|
|
}
|
|
status.argument("[query...]", "Search query");
|
|
|
|
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--json"]);
|
|
});
|
|
|
|
itWithFish("preserves documented short and long completion flags in real Fish", () => {
|
|
expect(
|
|
runGeneratedFishCompletion(createDocumentedCompletionProgram(), "openclaw completion -"),
|
|
).toEqual(
|
|
expect.arrayContaining(["-s", "--shell", "-i", "--install", "-y", "--yes", "--write-state"]),
|
|
);
|
|
});
|
|
|
|
itWithFish.each([
|
|
["a separated long optional value", "openclaw --color a", "always"],
|
|
["a separated short optional value", "openclaw -c n", "never"],
|
|
["an attached long optional value", "openclaw --color=a", "--color=always"],
|
|
])("completes real Fish Commander choices after %s", (_name, commandLine, expected) => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("-c, --color [when]").choices(["always", "never"]));
|
|
|
|
expect(runGeneratedFishCompletion(program, commandLine)).toContain(expected);
|
|
});
|
|
|
|
itWithFish.each([
|
|
["a long shell flag", "openclaw completion --shell f"],
|
|
["a short shell flag", "openclaw completion -s f"],
|
|
])("completes validated values in real Fish after %s", (_name, commandLine) => {
|
|
expect(runGeneratedFishCompletion(createDocumentedCompletionProgram(), commandLine)).toEqual([
|
|
"fish",
|
|
]);
|
|
});
|
|
|
|
it("registers validated Fish option choices without filesystem fallback", () => {
|
|
const script = getCompletionScript("fish", createDocumentedCompletionProgram());
|
|
|
|
expect(script).toContain(" -s s -l shell -r -f -a ");
|
|
expect(script).toContain("'zsh' 'bash' 'powershell' 'fish'");
|
|
});
|
|
|
|
itWithFish.each([
|
|
{ name: "whitespace", value: "two words", prefix: "tw" },
|
|
{ name: "double quotes", value: 'say "hello"', prefix: "sa" },
|
|
{ name: "apostrophes", value: "it's literal", prefix: "it" },
|
|
{
|
|
name: "literal command substitution",
|
|
value: "literal $(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
|
|
prefix: "literal",
|
|
},
|
|
{
|
|
name: "literal backtick substitution",
|
|
value: "literal `printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
|
|
prefix: "literal",
|
|
},
|
|
])("preserves Fish choice $name as one inert candidate", ({ value, prefix }) => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--value <value>", "Value").choices([value]));
|
|
|
|
expect(runGeneratedFishCompletion(program, `openclaw --value ${prefix}`)).toEqual([value]);
|
|
});
|
|
|
|
it("does not require optional Fish option choices", () => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--mode [mode]", "Mode").choices(["auto", "manual"]));
|
|
|
|
const optionLine = getCompletionScript("fish", program)
|
|
.split("\n")
|
|
.find((line) => line.includes(" -l mode "));
|
|
|
|
expect(optionLine).toContain(" -f -a ");
|
|
expect(optionLine).not.toContain(" -r ");
|
|
expect(optionLine).toContain("'auto' 'manual'");
|
|
});
|
|
|
|
it("scopes fish value-taking option skips to the active command path", () => {
|
|
const script = getCompletionScript("fish", createCompletionProgram());
|
|
|
|
expect(script).toContain("__openclaw_command_path_matches agent -- --verbose");
|
|
expect(script).toContain("__openclaw_command_path_matches sessions cleanup --");
|
|
expect(script).not.toContain("__openclaw_command_path_matches sessions cleanup -- --verbose");
|
|
expect(script).toContain(
|
|
"complete -c openclaw -n \"__openclaw_command_path_matches sessions cleanup --\" -l dry-run -d 'Preview cleanup'",
|
|
);
|
|
});
|
|
|
|
it("uses Commander's parsed flags instead of value placeholder syntax", () => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.option("--trigger-script <path|->", "Condition script file, or - for stdin")
|
|
.option("--ws, --workspace <name>", "Workspace");
|
|
|
|
const fishScript = getCompletionScript("fish", program);
|
|
|
|
expect(fishScript).toContain(
|
|
"complete -c openclaw -n \"__openclaw_command_path_matches -- --trigger-script --ws --workspace\" -l trigger-script -r -d 'Condition script file, or - for stdin'",
|
|
);
|
|
expect(fishScript).not.toContain(" -s > ");
|
|
expect(fishScript).toContain(" -l ws -l workspace -r -d 'Workspace'");
|
|
expect(getCompletionScript("bash", program)).not.toContain("--trigger-script ->");
|
|
expect(getCompletionScript("zsh", program)).not.toContain("{--trigger-script,->}");
|
|
});
|
|
|
|
it("generates Bash completions without comma-suffixed short flags", () => {
|
|
const script = getCompletionScript("bash", createCompletionProgram());
|
|
|
|
expect(script).toContain("--token");
|
|
expect(script).not.toContain("-t,");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"completes both root short flags and their long aliases in real Bash",
|
|
() => {
|
|
const completions = runGeneratedBashCompletion(createDocumentedCompletionProgram(), [
|
|
"openclaw",
|
|
"-",
|
|
]);
|
|
|
|
expect(completions).toEqual(expect.arrayContaining(["-v", "--verbose", "--status-json"]));
|
|
expect(completions.some((flag) => flag.endsWith(","))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"completes every documented completion short flag in real Bash",
|
|
() => {
|
|
const completions = runGeneratedBashCompletion(createDocumentedCompletionProgram(), [
|
|
"openclaw",
|
|
"completion",
|
|
"-",
|
|
]);
|
|
|
|
expect(completions).toEqual(
|
|
expect.arrayContaining([
|
|
"-s",
|
|
"--shell",
|
|
"-i",
|
|
"--install",
|
|
"-y",
|
|
"--yes",
|
|
"--write-state",
|
|
]),
|
|
);
|
|
expect(completions.some((flag) => flag.endsWith(","))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"completes both nested value-taking flag aliases in real Bash",
|
|
() => {
|
|
const completions = runGeneratedBashCompletion(createDocumentedCompletionProgram(), [
|
|
"openclaw",
|
|
"gateway",
|
|
"-",
|
|
]);
|
|
|
|
expect(completions).toEqual(expect.arrayContaining(["-t", "--token", "--force"]));
|
|
expect(completions.some((flag) => flag.endsWith(","))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"filters short and long aliases independently in real Bash",
|
|
() => {
|
|
const program = createDocumentedCompletionProgram();
|
|
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-s"])).toEqual(["-s"]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "--s"])).toEqual([
|
|
"--shell",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{
|
|
name: "a long shell flag",
|
|
words: ["openclaw", "completion", "--shell", "f"],
|
|
expected: ["fish"],
|
|
},
|
|
{
|
|
name: "a short shell flag",
|
|
words: ["openclaw", "completion", "-s", "f"],
|
|
expected: ["fish"],
|
|
},
|
|
{
|
|
name: "an inline long shell flag",
|
|
words: ["openclaw", "completion", "--shell=f"],
|
|
expected: ["--shell=fish"],
|
|
},
|
|
{
|
|
name: "an inline short shell flag",
|
|
words: ["openclaw", "completion", "-s=f"],
|
|
expected: ["-s=fish"],
|
|
},
|
|
])("completes validated values in real Bash after $name", ({ words, expected }) => {
|
|
expect(runGeneratedBashCompletion(createDocumentedCompletionProgram(), words)).toEqual(
|
|
expected,
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{
|
|
name: "an omitted optional value",
|
|
words: ["openclaw", "--mode", "--j"],
|
|
expected: ["--json"],
|
|
},
|
|
{
|
|
name: "a separate optional value",
|
|
words: ["openclaw", "--mode", "a"],
|
|
expected: ["auto"],
|
|
},
|
|
{
|
|
name: "an inline optional value",
|
|
words: ["openclaw", "--mode=a"],
|
|
expected: ["--mode=auto"],
|
|
},
|
|
{
|
|
name: "a hyphen-prefixed optional choice",
|
|
words: ["openclaw", "--mode", "-l"],
|
|
expected: ["-legacy"],
|
|
},
|
|
])("preserves real Bash completion after $name", ({ words, expected }) => {
|
|
expect(runGeneratedBashCompletion(createOptionalChoiceCompletionProgram(), words)).toEqual(
|
|
expected,
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{
|
|
name: "whitespace",
|
|
value: "two words",
|
|
prefix: "two ",
|
|
},
|
|
{
|
|
name: "double quotes",
|
|
value: 'say "hello"',
|
|
prefix: 'say "',
|
|
},
|
|
{
|
|
name: "apostrophes",
|
|
value: "it's literal",
|
|
prefix: "it's",
|
|
},
|
|
{
|
|
name: "literal command substitution",
|
|
value: "$(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
|
|
prefix: "$(",
|
|
},
|
|
{
|
|
name: "literal backtick substitution",
|
|
value: "`printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
|
|
prefix: "`",
|
|
},
|
|
])("keeps Bash choice $name literal without executing it", ({ value, prefix }) => {
|
|
const program = new Command().name("openclaw");
|
|
program.addOption(new Option("--value <value>", "Value").choices([value]));
|
|
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "--value", prefix])).toEqual([value]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", `--value=${prefix}`])).toEqual([
|
|
`--value=${value}`,
|
|
]);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{
|
|
name: "a root option",
|
|
words: ["openclaw", "--channel", "b"],
|
|
expected: ["beta"],
|
|
},
|
|
{
|
|
name: "an inherited parent option",
|
|
words: ["openclaw", "cron", "create", "--channel", "pre"],
|
|
expected: ["preview"],
|
|
},
|
|
{
|
|
name: "an inline inherited parent option",
|
|
words: ["openclaw", "cron", "create", "--channel=pre"],
|
|
expected: ["--channel=preview"],
|
|
},
|
|
{
|
|
name: "a differently prefixed inherited parent choice",
|
|
words: ["openclaw", "cron", "create", "--channel", "pro"],
|
|
expected: ["production"],
|
|
},
|
|
])("uses the nearest validated Bash choices for $name", ({ words, expected }) => {
|
|
const program = createAliasedCompletionProgram();
|
|
program.addOption(
|
|
new Option("--channel <channel>", "Update channel").choices(["stable", "beta"]),
|
|
);
|
|
const cron = program.commands.find((command) => command.name() === "cron");
|
|
if (!cron) {
|
|
throw new Error("Cron command is unavailable");
|
|
}
|
|
cron.addOption(
|
|
new Option("--channel <channel>", "Cron channel").choices(["production", "preview"]),
|
|
);
|
|
|
|
expect(runGeneratedBashCompletion(program, words)).toEqual(expected);
|
|
});
|
|
|
|
it("preserves documented short and long completion flags in Fish and Zsh", () => {
|
|
const program = createDocumentedCompletionProgram();
|
|
const fishScript = getCompletionScript("fish", program);
|
|
const zshScript = getCompletionScript("zsh", program);
|
|
|
|
expect(fishScript).toContain(" -s s -l shell ");
|
|
expect(fishScript).toContain(" -s i -l install ");
|
|
expect(fishScript).toContain(" -s y -l yes ");
|
|
expect(zshScript).toContain("{--shell,-s}");
|
|
expect(zshScript).toContain("{--install,-i}");
|
|
expect(zshScript).toContain("{--yes,-y}");
|
|
});
|
|
|
|
it("preserves required shell values and their Commander choices in Fish and Zsh", () => {
|
|
const program = createDocumentedCompletionProgram();
|
|
const fishScript = getCompletionScript("fish", program);
|
|
const zshScript = getCompletionScript("zsh", program);
|
|
|
|
expect(fishScript).toContain(" -s s -l shell -r -f -a ");
|
|
expect(fishScript).toContain(`"'zsh' 'bash' 'powershell' 'fish'"`);
|
|
expect(zshScript).toContain(
|
|
`{--shell,-s}"[Shell to generate completion for (default: zsh)]:shell:(zsh bash powershell fish)"`,
|
|
);
|
|
expect(zshScript).toContain('{--token,-t}"[Gateway token]:token:"');
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"completes Commander option choices instead of commands in real Bash",
|
|
() => {
|
|
const program = createDocumentedCompletionProgram();
|
|
|
|
expect(
|
|
runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell", ""]),
|
|
).toEqual(["zsh", "bash", "powershell", "fish"]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-s", "f"])).toEqual([
|
|
"fish",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell=f"])).toEqual([
|
|
"--shell=fish",
|
|
]);
|
|
expect(
|
|
runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell", "=", "f"]),
|
|
).toEqual(["fish"]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-sf"])).toEqual([
|
|
"-sfish",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ysf"])).toEqual([
|
|
"-ysfish",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ys", "f"])).toEqual([
|
|
"fish",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ys"])).toEqual([
|
|
"-yszsh",
|
|
"-ysbash",
|
|
"-yspowershell",
|
|
"-ysfish",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps optional choice values from consuming the following option in real Bash",
|
|
() => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("-c, --color [when]").choices(["always", "never"]))
|
|
.option("-v, --verbose", "Verbose output");
|
|
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "--color", "a"])).toEqual(["always"]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "--color", "--v"])).toEqual([
|
|
"--verbose",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "-ca"])).toEqual(["-calways"]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "-vca"])).toEqual(["-vcalways"]);
|
|
},
|
|
);
|
|
|
|
it("includes Commander option choices in the PowerShell argument completer", () => {
|
|
const script = getCompletionScript("powershell", createDocumentedCompletionProgram());
|
|
|
|
expect(script).toContain("switch ($candidatePath)");
|
|
expect(script).toContain("$choiceFlag -in @('-s','--shell')");
|
|
expect(script).toContain("$wordToComplete -match '^(--[^=]+)=(.*)$'");
|
|
expect(script).toContain("$wordToComplete -match '^-[^-].+$'");
|
|
expect(script).toContain("StartsWith($choicePrefix, [StringComparison]::OrdinalIgnoreCase)");
|
|
expect(script).toContain("@('zsh','bash','powershell','fish')");
|
|
});
|
|
|
|
it("omits empty PowerShell command-path switches for root-only programs", () => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("--theme <theme>").choices(["light", "dark"]));
|
|
|
|
expect(getCompletionScript("powershell", program)).not.toContain("switch ($candidatePath)");
|
|
});
|
|
|
|
it("quotes PowerShell choice completion text while preserving its display value", () => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("--theme <theme>").choices(["light blue", "Bob's green", "path`name"]));
|
|
|
|
const script = getCompletionScript("powershell", program);
|
|
|
|
expect(script).toContain('$completionText = "$choiceCompletionPrefix$choiceValue"');
|
|
expect(script).toContain('$_.Replace("\'", "\'\'")');
|
|
expect(script).toContain(
|
|
"[System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_)",
|
|
);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
["a separate option value", "openclaw completion --shell f", "fish"],
|
|
["an attached option value", "openclaw completion --shell=f", "--shell=fish"],
|
|
["an attached short option value", "openclaw completion -sf", "-sfish"],
|
|
["a short-option cluster value", "openclaw completion -ysf", "-ysfish"],
|
|
["a separated short-option cluster value", "openclaw completion -ys f", "fish"],
|
|
])("completes PowerShell Commander choices after %s", (_name, commandLine, expected) => {
|
|
expect(
|
|
runGeneratedPowerShellCompletion(createDocumentedCompletionProgram(), commandLine),
|
|
).toEqual([expected]);
|
|
});
|
|
|
|
itWithPowerShell("completes an empty attached short-option cluster value", () => {
|
|
expect(
|
|
runGeneratedPowerShellCompletion(
|
|
createDocumentedCompletionProgram(),
|
|
"openclaw completion -ys",
|
|
),
|
|
).toEqual(["-yszsh", "-ysbash", "-yspowershell", "-ysfish"]);
|
|
});
|
|
|
|
itWithPowerShell.each([
|
|
["a spaced choice", "openclaw --theme l", "'light blue'"],
|
|
["an attached spaced choice", "openclaw --theme=l", "--theme='light blue'"],
|
|
["an apostrophe", "openclaw --theme Bob", "'Bob''s green'"],
|
|
["a backtick", "openclaw --theme p", "'path`name'"],
|
|
])("quotes %s in real PowerShell completion text", (_name, commandLine, expected) => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("--theme <theme>").choices(["light blue", "Bob's green", "path`name"]));
|
|
|
|
expect(runGeneratedPowerShellCompletion(program, commandLine)).toEqual([expected]);
|
|
});
|
|
|
|
itWithPowerShell(
|
|
"keeps optional choice values from consuming the following PowerShell option",
|
|
() => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(new Option("-c, --color [when]").choices(["always", "never"]))
|
|
.option("-v, --verbose", "Verbose output");
|
|
|
|
expect(runGeneratedPowerShellCompletion(program, "openclaw --color a")).toEqual(["always"]);
|
|
expect(runGeneratedPowerShellCompletion(program, "openclaw --color --v")).toEqual([
|
|
"--verbose",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves spaces and apostrophes inside individual Commander choices",
|
|
() => {
|
|
const program = new Command()
|
|
.name("openclaw")
|
|
.addOption(
|
|
new Option("--theme <theme>", "Color theme").choices([
|
|
"light blue",
|
|
"dark",
|
|
"Bob's green",
|
|
]),
|
|
);
|
|
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "--theme", "l"])).toEqual([
|
|
"light blue",
|
|
]);
|
|
expect(runGeneratedBashCompletion(program, ["openclaw", "--theme", "Bob"])).toEqual([
|
|
"Bob's green",
|
|
]);
|
|
expect(getCompletionScript("fish", program)).toContain(`"'light blue' 'dark'`);
|
|
expect(getCompletionScript("zsh", program)).toContain(":theme:(light");
|
|
},
|
|
);
|
|
|
|
it("generates valid Bash completion without subcommands", () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const script = getCompletionScript("bash", new Command().name("openclaw"));
|
|
const result = spawnSync("bash", ["--noprofile", "--norc", "-n"], {
|
|
encoding: "utf8",
|
|
input: script,
|
|
});
|
|
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
});
|
|
});
|