import assert from "node:assert/strict"; import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; import { fileURLToPath } from "node:url"; const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); async function fixture( t: test.TestContext, options: { createMarker?: boolean; origin?: string; secondPiStatus?: number } = {}, ) { const root = await mkdtemp(join(tmpdir(), "my-pi-update-clean-recovery-")); t.after(() => rm(root, { recursive: true, force: true })); const home = join(root, "home"); const bin = join(root, "bin"); const agentDir = join(home, ".pi", "agent"); const target = join(agentDir, "git", "bitbucket.org", "siakitem", "my-pi"); const marker = join(agentDir, "git", "bitbucket.org", "siakitem", ".my-pi.pi-update-incomplete"); const piLog = join(root, "pi.log"); const piCount = join(root, "pi.count"); const gitLog = join(root, "git.log"); const cleanCount = join(root, "clean.count"); await mkdir(join(target, ".git"), { recursive: true }); await mkdir(join(target, "config"), { recursive: true }); await copyFile(join(repositoryRoot, "config", "p10k.zsh"), join(target, "config", "p10k.zsh")); await copyFile( join(repositoryRoot, "config", "hippo-memory-version"), join(target, "config", "hippo-memory-version"), ); await mkdir(bin, { recursive: true }); await writeFile( join(bin, "pi"), `#!/bin/sh count=0 [ ! -f "$MOCK_PI_COUNT" ] || count=$(cat "$MOCK_PI_COUNT") count=$((count + 1)) printf '%s\n' "$count" > "$MOCK_PI_COUNT" printf 'pi %s\n' "$*" >> "$MOCK_PI_LOG" if [ "$count" -eq 1 ]; then if [ "$MOCK_CREATE_MARKER" = 1 ]; then : > "$MOCK_MARKER" fi exit 1 fi if [ "$MOCK_SECOND_PI_STATUS" -eq 0 ]; then rm -f "$MOCK_MARKER" fi exit "$MOCK_SECOND_PI_STATUS" `, "utf8", ); await chmod(join(bin, "pi"), 0o755); await writeFile( join(bin, "git"), `#!/bin/sh printf 'git %s\n' "$*" >> "$MOCK_GIT_LOG" case "$*" in *" rev-parse --show-toplevel") printf '%s\n' "$MOCK_GIT_TARGET" ;; *" remote get-url origin") printf '%s\n' "$MOCK_GIT_ORIGIN" ;; *" clean -fdx") count=0 [ ! -f "$MOCK_CLEAN_COUNT" ] || count=$(cat "$MOCK_CLEAN_COUNT") count=$((count + 1)) printf '%s\n' "$count" > "$MOCK_CLEAN_COUNT" if [ "$count" -eq 1 ]; then printf '%s\n' 'warning: failed to remove node_modules/: Directory not empty' >&2 exit 1 fi ;; esac `, "utf8", ); await chmod(join(bin, "git"), 0o755); await writeFile(join(bin, "uname"), "#!/bin/sh\nprintf '%s\\n' Linux\n", "utf8"); await chmod(join(bin, "uname"), 0o755); const env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config"), PI_CODING_AGENT_DIR: agentDir, MOCK_CREATE_MARKER: options.createMarker === false ? "0" : "1", MOCK_MARKER: marker, MOCK_PI_COUNT: piCount, MOCK_PI_LOG: piLog, MOCK_SECOND_PI_STATUS: String(options.secondPiStatus ?? 0), MOCK_GIT_LOG: gitLog, MOCK_CLEAN_COUNT: cleanCount, MOCK_GIT_TARGET: target, MOCK_GIT_ORIGIN: options.origin ?? "git@bitbucket.org:siakitem/my-pi.git", PATH: `${bin}:${dirname(process.execPath)}:/usr/bin:/bin`, ZSH: "", ZSH_CUSTOM: "", ZDOTDIR: home, }; delete env.PI_PACKAGE_SOURCE; return { cleanCount, env, gitLog, marker, piCount, piLog }; } test("update.sh retries an identity-checked interrupted Git cleanup before retrying pi update", async (t) => { const f = await fixture(t); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: repositoryRoot, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal((await readFile(f.piCount, "utf8")).trim(), "2"); assert.equal((await readFile(f.cleanCount, "utf8")).trim(), "2"); assert.match(result.stdout, /检测到默认组合包的 Pi Git 升级中断/); assert.match(result.stdout, /第 2 次尝试后完成/); await assert.rejects(readFile(f.marker, "utf8"), { code: "ENOENT" }); }); test("update.sh keeps the original fail-fast behavior when no update marker exists", async (t) => { const f = await fixture(t, { createMarker: false }); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: repositoryRoot, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 1); assert.equal((await readFile(f.piCount, "utf8")).trim(), "1"); await assert.rejects(readFile(f.gitLog, "utf8"), { code: "ENOENT" }); assert.match(result.stderr, /pi update 执行失败/); }); test("update.sh refuses cleanup when the cached checkout origin does not match", async (t) => { const f = await fixture(t, { origin: "git@example.invalid:other/package.git" }); const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { cwd: repositoryRoot, env: f.env, encoding: "utf8", timeout: 30_000, }); assert.equal(result.status, 1); assert.equal((await readFile(f.piCount, "utf8")).trim(), "1"); const gitLog = await readFile(f.gitLog, "utf8"); assert.doesNotMatch(gitLog, /clean -fdx/); assert.match(result.stderr, /checkout 身份校验失败/); });