@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/CHANGELOG.md +22 -1
- package/LICENSE +21 -0
- package/README.md +42 -3
- package/dist/Mocker.d.ts +27 -2
- package/dist/Mocker.d.ts.map +1 -1
- package/dist/Mocker.js +43 -3
- 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 +67 -3
- 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
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:
|
|
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
|
});
|