Skip to content

Commit 6aac6fe

Browse files
committed
Add test coverage for error paths, edge cases, CLI, and concurrency
34 new tests across 7 files covering: - readBody failure and handleCompletions catch handler (server) - CLI argument validation, help, fixture loading, and shutdown - nextRequestError stacking, stop() error, reset() idempotency - buildTextChunks/buildToolCallChunks edge cases (empty, unicode, short) - writeSSEStream mid-stream write failure - fixture-loader fs error paths (EACCES, ENOENT) - onToolResult integration, large payload (50KB) streaming - concurrent request handling (10 parallel) - header forwarding verification in journal
1 parent bc047da commit 6aac6fe

7 files changed

Lines changed: 919 additions & 0 deletions

File tree

src/__tests__/cli.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { execFile, type ChildProcess } from "node:child_process";
3+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join, resolve } from "node:path";
6+
7+
const CLI_PATH = resolve(__dirname, "../../dist/cli.js");
8+
9+
/** Spawn the CLI and collect stdout/stderr/exit code. */
10+
function runCli(
11+
args: string[],
12+
opts: { timeout?: number } = {},
13+
): Promise<{ stdout: string; stderr: string; code: number | null }> {
14+
const timeout = opts.timeout ?? 5000;
15+
return new Promise((res) => {
16+
const cp = execFile("node", [CLI_PATH, ...args], { timeout }, (err, stdout, stderr) => {
17+
const code = cp.exitCode ?? (err && "code" in err ? (err as { code: number }).code : null);
18+
res({ stdout, stderr, code });
19+
});
20+
});
21+
}
22+
23+
/**
24+
* Spawn the CLI expecting a long-running server. Returns the child
25+
* process plus helpers to read accumulated output and send signals.
26+
*/
27+
function spawnCli(args: string[]): {
28+
cp: ChildProcess;
29+
stdout: () => string;
30+
stderr: () => string;
31+
kill: (signal?: NodeJS.Signals) => void;
32+
waitForOutput: (match: RegExp, timeoutMs?: number) => Promise<void>;
33+
} {
34+
let out = "";
35+
let err = "";
36+
const cp = execFile("node", [CLI_PATH, ...args]);
37+
cp.stdout?.on("data", (d) => {
38+
out += d;
39+
});
40+
cp.stderr?.on("data", (d) => {
41+
err += d;
42+
});
43+
44+
const waitForOutput = (match: RegExp, timeoutMs = 5000): Promise<void> =>
45+
new Promise((resolve, reject) => {
46+
const deadline = setTimeout(() => {
47+
reject(new Error(`Timed out waiting for ${match} — stdout: ${out}, stderr: ${err}`));
48+
}, timeoutMs);
49+
50+
const check = () => {
51+
if (match.test(out) || match.test(err)) {
52+
clearTimeout(deadline);
53+
resolve();
54+
return;
55+
}
56+
setTimeout(check, 50);
57+
};
58+
check();
59+
});
60+
61+
return {
62+
cp,
63+
stdout: () => out,
64+
stderr: () => err,
65+
kill: (signal: NodeJS.Signals = "SIGTERM") => cp.kill(signal),
66+
waitForOutput,
67+
};
68+
}
69+
70+
function makeTmpDir(): string {
71+
return mkdtempSync(join(tmpdir(), "cli-test-"));
72+
}
73+
74+
function writeFixture(dir: string, name: string): string {
75+
const filePath = join(dir, name);
76+
writeFileSync(
77+
filePath,
78+
JSON.stringify({
79+
fixtures: [
80+
{
81+
match: { userMessage: "hello" },
82+
response: { content: "Hello from test fixture!" },
83+
},
84+
],
85+
}),
86+
"utf-8",
87+
);
88+
return filePath;
89+
}
90+
91+
/* ================================================================== */
92+
93+
describe("CLI: --help", () => {
94+
it("prints usage text and exits with code 0", async () => {
95+
const { stdout, code } = await runCli(["--help"]);
96+
expect(stdout).toContain("Usage: mock-openai");
97+
expect(stdout).toContain("--port");
98+
expect(stdout).toContain("--fixtures");
99+
expect(code).toBe(0);
100+
});
101+
});
102+
103+
describe("CLI: argument validation", () => {
104+
it("rejects --port 99999 (out of range)", async () => {
105+
const { stderr, code } = await runCli(["--port", "99999"]);
106+
expect(stderr).toContain("Invalid port");
107+
expect(code).toBe(1);
108+
});
109+
110+
it("rejects --port=-1 (negative)", async () => {
111+
const { stderr, code } = await runCli(["--port=-1"]);
112+
expect(stderr).toContain("Invalid port");
113+
expect(code).toBe(1);
114+
});
115+
116+
it("rejects --latency=-5 (negative)", async () => {
117+
const { stderr, code } = await runCli(["--latency=-5"]);
118+
expect(stderr).toContain("Invalid latency");
119+
expect(code).toBe(1);
120+
});
121+
122+
it("rejects --chunk-size 0 (below minimum)", async () => {
123+
const { stderr, code } = await runCli(["--chunk-size", "0"]);
124+
expect(stderr).toContain("Invalid chunk-size");
125+
expect(code).toBe(1);
126+
});
127+
});
128+
129+
describe("CLI: fixture loading", () => {
130+
let tmpDir: string;
131+
132+
beforeEach(() => {
133+
tmpDir = makeTmpDir();
134+
});
135+
136+
afterEach(() => {
137+
rmSync(tmpDir, { recursive: true, force: true });
138+
});
139+
140+
it("starts server with a valid fixture file, then exits cleanly on SIGTERM", async () => {
141+
const fixturePath = writeFixture(tmpDir, "test.json");
142+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0"]);
143+
144+
await child.waitForOutput(/listening on/i, 5000);
145+
expect(child.stdout()).toContain("Loaded 1 fixture(s)");
146+
147+
child.kill("SIGTERM");
148+
149+
await new Promise<void>((resolve) => {
150+
child.cp.on("close", (code) => {
151+
expect(code).toBe(0);
152+
resolve();
153+
});
154+
});
155+
});
156+
157+
it("fails with error when --fixtures points to a non-existent path", async () => {
158+
const { stderr, code } = await runCli(["--fixtures", "/nonexistent/path/to/fixtures"]);
159+
expect(stderr).toContain("Fixtures path not found");
160+
expect(code).toBe(1);
161+
});
162+
});

src/__tests__/fixture-loader.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { loadFixtureFile, loadFixturesFromDir } from "../fixture-loader.js";
66

7+
/* ------------------------------------------------------------------ *
8+
* vi.mock for node:fs — defaults to the real implementation so that *
9+
* every test using real files keeps working. Individual tests in *
10+
* the "fs error paths" describe block override specific functions. *
11+
* ------------------------------------------------------------------ */
12+
const fsMocks = vi.hoisted(() => ({
13+
readFileSync: vi.fn(),
14+
readdirSync: vi.fn(),
15+
statSync: vi.fn(),
16+
}));
17+
18+
vi.mock("node:fs", async (importOriginal) => {
19+
const actual = await importOriginal<typeof import("node:fs")>();
20+
return {
21+
...actual,
22+
readFileSync: fsMocks.readFileSync.mockImplementation(actual.readFileSync),
23+
readdirSync: fsMocks.readdirSync.mockImplementation(actual.readdirSync),
24+
statSync: fsMocks.statSync.mockImplementation(actual.statSync),
25+
};
26+
});
27+
728
function makeTmpDir(): string {
829
return mkdtempSync(join(tmpdir(), "fixture-loader-test-"));
930
}
@@ -256,3 +277,85 @@ describe("loadFixturesFromDir", () => {
256277
warn.mockRestore();
257278
});
258279
});
280+
281+
/* ------------------------------------------------------------------ *
282+
* fs error paths (uses the vi.mock overrides declared at top level) *
283+
* ------------------------------------------------------------------ */
284+
describe("fixture-loader fs error paths", () => {
285+
afterEach(() => {
286+
vi.restoreAllMocks();
287+
});
288+
289+
it("loadFixtureFile warns and returns empty when readFileSync throws EACCES", () => {
290+
fsMocks.readFileSync.mockImplementation(() => {
291+
const err = new Error("permission denied") as NodeJS.ErrnoException;
292+
err.code = "EACCES";
293+
throw err;
294+
});
295+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
296+
297+
const result = loadFixtureFile("/some/protected-file.json");
298+
expect(result).toHaveLength(0);
299+
expect(warn).toHaveBeenCalledWith(
300+
expect.stringContaining("Could not read file"),
301+
expect.anything(),
302+
);
303+
});
304+
305+
it("loadFixturesFromDir silently skips a file when statSync throws ENOENT", () => {
306+
fsMocks.readdirSync.mockReturnValue(["a.json", "vanished.json", "b.json"]);
307+
fsMocks.statSync.mockImplementation((p: unknown) => {
308+
if (String(p).includes("vanished.json")) {
309+
const err = new Error("no such file") as NodeJS.ErrnoException;
310+
err.code = "ENOENT";
311+
throw err;
312+
}
313+
return { isDirectory: () => false };
314+
});
315+
fsMocks.readFileSync.mockImplementation((p: unknown) => {
316+
return JSON.stringify({
317+
fixtures: [{ match: { userMessage: String(p) }, response: { content: "ok" } }],
318+
});
319+
});
320+
321+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
322+
const result = loadFixturesFromDir("/fake/dir");
323+
324+
// vanished.json is silently skipped (ENOENT is not warned about per the source)
325+
expect(result).toHaveLength(2);
326+
327+
const statWarns = warn.mock.calls.filter(
328+
(c) => typeof c[0] === "string" && c[0].includes("Could not stat"),
329+
);
330+
expect(statWarns).toHaveLength(0);
331+
});
332+
333+
it("loadFixturesFromDir warns when statSync throws a non-ENOENT error (e.g. EACCES)", () => {
334+
fsMocks.readdirSync.mockReturnValue(["ok.json", "noperm.json"]);
335+
fsMocks.statSync.mockImplementation((p: unknown) => {
336+
if (String(p).includes("noperm.json")) {
337+
const err = new Error("permission denied") as NodeJS.ErrnoException;
338+
err.code = "EACCES";
339+
throw err;
340+
}
341+
return { isDirectory: () => false };
342+
});
343+
fsMocks.readFileSync.mockImplementation(() => {
344+
return JSON.stringify({
345+
fixtures: [{ match: { userMessage: "x" }, response: { content: "y" } }],
346+
});
347+
});
348+
349+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
350+
const result = loadFixturesFromDir("/fake/dir");
351+
352+
// noperm.json is skipped but a warning IS emitted for non-ENOENT errors
353+
expect(result).toHaveLength(1);
354+
355+
const statWarns = warn.mock.calls.filter(
356+
(c) => typeof c[0] === "string" && c[0].includes("Could not stat"),
357+
);
358+
expect(statWarns).toHaveLength(1);
359+
expect(statWarns[0][0]).toContain("noperm.json");
360+
});
361+
});

src/__tests__/helpers.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ describe("buildTextChunks", () => {
9292
expect(chunk.model).toBe("gpt-4o-mini");
9393
}
9494
});
95+
96+
it("produces role + finish with no content chunks for empty string", () => {
97+
const chunks = buildTextChunks("", "gpt-4", 20);
98+
expect(chunks.length).toBe(2); // role + finish only
99+
100+
// Role chunk
101+
expect(chunks[0].choices[0].delta.role).toBe("assistant");
102+
expect(chunks[0].choices[0].delta.content).toBe("");
103+
expect(chunks[0].choices[0].finish_reason).toBeNull();
104+
105+
// Finish chunk
106+
expect(chunks[1].choices[0].delta).toEqual({});
107+
expect(chunks[1].choices[0].finish_reason).toBe("stop");
108+
});
109+
110+
it("produces a single content chunk for a single character", () => {
111+
const chunks = buildTextChunks("a", "gpt-4", 20);
112+
expect(chunks.length).toBe(3); // role + "a" + finish
113+
114+
expect(chunks[1].choices[0].delta.content).toBe("a");
115+
});
116+
117+
it("preserves unicode multibyte content through chunking and reassembly", () => {
118+
const chunks = buildTextChunks("Hello 🌍🎉", "gpt-4", 3);
119+
120+
// Reassemble all content chunks — slice() splits on UTF-16 code units,
121+
// so individual chunks may contain lone surrogates, but concatenation
122+
// must reproduce the original string.
123+
const contentChunks = chunks.slice(1, -1); // skip role and finish
124+
const reassembled = contentChunks.map((c) => c.choices[0].delta.content).join("");
125+
expect(reassembled).toBe("Hello 🌍🎉");
126+
127+
// "Hello 🌍🎉" is 10 UTF-16 code units, so ceil(10/3) = 4 content chunks
128+
expect(contentChunks.length).toBe(4);
129+
});
130+
131+
it("produces a single content chunk when content is shorter than chunkSize", () => {
132+
const chunks = buildTextChunks("hi", "gpt-4", 100);
133+
expect(chunks.length).toBe(3); // role + "hi" + finish
134+
135+
expect(chunks[1].choices[0].delta.content).toBe("hi");
136+
});
95137
});
96138

97139
describe("buildToolCallChunks", () => {
@@ -170,4 +212,60 @@ describe("buildToolCallChunks", () => {
170212
const initial = chunks.find((c) => c.choices[0].delta.tool_calls?.[0]?.id);
171213
expect(initial!.choices[0].delta.tool_calls![0].id).toBe("call_custom123");
172214
});
215+
216+
it("handles empty arguments string with role + initial + finish", () => {
217+
const chunks = buildToolCallChunks(
218+
[{ name: "fn", arguments: "" }],
219+
"gpt-4",
220+
20,
221+
);
222+
// role + initial tool call chunk + finish = 3 (no arg chunks since args is empty)
223+
expect(chunks.length).toBe(3);
224+
225+
// Role chunk
226+
expect(chunks[0].choices[0].delta.role).toBe("assistant");
227+
expect(chunks[0].choices[0].delta.content).toBeNull();
228+
229+
// Initial tool call chunk
230+
const tc = chunks[1].choices[0].delta.tool_calls;
231+
expect(tc).toBeDefined();
232+
expect(tc![0].function?.name).toBe("fn");
233+
expect(tc![0].function?.arguments).toBe("");
234+
235+
// Finish chunk
236+
expect(chunks[2].choices[0].delta).toEqual({});
237+
expect(chunks[2].choices[0].finish_reason).toBe("tool_calls");
238+
});
239+
240+
it("handles empty toolCalls array with role + finish only", () => {
241+
const chunks = buildToolCallChunks([], "gpt-4", 20);
242+
expect(chunks.length).toBe(2); // role + finish
243+
244+
// Role chunk
245+
expect(chunks[0].choices[0].delta.role).toBe("assistant");
246+
expect(chunks[0].choices[0].delta.content).toBeNull();
247+
expect(chunks[0].choices[0].finish_reason).toBeNull();
248+
249+
// Finish chunk
250+
expect(chunks[1].choices[0].delta).toEqual({});
251+
expect(chunks[1].choices[0].finish_reason).toBe("tool_calls");
252+
});
253+
254+
it("produces a single arg chunk when arguments are shorter than chunkSize", () => {
255+
const chunks = buildToolCallChunks(
256+
[{ name: "fn", arguments: '{"x":1}' }],
257+
"gpt-4",
258+
100,
259+
);
260+
// role + initial + 1 arg chunk + finish = 4
261+
expect(chunks.length).toBe(4);
262+
263+
const argChunks = chunks.filter(
264+
(c) =>
265+
c.choices[0].delta.tool_calls?.[0].function?.arguments !== undefined &&
266+
c.choices[0].delta.tool_calls?.[0].function?.arguments !== "",
267+
);
268+
expect(argChunks.length).toBe(1);
269+
expect(argChunks[0].choices[0].delta.tool_calls![0].function!.arguments).toBe('{"x":1}');
270+
});
173271
});

0 commit comments

Comments
 (0)