Skip to content
Merged
4 changes: 3 additions & 1 deletion src/automate/lib/builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) });
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/doctor/__tests__/reclaim-analyzers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 30 additions & 1 deletion src/question/lib/decisions/store.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/question/lib/inbox/drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") };
Expand Down
4 changes: 4 additions & 0 deletions src/question/lib/inbox/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: [] };
}
Expand Down
7 changes: 6 additions & 1 deletion src/utils/claude/ClaudeSessionFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,7 +15,11 @@ type Row = Record<string, unknown>;
type FormatterOptions = ConstructorParameters<typeof ClaudeSessionFormatter>[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 {
Expand Down
8 changes: 8 additions & 0 deletions src/utils/process/open-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -153,6 +159,8 @@ describe("openFiles", () => {
).toEqual([7]);
} finally {
spawn.mockRestore();
lsof.mockRestore();
platform.mockRestore();
}
});

Expand Down
79 changes: 79 additions & 0 deletions src/utils/test/no-git.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Bun.spawnSync>;

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