mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 22:54:01 -06:00
6b40ad6a66
* fix(cli): honor shell-owned completion profile locations * test(cli): isolate completion profiles across platforms * fix(wizard): reuse canonical completion profile hints --------- Co-authored-by: Peter Steinberger <steipete@macos.shared>
677 lines
27 KiB
TypeScript
677 lines
27 KiB
TypeScript
// Completion runtime tests cover shell completion generation and runtime file writes.
|
|
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
|
import {
|
|
COMPLETION_SHELLS,
|
|
formatCompletionReloadCommand,
|
|
installCompletion,
|
|
isCompletionInstalled,
|
|
resolveCompletionCachePath,
|
|
resolveCompletionProfileHint,
|
|
resolveCompletionProfilePath,
|
|
resolveShellFromEnv,
|
|
usesSlowDynamicCompletion,
|
|
} from "./completion-runtime.js";
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
async function withBashCompletionHome(
|
|
run: (paths: { homeDir: string; stateDir: string }) => Promise<void>,
|
|
stateDirPrefix = "openclaw-bash-completion-state-",
|
|
): Promise<void> {
|
|
const homeDir = tempDirs.make("openclaw-bash-completion-home-");
|
|
const stateDir = tempDirs.make(stateDirPrefix);
|
|
|
|
await withEnvAsync(
|
|
{
|
|
HOME: homeDir,
|
|
USERPROFILE: homeDir,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
XDG_CONFIG_HOME: undefined,
|
|
ZDOTDIR: undefined,
|
|
},
|
|
async () => {
|
|
await run({ homeDir, stateDir });
|
|
},
|
|
);
|
|
}
|
|
|
|
function expectAvailableShellToSucceed(
|
|
shellName: "bash" | "zsh",
|
|
result: SpawnSyncReturns<string>,
|
|
): void {
|
|
if (result.error) {
|
|
if (
|
|
shellName === "zsh" &&
|
|
"code" in result.error &&
|
|
(result.error.code === "ENOENT" || result.error.code === "EACCES")
|
|
) {
|
|
return;
|
|
}
|
|
throw result.error;
|
|
}
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
}
|
|
|
|
describe("completion-runtime", () => {
|
|
it.each([
|
|
{
|
|
shell: "zsh" as const,
|
|
variable: "ZDOTDIR",
|
|
profileName: ".zshrc",
|
|
},
|
|
{
|
|
shell: "fish" as const,
|
|
variable: "XDG_CONFIG_HOME",
|
|
profileName: path.join("fish", "config.fish"),
|
|
},
|
|
])("installs $shell completion into its configured shell startup directory", async (testCase) => {
|
|
const homeDir = tempDirs.make("openclaw-shell-profile-home-");
|
|
const stateDir = tempDirs.make("openclaw-shell-profile-state-");
|
|
const configDir = tempDirs.make(`openclaw-${testCase.shell} profile config-`);
|
|
|
|
await withEnvAsync(
|
|
{
|
|
HOME: homeDir,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
ZDOTDIR: testCase.variable === "ZDOTDIR" ? configDir : undefined,
|
|
XDG_CONFIG_HOME: testCase.variable === "XDG_CONFIG_HOME" ? configDir : undefined,
|
|
},
|
|
async () => {
|
|
const cachePath = resolveCompletionCachePath(testCase.shell, "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "OPENCLAW_COMPLETION_LOADED=ready\n", "utf-8");
|
|
|
|
await installCompletion(testCase.shell, true, "openclaw");
|
|
|
|
const profilePath = path.join(configDir, testCase.profileName);
|
|
expect(resolveCompletionProfilePath(testCase.shell)).toBe(profilePath);
|
|
await expect(fs.readFile(profilePath, "utf-8")).resolves.toContain(cachePath);
|
|
await expect(isCompletionInstalled(testCase.shell, "openclaw")).resolves.toBe(true);
|
|
|
|
if (testCase.shell === "zsh") {
|
|
const shell = spawnSync("zsh", ["-ic", '[[ "$OPENCLAW_COMPLETION_LOADED" = ready ]]'], {
|
|
encoding: "utf8",
|
|
env: process.env,
|
|
});
|
|
expectAvailableShellToSucceed("zsh", shell);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("preserves zsh's set-but-empty startup directory contract", () => {
|
|
expect(
|
|
resolveCompletionProfilePath("zsh", {
|
|
env: { HOME: "/tmp/openclaw-home", ZDOTDIR: "" },
|
|
homeDir: () => "/tmp/openclaw-home",
|
|
}),
|
|
).toBe(path.join(path.sep, ".zshrc"));
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves Zsh and Fish symlink traversal before parent path components",
|
|
async () => {
|
|
for (const testCase of [
|
|
{ shell: "zsh" as const, variable: "ZDOTDIR", profileName: ".zshrc" },
|
|
{
|
|
shell: "fish" as const,
|
|
variable: "XDG_CONFIG_HOME",
|
|
profileName: path.join("fish", "config.fish"),
|
|
},
|
|
]) {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const profileParent = tempDirs.make(`openclaw-${testCase.shell}-symlink-profiles-`);
|
|
const nestedProfiles = path.join(profileParent, "nested");
|
|
const linkedProfiles = path.join(homeDir, "linked-profiles");
|
|
await fs.mkdir(nestedProfiles, { recursive: true });
|
|
await fs.symlink(nestedProfiles, linkedProfiles, "dir");
|
|
|
|
await withEnvAsync(
|
|
{ [testCase.variable]: `${linkedProfiles}${path.sep}..` },
|
|
async () => {
|
|
const cachePath = resolveCompletionCachePath(testCase.shell, "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "OPENCLAW_COMPLETION_LOADED=ready\n", "utf8");
|
|
|
|
await installCompletion(testCase.shell, true, "openclaw");
|
|
|
|
const profilePath = path.join(profileParent, testCase.profileName);
|
|
await expect(fs.readFile(profilePath, "utf8")).resolves.toContain(cachePath);
|
|
expect(resolveCompletionProfileHint(testCase.shell)).toBe(
|
|
`~${path.sep}linked-profiles${path.sep}..${path.sep}${testCase.profileName}`,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves every shell's default profile traversal through a symlinked HOME",
|
|
async () => {
|
|
const fixtureRoot = tempDirs.make("openclaw-completion-home-symlink-");
|
|
const realHome = path.join(fixtureRoot, "real-home");
|
|
const nestedHome = path.join(realHome, "nested");
|
|
const linkedHome = path.join(fixtureRoot, "linked-home");
|
|
await fs.mkdir(nestedHome, { recursive: true });
|
|
await fs.symlink(nestedHome, linkedHome, "dir");
|
|
|
|
for (const testCase of [
|
|
{ shell: "zsh" as const, profileName: ".zshrc" },
|
|
{ shell: "bash" as const, profileName: ".bash_profile" },
|
|
{ shell: "fish" as const, profileName: path.join(".config", "fish", "config.fish") },
|
|
{
|
|
shell: "powershell" as const,
|
|
profileName: path.join(".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
},
|
|
]) {
|
|
const stateDir = tempDirs.make(`openclaw-${testCase.shell}-home-symlink-state-`);
|
|
await withEnvAsync(
|
|
{
|
|
HOME: `${linkedHome}${path.sep}..`,
|
|
USERPROFILE: `${linkedHome}${path.sep}..`,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
XDG_CONFIG_HOME: undefined,
|
|
ZDOTDIR: undefined,
|
|
},
|
|
async () => {
|
|
const cachePath = resolveCompletionCachePath(testCase.shell, "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, `# cached ${testCase.shell} completion\n`, "utf8");
|
|
|
|
await installCompletion(testCase.shell, true, "openclaw");
|
|
|
|
const actualStartupProfile = path.join(realHome, testCase.profileName);
|
|
await expect(fs.readFile(actualStartupProfile, "utf8")).resolves.toContain(cachePath);
|
|
expect(resolveCompletionProfileHint(testCase.shell)).toBe(
|
|
testCase.shell === "powershell"
|
|
? `${linkedHome}${path.sep}..${path.sep}${testCase.profileName}`
|
|
: `~/${testCase.profileName}`,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["relative-xdg", "~/relative-xdg"])(
|
|
"ignores invalid relative Fish XDG configuration roots: %s",
|
|
(configHome) => {
|
|
const homeDir = path.join(path.sep, "tmp", "openclaw-home");
|
|
expect(
|
|
resolveCompletionProfilePath("fish", {
|
|
env: { HOME: homeDir, XDG_CONFIG_HOME: configHome },
|
|
homeDir: () => homeDir,
|
|
}),
|
|
).toBe(path.join(homeDir, ".config", "fish", "config.fish"));
|
|
},
|
|
);
|
|
|
|
it("preserves significant trailing whitespace in an absolute Fish XDG directory", () => {
|
|
const homeDir = path.join(path.sep, "tmp", "openclaw-home");
|
|
const configHome = path.join(path.sep, "tmp", "Fish Config ");
|
|
expect(
|
|
resolveCompletionProfilePath("fish", {
|
|
env: { HOME: homeDir, XDG_CONFIG_HOME: configHome },
|
|
homeDir: () => homeDir,
|
|
}),
|
|
).toBe(path.join(configHome, "fish", "config.fish"));
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
configHome: "C:\\Users\\Ada\\Fish Config",
|
|
expected: "C:\\Users\\Ada\\Fish Config\\fish\\config.fish",
|
|
},
|
|
{
|
|
configHome: "relative-fish",
|
|
expected: "C:\\Users\\Ada\\.config\\fish\\config.fish",
|
|
},
|
|
{
|
|
configHome: "\\\\fileserver\\profiles\\Fish Config",
|
|
expected: "\\\\fileserver\\profiles\\Fish Config\\fish\\config.fish",
|
|
},
|
|
])("resolves Windows Fish profiles with Windows separators: $configHome", (testCase) => {
|
|
const homeDir = "C:\\Users\\Ada";
|
|
expect(
|
|
resolveCompletionProfilePath("fish", {
|
|
env: { HOME: homeDir, XDG_CONFIG_HOME: testCase.configHome },
|
|
homeDir: () => homeDir,
|
|
platform: "win32",
|
|
}),
|
|
).toBe(testCase.expected);
|
|
});
|
|
|
|
it.each(["~/literal startup", "~root/startup", "-startup", "../startup"])(
|
|
"keeps relative Zsh profile hints literal: %s",
|
|
async (profileRoot) => {
|
|
await withEnvAsync({ HOME: "/tmp/openclaw-home", ZDOTDIR: profileRoot }, async () => {
|
|
const profilePath = path.join(profileRoot, ".zshrc");
|
|
const expected = profilePath.startsWith(`..${path.sep}`)
|
|
? profilePath
|
|
: `.${path.sep}${profilePath}`;
|
|
expect(resolveCompletionProfileHint("zsh")).toBe(expected);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("resolves the documented Bash login profile when .bashrc is absent", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
expect(resolveCompletionProfilePath("bash")).toBe(path.join(homeDir, ".bash_profile"));
|
|
});
|
|
});
|
|
|
|
it("recognizes cached Bash completion installed into the login profile", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
await expect(fs.readFile(profilePath, "utf-8")).resolves.toContain(cachePath);
|
|
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
|
|
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(false);
|
|
|
|
const shell = spawnSync(
|
|
"bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
'source "$1"; complete -p openclaw',
|
|
"openclaw",
|
|
profilePath,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(shell.stderr).toBe("");
|
|
expect(shell.status).toBe(0);
|
|
expect(shell.stdout).toContain("complete -W 'status' openclaw");
|
|
});
|
|
});
|
|
|
|
it("prints the same canonical reload hint used by Doctor and onboarding", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("zsh", "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "OPENCLAW_COMPLETION_LOADED=ready\n", "utf8");
|
|
await fs.writeFile(path.join(homeDir, ".zshrc"), "", "utf8");
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
try {
|
|
await installCompletion("zsh", false, "openclaw");
|
|
expect(log).toHaveBeenCalledWith(
|
|
"Completion installed. Restart your shell or run: source ~/.zshrc",
|
|
);
|
|
} finally {
|
|
log.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("detects slow dynamic Bash completion in the login profile", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
await fs.writeFile(
|
|
path.join(homeDir, ".bash_profile"),
|
|
"source <(openclaw completion --shell bash)\n",
|
|
"utf-8",
|
|
);
|
|
|
|
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
|
|
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(true);
|
|
});
|
|
});
|
|
|
|
it("does not mistake an orphaned completion marker for an installed profile", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
await fs.writeFile(
|
|
path.join(homeDir, ".bash_profile"),
|
|
"# OpenClaw Completion\nexport IMPORTANT=keep\n",
|
|
"utf-8",
|
|
);
|
|
|
|
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
it("recognizes an installed profile when its completion cache has been removed", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
await fs.writeFile(
|
|
path.join(homeDir, ".bash_profile"),
|
|
`# OpenClaw Completion\n[ -f "${cachePath}" ] && source "${cachePath}"\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
|
|
});
|
|
});
|
|
|
|
it.each(COMPLETION_SHELLS)(
|
|
"replaces the old generated %s source after the state directory changes",
|
|
async (shell) => {
|
|
await withBashCompletionHome(async ({ stateDir }) => {
|
|
const previousStateDir = tempDirs.make("openclaw-completion-previous-state-");
|
|
let previousCachePath = "";
|
|
await withEnvAsync({ OPENCLAW_STATE_DIR: previousStateDir }, async () => {
|
|
previousCachePath = resolveCompletionCachePath(shell, "openclaw");
|
|
await fs.mkdir(path.dirname(previousCachePath), { recursive: true });
|
|
await fs.writeFile(previousCachePath, "# previous completion\n", "utf-8");
|
|
await installCompletion(shell, true, "openclaw");
|
|
});
|
|
|
|
const currentCachePath = resolveCompletionCachePath(shell, "openclaw");
|
|
expect(currentCachePath).toContain(stateDir);
|
|
await fs.mkdir(path.dirname(currentCachePath), { recursive: true });
|
|
await fs.writeFile(currentCachePath, "# current completion\n", "utf-8");
|
|
await installCompletion(shell, true, "openclaw");
|
|
|
|
const profile = await fs.readFile(resolveCompletionProfilePath(shell), "utf-8");
|
|
expect(profile).toContain(currentCachePath);
|
|
expect(profile).not.toContain(previousCachePath);
|
|
expect(profile.match(/^# OpenClaw Completion$/gm)).toHaveLength(1);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves unrelated generated-looking sources that are not owned by its profile marker", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
const unrelatedSource = 'source "/opt/tools/completions/openclaw.zsh"';
|
|
const unmarkedPriorSource = 'source "/opt/tools/completions/openclaw.bash"';
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "# current completion\n", "utf-8");
|
|
await fs.writeFile(profilePath, `${unrelatedSource}\n${unmarkedPriorSource}\n`, "utf-8");
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toContain(`${unrelatedSource}\n`);
|
|
expect(profile).toContain(`${unmarkedPriorSource}\n`);
|
|
expect(profile).toContain(cachePath);
|
|
});
|
|
});
|
|
|
|
it("prefers an existing .bashrc over the Bash login profile", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const bashrc = path.join(homeDir, ".bashrc");
|
|
const bashProfile = path.join(homeDir, ".bash_profile");
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
await fs.writeFile(bashrc, "# existing interactive Bash profile\n", "utf-8");
|
|
await fs.writeFile(bashProfile, "# existing Bash login profile\n", "utf-8");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
|
|
expect(resolveCompletionProfilePath("bash")).toBe(bashrc);
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
await expect(fs.readFile(bashrc, "utf-8")).resolves.toContain(cachePath);
|
|
await expect(fs.readFile(bashProfile, "utf-8")).resolves.toBe(
|
|
"# existing Bash login profile\n",
|
|
);
|
|
await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true);
|
|
await expect(usesSlowDynamicCompletion("bash", "openclaw")).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
it("preserves unrelated profile lines while replacing orphaned completion blocks", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'";
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
await fs.writeFile(
|
|
profilePath,
|
|
`# OpenClaw Completion\nexport IMPORTANT=keep\n${refreshAlias}\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toContain("export IMPORTANT=keep\n");
|
|
expect(profile).toContain(`${refreshAlias}\n`);
|
|
expect(profile.match(/^# OpenClaw Completion$/gm)).toHaveLength(1);
|
|
expect(profile).toContain(cachePath);
|
|
});
|
|
});
|
|
|
|
it("replaces documented dynamic completion without deleting unrelated aliases", async () => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'";
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
await fs.writeFile(
|
|
profilePath,
|
|
`export IMPORTANT=keep\nsource <(openclaw completion --shell bash)\n${refreshAlias}\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toContain("export IMPORTANT=keep\n");
|
|
expect(profile).toContain(`${refreshAlias}\n`);
|
|
expect(profile).not.toContain("source <(openclaw completion");
|
|
expect(profile).toContain(cachePath);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"export IMPORTANT=keep; source <(openclaw completion --shell bash)",
|
|
"source <(openclaw completion --shell bash); export IMPORTANT=keep",
|
|
'source <(openclaw completion --shell bash) >"$HOME/completion.log"',
|
|
'eval "$(openclaw completion --shell bash)" >"$HOME/completion.log"',
|
|
'source <(openclaw completion --shell bash >"$HOME/completion.log")',
|
|
])("preserves compound user-owned Bash profile statements: %s", async (compoundLine) => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
await fs.writeFile(profilePath, `${compoundLine}\n`, "utf-8");
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toContain(`${compoundLine}\n`);
|
|
expect(profile).toContain(`[ -f "${cachePath}" ] && source "${cachePath}"`);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "dot-sourced process substitution",
|
|
sourceLine: ". <(openclaw completion --shell bash)",
|
|
},
|
|
{
|
|
name: "eval command substitution",
|
|
sourceLine: 'eval "$(openclaw completion --shell bash)"',
|
|
},
|
|
])("replaces $name without deleting unrelated aliases", async ({ sourceLine }) => {
|
|
await withBashCompletionHome(async ({ homeDir }) => {
|
|
const cachePath = resolveCompletionCachePath("bash", "openclaw");
|
|
const profilePath = path.join(homeDir, ".bash_profile");
|
|
const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'";
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8");
|
|
await fs.writeFile(profilePath, `${sourceLine}\n${refreshAlias}\n`, "utf-8");
|
|
|
|
await installCompletion("bash", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).not.toContain(sourceLine);
|
|
expect(profile).toContain(`${refreshAlias}\n`);
|
|
expect(profile).toContain(cachePath);
|
|
});
|
|
});
|
|
|
|
it("replaces PowerShell dynamic pipelines without deleting unrelated command strings", async () => {
|
|
await withBashCompletionHome(async () => {
|
|
const cachePath = resolveCompletionCachePath("powershell", "openclaw");
|
|
const profilePath = resolveCompletionProfilePath("powershell");
|
|
const dynamicLine = "openclaw completion --shell powershell | Out-String | Invoke-Expression";
|
|
const refreshCommand = '$refresh = "openclaw completion --write-state"';
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "# PowerShell completion\n", "utf-8");
|
|
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
|
await fs.writeFile(profilePath, `${dynamicLine}\n${refreshCommand}\n`, "utf-8");
|
|
|
|
await expect(usesSlowDynamicCompletion("powershell", "openclaw")).resolves.toBe(true);
|
|
await installCompletion("powershell", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).not.toContain(dynamicLine);
|
|
expect(profile).toContain(`${refreshCommand}\n`);
|
|
expect(profile).toContain(cachePath);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"openclaw completion --shell powershell | Out-String | Invoke-Expression; $env:IMPORTANT = 'keep'",
|
|
'openclaw completion --shell powershell | Tee-Object "$HOME/generated.ps1" | Out-String | Invoke-Expression',
|
|
])("preserves compound user-owned PowerShell profile statements: %s", async (compoundLine) => {
|
|
await withBashCompletionHome(async () => {
|
|
const cachePath = resolveCompletionCachePath("powershell", "openclaw");
|
|
const profilePath = resolveCompletionProfilePath("powershell");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "# PowerShell completion\n", "utf-8");
|
|
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
|
await fs.writeFile(profilePath, `${compoundLine}\n`, "utf-8");
|
|
|
|
await installCompletion("powershell", true, "openclaw");
|
|
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toContain(`${compoundLine}\n`);
|
|
expect(profile).toContain(`. '${cachePath}'`);
|
|
});
|
|
});
|
|
|
|
it("formats PowerShell reload commands with single-quoted paths", () => {
|
|
expect(formatCompletionReloadCommand("powershell", "C:\\Users\\Ada\\profile.ps1")).toBe(
|
|
". 'C:\\Users\\Ada\\profile.ps1'",
|
|
);
|
|
});
|
|
|
|
it.each(["bash", "zsh"] as const)(
|
|
"reloads a %s profile when its absolute path contains spaces",
|
|
async (shellName) => {
|
|
const profileDir = tempDirs.make(`openclaw ${shellName} Ada's !42 reload profile-`);
|
|
const profilePath = path.join(profileDir, ".shellrc");
|
|
await fs.writeFile(profilePath, "OPENCLAW_COMPLETION_LOADED=ready\n", "utf-8");
|
|
|
|
const reloadCommand = formatCompletionReloadCommand(shellName, profilePath);
|
|
expect(reloadCommand).toBe(`source '${profilePath.replaceAll("'", "'\\''")}'`);
|
|
const shell = spawnSync(
|
|
shellName,
|
|
["-c", `${reloadCommand}; [ "$OPENCLAW_COMPLETION_LOADED" = ready ]`],
|
|
{
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
expectAvailableShellToSucceed(shellName, shell);
|
|
},
|
|
);
|
|
|
|
it("quotes Fish reload profile paths containing spaces", () => {
|
|
expect(formatCompletionReloadCommand("fish", "/tmp/Ada's !42 Lovelace/config.fish")).toBe(
|
|
"source '/tmp/Ada\\'s !42 Lovelace/config.fish'",
|
|
);
|
|
});
|
|
|
|
it.each(["bash", "zsh"] as const)(
|
|
"preserves tilde expansion while quoting %s profile paths",
|
|
(shellName) => {
|
|
expect(formatCompletionReloadCommand(shellName, "~/Ada's !42 profile/.shellrc")).toBe(
|
|
"source ~/'Ada'\\''s !42 profile/.shellrc'",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("detects PowerShell shell names from Windows paths", () => {
|
|
expect(resolveShellFromEnv({ SHELL: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" })).toBe(
|
|
"powershell",
|
|
);
|
|
expect(
|
|
resolveShellFromEnv({
|
|
SHELL: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
|
}),
|
|
).toBe("powershell");
|
|
});
|
|
|
|
it("resolves Windows PowerShell and pwsh profile directories", () => {
|
|
expect(
|
|
resolveCompletionProfilePath("powershell", {
|
|
env: {
|
|
SHELL: "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
|
USERPROFILE: "C:\\Users\\Ada",
|
|
},
|
|
homeDir: () => "C:\\Users\\Ada",
|
|
platform: "win32",
|
|
}),
|
|
).toBe(
|
|
path.win32.join(
|
|
"C:\\Users\\Ada",
|
|
"Documents",
|
|
"PowerShell",
|
|
"Microsoft.PowerShell_profile.ps1",
|
|
),
|
|
);
|
|
expect(
|
|
resolveCompletionProfilePath("powershell", {
|
|
env: {
|
|
SHELL: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
|
USERPROFILE: "C:\\Users\\Ada",
|
|
},
|
|
homeDir: () => "C:\\Users\\Ada",
|
|
platform: "win32",
|
|
}),
|
|
).toBe(
|
|
path.win32.join(
|
|
"C:\\Users\\Ada",
|
|
"Documents",
|
|
"WindowsPowerShell",
|
|
"Microsoft.PowerShell_profile.ps1",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("installs PowerShell completion into the concrete profile path", async () => {
|
|
await withBashCompletionHome(async () => {
|
|
const cachePath = resolveCompletionCachePath("powershell", "openclaw");
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, "# powershell completion\n", "utf-8");
|
|
|
|
await installCompletion("powershell", true, "openclaw");
|
|
|
|
const profilePath = resolveCompletionProfilePath("powershell");
|
|
const profile = await fs.readFile(profilePath, "utf-8");
|
|
expect(profile).toBe(`# OpenClaw Completion\n. '${cachePath.replace(/'/g, "''")}'\n`);
|
|
}, "openclaw-completion-state-bob's-");
|
|
});
|
|
|
|
it("rejects install when the completion cache is missing", async () => {
|
|
await withBashCompletionHome(async () => {
|
|
await expect(installCompletion("zsh", true, "openclaw")).rejects.toThrow(
|
|
"Completion cache not found",
|
|
);
|
|
});
|
|
});
|
|
});
|