ai-spec-dev 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import * as os from "os";
5
+ import type { SpecDSL, ApiEndpoint, DataModel } from "../core/dsl-types";
6
+ import {
7
+ generateTypescriptTypes,
8
+ saveTypescriptTypes,
9
+ TypesGeneratorOptions,
10
+ } from "../core/types-generator";
11
+
12
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
13
+
14
+ function makeDsl(overrides: Partial<SpecDSL> = {}): SpecDSL {
15
+ return {
16
+ version: "1.0",
17
+ feature: { id: "order-mgmt", title: "Order Management", description: "Manage orders" },
18
+ models: [
19
+ {
20
+ name: "Order",
21
+ description: "Represents a customer order",
22
+ fields: [
23
+ { name: "id", type: "String", required: true, unique: true },
24
+ { name: "userId", type: "String", required: true },
25
+ { name: "total", type: "Float", required: true },
26
+ { name: "items", type: "OrderItem[]", required: true },
27
+ { name: "status", type: "String", required: true },
28
+ { name: "note", type: "String", required: false, description: "Optional note" },
29
+ { name: "createdAt", type: "DateTime", required: true },
30
+ { name: "metadata", type: "Json", required: false },
31
+ ],
32
+ },
33
+ ],
34
+ endpoints: [
35
+ {
36
+ id: "EP-001",
37
+ method: "GET",
38
+ path: "/orders",
39
+ description: "List orders",
40
+ auth: true,
41
+ request: { query: { page: "Int", status: "String" } },
42
+ successStatus: 200,
43
+ successDescription: "OK",
44
+ },
45
+ {
46
+ id: "EP-002",
47
+ method: "POST",
48
+ path: "/orders",
49
+ description: "Create order",
50
+ auth: true,
51
+ request: {
52
+ body: { userId: "String", items: "OrderItem[]", note: "String?" },
53
+ },
54
+ successStatus: 201,
55
+ successDescription: "Created",
56
+ },
57
+ {
58
+ id: "EP-003",
59
+ method: "GET",
60
+ path: "/orders/:id",
61
+ description: "Get order by ID",
62
+ auth: true,
63
+ request: { params: { id: "String" } },
64
+ successStatus: 200,
65
+ successDescription: "OK",
66
+ },
67
+ {
68
+ id: "EP-004",
69
+ method: "DELETE",
70
+ path: "/orders/:id",
71
+ description: "Delete order",
72
+ auth: true,
73
+ successStatus: 204,
74
+ successDescription: "Deleted",
75
+ },
76
+ ],
77
+ behaviors: [{ id: "BHV-001", description: "Send email on order creation" }],
78
+ ...overrides,
79
+ };
80
+ }
81
+
82
+ let tmpDir: string;
83
+
84
+ beforeEach(async () => {
85
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "types-gen-test-"));
86
+ });
87
+
88
+ afterEach(async () => {
89
+ await fs.remove(tmpDir);
90
+ });
91
+
92
+ // ─── Type Mapping ────────────────────────────────────────────────────────────
93
+
94
+ describe("generateTypescriptTypes — type mapping", () => {
95
+ it("maps String to string", () => {
96
+ const dsl = makeDsl({
97
+ models: [{ name: "M", fields: [{ name: "x", type: "String", required: true }] }],
98
+ endpoints: [],
99
+ });
100
+ const output = generateTypescriptTypes(dsl);
101
+ expect(output).toContain("x: string;");
102
+ });
103
+
104
+ it("maps Int and Float to number", () => {
105
+ const dsl = makeDsl({
106
+ models: [
107
+ {
108
+ name: "M",
109
+ fields: [
110
+ { name: "a", type: "Int", required: true },
111
+ { name: "b", type: "Float", required: true },
112
+ ],
113
+ },
114
+ ],
115
+ endpoints: [],
116
+ });
117
+ const output = generateTypescriptTypes(dsl);
118
+ expect(output).toContain("a: number;");
119
+ expect(output).toContain("b: number;");
120
+ });
121
+
122
+ it("maps Boolean to boolean", () => {
123
+ const dsl = makeDsl({
124
+ models: [{ name: "M", fields: [{ name: "x", type: "Boolean", required: true }] }],
125
+ endpoints: [],
126
+ });
127
+ const output = generateTypescriptTypes(dsl);
128
+ expect(output).toContain("x: boolean;");
129
+ });
130
+
131
+ it("maps DateTime/Date to string", () => {
132
+ const dsl = makeDsl({
133
+ models: [{ name: "M", fields: [{ name: "x", type: "DateTime", required: true }] }],
134
+ endpoints: [],
135
+ });
136
+ const output = generateTypescriptTypes(dsl);
137
+ expect(output).toContain("x: string;");
138
+ });
139
+
140
+ it("maps Json to Record<string, unknown>", () => {
141
+ const dsl = makeDsl({
142
+ models: [{ name: "M", fields: [{ name: "x", type: "Json", required: true }] }],
143
+ endpoints: [],
144
+ });
145
+ const output = generateTypescriptTypes(dsl);
146
+ expect(output).toContain("x: Record<string, unknown>;");
147
+ });
148
+
149
+ it("maps array types like String[]", () => {
150
+ const dsl = makeDsl({
151
+ models: [{ name: "M", fields: [{ name: "x", type: "String[]", required: true }] }],
152
+ endpoints: [],
153
+ });
154
+ const output = generateTypescriptTypes(dsl);
155
+ expect(output).toContain("x: string[];");
156
+ });
157
+
158
+ it("maps model references (PascalCase) as-is", () => {
159
+ const dsl = makeDsl({
160
+ models: [{ name: "M", fields: [{ name: "x", type: "OrderItem[]", required: true }] }],
161
+ endpoints: [],
162
+ });
163
+ const output = generateTypescriptTypes(dsl);
164
+ expect(output).toContain("x: OrderItem[];");
165
+ });
166
+
167
+ it("maps unknown lowercase types to string", () => {
168
+ const dsl = makeDsl({
169
+ models: [{ name: "M", fields: [{ name: "x", type: "foobar", required: true }] }],
170
+ endpoints: [],
171
+ });
172
+ const output = generateTypescriptTypes(dsl);
173
+ expect(output).toContain("x: string;");
174
+ });
175
+
176
+ it("strips nullable markers (? and !)", () => {
177
+ const dsl = makeDsl({
178
+ models: [{ name: "M", fields: [{ name: "x", type: "String?", required: false }] }],
179
+ endpoints: [],
180
+ });
181
+ const output = generateTypescriptTypes(dsl);
182
+ expect(output).toContain("x?: string;");
183
+ });
184
+ });
185
+
186
+ // ─── Model Interface Rendering ───────────────────────────────────────────────
187
+
188
+ describe("generateTypescriptTypes — model interfaces", () => {
189
+ it("renders export interface with correct name", () => {
190
+ const output = generateTypescriptTypes(makeDsl());
191
+ expect(output).toContain("export interface Order {");
192
+ });
193
+
194
+ it("marks optional fields with ?", () => {
195
+ const output = generateTypescriptTypes(makeDsl());
196
+ expect(output).toContain("note?: string;");
197
+ expect(output).toContain("metadata?: Record<string, unknown>;");
198
+ });
199
+
200
+ it("marks required fields without ?", () => {
201
+ const output = generateTypescriptTypes(makeDsl());
202
+ expect(output).toMatch(/\bid: string;/);
203
+ expect(output).toContain("total: number;");
204
+ });
205
+
206
+ it("includes model description as JSDoc comment", () => {
207
+ const output = generateTypescriptTypes(makeDsl());
208
+ expect(output).toContain("/** Represents a customer order */");
209
+ });
210
+
211
+ it("includes field description as JSDoc comment", () => {
212
+ const output = generateTypescriptTypes(makeDsl());
213
+ expect(output).toContain("/** Optional note */");
214
+ });
215
+ });
216
+
217
+ // ─── Endpoint Types ──────────────────────────────────────────────────────────
218
+
219
+ describe("generateTypescriptTypes — endpoint types", () => {
220
+ it("generates request body interfaces", () => {
221
+ const output = generateTypescriptTypes(makeDsl());
222
+ expect(output).toContain("export interface PostOrdersRequest {");
223
+ expect(output).toContain("userId: string;");
224
+ });
225
+
226
+ it("generates query param interfaces with optional fields", () => {
227
+ const output = generateTypescriptTypes(makeDsl());
228
+ expect(output).toContain("export interface GetOrdersQuery {");
229
+ expect(output).toContain("page?: number;");
230
+ expect(output).toContain("status?: string;");
231
+ });
232
+
233
+ it("generates path param interfaces", () => {
234
+ const output = generateTypescriptTypes(makeDsl());
235
+ expect(output).toContain("export interface GetOrdersByidParams {");
236
+ expect(output).toContain("id: string;");
237
+ });
238
+
239
+ it("omits endpoint types when includeEndpointTypes is false", () => {
240
+ const output = generateTypescriptTypes(makeDsl(), { includeEndpointTypes: false });
241
+ expect(output).not.toContain("PostOrdersRequest");
242
+ expect(output).not.toContain("GetOrdersQuery");
243
+ });
244
+
245
+ it("does not generate types for endpoints without request schemas", () => {
246
+ const output = generateTypescriptTypes(makeDsl());
247
+ // EP-004 DELETE /orders/:id has no request body/query/params
248
+ expect(output).not.toContain("DeleteOrdersByIdRequest");
249
+ });
250
+ });
251
+
252
+ // ─── Endpoint Map ────────────────────────────────────────────────────────────
253
+
254
+ describe("generateTypescriptTypes — endpoint map", () => {
255
+ it("generates API_ENDPOINTS constant", () => {
256
+ const output = generateTypescriptTypes(makeDsl());
257
+ expect(output).toContain("export const API_ENDPOINTS = {");
258
+ expect(output).toContain("} as const;");
259
+ });
260
+
261
+ it("includes method, path, and auth for each endpoint", () => {
262
+ const output = generateTypescriptTypes(makeDsl());
263
+ expect(output).toContain("method: 'GET'");
264
+ expect(output).toContain("path: '/orders'");
265
+ expect(output).toContain("auth: true");
266
+ });
267
+
268
+ it("generates ApiEndpointKey type", () => {
269
+ const output = generateTypescriptTypes(makeDsl());
270
+ expect(output).toContain("export type ApiEndpointKey = keyof typeof API_ENDPOINTS;");
271
+ });
272
+
273
+ it("omits endpoint map when includeEndpointMap is false", () => {
274
+ const output = generateTypescriptTypes(makeDsl(), { includeEndpointMap: false });
275
+ expect(output).not.toContain("API_ENDPOINTS");
276
+ });
277
+ });
278
+
279
+ // ─── Header ──────────────────────────────────────────────────────────────────
280
+
281
+ describe("generateTypescriptTypes — header", () => {
282
+ it("uses default header with feature title", () => {
283
+ const output = generateTypescriptTypes(makeDsl());
284
+ expect(output).toContain("Generated by ai-spec");
285
+ expect(output).toContain("Order Management");
286
+ });
287
+
288
+ it("uses custom header when provided", () => {
289
+ const output = generateTypescriptTypes(makeDsl(), { header: "// Custom header" });
290
+ expect(output).toContain("// Custom header");
291
+ expect(output).not.toContain("Generated by ai-spec");
292
+ });
293
+ });
294
+
295
+ // ─── Component Props ─────────────────────────────────────────────────────────
296
+
297
+ describe("generateTypescriptTypes — component props", () => {
298
+ it("generates component props interfaces for frontend DSLs", () => {
299
+ const dsl = makeDsl({
300
+ components: [
301
+ {
302
+ id: "CMP-001",
303
+ name: "OrderList",
304
+ description: "Displays list of orders",
305
+ props: [
306
+ { name: "orders", type: "Order[]", required: true },
307
+ { name: "loading", type: "Boolean", required: false, description: "Loading state" },
308
+ ],
309
+ events: [],
310
+ state: {},
311
+ apiCalls: [],
312
+ },
313
+ ],
314
+ });
315
+ const output = generateTypescriptTypes(dsl);
316
+ expect(output).toContain("export interface OrderListProps {");
317
+ expect(output).toContain("orders: Order[];");
318
+ expect(output).toContain("loading?: boolean;");
319
+ expect(output).toContain("/** Loading state */");
320
+ expect(output).toContain("/** Displays list of orders */");
321
+ });
322
+ });
323
+
324
+ // ─── saveTypescriptTypes ─────────────────────────────────────────────────────
325
+
326
+ describe("saveTypescriptTypes", () => {
327
+ it("writes types file to default path", async () => {
328
+ const dsl = makeDsl();
329
+ const outPath = await saveTypescriptTypes(dsl, tmpDir);
330
+
331
+ expect(outPath).toContain(".ai-spec");
332
+ expect(outPath).toContain("order-management.types.ts");
333
+ expect(await fs.pathExists(outPath)).toBe(true);
334
+
335
+ const content = await fs.readFile(outPath, "utf-8");
336
+ expect(content).toContain("export interface Order");
337
+ });
338
+
339
+ it("writes to custom output path", async () => {
340
+ const dsl = makeDsl();
341
+ const customPath = path.join(tmpDir, "src/types/api.ts");
342
+ const outPath = await saveTypescriptTypes(dsl, tmpDir, { outputPath: customPath });
343
+
344
+ expect(outPath).toBe(customPath);
345
+ expect(await fs.pathExists(customPath)).toBe(true);
346
+ });
347
+ });
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as path from "path";
3
+ import * as fs from "fs-extra";
4
+ import * as os from "os";
5
+ import {
6
+ VcrRecordingProvider,
7
+ VcrReplayProvider,
8
+ loadVcrRecording,
9
+ listVcrRecordings,
10
+ VCR_DIR,
11
+ } from "../core/vcr";
12
+ import type { VcrRecording } from "../core/vcr";
13
+ import type { AIProvider } from "../core/spec-generator";
14
+
15
+ // ─── Mock Provider ───────────────────────────────────────────────────────────
16
+
17
+ function makeMockProvider(responses: string[]): AIProvider {
18
+ let callIndex = 0;
19
+ return {
20
+ providerName: "test-provider",
21
+ modelName: "test-model",
22
+ generate: async (_prompt: string, _sys?: string) => {
23
+ return responses[callIndex++] ?? "no-more-responses";
24
+ },
25
+ };
26
+ }
27
+
28
+ let tmpDir: string;
29
+
30
+ beforeEach(async () => {
31
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vcr-test-"));
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await fs.remove(tmpDir);
36
+ });
37
+
38
+ // ─── VcrRecordingProvider ────────────────────────────────────────────────────
39
+
40
+ describe("VcrRecordingProvider", () => {
41
+ it("passes through generate calls to the inner provider", async () => {
42
+ const inner = makeMockProvider(["response-1", "response-2"]);
43
+ const recorder = new VcrRecordingProvider(inner);
44
+
45
+ const r1 = await recorder.generate("prompt 1");
46
+ const r2 = await recorder.generate("prompt 2", "system");
47
+
48
+ expect(r1).toBe("response-1");
49
+ expect(r2).toBe("response-2");
50
+ });
51
+
52
+ it("records entries with correct metadata", async () => {
53
+ const inner = makeMockProvider(["hello"]);
54
+ const recorder = new VcrRecordingProvider(inner);
55
+
56
+ await recorder.generate("What is 1+1?", "You are a calculator");
57
+
58
+ expect(recorder.callCount).toBe(1);
59
+
60
+ const filePath = await recorder.save(tmpDir, "run-001");
61
+ const recording: VcrRecording = await fs.readJson(filePath);
62
+
63
+ expect(recording.runId).toBe("run-001");
64
+ expect(recording.entryCount).toBe(1);
65
+ expect(recording.entries[0].response).toBe("hello");
66
+ expect(recording.entries[0].promptPreview).toContain("What is 1+1?");
67
+ expect(recording.entries[0].providerName).toBe("test-provider");
68
+ expect(recording.entries[0].modelName).toBe("test-model");
69
+ expect(recording.entries[0].systemInstruction).toBe("You are a calculator");
70
+ expect(recording.entries[0].callHash).toHaveLength(8);
71
+ expect(recording.entries[0].durationMs).toBeGreaterThanOrEqual(0);
72
+ });
73
+
74
+ it("exposes providerName and modelName from inner provider", () => {
75
+ const inner = makeMockProvider([]);
76
+ const recorder = new VcrRecordingProvider(inner);
77
+ expect(recorder.providerName).toBe("test-provider");
78
+ expect(recorder.modelName).toBe("test-model");
79
+ });
80
+
81
+ it("omits systemInstruction from entry when not provided", async () => {
82
+ const inner = makeMockProvider(["ok"]);
83
+ const recorder = new VcrRecordingProvider(inner);
84
+ await recorder.generate("hi");
85
+
86
+ const filePath = await recorder.save(tmpDir, "run-no-sys");
87
+ const recording: VcrRecording = await fs.readJson(filePath);
88
+ expect(recording.entries[0].systemInstruction).toBeUndefined();
89
+ });
90
+
91
+ it("truncates promptPreview to 200 chars", async () => {
92
+ const inner = makeMockProvider(["ok"]);
93
+ const recorder = new VcrRecordingProvider(inner);
94
+ const longPrompt = "x".repeat(500);
95
+ await recorder.generate(longPrompt);
96
+
97
+ const filePath = await recorder.save(tmpDir, "run-long");
98
+ const recording: VcrRecording = await fs.readJson(filePath);
99
+ expect(recording.entries[0].promptPreview.length).toBeLessThanOrEqual(200);
100
+ });
101
+
102
+ it("saves to .ai-spec-vcr directory", async () => {
103
+ const inner = makeMockProvider(["ok"]);
104
+ const recorder = new VcrRecordingProvider(inner);
105
+ await recorder.generate("test");
106
+
107
+ const filePath = await recorder.save(tmpDir, "run-dir-check");
108
+ expect(filePath).toContain(VCR_DIR);
109
+ expect(await fs.pathExists(filePath)).toBe(true);
110
+ });
111
+
112
+ it("merges entries from a second recorder sorted by timestamp", async () => {
113
+ const provider1 = makeMockProvider(["r1-a", "r1-b"]);
114
+ const provider2 = makeMockProvider(["r2-a"]);
115
+
116
+ const rec1 = new VcrRecordingProvider(provider1);
117
+ const rec2 = new VcrRecordingProvider(provider2);
118
+
119
+ await rec1.generate("p1");
120
+ await rec2.generate("p2");
121
+ await rec1.generate("p3");
122
+
123
+ const filePath = await rec1.save(tmpDir, "merged-run", rec2);
124
+ const recording: VcrRecording = await fs.readJson(filePath);
125
+
126
+ expect(recording.entryCount).toBe(3);
127
+ // All entries should have sequential indices after merge
128
+ expect(recording.entries.map((e) => e.index)).toEqual([0, 1, 2]);
129
+ // Providers should include both
130
+ expect(recording.providers).toContain("test-provider/test-model");
131
+ });
132
+
133
+ it("records multiple providers in providers array", async () => {
134
+ const p1 = { ...makeMockProvider(["a"]), providerName: "gemini", modelName: "pro" };
135
+ const p2 = { ...makeMockProvider(["b"]), providerName: "claude", modelName: "sonnet" };
136
+
137
+ const rec1 = new VcrRecordingProvider(p1);
138
+ const rec2 = new VcrRecordingProvider(p2);
139
+
140
+ await rec1.generate("x");
141
+ await rec2.generate("y");
142
+
143
+ const filePath = await rec1.save(tmpDir, "multi-provider", rec2);
144
+ const recording: VcrRecording = await fs.readJson(filePath);
145
+
146
+ expect(recording.providers).toContain("gemini/pro");
147
+ expect(recording.providers).toContain("claude/sonnet");
148
+ });
149
+ });
150
+
151
+ // ─── VcrReplayProvider ───────────────────────────────────────────────────────
152
+
153
+ describe("VcrReplayProvider", () => {
154
+ function makeRecording(responses: string[]): VcrRecording {
155
+ return {
156
+ runId: "test-replay",
157
+ recordedAt: new Date().toISOString(),
158
+ entryCount: responses.length,
159
+ providers: ["test/model"],
160
+ entries: responses.map((r, i) => ({
161
+ index: i,
162
+ promptPreview: "preview",
163
+ callHash: "abcd1234",
164
+ response: r,
165
+ providerName: "test",
166
+ modelName: "model",
167
+ ts: new Date().toISOString(),
168
+ durationMs: 100,
169
+ })),
170
+ };
171
+ }
172
+
173
+ it("replays responses in order", async () => {
174
+ const recording = makeRecording(["first", "second", "third"]);
175
+ const replay = new VcrReplayProvider(recording);
176
+
177
+ expect(await replay.generate("any prompt")).toBe("first");
178
+ expect(await replay.generate("another")).toBe("second");
179
+ expect(await replay.generate("third")).toBe("third");
180
+ });
181
+
182
+ it("exposes providerName as vcr-replay", () => {
183
+ const recording = makeRecording([]);
184
+ const replay = new VcrReplayProvider(recording);
185
+ expect(replay.providerName).toBe("vcr-replay");
186
+ });
187
+
188
+ it("exposes modelName as runId", () => {
189
+ const recording = makeRecording([]);
190
+ const replay = new VcrReplayProvider(recording);
191
+ expect(replay.modelName).toBe("test-replay");
192
+ });
193
+
194
+ it("tracks remaining and consumed counts", async () => {
195
+ const recording = makeRecording(["a", "b", "c"]);
196
+ const replay = new VcrReplayProvider(recording);
197
+
198
+ expect(replay.remaining).toBe(3);
199
+ expect(replay.consumed).toBe(0);
200
+
201
+ await replay.generate("x");
202
+
203
+ expect(replay.remaining).toBe(2);
204
+ expect(replay.consumed).toBe(1);
205
+ });
206
+
207
+ it("throws when replay is exhausted", async () => {
208
+ const recording = makeRecording(["only-one"]);
209
+ const replay = new VcrReplayProvider(recording);
210
+
211
+ await replay.generate("first");
212
+
213
+ await expect(replay.generate("second")).rejects.toThrow("VCR replay exhausted");
214
+ });
215
+
216
+ it("ignores prompt content — replays purely by index order", async () => {
217
+ const recording = makeRecording(["answer-A", "answer-B"]);
218
+ const replay = new VcrReplayProvider(recording);
219
+
220
+ // Prompts are completely different from recording — doesn't matter
221
+ expect(await replay.generate("completely different prompt")).toBe("answer-A");
222
+ expect(await replay.generate("also different", "with system")).toBe("answer-B");
223
+ });
224
+ });
225
+
226
+ // ─── loadVcrRecording ────────────────────────────────────────────────────────
227
+
228
+ describe("loadVcrRecording", () => {
229
+ it("loads a valid recording from disk", async () => {
230
+ const vcrDir = path.join(tmpDir, VCR_DIR);
231
+ await fs.ensureDir(vcrDir);
232
+
233
+ const recording: VcrRecording = {
234
+ runId: "test-load",
235
+ recordedAt: "2024-01-01T00:00:00.000Z",
236
+ entryCount: 1,
237
+ providers: ["test/model"],
238
+ entries: [
239
+ {
240
+ index: 0, promptPreview: "hi", callHash: "abc12345",
241
+ response: "hello", providerName: "test", modelName: "model",
242
+ ts: "2024-01-01T00:00:00.000Z", durationMs: 50,
243
+ },
244
+ ],
245
+ };
246
+ await fs.writeJson(path.join(vcrDir, "test-load.json"), recording);
247
+
248
+ const loaded = await loadVcrRecording(tmpDir, "test-load");
249
+ expect(loaded).not.toBeNull();
250
+ expect(loaded!.runId).toBe("test-load");
251
+ expect(loaded!.entries.length).toBe(1);
252
+ expect(loaded!.entries[0].response).toBe("hello");
253
+ });
254
+
255
+ it("returns null for non-existent recording", async () => {
256
+ const loaded = await loadVcrRecording(tmpDir, "nonexistent");
257
+ expect(loaded).toBeNull();
258
+ });
259
+
260
+ it("returns null for corrupt JSON", async () => {
261
+ const vcrDir = path.join(tmpDir, VCR_DIR);
262
+ await fs.ensureDir(vcrDir);
263
+ await fs.writeFile(path.join(vcrDir, "corrupt.json"), "not json{{{");
264
+
265
+ const loaded = await loadVcrRecording(tmpDir, "corrupt");
266
+ expect(loaded).toBeNull();
267
+ });
268
+ });
269
+
270
+ // ─── listVcrRecordings ───────────────────────────────────────────────────────
271
+
272
+ describe("listVcrRecordings", () => {
273
+ it("returns empty array when VCR dir does not exist", async () => {
274
+ const result = await listVcrRecordings(tmpDir);
275
+ expect(result).toEqual([]);
276
+ });
277
+
278
+ it("lists all valid recordings sorted reverse alphabetically", async () => {
279
+ const vcrDir = path.join(tmpDir, VCR_DIR);
280
+ await fs.ensureDir(vcrDir);
281
+
282
+ for (const id of ["run-001", "run-002", "run-003"]) {
283
+ await fs.writeJson(path.join(vcrDir, `${id}.json`), {
284
+ runId: id,
285
+ recordedAt: "2024-01-01T00:00:00.000Z",
286
+ entryCount: 0,
287
+ providers: [],
288
+ entries: [],
289
+ });
290
+ }
291
+
292
+ const result = await listVcrRecordings(tmpDir);
293
+ expect(result.length).toBe(3);
294
+ // Reverse sorted: run-003, run-002, run-001
295
+ expect(result[0].runId).toBe("run-003");
296
+ expect(result[1].runId).toBe("run-002");
297
+ expect(result[2].runId).toBe("run-001");
298
+ });
299
+
300
+ it("skips corrupt files gracefully", async () => {
301
+ const vcrDir = path.join(tmpDir, VCR_DIR);
302
+ await fs.ensureDir(vcrDir);
303
+
304
+ await fs.writeJson(path.join(vcrDir, "good.json"), {
305
+ runId: "good",
306
+ recordedAt: "2024-01-01",
307
+ entryCount: 0,
308
+ providers: [],
309
+ entries: [],
310
+ });
311
+ await fs.writeFile(path.join(vcrDir, "bad.json"), "corrupt{{{");
312
+
313
+ const result = await listVcrRecordings(tmpDir);
314
+ expect(result.length).toBe(1);
315
+ expect(result[0].runId).toBe("good");
316
+ });
317
+
318
+ it("ignores non-JSON files", async () => {
319
+ const vcrDir = path.join(tmpDir, VCR_DIR);
320
+ await fs.ensureDir(vcrDir);
321
+
322
+ await fs.writeFile(path.join(vcrDir, "readme.md"), "# VCR recordings");
323
+ await fs.writeJson(path.join(vcrDir, "valid.json"), {
324
+ runId: "valid",
325
+ recordedAt: "2024-01-01",
326
+ entryCount: 0,
327
+ providers: [],
328
+ entries: [],
329
+ });
330
+
331
+ const result = await listVcrRecordings(tmpDir);
332
+ expect(result.length).toBe(1);
333
+ });
334
+
335
+ it("returns correct summary fields", async () => {
336
+ const vcrDir = path.join(tmpDir, VCR_DIR);
337
+ await fs.ensureDir(vcrDir);
338
+
339
+ await fs.writeJson(path.join(vcrDir, "summary-test.json"), {
340
+ runId: "summary-test",
341
+ recordedAt: "2024-06-15T12:00:00.000Z",
342
+ entryCount: 5,
343
+ providers: ["gemini/pro", "claude/sonnet"],
344
+ entries: [],
345
+ });
346
+
347
+ const result = await listVcrRecordings(tmpDir);
348
+ expect(result[0]).toEqual({
349
+ runId: "summary-test",
350
+ recordedAt: "2024-06-15T12:00:00.000Z",
351
+ entryCount: 5,
352
+ providers: ["gemini/pro", "claude/sonnet"],
353
+ });
354
+ });
355
+ });