Skip to content

Commit bd2a7c6

Browse files
refactor(test): replace setTimeout with deterministic spawn polling (#63)
* refactor(test): extract afterSpawnCalled helper to helpers.ts Move the deterministic spawn polling helper from health.test.ts to the shared helpers.ts file so it can be reused across all test files. Part of #29 * refactor(test): use afterSpawnCalled in stream.test.ts Replace setTimeout patterns with deterministic spawn polling to eliminate flaky test timing issues. Part of #29 * refactor(test): use afterSpawnCalled in generate.test.ts Replace setTimeout patterns with deterministic spawn polling to eliminate flaky test timing issues. Part of #29 * refactor(test): use afterSpawnCalled in app.test.ts Replace setTimeout patterns with deterministic spawn polling to eliminate flaky test timing issues. Part of #29 * refactor(test): use afterSpawnCalled in sdk.integration.test.ts Replace setTimeout patterns with deterministic spawn polling to eliminate flaky test timing issues. Combined staggered streaming timeouts into a single afterSpawnCalled callback. Part of #29
1 parent a5cb3a5 commit bd2a7c6

File tree

6 files changed

+129
-110
lines changed

6 files changed

+129
-110
lines changed

packages/gateway/__tests__/helpers.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,42 @@ export async function advanceTimersAndFlush(ms: number): Promise<void> {
253253
vi.advanceTimersByTime(ms);
254254
await vi.runAllTimersAsync();
255255
}
256+
257+
// =============================================================================
258+
// Spawn Polling Helpers
259+
// =============================================================================
260+
261+
/**
262+
* Schedule callback after spawn is called (non-blocking).
263+
*
264+
* This helper eliminates flaky tests caused by setTimeout with arbitrary delays.
265+
* Instead of guessing when spawn will be called, it polls until spawn is actually
266+
* called, then executes the callback.
267+
*
268+
* @example
269+
* ```typescript
270+
* const responsePromise = request(app).post("/stream").send({ prompt: "Hello" });
271+
*
272+
* afterSpawnCalled(mockSpawn, () => {
273+
* mockProc.emit("close", 0, null);
274+
* });
275+
*
276+
* const res = await responsePromise;
277+
* ```
278+
*/
279+
export function afterSpawnCalled(
280+
mockSpawn: ReturnType<typeof vi.fn>,
281+
callback: () => void,
282+
): void {
283+
let iterations = 0;
284+
const check = () => {
285+
if (mockSpawn.mock.calls.length > 0) {
286+
callback();
287+
} else if (iterations++ < 1000) {
288+
setTimeout(check, 1);
289+
} else {
290+
throw new Error("Timeout waiting for spawn to be called");
291+
}
292+
};
293+
check();
294+
}

packages/gateway/__tests__/integration/app.test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
import { spawn } from "node:child_process";
99
import request from "supertest";
1010
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11-
import { createCliResultJson, createMockChildProcess } from "../helpers.js";
11+
import {
12+
afterSpawnCalled,
13+
createCliResultJson,
14+
createMockChildProcess,
15+
} from "../helpers.js";
1216

1317
// Set required environment variable BEFORE any imports
1418
// Using vi.hoisted to ensure this runs at the earliest possible time
@@ -46,11 +50,11 @@ describe("Claude Code Wrapper App (Integration)", () => {
4650

4751
const responsePromise = request(app).get("/health");
4852

49-
// Simulate successful claude --version check (50ms for CI reliability)
50-
setTimeout(() => {
53+
// Simulate successful claude --version check
54+
afterSpawnCalled(mockSpawn, () => {
5155
mockProc.exitCode = 0;
5256
mockProc.emit("close", 0, null);
53-
}, 50);
57+
});
5458

5559
const res = await responsePromise;
5660

@@ -96,14 +100,14 @@ describe("Claude Code Wrapper App (Integration)", () => {
96100
.set("Authorization", validAuthHeader)
97101
.send({ prompt: "Hello" });
98102

99-
setTimeout(() => {
103+
afterSpawnCalled(mockSpawn, () => {
100104
mockProc.stdout.emit(
101105
"data",
102106
Buffer.from(createCliResultJson({ result: "Hi!" })),
103107
);
104108
mockProc.exitCode = 0;
105109
mockProc.emit("close", 0, null);
106-
}, 50);
110+
});
107111

108112
const res = await responsePromise;
109113

@@ -128,10 +132,10 @@ describe("Claude Code Wrapper App (Integration)", () => {
128132

129133
const responsePromise = request(app).get("/health");
130134

131-
setTimeout(() => {
135+
afterSpawnCalled(mockSpawn, () => {
132136
mockProc.exitCode = 0;
133137
mockProc.emit("close", 0, null);
134-
}, 50);
138+
});
135139

136140
const res = await responsePromise;
137141

@@ -147,14 +151,14 @@ describe("Claude Code Wrapper App (Integration)", () => {
147151
.set("Authorization", validAuthHeader)
148152
.send({ prompt: "Hello" });
149153

150-
setTimeout(() => {
154+
afterSpawnCalled(mockSpawn, () => {
151155
mockProc.stdout.emit(
152156
"data",
153157
Buffer.from(createCliResultJson({ result: "Response" })),
154158
);
155159
mockProc.exitCode = 0;
156160
mockProc.emit("close", 0, null);
157-
}, 50);
161+
});
158162

159163
const res = await responsePromise;
160164

@@ -176,14 +180,14 @@ describe("Claude Code Wrapper App (Integration)", () => {
176180
schema: { type: "object", properties: { name: { type: "string" } } },
177181
});
178182

179-
setTimeout(() => {
183+
afterSpawnCalled(mockSpawn, () => {
180184
mockProc.stdout.emit(
181185
"data",
182186
Buffer.from(createCliResultJson({ result: '{"name": "Test"}' })),
183187
);
184188
mockProc.exitCode = 0;
185189
mockProc.emit("close", 0, null);
186-
}, 50);
190+
});
187191

188192
const res = await responsePromise;
189193

@@ -202,9 +206,9 @@ describe("Claude Code Wrapper App (Integration)", () => {
202206
.set("Authorization", validAuthHeader)
203207
.send({ prompt: "Hello" });
204208

205-
setTimeout(() => {
209+
afterSpawnCalled(mockSpawn, () => {
206210
mockProc.emit("close", 0, null);
207-
}, 50);
211+
});
208212

209213
const res = await responsePromise;
210214

@@ -224,14 +228,14 @@ describe("Claude Code Wrapper App (Integration)", () => {
224228
.set("Content-Type", "application/json")
225229
.send({ prompt: "Test prompt" });
226230

227-
setTimeout(() => {
231+
afterSpawnCalled(mockSpawn, () => {
228232
mockProc.stdout.emit(
229233
"data",
230234
Buffer.from(createCliResultJson({ result: "Response" })),
231235
);
232236
mockProc.exitCode = 0;
233237
mockProc.emit("close", 0, null);
234-
}, 50);
238+
});
235239

236240
const res = await responsePromise;
237241

packages/gateway/__tests__/integration/sdk.integration.test.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "vitest";
2222
import { z } from "zod";
2323
import {
24+
afterSpawnCalled,
2425
createCliResultJson,
2526
createMockChildProcess,
2627
createStreamAssistantMessage,
@@ -101,8 +102,8 @@ describe("SDK Integration Tests", () => {
101102
prompt: "Hello",
102103
});
103104

104-
// Simulate CLI response after a short delay
105-
setTimeout(() => {
105+
// Simulate CLI response after spawn is called
106+
afterSpawnCalled(mockSpawn, () => {
106107
mockProc.stdout.emit(
107108
"data",
108109
Buffer.from(
@@ -115,7 +116,7 @@ describe("SDK Integration Tests", () => {
115116
);
116117
mockProc.exitCode = 0;
117118
mockProc.emit("close", 0, null);
118-
}, 50);
119+
});
119120

120121
const result = await promise;
121122

@@ -135,14 +136,14 @@ describe("SDK Integration Tests", () => {
135136
prompt: "Hello",
136137
});
137138

138-
setTimeout(() => {
139+
afterSpawnCalled(mockSpawn, () => {
139140
mockProc.stdout.emit(
140141
"data",
141142
Buffer.from(createCliResultJson({ result: "Hi there!" })),
142143
);
143144
mockProc.exitCode = 0;
144145
mockProc.emit("close", 0, null);
145-
}, 50);
146+
});
146147

147148
const result = await promise;
148149

@@ -180,7 +181,7 @@ describe("SDK Integration Tests", () => {
180181
schema: personSchema,
181182
});
182183

183-
setTimeout(() => {
184+
afterSpawnCalled(mockSpawn, () => {
184185
mockProc.stdout.emit(
185186
"data",
186187
Buffer.from(
@@ -191,7 +192,7 @@ describe("SDK Integration Tests", () => {
191192
);
192193
mockProc.exitCode = 0;
193194
mockProc.emit("close", 0, null);
194-
}, 50);
195+
});
195196

196197
const result = await promise;
197198

@@ -220,7 +221,7 @@ describe("SDK Integration Tests", () => {
220221
schema: addressSchema,
221222
});
222223

223-
setTimeout(() => {
224+
afterSpawnCalled(mockSpawn, () => {
224225
mockProc.stdout.emit(
225226
"data",
226227
Buffer.from(
@@ -236,7 +237,7 @@ describe("SDK Integration Tests", () => {
236237
);
237238
mockProc.exitCode = 0;
238239
mockProc.emit("close", 0, null);
239-
}, 50);
240+
});
240241

241242
const result = await promise;
242243

@@ -256,29 +257,20 @@ describe("SDK Integration Tests", () => {
256257

257258
// Simulate streaming response using newline-delimited JSON (NDJSON)
258259
// Each JSON object must end with a newline for the gateway's line parser
259-
setTimeout(() => {
260+
afterSpawnCalled(mockSpawn, () => {
260261
// Text chunks (assistant messages with newlines)
261262
mockProc.stdout.emit(
262263
"data",
263264
Buffer.from(`${createStreamAssistantMessage("One ")}\n`),
264265
);
265-
}, 30);
266-
267-
setTimeout(() => {
268266
mockProc.stdout.emit(
269267
"data",
270268
Buffer.from(`${createStreamAssistantMessage("Two ")}\n`),
271269
);
272-
}, 60);
273-
274-
setTimeout(() => {
275270
mockProc.stdout.emit(
276271
"data",
277272
Buffer.from(`${createStreamAssistantMessage("Three")}\n`),
278273
);
279-
}, 90);
280-
281-
setTimeout(() => {
282274
// Result event (with newline)
283275
mockProc.stdout.emit(
284276
"data",
@@ -288,7 +280,7 @@ describe("SDK Integration Tests", () => {
288280
);
289281
mockProc.exitCode = 0;
290282
mockProc.emit("close", 0, null);
291-
}, 120);
283+
});
292284

293285
const result = await promise;
294286

@@ -343,11 +335,11 @@ describe("SDK Integration Tests", () => {
343335
prompt: "test",
344336
});
345337

346-
setTimeout(() => {
338+
afterSpawnCalled(mockSpawn, () => {
347339
mockProc.stderr.emit("data", Buffer.from("CLI error occurred"));
348340
mockProc.exitCode = 1;
349341
mockProc.emit("close", 1, null);
350-
}, 50);
342+
});
351343

352344
await expect(promise).rejects.toBeInstanceOf(KoineError);
353345
});

0 commit comments

Comments
 (0)