From f8f83ed1a7ecff351deb7508d884de670c3d8d60 Mon Sep 17 00:00:00 2001 From: yxr-2025 Date: Sun, 30 Aug 2026 16:24:31 +0800 Subject: [PATCH 1/4] fix(test): isolate Windows background terminal tests --- scripts/node-test-groups.d.mts | 4 +++ scripts/node-test-groups.mjs | 33 +++++++++++++++++++ scripts/run-tests.mjs | 31 ++++++++++++++---- tests/scripts/node-test-groups.test.ts | 44 ++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 scripts/node-test-groups.d.mts create mode 100644 scripts/node-test-groups.mjs create mode 100644 tests/scripts/node-test-groups.test.ts diff --git a/scripts/node-test-groups.d.mts b/scripts/node-test-groups.d.mts new file mode 100644 index 00000000..ed8bfd98 --- /dev/null +++ b/scripts/node-test-groups.d.mts @@ -0,0 +1,4 @@ +export function partitionNodeTestsByPlatform( + files: string[], + platform?: NodeJS.Platform, +): { parallel: string[]; serial: string[] }; diff --git a/scripts/node-test-groups.mjs b/scripts/node-test-groups.mjs new file mode 100644 index 00000000..a45b071d --- /dev/null +++ b/scripts/node-test-groups.mjs @@ -0,0 +1,33 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; + +const backgroundTerminalsRoot = resolve( + "tests", + "extensions", + "background-terminals", +); + +function isBackgroundTerminalsTest(file) { + const relativePath = relative(backgroundTerminalsRoot, file); + return ( + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ); +} + +export function partitionNodeTestsByPlatform( + files, + platform = process.platform, +) { + if (platform !== "win32") { + return { parallel: files, serial: [] }; + } + + const parallel = []; + const serial = []; + for (const file of files) { + (isBackgroundTerminalsTest(file) ? serial : parallel).push(file); + } + return { parallel, serial }; +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f5d24183..9e0f8631 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { discoverTestFiles } from "./discover-tests.mjs"; +import { partitionNodeTestsByPlatform } from "./node-test-groups.mjs"; const files = discoverTestFiles(resolve("tests")); const nodeTests = files.filter((file) => file.endsWith(".test.ts")); @@ -12,13 +13,29 @@ if (nodeTests.length === 0 || vitestTests.length === 0) { ); } -const nodeResult = spawnSync( - process.execPath, - ["--test", "--experimental-strip-types", ...nodeTests], - { stdio: "inherit" }, -); -if (nodeResult.status !== 0) { - process.exit(nodeResult.status ?? 1); +function runNodeTests(files, options = []) { + if (files.length === 0) return 0; + const result = spawnSync( + process.execPath, + ["--test", "--experimental-strip-types", ...options, ...files], + { stdio: "inherit" }, + ); + return result.status ?? 1; +} + +const nodeTestGroups = partitionNodeTestsByPlatform(nodeTests); +const parallelNodeResult = runNodeTests(nodeTestGroups.parallel); +if (parallelNodeResult !== 0) { + process.exit(parallelNodeResult); +} + +// Windows process-tree tests must not overlap unrelated Node test files. +// Keep the rest of the suite on Node's default file-level concurrency. +const serialNodeResult = runNodeTests(nodeTestGroups.serial, [ + "--test-concurrency=1", +]); +if (serialNodeResult !== 0) { + process.exit(serialNodeResult); } // Invoke the CLI module through Node instead of the package-manager shim. diff --git a/tests/scripts/node-test-groups.test.ts b/tests/scripts/node-test-groups.test.ts new file mode 100644 index 00000000..86c0912b --- /dev/null +++ b/tests/scripts/node-test-groups.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import test from "node:test"; +import { partitionNodeTestsByPlatform } from "../../scripts/node-test-groups.mjs"; + +const backgroundTest = resolve( + "tests", + "extensions", + "background-terminals", + "manager.test.ts", +); +const backgroundUnitTest = resolve( + "tests", + "extensions", + "background-terminals", + "output.test.ts", +); +const unrelatedTest = resolve("tests", "test-discovery.test.ts"); + +test("Windows isolates background-terminal tests from other Node files", () => { + assert.deepEqual( + partitionNodeTestsByPlatform( + [backgroundTest, unrelatedTest, backgroundUnitTest], + "win32", + ), + { + parallel: [unrelatedTest], + serial: [backgroundTest, backgroundUnitTest], + }, + ); +}); + +test("non-Windows keeps all Node files in the parallel group", () => { + assert.deepEqual( + partitionNodeTestsByPlatform( + [backgroundTest, unrelatedTest, backgroundUnitTest], + "linux", + ), + { + parallel: [backgroundTest, unrelatedTest, backgroundUnitTest], + serial: [], + }, + ); +}); From 9fae9ad272ff8ee976684785eb8efb3e34313801 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 31 Aug 2026 21:33:16 +0800 Subject: [PATCH 2/4] ci: exercise the full test runner on Windows --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 718cf5b4..5e92afef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,3 +117,6 @@ jobs: "extensions/background-terminals/manager.test.ts" } node --test --experimental-strip-types $testPath + - name: Test Windows full-suite isolation + timeout-minutes: 5 + run: bun run test From 3d9267f4ad0c3d0e86504e6ce340048f5b588693 Mon Sep 17 00:00:00 2001 From: yxr-2025 Date: Wed, 2 Sep 2026 00:41:48 +0800 Subject: [PATCH 3/4] fix(test): make Windows full-suite fixtures portable --- .../file-mutation-display/render.test.ts | 2 +- tests/extensions/git-info/index.test.ts | 88 +++++++++++++++---- tests/test-discovery.test.ts | 13 +-- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/tests/extensions/file-mutation-display/render.test.ts b/tests/extensions/file-mutation-display/render.test.ts index 412b9a77..c52c8be6 100644 --- a/tests/extensions/file-mutation-display/render.test.ts +++ b/tests/extensions/file-mutation-display/render.test.ts @@ -65,7 +65,7 @@ const fixtures: Array<{ content: [{ type: "text", text: "one\ntwo" }], details: undefined, }, - success: /Read\s+src\/long-file\.ts:10-29/, + success: /Read\s+src[\\/]long-file\.ts:10-29/, }, { name: "bash", diff --git a/tests/extensions/git-info/index.test.ts b/tests/extensions/git-info/index.test.ts index 61220088..7003c686 100644 --- a/tests/extensions/git-info/index.test.ts +++ b/tests/extensions/git-info/index.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, @@ -9,7 +10,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import test from "node:test"; import type { ExtensionAPI, @@ -32,10 +33,46 @@ test("automatic refresh stays local until /pr explicitly requests GitHub data", const callLog = join(root, "gh-calls.log"); mkdirSync(bin); - const gitPath = join(bin, "git"); - writeFileSync( - gitPath, - `#!/bin/sh + const isWindows = process.platform === "win32"; + const gitPath = join(bin, isWindows ? "git.exe" : "git"); + const ghPath = join(bin, isWindows ? "gh.exe" : "gh"); + const commandRunner = join(root, "command-runner.cjs"); + if (isWindows) { + // Direct Node spawning does not resolve .cmd fixtures without a shell, so + // use copied Node executables with a per-process command shim instead. + writeFileSync( + commandRunner, + `const { appendFileSync } = require("node:fs"); +const command = /[\\\\/]git\\.exe$/i.test(process.execPath) + ? "git" + : /[\\\\/]gh\\.exe$/i.test(process.execPath) + ? "gh" + : null; +const args = [ + ...(process.argv[1]?.split(/[\\\\/]/).pop() ? [process.argv[1].split(/[\\\\/]/).pop()] : []), + ...process.argv.slice(2), +]; +if (command === "git") { + const key = \`\${args[0] ?? ""} \${args[1] ?? ""}\`; + if (key === "rev-parse --is-inside-work-tree") process.stdout.write("true\\n"); + else if (key === "branch --show-current") process.stdout.write("main\\n"); + else if (key === "rev-parse --short") process.stdout.write("abc123\\n"); + else if (key === "status --porcelain=v1") process.stdout.write(" M local.ts\\n?? new.ts\\n"); + else process.exitCode = 2; + process.exit(); +} else if (command === "gh") { + appendFileSync(process.env.GH_CALL_LOG, args.join(" ") + "\\n"); + process.stdout.write("{\\"number\\":42,\\"url\\":\\"https://example.test/pr/42\\",\\"state\\":\\"OPEN\\",\\"isDraft\\":false}\\n"); + process.exit(); +} +`, + ); + copyFileSync(process.execPath, gitPath); + copyFileSync(process.execPath, ghPath); + } else { + writeFileSync( + gitPath, + `#!/bin/sh case "$1 $2" in "rev-parse --is-inside-work-tree") echo true ;; "branch --show-current") echo main ;; @@ -44,23 +81,31 @@ case "$1 $2" in *) exit 2 ;; esac `, - ); - chmodSync(gitPath, 0o755); - - const ghPath = join(bin, "gh"); - writeFileSync( - ghPath, - `#!/bin/sh + ); + chmodSync(gitPath, 0o755); + writeFileSync( + ghPath, + `#!/bin/sh printf '%s\\n' "$*" >> "$GH_CALL_LOG" printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN","isDraft":false}' `, - ); - chmodSync(ghPath, 0o755); + ); + chmodSync(ghPath, 0o755); + } const previousPath = process.env.PATH; const previousLog = process.env.GH_CALL_LOG; - process.env.PATH = `${bin}:${previousPath ?? ""}`; + const previousNodeOptions = process.env.NODE_OPTIONS; + process.env.PATH = `${bin}${delimiter}${previousPath ?? ""}`; process.env.GH_CALL_LOG = callLog; + if (isWindows) { + process.env.NODE_OPTIONS = [ + previousNodeOptions, + `--require=${commandRunner}`, + ] + .filter(Boolean) + .join(" "); + } const hooks = new Map< string, @@ -110,7 +155,16 @@ printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN"," try { await hooks.get("session_start")?.({}, ctx); - const local = await localPublished; + let timeoutHandle: ReturnType | undefined; + const local = await Promise.race([ + localPublished, + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error("Timed out waiting for local git info")), + 5_000, + ); + }), + ]).finally(() => clearTimeout(timeoutHandle)); assert.equal(local.branch, "main"); assert.equal(local.changedFiles, 2); assert.equal(local.pullRequest, null); @@ -127,6 +181,8 @@ printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN"," process.env.PATH = previousPath; if (previousLog === undefined) delete process.env.GH_CALL_LOG; else process.env.GH_CALL_LOG = previousLog; + if (previousNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = previousNodeOptions; rmSync(root, { recursive: true, force: true }); } }); diff --git a/tests/test-discovery.test.ts b/tests/test-discovery.test.ts index aae4d1f5..ac7a8dc3 100644 --- a/tests/test-discovery.test.ts +++ b/tests/test-discovery.test.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { relative, resolve, sep } from "node:path"; import test from "node:test"; test("recursive discovery includes nested Node and Vitest suites", () => { + const testsRoot = resolve("tests"); const discovered = JSON.parse( execFileSync(process.execPath, ["scripts/discover-tests.mjs"], { cwd: resolve("."), @@ -12,13 +13,15 @@ test("recursive discovery includes nested Node and Vitest suites", () => { }), ) as string[]; + assert.ok(discovered.includes(resolve(testsRoot, "test-discovery.test.ts"))); assert.ok( - discovered.some((file) => file.endsWith("tests/test-discovery.test.ts")), + discovered.some((file) => + relative(testsRoot, file).startsWith(`extensions${sep}`), + ), ); - assert.ok(discovered.some((file) => file.includes("tests/extensions/"))); assert.ok( - discovered.some((file) => - file.endsWith("tests/extensions/file-search/index.spec.ts"), + discovered.includes( + resolve(testsRoot, "extensions", "file-search", "index.spec.ts"), ), ); }); From 3a66be0b3517cc87145c37d169cee4277dda86ad Mon Sep 17 00:00:00 2001 From: yxr-2025 Date: Wed, 2 Sep 2026 01:15:22 +0800 Subject: [PATCH 4/4] fix(test): clear remaining Windows suite blockers --- tests/extensions/file-search/index.spec.ts | 13 ++- tests/extensions/sessions/git-stats.test.ts | 12 +- .../sessions/preview-loader.test.ts | 18 ++- tests/extensions/shared/worktree.test.ts | 24 +++- .../extensions/subagents/agent-types.test.ts | 29 +++-- .../subagents/result-artifact.test.ts | 10 +- tests/extensions/subagents/transcript.test.ts | 3 +- tests/extensions/web/index.test.ts | 5 +- tests/extensions/workflows/artifacts.test.ts | 2 +- tests/extensions/workflows/dashboard.test.ts | 14 ++- tests/extensions/workflows/operator.test.ts | 3 +- .../workflows/replay-safety.test.ts | 59 ++++++++-- .../workflows/resume-lookup.test.ts | 6 +- .../workspace-provenance.test.ts | 48 ++++---- tests/web/cli.test.ts | 110 +++++++++--------- tests/web/pi-runtime.test.ts | 6 +- 16 files changed, 238 insertions(+), 124 deletions(-) diff --git a/tests/extensions/file-search/index.spec.ts b/tests/extensions/file-search/index.spec.ts index 9aee7e17..7a8e4352 100644 --- a/tests/extensions/file-search/index.spec.ts +++ b/tests/extensions/file-search/index.spec.ts @@ -232,17 +232,19 @@ it.effect("binary resolution: fdfind is accepted as a system fd", () => it.effect("binary resolution: an existing cached binary is used silently", () => Effect.gen(function* () { - const env = makeEnv({ available: ["/cache/bin/rg"] }); + const binDir = join("/cache", "bin"); + const cachedBinary = join(binDir, "rg"); + const env = makeEnv({ available: [cachedBinary] }); const resolved = yield* resolveBinary( TOOL_SPECS.rg, - "/cache/bin", + binDir, darwinArm, env, ); assert.deepEqual(resolved, { tool: "rg", - command: "/cache/bin/rg", + command: cachedBinary, source: "cached", }); assert.equal(env.installs.length, 0); @@ -253,16 +255,17 @@ it.effect( "binary resolution: missing everywhere triggers exactly one install", () => Effect.gen(function* () { + const binDir = join("/repo", "bin"); const env = makeEnv({ available: [] }); const resolved = yield* resolveBinary( TOOL_SPECS.rg, - "/repo/bin", + binDir, darwinArm, env, ); assert.equal(resolved.source, "installed"); - assert.equal(resolved.command, "/repo/bin/rg"); + assert.equal(resolved.command, join(binDir, "rg")); assert.equal(env.installs.length, 1); assert.match( env.installs[0].url, diff --git a/tests/extensions/sessions/git-stats.test.ts b/tests/extensions/sessions/git-stats.test.ts index 8eba3b05..aed5768f 100644 --- a/tests/extensions/sessions/git-stats.test.ts +++ b/tests/extensions/sessions/git-stats.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import test from "node:test"; import { createSessionStatsLoader } from "../../../extensions/sessions/git-stats.ts"; import type { SessionInfoLike } from "../../../extensions/sessions/sessions.ts"; @@ -105,6 +105,7 @@ test("overlapping viewport work is retained while work that leaves is cancelled" }); const loader = createSessionStatsLoader({ maxConcurrency: 4, + canonicalizeCwd: async (cwd) => cwd, runGit: (args, cwd, signal) => { const id = `${cwd}:${args[0]}`; started.push(id); @@ -140,7 +141,8 @@ test("overlapping viewport work is retained while work that leaves is cancelled" assert.equal(aborted.length, 4); assert.equal( - started.filter((entry) => entry.startsWith("/tmp/project-2:")).length, + started.filter((entry) => entry.startsWith(`${resolve("/tmp/project-2")}:`)) + .length, 2, "overlapping work must not restart", ); @@ -219,7 +221,11 @@ test("real and symlink workspace paths share one canonical latest bucket", async const alias = join(root, "alias"); try { await mkdir(workspace); - await symlink(workspace, alias); + await symlink( + workspace, + alias, + process.platform === "win32" ? "junction" : "dir", + ); const [newest, older] = makeSessions(2).map((entry, index) => ({ ...entry, cwd: index === 0 ? workspace : alias, diff --git a/tests/extensions/sessions/preview-loader.test.ts b/tests/extensions/sessions/preview-loader.test.ts index 6d40644c..209604fd 100644 --- a/tests/extensions/sessions/preview-loader.test.ts +++ b/tests/extensions/sessions/preview-loader.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { appendFileSync, renameSync } from "node:fs"; +import { appendFileSync, renameSync, writeFileSync } from "node:fs"; import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -399,10 +399,10 @@ for (const version of [1, 3]) { : [header(3), message("m1", null, "user", "replaced")]; const fixture = await writeSession(originalEntries); const replacementPath = join(fixture.directory, "replacement.jsonl"); - await writeFile( - replacementPath, - `${replacementEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, - ); + const replacementContent = `${replacementEntries + .map((entry) => JSON.stringify(entry)) + .join("\n")}\n`; + await writeFile(replacementPath, replacementContent); t.after(() => rm(fixture.directory, { recursive: true, force: true })); let replaced = false; @@ -411,7 +411,13 @@ for (const version of [1, 3]) { onRead: () => { if (replaced) return; replaced = true; - renameSync(replacementPath, fixture.path); + if (process.platform === "win32") { + // Windows rejects rename-over-open. An in-place replacement still + // exercises the loader's changed-file rejection on that platform. + writeFileSync(fixture.path, `${replacementContent}\n`); + } else { + renameSync(replacementPath, fixture.path); + } }, }), /changed while preview was loading/, diff --git a/tests/extensions/shared/worktree.test.ts b/tests/extensions/shared/worktree.test.ts index 46886274..3086c4a7 100644 --- a/tests/extensions/shared/worktree.test.ts +++ b/tests/extensions/shared/worktree.test.ts @@ -554,7 +554,12 @@ describe("worktree lifecycle", () => { `child ${index}\n`, ); } - assert.equal(fs.readFileSync(path.join(repo, "a.txt"), "utf8"), "hello\n"); + assert.equal( + fs + .readFileSync(path.join(repo, "a.txt"), "utf8") + .replaceAll("\r\n", "\n"), + "hello\n", + ); for (const worktree of worktrees) { if (!worktree) continue; @@ -577,10 +582,19 @@ describe("worktree lifecycle", () => { }); assert.ok(inner.ok); if (!inner.ok) return; - assert.equal( - // realpath because git resolves symlinks and macOS tmpdirs are one. - fs.realpathSync(path.dirname(path.dirname(inner.worktree.path))), - fs.realpathSync(path.join(repo, ".git")), + const worktreeContainer = fs.statSync( + path.dirname(path.dirname(inner.worktree.path)), + ); + const commonGitDirectory = fs.statSync(path.join(repo, ".git")); + assert.deepEqual( + { + dev: worktreeContainer.dev, + ino: worktreeContainer.ino, + }, + { + dev: commonGitDirectory.dev, + ino: commonGitDirectory.ino, + }, ); git(repo, "worktree", "remove", "--force", inner.worktree.path); diff --git a/tests/extensions/subagents/agent-types.test.ts b/tests/extensions/subagents/agent-types.test.ts index f8e343e8..7cc3561a 100644 --- a/tests/extensions/subagents/agent-types.test.ts +++ b/tests/extensions/subagents/agent-types.test.ts @@ -338,7 +338,7 @@ test("a project agent type overrides the global one of the same name", async () // Global replaces the built-in, then the trusted project replaces global. const messages = diagnostics.map((entry) => entry.message).join("\n"); assert.match(messages, /from built-in:explorer/); - assert.match(messages, /from .*agent\/agents\/explorer\.md/); + assert.match(messages, /from .*agent[\\/]agents[\\/]explorer\.md/); }); }); @@ -577,7 +577,7 @@ Body. assert.match(messages, /unrecognized tool "gerp"/); }); -test("a symlinked agent type is discovered like a real file", async () => { +test("a symlinked agent type is discovered like a real file", async (t) => { // These commonly live in a dotfiles repo and are symlinked into place — the // same shape this user's own ~/.pi/agent/skills uses. `isFile()` is false // for a symlink, so the type simply never appeared, with no diagnostic. @@ -586,10 +586,21 @@ test("a symlinked agent type is discovered like a real file", async () => { const real = path.join(root, "dotfiles"); await mkdir(real, { recursive: true }); await writeFile(path.join(real, "explore.md"), VALID); - await symlink( - path.join(real, "explore.md"), - path.join(agentDir, "agents", "explore.md"), - ); + try { + await symlink( + path.join(real, "explore.md"), + path.join(agentDir, "agents", "explore.md"), + ); + } catch (error) { + if ( + process.platform === "win32" && + (error as NodeJS.ErrnoException).code === "EPERM" + ) { + t.skip("Windows file symlinks require Developer Mode or elevation"); + return; + } + throw error; + } const { agentTypes } = loadAgentTypes({ agentDir, @@ -611,7 +622,11 @@ test("a symlink pointing at a directory is still skipped", async () => { const { agentDir, cwd } = await seed(root, {}); const dir = path.join(root, "notafile"); await mkdir(dir, { recursive: true }); - await symlink(dir, path.join(agentDir, "agents", "broken.md")); + await symlink( + dir, + path.join(agentDir, "agents", "broken.md"), + process.platform === "win32" ? "junction" : "dir", + ); const { agentTypes } = loadAgentTypes({ agentDir, diff --git a/tests/extensions/subagents/result-artifact.test.ts b/tests/extensions/subagents/result-artifact.test.ts index 2749289c..9c1c56fe 100644 --- a/tests/extensions/subagents/result-artifact.test.ts +++ b/tests/extensions/subagents/result-artifact.test.ts @@ -139,7 +139,9 @@ test("content-addressed artifacts are exact, private, and reusable", async () => assert.equal(first, second); assert.equal(await readFile(first, "utf8"), content); - assert.equal((await lstat(first)).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal((await lstat(first)).mode & 0o777, 0o600); + } assert.equal(path.basename(first).length, 68); } finally { await rm(agentDir, { recursive: true, force: true }); @@ -150,7 +152,11 @@ test("artifact persistence refuses a symlinked cache component", async () => { const agentDir = await mkdtemp(path.join(tmpdir(), "openpi-result-symlink-")); const outside = await mkdtemp(path.join(tmpdir(), "openpi-result-outside-")); try { - await symlink(outside, path.join(agentDir, "cache")); + await symlink( + outside, + path.join(agentDir, "cache"), + process.platform === "win32" ? "junction" : "dir", + ); assert.throws( () => persistResultArtifact(agentDir, "do not write outside"), /Unsafe result artifact directory/, diff --git a/tests/extensions/subagents/transcript.test.ts b/tests/extensions/subagents/transcript.test.ts index 2570c3de..4ddc9bbb 100644 --- a/tests/extensions/subagents/transcript.test.ts +++ b/tests/extensions/subagents/transcript.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import path from "node:path"; import test from "node:test"; import { defineTool, @@ -269,7 +270,7 @@ test("tool argument summaries relativize paths inside the child cwd", () => { const cwd = "/repo"; assert.equal( summarizeToolArgs("read", '{"path":"/repo/src/a.ts"}', cwd), - "src/a.ts", + path.join("src", "a.ts"), ); assert.equal( summarizeToolArgs("rg", '{"pattern":"foo","path":"/repo/ext"}', cwd), diff --git a/tests/extensions/web/index.test.ts b/tests/extensions/web/index.test.ts index bedfaa4d..3fd7fde1 100644 --- a/tests/extensions/web/index.test.ts +++ b/tests/extensions/web/index.test.ts @@ -83,7 +83,10 @@ function harness( assert.equal(spawnOptions.env.INIT_CWD, undefined); assert.equal(spawnOptions.env.PI_SESSION_ID, undefined); assert.equal(spawnOptions.env.PI_SESSION_FILE, undefined); - assert.equal(spawnOptions.env.PATH, process.env.PATH); + const childPath = Object.entries(spawnOptions.env).find( + ([name]) => name.toLowerCase() === "path", + )?.[1]; + assert.equal(childPath, process.env.PATH); assert.equal(spawnOptions.shell, false); assert.equal(spawnOptions.stdio, "inherit"); const child = new FakeWebProcess(); diff --git a/tests/extensions/workflows/artifacts.test.ts b/tests/extensions/workflows/artifacts.test.ts index 249b185c..9218bc70 100644 --- a/tests/extensions/workflows/artifacts.test.ts +++ b/tests/extensions/workflows/artifacts.test.ts @@ -302,7 +302,7 @@ test("a dependent artifact write failure cannot leave the prior running manifest assert.throws( () => persistWorkflowJson(directory, details), - /EISDIR|directory/i, + /EISDIR|EPERM|directory/i, ); const stored = JSON.parse( diff --git a/tests/extensions/workflows/dashboard.test.ts b/tests/extensions/workflows/dashboard.test.ts index 77ebbfc1..04f43f8a 100644 --- a/tests/extensions/workflows/dashboard.test.ts +++ b/tests/extensions/workflows/dashboard.test.ts @@ -405,7 +405,11 @@ test("retained projections without session metadata keep current-session runs vi assert.equal(entry.details, details); }); test("restored run directories require a generated safe id", () => { - writeRun("wf_\u001b]52;c;clipboard\u0007", 9_000); + const unsafeRunId = + process.platform === "win32" + ? "wf_not-generated-clipboard" + : "wf_\u001b]52;c;clipboard\u0007"; + writeRun(unsafeRunId, 9_000); const runIds = loadRunEntries(new Map(), SESSION, new Set()).map( (entry) => entry.runId, ); @@ -834,7 +838,9 @@ function saveReport(runId: string) { test("a newly created dashboard report is private", () => { const report = saveReport("wf_600001"); - assert.equal(statSync(report).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal(statSync(report).mode & 0o777, 0o600); + } }); test("overwriting a dashboard report restores private mode atomically", () => { @@ -845,7 +851,9 @@ test("overwriting a dashboard report restores private mode atomically", () => { chmodSync(report, 0o644); assert.equal(saveReport(runId), report); - assert.equal(statSync(report).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal(statSync(report).mode & 0o777, 0o600); + } assert.notEqual(readFileSync(report, "utf8"), "old report"); }); diff --git a/tests/extensions/workflows/operator.test.ts b/tests/extensions/workflows/operator.test.ts index 44ba0773..f0ad08ff 100644 --- a/tests/extensions/workflows/operator.test.ts +++ b/tests/extensions/workflows/operator.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { resolve } from "node:path"; import { test } from "node:test"; import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { @@ -16,7 +17,7 @@ function deferred() { test("reuses one in-memory SessionManager for an operator identity", async () => { const registry = new WorkflowOperatorRegistry(); - const cwd = "/tmp/openpi-operator"; + const cwd = resolve("/tmp/openpi-operator"); const managers: SessionManager[] = []; const identity = { key: "reviewer:1", diff --git a/tests/extensions/workflows/replay-safety.test.ts b/tests/extensions/workflows/replay-safety.test.ts index 78aa132e..eac5634d 100644 --- a/tests/extensions/workflows/replay-safety.test.ts +++ b/tests/extensions/workflows/replay-safety.test.ts @@ -7,6 +7,7 @@ import { realpathSync, renameSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -29,6 +30,35 @@ function git(cwd: string, ...args: string[]) { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); } +function assertSameDirectory(actual: string, expected: string) { + const actualStats = statSync(actual); + const expectedStats = statSync(expected); + assert.deepEqual( + { dev: actualStats.dev, ino: actualStats.ino }, + { dev: expectedStats.dev, ino: expectedStats.ino }, + ); +} + +function createFileSymlinkOrSkip( + t: { skip(message?: string): void }, + target: string, + path: string, +) { + try { + symlinkSync(target, path); + return true; + } catch (error) { + if ( + process.platform === "win32" && + (error as NodeJS.ErrnoException).code === "EPERM" + ) { + t.skip("Windows file symlinks require Developer Mode or elevation"); + return false; + } + throw error; + } +} + function recordingGit(commands: string[][]) { // Mirrors boundedGit's spawn shape (fsmonitor disabled, 32 MiB cap, stderr // ignored); the production deadline is intentionally not reproduced — the @@ -227,13 +257,17 @@ test("replay filesystem boundary permits repo-local observers and rejects escapi } }); -test("replay filesystem boundary fails closed for symlinks and uncertain paths", async () => { +test("replay filesystem boundary fails closed for symlinks and uncertain paths", async (t) => { const cwd = repository(); const outside = mkdtempSync(path.join(tmpdir(), "pi-replay-symlink-")); try { const outsideFile = path.join(outside, "outside.txt"); writeFileSync(outsideFile, "external one\n"); - symlinkSync(outsideFile, path.join(cwd, "external-link")); + if ( + !createFileSymlinkOrSkip(t, outsideFile, path.join(cwd, "external-link")) + ) { + return; + } let observations = 0; let violations = 0; const definition = filesystemTool("read", (pathname) => { @@ -417,11 +451,12 @@ test("canonical cwd and repository state participate in replay identity", () => true, ); assert.equal(originalIdentity?.version, 3); - assert.equal(originalIdentity?.repositoryRoot, realpathSync(cwd)); + assert.ok(originalIdentity); + assertSameDirectory(originalIdentity.repositoryRoot, realpathSync(cwd)); const original = replayKey(cwd, resourcePath); const alias = path.join(path.dirname(cwd), `${path.basename(cwd)}-alias`); - symlinkSync(cwd, alias, "dir"); + symlinkSync(cwd, alias, process.platform === "win32" ? "junction" : "dir"); try { assert.equal( replayKey(alias, path.join(alias, "resource.md")), @@ -429,7 +464,7 @@ test("canonical cwd and repository state participate in replay identity", () => "a symlink spelling of the same cwd must canonicalize", ); } finally { - rmSync(alias, { force: true }); + rmSync(alias, { recursive: true, force: true }); } const nested = path.join(cwd, "nested"); @@ -486,13 +521,21 @@ test("ignored observable files disable replay rather than returning stale output } }); -test("tracked and untracked symlinks disable replay", () => { +test("tracked and untracked symlinks disable replay", (t) => { for (const tracked of [true, false]) { const cwd = repository(); try { const resourcePath = path.join(cwd, "resource.md"); writeFileSync(resourcePath, "resource\n"); - symlinkSync("tracked.txt", path.join(cwd, "observable-link")); + if ( + !createFileSymlinkOrSkip( + t, + "tracked.txt", + path.join(cwd, "observable-link"), + ) + ) { + return; + } if (tracked) { git(cwd, "add", "observable-link"); git(cwd, "commit", "-qm", "track symlink"); @@ -580,7 +623,7 @@ test("replay fingerprint keeps the early ignored gate and post-diff safety gates const commands: string[][] = []; const fingerprint = repositoryFingerprint(cwd, recordingGit(commands)); assert.ok(fingerprint); - assert.equal(fingerprint.root, realpathSync(cwd)); + assertSameDirectory(fingerprint.root, realpathSync(cwd)); assert.deepEqual(commands, [ ["rev-parse", "--show-toplevel"], [ diff --git a/tests/extensions/workflows/resume-lookup.test.ts b/tests/extensions/workflows/resume-lookup.test.ts index eaf51e6d..bdd7c459 100644 --- a/tests/extensions/workflows/resume-lookup.test.ts +++ b/tests/extensions/workflows/resume-lookup.test.ts @@ -16,7 +16,11 @@ const runs = join(agentDir, "workflows"); mkdirSync(join(runs, "wf_1a2b3c4d5e6f"), { recursive: true }); mkdirSync(join(runs, "wf_ffeeddcc5e6f"), { recursive: true }); mkdirSync(join(runs, "4d5e6f"), { recursive: true }); -mkdirSync(join(runs, "wf_\u001b]52;c;clipboard\u0007bad0"), { +const invalidRunId = + process.platform === "win32" + ? "wf_not-generated-bad0" + : "wf_\u001b]52;c;clipboard\u0007bad0"; +mkdirSync(join(runs, invalidRunId), { recursive: true, }); diff --git a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts index 3f93aa2f..3a43890f 100644 --- a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts +++ b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts @@ -301,13 +301,24 @@ test("unproven or non-standalone rm input fails closed", async () => { test("quoted or escaped literals remain directly verifiable", async () => { await withWorkspace(async (workspace) => { - for (const target of [ - "keep*.txt", - "keep\\*.txt", - "keep.txt", - "scratch$1.txt", - "file name.txt", - ]) { + const cases = + process.platform === "win32" + ? [ + ["rm 'keep[1].txt'", "keep[1].txt"], + ["rm scratch\\$1.txt", "scratch$1.txt"], + ["rm 'scratch$1.txt'", "scratch$1.txt"], + ['rm "file name.txt"', "file name.txt"], + ['rm "keep\\\n.txt"', "keep.txt"], + ] + : [ + ["rm 'keep*.txt'", "keep*.txt"], + ["rm keep\\*.txt", "keep*.txt"], + ["rm 'scratch$1.txt'", "scratch$1.txt"], + ['rm "file name.txt"', "file name.txt"], + ['rm "keep\\*.txt"', "keep\\*.txt"], + ['rm "keep\\\n.txt"', "keep.txt"], + ]; + for (const target of new Set(cases.map(([, target]) => target))) { await writeFile(path.join(workspace, target), "keep"); } const confirmations: string[][] = []; @@ -316,14 +327,7 @@ test("quoted or escaped literals remain directly verifiable", async () => { return false; }); - for (const [index, command] of [ - "rm 'keep*.txt'", - "rm keep\\*.txt", - "rm 'scratch$1.txt'", - 'rm "file name.txt"', - 'rm "keep\\*.txt"', - 'rm "keep\\\n.txt"', - ].entries()) { + for (const [index, [command]] of cases.entries()) { assert.equal( ( await guard.before({ @@ -336,14 +340,10 @@ test("quoted or escaped literals remain directly verifiable", async () => { command, ); } - assert.deepEqual(confirmations, [ - ["keep*.txt"], - ["keep*.txt"], - ["scratch$1.txt"], - ["file name.txt"], - ["keep\\*.txt"], - ["keep.txt"], - ]); + assert.deepEqual( + confirmations, + cases.map(([, target]) => [target]), + ); }); }); @@ -413,7 +413,7 @@ test("direct rm targets must resolve from workspace-relative paths", async () => }); for (const [index, command] of [ - `rm ${target}`, + "rm /outside/keep.txt", "rm ../keep.txt", "rm .", "rm ~/keep.txt", diff --git a/tests/web/cli.test.ts b/tests/web/cli.test.ts index b0f55b96..845067e3 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -23,8 +23,10 @@ const staticAssetsPath = fileURLToPath( ); test("openpi is an executable standalone Web entrypoint", async () => { - const info = await stat(entrypoint); - assert.notEqual(info.mode & 0o100, 0); + if (process.platform !== "win32") { + const info = await stat(entrypoint); + assert.notEqual(info.mode & 0o100, 0); + } const { stdout } = await execFileAsync(process.execPath, [ entrypointPath, @@ -171,59 +173,61 @@ export class PiWebRuntime { ); assert.equal(await readFile(disposeMarker, "utf8"), "disposed"); - const child = spawn( - process.execPath, - [ - join(packageRoot, "bin", "openpi.js"), - "web", - temporaryRoot, - "--no-open", - ], - { - env: { - ...process.env, - OPENPI_CLI_KEEPALIVE: "1", - OPENPI_CLI_STOP_FAIL: "1", + if (process.platform !== "win32") { + const child = spawn( + process.execPath, + [ + join(packageRoot, "bin", "openpi.js"), + "web", + temporaryRoot, + "--no-open", + ], + { + env: { + ...process.env, + OPENPI_CLI_KEEPALIVE: "1", + OPENPI_CLI_STOP_FAIL: "1", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - let signalOutput = ""; - let signalError = ""; - child.stdout.on("data", (chunk) => { - signalOutput += chunk; - }); - child.stderr.on("data", (chunk) => { - signalError += chunk; - }); - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("installed CLI did not start")), - 5_000, ); - const waitForReady = () => { - if ( - signalOutput.includes( - "OpenPI Web Workbench is running at http://127.0.0.1:12345", - ) - ) { - clearTimeout(timeout); - resolve(); - return; - } - setTimeout(waitForReady, 10); - }; - waitForReady(); - }); - child.kill("SIGTERM"); - const [exitCode] = (await once(child, "close")) as [number | null]; - assert.equal(exitCode, 1); - assert.match( - signalError, - /Failed to stop OpenPI Web Workbench: stop failed/u, - ); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let signalOutput = ""; + let signalError = ""; + child.stdout.on("data", (chunk) => { + signalOutput += chunk; + }); + child.stderr.on("data", (chunk) => { + signalError += chunk; + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("installed CLI did not start")), + 5_000, + ); + const waitForReady = () => { + if ( + signalOutput.includes( + "OpenPI Web Workbench is running at http://127.0.0.1:12345", + ) + ) { + clearTimeout(timeout); + resolve(); + return; + } + setTimeout(waitForReady, 10); + }; + waitForReady(); + }); + child.kill("SIGTERM"); + const [exitCode] = (await once(child, "close")) as [number | null]; + assert.equal(exitCode, 1); + assert.match( + signalError, + /Failed to stop OpenPI Web Workbench: stop failed/u, + ); + } } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index ae6f3f78..996012f7 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -760,7 +760,7 @@ test("dispose waits for pending candidate creation and cleans it before releasin }; try { - const sessionChange = harness.newSession("/tmp"); + const sessionChange = harness.newSession(process.cwd()); await createEntered.promise; const disposal = harness.dispose(); await new Promise((resolve) => setImmediate(resolve)); @@ -806,7 +806,7 @@ test("new session projects its command id and activated session path", async () }); try { - const result = await harness.newSession("/tmp", { + const result = await harness.newSession(process.cwd(), { commandId: "create-command", }); @@ -851,7 +851,7 @@ test("dispose also waits for a pending switched-session candidate", async () => const originalCreateRuntime = runtimeConstructor.createRuntime; const originalOpen = SessionManager.open; SessionManager.open = (() => ({ - getCwd: () => "/tmp", + getCwd: () => process.cwd(), })) as unknown as typeof SessionManager.open; runtimeConstructor.createRuntime = async () => { createEntered.resolve();