Skip to content

Commit fb0b56b

Browse files
authored
merge: add --watch, --log-level, --validate-on-load CLI flags (#40)
## Summary Three CLI features from the Future Direction section: - **--watch** (`-w`): 500ms debounced `fs.watch` with in-place fixture reload. Keeps previous fixtures on validation failure. Error handler on FSWatcher surfaces dead-watcher conditions. - **--log-level**: `silent` / `info` (default) / `debug`. Startup messages and per-request access logs at info. Match traces at debug. Warnings/errors always print. - **--validate-on-load**: Runs `validateFixtures()` at startup, exits 1 on errors. In watch mode, errors prevent reload but don't exit. ### Validation checks **Errors** (exit 1): unrecognized response type, empty content, empty tool call name, unparseable tool call arguments, empty error message, invalid HTTP status, negative latency, chunkSize < 1, truncateAfterChunks < 1, negative disconnectAfterMs. **Warnings** (logged): duplicate userMessage shadowing, catch-all not in last position. ### New files - `src/logger.ts` — Logger class with silent/info/debug levels - `src/watcher.ts` — File watcher for fixture hot-reload ### Documentation - CLI options table updated with new flags - Future Direction CLI section removed (all items implemented) ## Test plan - [x] 564/564 tests pass (24 new: 15 validation + 9 CLI integration) - [x] Prettier + ESLint clean - [x] 2 rounds of CR (code-reviewer, silent-failure-hunter, code-simplifier, pr-test-analyzer) — clean on final round 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 44fec2f + 9285e78 commit fb0b56b

12 files changed

Lines changed: 753 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# @copilotkit/llmock
22

3+
## 1.4.0
4+
5+
### Minor Changes
6+
7+
- `--watch` (`-w`): File-watching with 500ms debounced reload. Keeps previous fixtures on validation failure.
8+
- `--log-level`: Configurable log verbosity (`silent`, `info`, `debug`). Default `info` for CLI, `silent` for programmatic API.
9+
- `--validate-on-load`: Fixture schema validation at startup — checks response types, tool call JSON, numeric ranges, shadowing, and catch-all positioning.
10+
- `validateFixtures()` exported for programmatic use
11+
- `Logger` class exported for programmatic use
12+
313
## 1.3.3
414

515
### Patch Changes

README.md

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -590,14 +590,17 @@ The package includes a standalone server binary:
590590
llmock [options]
591591
```
592592

593-
| Option | Short | Default | Description |
594-
| -------------- | ----- | ------------ | ---------------------------------- |
595-
| `--port` | `-p` | `4010` | Port to listen on |
596-
| `--host` | `-h` | `127.0.0.1` | Host to bind to |
597-
| `--fixtures` | `-f` | `./fixtures` | Path to fixtures directory or file |
598-
| `--latency` | `-l` | `0` | Latency between SSE chunks (ms) |
599-
| `--chunk-size` | `-c` | `20` | Characters per SSE chunk |
600-
| `--help` | | | Show help |
593+
| Option | Short | Default | Description |
594+
| -------------------- | ----- | ------------ | ----------------------------------------- |
595+
| `--port` | `-p` | `4010` | Port to listen on |
596+
| `--host` | `-h` | `127.0.0.1` | Host to bind to |
597+
| `--fixtures` | `-f` | `./fixtures` | Path to fixtures directory or file |
598+
| `--latency` | `-l` | `0` | Latency between SSE chunks (ms) |
599+
| `--chunk-size` | `-c` | `20` | Characters per SSE chunk |
600+
| `--watch` | `-w` | | Watch fixture path for changes and reload |
601+
| `--log-level` | | `info` | Log verbosity: `silent`, `info`, `debug` |
602+
| `--validate-on-load` | | | Validate fixture schemas at startup |
603+
| `--help` | | | Show help |
601604

602605
```bash
603606
# Start with bundled example fixtures
@@ -697,12 +700,6 @@ Areas where llmock could grow, and explicit non-goals for the current scope.
697700
- **Token counts**: Usage fields are always zero across all providers.
698701
- **Vision/image content**: Image content parts are not handled by any provider.
699702

700-
### CLI
701-
702-
- **`--watch` mode**: No file-watching to auto-reload fixtures on change.
703-
- **`--log-level`**: No configurable log verbosity.
704-
- **`--validate-on-load`**: No flag to validate fixture schemas at startup.
705-
706703
## Real-World Usage
707704

708705
[CopilotKit](https://github.com/CopilotKit/CopilotKit) uses llmock across its test suite to verify AI agent behavior across multiple LLM providers without hitting real APIs. The tests cover streaming text, tool calls, and multi-turn conversations across both v1 and v2 runtimes.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/llmock",
3-
"version": "1.3.3",
3+
"version": "1.4.0",
44
"description": "Deterministic mock LLM server for testing (OpenAI, Anthropic, Gemini)",
55
"license": "MIT",
66
"packageManager": "pnpm@10.28.2",

src/__tests__/cli.test.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,200 @@ describe.skipIf(!CLI_AVAILABLE)("CLI: fixture loading", () => {
157157

158158
it("fails with error when --fixtures points to a non-existent path", async () => {
159159
const { stderr, code } = await runCli(["--fixtures", "/nonexistent/path/to/fixtures"]);
160-
expect(stderr).toContain("Fixtures path not found");
160+
expect(stderr).toContain("Failed to load fixtures");
161161
expect(code).toBe(1);
162162
});
163163
});
164+
165+
describe.skipIf(!CLI_AVAILABLE)("CLI: --log-level", () => {
166+
let tmpDir: string;
167+
168+
beforeEach(() => {
169+
tmpDir = makeTmpDir();
170+
});
171+
172+
afterEach(() => {
173+
rmSync(tmpDir, { recursive: true, force: true });
174+
});
175+
176+
it("--log-level silent suppresses startup output", async () => {
177+
const fixturePath = writeFixture(tmpDir, "test.json");
178+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--log-level", "silent"]);
179+
180+
// Wait for the server to be ready (listen on port)
181+
// With silent, there should be no [llmock] output
182+
await new Promise((r) => setTimeout(r, 1500));
183+
184+
const stdout = child.stdout();
185+
expect(stdout).not.toContain("[llmock]");
186+
187+
child.kill("SIGTERM");
188+
await new Promise<void>((resolve) => {
189+
child.cp.on("close", () => resolve());
190+
});
191+
});
192+
193+
it("--log-level info shows startup messages", async () => {
194+
const fixturePath = writeFixture(tmpDir, "test.json");
195+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--log-level", "info"]);
196+
197+
await child.waitForOutput(/listening on/i, 5000);
198+
expect(child.stdout()).toContain("[llmock]");
199+
expect(child.stdout()).toContain("Loaded 1 fixture(s)");
200+
201+
child.kill("SIGTERM");
202+
await new Promise<void>((resolve) => {
203+
child.cp.on("close", () => resolve());
204+
});
205+
});
206+
207+
it("--log-level debug starts successfully", async () => {
208+
const fixturePath = writeFixture(tmpDir, "test.json");
209+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--log-level", "debug"]);
210+
211+
await child.waitForOutput(/listening on/i, 5000);
212+
expect(child.stdout()).toContain("[llmock]");
213+
214+
child.kill("SIGTERM");
215+
await new Promise<void>((resolve) => {
216+
child.cp.on("close", () => resolve());
217+
});
218+
});
219+
220+
it("rejects invalid --log-level value", async () => {
221+
const { stderr, code } = await runCli(["--log-level", "verbose"]);
222+
expect(stderr).toContain("Invalid log-level");
223+
expect(code).toBe(1);
224+
});
225+
});
226+
227+
describe.skipIf(!CLI_AVAILABLE)("CLI: --validate-on-load", () => {
228+
let tmpDir: string;
229+
230+
beforeEach(() => {
231+
tmpDir = makeTmpDir();
232+
});
233+
234+
afterEach(() => {
235+
rmSync(tmpDir, { recursive: true, force: true });
236+
});
237+
238+
it("passes validation for valid fixtures", async () => {
239+
const fixturePath = writeFixture(tmpDir, "test.json");
240+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--validate-on-load"]);
241+
242+
await child.waitForOutput(/listening on/i, 5000);
243+
expect(child.stderr()).not.toContain("Validation failed");
244+
245+
child.kill("SIGTERM");
246+
await new Promise<void>((resolve) => {
247+
child.cp.on("close", () => resolve());
248+
});
249+
});
250+
251+
it("exits 1 on invalid fixture (empty content)", async () => {
252+
const filePath = join(tmpDir, "bad.json");
253+
writeFileSync(
254+
filePath,
255+
JSON.stringify({
256+
fixtures: [
257+
{
258+
match: { userMessage: "hello" },
259+
response: { content: "" },
260+
},
261+
],
262+
}),
263+
"utf-8",
264+
);
265+
266+
const { stderr, code } = await runCli(["--fixtures", filePath, "--validate-on-load"]);
267+
expect(stderr).toContain("Validation failed");
268+
expect(code).toBe(1);
269+
});
270+
271+
it("exits 1 on invalid fixture (unparseable toolCalls arguments)", async () => {
272+
const filePath = join(tmpDir, "bad-tool.json");
273+
writeFileSync(
274+
filePath,
275+
JSON.stringify({
276+
fixtures: [
277+
{
278+
match: { userMessage: "weather" },
279+
response: {
280+
toolCalls: [{ name: "get_weather", arguments: "not json" }],
281+
},
282+
},
283+
],
284+
}),
285+
"utf-8",
286+
);
287+
288+
const { stderr, code } = await runCli(["--fixtures", filePath, "--validate-on-load"]);
289+
expect(stderr).toContain("Validation failed");
290+
expect(code).toBe(1);
291+
});
292+
});
293+
294+
describe.skipIf(!CLI_AVAILABLE)("CLI: --watch", () => {
295+
let tmpDir: string;
296+
297+
beforeEach(() => {
298+
tmpDir = makeTmpDir();
299+
});
300+
301+
afterEach(() => {
302+
rmSync(tmpDir, { recursive: true, force: true });
303+
});
304+
305+
it("survives invalid JSON during reload", async () => {
306+
const fixturePath = writeFixture(tmpDir, "test.json");
307+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--watch"]);
308+
309+
await child.waitForOutput(/listening on/i, 5000);
310+
311+
// Write invalid JSON
312+
writeFileSync(fixturePath, "{ not valid json", "utf-8");
313+
314+
// Wait for the reload attempt — server should stay up
315+
await new Promise((r) => setTimeout(r, 1500));
316+
317+
// Server should still be running (not crashed)
318+
expect(child.cp.exitCode).toBeNull();
319+
320+
child.kill("SIGTERM");
321+
await new Promise<void>((resolve) => {
322+
child.cp.on("close", () => resolve());
323+
});
324+
});
325+
326+
it("reloads fixtures when file changes", async () => {
327+
const fixturePath = writeFixture(tmpDir, "test.json");
328+
const child = spawnCli(["--fixtures", fixturePath, "--port", "0", "--watch"]);
329+
330+
await child.waitForOutput(/listening on/i, 5000);
331+
expect(child.stdout()).toContain("Watching");
332+
333+
// Modify the fixture file
334+
writeFileSync(
335+
fixturePath,
336+
JSON.stringify({
337+
fixtures: [
338+
{
339+
match: { userMessage: "goodbye" },
340+
response: { content: "Bye!" },
341+
},
342+
],
343+
}),
344+
"utf-8",
345+
);
346+
347+
// Wait for reload
348+
await child.waitForOutput(/Reloaded/i, 5000);
349+
expect(child.stdout()).toContain("Reloaded 1 fixture(s)");
350+
351+
child.kill("SIGTERM");
352+
await new Promise<void>((resolve) => {
353+
child.cp.on("close", () => resolve());
354+
});
355+
});
356+
});

0 commit comments

Comments
 (0)