Skip to content

Commit 0b53789

Browse files
committed
test(testkit): share reactor stubs, signal spawn, and process waits
1 parent 4dd0ee0 commit 0b53789

18 files changed

Lines changed: 322 additions & 399 deletions

‎e2e/crash-finalize.test.ts‎

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test";
55

66
import { generateSessionId, sessionDir } from "../src/session/index.js";
77
import type { RunState } from "../src/session/state.js";
8-
import { createTempDirs } from "../testkit/temporary-dirs.js";
8+
import { spawnSignalFixture } from "./signal-helpers.js";
99

1010
const FIXTURE = join(
1111
import.meta.dirname,
@@ -18,28 +18,23 @@ const RUN_END_FIXTURE = join(
1818

1919
describe("integration — crash finalizes run.json", () => {
2020
test("uncaughtException writes status: crashed with finishedAt, racing in-flight snapshot writes", async () => {
21-
const { cwd, home, cleanup } = createTempDirs(
22-
"corbits-crash-cwd-",
23-
"corbits-crash-home-",
24-
);
2521
const sessionId = generateSessionId();
22+
const { proc, output, cleanup } = await spawnSignalFixture({
23+
fixture: FIXTURE,
24+
cwdPrefix: "corbits-crash-cwd-",
25+
homePrefix: "corbits-crash-home-",
26+
sessionId,
27+
sessionIdEnv: "CRASH_TEST_SESSION_ID",
28+
});
2629

2730
try {
28-
const proc = Bun.spawn(["bun", "run", FIXTURE], {
29-
cwd,
30-
env: { ...process.env, HOME: home, CRASH_TEST_SESSION_ID: sessionId },
31-
stdout: "pipe",
32-
stderr: "pipe",
33-
});
34-
3531
const exitCode = await proc.exited;
36-
const stdout = await new Response(proc.stdout).text();
3732
const stderr = await new Response(proc.stderr).text();
3833

3934
expect(exitCode).toBe(1);
4035
expect(stderr).toContain("uncaughtException: Error: simulated crash");
4136

42-
const runJsonPath = join(stdout.trim(), "run.json");
37+
const runJsonPath = join(output.trim(), "run.json");
4338
const raw = readFileSync(runJsonPath, "utf8");
4439
const state = JSON.parse(raw) as RunState;
4540

@@ -57,28 +52,19 @@ describe("integration — crash finalizes run.json", () => {
5752
}, 15_000);
5853

5954
test("a crash after session rotation still writes crashed for the new session", async () => {
60-
const { cwd, home, cleanup } = createTempDirs(
61-
"corbits-crash-cwd-",
62-
"corbits-crash-home-",
63-
);
6455
const sessionId = generateSessionId();
6556
const rotatedSessionId = generateSessionId();
57+
const { proc, cwd, home, output, cleanup } = await spawnSignalFixture({
58+
fixture: FIXTURE,
59+
cwdPrefix: "corbits-crash-cwd-",
60+
homePrefix: "corbits-crash-home-",
61+
sessionId,
62+
sessionIdEnv: "CRASH_TEST_SESSION_ID",
63+
extraEnv: { CRASH_TEST_ROTATED_SESSION_ID: rotatedSessionId },
64+
});
6665

6766
try {
68-
const proc = Bun.spawn(["bun", "run", FIXTURE], {
69-
cwd,
70-
env: {
71-
...process.env,
72-
HOME: home,
73-
CRASH_TEST_SESSION_ID: sessionId,
74-
CRASH_TEST_ROTATED_SESSION_ID: rotatedSessionId,
75-
},
76-
stdout: "pipe",
77-
stderr: "pipe",
78-
});
79-
8067
const exitCode = await proc.exited;
81-
const stdout = await new Response(proc.stdout).text();
8268
const stderr = await new Response(proc.stderr).text();
8369

8470
expect(exitCode).toBe(1);
@@ -95,7 +81,7 @@ describe("integration — crash finalizes run.json", () => {
9581
expect(outgoingState.status).toBe("done");
9682
expect(outgoingState.turnsUsed).toBe(3);
9783

98-
const rotatedRunJsonPath = join(stdout.trim(), "run.json");
84+
const rotatedRunJsonPath = join(output.trim(), "run.json");
9985
const rotatedState = JSON.parse(
10086
readFileSync(rotatedRunJsonPath, "utf8"),
10187
) as RunState;
@@ -112,22 +98,17 @@ describe("integration — crash finalizes run.json", () => {
11298
}, 15_000);
11399

114100
test("an unrelated crash while the run-end write is in flight does not report crashed", async () => {
115-
const { cwd, home, cleanup } = createTempDirs(
116-
"corbits-crash-cwd-",
117-
"corbits-crash-home-",
118-
);
119101
const sessionId = generateSessionId();
102+
const { proc, output, cleanup } = await spawnSignalFixture({
103+
fixture: RUN_END_FIXTURE,
104+
cwdPrefix: "corbits-crash-cwd-",
105+
homePrefix: "corbits-crash-home-",
106+
sessionId,
107+
sessionIdEnv: "RUN_END_TEST_SESSION_ID",
108+
});
120109

121110
try {
122-
const proc = Bun.spawn(["bun", "run", RUN_END_FIXTURE], {
123-
cwd,
124-
env: { ...process.env, HOME: home, RUN_END_TEST_SESSION_ID: sessionId },
125-
stdout: "pipe",
126-
stderr: "pipe",
127-
});
128-
129111
const exitCode = await proc.exited;
130-
const stdout = await new Response(proc.stdout).text();
131112
const stderr = await new Response(proc.stderr).text();
132113

133114
expect(exitCode).toBe(1);
@@ -139,7 +120,7 @@ describe("integration — crash finalizes run.json", () => {
139120
// saveState write resolved; the crash handler then saw a live run and
140121
// wrote "crashed" via saveCrashState, clobbering the clean finish.
141122
// Clearing the handle before the await closes that window.
142-
const runJsonPath = join(stdout.trim(), "run.json");
123+
const runJsonPath = join(output.trim(), "run.json");
143124
const raw = readFileSync(runJsonPath, "utf8");
144125
const state = JSON.parse(raw) as RunState;
145126
expect(state.status).not.toBe("crashed");

‎e2e/exec-shutdown-reap.test.ts‎

Lines changed: 22 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,18 @@
11
import { spawnSync } from "node:child_process";
2-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3-
import { tmpdir } from "node:os";
2+
import { readFileSync } from "node:fs";
43
import { join } from "node:path";
54
import { randomUUID } from "node:crypto";
65

76
import { describe, expect, test } from "bun:test";
87

8+
import { waitUntilGone } from "../testkit/process-tag.js";
9+
import { spawnSignalFixture } from "./signal-helpers.js";
10+
911
const FIXTURE = join(
1012
import.meta.dirname,
1113
"../fixtures/exec-shutdown-reap/simulate-reap.ts",
1214
);
1315

14-
async function readLine(stream: ReadableStream<Uint8Array>): Promise<string> {
15-
const reader = stream.getReader();
16-
const decoder = new TextDecoder();
17-
let buffer = "";
18-
while (!buffer.includes("\n")) {
19-
const { value, done } = await reader.read();
20-
if (done) break;
21-
buffer += decoder.decode(value, { stream: true });
22-
}
23-
reader.releaseLock();
24-
return buffer;
25-
}
26-
27-
async function waitUntilGone(token: string): Promise<string> {
28-
const started = Date.now();
29-
let stdout = "";
30-
while (Date.now() - started < 5_000) {
31-
const probe = spawnSync("pgrep", ["-f", token], { encoding: "utf8" });
32-
stdout = probe.stdout?.trim() ?? "";
33-
if (stdout.length === 0) return stdout;
34-
await new Promise((resolve) => setTimeout(resolve, 50));
35-
}
36-
return stdout;
37-
}
38-
3916
describe.skipIf(process.platform === "win32")(
4017
"integration — exec shutdown reaps shell-guard children",
4118
() => {
@@ -48,32 +25,28 @@ describe.skipIf(process.platform === "win32")(
4825
] as const)(
4926
"%s %s reaps the tagged child and disposes once",
5027
async (path, signal, expectedExitCode) => {
51-
const home = mkdtempSync(join(tmpdir(), "corbits-exec-reap-home-"));
5228
const token = `ic_reap_${randomUUID()}`;
53-
const countPath = join(home, "dispose-count");
54-
55-
try {
56-
const proc = Bun.spawn(["bun", "run", FIXTURE], {
57-
cwd: home,
58-
env: {
59-
...process.env,
60-
HOME: home,
29+
let countPath: string | undefined;
30+
const { proc, output, cleanup } = await spawnSignalFixture({
31+
fixture: FIXTURE,
32+
cwdPrefix: "corbits-exec-reap-cwd-",
33+
homePrefix: "corbits-exec-reap-home-",
34+
extraEnv: ({ home }) => {
35+
countPath = join(home, "dispose-count");
36+
return {
6137
REAP_TOKEN: token,
6238
REAP_PATH: path,
6339
REAP_COUNT_PATH: countPath,
64-
},
65-
stdout: "pipe",
66-
stderr: "pipe",
67-
});
40+
};
41+
},
42+
});
6843

69-
const ready = await readLine(proc.stdout);
70-
if (!ready.includes("READY")) {
71-
const errText = await new Response(proc.stderr).text();
72-
throw new Error(
73-
`fixture did not report READY: ${JSON.stringify(ready)} stderr=${errText}`,
74-
);
75-
}
44+
const resolvedCountPath = countPath;
45+
if (resolvedCountPath === undefined)
46+
throw new Error("fixture env not resolved");
7647

48+
try {
49+
expect(output).toContain("READY");
7750
if (
7851
signal === "SIGINT" ||
7952
signal === "SIGTERM" ||
@@ -86,10 +59,10 @@ describe.skipIf(process.platform === "win32")(
8659

8760
const leftover = await waitUntilGone(token);
8861
expect(leftover).toBe("");
89-
expect(readFileSync(countPath, "utf8").trim()).toBe("1");
62+
expect(readFileSync(resolvedCountPath, "utf8").trim()).toBe("1");
9063
} finally {
9164
spawnSync("pkill", ["-9", "-f", token]);
92-
rmSync(home, { recursive: true, force: true });
65+
cleanup();
9366
}
9467
},
9568
15_000,

‎e2e/signal-helpers.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ function spawnFixtureProc(
2525
fixture: string,
2626
cwd: string,
2727
home: string,
28-
sessionId: string,
28+
sessionEnv: Record<string, string>,
2929
) {
3030
return Bun.spawn(["bun", "run", fixture], {
3131
cwd,
3232
env: {
3333
...process.env,
3434
HOME: home,
35-
SIGNAL_TEST_SESSION_ID: sessionId,
35+
...sessionEnv,
3636
},
3737
stdout: "pipe",
3838
stderr: "pipe",
@@ -54,7 +54,20 @@ export interface SpawnSignalFixtureOptions {
5454
readonly fixture: string;
5555
readonly cwdPrefix: string;
5656
readonly homePrefix: string;
57-
readonly sessionId: string;
57+
/** Optional session id, handed to the fixture child in `sessionIdEnv`. */
58+
readonly sessionId?: string;
59+
/** Env var name carrying `sessionId` (default `SIGNAL_TEST_SESSION_ID`). */
60+
readonly sessionIdEnv?: string;
61+
/**
62+
* Extra child env for fixture variants (rotation ids, reap tokens). As a
63+
* function it receives the spawned cwd/home so files can live inside the
64+
* isolated pair; HOME is already set to `home`.
65+
*/
66+
readonly extraEnv?:
67+
| Record<string, string>
68+
| ((dirs: { readonly cwd: string; readonly home: string }) => {
69+
readonly [key: string]: string;
70+
});
5871
}
5972

6073
/**
@@ -67,12 +80,20 @@ export async function spawnSignalFixture(
6780
options: SpawnSignalFixtureOptions,
6881
): Promise<SpawnedSignalFixture> {
6982
const dirs = createTempDirs(options.cwdPrefix, options.homePrefix);
70-
const proc = spawnFixtureProc(
71-
options.fixture,
72-
dirs.cwd,
73-
dirs.home,
74-
options.sessionId,
75-
);
83+
const extra =
84+
typeof options.extraEnv === "function"
85+
? options.extraEnv({ cwd: dirs.cwd, home: dirs.home })
86+
: options.extraEnv;
87+
const sessionEnv =
88+
options.sessionId === undefined
89+
? {}
90+
: {
91+
[options.sessionIdEnv ?? "SIGNAL_TEST_SESSION_ID"]: options.sessionId,
92+
};
93+
const proc = spawnFixtureProc(options.fixture, dirs.cwd, dirs.home, {
94+
...sessionEnv,
95+
...extra,
96+
});
7697
const output = await readLine(proc.stdout);
7798
return {
7899
proc,

‎fixtures/exec-shutdown-reap/simulate-reap.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime";
99
import { disposeExecRuntime } from "../../src/exec/dispose.js";
1010
import { shellGuardPlugin } from "../../src/plugins/shell-guard-plugin.js";
1111
import { setActiveDisposeHost } from "../../src/session/active-host.js";
12+
import { waitUntilTagged } from "../../testkit/process-tag.js";
1213

1314
const token = process.env["REAP_TOKEN"];
1415
const path = process.env["REAP_PATH"];
@@ -79,17 +80,7 @@ void handler(
7980
new AbortController().signal,
8081
);
8182

82-
async function waitForTag(): Promise<void> {
83-
const started = Date.now();
84-
while (Date.now() - started < 5_000) {
85-
const probe = spawnSync("pgrep", ["-f", reapToken], { encoding: "utf8" });
86-
if ((probe.stdout?.trim() ?? "").length > 0) return;
87-
await new Promise((resolve) => setTimeout(resolve, 50));
88-
}
89-
throw new Error(`tagged child did not appear: ${reapToken}`);
90-
}
91-
92-
await waitForTag();
83+
await waitUntilTagged(reapToken);
9384
setActiveDisposeHost(host);
9485
writeFileSync(disposeCountPath, "0");
9586
process.stdout.write("READY\n");

0 commit comments

Comments
 (0)