diff --git a/scripts/test.ts b/scripts/test.ts index 68a60162ece..3ea636228c9 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -52,6 +52,8 @@ export function createIsolatedTestEnvironment( const opencodexHome = join(root, ".opencodex"); const codexHome = join(root, ".codex"); const containedTemp = createContainedTestTemp(root); + const transpilerCache = baseEnv.BUN_RUNTIME_TRANSPILER_CACHE_PATH ?? join(root, "bun-transpiler-cache"); + if (baseEnv.BUN_RUNTIME_TRANSPILER_CACHE_PATH === undefined) mkdirSync(transpilerCache, { mode: 0o700 }); mkdirSync(opencodexHome, { recursive: true }); mkdirSync(codexHome, { recursive: true }); if (process.platform === "win32") { @@ -89,15 +91,11 @@ export function createIsolatedTestEnvironment( // whichever adapter collected the metadata. Naming the file keeps the sandbox // (git still writes nothing here) while leaving git's own trust decisions intact. GIT_CONFIG_GLOBAL: baseEnv.GIT_CONFIG_GLOBAL ?? join(homedir(), ".gitconfig"), - // Pin Bun's runtime transpiler cache for the same reason. Bun keeps it under the home - // directory (macOS: ~/Library/Caches/bun/@t@), so a sandboxed HOME/USERPROFILE handed every - // batch, and every fixture that gives its child its own HOME, an empty cache: the first child - // re-transpiled each large module (73 src files are over the 50 KB cache threshold). On a busy - // Windows shard that first child took 10-45 s where later ones took 2-5 s, failing whichever - // timed case happened to spawn it. The cache holds transpiled source only, so sharing it keeps - // the sandbox. An explicit value, including "" or "0" to disable it, is kept as given. - BUN_RUNTIME_TRANSPILER_CACHE_PATH: baseEnv.BUN_RUNTIME_TRANSPILER_CACHE_PATH - ?? join(hostTemp, "ocx-test-bun-transpiler-cache"), + // Cached JavaScript is executable input. Keep the default beneath the exclusively created + // private root; nested sandboxes and fixture children retain this path even when HOME moves. + // Independent runs start cold. A protected explicit cache can share across runs; "" and "0" + // still disable caching. Never adopt, repair or reclaim the old shared host-TEMP cache. + BUN_RUNTIME_TRANSPILER_CACHE_PATH: transpilerCache, HOME: root, USERPROFILE: root, OPENCODEX_HOME: opencodexHome, @@ -374,6 +372,10 @@ export const SERIAL_FULL_SUITE_FILES = [ "providers/cursor/cursor-native-exec-shell.test.ts", "codex-integration/issue-452-empty-503.test.ts", "adapters/openai/openai-provider-option-e2e.test.ts", + // Three Linux runs in a row timed out mid-file inside a 12-file batch while + // every case passed alone (37730984813, 37732846946, 37735238354, all test + // 1/4 batch 4): the same multi-file process-state class as the entries below. + "ci-workflows/ci-gui-typecheck-gate.test.ts", "ci-workflows/release-helper.test.ts", // The full macOS isolate pool stalled in the structure gate's synchronous Git // child after earlier files; fresh-process execution retains the same assertions. diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 177dd0ec9c3..49f7dcc5165 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -72,9 +72,16 @@ release that requires Windows proof still dispatches it for the exact publish SH Test sandboxes keep the runner's `LOCALAPPDATA`, so Windows PowerShell 5.1 children reuse the image's warm module-analysis cache. Replacing it with a freshly built seed made every child re-analyze modules and timed out seven shards on the first run of #6670. -The sandbox also pins `BUN_RUNTIME_TRANSPILER_CACHE_PATH` to one shared directory: Bun keeps that cache -under the home directory, so a sandboxed home made the first child of every batch or fixture -re-transpile each large module, 10-45 s on a busy Windows shard against 2-5 s warm. +`scripts/test.ts` pins the default `BUN_RUNTIME_TRANSPILER_CACHE_PATH` beneath its exclusively +created test root, in a `bun-transpiler-cache` directory (mode 0700 on POSIX). Cached JavaScript is +executable input, so the runner never adopts, repairs, migrates or deletes the old fixed host-TEMP +cache. Nested sandboxes and fixture children inherit the owning environment's cache even when +HOME changes; only that owner's cleanup removes it. Separate environments and CI batches start +with separate caches. This gives up automatic cross-batch reuse: cold Windows startup coverage +must be evaluated without raising test deadlines or weakening assertions. An explicit override, +including "" or "0" to disable caching, is preserved and remains the operator's protected-path +responsibility. `tests/ci-workflows/test-runner.test.ts` covers isolation, legacy-path refusal, +cleanup ownership, overrides and actual Bun children with different homes. Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` (`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: diff --git a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts index d462432510f..ef22cc92bb0 100644 --- a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts +++ b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts @@ -4,8 +4,14 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { repoPath } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; /** + * Every spawnSync below is bounded by the shared spawned-child deadline: Bun's test + * timeout cannot interrupt a synchronous wait, so an unbounded child that wedges on + * a lock or a runner stall pins its whole batch until the shard deadline cuts it + * (Linux test 1/4, run 37730984813). A bounded child fails this one test instead. + * * Local push validation ("bun run prepush") typechecks the root project only: * tsconfig.json has no project references, so "bun x tsc --noEmit" never parses * gui/. A gui compile error therefore ships silently and first surfaces in CI's @@ -33,6 +39,7 @@ describe("gui typecheck if-changed gate", () => { const dryRun = (files: string): string => { const probe = Bun.spawnSync([process.execPath, typecheckGuiIfChangedScript], { env: { ...process.env, TYPECHECK_DRY_RUN: "1", TYPECHECK_FILES: files }, + timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", }); return probe.stdout.toString().trim(); }; @@ -49,6 +56,7 @@ describe("gui typecheck if-changed gate", () => { TYPECHECK_FILES: "gui/src/App.tsx", TYPECHECK_CMD: stubCompiler, }, + timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", }); expect(failing.exitCode).toBe(23); @@ -59,6 +67,7 @@ describe("gui typecheck if-changed gate", () => { TYPECHECK_FILES: "gui/src/App.tsx", TYPECHECK_CMD: JSON.stringify(["ocx-gui-typecheck-missing-compiler"]), }, + timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", }); expect(missing.exitCode).toBe(1); @@ -68,6 +77,7 @@ describe("gui typecheck if-changed gate", () => { TYPECHECK_FILES: "scripts/x.ts\nREADME.md", TYPECHECK_CMD: stubCompiler, }, + timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", }); expect(skipping.exitCode).toBe(0); }); @@ -90,7 +100,7 @@ describe("gui typecheck if-changed gate", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-gui-typecheck-")); fixtures.push(dir); const git = (...args: string[]): void => { - const run = Bun.spawnSync(["git", ...args], { cwd: dir, env: gitEnv() }); + const run = Bun.spawnSync(["git", ...args], { cwd: dir, env: gitEnv(), timeout: INTERNAL_DEADLINE_MS }); if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString()}`); }; const commit = (path: string): void => { @@ -108,7 +118,7 @@ describe("gui typecheck if-changed gate", () => { const decide = (): string => { const env = { ...gitEnv(), TYPECHECK_DRY_RUN: "1" }; delete env.TYPECHECK_FILES; - const probe = Bun.spawnSync([process.execPath, join(dir, "scripts", "typecheck-gui-if-changed.ts")], { cwd: dir, env }); + const probe = Bun.spawnSync([process.execPath, join(dir, "scripts", "typecheck-gui-if-changed.ts")], { cwd: dir, env, timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL" }); return probe.stdout.toString().trim(); }; return { dir, git, commit, decide }; diff --git a/tests/ci-workflows/cold-spawn-warmup.test.ts b/tests/ci-workflows/cold-spawn-warmup.test.ts index 7a3cdb62024..af886c29f9f 100644 --- a/tests/ci-workflows/cold-spawn-warmup.test.ts +++ b/tests/ci-workflows/cold-spawn-warmup.test.ts @@ -55,10 +55,27 @@ type Disposition = WarmupDisposition; * `tests/helpers/cold-spawn-warmup.ts`. `warmed: false` needs a reason that survives review. */ const DISPOSITIONS: Readonly> = { + "tests/ci-workflows/ci-gui-typecheck-gate.test.ts": { + warmed: false, + why: + "Its children run scripts/typecheck-gui-if-changed.ts, which imports only node builtins, " + + "or git itself, so an import scan has no repository module graph to warm and a registered " + + "warm-up would fail closed. The spawnSync bounds exist because an unbounded child pinned " + + "Linux test 1/4 batch 4 until the 120s shard deadline cut it (run 37730984813).", + }, "tests/ci-workflows/test-runner.test.ts": { warmed: true, why: "one throwaway lane pays Bun's test-runner bootstrap before the captured-output lane is timed", }, + "tests/ci-workflows/release-version-sources.test.ts": { + warmed: false, + why: + "Its children run scripts/release-version-sources.ts, which imports only node builtins, " + + "or a bash -c guard fragment, so an import scan has no repository module graph to warm " + + "and a registered warm-up would fail closed. The spawnSync bounds exist because an " + + "unbounded child pinned Linux test 1/4 batch 6 until the 120s shard deadline cut it " + + "(run 37736425700).", + }, "tests/cli/cli-connect-readiness.test.ts": { warmed: true, why: "two graphs: the connect eval, and the observed ladder that also loads src/codex/runtime", diff --git a/tests/ci-workflows/release-version-sources.test.ts b/tests/ci-workflows/release-version-sources.test.ts index 36947fd84d1..8c8bad5768b 100644 --- a/tests/ci-workflows/release-version-sources.test.ts +++ b/tests/ci-workflows/release-version-sources.test.ts @@ -8,8 +8,14 @@ import { writeVersionSources, } from "../../scripts/release-version-sources"; import { repoPath, repoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; /** + * Every spawnSync below is bounded by the shared spawned-child deadline: Bun's test + * timeout cannot interrupt a synchronous wait, so an unbounded child that wedges on + * a lock or a runner stall pins its whole batch until the shard deadline cuts it + * (Linux test 1/4 batch 6, run 37736425700). A bounded child fails this one test instead. + * * The npm package and the desktop app read their version from different files. When only * package.json moved, dev carried 2.62.0 for npm while tauri.conf.json, Cargo.toml and the * opencodex-desktop Cargo.lock entry still said 2.61.0, so a release would have shipped an app @@ -39,7 +45,7 @@ function snapshot(root: string): Record { } function runCli(...args: string[]) { - const proc = Bun.spawnSync([process.execPath, CLI, ...args]); + const proc = Bun.spawnSync([process.execPath, CLI, ...args], { timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL" }); return { exitCode: proc.exitCode, stderr: new TextDecoder().decode(proc.stderr) }; } @@ -217,6 +223,7 @@ describe("every version move covers all four sources", () => { const verdict = (changed: string) => Bun.spawnSync( ["bash", "-c", 'set -euo pipefail\nbranch=codex/dev-version-9.9.9\nchanged_files="$1"\n' + guard + "\necho accepted", "guard", changed], + { timeout: INTERNAL_DEADLINE_MS }, ).exitCode; expect(verdict("package.json")).toBe(0); diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index db18e231f3b..fe6161f6daa 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -387,6 +388,154 @@ describe("test runner isolation", () => { ); }); +describe("test runner transpiler cache isolation", () => { + test("places the default executable cache inside its owned sandbox", () => { + const isolated = createIsolatedTestEnvironment({}); + try { + const cache = isolated.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH!; + expect(cache).toBe(join(isolated.root, "bun-transpiler-cache")); + const entry = lstatSync(cache); + expect(entry.isDirectory()).toBe(true); + expect(entry.isSymbolicLink()).toBe(false); + if (process.platform !== "win32") { + expect(entry.uid).toBe(process.geteuid!()); + expect(entry.mode & 0o777).toBe(0o700); + expect(lstatSync(isolated.root).mode & 0o777).toBe(0o700); + } + } finally { isolated.cleanup(); } + expect(existsSync(isolated.root)).toBe(false); + }); + + test("independent sandboxes neither reuse nor remove another default cache", () => { + const first = createIsolatedTestEnvironment({}); + const second = createIsolatedTestEnvironment({}); + try { + const firstCache = first.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH!; + const secondCache = second.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH!; + expect(firstCache).not.toBe(secondCache); + writeFileSync(join(firstCache, "fixture"), "first"); + writeFileSync(join(secondCache, "fixture"), "second"); + first.cleanup(); + expect(existsSync(firstCache)).toBe(false); + expect(readFileSync(join(secondCache, "fixture"), "utf8")).toBe("second"); + } finally { first.cleanup(); second.cleanup(); } + }); + + test("nested home isolation inherits the parent's cache and leaves its lifetime to the owner", () => { + const parent = createIsolatedTestEnvironment({}); + const child = createIsolatedTestEnvironment(parent.env); + try { + const cache = parent.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH!; + expect(child.root).not.toBe(parent.root); + expect(child.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH).toBe(cache); + writeFileSync(join(cache, "fixture"), "parent-owned"); + child.cleanup(); + expect(readFileSync(join(cache, "fixture"), "utf8")).toBe("parent-owned"); + } finally { child.cleanup(); parent.cleanup(); } + }); + + test("keeps an explicit cache path without creating or reclaiming it", () => { + const fixture = mkdtempSync(join(tmpdir(), "ocx-cache-override-")); + const override = join(fixture, "operator-selected"); + const isolated = createIsolatedTestEnvironment({ BUN_RUNTIME_TRANSPILER_CACHE_PATH: override }); + try { + expect(isolated.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH).toBe(override); + expect(existsSync(override)).toBe(false); + expect(existsSync(join(isolated.root, "bun-transpiler-cache"))).toBe(false); + mkdirSync(override); + writeFileSync(join(override, "fixture"), "operator-owned"); + isolated.cleanup(); + expect(readFileSync(join(override, "fixture"), "utf8")).toBe("operator-owned"); + } finally { isolated.cleanup(); removeTreeWithRetry(fixture); } + }); + + test.each(["", "0"])("preserves cache-disable value %j without allocating a default cache", value => { + const isolated = createIsolatedTestEnvironment({ BUN_RUNTIME_TRANSPILER_CACHE_PATH: value }); + try { + expect(isolated.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH).toBe(value); + expect(existsSync(join(isolated.root, "bun-transpiler-cache"))).toBe(false); + } finally { isolated.cleanup(); } + }); + + test.each(["directory", "symlink"])("does not adopt or alter a pre-existing shared cache %s", kind => { + const fixture = mkdtempSync(join(tmpdir(), "ocx-cache-legacy-")); + const legacy = join(fixture, "ocx-test-bun-transpiler-cache"); + const target = join(fixture, "foreign-cache"); + mkdirSync(target); + writeFileSync(join(target, "fixture"), "leave-unchanged"); + if (kind === "symlink") symlinkSync(target, legacy, process.platform === "win32" ? "junction" : "dir"); + else { mkdirSync(legacy); writeFileSync(join(legacy, "fixture"), "leave-unchanged"); } + const before = lstatSync(legacy); + const oldEnv = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, TEMP: process.env.TEMP }; + let isolated: ReturnType | undefined; + try { + process.env.TMPDIR = fixture; process.env.TMP = fixture; process.env.TEMP = fixture; + isolated = createIsolatedTestEnvironment({}); + expect(dirname(isolated.root)).toBe(fixture); + expect(isolated.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH).toBe(join(isolated.root, "bun-transpiler-cache")); + isolated.cleanup(); + expect(readFileSync(join(legacy, "fixture"), "utf8")).toBe("leave-unchanged"); + expect(lstatSync(legacy).ino).toBe(before.ino); + expect(lstatSync(legacy).mode).toBe(before.mode); + expect(lstatSync(legacy).isSymbolicLink()).toBe(kind === "symlink"); + } finally { + isolated?.cleanup(); + for (const [name, value] of Object.entries(oldEnv)) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + removeTreeWithRetry(fixture); + } + }); + + test("Bun children with different homes reuse only the selected private cache", () => { + const isolated = createIsolatedTestEnvironment({ ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: undefined }); + try { + const cache = isolated.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH!; + const source = join(isolated.root, "cacheable-fixture.ts"); + // Above the pinned Bun runtime's cache threshold; no production code or credential is loaded. + writeFileSync(source, `const value: string = ${JSON.stringify("x".repeat(70_000))}; console.log(value.length);`); + // A hit only reads; a miss for the identical input must add or rewrite an entry. Seal the + // first home's entries at a sentinel mtime, then the second home proves reuse by adding + // nothing, rewriting nothing, and leaving no cache anywhere else in the sandbox. + const sealedAt = new Date("2001-01-01T00:00:00Z"); + const sandboxPiles = () => readdirSync(isolated.root, { recursive: true }) + .map(String) + .filter(entry => entry.endsWith(".pile")) + .sort(); + let sealed: string[] | undefined; + for (const name of ["first-home", "second-home"]) { + const home = join(isolated.root, name); + mkdirSync(home); + const child = Bun.spawnSync([process.execPath, source], { + cwd: isolated.root, + env: { ...isolated.env, HOME: home, USERPROFILE: home }, + stdout: "pipe", stderr: "pipe", timeout: SPAWN_BUDGET_MS, + }); + expect(child.exitCode, new TextDecoder().decode(child.stderr)).toBe(0); + expect(new TextDecoder().decode(child.stdout).trim()).toBe("70000"); + expect(readdirSync(home)).toEqual([]); + const piles = sandboxPiles(); + if (sealed === undefined) { + expect(piles.length).toBeGreaterThan(0); + for (const entry of piles) { + const file = join(isolated.root, entry); + expect(pathIsContainedBy(cache, file, process.platform === "win32" ? "win32" : "posix")) + .toBe(true); + utimesSync(file, sealedAt, sealedAt); + } + sealed = piles; + } else { + expect(piles).toEqual(sealed); + for (const entry of sealed) { + expect(statSync(join(isolated.root, entry)).mtimeMs).toBe(sealedAt.getTime()); + } + } + } + expect(dirname(cache)).toBe(isolated.root); + } finally { isolated.cleanup(); } + }, { timeout: SPAWN_BUDGET_MS * 3 }); +}); + describe("Windows test TEMP recovery", () => { const age = (path: string, milliseconds: number) => { const date = new Date(milliseconds); diff --git a/tests/claude-integration/claude-cli-picker.test.ts b/tests/claude-integration/claude-cli-picker.test.ts index 68e6cdc1884..16f579050ed 100644 --- a/tests/claude-integration/claude-cli-picker.test.ts +++ b/tests/claude-integration/claude-cli-picker.test.ts @@ -1,6 +1,7 @@ // INV-CLIPICKER-01: cc catalog rows only for CLI-classified, CLI-first-party requests; registry-decodable aliases only; fail-open. import { afterAll, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { activeDesktop3pAlias, buildDesktop3pRegistry, resolveDesktop3pAlias } from "../../src/claude/desktop-3p"; @@ -31,7 +32,7 @@ afterAll(async () => { function tempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); - cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + cleanups.push(() => removeTreeWithRetry(dir)); return dir; } diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 78a4cb24973..963258f4fcc 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { buildClaudeEnv as buildClaudeEnvWithIo, buildNativeClaudeEnv, @@ -16,7 +17,7 @@ import { } from "../../src/cli/claude"; import { buildClaudeContextWindows } from "../../src/claude/context-windows"; import { commandInvocation } from "../../src/lib/win-exec"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { LivenessIo, LiveProxy } from "../../src/server/proxy-liveness"; @@ -231,7 +232,7 @@ describe("ocx claude native fallback", () => { expect(windows["ocx-claude-native--gpt-5.6-sol"]).toBe(272_000); expect(windows["claude-ocx-native--gpt-5.6-sol"]).toBe(272_000); } finally { - rmSync(dir, { recursive: true, force: true }); + removeTreeWithRetry(dir); } }); @@ -269,7 +270,7 @@ describe("ocx claude native fallback", () => { expect(warnings).toHaveLength(1); } finally { console.warn = realWarn; - rmSync(dir, { recursive: true, force: true }); + removeTreeWithRetry(dir); } }); }); diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index e56ae92b87d..6427cd4536f 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -52,6 +52,12 @@ async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig return { status: response!.status, body: await response!.json() as Record }; } +// Attach the response body to every status failure so a hosted-runner flake classifies +// itself (an environmental ca_unavailable, say, instead of a bare "Expected: 200 / Received: 500"). +function expectStatus(result: { status: number; body: unknown }, status: number): void { + expect(result.status, `body=${JSON.stringify(result.body)}`).toBe(status); +} + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); library = join(root, "desktop-library"); @@ -163,7 +169,7 @@ test("first-party apply refuses foreign proxy env and disabled intercept", () => test("POST /api/claude-desktop/apply defaults to gateway; first-party is explicit and carries the account-risk notice", async () => { const byDefault = await dispatch("/api/claude-desktop/apply", { method: "POST" }); - expect(byDefault.status).toBe(200); + expectStatus(byDefault, 200); expect(byDefault.body.riskWarning).toBeUndefined(); expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); const afterDefault = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -174,7 +180,7 @@ test("POST /api/claude-desktop/apply defaults to gateway; first-party is explici expect(gatewayStatus.body.riskWarning).toBeNull(); const first = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterDefault); - expect(first.status).toBe(200); + expectStatus(first, 200); expect(first.body).toMatchObject({ ok: true, mode: "first-party", @@ -202,7 +208,7 @@ test("POST /api/claude-desktop/apply defaults to gateway; first-party is explici expect(status.body.health.ok).toBe(true); const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, saved); - expect(gateway.status).toBe(200); + expectStatus(gateway, 200); expect(settings().env?.HTTPS_PROXY).toBeUndefined(); expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -211,7 +217,7 @@ test("POST /api/claude-desktop/apply defaults to gateway; first-party is explici // Switching back replaces the gateway profile with the first-party env in one apply. const back = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); - expect(back.status).toBe(200); + expectStatus(back, 200); expect(back.body).toMatchObject({ ok: true, mode: "first-party", applied: true, gatewayRemoved: true }); expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10200)); const afterBack = await dispatch("/api/claude-desktop/status", {}, afterGateway); @@ -230,7 +236,7 @@ test("POST /api/claude-desktop/apply defaults to gateway; first-party is explici test("native toggle: enable applies gateway by default and never writes first-party env", async () => { const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); - expect(enabled.status).toBe(200); + expectStatus(enabled, 200); expect(enabled.body).toMatchObject({ ok: true, state: "current", desiredEnabled: true }); expect(enabled.body.message).not.toContain("suspend"); expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); @@ -242,7 +248,7 @@ test("native toggle: explicit first-party enable warns about the account risk an const chosen = config({ claudeCode: { desktopMode: "first-party" } }); writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); - expect(enabled.status).toBe(200); + expectStatus(enabled, 200); expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); expect(enabled.body.message).toContain("suspend the account"); expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10200)); @@ -252,7 +258,7 @@ test("native toggle: explicit first-party enable warns about the account risk an expect(desktop?.state).toBe("current"); const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }, chosen); - expect(disabled.status).toBe(200); + expectStatus(disabled, 200); expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); expect(settings().env?.HTTPS_PROXY).toBeUndefined(); }); @@ -261,7 +267,7 @@ test("native first-party ON pins the committed mode on a stale live config", asy writeFileSync(join(root, "config.json"), JSON.stringify(config({ claudeCode: { desktopMode: "first-party" } }))); const live = config({ claudeCode: { desktopMode: "gateway" } }); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, live); - expect(enabled.status).toBe(200); + expectStatus(enabled, 200); expect(enabled.body).toMatchObject({ ok: true, state: "current", desiredEnabled: true }); expect(live.claudeCode?.desktopMode).toBe("first-party"); expect(firstPartyDesired(live).desktop).toBe(true); @@ -269,7 +275,7 @@ test("native first-party ON pins the committed mode on a stale live config", asy test("native toggle: enabling into explicit first-party pivots an applied gateway profile and saves the mode marker", async () => { const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); - expect(gateway.status).toBe(200); + expectStatus(gateway, 200); const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); // The operator chose first-party in config while the gateway profile is still on disk. @@ -277,7 +283,7 @@ test("native toggle: enabling into explicit first-party pivots an applied gatewa writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); - expect(enabled.status).toBe(200); + expectStatus(enabled, 200); expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10200)); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -294,7 +300,7 @@ test("native toggle: enabling into gateway saves the gateway mode marker like th const chosen = config({ claudeCode: { intercept: { enabled: false } } }); writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); - expect(enabled.status).toBe(200); + expectStatus(enabled, 200); expect(enabled.body).toMatchObject({ ok: true, state: "current", message: "Claude Desktop integration enabled." }); expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -327,7 +333,7 @@ test("first-party apply rebases the Claude hand-edit guard after its scoped mode armClaudeCodeBaseline(snapshot); const applied = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, snapshot); - expect(applied.status).toBe(200); + expectStatus(applied, 200); expect(applied.body).toMatchObject({ mode: "first-party", saved: true }); const handEdited = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -505,7 +511,7 @@ for (const surface of ["cli", "api"] as const) { for (const failure of ["intercept_disabled", "foreign_env", "unreadable", "ca_unavailable"] as const) { test(`${surface} failed first-party ${failure} leaves the gateway profile active`, async () => { const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); - expect(gateway.status).toBe(200); + expectStatus(gateway, 200); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; const appliedFingerprint = saved.claudeCode!.desktopProfile!.appliedFingerprint!; const before = inspectDesktop3pConfigLibrary({ appliedFingerprint }); @@ -543,7 +549,7 @@ test("CLI gateway apply refusal leaves the first-party connection intact", async }); test("a refused gateway cleanup restores the previous settings env", async () => { - expect((await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) })).status).toBe(200); + expectStatus(await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }), 200); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; mkdirSync(claudeDir, { recursive: true }); const before = { theme: "dark", env: { FOO: "keep" } }; @@ -551,14 +557,14 @@ test("a refused gateway cleanup restores the previous settings env", async () => const result = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, saved, { removeDesktop3pStandardPivot: () => ({ ok: false, changed: false, kind: "unsafe", libraryPath: library, reason: "metadata_unreadable" }), }); - expect(result.status).toBe(409); + expectStatus(result, 409); expect(result.body.code).toBe("claude_desktop_gateway_removal_failed"); expect(settings()).toEqual(before); expect(inspectDesktop3pConfigLibrary({ appliedFingerprint: saved.claudeCode!.desktopProfile!.appliedFingerprint }).kind).toBe("gateway_ours"); }); test("a completed standard pivot keeps first-party active when credential cleanup is incomplete", async () => { - expect((await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) })).status).toBe(200); + expectStatus(await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }), 200); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; const result = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, saved, { removeDesktop3pStandardPivot: options => { @@ -567,7 +573,7 @@ test("a completed standard pivot keeps first-party active when credential cleanu return { ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: library, residualPaths: ["fixture-residue"] }; }, }); - expect(result.status).toBe(500); + expectStatus(result, 500); expect(result.body.reason).toBe("cleanup_incomplete"); expect(inspectDesktopFirstParty(config()).applied).toBe(true); const after = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; @@ -593,7 +599,7 @@ for (const surface of ["api", "native", "cli"] as const) { const result = surface === "api" ? await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, initial) : await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, initial); - expect(result.status).toBe(500); + expectStatus(result, 500); if (surface === "api") expect(result.body).toMatchObject({ applied: true, saved: true, mode: "gateway" }); else expect(result.body.message).toContain("Gateway applied"); } @@ -641,9 +647,9 @@ test("a deleted token flips an applied env to stale and inspection does not recr test("the status routes never mint the proxy token", async () => { const status = await dispatch("/api/claude-desktop/status"); - expect(status.status).toBe(200); + expectStatus(status, 200); const list = await dispatch("/api/native-integrations"); - expect(list.status).toBe(200); + expectStatus(list, 200); expect(existsSync(claudeInterceptProxyTokenPath(root))).toBe(false); }); @@ -652,7 +658,7 @@ test("first-party apply and native enable refuse a changed configured port befor writeFileSync(join(root, "config.json"), JSON.stringify(live)); for (const [path, body] of [["/api/claude-desktop/apply", { mode: "first-party" }], ["/api/native-integrations/claude-desktop", { enabled: true }]] as const) { const result = await dispatch(path, { method: path.endsWith("apply") ? "POST" : "PUT", body: JSON.stringify(body) }, live); - expect(result.status).toBe(409); + expectStatus(result, 409); expect(result.body.code).toBe("port_mismatch"); expect(result.body.bound).toBe(10200); expect(result.body.configured).toBe(10300); diff --git a/tests/claude-integration/claude-intercept-on-demand.test.ts b/tests/claude-integration/claude-intercept-on-demand.test.ts index ab893307341..1566f804d7a 100644 --- a/tests/claude-integration/claude-intercept-on-demand.test.ts +++ b/tests/claude-integration/claude-intercept-on-demand.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createClaudeInterceptLifecycle } from "../../src/server/index/claude-intercept-lifecycle"; @@ -28,7 +29,7 @@ afterEach(async () => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = previousClaude; if (previousDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktop; - rmSync(root, { recursive: true, force: true }); + removeTreeWithRetry(root); }); function config(): OcxConfig { return { port: 10100, providers: { mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["test"], liveModels: false } }, defaultProvider: "mock", claudeCode: { enabled: false } } as OcxConfig; diff --git a/tests/claude-integration/claude-picker-ca-store.test.ts b/tests/claude-integration/claude-picker-ca-store.test.ts index 6a12ab894ab..0d113b19ff6 100644 --- a/tests/claude-integration/claude-picker-ca-store.test.ts +++ b/tests/claude-integration/claude-picker-ca-store.test.ts @@ -9,10 +9,11 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { discardPickerCaKey, ensurePickerCa, pickerCaCertPath, pickerCaFingerprints, readPendingPickerCaUntrust } from "../../src/claude/intercept/picker-ca"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const roots: string[] = []; const root = () => { const value = mkdtempSync(join(tmpdir(), "ocx-picker-store-")); roots.push(value); return value; }; -afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +afterEach(() => { for (const dir of roots.splice(0)) removeTreeWithRetry(dir); }); /** Keep non-link assertions active; only a native Windows file-link privilege gap is unavailable. */ function fileSymlink(target: string, path: string): boolean { try { symlinkSync(target, path, "file"); return true; } diff --git a/tests/claude-integration/claude-picker-recovery.test.ts b/tests/claude-integration/claude-picker-recovery.test.ts index 91d6c90333b..951ab1e9067 100644 --- a/tests/claude-integration/claude-picker-recovery.test.ts +++ b/tests/claude-integration/claude-picker-recovery.test.ts @@ -1,6 +1,7 @@ // INV-PICKER-02: the outgoing public picker CA survives process replacement until a confirmed untrust clears it. import { expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -100,7 +101,7 @@ test("a replacement process retries the recorded predecessor before rotating and expect(readPendingPickerCaUntrust(root)).toBeNull(); } finally { occupied.stop(true); - rmSync(root, { recursive: true, force: true }); + removeTreeWithRetry(root); } }); @@ -128,7 +129,7 @@ test("controller enable with pending cleanup does not request trust", async () = expect(calls).toEqual([]); expect(readPendingPickerCaUntrust(root)).not.toBeNull(); } finally { - rmSync(root, { recursive: true, force: true }); + removeTreeWithRetry(root); } }); @@ -145,6 +146,6 @@ test("replacement defers a pending certificate still published by a live owner", expect(readPendingPickerCaUntrust(root)).toEqual(pending); expect(pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha1).toBe(pending.sha1); } finally { - rmSync(root, { recursive: true, force: true }); + removeTreeWithRetry(root); } }); diff --git a/tests/cli/cli-ready-subprocess.test.ts b/tests/cli/cli-ready-subprocess.test.ts index 54a5416a62e..f2bbaeec858 100644 --- a/tests/cli/cli-ready-subprocess.test.ts +++ b/tests/cli/cli-ready-subprocess.test.ts @@ -5,11 +5,12 @@ * prove that the top-level CLI preserves terminal-failed and pre-parse behavior * with isolated homes and an actual discovered proxy fixture. */ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); @@ -70,6 +71,15 @@ function writeRuntimePort(opencodexHome: string, port: number, pid: number): voi } describe("ocx ready real subprocess", () => { + // src/cli/index.ts has roughly fifty top-level imports, so the runCli children below all pay + // the same static graph. This file's first spawned child is the one that loads it, and under + // the run's private transpiler cache that cold load lands inside the child's own kill bound + // (windows 9/9 run 37736425700 killed it at 10s before it reached /healthz). Pay it once in + // setup instead; see tests/helpers/cold-spawn-warmup.ts. + beforeAll(async () => { + await warmModuleGraph({ graph: "cli-index/ready-subprocess", entry: cliPath }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test("released-process protocol skew matrix rejects before any local write", async () => { const homes = isolatedHomes("ocx-protocol-skew-subprocess-"); const script = ` diff --git a/tests/codex-integration/catalog-remote-pull.test.ts b/tests/codex-integration/catalog-remote-pull.test.ts index 1082a4c6f46..7cedbfac6a2 100644 --- a/tests/codex-integration/catalog-remote-pull.test.ts +++ b/tests/codex-integration/catalog-remote-pull.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; import { @@ -13,6 +14,7 @@ import { } from "../../src/codex/catalog/remote"; import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; /** Every K acquisition states its intent (#6529); these tests exercise the lock, not the intent. */ @@ -51,7 +53,24 @@ async function withProxyEnv(env: Record, action: () => Promise { + // The --eval children load the catalog-remote graph in a fresh Bun process bounded by a + // 10s kill deadline; the cold module load can exceed it on a loaded Windows runner (run + // 37735238354, windows 3/9). Pay it once in setup, into the same transpiler cache the + // children inherit via BUN_RUNTIME_TRANSPILER_CACHE_PATH below. + beforeAll(async () => { + await warmModuleGraph({ graph: "catalog-remote-pull/transport-eval", source: REMOTE_TRANSPORT_EVAL_SCRIPT }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test("accepts HTTPS and loopback HTTP but rejects credentials and insecure remote HTTP", () => { expect(validateRemoteCatalogUrl("https://hub.example.com/v1/catalog").href).toBe("https://hub.example.com/v1/catalog"); expect(validateRemoteCatalogUrl("http://127.0.0.1:10100/v1/catalog").protocol).toBe("http:"); @@ -167,7 +186,7 @@ describe("remote catalog acquisition", () => { // Inherit process-launch necessities and test provenance only, never host credentials. const env: Record = {}; for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", - "OCX_TEST_HOME_GUARD", "OCX_TEST_RUN_ID"]) { + "OCX_TEST_HOME_GUARD", "OCX_TEST_RUN_ID", "BUN_RUNTIME_TRANSPILER_CACHE_PATH"]) { const value = process.env[key]; if (value !== undefined) env[key] = value; } @@ -179,7 +198,7 @@ describe("remote catalog acquisition", () => { writeFileSync(join(env.OPENCODEX_HOME, ".env"), "no_proxy=*\n"); env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`; env.NO_PROXY = bypass; - const source = new URL("../../src/codex/catalog/remote.ts", import.meta.url).href; + const source = remoteModule; const script = ` const { fetchRemoteCatalog } = await import(${JSON.stringify(source)}); try { diff --git a/tests/codex-integration/codex-shim-path-readiness.test.ts b/tests/codex-integration/codex-shim-path-readiness.test.ts index 4f1b87b198e..d9c4a5c0623 100644 --- a/tests/codex-integration/codex-shim-path-readiness.test.ts +++ b/tests/codex-integration/codex-shim-path-readiness.test.ts @@ -1,10 +1,12 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, describe, expect, mock, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { findFirstCodexOnPath } from "../../src/codex/shim-path-resolution"; import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "../../src/codex/shim-templates"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const roots: string[] = []; @@ -28,7 +30,29 @@ function fixture() { return { root, first, later, command, fallback, pathValue }; } +// Absolute filesystem paths, not file:// hrefs: the warm-up's import scan keeps specifiers +// that are absolute paths, and the spawned --eval children resolve either form identically. +const shimModule = fileURLToPath(new URL("../../src/codex/shim.ts", import.meta.url)); +const scannerModule = fileURLToPath(new URL("../../src/codex/shim-path-resolution.ts", import.meta.url)); +const readinessModule = fileURLToPath(new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url)); + +// The union of the repository module graphs this file's --eval children load; the other eval +// scripts below import a subset of the same three specifiers. +const READINESS_EVAL_SCRIPT = ` + const { diagnoseCodexShim } = await import(${JSON.stringify(shimModule)}); + const { findFirstCodexOnPath } = await import(${JSON.stringify(scannerModule)}); + const { inspectCodexShimForConnect } = await import(${JSON.stringify(readinessModule)}); + console.log(JSON.stringify({ diagnosis: diagnoseCodexShim(), command: findFirstCodexOnPath(), readiness: inspectCodexShimForConnect() })); +`; + describe("connect readiness PATH inspection", () => { + // The file's --eval children load the readiness graph under the run's private transpiler + // cache, so the first one's cold module load lands inside its own 3000ms bound. Pay it + // once in setup instead; see tests/helpers/cold-spawn-warmup.ts. + beforeAll(async () => { + await warmModuleGraph({ graph: "codex-shim-path-readiness/eval", source: READINESS_EVAL_SCRIPT }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test.skipIf(process.platform === "win32").each([ { kind: "ordinary file", shim: false, symlink: false, executable: false }, { kind: "shim file", shim: true, symlink: false, executable: false }, @@ -50,11 +74,9 @@ describe("connect readiness PATH inspection", () => { const expectedPath = executable ? f.command : f.fallback; const expectedShim = executable ? shim : !shim; expect(shell.stdout.trim()).toBe(expectedPath); - const scanner = new URL("../../src/codex/shim-path-resolution.ts", import.meta.url).href; - const readiness = new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url).href; const script = ` - const { findFirstCodexOnPath } = await import(${JSON.stringify(scanner)}); - const { inspectCodexShimForConnect } = await import(${JSON.stringify(readiness)}); + const { findFirstCodexOnPath } = await import(${JSON.stringify(scannerModule)}); + const { inspectCodexShimForConnect } = await import(${JSON.stringify(readinessModule)}); console.log(JSON.stringify({ candidate: findFirstCodexOnPath({ wsl: false }), result: inspectCodexShimForConnect({ @@ -116,16 +138,7 @@ describe("connect readiness PATH inspection", () => { expect(shell.error).toBeUndefined(); expect(shell.status).not.toBe(0); } - const shim = new URL("../../src/codex/shim.ts", import.meta.url).href; - const scanner = new URL("../../src/codex/shim-path-resolution.ts", import.meta.url).href; - const readiness = new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url).href; - const script = ` - const { diagnoseCodexShim } = await import(${JSON.stringify(shim)}); - const { findFirstCodexOnPath } = await import(${JSON.stringify(scanner)}); - const { inspectCodexShimForConnect } = await import(${JSON.stringify(readiness)}); - console.log(JSON.stringify({ diagnosis: diagnoseCodexShim(), command: findFirstCodexOnPath(), readiness: inspectCodexShimForConnect() })); - `; - const child = spawnSync(process.execPath, ["--eval", script], { env, encoding: "utf8", timeout: 3000, killSignal: "SIGKILL" }); + const child = spawnSync(process.execPath, ["--eval", READINESS_EVAL_SCRIPT], { env, encoding: "utf8", timeout: 3000, killSignal: "SIGKILL" }); expect(child.error).toBeUndefined(); expect(child.status).toBe(0); const output = JSON.parse(child.stdout); @@ -143,13 +156,11 @@ describe("connect readiness PATH inspection", () => { if (mode === "fifo") fs.renameSync(fifo, f.command); else if (mode === "symlink") fs.symlinkSync(fifo, f.command); else fs.writeFileSync(f.command, "ordinary launcher", { mode: 0o755 }); - const scanner = new URL("../../src/codex/shim-path-resolution.ts", import.meta.url).href; - const readiness = new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url).href; const script = ` import * as fs from "node:fs"; import { spyOn } from "bun:test"; - const { findFirstCodexOnPath } = await import(${JSON.stringify(scanner)}); - const { inspectCodexShimForConnect } = await import(${JSON.stringify(readiness)}); + const { findFirstCodexOnPath } = await import(${JSON.stringify(scannerModule)}); + const { inspectCodexShimForConnect } = await import(${JSON.stringify(readinessModule)}); const command = ${JSON.stringify(f.command)}; let replaced = false, rejectedDescriptor = false, closedRejectedDescriptor = false; if (${JSON.stringify(mode)} === "replacement") { diff --git a/tests/helpers/management-auth-fixture.ts b/tests/helpers/management-auth-fixture.ts new file mode 100644 index 00000000000..7fba14e6b16 --- /dev/null +++ b/tests/helpers/management-auth-fixture.ts @@ -0,0 +1,82 @@ +import { expect, spyOn } from "bun:test"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; + +export function remoteConfig(): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + disabled: true, + models: ["gpt-test"], + }, + }, + }; +} + +export function hubConfig(publicOrigin = "https://hub.example.test"): OcxConfig { + return { + ...remoteConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: publicOrigin }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, + corsAllowOrigins: ["https://dashboard.example.test"], + }; +} + +/** Keep real ingress/handlers while the kernel allocates both ports at the actual bind. */ +export async function startEphemeralHubServer(deps: Parameters[1]) { + const nativeServe = Bun.serve.bind(Bun); + const listeners: Array> = []; + const hostnames: unknown[] = []; + const serveSpy = spyOn(Bun, "serve").mockImplementation((options) => { + const listener = nativeServe({ ...options, port: 0 } as Parameters[0]); + listeners.push(listener); + hostnames.push("hostname" in options ? options.hostname : undefined); + return listener; + }); + try { + let server: ReturnType; + try { + server = startServer(0, deps); + } finally { + // startServer is synchronous; restore before requests or any awaited cleanup. + serveSpy.mockRestore(); + } + expect(listeners).toHaveLength(2); + expect(listeners[0]).toBe(server); + expect(hostnames).toEqual(["0.0.0.0", "127.0.0.1"]); + const managementPort = listeners[1]?.port; + if (!managementPort || managementPort === server.port) throw new Error("expected distinct live ingress ports"); + return { server, managementPort }; + } catch (error) { + await Promise.allSettled(listeners.map(async listener => { await listener.stop(true); })); + throw error; + } +} + +export function websocketHandshakeOpens(url: URL, token: string): Promise { + return new Promise(resolve => { + const target = new URL("/v1/responses", url); + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(target, { + headers: { "X-OpenCodex-API-Key": token }, + } as unknown as string[]); + let settled = false; + const finish = (opened: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.close(); } catch { /* already closed */ } + resolve(opened); + }; + socket.addEventListener("open", () => finish(true)); + socket.addEventListener("error", () => finish(false)); + socket.addEventListener("close", () => finish(false)); + const timer = setTimeout(() => finish(false), 5_000); + }); +} diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 63f4b721c02..512f1ed2771 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -145,7 +146,7 @@ function versionRunner(remote: { probeCode: number; probeStderr: string; code: n } afterEach(() => { - if (temp) rmSync(temp, { recursive: true, force: true }); + if (temp) removeTreeWithRetry(temp); temp = ""; }); diff --git a/tests/server/local-account-switch-ingress.test.ts b/tests/server/local-account-switch-ingress.test.ts index b82c37e8da1..0f15fb0fec3 100644 --- a/tests/server/local-account-switch-ingress.test.ts +++ b/tests/server/local-account-switch-ingress.test.ts @@ -1,7 +1,8 @@ // Moved out of server-management-auth.test.ts, which sits at the 2000-line ratchet threshold. // Proves the account-switch capability survives the real server ingress and reaches the handler. import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; @@ -53,7 +54,7 @@ afterEach(async () => { restore("OPENCODEX_HOME", previous.home); restore("OPENCODEX_API_AUTH_TOKEN", previous.dataToken); restore("OPENCODEX_ADMIN_AUTH_TOKEN", previous.adminToken); - if (testHome) rmSync(testHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + if (testHome) removeTreeWithRetry(testHome); testHome = ""; }); diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index 56125cbd192..af663eeae6a 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -1,6 +1,6 @@ import { warnPlaintextV2AgentMessagesStartup } from "../../src/server"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -16,6 +16,7 @@ import { clearResponseStateForTests, expandPreviousResponseInput } from "../../s import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; let releaseInheritedSpendHome: (() => void) | undefined; @@ -122,7 +123,7 @@ async function withPoolHome(run: () => Promise): Promise { clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountQuota(); - rmSync(home, { recursive: true, force: true }); + removeTreeWithRetry(home); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; diff --git a/tests/server/restart-replacement.test.ts b/tests/server/restart-replacement.test.ts index 9fca5e4e52d..578346de90b 100644 --- a/tests/server/restart-replacement.test.ts +++ b/tests/server/restart-replacement.test.ts @@ -9,8 +9,9 @@ * The scripted children print fixed lines, so what a real `ocx start` prints is not covered here. */ import { afterEach, describe, expect, test } from "bun:test"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { spawn, type ChildProcess } from "node:child_process"; -import { closeSync, constants, lstatSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs"; +import { closeSync, constants, lstatSync, mkdtempSync, openSync, readFileSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -33,7 +34,7 @@ afterEach(() => { for (const child of children.splice(0)) { try { child.kill("SIGKILL"); } catch { /* already gone */ } } - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs.splice(0)) removeTreeWithRetry(dir); }); function tempDir(): string { diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index cb392a5b667..32b7632046f 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -92,6 +92,13 @@ import { createGuiPairingGrant, } from "../../src/server/gui-session"; import { setSystemRestartIoForTests } from "../../src/server/management/system-restart"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { + hubConfig, + remoteConfig, + startEphemeralHubServer, + websocketHandshakeOpens, +} from "../helpers/management-auth-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const previousHome = process.env.OPENCODEX_HOME; @@ -105,85 +112,6 @@ function enableContextRelay(): void { resetContextRelayActivationForTests(); } -function remoteConfig(): OcxConfig { - return { - port: 0, - hostname: "0.0.0.0", - defaultProvider: "test", - providers: { - test: { - adapter: "openai-chat", - baseUrl: "https://example.test/v1", - disabled: true, - models: ["gpt-test"], - }, - }, - }; -} - -function hubConfig(publicOrigin = "https://hub.example.test"): OcxConfig { - return { - ...remoteConfig(), - runtimeRole: "hub", - hub: { managementPublicOrigin: publicOrigin }, - remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, - corsAllowOrigins: ["https://dashboard.example.test"], - }; -} - -/** Keep real ingress/handlers while the kernel allocates both ports at the actual bind. */ -async function startEphemeralHubServer(deps: Parameters[1]) { - const nativeServe = Bun.serve.bind(Bun); - const listeners: Array> = []; - const hostnames: unknown[] = []; - const serveSpy = spyOn(Bun, "serve").mockImplementation((options) => { - const listener = nativeServe({ ...options, port: 0 } as Parameters[0]); - listeners.push(listener); - hostnames.push("hostname" in options ? options.hostname : undefined); - return listener; - }); - try { - let server: ReturnType; - try { - server = startServer(0, deps); - } finally { - // startServer is synchronous; restore before requests or any awaited cleanup. - serveSpy.mockRestore(); - } - expect(listeners).toHaveLength(2); - expect(listeners[0]).toBe(server); - expect(hostnames).toEqual(["0.0.0.0", "127.0.0.1"]); - const managementPort = listeners[1]?.port; - if (!managementPort || managementPort === server.port) throw new Error("expected distinct live ingress ports"); - return { server, managementPort }; - } catch (error) { - await Promise.allSettled(listeners.map(async listener => { await listener.stop(true); })); - throw error; - } -} - -function websocketHandshakeOpens(url: URL, token: string): Promise { - return new Promise(resolve => { - const target = new URL("/v1/responses", url); - target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(target, { - headers: { "X-OpenCodex-API-Key": token }, - } as unknown as string[]); - let settled = false; - const finish = (opened: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); - try { socket.close(); } catch { /* already closed */ } - resolve(opened); - }; - socket.addEventListener("open", () => finish(true)); - socket.addEventListener("error", () => finish(false)); - socket.addEventListener("close", () => finish(false)); - const timer = setTimeout(() => finish(false), 5_000); - }); -} - beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-management-auth-")); process.env.OPENCODEX_HOME = testHome; @@ -201,6 +129,10 @@ afterEach(async () => { // Flush all homes before restoring environment variables, including startup // rollback flights that no successfully returned server could have awaited. await flushConfigDirHardeningForTests(); + // The process-wide routing-history index keeps its SQLite under this home open + // after stop; close it before the reaps barrier or the removal below meets a + // delete-pending handle for the whole retry budget. + closeRequestHistoryIndex(); // The caller-facing ACL timeout may settle before icacls actually exits. // Deletion waits for actual reaps, after every producer above has settled. await flushWindowsSecretAclReapsBeforeRemoval(testHome); diff --git a/tests/server/startup-health-packaged-probe.test.ts b/tests/server/startup-health-packaged-probe.test.ts index c3df5f4883f..a8b5e19d3a4 100644 --- a/tests/server/startup-health-packaged-probe.test.ts +++ b/tests/server/startup-health-packaged-probe.test.ts @@ -1,8 +1,9 @@ import { expect, test } from "bun:test"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { helperPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; // Exercise the default cache reader's real subprocess from a compiled executable. // A unit test of selfLaunchArgv alone misses a caller that still passes $bunfs source. @@ -36,6 +37,6 @@ test("packaged startup probe is fresh before and after replacing its bundled exe expect(health).toMatchObject({ status: "native", diagnosticStale: false, rebootSafe: true }); } } finally { - rmSync(scratch, { recursive: true, force: true }); + removeTreeWithRetry(scratch); } }, 120_000); diff --git a/tests/server/system-restart.test.ts b/tests/server/system-restart.test.ts index 1a7c67ff046..4de18fae2e5 100644 --- a/tests/server/system-restart.test.ts +++ b/tests/server/system-restart.test.ts @@ -2,7 +2,7 @@ * #563 — memory-card drain-and-restart acceptance + respawn policy. */ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { consumeSiblingHandoff } from "../../src/codex/sibling-handoff"; @@ -62,7 +62,7 @@ test("a sibling replacement environment carries a one-use handoff before restart removeRuntimePort(process.pid); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; - rmSync(home, { recursive: true, force: true }); + removeTreeWithRetry(home); } }); @@ -972,3 +972,4 @@ describe("POST /api/system/restart", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree";