From 5049550203fdf7104050ed8a489bee221b119c7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20McNamara?= Date: Sat, 7 Nov 2026 04:15:53 +0000 Subject: [PATCH] test: stabilize daemon fixtures under startup contention (#2628) Makes the daemon test fixtures hold on macOS paths and under startup contention, without weakening any assertion: readiness waits instead of fixed sleeps, temp-root resolution through the real path, a script-file shell for long commands, serialized mutations where tests raced, a long-path command, and the shared hermetic test setup. The refocus-overlap cell still asserts the lost pendingOn race, now through a deterministic handshake (the time budget only scales timeouts). The existing Linux CI job that covers /proc seat environments also runs pi-shell-launch-native.test.ts, and the Linux long-command session tokens stay within their limit. Test and setup only; no product code changes. Reported by seanmcn in #2627. --- .github/workflows/tests.yml | 4 +-- .../daemon/test/claude-member-token.test.ts | 9 +++-- packages/daemon/test/codex-seat-home.test.ts | 5 +-- .../daemon/test/fixture-home-cleanup.test.ts | 13 +++++++ .../test/pi-shell-launch-native.test.ts | 35 ++++++++++--------- .../daemon/test/probe-groups-on-stop.test.ts | 7 ++-- .../test/refocus-budget-concurrency.test.ts | 30 +++++++++++----- .../test/runner-conformance-check.test.ts | 3 +- .../runner-conformance-real-routes.test.ts | 18 ++++++---- .../test/seat-launch-environment.test.ts | 4 ++- test/hermetic-env.setup.ts | 17 +++++++-- 11 files changed, 100 insertions(+), 45 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22f92fc99..428a2a6e7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,7 +79,7 @@ jobs: linux-seat-environment: if: ${{ github.event_name != 'push' }} - # This integration test reads /proc//environ and is skipped on macOS. + # Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise. runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -108,7 +108,7 @@ jobs: ip link set lo up exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \ CI=true TERM=xterm-256color \ - npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose + npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose SUITE package-tests: diff --git a/packages/daemon/test/claude-member-token.test.ts b/packages/daemon/test/claude-member-token.test.ts index cc081020f..42a057bcc 100644 --- a/packages/daemon/test/claude-member-token.test.ts +++ b/packages/daemon/test/claude-member-token.test.ts @@ -142,14 +142,17 @@ describe("a Claude member's own subscription token (claude_token)", () => { const dir = tempDir(), tokenFile = path.join(dir, "dev.token"); writeFileSync(tokenFile, `${SYNTHETIC}\n`, { mode: 0o600 }); const step = claudeSeatTokenStep({ label: "dev-sub", tokenFile }, shellQuote); - const traced = spawnSync("/bin/sh", ["-xc", `${step} && /bin/sh -c 'printf %s "$CLAUDE_CODE_OAUTH_TOKEN"'; echo traced-after`], + const script = path.join(dir, "launch.sh"); + writeFileSync(script, `${step} && /bin/sh -c 'printf %s "$CLAUDE_CODE_OAUTH_TOKEN"'; echo traced-after`); + const traced = spawnSync("/bin/sh", ["-x", script], { encoding: "utf8", env: { PATH: "/usr/bin:/bin" } }); - expect(traced.status, traced.stderr).toBe(0); + expect(traced.status, JSON.stringify({ stderr: traced.stderr, signal: traced.signal, error: traced.error })).toBe(0); expect(traced.stdout).toBe(`${SYNTHETIC}traced-after\n`); expect(traced.stderr).not.toContain(SYNTHETIC); expect(traced.stderr).toContain("echo traced-after"); const missing = claudeSeatTokenStep({ label: "dev-sub", tokenFile: path.join(dir, "missing.token") }, shellQuote, { required: true }); - const failed = spawnSync("/bin/sh", ["-xc", `${missing}; echo "rc=$?"`], { encoding: "utf8", env: { PATH: "/usr/bin:/bin" } }); + writeFileSync(script, `${missing}; echo "rc=$?"`); + const failed = spawnSync("/bin/sh", ["-x", script], { encoding: "utf8", env: { PATH: "/usr/bin:/bin" } }); expect(failed.stdout).toBe("rc=1\n"); }); diff --git a/packages/daemon/test/codex-seat-home.test.ts b/packages/daemon/test/codex-seat-home.test.ts index 8e0936e43..802a5cf80 100644 --- a/packages/daemon/test/codex-seat-home.test.ts +++ b/packages/daemon/test/codex-seat-home.test.ts @@ -16,6 +16,7 @@ import { provenCodexResume } from "./helpers/codex-resume-proof.js"; const exec = promisify(execFile), roots: string[] = []; afterEach(() => { vi.restoreAllMocks(); for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); }); +/** Build a private launch environment whose scripted Codex reports its selected home. */ function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-seat-home-")); roots.push(root); const home = path.join(root, "home"), daemonHome = path.join(root, "daemon codex"), seatHome = path.join(root, "seat's codex"); @@ -33,7 +34,7 @@ function fixture() { const fsOps = { homedir: home, readFile: (p: string) => fs.readFileSync(p, "utf8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c), exists: fs.existsSync, mkdirp: (p: string) => { fs.mkdirSync(p, { recursive: true }); } }; const launchEnv = new (SeatLaunchEnvironment as any)(tmux, env, root, cli, daemonHome) as SeatLaunchEnvironment; - const run = (cmd: string) => exec("/bin/bash", ["--noprofile", "--norc", "-c", cmd], { cwd, env, timeout: 5000 }); + const run = (cmd: string) => exec("/bin/bash", ["--noprofile", "--norc", "-c", cmd], { cwd, env, timeout: 15000 }); const seatHomeOf = (nodeId: string) => nodeId === "node" ? seatHome : undefined; return { root, home, daemonHome, seatHome, cwd, env, tmux, fsOps, launchEnv, run, seatHomeOf }; } @@ -170,7 +171,7 @@ it("the legacy Codex resume launches in the seat's own home when one is passed, expect((await adapter.resume("seat@rig", "codex_id", "old", f.cwd)).ok).toBe(true); expect(await homeOf()).toBe(f.daemonHome); expect(ensured).toEqual([f.seatHome, undefined]); -}); +}, 35_000); it("context usage finds a thread's state in a declared seat home", () => { const f = fixture(); seed(f.seatHome, "seat-thread"); diff --git a/packages/daemon/test/fixture-home-cleanup.test.ts b/packages/daemon/test/fixture-home-cleanup.test.ts index 3669f1ff0..8ab79ffd9 100644 --- a/packages/daemon/test/fixture-home-cleanup.test.ts +++ b/packages/daemon/test/fixture-home-cleanup.test.ts @@ -10,6 +10,7 @@ import { createRequire } from "node:module"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +/** Run the real setup in a child and return its paths after exit-time cleanup. */ function runSetup(env: Record = {}) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fixture-home-run-")); roots.push(tmp); @@ -48,6 +49,18 @@ describe("fixture homes are removed when the worker exits", () => { expect(leftovers(tmp)).toEqual([]); }); + it.skipIf(process.platform !== "darwin").each([false, true])("handles an explicit macOS per-user TMPDIR (keep=%s)", keep => { + const systemTmp = spawnSync("getconf", ["DARWIN_USER_TEMP_DIR"], { encoding: "utf8" }); + expect(systemTmp.status, systemTmp.stderr).toBe(0); + const supplied = fs.mkdtempSync(path.join(fs.realpathSync(systemTmp.stdout.trim()), "fixture-override-")); + roots.push(supplied); + const { openrigHome, tmpdir, scratch } = runSetup({ TMPDIR: supplied, OPENRIG_TEST_KEEP_TMPDIR: keep ? "1" : undefined }); + expect(path.dirname(openrigHome)).toBe(keep ? supplied : fs.realpathSync("/tmp")); + expect(keep ? tmpdir : path.dirname(tmpdir)).toBe(keep ? supplied : fs.realpathSync("/tmp")); + expect(fs.existsSync(openrigHome)).toBe(false); + expect(leftovers(supplied)).toEqual(keep ? [path.basename(scratch)] : []); + }, 65_000); + it("removes the fixture home and the user home", () => { const { tmp, openrigHome, home } = runSetup(); expect(path.dirname(openrigHome)).toBe(tmp); diff --git a/packages/daemon/test/pi-shell-launch-native.test.ts b/packages/daemon/test/pi-shell-launch-native.test.ts index 63c0ab56e..108ffaecd 100644 --- a/packages/daemon/test/pi-shell-launch-native.test.ts +++ b/packages/daemon/test/pi-shell-launch-native.test.ts @@ -24,7 +24,7 @@ const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'"; // The runner entry is deliberately offline: this exercises the real adapter, // tmux, canonical macOS tty and shell, without starting Pi or touching provider settings. describe.skipIf(!hasTmux || process.platform === "win32")("Pi launch through native tty", () => { - it.each([...["fresh", "fork", "resume", "short-long-tmpdir", "fresh-long-tmpdir", "fork-long-tmpdir", "resume-long-tmpdir"].flatMap(mode => [ + it.each([...(process.platform === "linux" ? [{ mode: "fresh-linux-long", canonical: true }] : []), ...["fresh", "fork", "resume", "short-long-tmpdir", "fresh-long-tmpdir", "fork-long-tmpdir", "resume-long-tmpdir"].flatMap(mode => [ { mode, canonical: true }, { mode, canonical: false }, ]), ...["fresh-routing", "fork-routing", "resume-routing", "fork-routing-long-tmpdir", "resume-routing-long-tmpdir"].map(mode => ({ mode, canonical: false }))])("preserves $mode runner arguments (canonical reader: $canonical)", async ({ mode, canonical }) => { const launchMode = mode.split("-")[0]; @@ -33,9 +33,12 @@ describe.skipIf(!hasTmux || process.platform === "win32")("Pi launch through nat const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-shell-")); const socket = path.join(temp, "tmux.sock"); const session = "pi-fixture"; - const long = Array.from({ length: 8 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep); + const long = Array.from({ length: 14 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep); const stateRoot = path.join(temp, longTmp ? "short" : long, "state"); - const cwd = path.join(temp, longTmp ? "short" : long, "project with 'quotes'"); + // Keep session tokens below 1024 characters; Linux's oversized command comes from cwd instead. + const projectPath = mode === "fresh-linux-long" + ? Array.from({ length: 64 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep) : long; + const cwd = path.join(temp, longTmp ? "short" : projectPath, "project with 'quotes'"); fs.mkdirSync(cwd, { recursive: true }); const routeServer = routing ? createServer((req, res) => { res.end(JSON.stringify({ instance: "intended", node: req.headers["x-node"] })); @@ -74,7 +77,8 @@ setInterval(() => {}, 1000); const parent = path.join(stateRoot, session, "sessions", "parent.jsonl"); fs.mkdirSync(path.dirname(parent), { recursive: true }); fs.writeFileSync(parent, "fixture\n"); - const model = mode === "short-long-tmpdir" ? undefined : "provider/model-" + "m".repeat(longTmp ? 400 : 7000); + // Long paths exceed the terminal bound without an unrealistic multi-kilobyte model argument. + const model = mode === "short-long-tmpdir" ? undefined : "provider/model-" + "m".repeat(400); const expected = buildPiRunnerCommand({ runnerEntryPath: runner, sessionName: session, stateRoot, cwd, model, launchId: "attempt", trust: "no-approve", sessionFile: launchMode === "resume" ? parent : undefined, @@ -83,7 +87,7 @@ setInterval(() => {}, 1000); else if (longTmp) { expect(Buffer.byteLength(expected)).toBeGreaterThan(512); expect(Buffer.byteLength(expected)).toBeLessThan(1024); - } else expect(Buffer.byteLength(expected)).toBeGreaterThan(7000); + } else expect(Buffer.byteLength(expected)).toBeGreaterThan(mode === "fresh-linux-long" ? 4096 : 2000); const fsOps = { readFile: (p: string) => fs.readFileSync(p, "utf8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c), exists: fs.existsSync, mkdirp: (p: string) => { fs.mkdirSync(p, { recursive: true }); } }; @@ -130,7 +134,8 @@ setInterval(() => {}, 1000); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); } else { const adapter = new PiRuntimeAdapter({ tmux, fsOps, stateRoot, runnerEntryPath: runner, seatLaunchEnvironment, - sleep: () => new Promise(resolve => setTimeout(resolve, 25)), newLaunchId: () => "attempt" }); + // The adapter polls 60 times for readiness; 100 ms each gives a loaded runner ~6 s, like the resume path's 4 s. + sleep: () => new Promise(resolve => setTimeout(resolve, 100)), newLaunchId: () => "attempt" }); const result = await adapter.launchHarness({ tmuxSession: session, cwd, model, nodeId: "fixture-node", launchGeneration: "reserved-generation" } as never, { name: session, ...(launchMode === "fork" ? { forkSource: { kind: "native_id" as const, value: parent } } : {}) }); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); @@ -333,17 +338,18 @@ await main(); seatLaunchEnvironment, maxWaitMs: 6000, pollMs: 25, newLaunchId: () => "attempt", }); const result = await adapter.resume(session, "pi_session_file", parent, cwd, model, "floor", "high", wrapper); - expect(result).toMatchObject({ ok: true }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); } else { const adapter = new PiRuntimeAdapter({ tmux, fsOps, stateRoot, runnerEntryPath: runner, seatLaunchEnvironment, - sleep: () => new Promise(resolve => setTimeout(resolve, 25)), newLaunchId: () => "attempt", + // Match the resume fixture's six-second readiness allowance for wrapper and child startup. + sleep: () => new Promise(resolve => setTimeout(resolve, 100)), newLaunchId: () => "attempt", }); const result = await adapter.launchHarness( { tmuxSession: session, cwd, model, effort: "high", nodeId: "fixture-node", launchGeneration: "reserved-generation", launchWrapper: wrapper } as never, { name: session, ...(launchMode === "fork" ? { forkSource: { kind: "native_id" as const, value: parent } } : {}) }, ); - expect(result).toMatchObject({ ok: true }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); } expect(fs.existsSync(bareVersionMarker)).toBe(false); @@ -652,9 +658,8 @@ setInterval(() => {}, 1000); expect(res).toMatchObject({ ok: true }); } - for (let i = 0; i < 100 && !fs.existsSync(codexRecordPath); i++) { - await new Promise(resolve => setTimeout(resolve, 20)); - } + // The wrapper and its Node child can take more than two seconds to start under suite load. + await vi.waitFor(() => expect(fs.existsSync(codexRecordPath)).toBe(true), { timeout: 10_000, interval: 25 }); const wrapperRec = JSON.parse(fs.readFileSync(wrapperRecordPath, "utf8")); const codexRec = JSON.parse(fs.readFileSync(codexRecordPath, "utf8")); expect(wrapperRec.wrapperPid).toBe(codexRec.codexPid); @@ -671,14 +676,12 @@ setInterval(() => {}, 1000); expect(codexRec.generation).toBe(mode === "resume" ? "codex-gen-1" : "codex-gen-2"); process.kill(codexRec.codexPid, "SIGUSR2"); - for (let i = 0; i < 100 && !fs.existsSync(codexExitFile); i++) { - await new Promise(resolve => setTimeout(resolve, 20)); - } + await vi.waitFor(() => expect(fs.existsSync(codexExitFile)).toBe(true), { timeout: 10_000, interval: 25 }); expect(codexRec.signalHandlerReady).toBe(true); expect(JSON.parse(fs.readFileSync(codexExitFile, "utf8"))).toEqual({ code: 13, pid: codexRec.codexPid }); } finally { await run("tmux", ["-S", socket, "kill-server"]).catch(() => {}); fs.rmSync(temp, { recursive: true, force: true }); } - }, 10_000); + }, 30_000); }); diff --git a/packages/daemon/test/probe-groups-on-stop.test.ts b/packages/daemon/test/probe-groups-on-stop.test.ts index 171f41c92..e3e18e6ec 100644 --- a/packages/daemon/test/probe-groups-on-stop.test.ts +++ b/packages/daemon/test/probe-groups-on-stop.test.ts @@ -33,8 +33,9 @@ afterEach(async () => { function alive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } +/** Wait for a spawned probe to publish its PID and register it for cleanup. */ async function readPid(file: string): Promise { - for (let i = 0; i < 200; i++) { + for (let i = 0; i < 600; i++) { const text = fs.existsSync(file) ? fs.readFileSync(file, "utf-8").trim() : ""; if (text) { seenPids.add(Number(text)); return Number(text); } await new Promise((r) => setTimeout(r, 25)); @@ -103,7 +104,7 @@ describe.skipIf(process.platform === "win32")("runtime probe groups end when the } finally { process.env.PATH = savedPath; } - }); + }, 60_000); it("reports a probe the daemon's stop ended as cut short, not as a failed runtime", () => { expect(runtimeProbeFailure(new ProbeStoppedError("codex --version", "ended"))).toBe("check cut short: the daemon is stopping"); @@ -276,7 +277,7 @@ describe.skipIf(process.platform === "win32")("a daemon that stops during a runt const outcome = verifying.then(() => undefined, (err: unknown) => err); // Each probe writes its own marker once its trap is installed: stop only when both are up. const markers = () => fs.readdirSync(root).filter((name) => name.startsWith("ready.") && fs.readFileSync(path.join(root, name), "utf-8").trim()); - for (let i = 0; i < 400 && markers().length < 2; i++) await new Promise((r) => setTimeout(r, 25)); + for (let i = 0; i < 600 && markers().length < 2; i++) await new Promise((r) => setTimeout(r, 25)); expect(markers()).toHaveLength(2); for (const name of markers()) await readPid(path.join(root, name)); diff --git a/packages/daemon/test/refocus-budget-concurrency.test.ts b/packages/daemon/test/refocus-budget-concurrency.test.ts index 2e4443f72..63585e977 100644 --- a/packages/daemon/test/refocus-budget-concurrency.test.ts +++ b/packages/daemon/test/refocus-budget-concurrency.test.ts @@ -3,7 +3,7 @@ // Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub // `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a // harness overlaps these events natively is not established here. -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { spawn } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -25,6 +25,7 @@ afterEach(() => { interface Fixture { home: string; bin: string; log: string } +/** Build isolated hook state and controllable lookup scripts for timing and overlap cases. */ function fixture(): Fixture { root = mkdtempSync(join(tmpdir(), "refocus-budget-")); const home = join(root, "openrig"); @@ -45,6 +46,10 @@ case "$1 $2" in esac `); writeFileSync(join(bin, "trace"), `#!/bin/sh +[ -n "$STUB_TRACE_READY" ] && touch "$STUB_TRACE_READY" +if [ -n "$STUB_TRACE_RELEASE" ]; then + while [ ! -f "$STUB_TRACE_RELEASE" ]; do sleep 0.02; done +fi [ -n "$STUB_TRACE_PARTIAL" ] && echo "$STUB_TRACE_PARTIAL" ${sleep("STUB_TRACE_MS")} echo "TRACE OK: work and topology" @@ -145,10 +150,10 @@ describe("refocus: the time budget", () => { }, 20_000); it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async () => { - // Lookups each under their own 2 s cap (with room for a slow runner's process start-up) that together pass 5 s: on + // Lookups each under their own 2 s cap (with room for a slow runner's process start-up) that together pass the 4.5 s lookup budget: on // main's 5 s registration this was killed; now it completes as main's code does. const f = fixture(); - const slow = { STUB_WHOAMI_MS: "1700", STUB_TRACE_MS: "1700", STUB_CONTEXT_MS: "1700", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", + const slow = { STUB_WHOAMI_MS: "1550", STUB_TRACE_MS: "1550", STUB_CONTEXT_MS: "1550", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" }; await hook(f, "PostCompact", "s1"); const r = await hook(f, "UserPromptSubmit", "s1", slow); @@ -210,10 +215,19 @@ describe("refocus: overlapping events for one occupant (UNRESOLVED; characterize await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }); const file = join(f.home, "refocus", `${SEAT}__s1.json`); writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingOn: "Stop", pendingAt: "2026-10-18T17:00:00.000Z" })); - const delivering = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1500" }); - await wait(600); - await hook(f, "PostCompact", "s1"); - expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" }); + const ready = join(root!, "trace-ready"); + const release = join(root!, "trace-release"); + // Hold delivery after it has read the state; process start-up time cannot choose the interleaving. + const delivering = hook(f, "UserPromptSubmit", "s1", { + OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release, + }); + try { + await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 10000 }); + await hook(f, "PostCompact", "s1"); + expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" }); + } finally { + writeFileSync(release, ""); + } expect(context(await delivering)).toContain("REFOCUS"); // The delivery wrote back the state it read before the PostCompact: the compaction's pending is gone. expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined(); @@ -250,7 +264,7 @@ describe("refocus: state left by an earlier candidate", () => { writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingSeq: 3, delivering: { token: "left-behind", at: new Date().toISOString(), pendingSeq: 3 } })); writeFileSync(`${file}.lock`, "left-behind"); - const r = await hook(f, "UserPromptSubmit", "s1"); + const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000" }); expect(context(r)).toContain("REFOCUS (just compacted"); expect(stateOf(f, "s1")).toMatchObject({ firedOn: "UserPromptSubmit" }); }, 20_000); diff --git a/packages/daemon/test/runner-conformance-check.test.ts b/packages/daemon/test/runner-conformance-check.test.ts index ed50136e9..7c63a781c 100644 --- a/packages/daemon/test/runner-conformance-check.test.ts +++ b/packages/daemon/test/runner-conformance-check.test.ts @@ -72,7 +72,8 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => { }, 60_000); // Each break, the case that must catch it, and the check in that case that names it. - it.concurrent.each([ + // Each mutation launches many tmux sessions; overlap can fail startup before reaching the intended break. + it.each([ ["record-early", "1", "no launch record while session/new is still unanswered"], ["record-on-failure", "1b", "no conversation in a launch record"], ["split-lines", "2", "one pasted block is exactly one session/prompt"], diff --git a/packages/daemon/test/runner-conformance-real-routes.test.ts b/packages/daemon/test/runner-conformance-real-routes.test.ts index 7abd3f08e..aa6234fdf 100644 --- a/packages/daemon/test/runner-conformance-real-routes.test.ts +++ b/packages/daemon/test/runner-conformance-real-routes.test.ts @@ -107,6 +107,7 @@ async function daemon(options: { people: string[]; owner?: string }) { // The test double with the fake agent, given the environment a runner-preset launch gives it, on plain pipes. Input // arrives as `rig send` delivers it: one bracketed paste, then Enter. +/** Start a scripted runner and wait for its native reference before testing permission routes. */ async function launchRunner(d: Awaited>, scenario: object = { turns: [{ permission: true }, {}] }) { const dir = mkdtempSync(join(tmpdir(), "runner-real-routes-")); const log = join(dir, "agent.jsonl"), record = join(dir, "record.json"); @@ -128,7 +129,12 @@ async function launchRunner(d: Awaited>, scenario: obj rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); const entries = (): LogEntry[] => !existsSync(log) ? [] : readFileSync(log, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); - const opened = await waitFor(() => existsSync(record) && JSON.parse(readFileSync(record, "utf8")).nativeRef); + // Shell, runner and agent startup can be slower than the route checks under suite load. + const opened = await waitFor(() => { + if (!existsSync(record)) return undefined; + try { return JSON.parse(readFileSync(record, "utf8")).nativeRef; } + catch { return undefined; } // The runner may still be writing the record. + }, 15000); expect(opened, "the runner opens its conversation and writes the launch record").toBeTruthy(); return { send: (text: string) => child.stdin.write(`\x1b[200~${text}\x1b[201~\r`), @@ -159,7 +165,7 @@ describe("the conformance check's test double against OpenRig's real permission const idle = await waitFor(() => { const latest = d.latest(); return latest?.state === "idle" && latest; }); expect(idle).toMatchObject({ generationProvenance: "resolved", stale: false }); expect(r.answers()).toHaveLength(1); - }, 30_000); + }, 60_000); it("OpenRig's permit, with no one to ask, is delivered at once and opens no ask", async () => { const d = await daemon({ people: [] }); @@ -170,7 +176,7 @@ describe("the conformance check's test double against OpenRig's real permission expect(d.asks()).toEqual([]); expect(await waitFor(() => r.turnEnded())).toBe(true); expect(d.waitsOnPerson()).toBe(false); - }, 30_000); + }, 60_000); it("the seat's opt-in refusal (denied_by_seat) is refused at once and held as needs input", async () => { const d = await daemon({ people: ["mike"], owner: "deny" }); @@ -183,7 +189,7 @@ describe("the conformance check's test double against OpenRig's real permission expect(await waitFor(() => r.turnEnded())).toBe(true); await sleep(300); expect(d.waitsOnPerson(), "no idle post replaces the held attention").toBe(true); - }, 30_000); + }, 60_000); it("a person's no, when the agent offered no reject option, reaches the agent as cancelled, never a grant, holding nothing", async () => { const d = await daemon({ people: ["mike"] }); @@ -199,7 +205,7 @@ describe("the conformance check's test double against OpenRig's real permission // No person is still waiting, so the turn's end is reported and the seat doesn't read as needing one. expect(await waitFor(() => d.latest()?.state === "idle")).toBe(true); expect(d.waitsOnPerson()).toBe(false); - }, 30_000); + }, 60_000); it("an earlier occupant's request is refused at submit (stale_generation), so the runner answers cancelled, opens no ask, and its posts don't count", async () => { const d = await daemon({ people: ["mike"] }); @@ -213,5 +219,5 @@ describe("the conformance check's test double against OpenRig's real permission expect(d.asks()).toEqual([]); expect(await waitFor(() => r.turnEnded())).toBe(true); expect(await waitFor(() => d.latest()?.reason === "generation_mismatch" && d.latest())).toMatchObject({ state: "unknown" }); - }, 30_000); + }, 60_000); }); diff --git a/packages/daemon/test/seat-launch-environment.test.ts b/packages/daemon/test/seat-launch-environment.test.ts index 28c69f144..7d0feb98c 100644 --- a/packages/daemon/test/seat-launch-environment.test.ts +++ b/packages/daemon/test/seat-launch-environment.test.ts @@ -58,10 +58,12 @@ function fixture() { cleanup.push(() => new Promise((r, reject) => server.close(e => e ? reject(e) : r()))); env.OPENRIG_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`; } + /** Execute the launch envelope and verify the child received the fixture connection and environment. */ async function execute(command: string, managed = false) { expect(command).not.toContain(env.OPENRIG_ACTIVITY_HOOK_TOKEN); expect(command).not.toContain(env.ANTHROPIC_API_KEY); - const { stdout } = await exec("/bin/bash", ["--noprofile", "--rcfile", rc, "-ic", command], { env, cwd: binding.cwd, timeout: 5000 }); + // This starts a shell, a Node fixture and its CLI child before the local HTTP check. + const { stdout } = await exec("/bin/bash", ["--noprofile", "--rcfile", rc, "-ic", command], { env, cwd: binding.cwd, timeout: 15_000 }); const observed = JSON.parse(stdout); expect(observed).toMatchObject({ body: { node: "node-current" }, home: env.OPENRIG_HOME, node: "node-current", OPENRIG_DB: env.OPENRIG_DB, TMUX_TMPDIR: env.TMUX_TMPDIR }); if (!managed) expect(observed).toMatchObject({ generation: "successor-generation", HOME: "/user/home", CODEX_HOME: "/user/codex", CLAUDE_CONFIG_DIR: "/user/claude", USER_VALUE: "keep" }); diff --git a/test/hermetic-env.setup.ts b/test/hermetic-env.setup.ts index 7aff82a5e..1c53159db 100644 --- a/test/hermetic-env.setup.ts +++ b/test/hermetic-env.setup.ts @@ -71,6 +71,17 @@ if (process.env["TERM_PROGRAM"] === "rex") delete process.env["TERM_PROGRAM"]; // 1), which flakes any test reading one vs the other under suite ordering. So reuse a // fixture home already established in this worker. Named as a realistic `.openrig`-style // home (still fixture-scoped via the marker) so path-shape assertions hold against it. +// Where the fixture home and the worker TMPDIR go: the given temp folder with its symlinks resolved, so a path a +// test builds and the same path the product resolves are spelled alike. macOS's default is a per-user folder under +// /var/folders, ~57 characters once resolved, which leaves too little of the 104-byte Unix socket cap for the tmux +// and daemon sockets under it; in its place the base is /tmp, resolved (/private/tmp), as the CI job gets by running +// with a short resolved TMPDIR. This includes explicit overrides under /private/var/folders; set +// OPENRIG_TEST_KEEP_TMPDIR=1 to preserve those exactly. Other roots are kept. mkdtemp makes each folder 0700. +// A package that keeps its TMPDIR (OPENRIG_TEST_KEEP_TMPDIR=1) keeps it here too, unresolved. +const keepTmpdir = process.env["OPENRIG_TEST_KEEP_TMPDIR"] === "1"; +const givenTmp = fs.realpathSync(os.tmpdir()); +const tmpBase = keepTmpdir ? os.tmpdir() + : process.platform === "darwin" && givenTmp.startsWith("/private/var/folders/") ? fs.realpathSync("/tmp") : givenTmp; const existingHome = process.env["OPENRIG_HOME"]; let createdHome: string | undefined; let createdTmp: string | undefined; @@ -78,7 +89,7 @@ const fixtureHome = existingHome && fs.existsSync(path.join(existingHome, ".openrig-fixture")) ? existingHome : (() => { - const h = fs.mkdtempSync(path.join(os.tmpdir(), ".openrig-test-fixture-home-")); + const h = fs.mkdtempSync(path.join(tmpBase, ".openrig-test-fixture-home-")); fs.writeFileSync(path.join(h, ".openrig-fixture"), ""); createdHome = h; // The worker's own TMPDIR, so every os.tmpdir() mkdtemp a test makes lands in a directory removed with @@ -86,8 +97,8 @@ const fixtureHome = // and a Unix socket path is capped (104 bytes on macOS, where TMPDIR is already ~49 long). // A package whose tests are tied to the length of the temp path (the tui's Unix socket tests) opts out with // test.env OPENRIG_TEST_KEEP_TMPDIR=1 in its vitest config and keeps the TMPDIR it was given. - if (process.env["OPENRIG_TEST_KEEP_TMPDIR"] !== "1") { - createdTmp = fs.mkdtempSync(path.join(os.tmpdir(), "o-")); + if (!keepTmpdir) { + createdTmp = fs.mkdtempSync(path.join(tmpBase, "o-")); process.env["TMPDIR"] = createdTmp; } return h;