import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; import { fileURLToPath } from "node:url"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); async function executable(path: string, content: string): Promise { await writeFile(path, `#!/bin/sh\n${content}\n`, "utf8"); await chmod(path, 0o755); } async function fixture(initialHippoVersion?: string) { const root = await mkdtemp(join(tmpdir(), "my-pi-hippo-scripts-")); const home = join(root, "home"); const bin = join(root, "bin"); const project = join(root, "project"); const log = join(root, "commands.log"); const versionState = join(root, "hippo-version"); await mkdir(home, { recursive: true }); await mkdir(bin, { recursive: true }); await mkdir(project, { recursive: true }); await writeFile(log, "", "utf8"); if (initialHippoVersion) await writeFile(versionState, `${initialHippoVersion}\n`, "utf8"); await executable(join(bin, "pi"), 'printf "pi %s\\n" "$*" >> "$MOCK_LOG"'); await executable( join(bin, "npm"), String.raw`printf 'npm %s\n' "$*" >> "$MOCK_LOG" if [ "$1" = "view" ]; then printf '%s\n' '1.33.0' elif [ "$1" = "install" ] && [ "$2" = "-g" ]; then printf '%s\n' '1.33.0' > "$HIPPO_VERSION_STATE" cat > "$MOCK_BIN/hippo" <<'HIPPO' #!/bin/sh if [ "$1" = "--version" ]; then cat "$HIPPO_VERSION_STATE"; exit 0; fi if [ "$1" = "init" ]; then mkdir -p .hippo; printf 'hippo init\n' >> "$MOCK_LOG"; exit 0; fi exit 0 HIPPO chmod +x "$MOCK_BIN/hippo" fi`, ); if (initialHippoVersion) { await executable( join(bin, "hippo"), String.raw`if [ "$1" = "--version" ]; then cat "$HIPPO_VERSION_STATE"; exit 0; fi if [ "$1" = "init" ]; then mkdir -p .hippo; printf 'hippo init\n' >> "$MOCK_LOG"; exit 0; fi exit 0`, ); } const env = { ...process.env, HOME: home, PATH: `${bin}:/usr/bin:/bin`, MOCK_BIN: bin, MOCK_LOG: log, HIPPO_VERSION_STATE: versionState, PI_PACKAGE_SOURCE: repositoryRoot, ZSH: "", ZSH_CUSTOM: "", ZDOTDIR: home, }; return { root, home, bin, project, log, versionState, env }; } async function logText(path: string): Promise { return readFile(path, "utf8"); } test("install.sh leaves Hippo absent when installation is declined", async () => { const f = await fixture(); const result = spawnSync("sh", [join(repositoryRoot, "install.sh")], { cwd: f.project, env: f.env, input: "n\n".repeat(14), encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.doesNotMatch(await logText(f.log), /npm install -g hippo-memory/); assert.match(result.stdout, /已跳过 Hippo Memory CLI/); }); test("install.sh installs the pinned Hippo CLI without choosing an init directory", async () => { const f = await fixture(); const input = `y\n${"n\n".repeat(12)}`; const result = spawnSync("sh", [join(repositoryRoot, "install.sh")], { cwd: f.project, env: f.env, input, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 0, result.stderr || result.stdout); const log = await logText(f.log); assert.match(log, /npm install -g hippo-memory@1\.33\.0/); assert.doesNotMatch(log, /hippo init/); assert.equal(await readFile(f.versionState, "utf8"), "1.33.0\n"); assert.match(result.stdout, /未自动运行 hippo init/); }); test("update.sh leaves an already matching Hippo CLI untouched", async () => { const f = await fixture("1.33.0"); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: f.project, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 0, result.stderr || result.stdout); const log = await logText(f.log); assert.match(log, /npm view hippo-memory@1\.33\.0 version/); assert.doesNotMatch(log, /npm install -g/); assert.match(result.stdout, /已是组合包目标版本/); }); test("update.sh upgrades an npm-managed older Hippo CLI exactly once", async () => { const f = await fixture("1.32.0"); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: f.project, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 0, result.stderr || result.stdout); const log = await logText(f.log); assert.match(log, /npm list -g --depth=0 hippo-memory/); assert.equal((log.match(/npm install -g hippo-memory@1\.33\.0/g) ?? []).length, 1); assert.equal(await readFile(f.versionState, "utf8"), "1.33.0\n"); }); test("update.sh preserves a Hippo executable that is not npm globally managed", async () => { const f = await fixture("1.32.0"); await executable( join(f.bin, "npm"), String.raw`printf 'npm %s\n' "$*" >> "$MOCK_LOG" if [ "$1" = "view" ]; then printf '%s\n' '1.33.0'; exit 0; fi if [ "$1" = "list" ]; then exit 1; fi exit 0`, ); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: f.project, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 1); const log = await logText(f.log); assert.doesNotMatch(log, /npm install -g/); assert.match(result.stderr, /不属于 npm 全局/); }); test("uninstall.sh keeps Hippo by default and removes only after explicit approval", async () => { const keep = await fixture("1.33.0"); await mkdir(join(keep.project, ".hippo"), { recursive: true }); await writeFile(join(keep.project, ".hippo", "sentinel"), "keep\n", "utf8"); const keepResult = spawnSync("sh", [join(repositoryRoot, "uninstall.sh")], { cwd: keep.project, env: keep.env, input: "n\n", encoding: "utf8", timeout: 30_000, }); assert.equal(keepResult.status, 0, keepResult.stderr || keepResult.stdout); assert.doesNotMatch(await logText(keep.log), /npm uninstall/); assert.equal(await readFile(join(keep.project, ".hippo", "sentinel"), "utf8"), "keep\n"); const remove = await fixture("1.33.0"); await mkdir(join(remove.project, ".hippo"), { recursive: true }); await writeFile(join(remove.project, ".hippo", "sentinel"), "keep\n", "utf8"); const removeResult = spawnSync("sh", [join(repositoryRoot, "uninstall.sh")], { cwd: remove.project, env: remove.env, input: "y\n", encoding: "utf8", timeout: 30_000, }); assert.equal(removeResult.status, 0, removeResult.stderr || removeResult.stdout); assert.match(await logText(remove.log), /npm uninstall -g hippo-memory/); assert.equal(await readFile(join(remove.project, ".hippo", "sentinel"), "utf8"), "keep\n"); });