Skip to content
This repository was archived by the owner on Jun 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/gateway/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,42 @@ export async function advanceTimersAndFlush(ms: number): Promise<void> {
vi.advanceTimersByTime(ms);
await vi.runAllTimersAsync();
}

// =============================================================================
// Spawn Polling Helpers
// =============================================================================

/**
* Schedule callback after spawn is called (non-blocking).
*
* This helper eliminates flaky tests caused by setTimeout with arbitrary delays.
* Instead of guessing when spawn will be called, it polls until spawn is actually
* called, then executes the callback.
*
* @example
* ```typescript
* const responsePromise = request(app).post("/stream").send({ prompt: "Hello" });
*
* afterSpawnCalled(mockSpawn, () => {
* mockProc.emit("close", 0, null);
* });
*
* const res = await responsePromise;
* ```
*/
export function afterSpawnCalled(
mockSpawn: ReturnType<typeof vi.fn>,
callback: () => void,
): void {
let iterations = 0;
const check = () => {
if (mockSpawn.mock.calls.length > 0) {
callback();
} else if (iterations++ < 1000) {
setTimeout(check, 1);
} else {
throw new Error("Timeout waiting for spawn to be called");
}
};
check();
}
36 changes: 20 additions & 16 deletions packages/gateway/__tests__/integration/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import { spawn } from "node:child_process";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createCliResultJson, createMockChildProcess } from "../helpers.js";
import {
afterSpawnCalled,
createCliResultJson,
createMockChildProcess,
} from "../helpers.js";

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

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

// Simulate successful claude --version check (50ms for CI reliability)
setTimeout(() => {
// Simulate successful claude --version check
afterSpawnCalled(mockSpawn, () => {
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(createCliResultJson({ result: "Hi!" })),
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(createCliResultJson({ result: "Response" })),
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(createCliResultJson({ result: '{"name": "Test"}' })),
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(createCliResultJson({ result: "Response" })),
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const res = await responsePromise;

Expand Down
36 changes: 14 additions & 22 deletions packages/gateway/__tests__/integration/sdk.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "vitest";
import { z } from "zod";
import {
afterSpawnCalled,
createCliResultJson,
createMockChildProcess,
createStreamAssistantMessage,
Expand Down Expand Up @@ -101,8 +102,8 @@ describe("SDK Integration Tests", () => {
prompt: "Hello",
});

// Simulate CLI response after a short delay
setTimeout(() => {
// Simulate CLI response after spawn is called
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(
Expand All @@ -115,7 +116,7 @@ describe("SDK Integration Tests", () => {
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const result = await promise;

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

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(createCliResultJson({ result: "Hi there!" })),
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const result = await promise;

Expand Down Expand Up @@ -180,7 +181,7 @@ describe("SDK Integration Tests", () => {
schema: personSchema,
});

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(
Expand All @@ -191,7 +192,7 @@ describe("SDK Integration Tests", () => {
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const result = await promise;

Expand Down Expand Up @@ -220,7 +221,7 @@ describe("SDK Integration Tests", () => {
schema: addressSchema,
});

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stdout.emit(
"data",
Buffer.from(
Expand All @@ -236,7 +237,7 @@ describe("SDK Integration Tests", () => {
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 50);
});

const result = await promise;

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

// Simulate streaming response using newline-delimited JSON (NDJSON)
// Each JSON object must end with a newline for the gateway's line parser
setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
// Text chunks (assistant messages with newlines)
mockProc.stdout.emit(
"data",
Buffer.from(`${createStreamAssistantMessage("One ")}\n`),
);
}, 30);

setTimeout(() => {
mockProc.stdout.emit(
"data",
Buffer.from(`${createStreamAssistantMessage("Two ")}\n`),
);
}, 60);

setTimeout(() => {
mockProc.stdout.emit(
"data",
Buffer.from(`${createStreamAssistantMessage("Three")}\n`),
);
}, 90);

setTimeout(() => {
// Result event (with newline)
mockProc.stdout.emit(
"data",
Expand All @@ -288,7 +280,7 @@ describe("SDK Integration Tests", () => {
);
mockProc.exitCode = 0;
mockProc.emit("close", 0, null);
}, 120);
});

const result = await promise;

Expand Down Expand Up @@ -343,11 +335,11 @@ describe("SDK Integration Tests", () => {
prompt: "test",
});

setTimeout(() => {
afterSpawnCalled(mockSpawn, () => {
mockProc.stderr.emit("data", Buffer.from("CLI error occurred"));
mockProc.exitCode = 1;
mockProc.emit("close", 1, null);
}, 50);
});

await expect(promise).rejects.toBeInstanceOf(KoineError);
});
Expand Down
Loading
Loading