diff --git a/src/automate/lib/builtins.test.ts b/src/automate/lib/builtins.test.ts index f7d3f9d397..90625b3953 100644 --- a/src/automate/lib/builtins.test.ts +++ b/src/automate/lib/builtins.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from "node:path"; import * as realPrompts from "@clack/prompts"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; +import { stripAnsi } from "@genesiscz/utils/string"; import { executeBuiltin } from "./builtins"; import { saveCredential } from "./credentials"; import type { RunLogger } from "./run-logger"; @@ -33,9 +34,10 @@ const spinnerStops: string[] = []; const metaCalls: string[] = []; let metaFails = false; +/** Records the text a reader sees: picocolors colors its output whenever `CI` is set, so CI and a local run differ only in escape codes. */ function logSink(level: string): (message: string) => void { return (message) => { - logLines.push({ level, message }); + logLines.push({ level, message: stripAnsi(message) }); }; } diff --git a/src/doctor/__tests__/reclaim-analyzers.test.ts b/src/doctor/__tests__/reclaim-analyzers.test.ts index e131fa1966..2e35782e81 100644 --- a/src/doctor/__tests__/reclaim-analyzers.test.ts +++ b/src/doctor/__tests__/reclaim-analyzers.test.ts @@ -919,6 +919,9 @@ describe("review fixes: app-bloat", () => { const old = join(versions, "2026.01.01-aaa"); file(join(old, "cursor-agent"), 4096); file(join(versions, "2026.10.01-bbb", "cursor-agent"), 4096); + // The analyzer keeps the newest folder by mtime. Two folders made in one test share an mtime on Linux, + // whose file times tick at the kernel clock, and then directory order alone picked the one kept. + touch(old, 30 * 86_400_000, Date.now()); const original = measurements.measurePath; const probe = spyOn(measurements, "measurePath").mockImplementation((path, budget) => path === old ? null : original(path, budget) diff --git a/src/question/lib/decisions/store.test.ts b/src/question/lib/decisions/store.test.ts index 134eee339b..db71652821 100644 --- a/src/question/lib/decisions/store.test.ts +++ b/src/question/lib/decisions/store.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { appendFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +17,7 @@ import { withTimeout } from "@genesiscz/utils/async"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { withFileLock } from "@genesiscz/utils/storage/file-lock"; +import { answerGitWithoutSpawning } from "@genesiscz/utils/test/no-git"; import { z } from "zod"; import { DeliveryUnknownError, deliverToSession, resolveDeliveryTarget } from "./deliver"; import { livePaneTargets, noPaneTargets } from "./deliver.fixtures"; @@ -47,6 +48,34 @@ import { updateTodo, } from "./store"; +// A decision post reads its checkout with eight `git` starts; see no-git.ts for the CI hang they caused. +const git = answerGitWithoutSpawning(); + +test("a decision post asks git only through the stub and starts no process", async () => { + const dir = mkdtempSync(join(tmpdir(), "decisions-no-spawn-")); + // Both start paths throw before a process exists, so a regression fails here instead of hanging on a lost exit. + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected Bun.spawn in a decision post"); + }); + const answered = git.answered; + const started = git.started.length; + git.refuseOthers = true; + + try { + await postDecisions(join(dir, "decisions.jsonl"), join(dir, "events.jsonl"), { + sessionId: "abc", + decisions: [{ prompt: "one", options: ["a"] }], + }); + + expect(git.answered).toBeGreaterThan(answered); + expect(git.started.slice(started)).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + } finally { + git.refuseOthers = false; + spawn.mockRestore(); + } +}); + /** A row another process wrote, appended while the test holds the decisions lock. */ function foreignRow(sessionId: string, number: number): DecisionRecord { return { diff --git a/src/question/lib/inbox/drafts.test.ts b/src/question/lib/inbox/drafts.test.ts index ff63297ff6..96a3317244 100644 --- a/src/question/lib/inbox/drafts.test.ts +++ b/src/question/lib/inbox/drafts.test.ts @@ -3,10 +3,14 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { answerGitWithoutSpawning } from "@genesiscz/utils/test/no-git"; import { deliverDecisions, parseDecisionBlocks } from "../decisions/read"; import { type DecisionRecord, postDecisions, readDecisions } from "../decisions/store"; import { dismissDecision, draftDecision, sendDrafts } from "./drafts"; +// A decision post reads its checkout with eight `git` starts; see no-git.ts for the CI hang they caused. +answerGitWithoutSpawning(); + function scratch(): { file: string; events: string } { const dir = mkdtempSync(join(tmpdir(), "gt-drafts-")); return { file: join(dir, "decisions.jsonl"), events: join(dir, "events.jsonl") }; diff --git a/src/question/lib/inbox/inbox.test.ts b/src/question/lib/inbox/inbox.test.ts index d9cf717d02..97195eed1f 100644 --- a/src/question/lib/inbox/inbox.test.ts +++ b/src/question/lib/inbox/inbox.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import type { AgentSessionRow } from "@app/ai/lib/sessions/agent-session-rows"; import type { TranscriptTurn } from "@genesiscz/utils/ai/transcripts/types"; import { SafeJSON } from "@genesiscz/utils/json"; +import { answerGitWithoutSpawning } from "@genesiscz/utils/test/no-git"; import { Command } from "commander"; import { type InboxCommandDeps, registerInboxCommand } from "../../commands/inbox"; import { paneMiss } from "../decisions/deliver"; @@ -18,6 +19,9 @@ import { buildInbox, type InboxSessionInfo, inboxDelivery, scanTurns, sessionDec import { locateRef } from "./excerpts"; import { type InboxDeps, loadInbox, loadSessionDecisions, lookupListing, lookupSessionCwd, waitingBlock } from "./load"; +// A decision post reads its checkout with eight `git` starts; see no-git.ts for the CI hang they caused. +answerGitWithoutSpawning(); + function turn(role: TranscriptTurn["role"], text: string, at = "2026-03-01T10:00:00.000Z"): TranscriptTurn { return { id: `${role}-${at}`, role, at, text, tools: [] }; } diff --git a/src/utils/claude/ClaudeSessionFormatter.test.ts b/src/utils/claude/ClaudeSessionFormatter.test.ts index bd35fd7fc5..04778da334 100644 --- a/src/utils/claude/ClaudeSessionFormatter.test.ts +++ b/src/utils/claude/ClaudeSessionFormatter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, spyOn } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { formatDateTime } from "@genesiscz/utils/date"; import { SafeJSON } from "@genesiscz/utils/json"; import { out } from "@genesiscz/utils/logger"; import { stripAnsi } from "@genesiscz/utils/string"; @@ -14,7 +15,11 @@ type Row = Record; type FormatterOptions = ConstructorParameters[0]; const TS = "2026-03-01T10:00:00.000Z"; -const CLOCK = /\d{2}:\d{2}:\d{2}/; +/** + * The clock text the formatter prints for TS. It follows the system locale and zone ("11:00:00" in Prague with a + * Czech locale, "10:00:00 AM" on the UTC runner, whose LANG=C.UTF-8 leaves Intl at en-US), so it is computed. + */ +const CLOCK = new RegExp(formatDateTime(TS, { absolute: "time-seconds" }).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); /** A record the way it arrives from a JSONL line. */ function rec(row: Row): ConversationMessage { diff --git a/src/utils/process/open-files.test.ts b/src/utils/process/open-files.test.ts index df92fc77b9..787c8024ea 100644 --- a/src/utils/process/open-files.test.ts +++ b/src/utils/process/open-files.test.ts @@ -120,6 +120,12 @@ describe("openFiles", () => { const root = mkdtempSync(join(tmpdir(), "open-files-")); const path = join(root, "file.txt"); writeFileSync(path, "x"); + // The lsof path, on any host: Linux reads /proc instead, and a box without lsof answers before the spawn. + const platform = spyOn(os, "processPlatform").mockReturnValue("darwin"); + const which = Bun.which; + const lsof = spyOn(Bun, "which").mockImplementation((name, options) => + name === "lsof" ? "/usr/sbin/lsof" : which(name, options) + ); const spawn = spyOn(Bun, "spawnSync"); try { @@ -153,6 +159,8 @@ describe("openFiles", () => { ).toEqual([7]); } finally { spawn.mockRestore(); + lsof.mockRestore(); + platform.mockRestore(); } }); diff --git a/src/utils/test/no-git.ts b/src/utils/test/no-git.ts new file mode 100644 index 0000000000..128e640e88 --- /dev/null +++ b/src/utils/test/no-git.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, spyOn } from "bun:test"; + +/** + * For the rest of the test file, every synchronous `git` start answers "not a git repository" (exit 128) + * without starting a process. Other programs still start for real. + * + * Bun 1.4.2 under `bun test --parallel` on Linux now and then loses a child's exit (oven-sh/bun#43697, open). + * The worker that hits it waits on that child, and every later test of the file runs to its 20 s timeout. A + * decision post reads the poster's checkout with eight `git` starts, about 400 in the decision store tests + * alone. On 2026-10-19 and 2026-10-22 six CI runs hung that way in the store, inbox or drafts tests, each first + * timeout inside a post, and the timeouts used up the job's budget, so the suite never finished. + * + * Outside a checkout the poster falls back to its cwd, which is what these tests need. The git reads have + * their own tests in `src/utils/agent/`. + */ +export interface GitWithoutSpawns { + /** How many `git` starts the stub answered. */ + readonly answered: number; + /** Every other command, which was started for real. */ + readonly started: readonly string[][]; + /** While true, every other command throws before it starts: a guard test fails at once instead of hanging. */ + refuseOthers: boolean; +} + +const NOT_A_REPOSITORY = { + exitCode: 128, + signalCode: undefined, + success: false, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + pid: 0, +} as unknown as ReturnType; + +function argvOf(first: unknown): string[] { + if (Array.isArray(first)) { + return first.map(String); + } + + if (first && typeof first === "object" && "cmd" in first && Array.isArray(first.cmd)) { + return first.cmd.map(String); + } + + return []; +} + +function isGit(argv: string[]): boolean { + const program = argv[0] ?? ""; + return program === "git" || program.endsWith("/git"); +} + +export function answerGitWithoutSpawning(): GitWithoutSpawns { + const state = { answered: 0, started: [] as string[][], refuseOthers: false }; + let restore: (() => void) | undefined; + + beforeAll(() => { + const real = Bun.spawnSync; + const spy = spyOn(Bun, "spawnSync").mockImplementation(((first: unknown, options?: unknown) => { + const argv = argvOf(first); + + if (isGit(argv)) { + state.answered++; + return NOT_A_REPOSITORY; + } + + state.started.push(argv); + + if (state.refuseOthers) { + throw new Error(`unexpected process start: ${argv.join(" ")}`); + } + + return Reflect.apply(real, Bun, options === undefined ? [first] : [first, options]); + }) as typeof Bun.spawnSync); + restore = () => spy.mockRestore(); + }); + + afterAll(() => restore?.()); + + return state; +}