Skip to content

Commit 2839acf

Browse files
committed
fix: load snapshot-style fixture subdirectories on boot
The recorder (v1.19.0, #155) writes fixtures to <fixturePath>/<testId>/<provider>.json, but loadFixturesFromDir skipped all subdirectories with a warning. Recorded fixtures could never be replayed. Recurse one level into subdirectories so the read path matches the write path. Top-level JSON files load first, then subdirectory fixtures in alphabetical order. Deeper nesting is ignored. Closes #161
1 parent 2218ad5 commit 2839acf

3 files changed

Lines changed: 134 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# @copilotkit/aimock
22

3+
## [Unreleased]
4+
5+
### Fixed
6+
7+
- **Fixture loader: snapshot-style subdirectories skipped on boot**`loadFixturesFromDir` now recurses one level into subdirectories to load `<testId>/<provider>.json` files written by the snapshot recorder. Previously the loader skipped all subdirectories with a warning, so recorded fixtures could never be replayed. (Issue #161, reported by @jantimon)
8+
- **CLI: immediate exit on npx/bunx** — Entry-point guard now matches the `aimock` bin name (not just `aimock-cli.js`), fixing silent exit when invoked via `npx aimock` or `bunx aimock`. (Issue #160)
9+
310
## [1.19.0] - 2026-05-06
411

512
### Added

src/__tests__/fixture-loader.test.ts

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,22 +455,102 @@ describe("loadFixturesFromDir", () => {
455455
expect(fixtures[0].match.userMessage).toBe("nested");
456456
});
457457

458-
it("warns and skips subdirectories, still loads sibling JSON files", () => {
458+
it("recurses one level into subdirectories (snapshot-style layout)", () => {
459459
writeJson(tmpDir, "a-valid.json", {
460460
fixtures: [{ match: { userMessage: "top" }, response: { content: "yes" } }],
461461
});
462-
const subDir = join(tmpDir, "nested");
462+
const subDir = join(tmpDir, "greeting--hello-world");
463463
mkdirSync(subDir);
464-
writeJson(subDir, "inner.json", {
465-
fixtures: [{ match: { userMessage: "deep" }, response: { content: "nope" } }],
464+
writeJson(subDir, "openai.json", {
465+
fixtures: [{ match: { userMessage: "deep" }, response: { content: "found" } }],
466+
});
467+
468+
const fixtures = loadFixturesFromDir(tmpDir);
469+
expect(fixtures).toHaveLength(2);
470+
expect(fixtures[0].match.userMessage).toBe("top");
471+
expect(fixtures[1].match.userMessage).toBe("deep");
472+
});
473+
474+
it("loads multiple provider files from one snapshot subdirectory", () => {
475+
const subDir = join(tmpDir, "test-greeting");
476+
mkdirSync(subDir);
477+
writeJson(subDir, "anthropic.json", {
478+
fixtures: [{ match: { userMessage: "hi anthropic" }, response: { content: "A" } }],
479+
});
480+
writeJson(subDir, "openai.json", {
481+
fixtures: [{ match: { userMessage: "hi openai" }, response: { content: "O" } }],
482+
});
483+
484+
const fixtures = loadFixturesFromDir(tmpDir);
485+
expect(fixtures).toHaveLength(2);
486+
// Alphabetical order within subdirectory
487+
expect(fixtures[0].match.userMessage).toBe("hi anthropic");
488+
expect(fixtures[1].match.userMessage).toBe("hi openai");
489+
});
490+
491+
it("loads fixtures from multiple snapshot subdirectories in alphabetical order", () => {
492+
const subA = join(tmpDir, "a-test");
493+
const subB = join(tmpDir, "b-test");
494+
mkdirSync(subA);
495+
mkdirSync(subB);
496+
writeJson(subA, "openai.json", {
497+
fixtures: [{ match: { userMessage: "a" }, response: { content: "A" } }],
498+
});
499+
writeJson(subB, "openai.json", {
500+
fixtures: [{ match: { userMessage: "b" }, response: { content: "B" } }],
501+
});
502+
503+
const fixtures = loadFixturesFromDir(tmpDir);
504+
expect(fixtures).toHaveLength(2);
505+
expect(fixtures[0].match.userMessage).toBe("a");
506+
expect(fixtures[1].match.userMessage).toBe("b");
507+
});
508+
509+
it("does not recurse deeper than one level", () => {
510+
const subDir = join(tmpDir, "level1");
511+
const deepDir = join(subDir, "level2");
512+
mkdirSync(subDir);
513+
mkdirSync(deepDir);
514+
writeJson(subDir, "openai.json", {
515+
fixtures: [{ match: { userMessage: "one" }, response: { content: "1" } }],
516+
});
517+
writeJson(deepDir, "openai.json", {
518+
fixtures: [{ match: { userMessage: "two" }, response: { content: "2" } }],
466519
});
467520

468-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
469521
const fixtures = loadFixturesFromDir(tmpDir);
470522
expect(fixtures).toHaveLength(1);
523+
expect(fixtures[0].match.userMessage).toBe("one");
524+
});
525+
526+
it("top-level JSON files come before subdirectory fixtures", () => {
527+
writeJson(tmpDir, "z-top.json", {
528+
fixtures: [{ match: { userMessage: "top" }, response: { content: "T" } }],
529+
});
530+
const subDir = join(tmpDir, "a-sub");
531+
mkdirSync(subDir);
532+
writeJson(subDir, "openai.json", {
533+
fixtures: [{ match: { userMessage: "sub" }, response: { content: "S" } }],
534+
});
535+
536+
const fixtures = loadFixturesFromDir(tmpDir);
537+
expect(fixtures).toHaveLength(2);
538+
// Top-level files load first (regardless of sort order vs subdirectory names)
471539
expect(fixtures[0].match.userMessage).toBe("top");
472-
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Skipping subdirectory"));
473-
warn.mockRestore();
540+
expect(fixtures[1].match.userMessage).toBe("sub");
541+
});
542+
543+
it("ignores non-.json files inside subdirectories", () => {
544+
const subDir = join(tmpDir, "test-sub");
545+
mkdirSync(subDir);
546+
writeFileSync(join(subDir, "readme.txt"), "ignore me", "utf-8");
547+
writeJson(subDir, "openai.json", {
548+
fixtures: [{ match: { userMessage: "real" }, response: { content: "yes" } }],
549+
});
550+
551+
const fixtures = loadFixturesFromDir(tmpDir);
552+
expect(fixtures).toHaveLength(1);
553+
expect(fixtures[0].match.userMessage).toBe("real");
474554
});
475555
});
476556

src/fixture-loader.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,12 @@ export function loadFixturesFromDir(dirPath: string, logger?: Logger): Fixture[]
127127
}
128128

129129
const jsonFiles: string[] = [];
130+
const subdirs: string[] = [];
130131
for (const name of entries) {
131132
const fullPath = join(dirPath, name);
132133
try {
133134
if (statSync(fullPath).isDirectory()) {
134-
warn(logger, `Skipping subdirectory ${fullPath} (fixtures are not loaded recursively)`);
135+
subdirs.push(name);
135136
continue;
136137
}
137138
} catch (err) {
@@ -153,6 +154,44 @@ export function loadFixturesFromDir(dirPath: string, logger?: Logger): Fixture[]
153154
fixtures.push(...loadFixtureFile(filePath, logger));
154155
}
155156

157+
// Recurse one level into subdirectories to support snapshot-style layouts
158+
// where the recorder writes to <fixturePath>/<testId>/<provider>.json.
159+
subdirs.sort();
160+
for (const sub of subdirs) {
161+
const subPath = join(dirPath, sub);
162+
let subEntries: string[];
163+
try {
164+
subEntries = readdirSync(subPath);
165+
} catch (err) {
166+
warn(logger, `Could not read subdirectory ${subPath}:`, err);
167+
continue;
168+
}
169+
const subJsonFiles: string[] = [];
170+
for (const subName of subEntries) {
171+
const subFullPath = join(subPath, subName);
172+
try {
173+
if (statSync(subFullPath).isDirectory()) {
174+
// Only one level of recursion — skip deeper nesting
175+
continue;
176+
}
177+
} catch (err) {
178+
const code = (err as NodeJS.ErrnoException).code;
179+
if (code !== "ENOENT") {
180+
warn(logger, `Could not stat ${subFullPath}:`, err);
181+
}
182+
continue;
183+
}
184+
if (subName.endsWith(".json")) {
185+
subJsonFiles.push(subName);
186+
}
187+
}
188+
subJsonFiles.sort();
189+
for (const subName of subJsonFiles) {
190+
const filePath = join(subPath, subName);
191+
fixtures.push(...loadFixtureFile(filePath, logger));
192+
}
193+
}
194+
156195
return fixtures;
157196
}
158197

0 commit comments

Comments
 (0)