mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 14:01:33 +00:00
fix: recognize Bash completion installed in login profiles (#113790)
* fix(cli): recognize Bash completion in login profiles * test: track Bash completion profile fixtures --------- Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
committed by
GitHub
parent
fca18dddc7
commit
a1cc41acbc
@@ -1,18 +1,107 @@
|
||||
// Completion runtime tests cover shell completion generation and runtime file writes.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import {
|
||||
formatCompletionReloadCommand,
|
||||
installCompletion,
|
||||
isCompletionInstalled,
|
||||
resolveCompletionCachePath,
|
||||
resolveCompletionProfilePath,
|
||||
resolveShellFromEnv,
|
||||
usesSlowDynamicCompletion,
|
||||
} from "./completion-runtime.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
async function withBashCompletionHome(
|
||||
run: (paths: { homeDir: string; stateDir: string }) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const homeDir = tempDirs.make("openclaw-bash-completion-home-");
|
||||
const stateDir = tempDirs.make("openclaw-bash-completion-state-");
|
||||
|
||||
await withEnvAsync({ HOME: homeDir, OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await run({ homeDir, stateDir });
|
||||
});
|
||||
}
|
||||
|
||||
describe("completion-runtime", () => {
|
||||
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("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("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("formats PowerShell reload commands with single-quoted paths", () => {
|
||||
expect(formatCompletionReloadCommand("powershell", "C:\\Users\\Ada\\profile.ps1")).toBe(
|
||||
". 'C:\\Users\\Ada\\profile.ps1'",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Shell completion runtime: cache paths, profile installation, and shell detection.
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -170,7 +171,9 @@ export function resolveCompletionProfilePath(
|
||||
return path.join(home, ".zshrc");
|
||||
}
|
||||
if (shell === "bash") {
|
||||
return path.join(home, ".bashrc");
|
||||
// Installation, status, and repairs must inspect the same real Bash profile.
|
||||
const bashrc = path.join(home, ".bashrc");
|
||||
return existsSync(bashrc) ? bashrc : path.join(home, ".bash_profile");
|
||||
}
|
||||
if (shell === "fish") {
|
||||
return path.join(home, ".config", "fish", "config.fish");
|
||||
@@ -257,12 +260,6 @@ export async function installCompletion(shell: string, yes: boolean, binName = "
|
||||
break;
|
||||
case "bash":
|
||||
profilePath = resolveCompletionProfilePath("bash");
|
||||
try {
|
||||
await fs.access(profilePath);
|
||||
} catch {
|
||||
const home = process.env.HOME || os.homedir();
|
||||
profilePath = path.join(home, ".bash_profile");
|
||||
}
|
||||
sourceLine = formatCompletionSourceLine("bash", cachePath);
|
||||
break;
|
||||
case "fish":
|
||||
|
||||
@@ -40,6 +40,53 @@ function status(overrides: Partial<ShellCompletionStatus> = {}): ShellCompletion
|
||||
}
|
||||
|
||||
describe("shell completion health mapping", () => {
|
||||
it("recognizes cached Bash completion from the documented login profile", async () => {
|
||||
const homeDir = tempDirs.make("openclaw-bash-profile-home-");
|
||||
const stateDir = tempDirs.make("openclaw-bash-profile-state-");
|
||||
setTestEnvValue("HOME", homeDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
setTestEnvValue("SHELL", "/bin/bash");
|
||||
|
||||
const cachePath = path.join(stateDir, "completions", "openclaw.bash");
|
||||
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\n[ -f "${cachePath}" ] && source "${cachePath}"\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(checkShellCompletionStatus("openclaw", { shell: "bash" })).resolves.toEqual({
|
||||
shell: "bash",
|
||||
profileInstalled: true,
|
||||
cacheExists: true,
|
||||
cachePath,
|
||||
usesSlowPattern: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports slow dynamic Bash completion from the documented login profile", async () => {
|
||||
const homeDir = tempDirs.make("openclaw-bash-slow-profile-home-");
|
||||
const stateDir = tempDirs.make("openclaw-bash-slow-profile-state-");
|
||||
setTestEnvValue("HOME", homeDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
setTestEnvValue("SHELL", "/bin/bash");
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(homeDir, ".bash_profile"),
|
||||
"source <(openclaw completion --shell bash)\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(checkShellCompletionStatus("openclaw", { shell: "bash" })).resolves.toEqual({
|
||||
shell: "bash",
|
||||
profileInstalled: true,
|
||||
cacheExists: false,
|
||||
cachePath: path.join(stateDir, "completions", "openclaw.bash"),
|
||||
usesSlowPattern: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("checks an explicit shell instead of the detected environment shell", async () => {
|
||||
const homeDir = tempDirs.make("openclaw-completion-home-");
|
||||
const stateDir = tempDirs.make("openclaw-completion-state-");
|
||||
@@ -174,6 +221,20 @@ describe("doctorShellCompletion", () => {
|
||||
spawnSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("shows the Bash login profile after installing completion without .bashrc", async () => {
|
||||
await setupDoctorCompletionTest(false);
|
||||
installCompletionMock.mockResolvedValue(undefined);
|
||||
const noteSpy = vi.spyOn(noteModule, "note");
|
||||
|
||||
await doctorShellCompletion({} as never, mockPrompter());
|
||||
|
||||
expect(installCompletionMock).toHaveBeenCalledWith("bash", true, "openclaw");
|
||||
expect(noteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("source ~/.bash_profile"),
|
||||
"Shell completion",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ generationMode: "core-only" as const, expectedSkipValue: "1" },
|
||||
{ generationMode: "full" as const, expectedSkipValue: undefined },
|
||||
|
||||
@@ -44,7 +44,10 @@ function resolveCompletionReloadPath(shell: CompletionShell): string {
|
||||
if (shell === "powershell") {
|
||||
return resolveCompletionProfilePath("powershell");
|
||||
}
|
||||
return `~/.${shell === "zsh" ? "zshrc" : shell === "bash" ? "bashrc" : "config/fish/config.fish"}`;
|
||||
if (shell === "bash") {
|
||||
return `~/${path.basename(resolveCompletionProfilePath("bash"))}`;
|
||||
}
|
||||
return `~/.${shell === "zsh" ? "zshrc" : "config/fish/config.fish"}`;
|
||||
}
|
||||
|
||||
function formatCompletionReloadNote(
|
||||
|
||||
Reference in New Issue
Block a user