@node-llm/testing 0.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +541 -0
  3. package/dist/Mocker.d.ts +58 -0
  4. package/dist/Mocker.d.ts.map +1 -0
  5. package/dist/Mocker.js +247 -0
  6. package/dist/Scrubber.d.ts +18 -0
  7. package/dist/Scrubber.d.ts.map +1 -0
  8. package/dist/Scrubber.js +68 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +2 -0
  12. package/dist/vcr.d.ts +57 -0
  13. package/dist/vcr.d.ts.map +1 -0
  14. package/dist/vcr.js +291 -0
  15. package/package.json +19 -0
  16. package/src/Mocker.ts +311 -0
  17. package/src/Scrubber.ts +85 -0
  18. package/src/index.ts +2 -0
  19. package/src/vcr.ts +377 -0
  20. package/test/cassettes/custom-scrub-config.json +33 -0
  21. package/test/cassettes/defaults-plus-custom.json +33 -0
  22. package/test/cassettes/explicit-sugar-test.json +33 -0
  23. package/test/cassettes/feature-1-vcr.json +33 -0
  24. package/test/cassettes/global-config-keys.json +33 -0
  25. package/test/cassettes/global-config-merge.json +33 -0
  26. package/test/cassettes/global-config-patterns.json +33 -0
  27. package/test/cassettes/global-config-reset.json +33 -0
  28. package/test/cassettes/global-config-test.json +33 -0
  29. package/test/cassettes/streaming-chunks.json +18 -0
  30. package/test/cassettes/testunitdxtestts-vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +33 -0
  31. package/test/cassettes/vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +28 -0
  32. package/test/cassettes/vcr-streaming.json +17 -0
  33. package/test/helpers/MockProvider.ts +75 -0
  34. package/test/unit/ci.test.ts +36 -0
  35. package/test/unit/dx.test.ts +86 -0
  36. package/test/unit/mocker-debug.test.ts +68 -0
  37. package/test/unit/mocker.test.ts +46 -0
  38. package/test/unit/multimodal.test.ts +46 -0
  39. package/test/unit/scoping.test.ts +54 -0
  40. package/test/unit/scrubbing.test.ts +110 -0
  41. package/test/unit/streaming.test.ts +51 -0
  42. package/test/unit/strict-mode.test.ts +112 -0
  43. package/test/unit/tools.test.ts +58 -0
  44. package/test/unit/vcr-global-config.test.ts +87 -0
  45. package/test/unit/vcr-mismatch.test.ts +172 -0
  46. package/test/unit/vcr-passthrough.test.ts +68 -0
  47. package/test/unit/vcr-streaming.test.ts +86 -0
  48. package/test/unit/vcr.test.ts +34 -0
  49. package/tsconfig.json +9 -0
  50. package/vitest.config.ts +12 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "test/unit/dx.test.ts > VCR Feature 5 & 6: DX Sugar & Auto-Naming > Automatically names and records cassettes",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:42:20.369Z",
6
+ "duration": 7
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "Sugar test"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to Sugar test",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "VCR Feature 5 & 6: DX Sugar & Auto-Naming > Automatically names and records cassettes",
3
+ "interactions": [
4
+ {
5
+ "method": "chat",
6
+ "request": {
7
+ "model": "mock-model",
8
+ "messages": [
9
+ {
10
+ "role": "user",
11
+ "content": "Sugar test"
12
+ }
13
+ ],
14
+ "max_tokens": 4096,
15
+ "headers": {},
16
+ "requestTimeout": 30000
17
+ },
18
+ "response": {
19
+ "content": "Response to Sugar test",
20
+ "usage": {
21
+ "input_tokens": 10,
22
+ "output_tokens": 10,
23
+ "total_tokens": 20
24
+ }
25
+ }
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "vcr-streaming",
3
+ "version": "1.0",
4
+ "interactions": [
5
+ {
6
+ "method": "chat",
7
+ "request": {
8
+ "messages": [{ "role": "user", "content": "Tell me a short story" }]
9
+ },
10
+ "response": {
11
+ "content": "Once upon a time, there was a small robot who learned to dream.",
12
+ "tool_calls": [],
13
+ "usage": { "input_tokens": 10, "output_tokens": 15, "total_tokens": 25 }
14
+ }
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,75 @@
1
+ import { vi, type Mock } from "vitest";
2
+ import {
3
+ BaseProvider,
4
+ ChatRequest,
5
+ ChatResponse,
6
+ ProviderCapabilities,
7
+ EmbeddingResponse,
8
+ ImageResponse,
9
+ TranscriptionResponse,
10
+ ModerationResponse,
11
+ EmbeddingRequest,
12
+ ImageRequest,
13
+ TranscriptionRequest,
14
+ ModerationRequest
15
+ } from "@node-llm/core";
16
+
17
+ export class MockProvider extends BaseProvider {
18
+ get id() {
19
+ return "mock-provider";
20
+ }
21
+
22
+ apiBase() {
23
+ return "http://mock";
24
+ }
25
+ headers() {
26
+ return {};
27
+ }
28
+ protected providerName() {
29
+ return "mock-provider";
30
+ }
31
+
32
+ defaultModel() {
33
+ return "mock-model";
34
+ }
35
+
36
+ capabilities: ProviderCapabilities = {
37
+ supportsVision: () => true,
38
+ supportsTools: () => true,
39
+ supportsStructuredOutput: () => true,
40
+ supportsEmbeddings: () => true,
41
+ supportsImageGeneration: () => true,
42
+ supportsTranscription: () => true,
43
+ supportsModeration: () => true,
44
+ supportsReasoning: () => true,
45
+ supportsDeveloperRole: () => true,
46
+ getContextWindow: () => 128000
47
+ };
48
+
49
+ chat: Mock<(req: ChatRequest) => Promise<ChatResponse>> = vi.fn(
50
+ async (req: ChatRequest): Promise<ChatResponse> => {
51
+ const lastMsg = req.messages[req.messages.length - 1];
52
+ let contentStr = "nothing";
53
+
54
+ if (lastMsg && lastMsg.content) {
55
+ if (typeof lastMsg.content === "string") {
56
+ contentStr = lastMsg.content;
57
+ } else if (Array.isArray(lastMsg.content)) {
58
+ contentStr = lastMsg.content.map((p) => (p.type === "text" ? p.text : "")).join("");
59
+ } else {
60
+ contentStr = String(lastMsg.content);
61
+ }
62
+ }
63
+
64
+ return {
65
+ content: `Response to ${contentStr}`,
66
+ usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }
67
+ };
68
+ }
69
+ );
70
+
71
+ embed: Mock<(req: EmbeddingRequest) => Promise<EmbeddingResponse>> = vi.fn();
72
+ paint: Mock<(req: ImageRequest) => Promise<ImageResponse>> = vi.fn();
73
+ transcribe: Mock<(req: TranscriptionRequest) => Promise<TranscriptionResponse>> = vi.fn();
74
+ moderate: Mock<(req: ModerationRequest) => Promise<ModerationResponse>> = vi.fn();
75
+ }
@@ -0,0 +1,36 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { setupVCR } from "../../src/vcr.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ describe("VCR Feature 8: CI-Safe Replay", () => {
7
+ const CASSETTE_NAME = "ci-test-cassette";
8
+ const CASSETTE_DIR = path.join(__dirname, "../cassettes");
9
+ const CASSETTE_PATH = path.join(CASSETTE_DIR, `${CASSETTE_NAME}.json`);
10
+ const originalCI = process.env.CI;
11
+
12
+ beforeEach(() => {
13
+ if (fs.existsSync(CASSETTE_PATH)) fs.unlinkSync(CASSETTE_PATH);
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.env.CI = originalCI;
18
+ });
19
+
20
+ test("Throws error if cassette is missing in CI", () => {
21
+ process.env.CI = "true";
22
+
23
+ // Using auto mode when file doesn't exist should throw in CI
24
+ expect(() => setupVCR(CASSETTE_NAME, { mode: "auto", cassettesDir: CASSETTE_DIR })).toThrow(
25
+ /Cassette missing in CI/
26
+ );
27
+ });
28
+
29
+ test("Throws error if record mode is explicitly set in CI", () => {
30
+ process.env.CI = "true";
31
+
32
+ expect(() => setupVCR(CASSETTE_NAME, { mode: "record", cassettesDir: CASSETTE_DIR })).toThrow(
33
+ /Recording cassettes is not allowed in CI/
34
+ );
35
+ });
36
+ });
@@ -0,0 +1,86 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { withVCR } from "../../src/vcr.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { MockProvider } from "../helpers/MockProvider.js";
7
+
8
+ describe("VCR Feature 5 & 6: DX Sugar & Auto-Naming", () => {
9
+ let mock: MockProvider;
10
+ const CASSETTE_DIR = path.join(__dirname, "../cassettes");
11
+
12
+ beforeEach(() => {
13
+ mock = new MockProvider();
14
+ providerRegistry.register("mock-provider", () => mock);
15
+ });
16
+
17
+ afterEach(() => {
18
+ providerRegistry.setInterceptor(undefined);
19
+ });
20
+
21
+ test(
22
+ "Automatically names and records cassettes",
23
+ withVCR({ cassettesDir: CASSETTE_DIR }, async () => {
24
+ const llm = NodeLLM.withProvider("mock-provider");
25
+ await llm.chat().ask("Sugar test");
26
+
27
+ // The cassette name should be derived from the test title
28
+ })
29
+ );
30
+
31
+ test("Verifying the file exists after withVCR", async () => {
32
+ // Note: Since withVCR above runs, we check it here
33
+ const files = fs.readdirSync(CASSETTE_DIR);
34
+ const found = files.some((f) => f.includes("automatically-names-and-records-cassettes"));
35
+ expect(found).toBe(true);
36
+ });
37
+
38
+ test(
39
+ "Allows explicit naming",
40
+ withVCR("explicit-sugar-test", { cassettesDir: CASSETTE_DIR }, async () => {
41
+ const llm = NodeLLM.withProvider("mock-provider");
42
+ await llm.chat().ask("Explicit test");
43
+ })
44
+ );
45
+
46
+ test("Verifying explicit file existence", async () => {
47
+ const filePath = path.join(CASSETTE_DIR, "explicit-sugar-test.json");
48
+ expect(fs.existsSync(filePath)).toBe(true);
49
+ });
50
+
51
+ test("Respects global configuration", async () => {
52
+ // 1. Set global config
53
+ const { configureVCR } = await import("../../src/vcr.js");
54
+ configureVCR({ sensitiveKeys: ["global_secret"] });
55
+
56
+ // 2. Run a test that relies on this global config merging
57
+ await withVCR("global-config-test", { cassettesDir: CASSETTE_DIR }, async () => {
58
+ const llm = NodeLLM.withProvider("mock-provider");
59
+ await llm.chat().ask("global test");
60
+ })();
61
+
62
+ // Cleanup
63
+ configureVCR({});
64
+ });
65
+
66
+ test("Respects global cassettesDir configuration", async () => {
67
+ // 1. Set global cassettesDir
68
+ const { configureVCR } = await import("../../src/vcr.js");
69
+ const GLOBAL_DIR = path.join(CASSETTE_DIR, "global-custom-dir");
70
+ configureVCR({ cassettesDir: GLOBAL_DIR });
71
+
72
+ // 2. Run a test relying on global dir (explicit record mode to create cassette)
73
+ await withVCR("global-dir-test", { mode: "record" }, async () => {
74
+ const llm = NodeLLM.withProvider("mock-provider");
75
+ await llm.chat().ask("global dir test");
76
+ })();
77
+
78
+ // 3. Verify file exists in global dir
79
+ const filePath = path.join(GLOBAL_DIR, "global-dir-test.json");
80
+ expect(fs.existsSync(filePath)).toBe(true);
81
+
82
+ // Cleanup
83
+ if (fs.existsSync(GLOBAL_DIR)) fs.rmSync(GLOBAL_DIR, { recursive: true, force: true });
84
+ configureVCR({});
85
+ });
86
+ });
@@ -0,0 +1,68 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker: Debug Information", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ providerRegistry.register("mock-provider", () => new MockProvider());
11
+ mocker = mockLLM();
12
+ });
13
+
14
+ afterEach(() => {
15
+ mocker.clear();
16
+ });
17
+
18
+ test("getDebugInfo returns total mock count", () => {
19
+ mocker.chat("Hello").respond("Hi");
20
+ mocker.chat("Bye").respond("Goodbye");
21
+ mocker.embed("test").respond({ vectors: [[0.1]] });
22
+
23
+ const debug = mocker.getDebugInfo();
24
+
25
+ expect(debug.totalMocks).toBe(3);
26
+ expect(debug.methods).toContain("chat");
27
+ expect(debug.methods).toContain("embed");
28
+ });
29
+
30
+ test("getDebugInfo lists unique methods", () => {
31
+ mocker.chat("First").respond("1");
32
+ mocker.chat("Second").respond("2");
33
+ mocker.chat("Third").respond("3");
34
+ mocker.embed("input").respond({ vectors: [[0.1]] });
35
+ mocker.paint("prompt").respond({ url: "http://test.com/img.png" });
36
+
37
+ const debug = mocker.getDebugInfo();
38
+
39
+ expect(debug.totalMocks).toBe(5);
40
+ expect(debug.methods).toEqual(expect.arrayContaining(["chat", "embed", "paint"]));
41
+ expect(debug.methods.length).toBe(3); // Unique methods only
42
+ });
43
+
44
+ test("getDebugInfo with no mocks", () => {
45
+ const debug = mocker.getDebugInfo();
46
+
47
+ expect(debug.totalMocks).toBe(0);
48
+ expect(debug.methods).toEqual([]);
49
+ });
50
+
51
+ test("getDebugInfo works with all mock types", () => {
52
+ mocker.chat("test").respond("chat response");
53
+ mocker.embed("test").respond({ vectors: [[0.1]] });
54
+ mocker.paint("test").respond({ url: "http://test.com/img.png" });
55
+ mocker.transcribe("test.mp3").respond("transcript");
56
+ mocker.moderate("test").respond({ results: [] });
57
+
58
+ const debug = mocker.getDebugInfo();
59
+
60
+ const methods = debug.methods;
61
+ expect(methods).toContain("chat");
62
+ expect(methods).toContain("embed");
63
+ expect(methods).toContain("paint");
64
+ expect(methods).toContain("transcribe");
65
+ expect(methods).toContain("moderate");
66
+ expect(debug.totalMocks).toBe(5);
67
+ });
68
+ });
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker Feature 10: Declarative Mocks", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ // Register a real provider to verify pass-through vs mock
11
+ providerRegistry.register("mock-provider", () => new MockProvider());
12
+ mocker = mockLLM();
13
+ });
14
+
15
+ afterEach(() => {
16
+ mocker.clear();
17
+ });
18
+
19
+ test("Mocks exact chat queries", async () => {
20
+ mocker.chat("Ping").respond("Pong");
21
+
22
+ const llm = NodeLLM.withProvider("mock-provider");
23
+ const res = await llm.chat().ask("Ping");
24
+
25
+ expect(res.content).toBe("Pong");
26
+ });
27
+
28
+ test("Mocks using Regex patterns", async () => {
29
+ mocker.chat(/hello/i).respond("Greetings!");
30
+
31
+ const llm = NodeLLM.withProvider("mock-provider");
32
+ const res = await llm.chat().ask("HHeelllooo"); // Should fail
33
+ const res2 = await llm.chat().ask("Hello there");
34
+
35
+ expect(res2.content).toBe("Greetings!");
36
+ });
37
+
38
+ test("Simulates Provider Errors", async () => {
39
+ mocker.chat("Fail me").respond({
40
+ error: new Error("Rate limit exceeded")
41
+ });
42
+
43
+ const llm = NodeLLM.withProvider("mock-provider");
44
+ await expect(llm.chat().ask("Fail me")).rejects.toThrow("Rate limit exceeded");
45
+ });
46
+ });
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker Feature: Explicit Multimodal API", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ providerRegistry.register("mock-provider", () => new MockProvider());
11
+ mocker = mockLLM();
12
+ });
13
+
14
+ afterEach(() => {
15
+ mocker.clear();
16
+ });
17
+
18
+ test("Uses explicit .paint() mock", async () => {
19
+ mocker.paint(/scary cat/i).respond({ url: "http://mock.com/scary-cat.png" });
20
+
21
+ const llm = NodeLLM.withProvider("mock-provider");
22
+ const res = await (llm.provider as any).paint({ prompt: "A very scary cat" });
23
+
24
+ expect(res.url).toBe("http://mock.com/scary-cat.png");
25
+ });
26
+
27
+ test("Uses explicit .transcribe() mock", async () => {
28
+ mocker.transcribe("audio.mp3").respond("This is the transcript");
29
+
30
+ const llm = NodeLLM.withProvider("mock-provider");
31
+ const res = await (llm.provider as any).transcribe({ file: "audio.mp3" });
32
+
33
+ expect(res.text).toBe("This is the transcript");
34
+ });
35
+
36
+ test("Uses explicit .moderate() mock", async () => {
37
+ mocker.moderate(/hate/i).respond({
38
+ results: [{ flagged: true, categories: { hate: true }, category_scores: { hate: 0.99 } }]
39
+ });
40
+
41
+ const llm = NodeLLM.withProvider("mock-provider");
42
+ const res = await (llm.provider as any).moderate({ input: "I hate mushrooms" });
43
+
44
+ expect(res.results[0].flagged).toBe(true);
45
+ });
46
+ });
@@ -0,0 +1,54 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { withVCR, describeVCR } from "../../src/vcr.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ describe("VCR Feature 12: Hierarchical Scoping", () => {
9
+ // Use absolute path relative to this test file
10
+ const CUSTOM_DIR = path.join(__dirname, "../cassettes/custom-scope-test");
11
+ const LEVEL_1 = "Authentication";
12
+ const LEVEL_2 = "Login Flow";
13
+ const TEST_NAME = "Successful Login";
14
+
15
+ beforeEach(() => {
16
+ if (fs.existsSync(CUSTOM_DIR)) fs.rmSync(CUSTOM_DIR, { recursive: true, force: true });
17
+ providerRegistry.register("mock-provider", () => new MockProvider());
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Clean up after test
22
+ if (fs.existsSync(CUSTOM_DIR)) fs.rmSync(CUSTOM_DIR, { recursive: true, force: true });
23
+ });
24
+
25
+ test("Organizes cassettes into nested subfolders", async () => {
26
+ // We use process.env to set the base dir for this test
27
+ process.env.VCR_CASSETTE_DIR = CUSTOM_DIR;
28
+
29
+ await describeVCR(LEVEL_1, () => {
30
+ return describeVCR(LEVEL_2, async () => {
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
+ });
36
+
37
+ await testFn();
38
+ });
39
+ });
40
+
41
+ // Verify the path exists
42
+ const expectedPath = path.join(
43
+ CUSTOM_DIR,
44
+ "authentication",
45
+ "login-flow",
46
+ "successful-login.json"
47
+ );
48
+
49
+ expect(fs.existsSync(expectedPath)).toBe(true);
50
+
51
+ // Clean up env
52
+ delete process.env.VCR_CASSETTE_DIR;
53
+ });
54
+ });
@@ -0,0 +1,110 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { setupVCR } from "../../src/vcr.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { MockProvider } from "../helpers/MockProvider.js";
8
+
9
+ describe("VCR Feature 4: Automatic Scrubbing", () => {
10
+ const CASSETTE_NAME = "scrub-test";
11
+ // Use temp directory for scrubbing tests to avoid re-recording committed cassettes
12
+ let CASSETTE_DIR: string;
13
+ let CASSETTE_PATH: string;
14
+ let mock: MockProvider;
15
+
16
+ beforeEach(() => {
17
+ CASSETTE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vcr-scrub-test-"));
18
+ CASSETTE_PATH = path.join(CASSETTE_DIR, `${CASSETTE_NAME}.json`);
19
+ mock = new MockProvider();
20
+ providerRegistry.register("mock-provider", () => mock);
21
+ });
22
+
23
+ afterEach(() => {
24
+ providerRegistry.setInterceptor(undefined);
25
+ // Clean up temp directory
26
+ if (fs.existsSync(CASSETTE_DIR)) {
27
+ fs.rmSync(CASSETTE_DIR, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test("Automatically scrubs API keys and sensitive JSON keys", async () => {
32
+ const vcr = setupVCR(CASSETTE_NAME, { mode: "record", cassettesDir: CASSETTE_DIR });
33
+ const llm = NodeLLM.withProvider("mock-provider");
34
+
35
+ // 1. Trigger request with secrets
36
+ await llm.chat().ask("My key is sk-abcdef1234567890abcdef1234567890");
37
+
38
+ await vcr.stop();
39
+
40
+ // 2. Read back from disk and verify redaction
41
+ const raw = fs.readFileSync(CASSETTE_PATH, "utf-8");
42
+ expect(raw).not.toContain("sk-abcdef");
43
+ expect(raw).toContain("[REDACTED]");
44
+
45
+ // 3. Verify that we DID NOT redact token counts (because they are numbers)
46
+ expect(raw).toContain('"total_tokens": 20');
47
+ });
48
+
49
+ test("Allows custom scrubbing hooks", async () => {
50
+ const vcr = setupVCR(CASSETTE_NAME, {
51
+ mode: "record",
52
+ cassettesDir: CASSETTE_DIR,
53
+ scrub: (data: unknown) => {
54
+ // Deep string replacement on the whole interaction object
55
+ return JSON.parse(JSON.stringify(data).replace(/sensitive-info/g, "XXXX"));
56
+ }
57
+ });
58
+
59
+ const llm = NodeLLM.withProvider("mock-provider");
60
+ await llm.chat().ask("This contains sensitive-info");
61
+ await vcr.stop();
62
+
63
+ const raw = fs.readFileSync(CASSETTE_PATH, "utf-8");
64
+ expect(raw).toContain("XXXX");
65
+ expect(raw).not.toContain("sensitive-info");
66
+ });
67
+
68
+ test("Supports declarative custom keys and patterns via config", async () => {
69
+ const vcr = setupVCR("custom-scrub-config", {
70
+ mode: "record",
71
+ cassettesDir: CASSETTE_DIR,
72
+ sensitiveKeys: ["user_email", "internal_id"],
73
+ sensitivePatterns: [/secret-project-[a-z]+/g]
74
+ });
75
+
76
+ const llm = NodeLLM.withProvider("mock-provider");
77
+ await llm.chat().ask("status of secret-project-omega");
78
+
79
+ await vcr.stop();
80
+
81
+ const cassettePath = path.join(CASSETTE_DIR, "custom-scrub-config.json");
82
+ const raw = fs.readFileSync(cassettePath, "utf-8");
83
+
84
+ // Verify Pattern Scrubbing
85
+ expect(raw).not.toContain("secret-project-omega");
86
+ expect(raw).toContain("[REDACTED]");
87
+ });
88
+
89
+ test("Retains default scrubbing when custom config is provided", async () => {
90
+ const vcr = setupVCR("defaults-plus-custom", {
91
+ mode: "record",
92
+ cassettesDir: CASSETTE_DIR,
93
+ sensitiveKeys: ["custom_field"]
94
+ });
95
+
96
+ const llm = NodeLLM.withProvider("mock-provider");
97
+
98
+ // We send a standard fake key (matches default pattern) AND a custom field
99
+ await llm.chat().ask("key sk-123456789012345678901234567890 plus custom_field");
100
+
101
+ await vcr.stop();
102
+
103
+ const path = `${CASSETTE_DIR}/defaults-plus-custom.json`;
104
+ const raw = fs.readFileSync(path, "utf-8");
105
+
106
+ // Default pattern should still work
107
+ expect(raw).not.toContain("sk-1234567890");
108
+ expect(raw).toContain("[REDACTED]");
109
+ });
110
+ });
@@ -0,0 +1,51 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import { mockLLM } from "../../src/Mocker.js";
3
+ import { NodeLLM, providerRegistry } from "@node-llm/core";
4
+ import { MockProvider } from "../helpers/MockProvider.js";
5
+
6
+ describe("Mocker Feature 11: Streaming Mocks", () => {
7
+ let mocker: ReturnType<typeof mockLLM>;
8
+
9
+ beforeEach(() => {
10
+ providerRegistry.register("mock-provider", () => new MockProvider());
11
+ mocker = mockLLM();
12
+ });
13
+
14
+ afterEach(() => {
15
+ mocker.clear();
16
+ });
17
+
18
+ test("Mocks a streaming response token by token", async () => {
19
+ const chunks = ["Once ", "upon ", "a ", "time."];
20
+ mocker.chat("Tell me a story").stream(chunks);
21
+
22
+ const llm = NodeLLM.withProvider("mock-provider");
23
+ const stream = llm.chat("mock-model").stream("Tell me a story");
24
+
25
+ const received: string[] = [];
26
+ for await (const chunk of stream) {
27
+ received.push(chunk.content);
28
+ }
29
+
30
+ expect(received).toEqual(chunks);
31
+ });
32
+
33
+ test("Mocks a streaming response with manual ChatChunk objects", async () => {
34
+ const chunks = [
35
+ { content: "Hello", done: false },
36
+ { content: " world", done: true }
37
+ ];
38
+ mocker.chat("Greeting").stream(chunks);
39
+
40
+ const llm = NodeLLM.withProvider("mock-provider");
41
+ const stream = llm.chat("mock-model").stream("Greeting");
42
+
43
+ const received: any[] = [];
44
+ for await (const chunk of stream) {
45
+ received.push(chunk);
46
+ }
47
+
48
+ expect(received[0].content).toBe("Hello");
49
+ expect(received[1].done).toBe(true);
50
+ });
51
+ });