@jamesaphoenix/tx-test-utils 0.4.2
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/dist/database/index.d.ts +8 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +7 -0
- package/dist/database/index.js.map +1 -0
- package/dist/database/test-database.d.ts +101 -0
- package/dist/database/test-database.d.ts.map +1 -0
- package/dist/database/test-database.js +130 -0
- package/dist/database/test-database.js.map +1 -0
- package/dist/factories/anchor.factory.d.ts +117 -0
- package/dist/factories/anchor.factory.d.ts.map +1 -0
- package/dist/factories/anchor.factory.js +201 -0
- package/dist/factories/anchor.factory.js.map +1 -0
- package/dist/factories/candidate.factory.d.ts +151 -0
- package/dist/factories/candidate.factory.d.ts.map +1 -0
- package/dist/factories/candidate.factory.js +194 -0
- package/dist/factories/candidate.factory.js.map +1 -0
- package/dist/factories/edge.factory.d.ts +119 -0
- package/dist/factories/edge.factory.d.ts.map +1 -0
- package/dist/factories/edge.factory.js +191 -0
- package/dist/factories/edge.factory.js.map +1 -0
- package/dist/factories/factories.test.d.ts +8 -0
- package/dist/factories/factories.test.d.ts.map +1 -0
- package/dist/factories/factories.test.js +419 -0
- package/dist/factories/factories.test.js.map +1 -0
- package/dist/factories/index.d.ts +15 -0
- package/dist/factories/index.d.ts.map +1 -0
- package/dist/factories/index.js +21 -0
- package/dist/factories/index.js.map +1 -0
- package/dist/factories/learning.factory.d.ts +107 -0
- package/dist/factories/learning.factory.d.ts.map +1 -0
- package/dist/factories/learning.factory.js +150 -0
- package/dist/factories/learning.factory.js.map +1 -0
- package/dist/factories/task.factory.d.ts +106 -0
- package/dist/factories/task.factory.d.ts.map +1 -0
- package/dist/factories/task.factory.js +151 -0
- package/dist/factories/task.factory.js.map +1 -0
- package/dist/fixtures/index.d.ts +36 -0
- package/dist/fixtures/index.d.ts.map +1 -0
- package/dist/fixtures/index.js +47 -0
- package/dist/fixtures/index.js.map +1 -0
- package/dist/helpers/effect.d.ts +186 -0
- package/dist/helpers/effect.d.ts.map +1 -0
- package/dist/helpers/effect.js +298 -0
- package/dist/helpers/effect.js.map +1 -0
- package/dist/helpers/effect.test.d.ts +7 -0
- package/dist/helpers/effect.test.d.ts.map +1 -0
- package/dist/helpers/effect.test.js +271 -0
- package/dist/helpers/effect.test.js.map +1 -0
- package/dist/helpers/index.d.ts +7 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +11 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/llm-cache/cache.d.ts +152 -0
- package/dist/llm-cache/cache.d.ts.map +1 -0
- package/dist/llm-cache/cache.js +199 -0
- package/dist/llm-cache/cache.js.map +1 -0
- package/dist/llm-cache/cache.test.d.ts +7 -0
- package/dist/llm-cache/cache.test.d.ts.map +1 -0
- package/dist/llm-cache/cache.test.js +310 -0
- package/dist/llm-cache/cache.test.js.map +1 -0
- package/dist/llm-cache/cli.d.ts +113 -0
- package/dist/llm-cache/cli.d.ts.map +1 -0
- package/dist/llm-cache/cli.js +248 -0
- package/dist/llm-cache/cli.js.map +1 -0
- package/dist/llm-cache/index.d.ts +31 -0
- package/dist/llm-cache/index.d.ts.map +1 -0
- package/dist/llm-cache/index.js +31 -0
- package/dist/llm-cache/index.js.map +1 -0
- package/dist/mocks/anthropic.mock.d.ts +173 -0
- package/dist/mocks/anthropic.mock.d.ts.map +1 -0
- package/dist/mocks/anthropic.mock.js +125 -0
- package/dist/mocks/anthropic.mock.js.map +1 -0
- package/dist/mocks/ast-grep.mock.d.ts +216 -0
- package/dist/mocks/ast-grep.mock.d.ts.map +1 -0
- package/dist/mocks/ast-grep.mock.js +164 -0
- package/dist/mocks/ast-grep.mock.js.map +1 -0
- package/dist/mocks/file-system.mock.d.ts +181 -0
- package/dist/mocks/file-system.mock.d.ts.map +1 -0
- package/dist/mocks/file-system.mock.js +280 -0
- package/dist/mocks/file-system.mock.js.map +1 -0
- package/dist/mocks/index.d.ts +10 -0
- package/dist/mocks/index.d.ts.map +1 -0
- package/dist/mocks/index.js +16 -0
- package/dist/mocks/index.js.map +1 -0
- package/dist/mocks/mocks.test.d.ts +10 -0
- package/dist/mocks/mocks.test.d.ts.map +1 -0
- package/dist/mocks/mocks.test.js +961 -0
- package/dist/mocks/mocks.test.js.map +1 -0
- package/dist/mocks/openai.mock.d.ts +205 -0
- package/dist/mocks/openai.mock.d.ts.map +1 -0
- package/dist/mocks/openai.mock.js +178 -0
- package/dist/mocks/openai.mock.js.map +1 -0
- package/dist/setup/index.d.ts +7 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +9 -0
- package/dist/setup/index.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock services unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests all mock services for correct behavior:
|
|
5
|
+
* - createMockAnthropic: call tracking, response fixtures, failure injection
|
|
6
|
+
* - MockAstGrepService: findSymbols, getImports, empty results
|
|
7
|
+
* - MockFileSystem: read/write cycle, exists, failure injection
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { Effect, Either } from "effect";
|
|
11
|
+
import { createMockAnthropic, createMockAnthropicForExtraction } from "./anthropic.mock.js";
|
|
12
|
+
import { createMockOpenAI, createMockOpenAIForExtraction, createMockOpenAIForExtractionRaw } from "./openai.mock.js";
|
|
13
|
+
import { MockAstGrepService, MockAstGrepServiceTag } from "./ast-grep.mock.js";
|
|
14
|
+
import { MockFileSystem, MockFileSystemServiceTag } from "./file-system.mock.js";
|
|
15
|
+
import { runEffect, runEffectEither, expectEffectFailure } from "../helpers/effect.js";
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// createMockAnthropic Tests
|
|
18
|
+
// =============================================================================
|
|
19
|
+
describe("createMockAnthropic", () => {
|
|
20
|
+
describe("call tracking", () => {
|
|
21
|
+
it("records all calls", async () => {
|
|
22
|
+
const mock = createMockAnthropic();
|
|
23
|
+
await mock.client.messages.create({
|
|
24
|
+
model: "claude-haiku-4-20250514",
|
|
25
|
+
max_tokens: 256,
|
|
26
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
27
|
+
});
|
|
28
|
+
await mock.client.messages.create({
|
|
29
|
+
model: "claude-haiku-4-20250514",
|
|
30
|
+
max_tokens: 512,
|
|
31
|
+
messages: [{ role: "user", content: "World" }]
|
|
32
|
+
});
|
|
33
|
+
expect(mock.calls).toHaveLength(2);
|
|
34
|
+
expect(mock.getCallCount()).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
it("stores call parameters correctly", async () => {
|
|
37
|
+
const mock = createMockAnthropic();
|
|
38
|
+
await mock.client.messages.create({
|
|
39
|
+
model: "claude-haiku-4-20250514",
|
|
40
|
+
max_tokens: 256,
|
|
41
|
+
messages: [
|
|
42
|
+
{ role: "user", content: "First message" },
|
|
43
|
+
{ role: "assistant", content: "Response" },
|
|
44
|
+
{ role: "user", content: "Second message" }
|
|
45
|
+
]
|
|
46
|
+
});
|
|
47
|
+
expect(mock.calls[0]).toEqual({
|
|
48
|
+
model: "claude-haiku-4-20250514",
|
|
49
|
+
max_tokens: 256,
|
|
50
|
+
messages: [
|
|
51
|
+
{ role: "user", content: "First message" },
|
|
52
|
+
{ role: "assistant", content: "Response" },
|
|
53
|
+
{ role: "user", content: "Second message" }
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
it("getLastCall returns the most recent call", async () => {
|
|
58
|
+
const mock = createMockAnthropic();
|
|
59
|
+
await mock.client.messages.create({
|
|
60
|
+
model: "model-a",
|
|
61
|
+
messages: [{ role: "user", content: "First" }]
|
|
62
|
+
});
|
|
63
|
+
await mock.client.messages.create({
|
|
64
|
+
model: "model-b",
|
|
65
|
+
messages: [{ role: "user", content: "Second" }]
|
|
66
|
+
});
|
|
67
|
+
const lastCall = mock.getLastCall();
|
|
68
|
+
expect(lastCall?.model).toBe("model-b");
|
|
69
|
+
expect(lastCall?.messages[0].content).toBe("Second");
|
|
70
|
+
});
|
|
71
|
+
it("getLastCall returns undefined when no calls made", () => {
|
|
72
|
+
const mock = createMockAnthropic();
|
|
73
|
+
expect(mock.getLastCall()).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it("reset clears all tracked calls", async () => {
|
|
76
|
+
const mock = createMockAnthropic();
|
|
77
|
+
await mock.client.messages.create({
|
|
78
|
+
model: "claude-haiku-4-20250514",
|
|
79
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
80
|
+
});
|
|
81
|
+
expect(mock.calls).toHaveLength(1);
|
|
82
|
+
mock.reset();
|
|
83
|
+
expect(mock.calls).toHaveLength(0);
|
|
84
|
+
expect(mock.getCallCount()).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe("response fixtures", () => {
|
|
88
|
+
it("returns default minimal response when no config", async () => {
|
|
89
|
+
const mock = createMockAnthropic();
|
|
90
|
+
const response = await mock.client.messages.create({
|
|
91
|
+
model: "claude-haiku-4-20250514",
|
|
92
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
93
|
+
});
|
|
94
|
+
expect(response.id).toBe("mock-msg-id");
|
|
95
|
+
expect(response.type).toBe("message");
|
|
96
|
+
expect(response.role).toBe("assistant");
|
|
97
|
+
expect(response.model).toBe("claude-haiku-4-20250514");
|
|
98
|
+
expect(response.content).toHaveLength(1);
|
|
99
|
+
expect(response.content[0].type).toBe("text");
|
|
100
|
+
});
|
|
101
|
+
it("returns configured defaultResponse", async () => {
|
|
102
|
+
const customResponse = {
|
|
103
|
+
id: "custom-id",
|
|
104
|
+
type: "message",
|
|
105
|
+
role: "assistant",
|
|
106
|
+
content: [{ type: "text", text: "Custom response text" }],
|
|
107
|
+
model: "claude-haiku-4-20250514",
|
|
108
|
+
usage: { input_tokens: 100, output_tokens: 50 }
|
|
109
|
+
};
|
|
110
|
+
const mock = createMockAnthropic({ defaultResponse: customResponse });
|
|
111
|
+
const response = await mock.client.messages.create({
|
|
112
|
+
model: "any-model",
|
|
113
|
+
messages: [{ role: "user", content: "Anything" }]
|
|
114
|
+
});
|
|
115
|
+
expect(response).toEqual(customResponse);
|
|
116
|
+
});
|
|
117
|
+
it("returns specific response for matching messages", async () => {
|
|
118
|
+
const specificResponse = {
|
|
119
|
+
id: "specific-id",
|
|
120
|
+
type: "message",
|
|
121
|
+
role: "assistant",
|
|
122
|
+
content: [{ type: "text", text: "Specific answer" }],
|
|
123
|
+
model: "claude-haiku-4-20250514"
|
|
124
|
+
};
|
|
125
|
+
const messages = [{ role: "user", content: "Specific question" }];
|
|
126
|
+
const responses = new Map([
|
|
127
|
+
[JSON.stringify(messages), specificResponse]
|
|
128
|
+
]);
|
|
129
|
+
const mock = createMockAnthropic({ responses });
|
|
130
|
+
const response = await mock.client.messages.create({
|
|
131
|
+
model: "claude-haiku-4-20250514",
|
|
132
|
+
messages: messages
|
|
133
|
+
});
|
|
134
|
+
expect(response.id).toBe("specific-id");
|
|
135
|
+
expect(response.content[0].text).toBe("Specific answer");
|
|
136
|
+
});
|
|
137
|
+
it("falls back to defaultResponse when specific response not found", async () => {
|
|
138
|
+
const defaultResponse = {
|
|
139
|
+
id: "default-id",
|
|
140
|
+
type: "message",
|
|
141
|
+
role: "assistant",
|
|
142
|
+
content: [{ type: "text", text: "Default" }],
|
|
143
|
+
model: "claude-haiku-4-20250514"
|
|
144
|
+
};
|
|
145
|
+
const responses = new Map([
|
|
146
|
+
[JSON.stringify([{ role: "user", content: "Match" }]), {
|
|
147
|
+
id: "match-id",
|
|
148
|
+
type: "message",
|
|
149
|
+
role: "assistant",
|
|
150
|
+
content: [{ type: "text", text: "Matched" }],
|
|
151
|
+
model: "claude-haiku-4-20250514"
|
|
152
|
+
}]
|
|
153
|
+
]);
|
|
154
|
+
const mock = createMockAnthropic({ responses, defaultResponse });
|
|
155
|
+
const response = await mock.client.messages.create({
|
|
156
|
+
model: "claude-haiku-4-20250514",
|
|
157
|
+
messages: [{ role: "user", content: "No match" }]
|
|
158
|
+
});
|
|
159
|
+
expect(response.id).toBe("default-id");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("failure injection", () => {
|
|
163
|
+
it("throws error when shouldFail is true", async () => {
|
|
164
|
+
const mock = createMockAnthropic({ shouldFail: true });
|
|
165
|
+
await expect(mock.client.messages.create({
|
|
166
|
+
model: "claude-haiku-4-20250514",
|
|
167
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
168
|
+
})).rejects.toThrow("Mock Anthropic API error");
|
|
169
|
+
});
|
|
170
|
+
it("throws custom failureMessage", async () => {
|
|
171
|
+
const mock = createMockAnthropic({
|
|
172
|
+
shouldFail: true,
|
|
173
|
+
failureMessage: "Rate limit exceeded"
|
|
174
|
+
});
|
|
175
|
+
await expect(mock.client.messages.create({
|
|
176
|
+
model: "claude-haiku-4-20250514",
|
|
177
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
178
|
+
})).rejects.toThrow("Rate limit exceeded");
|
|
179
|
+
});
|
|
180
|
+
it("throws custom failureError", async () => {
|
|
181
|
+
const customError = new Error("Custom error object");
|
|
182
|
+
const mock = createMockAnthropic({
|
|
183
|
+
shouldFail: true,
|
|
184
|
+
failureError: customError
|
|
185
|
+
});
|
|
186
|
+
await expect(mock.client.messages.create({
|
|
187
|
+
model: "claude-haiku-4-20250514",
|
|
188
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
189
|
+
})).rejects.toBe(customError);
|
|
190
|
+
});
|
|
191
|
+
it("failureError takes precedence over failureMessage", async () => {
|
|
192
|
+
const customError = new Error("Custom error wins");
|
|
193
|
+
const mock = createMockAnthropic({
|
|
194
|
+
shouldFail: true,
|
|
195
|
+
failureMessage: "This should not be used",
|
|
196
|
+
failureError: customError
|
|
197
|
+
});
|
|
198
|
+
await expect(mock.client.messages.create({
|
|
199
|
+
model: "claude-haiku-4-20250514",
|
|
200
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
201
|
+
})).rejects.toBe(customError);
|
|
202
|
+
});
|
|
203
|
+
it("still tracks calls when failing", async () => {
|
|
204
|
+
const mock = createMockAnthropic({ shouldFail: true });
|
|
205
|
+
try {
|
|
206
|
+
await mock.client.messages.create({
|
|
207
|
+
model: "claude-haiku-4-20250514",
|
|
208
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Expected
|
|
213
|
+
}
|
|
214
|
+
expect(mock.calls).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
describe("latency simulation", () => {
|
|
218
|
+
it("delays response by configured latencyMs", async () => {
|
|
219
|
+
const mock = createMockAnthropic({ latencyMs: 100 });
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
await mock.client.messages.create({
|
|
222
|
+
model: "claude-haiku-4-20250514",
|
|
223
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
224
|
+
});
|
|
225
|
+
const elapsed = Date.now() - start;
|
|
226
|
+
expect(elapsed).toBeGreaterThanOrEqual(95); // Allow small timing variance
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
describe("createMockAnthropicForExtraction", () => {
|
|
230
|
+
it("returns candidates as JSON text in response", async () => {
|
|
231
|
+
const candidates = [
|
|
232
|
+
{ content: "Always use transactions", confidence: "high", category: "patterns" },
|
|
233
|
+
{ content: "Test database migrations", confidence: "medium", category: "testing" }
|
|
234
|
+
];
|
|
235
|
+
const mock = createMockAnthropicForExtraction(candidates);
|
|
236
|
+
const response = await mock.client.messages.create({
|
|
237
|
+
model: "claude-haiku-4-20250514",
|
|
238
|
+
messages: [{ role: "user", content: "Extract learnings" }]
|
|
239
|
+
});
|
|
240
|
+
expect(response.content[0].text).toBe(JSON.stringify(candidates));
|
|
241
|
+
expect(JSON.parse(response.content[0].text)).toEqual(candidates);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
// =============================================================================
|
|
246
|
+
// MockAstGrepService Tests
|
|
247
|
+
// =============================================================================
|
|
248
|
+
describe("MockAstGrepService", () => {
|
|
249
|
+
describe("findSymbols", () => {
|
|
250
|
+
it("returns fixture data for configured file path", async () => {
|
|
251
|
+
const symbols = [
|
|
252
|
+
{ name: "main", kind: "function", line: 1, exported: true },
|
|
253
|
+
{ name: "Helper", kind: "class", line: 10, exported: false }
|
|
254
|
+
];
|
|
255
|
+
const mock = MockAstGrepService({
|
|
256
|
+
symbols: new Map([["src/index.ts", symbols]])
|
|
257
|
+
});
|
|
258
|
+
const effect = Effect.gen(function* () {
|
|
259
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
260
|
+
return yield* astGrep.findSymbols("src/index.ts");
|
|
261
|
+
});
|
|
262
|
+
const result = await runEffect(effect, mock.layer);
|
|
263
|
+
expect(result).toEqual(symbols);
|
|
264
|
+
expect(mock.findSymbolsCalls).toContain("src/index.ts");
|
|
265
|
+
});
|
|
266
|
+
it("returns empty array when no fixtures configured", async () => {
|
|
267
|
+
const mock = MockAstGrepService();
|
|
268
|
+
const effect = Effect.gen(function* () {
|
|
269
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
270
|
+
return yield* astGrep.findSymbols("src/unknown.ts");
|
|
271
|
+
});
|
|
272
|
+
const result = await runEffect(effect, mock.layer);
|
|
273
|
+
expect(result).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
it("returns defaultSymbols for unconfigured paths", async () => {
|
|
276
|
+
const defaultSymbols = [
|
|
277
|
+
{ name: "default", kind: "function", line: 1, exported: true }
|
|
278
|
+
];
|
|
279
|
+
const mock = MockAstGrepService({ defaultSymbols });
|
|
280
|
+
const effect = Effect.gen(function* () {
|
|
281
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
282
|
+
return yield* astGrep.findSymbols("any/path.ts");
|
|
283
|
+
});
|
|
284
|
+
const result = await runEffect(effect, mock.layer);
|
|
285
|
+
expect(result).toEqual(defaultSymbols);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe("getImports", () => {
|
|
289
|
+
it("returns fixture data for configured file path", async () => {
|
|
290
|
+
const imports = [
|
|
291
|
+
{ source: "effect", specifiers: ["Effect", "Layer"], kind: "static" },
|
|
292
|
+
{ source: "./utils", specifiers: ["helper"], kind: "static" }
|
|
293
|
+
];
|
|
294
|
+
const mock = MockAstGrepService({
|
|
295
|
+
imports: new Map([["src/index.ts", imports]])
|
|
296
|
+
});
|
|
297
|
+
const effect = Effect.gen(function* () {
|
|
298
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
299
|
+
return yield* astGrep.getImports("src/index.ts");
|
|
300
|
+
});
|
|
301
|
+
const result = await runEffect(effect, mock.layer);
|
|
302
|
+
expect(result).toEqual(imports);
|
|
303
|
+
expect(mock.getImportsCalls).toContain("src/index.ts");
|
|
304
|
+
});
|
|
305
|
+
it("returns empty array when no fixtures configured", async () => {
|
|
306
|
+
const mock = MockAstGrepService();
|
|
307
|
+
const effect = Effect.gen(function* () {
|
|
308
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
309
|
+
return yield* astGrep.getImports("src/unknown.ts");
|
|
310
|
+
});
|
|
311
|
+
const result = await runEffect(effect, mock.layer);
|
|
312
|
+
expect(result).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
it("returns defaultImports for unconfigured paths", async () => {
|
|
315
|
+
const defaultImports = [
|
|
316
|
+
{ source: "default-module", specifiers: ["*"], kind: "static" }
|
|
317
|
+
];
|
|
318
|
+
const mock = MockAstGrepService({ defaultImports });
|
|
319
|
+
const effect = Effect.gen(function* () {
|
|
320
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
321
|
+
return yield* astGrep.getImports("any/path.ts");
|
|
322
|
+
});
|
|
323
|
+
const result = await runEffect(effect, mock.layer);
|
|
324
|
+
expect(result).toEqual(defaultImports);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
describe("matchPattern", () => {
|
|
328
|
+
it("returns matches for specific pattern+path key", async () => {
|
|
329
|
+
const matches = [
|
|
330
|
+
{ file: "src/index.ts", line: 5, column: 1, text: "export function", captures: {} }
|
|
331
|
+
];
|
|
332
|
+
const mock = MockAstGrepService({
|
|
333
|
+
matches: new Map([["export $NAME::src/index.ts", matches]])
|
|
334
|
+
});
|
|
335
|
+
const effect = Effect.gen(function* () {
|
|
336
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
337
|
+
return yield* astGrep.matchPattern("export $NAME", "src/index.ts");
|
|
338
|
+
});
|
|
339
|
+
const result = await runEffect(effect, mock.layer);
|
|
340
|
+
expect(result).toEqual(matches);
|
|
341
|
+
expect(mock.matchPatternCalls).toContainEqual({
|
|
342
|
+
pattern: "export $NAME",
|
|
343
|
+
path: "src/index.ts"
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
it("returns matches for pattern-only key", async () => {
|
|
347
|
+
const matches = [
|
|
348
|
+
{ file: "any.ts", line: 1, column: 1, text: "function foo", captures: { NAME: "foo" } }
|
|
349
|
+
];
|
|
350
|
+
const mock = MockAstGrepService({
|
|
351
|
+
matches: new Map([["function $NAME", matches]])
|
|
352
|
+
});
|
|
353
|
+
const effect = Effect.gen(function* () {
|
|
354
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
355
|
+
return yield* astGrep.matchPattern("function $NAME", "src/any.ts");
|
|
356
|
+
});
|
|
357
|
+
const result = await runEffect(effect, mock.layer);
|
|
358
|
+
expect(result).toEqual(matches);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
describe("call tracking", () => {
|
|
362
|
+
it("tracks all calls made", async () => {
|
|
363
|
+
const mock = MockAstGrepService();
|
|
364
|
+
const effect = Effect.gen(function* () {
|
|
365
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
366
|
+
yield* astGrep.findSymbols("file1.ts");
|
|
367
|
+
yield* astGrep.getImports("file2.ts");
|
|
368
|
+
yield* astGrep.matchPattern("pattern", "file3.ts");
|
|
369
|
+
return "done";
|
|
370
|
+
});
|
|
371
|
+
await runEffect(effect, mock.layer);
|
|
372
|
+
expect(mock.findSymbolsCalls).toEqual(["file1.ts"]);
|
|
373
|
+
expect(mock.getImportsCalls).toEqual(["file2.ts"]);
|
|
374
|
+
expect(mock.matchPatternCalls).toEqual([{ pattern: "pattern", path: "file3.ts" }]);
|
|
375
|
+
expect(mock.getCallCount()).toBe(3);
|
|
376
|
+
});
|
|
377
|
+
it("reset clears all tracked calls", async () => {
|
|
378
|
+
const mock = MockAstGrepService();
|
|
379
|
+
const effect = Effect.gen(function* () {
|
|
380
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
381
|
+
yield* astGrep.findSymbols("file.ts");
|
|
382
|
+
return "done";
|
|
383
|
+
});
|
|
384
|
+
await runEffect(effect, mock.layer);
|
|
385
|
+
expect(mock.getCallCount()).toBe(1);
|
|
386
|
+
mock.reset();
|
|
387
|
+
expect(mock.findSymbolsCalls).toHaveLength(0);
|
|
388
|
+
expect(mock.getCallCount()).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
describe("failure injection", () => {
|
|
392
|
+
it("fails all operations when shouldFail is true", async () => {
|
|
393
|
+
const mock = MockAstGrepService({
|
|
394
|
+
shouldFail: true,
|
|
395
|
+
failureMessage: "ast-grep not installed"
|
|
396
|
+
});
|
|
397
|
+
const effect = Effect.gen(function* () {
|
|
398
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
399
|
+
return yield* astGrep.findSymbols("file.ts");
|
|
400
|
+
});
|
|
401
|
+
const error = await expectEffectFailure(effect, mock.layer);
|
|
402
|
+
expect(error._tag).toBe("AstGrepError");
|
|
403
|
+
expect(error.reason).toBe("ast-grep not installed");
|
|
404
|
+
});
|
|
405
|
+
it("fails specific operations via failuresByOperation", async () => {
|
|
406
|
+
const mock = MockAstGrepService({
|
|
407
|
+
failuresByOperation: new Map([["findSymbols", "Parse error"]])
|
|
408
|
+
});
|
|
409
|
+
// findSymbols should fail
|
|
410
|
+
const findSymbolsEffect = Effect.gen(function* () {
|
|
411
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
412
|
+
return yield* astGrep.findSymbols("file.ts");
|
|
413
|
+
});
|
|
414
|
+
const findSymbolsResult = await runEffectEither(findSymbolsEffect, mock.layer);
|
|
415
|
+
expect(Either.isLeft(findSymbolsResult)).toBe(true);
|
|
416
|
+
// getImports should succeed
|
|
417
|
+
const getImportsEffect = Effect.gen(function* () {
|
|
418
|
+
const astGrep = yield* MockAstGrepServiceTag;
|
|
419
|
+
return yield* astGrep.getImports("file.ts");
|
|
420
|
+
});
|
|
421
|
+
const getImportsResult = await runEffectEither(getImportsEffect, mock.layer);
|
|
422
|
+
expect(Either.isRight(getImportsResult)).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
// =============================================================================
|
|
427
|
+
// MockFileSystem Tests
|
|
428
|
+
// =============================================================================
|
|
429
|
+
describe("MockFileSystem", () => {
|
|
430
|
+
describe("read/write cycle", () => {
|
|
431
|
+
it("writes and reads file content correctly", async () => {
|
|
432
|
+
const mock = MockFileSystem();
|
|
433
|
+
const effect = Effect.gen(function* () {
|
|
434
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
435
|
+
yield* fs.writeFile("/tmp/test.txt", "Hello, World!");
|
|
436
|
+
return yield* fs.readFile("/tmp/test.txt");
|
|
437
|
+
});
|
|
438
|
+
const result = await runEffect(effect, mock.layer);
|
|
439
|
+
expect(result).toBe("Hello, World!");
|
|
440
|
+
});
|
|
441
|
+
it("overwrites existing file content", async () => {
|
|
442
|
+
const mock = MockFileSystem({
|
|
443
|
+
initialFiles: new Map([["/tmp/existing.txt", "Original content"]])
|
|
444
|
+
});
|
|
445
|
+
const effect = Effect.gen(function* () {
|
|
446
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
447
|
+
yield* fs.writeFile("/tmp/existing.txt", "New content");
|
|
448
|
+
return yield* fs.readFile("/tmp/existing.txt");
|
|
449
|
+
});
|
|
450
|
+
const result = await runEffect(effect, mock.layer);
|
|
451
|
+
expect(result).toBe("New content");
|
|
452
|
+
});
|
|
453
|
+
it("reads initial files correctly", async () => {
|
|
454
|
+
const mock = MockFileSystem({
|
|
455
|
+
initialFiles: new Map([
|
|
456
|
+
["/app/config.json", '{"debug": true}'],
|
|
457
|
+
["/app/data.txt", "Hello World"]
|
|
458
|
+
])
|
|
459
|
+
});
|
|
460
|
+
const effect = Effect.gen(function* () {
|
|
461
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
462
|
+
const config = yield* fs.readFile("/app/config.json");
|
|
463
|
+
const data = yield* fs.readFile("/app/data.txt");
|
|
464
|
+
return { config, data };
|
|
465
|
+
});
|
|
466
|
+
const result = await runEffect(effect, mock.layer);
|
|
467
|
+
expect(result.config).toBe('{"debug": true}');
|
|
468
|
+
expect(result.data).toBe("Hello World");
|
|
469
|
+
});
|
|
470
|
+
it("fails to read non-existent file", async () => {
|
|
471
|
+
const mock = MockFileSystem();
|
|
472
|
+
const effect = Effect.gen(function* () {
|
|
473
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
474
|
+
return yield* fs.readFile("/nonexistent.txt");
|
|
475
|
+
});
|
|
476
|
+
const error = await expectEffectFailure(effect, mock.layer);
|
|
477
|
+
expect(error._tag).toBe("FileSystemError");
|
|
478
|
+
expect(error.reason).toContain("ENOENT");
|
|
479
|
+
expect(error.path).toBe("/nonexistent.txt");
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
describe("exists", () => {
|
|
483
|
+
it("returns true for existing file", async () => {
|
|
484
|
+
const mock = MockFileSystem({
|
|
485
|
+
initialFiles: new Map([["/app/exists.txt", "content"]])
|
|
486
|
+
});
|
|
487
|
+
const effect = Effect.gen(function* () {
|
|
488
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
489
|
+
return yield* fs.exists("/app/exists.txt");
|
|
490
|
+
});
|
|
491
|
+
const result = await runEffect(effect, mock.layer);
|
|
492
|
+
expect(result).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
it("returns false for non-existent file", async () => {
|
|
495
|
+
const mock = MockFileSystem();
|
|
496
|
+
const effect = Effect.gen(function* () {
|
|
497
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
498
|
+
return yield* fs.exists("/app/missing.txt");
|
|
499
|
+
});
|
|
500
|
+
const result = await runEffect(effect, mock.layer);
|
|
501
|
+
expect(result).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
it("returns true for existing directory", async () => {
|
|
504
|
+
const mock = MockFileSystem({
|
|
505
|
+
initialFiles: new Map([["/app/src/index.ts", "export {}"]])
|
|
506
|
+
});
|
|
507
|
+
const effect = Effect.gen(function* () {
|
|
508
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
509
|
+
return yield* fs.exists("/app/src");
|
|
510
|
+
});
|
|
511
|
+
const result = await runEffect(effect, mock.layer);
|
|
512
|
+
expect(result).toBe(true);
|
|
513
|
+
});
|
|
514
|
+
it("returns true after file is written", async () => {
|
|
515
|
+
const mock = MockFileSystem();
|
|
516
|
+
const effect = Effect.gen(function* () {
|
|
517
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
518
|
+
const before = yield* fs.exists("/tmp/new.txt");
|
|
519
|
+
yield* fs.writeFile("/tmp/new.txt", "content");
|
|
520
|
+
const after = yield* fs.exists("/tmp/new.txt");
|
|
521
|
+
return { before, after };
|
|
522
|
+
});
|
|
523
|
+
const result = await runEffect(effect, mock.layer);
|
|
524
|
+
expect(result.before).toBe(false);
|
|
525
|
+
expect(result.after).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
describe("mkdir", () => {
|
|
529
|
+
it("creates directory", async () => {
|
|
530
|
+
const mock = MockFileSystem();
|
|
531
|
+
const effect = Effect.gen(function* () {
|
|
532
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
533
|
+
yield* fs.mkdir("/app/new-dir");
|
|
534
|
+
return yield* fs.exists("/app/new-dir");
|
|
535
|
+
});
|
|
536
|
+
const result = await runEffect(effect, mock.layer);
|
|
537
|
+
expect(result).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
it("creates parent directories recursively", async () => {
|
|
540
|
+
const mock = MockFileSystem();
|
|
541
|
+
const effect = Effect.gen(function* () {
|
|
542
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
543
|
+
yield* fs.mkdir("/app/deep/nested/dir");
|
|
544
|
+
const deep = yield* fs.exists("/app/deep");
|
|
545
|
+
const nested = yield* fs.exists("/app/deep/nested");
|
|
546
|
+
const dir = yield* fs.exists("/app/deep/nested/dir");
|
|
547
|
+
return { deep, nested, dir };
|
|
548
|
+
});
|
|
549
|
+
const result = await runEffect(effect, mock.layer);
|
|
550
|
+
expect(result.deep).toBe(true);
|
|
551
|
+
expect(result.nested).toBe(true);
|
|
552
|
+
expect(result.dir).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
describe("readdir", () => {
|
|
556
|
+
it("lists files in directory", async () => {
|
|
557
|
+
const mock = MockFileSystem({
|
|
558
|
+
initialFiles: new Map([
|
|
559
|
+
["/app/src/a.ts", ""],
|
|
560
|
+
["/app/src/b.ts", ""],
|
|
561
|
+
["/app/src/c.ts", ""]
|
|
562
|
+
])
|
|
563
|
+
});
|
|
564
|
+
const effect = Effect.gen(function* () {
|
|
565
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
566
|
+
return yield* fs.readdir("/app/src");
|
|
567
|
+
});
|
|
568
|
+
const result = await runEffect(effect, mock.layer);
|
|
569
|
+
expect(result).toContain("a.ts");
|
|
570
|
+
expect(result).toContain("b.ts");
|
|
571
|
+
expect(result).toContain("c.ts");
|
|
572
|
+
});
|
|
573
|
+
it("fails for non-existent directory", async () => {
|
|
574
|
+
const mock = MockFileSystem();
|
|
575
|
+
const effect = Effect.gen(function* () {
|
|
576
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
577
|
+
return yield* fs.readdir("/nonexistent");
|
|
578
|
+
});
|
|
579
|
+
const error = await expectEffectFailure(effect, mock.layer);
|
|
580
|
+
expect(error._tag).toBe("FileSystemError");
|
|
581
|
+
expect(error.reason).toContain("ENOENT");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
describe("call tracking", () => {
|
|
585
|
+
it("tracks all file system calls", async () => {
|
|
586
|
+
const mock = MockFileSystem({
|
|
587
|
+
initialFiles: new Map([["/tmp/test.txt", "content"]])
|
|
588
|
+
});
|
|
589
|
+
const effect = Effect.gen(function* () {
|
|
590
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
591
|
+
yield* fs.exists("/tmp/test.txt");
|
|
592
|
+
yield* fs.readFile("/tmp/test.txt");
|
|
593
|
+
yield* fs.writeFile("/tmp/new.txt", "new content");
|
|
594
|
+
yield* fs.mkdir("/tmp/new-dir");
|
|
595
|
+
return "done";
|
|
596
|
+
});
|
|
597
|
+
await runEffect(effect, mock.layer);
|
|
598
|
+
expect(mock.existsCalls).toEqual(["/tmp/test.txt"]);
|
|
599
|
+
expect(mock.readFileCalls).toEqual(["/tmp/test.txt"]);
|
|
600
|
+
expect(mock.writeFileCalls).toEqual([{ path: "/tmp/new.txt", content: "new content" }]);
|
|
601
|
+
expect(mock.mkdirCalls).toEqual(["/tmp/new-dir"]);
|
|
602
|
+
expect(mock.getCallCount()).toBe(4);
|
|
603
|
+
});
|
|
604
|
+
it("getFiles returns current file state", async () => {
|
|
605
|
+
const mock = MockFileSystem({
|
|
606
|
+
initialFiles: new Map([["/initial.txt", "initial"]])
|
|
607
|
+
});
|
|
608
|
+
const effect = Effect.gen(function* () {
|
|
609
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
610
|
+
yield* fs.writeFile("/new.txt", "new");
|
|
611
|
+
return "done";
|
|
612
|
+
});
|
|
613
|
+
await runEffect(effect, mock.layer);
|
|
614
|
+
const files = mock.getFiles();
|
|
615
|
+
expect(files.get("/initial.txt")).toBe("initial");
|
|
616
|
+
expect(files.get("/new.txt")).toBe("new");
|
|
617
|
+
});
|
|
618
|
+
it("reset restores initial state", async () => {
|
|
619
|
+
const mock = MockFileSystem({
|
|
620
|
+
initialFiles: new Map([["/initial.txt", "initial"]])
|
|
621
|
+
});
|
|
622
|
+
const effect = Effect.gen(function* () {
|
|
623
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
624
|
+
yield* fs.writeFile("/new.txt", "new");
|
|
625
|
+
yield* fs.writeFile("/initial.txt", "modified");
|
|
626
|
+
return "done";
|
|
627
|
+
});
|
|
628
|
+
await runEffect(effect, mock.layer);
|
|
629
|
+
expect(mock.getFiles().get("/initial.txt")).toBe("modified");
|
|
630
|
+
expect(mock.getFiles().has("/new.txt")).toBe(true);
|
|
631
|
+
mock.reset();
|
|
632
|
+
expect(mock.getFiles().get("/initial.txt")).toBe("initial");
|
|
633
|
+
expect(mock.getFiles().has("/new.txt")).toBe(false);
|
|
634
|
+
expect(mock.getCallCount()).toBe(0);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
describe("failure injection", () => {
|
|
638
|
+
it("fails all operations when shouldFail is true", async () => {
|
|
639
|
+
const mock = MockFileSystem({
|
|
640
|
+
shouldFail: true,
|
|
641
|
+
failureMessage: "Disk full"
|
|
642
|
+
});
|
|
643
|
+
const effect = Effect.gen(function* () {
|
|
644
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
645
|
+
return yield* fs.readFile("/any.txt");
|
|
646
|
+
});
|
|
647
|
+
const error = await expectEffectFailure(effect, mock.layer);
|
|
648
|
+
expect(error._tag).toBe("FileSystemError");
|
|
649
|
+
expect(error.reason).toBe("Disk full");
|
|
650
|
+
});
|
|
651
|
+
it("fails specific operations via failuresByOperation", async () => {
|
|
652
|
+
const mock = MockFileSystem({
|
|
653
|
+
failuresByOperation: new Map([["writeFile", "Read-only file system"]])
|
|
654
|
+
});
|
|
655
|
+
// writeFile should fail
|
|
656
|
+
const writeEffect = Effect.gen(function* () {
|
|
657
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
658
|
+
return yield* fs.writeFile("/test.txt", "content");
|
|
659
|
+
});
|
|
660
|
+
const writeResult = await runEffectEither(writeEffect, mock.layer);
|
|
661
|
+
expect(Either.isLeft(writeResult)).toBe(true);
|
|
662
|
+
// readFile should succeed (after we have a file)
|
|
663
|
+
const mock2 = MockFileSystem({
|
|
664
|
+
initialFiles: new Map([["/test.txt", "content"]]),
|
|
665
|
+
failuresByOperation: new Map([["writeFile", "Read-only file system"]])
|
|
666
|
+
});
|
|
667
|
+
const readEffect = Effect.gen(function* () {
|
|
668
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
669
|
+
return yield* fs.readFile("/test.txt");
|
|
670
|
+
});
|
|
671
|
+
const readResult = await runEffectEither(readEffect, mock2.layer);
|
|
672
|
+
expect(Either.isRight(readResult)).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
it("fails on specific paths via failuresByPath", async () => {
|
|
675
|
+
const mock = MockFileSystem({
|
|
676
|
+
initialFiles: new Map([
|
|
677
|
+
["/allowed.txt", "content"],
|
|
678
|
+
["/protected/secret.txt", "secret"]
|
|
679
|
+
]),
|
|
680
|
+
failuresByPath: new Map([["/protected/secret.txt", "Permission denied"]])
|
|
681
|
+
});
|
|
682
|
+
// Reading allowed file should succeed
|
|
683
|
+
const allowedEffect = Effect.gen(function* () {
|
|
684
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
685
|
+
return yield* fs.readFile("/allowed.txt");
|
|
686
|
+
});
|
|
687
|
+
const allowedResult = await runEffect(allowedEffect, mock.layer);
|
|
688
|
+
expect(allowedResult).toBe("content");
|
|
689
|
+
// Reading protected file should fail
|
|
690
|
+
const protectedEffect = Effect.gen(function* () {
|
|
691
|
+
const fs = yield* MockFileSystemServiceTag;
|
|
692
|
+
return yield* fs.readFile("/protected/secret.txt");
|
|
693
|
+
});
|
|
694
|
+
const error = await expectEffectFailure(protectedEffect, mock.layer);
|
|
695
|
+
expect(error.reason).toBe("Permission denied");
|
|
696
|
+
expect(error.path).toBe("/protected/secret.txt");
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
// =============================================================================
|
|
701
|
+
// createMockOpenAI Tests
|
|
702
|
+
// =============================================================================
|
|
703
|
+
describe("createMockOpenAI", () => {
|
|
704
|
+
describe("call tracking", () => {
|
|
705
|
+
it("records all calls", async () => {
|
|
706
|
+
const mock = createMockOpenAI();
|
|
707
|
+
await mock.client.chat.completions.create({
|
|
708
|
+
model: "gpt-4o-mini",
|
|
709
|
+
max_tokens: 256,
|
|
710
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
711
|
+
});
|
|
712
|
+
await mock.client.chat.completions.create({
|
|
713
|
+
model: "gpt-4o-mini",
|
|
714
|
+
max_tokens: 512,
|
|
715
|
+
messages: [{ role: "user", content: "World" }]
|
|
716
|
+
});
|
|
717
|
+
expect(mock.calls).toHaveLength(2);
|
|
718
|
+
expect(mock.getCallCount()).toBe(2);
|
|
719
|
+
});
|
|
720
|
+
it("stores call parameters correctly", async () => {
|
|
721
|
+
const mock = createMockOpenAI();
|
|
722
|
+
await mock.client.chat.completions.create({
|
|
723
|
+
model: "gpt-4o-mini",
|
|
724
|
+
max_tokens: 256,
|
|
725
|
+
messages: [
|
|
726
|
+
{ role: "user", content: "First message" },
|
|
727
|
+
{ role: "assistant", content: "Response" },
|
|
728
|
+
{ role: "user", content: "Second message" }
|
|
729
|
+
],
|
|
730
|
+
response_format: { type: "json_object" }
|
|
731
|
+
});
|
|
732
|
+
expect(mock.calls[0]).toEqual({
|
|
733
|
+
model: "gpt-4o-mini",
|
|
734
|
+
max_tokens: 256,
|
|
735
|
+
messages: [
|
|
736
|
+
{ role: "user", content: "First message" },
|
|
737
|
+
{ role: "assistant", content: "Response" },
|
|
738
|
+
{ role: "user", content: "Second message" }
|
|
739
|
+
],
|
|
740
|
+
response_format: { type: "json_object" }
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
it("getLastCall returns the most recent call", async () => {
|
|
744
|
+
const mock = createMockOpenAI();
|
|
745
|
+
await mock.client.chat.completions.create({
|
|
746
|
+
model: "model-a",
|
|
747
|
+
messages: [{ role: "user", content: "First" }]
|
|
748
|
+
});
|
|
749
|
+
await mock.client.chat.completions.create({
|
|
750
|
+
model: "model-b",
|
|
751
|
+
messages: [{ role: "user", content: "Second" }]
|
|
752
|
+
});
|
|
753
|
+
const lastCall = mock.getLastCall();
|
|
754
|
+
expect(lastCall?.model).toBe("model-b");
|
|
755
|
+
expect(lastCall?.messages[0].content).toBe("Second");
|
|
756
|
+
});
|
|
757
|
+
it("getLastCall returns undefined when no calls made", () => {
|
|
758
|
+
const mock = createMockOpenAI();
|
|
759
|
+
expect(mock.getLastCall()).toBeUndefined();
|
|
760
|
+
});
|
|
761
|
+
it("reset clears all tracked calls", async () => {
|
|
762
|
+
const mock = createMockOpenAI();
|
|
763
|
+
await mock.client.chat.completions.create({
|
|
764
|
+
model: "gpt-4o-mini",
|
|
765
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
766
|
+
});
|
|
767
|
+
expect(mock.calls).toHaveLength(1);
|
|
768
|
+
mock.reset();
|
|
769
|
+
expect(mock.calls).toHaveLength(0);
|
|
770
|
+
expect(mock.getCallCount()).toBe(0);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
describe("response fixtures", () => {
|
|
774
|
+
it("returns default minimal response when no config", async () => {
|
|
775
|
+
const mock = createMockOpenAI();
|
|
776
|
+
const response = await mock.client.chat.completions.create({
|
|
777
|
+
model: "gpt-4o-mini",
|
|
778
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
779
|
+
});
|
|
780
|
+
expect(response.id).toBe("mock-chatcmpl-id");
|
|
781
|
+
expect(response.object).toBe("chat.completion");
|
|
782
|
+
expect(response.model).toBe("gpt-4o-mini");
|
|
783
|
+
expect(response.choices).toHaveLength(1);
|
|
784
|
+
expect(response.choices[0].message.role).toBe("assistant");
|
|
785
|
+
});
|
|
786
|
+
it("returns configured defaultResponse", async () => {
|
|
787
|
+
const customResponse = {
|
|
788
|
+
id: "custom-id",
|
|
789
|
+
object: "chat.completion",
|
|
790
|
+
created: 1234567890,
|
|
791
|
+
model: "gpt-4o-mini",
|
|
792
|
+
choices: [{
|
|
793
|
+
index: 0,
|
|
794
|
+
message: { role: "assistant", content: "Custom response text" },
|
|
795
|
+
finish_reason: "stop"
|
|
796
|
+
}],
|
|
797
|
+
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }
|
|
798
|
+
};
|
|
799
|
+
const mock = createMockOpenAI({ defaultResponse: customResponse });
|
|
800
|
+
const response = await mock.client.chat.completions.create({
|
|
801
|
+
model: "any-model",
|
|
802
|
+
messages: [{ role: "user", content: "Anything" }]
|
|
803
|
+
});
|
|
804
|
+
expect(response).toEqual(customResponse);
|
|
805
|
+
});
|
|
806
|
+
it("returns specific response for matching messages", async () => {
|
|
807
|
+
const specificResponse = {
|
|
808
|
+
id: "specific-id",
|
|
809
|
+
object: "chat.completion",
|
|
810
|
+
created: 1234567890,
|
|
811
|
+
model: "gpt-4o-mini",
|
|
812
|
+
choices: [{
|
|
813
|
+
index: 0,
|
|
814
|
+
message: { role: "assistant", content: "Specific answer" },
|
|
815
|
+
finish_reason: "stop"
|
|
816
|
+
}]
|
|
817
|
+
};
|
|
818
|
+
const messages = [{ role: "user", content: "Specific question" }];
|
|
819
|
+
const responses = new Map([
|
|
820
|
+
[JSON.stringify(messages), specificResponse]
|
|
821
|
+
]);
|
|
822
|
+
const mock = createMockOpenAI({ responses });
|
|
823
|
+
const response = await mock.client.chat.completions.create({
|
|
824
|
+
model: "gpt-4o-mini",
|
|
825
|
+
messages: messages
|
|
826
|
+
});
|
|
827
|
+
expect(response.id).toBe("specific-id");
|
|
828
|
+
expect(response.choices[0].message.content).toBe("Specific answer");
|
|
829
|
+
});
|
|
830
|
+
it("falls back to defaultResponse when specific response not found", async () => {
|
|
831
|
+
const defaultResponse = {
|
|
832
|
+
id: "default-id",
|
|
833
|
+
object: "chat.completion",
|
|
834
|
+
created: 1234567890,
|
|
835
|
+
model: "gpt-4o-mini",
|
|
836
|
+
choices: [{
|
|
837
|
+
index: 0,
|
|
838
|
+
message: { role: "assistant", content: "Default" },
|
|
839
|
+
finish_reason: "stop"
|
|
840
|
+
}]
|
|
841
|
+
};
|
|
842
|
+
const responses = new Map([
|
|
843
|
+
[JSON.stringify([{ role: "user", content: "Match" }]), {
|
|
844
|
+
id: "match-id",
|
|
845
|
+
object: "chat.completion",
|
|
846
|
+
created: 1234567890,
|
|
847
|
+
model: "gpt-4o-mini",
|
|
848
|
+
choices: [{
|
|
849
|
+
index: 0,
|
|
850
|
+
message: { role: "assistant", content: "Matched" },
|
|
851
|
+
finish_reason: "stop"
|
|
852
|
+
}]
|
|
853
|
+
}]
|
|
854
|
+
]);
|
|
855
|
+
const mock = createMockOpenAI({ responses, defaultResponse });
|
|
856
|
+
const response = await mock.client.chat.completions.create({
|
|
857
|
+
model: "gpt-4o-mini",
|
|
858
|
+
messages: [{ role: "user", content: "No match" }]
|
|
859
|
+
});
|
|
860
|
+
expect(response.id).toBe("default-id");
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
describe("failure injection", () => {
|
|
864
|
+
it("throws error when shouldFail is true", async () => {
|
|
865
|
+
const mock = createMockOpenAI({ shouldFail: true });
|
|
866
|
+
await expect(mock.client.chat.completions.create({
|
|
867
|
+
model: "gpt-4o-mini",
|
|
868
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
869
|
+
})).rejects.toThrow("Mock OpenAI API error");
|
|
870
|
+
});
|
|
871
|
+
it("throws custom failureMessage", async () => {
|
|
872
|
+
const mock = createMockOpenAI({
|
|
873
|
+
shouldFail: true,
|
|
874
|
+
failureMessage: "Rate limit exceeded"
|
|
875
|
+
});
|
|
876
|
+
await expect(mock.client.chat.completions.create({
|
|
877
|
+
model: "gpt-4o-mini",
|
|
878
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
879
|
+
})).rejects.toThrow("Rate limit exceeded");
|
|
880
|
+
});
|
|
881
|
+
it("throws custom failureError", async () => {
|
|
882
|
+
const customError = new Error("Custom error object");
|
|
883
|
+
const mock = createMockOpenAI({
|
|
884
|
+
shouldFail: true,
|
|
885
|
+
failureError: customError
|
|
886
|
+
});
|
|
887
|
+
await expect(mock.client.chat.completions.create({
|
|
888
|
+
model: "gpt-4o-mini",
|
|
889
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
890
|
+
})).rejects.toBe(customError);
|
|
891
|
+
});
|
|
892
|
+
it("failureError takes precedence over failureMessage", async () => {
|
|
893
|
+
const customError = new Error("Custom error wins");
|
|
894
|
+
const mock = createMockOpenAI({
|
|
895
|
+
shouldFail: true,
|
|
896
|
+
failureMessage: "This should not be used",
|
|
897
|
+
failureError: customError
|
|
898
|
+
});
|
|
899
|
+
await expect(mock.client.chat.completions.create({
|
|
900
|
+
model: "gpt-4o-mini",
|
|
901
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
902
|
+
})).rejects.toBe(customError);
|
|
903
|
+
});
|
|
904
|
+
it("still tracks calls when failing", async () => {
|
|
905
|
+
const mock = createMockOpenAI({ shouldFail: true });
|
|
906
|
+
try {
|
|
907
|
+
await mock.client.chat.completions.create({
|
|
908
|
+
model: "gpt-4o-mini",
|
|
909
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
// Expected
|
|
914
|
+
}
|
|
915
|
+
expect(mock.calls).toHaveLength(1);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
describe("latency simulation", () => {
|
|
919
|
+
it("delays response by configured latencyMs", async () => {
|
|
920
|
+
const mock = createMockOpenAI({ latencyMs: 100 });
|
|
921
|
+
const start = Date.now();
|
|
922
|
+
await mock.client.chat.completions.create({
|
|
923
|
+
model: "gpt-4o-mini",
|
|
924
|
+
messages: [{ role: "user", content: "Hello" }]
|
|
925
|
+
});
|
|
926
|
+
const elapsed = Date.now() - start;
|
|
927
|
+
expect(elapsed).toBeGreaterThanOrEqual(95); // Allow small timing variance
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
describe("createMockOpenAIForExtraction", () => {
|
|
931
|
+
it("returns candidates wrapped in object", async () => {
|
|
932
|
+
const candidates = [
|
|
933
|
+
{ content: "Always use transactions", confidence: "high", category: "patterns" },
|
|
934
|
+
{ content: "Test database migrations", confidence: "medium", category: "testing" }
|
|
935
|
+
];
|
|
936
|
+
const mock = createMockOpenAIForExtraction(candidates);
|
|
937
|
+
const response = await mock.client.chat.completions.create({
|
|
938
|
+
model: "gpt-4o-mini",
|
|
939
|
+
messages: [{ role: "user", content: "Extract learnings" }]
|
|
940
|
+
});
|
|
941
|
+
expect(response.choices[0].message.content).toBe(JSON.stringify({ candidates }));
|
|
942
|
+
expect(JSON.parse(response.choices[0].message.content)).toEqual({ candidates });
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
describe("createMockOpenAIForExtractionRaw", () => {
|
|
946
|
+
it("returns candidates as raw JSON array", async () => {
|
|
947
|
+
const candidates = [
|
|
948
|
+
{ content: "Always use transactions", confidence: "high", category: "patterns" },
|
|
949
|
+
{ content: "Test database migrations", confidence: "medium", category: "testing" }
|
|
950
|
+
];
|
|
951
|
+
const mock = createMockOpenAIForExtractionRaw(candidates);
|
|
952
|
+
const response = await mock.client.chat.completions.create({
|
|
953
|
+
model: "gpt-4o-mini",
|
|
954
|
+
messages: [{ role: "user", content: "Extract learnings" }]
|
|
955
|
+
});
|
|
956
|
+
expect(response.choices[0].message.content).toBe(JSON.stringify(candidates));
|
|
957
|
+
expect(JSON.parse(response.choices[0].message.content)).toEqual(candidates);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
//# sourceMappingURL=mocks.test.js.map
|