@node-llm/testing 0.1.0 → 0.2.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.
- package/CHANGELOG.md +22 -1
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/Mocker.d.ts +18 -0
- package/dist/Mocker.d.ts.map +1 -1
- package/dist/Mocker.js +39 -0
- package/dist/Scrubber.d.ts.map +1 -1
- package/dist/Scrubber.js +25 -0
- package/dist/Serializer.d.ts +16 -0
- package/dist/Serializer.d.ts.map +1 -0
- package/dist/Serializer.js +107 -0
- package/dist/vcr.d.ts +8 -6
- package/dist/vcr.d.ts.map +1 -1
- package/dist/vcr.js +11 -11
- package/package.json +8 -8
- package/src/Mocker.ts +55 -0
- package/src/Scrubber.ts +30 -0
- package/src/Serializer.ts +113 -0
- package/src/vcr.ts +37 -27
- package/test/cassettes/handles-rich-types.json +38 -0
- package/test/unit/__snapshots__/mocker_snapshots.test.ts.snap +12 -0
- package/test/unit/dx.test.ts +1 -1
- package/test/unit/mocker_history.test.ts +115 -0
- package/test/unit/mocker_snapshots.test.ts +48 -0
- package/test/unit/scoping.test.ts +8 -4
- package/test/unit/scrubbing.test.ts +9 -2
- package/test/unit/serializer.test.ts +81 -0
- package/test/unit/vcr-global-config.test.ts +15 -3
- package/test/unit/vcr-mismatch.test.ts +2 -1
- package/test/unit/vcr_advanced_types.test.ts +72 -0
|
@@ -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:
|
|
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:
|
|
25
|
-
response:
|
|
26
|
-
chunks?:
|
|
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:
|
|
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 =
|
|
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,
|
|
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:
|
|
182
|
-
request:
|
|
183
|
-
): Promise<
|
|
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:
|
|
210
|
-
request:
|
|
211
|
-
): AsyncIterable<
|
|
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:
|
|
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:
|
|
244
|
-
|
|
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:
|
|
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(
|
|
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"`;
|
package/test/unit/dx.test.ts
CHANGED
|
@@ -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(
|
|
33
|
-
|
|
34
|
-
|
|
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
|
});
|
|
@@ -17,7 +17,7 @@ describe("VCR Feature 4: Automatic Scrubbing", () => {
|
|
|
17
17
|
CASSETTE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vcr-scrub-test-"));
|
|
18
18
|
CASSETTE_PATH = path.join(CASSETTE_DIR, `${CASSETTE_NAME}.json`);
|
|
19
19
|
mock = new MockProvider();
|
|
20
|
-
providerRegistry.register("mock-provider", () => mock);
|
|
20
|
+
providerRegistry.register("mock-provider", () => mock as any);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
afterEach(() => {
|
|
@@ -29,7 +29,11 @@ describe("VCR Feature 4: Automatic Scrubbing", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test("Automatically scrubs API keys and sensitive JSON keys", async () => {
|
|
32
|
-
const vcr = setupVCR(CASSETTE_NAME, {
|
|
32
|
+
const vcr = setupVCR(CASSETTE_NAME, {
|
|
33
|
+
mode: "record",
|
|
34
|
+
cassettesDir: CASSETTE_DIR,
|
|
35
|
+
_allowRecordingInCI: true
|
|
36
|
+
});
|
|
33
37
|
const llm = NodeLLM.withProvider("mock-provider");
|
|
34
38
|
|
|
35
39
|
// 1. Trigger request with secrets
|
|
@@ -50,6 +54,7 @@ describe("VCR Feature 4: Automatic Scrubbing", () => {
|
|
|
50
54
|
const vcr = setupVCR(CASSETTE_NAME, {
|
|
51
55
|
mode: "record",
|
|
52
56
|
cassettesDir: CASSETTE_DIR,
|
|
57
|
+
_allowRecordingInCI: true,
|
|
53
58
|
scrub: (data: unknown) => {
|
|
54
59
|
// Deep string replacement on the whole interaction object
|
|
55
60
|
return JSON.parse(JSON.stringify(data).replace(/sensitive-info/g, "XXXX"));
|
|
@@ -69,6 +74,7 @@ describe("VCR Feature 4: Automatic Scrubbing", () => {
|
|
|
69
74
|
const vcr = setupVCR("custom-scrub-config", {
|
|
70
75
|
mode: "record",
|
|
71
76
|
cassettesDir: CASSETTE_DIR,
|
|
77
|
+
_allowRecordingInCI: true,
|
|
72
78
|
sensitiveKeys: ["user_email", "internal_id"],
|
|
73
79
|
sensitivePatterns: [/secret-project-[a-z]+/g]
|
|
74
80
|
});
|
|
@@ -90,6 +96,7 @@ describe("VCR Feature 4: Automatic Scrubbing", () => {
|
|
|
90
96
|
const vcr = setupVCR("defaults-plus-custom", {
|
|
91
97
|
mode: "record",
|
|
92
98
|
cassettesDir: CASSETTE_DIR,
|
|
99
|
+
_allowRecordingInCI: true,
|
|
93
100
|
sensitiveKeys: ["custom_field"]
|
|
94
101
|
});
|
|
95
102
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { Serializer } from "../../src/Serializer.js";
|
|
3
|
+
|
|
4
|
+
describe("Serializer", () => {
|
|
5
|
+
test("Handles Date objects", () => {
|
|
6
|
+
const data = { date: new Date("2023-01-01T00:00:00.000Z") };
|
|
7
|
+
const serialized = Serializer.serialize(data);
|
|
8
|
+
const deserialized = Serializer.deserialize(serialized);
|
|
9
|
+
expect(deserialized).toEqual(data);
|
|
10
|
+
expect((deserialized as any).date).toBeInstanceOf(Date);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("Handles Map objects", () => {
|
|
14
|
+
const data = {
|
|
15
|
+
map: new Map<string, string | number>([
|
|
16
|
+
["key", "value"],
|
|
17
|
+
["num", 123]
|
|
18
|
+
])
|
|
19
|
+
};
|
|
20
|
+
const serialized = Serializer.serialize(data);
|
|
21
|
+
const deserialized = Serializer.deserialize(serialized);
|
|
22
|
+
expect(deserialized).toEqual(data);
|
|
23
|
+
expect((deserialized as any).map).toBeInstanceOf(Map);
|
|
24
|
+
expect((deserialized as any).map.get("key")).toBe("value");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Handles Set objects", () => {
|
|
28
|
+
const data = { set: new Set([1, 2, 3, "four"]) };
|
|
29
|
+
const serialized = Serializer.serialize(data);
|
|
30
|
+
const deserialized = Serializer.deserialize(serialized);
|
|
31
|
+
expect(deserialized).toEqual(data);
|
|
32
|
+
expect((deserialized as any).set).toBeInstanceOf(Set);
|
|
33
|
+
expect((deserialized as any).set.has("four")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Handles RegExp objects", () => {
|
|
37
|
+
const data = { regex: /abc/gi };
|
|
38
|
+
const serialized = Serializer.serialize(data);
|
|
39
|
+
const deserialized = Serializer.deserialize(serialized);
|
|
40
|
+
expect(deserialized).toEqual(data);
|
|
41
|
+
expect((deserialized as any).regex).toBeInstanceOf(RegExp);
|
|
42
|
+
expect((deserialized as any).regex.flags).toContain("g");
|
|
43
|
+
expect((deserialized as any).regex.flags).toContain("i");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("Handles Error objects", () => {
|
|
47
|
+
const err = new Error("Test error");
|
|
48
|
+
err.name = "CustomError";
|
|
49
|
+
(err as any).customProp = "customValue";
|
|
50
|
+
|
|
51
|
+
const data = { error: err };
|
|
52
|
+
const serialized = Serializer.serialize(data);
|
|
53
|
+
const deserialized: any = Serializer.deserialize(serialized);
|
|
54
|
+
|
|
55
|
+
expect(deserialized.error).toBeInstanceOf(Error);
|
|
56
|
+
expect(deserialized.error.message).toBe("Test error");
|
|
57
|
+
expect(deserialized.error.name).toBe("CustomError");
|
|
58
|
+
expect(deserialized.error.customProp).toBe("customValue");
|
|
59
|
+
expect(deserialized.error.stack).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("Handles Buffer objects", () => {
|
|
63
|
+
if (typeof Buffer === "undefined") return; // Skip in browser-like envs if any
|
|
64
|
+
const data = { buf: Buffer.from("Hello World") };
|
|
65
|
+
const serialized = Serializer.serialize(data);
|
|
66
|
+
const deserialized: any = Serializer.deserialize(serialized);
|
|
67
|
+
|
|
68
|
+
expect(deserialized.buf).toBeInstanceOf(Buffer);
|
|
69
|
+
expect(deserialized.buf.toString()).toBe("Hello World");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Handles Infinity and NaN", () => {
|
|
73
|
+
const data = { inf: Infinity, negInf: -Infinity, nan: NaN };
|
|
74
|
+
const serialized = Serializer.serialize(data);
|
|
75
|
+
const deserialized: any = Serializer.deserialize(serialized);
|
|
76
|
+
|
|
77
|
+
expect(deserialized.inf).toBe(Infinity);
|
|
78
|
+
expect(deserialized.negInf).toBe(-Infinity);
|
|
79
|
+
expect(Number.isNaN(deserialized.nan)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|