@node-llm/testing 0.1.0 → 0.2.1

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.
package/src/Mocker.ts CHANGED
@@ -55,14 +55,56 @@ export interface MockerDebugInfo {
55
55
 
56
56
  const EXECUTION_METHODS = ["chat", "stream", "paint", "transcribe", "moderate", "embed"];
57
57
 
58
+ export interface MockerOptions {
59
+ /**
60
+ * Enforce that every LLM call must have a corresponding mock.
61
+ * If true, unmocked calls throw an error.
62
+ */
63
+ strict?: boolean;
64
+ }
65
+
66
+ export interface MockCall {
67
+ method: string;
68
+ args: unknown[];
69
+ timestamp: number;
70
+ /**
71
+ * Convenience accessor for the primary input "prompt" of the call.
72
+ * - chat/stream: `messages`
73
+ * - embed/moderate: `input`
74
+ * - paint: `prompt`
75
+ * - transcribe: `file`
76
+ */
77
+ prompt?: unknown;
78
+ }
79
+
58
80
  export class Mocker {
59
81
  private mocks: MockDefinition[] = [];
82
+ private _history: MockCall[] = [];
60
83
  public strict = false;
61
84
 
62
- constructor() {
85
+ constructor(options: MockerOptions = {}) {
86
+ this.strict = !!options.strict;
63
87
  this.setupInterceptor();
64
88
  }
65
89
 
90
+ public get history(): MockCall[] {
91
+ return [...this._history];
92
+ }
93
+
94
+ public getCalls(method?: string): MockCall[] {
95
+ if (!method) return this.history;
96
+ return this._history.filter((c) => c.method === method);
97
+ }
98
+
99
+ public getLastCall(method?: string): MockCall | undefined {
100
+ const calls = this.getCalls(method);
101
+ return calls[calls.length - 1];
102
+ }
103
+
104
+ public resetHistory(): void {
105
+ this._history = [];
106
+ }
107
+
66
108
  public chat(query?: string | RegExp): this {
67
109
  return this.addMock("chat", (req: unknown) => {
68
110
  const chatReq = req as ChatRequest;
@@ -178,6 +220,7 @@ export class Mocker {
178
220
 
179
221
  public clear(): void {
180
222
  this.mocks = [];
223
+ this._history = [];
181
224
  providerRegistry.setInterceptor(undefined);
182
225
  }
183
226
 
@@ -207,6 +250,13 @@ export class Mocker {
207
250
  if (EXECUTION_METHODS.includes(methodName)) {
208
251
  if (methodName === "stream") {
209
252
  return async function* (this: Mocker, request: ChatRequest) {
253
+ this._history.push({
254
+ method: methodName,
255
+ args: [request],
256
+ timestamp: Date.now(),
257
+ prompt: request.messages
258
+ });
259
+
210
260
  const matchingMocks = this.mocks.filter(
211
261
  (m) => m.method === methodName && m.match(request)
212
262
  );
@@ -234,6 +284,20 @@ export class Mocker {
234
284
 
235
285
  // Promise-based methods
236
286
  return (async (request: unknown) => {
287
+ const req = request as any;
288
+ let promptAttr: unknown;
289
+ if (methodName === "chat") promptAttr = req.messages;
290
+ else if (methodName === "embed" || methodName === "moderate") promptAttr = req.input;
291
+ else if (methodName === "paint") promptAttr = req.prompt;
292
+ else if (methodName === "transcribe") promptAttr = req.file;
293
+
294
+ this._history.push({
295
+ method: methodName,
296
+ args: [request],
297
+ timestamp: Date.now(),
298
+ prompt: promptAttr
299
+ });
300
+
237
301
  const matchingMocks = this.mocks.filter(
238
302
  (m) => m.method === methodName && m.match(request)
239
303
  );
@@ -306,6 +370,6 @@ export class Mocker {
306
370
  }
307
371
  }
308
372
 
309
- export function mockLLM() {
310
- return new Mocker();
373
+ export function mockLLM(options: MockerOptions = {}) {
374
+ return new Mocker(options);
311
375
  }
package/src/Scrubber.ts CHANGED
@@ -56,6 +56,36 @@ export class Scrubber {
56
56
  return val.map((v) => this.deepScrub(v));
57
57
  }
58
58
 
59
+ if (val instanceof Map) {
60
+ const newMap = new Map();
61
+ for (const [k, v] of val.entries()) {
62
+ const scrubbedKey = typeof k === "string" ? this.deepScrub(k) : k;
63
+ const scrubbedValue = this.deepScrub(v);
64
+ newMap.set(scrubbedKey, scrubbedValue);
65
+ }
66
+ return newMap;
67
+ }
68
+
69
+ if (val instanceof Set) {
70
+ const newSet = new Set();
71
+ for (const v of val.values()) {
72
+ newSet.add(this.deepScrub(v));
73
+ }
74
+ return newSet;
75
+ }
76
+
77
+ if (val instanceof Date) {
78
+ return new Date(val);
79
+ }
80
+
81
+ if (val instanceof RegExp) {
82
+ return new RegExp(val);
83
+ }
84
+
85
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(val)) {
86
+ return Buffer.from(val);
87
+ }
88
+
59
89
  if (val !== null && typeof val === "object") {
60
90
  const obj = val as Record<string, unknown>;
61
91
  const newObj: Record<string, unknown> = {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Handles serialization and deserialization of complex types that JSON.stringify/parse
3
+ * natively lose (Date, Map, Set, RegExp, Error, Infinity, NaN, Buffer).
4
+ */
5
+ export class Serializer {
6
+ public static serialize(data: unknown, space?: string | number): string {
7
+ return JSON.stringify(data, Serializer.replacer, space);
8
+ }
9
+
10
+ public static deserialize<T = unknown>(json: string): T {
11
+ return JSON.parse(json, Serializer.reviver);
12
+ }
13
+
14
+ /**
15
+ * Deep clone that preserves types better than JSON.parse(JSON.stringify(x))
16
+ * Uses structuredClone if available (Node 17+), otherwise falls back to serialization.
17
+ */
18
+ public static clone<T>(data: T): T {
19
+ if (typeof globalThis.structuredClone === "function") {
20
+ try {
21
+ return globalThis.structuredClone(data);
22
+ } catch (err) {
23
+ // Fallback for types structuredClone might not handle or implementation quirks
24
+ }
25
+ }
26
+ return Serializer.deserialize(Serializer.serialize(data));
27
+ }
28
+
29
+ private static replacer(this: any, key: string, value: unknown): unknown {
30
+ const originalValue = this[key];
31
+
32
+ if (originalValue === Infinity) return { $type: "Infinity" };
33
+ if (originalValue === -Infinity) return { $type: "-Infinity" };
34
+ if (Number.isNaN(originalValue)) return { $type: "NaN" };
35
+
36
+ if (originalValue instanceof Date) {
37
+ return { $type: "Date", value: originalValue.toISOString() };
38
+ }
39
+
40
+ if (originalValue instanceof RegExp) {
41
+ return { $type: "RegExp", source: originalValue.source, flags: originalValue.flags };
42
+ }
43
+
44
+ if (originalValue instanceof Map) {
45
+ return { $type: "Map", value: Array.from(originalValue.entries()) };
46
+ }
47
+
48
+ if (originalValue instanceof Set) {
49
+ return { $type: "Set", value: Array.from(originalValue.values()) };
50
+ }
51
+
52
+ if (originalValue instanceof Error) {
53
+ return {
54
+ ...originalValue, // Capture literal properties attached to the error
55
+ $type: "Error",
56
+ name: originalValue.name,
57
+ message: originalValue.message,
58
+ stack: originalValue.stack,
59
+ cause: (originalValue as any).cause
60
+ };
61
+ }
62
+
63
+ // Handle Buffers (Node.js)
64
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(originalValue)) {
65
+ return { $type: "Buffer", value: originalValue.toString("base64") };
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ private static reviver(key: string, value: unknown): unknown {
72
+ if (value && typeof value === "object" && "$type" in value) {
73
+ const typedValue = value as { $type: string; value?: any; [key: string]: any };
74
+
75
+ switch (typedValue.$type) {
76
+ case "Date":
77
+ return new Date(typedValue.value);
78
+ case "RegExp":
79
+ return new RegExp(typedValue.source, typedValue.flags);
80
+ case "Map":
81
+ return new Map(typedValue.value);
82
+ case "Set":
83
+ return new Set(typedValue.value);
84
+ case "Infinity":
85
+ return Infinity;
86
+ case "-Infinity":
87
+ return -Infinity;
88
+ case "NaN":
89
+ return NaN;
90
+ case "Error": {
91
+ const err = new Error(typedValue.message);
92
+ err.name = typedValue.name;
93
+ if (typedValue.stack) err.stack = typedValue.stack;
94
+ if (typedValue.cause) (err as any).cause = typedValue.cause;
95
+ // Restore other properties
96
+ for (const k in typedValue) {
97
+ if (["$type", "name", "message", "stack", "cause"].includes(k)) continue;
98
+ (err as any)[k] = typedValue[k];
99
+ }
100
+ return err;
101
+ }
102
+ case "Buffer":
103
+ if (typeof Buffer !== "undefined") {
104
+ return Buffer.from(typedValue.value, "base64");
105
+ }
106
+ return typedValue.value;
107
+ default:
108
+ return value;
109
+ }
110
+ }
111
+ return value;
112
+ }
113
+ }
package/src/vcr.ts CHANGED
@@ -2,12 +2,13 @@ import { Provider, providerRegistry } from "@node-llm/core";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Scrubber } from "./Scrubber.js";
5
+ import { Serializer } from "./Serializer.js";
5
6
 
6
7
  // Internal state for nested scoping (Feature 12)
7
8
  const currentVCRScopes: string[] = [];
8
9
 
9
10
  // Try to import Vitest's expect to get test state
10
- let vitestExpect: any;
11
+ let vitestExpect: { getState?: () => { currentTestName?: string } } | undefined;
11
12
  try {
12
13
  // @ts-ignore
13
14
  import("vitest").then((m) => {
@@ -21,9 +22,9 @@ export type VCRMode = "record" | "replay" | "auto" | "passthrough";
21
22
 
22
23
  export interface VCRInteraction {
23
24
  method: string;
24
- request: any;
25
- response: any;
26
- chunks?: any[];
25
+ request: unknown;
26
+ response: unknown;
27
+ chunks?: unknown[];
27
28
  }
28
29
 
29
30
  export interface VCRCassette {
@@ -40,11 +41,13 @@ export interface VCRCassette {
40
41
 
41
42
  export interface VCROptions {
42
43
  mode?: VCRMode;
43
- scrub?: (data: any) => any;
44
+ scrub?: (data: unknown) => unknown;
44
45
  cassettesDir?: string;
45
46
  scope?: string | string[]; // Allow single or multiple scopes
46
47
  sensitivePatterns?: RegExp[];
47
48
  sensitiveKeys?: string[];
49
+ /** @internal - For testing VCR library itself. Bypasses CI recording check. */
50
+ _allowRecordingInCI?: boolean;
48
51
  }
49
52
 
50
53
  export class VCR {
@@ -95,9 +98,10 @@ export class VCR {
95
98
  const initialMode = mergedOptions.mode || (process.env.VCR_MODE as VCRMode) || "auto";
96
99
  const isCI = !!process.env.CI;
97
100
  const exists = fs.existsSync(this.filePath);
101
+ const allowRecordingInCI = mergedOptions._allowRecordingInCI === true;
98
102
 
99
- // CI Enforcement
100
- if (isCI) {
103
+ // CI Enforcement (can be bypassed for VCR library's own unit tests)
104
+ if (isCI && !allowRecordingInCI) {
101
105
  if (initialMode === "record") {
102
106
  throw new Error(`VCR[${name}]: Recording cassettes is not allowed in CI.`);
103
107
  }
@@ -136,7 +140,7 @@ export class VCR {
136
140
  if (!exists) {
137
141
  throw new Error(`VCR[${name}]: Cassette not found at ${this.filePath}`);
138
142
  }
139
- this.cassette = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
143
+ this.cassette = Serializer.deserialize(fs.readFileSync(this.filePath, "utf-8"));
140
144
  } else {
141
145
  this.cassette = {
142
146
  name,
@@ -167,7 +171,7 @@ export class VCR {
167
171
  this.cassette.metadata.duration = duration;
168
172
  }
169
173
 
170
- fs.writeFileSync(this.filePath, JSON.stringify(this.cassette, null, 2));
174
+ fs.writeFileSync(this.filePath, Serializer.serialize(this.cassette, 2));
171
175
  }
172
176
  providerRegistry.setInterceptor(undefined);
173
177
  }
@@ -178,9 +182,9 @@ export class VCR {
178
182
 
179
183
  public async execute(
180
184
  method: string,
181
- originalMethod: (...args: any[]) => Promise<any>,
182
- request: any
183
- ): Promise<any> {
185
+ originalMethod: (...args: unknown[]) => Promise<unknown>,
186
+ request: unknown
187
+ ): Promise<unknown> {
184
188
  if (this.mode === "replay") {
185
189
  const interaction = this.cassette.interactions[this.interactionIndex++];
186
190
  if (!interaction) {
@@ -206,9 +210,9 @@ export class VCR {
206
210
 
207
211
  public async *executeStream(
208
212
  method: string,
209
- originalMethod: (...args: any[]) => AsyncIterable<any>,
210
- request: any
211
- ): AsyncIterable<any> {
213
+ originalMethod: (...args: unknown[]) => AsyncIterable<unknown>,
214
+ request: unknown
215
+ ): AsyncIterable<unknown> {
212
216
  if (this.mode === "replay") {
213
217
  const interaction = this.cassette.interactions[this.interactionIndex++];
214
218
  if (!interaction || !interaction.chunks) {
@@ -221,7 +225,7 @@ export class VCR {
221
225
  }
222
226
 
223
227
  const stream = originalMethod(request);
224
- const chunks: any[] = [];
228
+ const chunks: unknown[] = [];
225
229
 
226
230
  for await (const chunk of stream) {
227
231
  if (this.mode === "record") chunks.push(this.clone(chunk));
@@ -240,12 +244,8 @@ export class VCR {
240
244
  }
241
245
  }
242
246
 
243
- private clone(obj: any) {
244
- try {
245
- return JSON.parse(JSON.stringify(obj));
246
- } catch {
247
- return obj;
248
- }
247
+ private clone(obj: unknown): unknown {
248
+ return Serializer.clone(obj);
249
249
  }
250
250
 
251
251
  private slugify(text: string): string {
@@ -271,7 +271,7 @@ export function setupVCR(name: string, options: VCROptions = {}) {
271
271
  const method = prop.toString();
272
272
 
273
273
  if (typeof originalValue === "function" && EXECUTION_METHODS.includes(method)) {
274
- return function (...args: any[]) {
274
+ return function (...args: unknown[]) {
275
275
  if (method === "stream") {
276
276
  return vcr.executeStream(method, originalValue.bind(target), args[0]);
277
277
  }
@@ -297,14 +297,20 @@ export function withVCR(
297
297
  options: VCROptions,
298
298
  fn: () => Promise<void>
299
299
  ): () => Promise<void>;
300
- export function withVCR(...args: any[]): () => Promise<void> {
300
+ export function withVCR(
301
+ ...args: [
302
+ (() => Promise<void>) | string | VCROptions,
303
+ ((() => Promise<void>) | VCROptions)?,
304
+ (() => Promise<void>)?
305
+ ]
306
+ ): () => Promise<void> {
301
307
  // Capture scopes at initialization time
302
308
  const capturedScopes = [...currentVCRScopes];
303
309
 
304
310
  return async function () {
305
311
  let name: string | undefined;
306
312
  let options: VCROptions = {};
307
- let fn: () => Promise<void>;
313
+ let fn: (() => Promise<void>) | undefined;
308
314
 
309
315
  if (typeof args[0] === "function") {
310
316
  fn = args[0];
@@ -318,7 +324,11 @@ export function withVCR(...args: any[]): () => Promise<void> {
318
324
  }
319
325
  } else {
320
326
  options = args[0] || {};
321
- fn = args[1];
327
+ fn = args[1] as (() => Promise<void>) | undefined;
328
+ }
329
+
330
+ if (!fn) {
331
+ throw new Error("VCR: No test function provided.");
322
332
  }
323
333
 
324
334
  // Pass captured inherited scopes if not explicitly overridden
@@ -326,7 +336,7 @@ export function withVCR(...args: any[]): () => Promise<void> {
326
336
  options.scope = capturedScopes;
327
337
  }
328
338
 
329
- if (!name && vitestExpect) {
339
+ if (!name && vitestExpect?.getState) {
330
340
  const state = vitestExpect.getState();
331
341
  name = state.currentTestName || "unnamed-test";
332
342
  }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "Handles Rich Types",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T14:26:32.372Z",
6
+ "duration": 5
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "Hello"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000,
22
+ "createdAt": {},
23
+ "meta": {
24
+ "$type": "Map",
25
+ "value": [["key", "value"]]
26
+ }
27
+ },
28
+ "response": {
29
+ "content": "Response to Hello",
30
+ "usage": {
31
+ "input_tokens": 10,
32
+ "output_tokens": 10,
33
+ "total_tokens": 20
34
+ }
35
+ }
36
+ }
37
+ ]
38
+ }
@@ -0,0 +1,12 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Mocker Feature: Prompt Snapshots > Snapshots chat messages 1`] = `
4
+ [
5
+ {
6
+ "content": "Hello",
7
+ "role": "user",
8
+ },
9
+ ]
10
+ `;
11
+
12
+ exports[`Mocker Feature: Prompt Snapshots > Snapshots embedding input 1`] = `"text"`;
@@ -70,7 +70,7 @@ describe("VCR Feature 5 & 6: DX Sugar & Auto-Naming", () => {
70
70
  configureVCR({ cassettesDir: GLOBAL_DIR });
71
71
 
72
72
  // 2. Run a test relying on global dir (explicit record mode to create cassette)
73
- await withVCR("global-dir-test", { mode: "record" }, async () => {
73
+ await withVCR("global-dir-test", { mode: "record", _allowRecordingInCI: true }, async () => {
74
74
  const llm = NodeLLM.withProvider("mock-provider");
75
75
  await llm.chat().ask("global dir test");
76
76
  })();
@@ -0,0 +1,115 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry, Provider } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker Feature: Call History", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ providerRegistry.register("mock-provider", () => new MockProvider() as unknown as Provider);
11
+ mocker = mockLLM();
12
+ });
13
+
14
+ afterEach(() => {
15
+ mocker.clear();
16
+ });
17
+
18
+ test("Records chat calls in history", async () => {
19
+ mocker.chat("Hello").respond("Hi");
20
+ mocker.chat("Bye").respond("Goodbye");
21
+
22
+ const llm = NodeLLM.withProvider("mock-provider");
23
+ await llm.chat().ask("Hello");
24
+ await llm.chat().ask("Bye");
25
+
26
+ expect(mocker.history.length).toBe(2);
27
+ expect(mocker.history[0]!.method).toBe("chat");
28
+ expect((mocker.history[0]!.args[0] as any).messages[0].content).toBe("Hello");
29
+ expect((mocker.history[1]!.args[0] as any).messages[0].content).toBe("Bye");
30
+ });
31
+
32
+ test("Records streaming calls in history", async () => {
33
+ mocker.chat("Stream me").stream(["chunk1", "chunk2"]);
34
+ const llm = NodeLLM.withProvider("mock-provider");
35
+
36
+ const stream = llm.chat().stream("Stream me");
37
+ for await (const _ of stream) {
38
+ /* consume */
39
+ }
40
+
41
+ expect(mocker.history.length).toBe(1);
42
+ expect(mocker.history[0]!.method).toBe("stream");
43
+ expect((mocker.history[0]!.args[0] as any).messages[0].content).toBe("Stream me");
44
+ });
45
+
46
+ test("getCalls filters by method", async () => {
47
+ mocker.chat("ChatCall").respond("OK");
48
+ mocker.embed("EmbedCall").respond({ vectors: [[0.1]] });
49
+
50
+ const llm = NodeLLM.withProvider("mock-provider");
51
+ await llm.chat().ask("ChatCall");
52
+ await llm.embed("EmbedCall");
53
+
54
+ const chatCalls = mocker.getCalls("chat");
55
+ const embedCalls = mocker.getCalls("embed");
56
+
57
+ expect(chatCalls.length).toBe(1);
58
+ expect(chatCalls[0]!.method).toBe("chat");
59
+
60
+ expect(embedCalls.length).toBe(1);
61
+ expect(embedCalls[0]!.method).toBe("embed");
62
+
63
+ expect(mocker.history.length).toBe(2);
64
+ });
65
+
66
+ test("getLastCall retrieves the most recent call", async () => {
67
+ mocker.chat("First").respond("1");
68
+ mocker.chat("Second").respond("2");
69
+
70
+ const llm = NodeLLM.withProvider("mock-provider");
71
+ await llm.chat().ask("First");
72
+ await llm.chat().ask("Second");
73
+
74
+ const last = mocker.getLastCall();
75
+ expect(last).toBeDefined();
76
+ expect((last?.args[0] as any).messages[0].content).toBe("Second");
77
+ });
78
+
79
+ test("getLastCall with method filtering checks types", async () => {
80
+ mocker.embed("Embed").respond({ vectors: [[1]] });
81
+ mocker.chat("Chat").respond("Hi");
82
+
83
+ const llm = NodeLLM.withProvider("mock-provider");
84
+ await llm.embed("Embed");
85
+ await llm.chat().ask("Chat");
86
+
87
+ const lastEmbed = mocker.getLastCall("embed");
88
+ expect(lastEmbed).toBeDefined();
89
+ expect(lastEmbed?.method).toBe("embed");
90
+
91
+ const lastChat = mocker.getLastCall("chat");
92
+ expect(lastChat).toBeDefined();
93
+ expect(lastChat?.method).toBe("chat");
94
+ });
95
+
96
+ test("resetHistory clears history but preserves mocks", async () => {
97
+ mocker.chat("Preserved").respond("Still here");
98
+
99
+ const llm = NodeLLM.withProvider("mock-provider");
100
+ await llm.chat().ask("Preserved");
101
+
102
+ expect(mocker.history.length).toBe(1);
103
+
104
+ mocker.resetHistory();
105
+
106
+ expect(mocker.history.length).toBe(0);
107
+
108
+ // Mocks should still work
109
+ const res = await llm.chat().ask("Preserved");
110
+ expect(res.content).toBe("Still here");
111
+
112
+ // New history recorded
113
+ expect(mocker.history.length).toBe(1);
114
+ });
115
+ });
@@ -0,0 +1,48 @@
1
+ import { test, expect, describe, afterEach, beforeEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry, Provider } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker Feature: Prompt Snapshots", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ providerRegistry.register("mock-provider", () => new MockProvider() as unknown as Provider);
11
+ mocker = mockLLM();
12
+ });
13
+
14
+ afterEach(() => {
15
+ mocker.clear();
16
+ });
17
+
18
+ test("Snapshots chat messages", async () => {
19
+ mocker.chat("Hello").respond("Hi");
20
+
21
+ // Test basic chat snapshot
22
+ const llm = NodeLLM.withProvider("mock-provider");
23
+ await llm.chat().ask("Hello");
24
+
25
+ const lastCall = mocker.getLastCall();
26
+ expect(lastCall).toBeDefined();
27
+ // Snapshot should contain just the user message
28
+ expect(lastCall!.prompt).toMatchSnapshot();
29
+ });
30
+
31
+ test("Snapshots embedding input", async () => {
32
+ mocker.embed("text").respond({ vectors: [[0.1]] });
33
+
34
+ const llm = NodeLLM.withProvider("mock-provider");
35
+ await llm.embed("text");
36
+
37
+ expect(mocker.getLastCall()?.prompt).toMatchSnapshot();
38
+ });
39
+
40
+ test("Snapshots paint prompt", async () => {
41
+ mocker.paint("A sunset").respond({ url: "img.png" });
42
+
43
+ const llm = NodeLLM.withProvider("mock-provider");
44
+ await llm.paint("A sunset");
45
+
46
+ expect(mocker.getLastCall()?.prompt).toBe("A sunset");
47
+ });
48
+ });
@@ -29,10 +29,14 @@ describe("VCR Feature 12: Hierarchical Scoping", () => {
29
29
  await describeVCR(LEVEL_1, () => {
30
30
  return describeVCR(LEVEL_2, async () => {
31
31
  // Use explicit mode: "record" to test cassette creation in temp dir
32
- const testFn = withVCR(TEST_NAME, { mode: "record" }, async () => {
33
- const llm = NodeLLM.withProvider("mock-provider");
34
- await llm.chat().ask("Trigger record");
35
- });
32
+ const testFn = withVCR(
33
+ TEST_NAME,
34
+ { mode: "record", _allowRecordingInCI: true },
35
+ async () => {
36
+ const llm = NodeLLM.withProvider("mock-provider");
37
+ await llm.chat().ask("Trigger record");
38
+ }
39
+ );
36
40
 
37
41
  await testFn();
38
42
  });