Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions scripts/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions structure/ops/cross-platform-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions tests/ci-workflows/ci-gui-typecheck-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
};
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);
});
Expand All @@ -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 => {
Expand All @@ -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 };
Expand Down
17 changes: 17 additions & 0 deletions tests/ci-workflows/cold-spawn-warmup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,27 @@ type Disposition = WarmupDisposition;
* `tests/helpers/cold-spawn-warmup.ts`. `warmed: false` needs a reason that survives review.
*/
const DISPOSITIONS: Readonly<Record<string, Disposition>> = {
"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",
Expand Down
9 changes: 8 additions & 1 deletion tests/ci-workflows/release-version-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,7 +45,7 @@ function snapshot(root: string): Record<string, string> {
}

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) };
}

Expand Down Expand Up @@ -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);
Expand Down
149 changes: 149 additions & 0 deletions tests/ci-workflows/test-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof createIsolatedTestEnvironment> | 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);
Expand Down
5 changes: 3 additions & 2 deletions tests/claude-integration/claude-cli-picker.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading