@sparkleideas/testing 3.0.0-alpha.10
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/README.md +547 -0
- package/__tests__/framework.test.ts +21 -0
- package/package.json +61 -0
- package/src/fixtures/agent-fixtures.ts +793 -0
- package/src/fixtures/agents.ts +212 -0
- package/src/fixtures/configurations.ts +491 -0
- package/src/fixtures/index.ts +21 -0
- package/src/fixtures/mcp-fixtures.ts +1030 -0
- package/src/fixtures/memory-entries.ts +328 -0
- package/src/fixtures/memory-fixtures.ts +750 -0
- package/src/fixtures/swarm-fixtures.ts +837 -0
- package/src/fixtures/tasks.ts +309 -0
- package/src/helpers/assertion-helpers.ts +616 -0
- package/src/helpers/assertions.ts +286 -0
- package/src/helpers/create-mock.ts +200 -0
- package/src/helpers/index.ts +182 -0
- package/src/helpers/mock-factory.ts +711 -0
- package/src/helpers/setup-teardown.ts +678 -0
- package/src/helpers/swarm-instance.ts +326 -0
- package/src/helpers/test-application.ts +310 -0
- package/src/helpers/test-utils.ts +670 -0
- package/src/index.ts +232 -0
- package/src/mocks/index.ts +29 -0
- package/src/mocks/mock-mcp-client.ts +723 -0
- package/src/mocks/mock-services.ts +793 -0
- package/src/regression/api-contract.ts +473 -0
- package/src/regression/index.ts +46 -0
- package/src/regression/integration-regression.ts +416 -0
- package/src/regression/performance-baseline.ts +356 -0
- package/src/regression/regression-runner.ts +339 -0
- package/src/regression/security-regression.ts +331 -0
- package/src/setup.ts +127 -0
- package/src/v2-compat/api-compat.test.ts +590 -0
- package/src/v2-compat/cli-compat.test.ts +484 -0
- package/src/v2-compat/compatibility-validator.ts +1072 -0
- package/src/v2-compat/hooks-compat.test.ts +602 -0
- package/src/v2-compat/index.ts +58 -0
- package/src/v2-compat/mcp-compat.test.ts +557 -0
- package/src/v2-compat/report-generator.ts +441 -0
- package/tmp.json +0 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sparkleideas/testing - Assertion Helpers
|
|
3
|
+
*
|
|
4
|
+
* Custom Vitest matchers and assertion utilities for V3 module testing.
|
|
5
|
+
* Implements London School TDD behavior verification patterns.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, type Mock, type ExpectStatic } from 'vitest';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Assert that a mock was called with arguments matching a pattern
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* assertCalledWithPattern(mockFn, { userId: expect.any(String) });
|
|
14
|
+
*/
|
|
15
|
+
export function assertCalledWithPattern(
|
|
16
|
+
mock: Mock,
|
|
17
|
+
pattern: Record<string, unknown> | unknown[]
|
|
18
|
+
): void {
|
|
19
|
+
const calls = mock.mock.calls;
|
|
20
|
+
const matched = calls.some(call => {
|
|
21
|
+
if (Array.isArray(pattern)) {
|
|
22
|
+
return pattern.every((expected, i) => {
|
|
23
|
+
if (typeof expected === 'object' && expected !== null && 'asymmetricMatch' in expected) {
|
|
24
|
+
return (expected as { asymmetricMatch: (actual: unknown) => boolean }).asymmetricMatch(call[i]);
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify(call[i]) === JSON.stringify(expected);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const callArg = call[0] as Record<string, unknown>;
|
|
31
|
+
return Object.entries(pattern).every(([key, expected]) => {
|
|
32
|
+
if (typeof expected === 'object' && expected !== null && 'asymmetricMatch' in expected) {
|
|
33
|
+
return (expected as { asymmetricMatch: (actual: unknown) => boolean }).asymmetricMatch(callArg[key]);
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(callArg[key]) === JSON.stringify(expected);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(matched).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Assert that events were published in order
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* assertEventOrder(mockEventBus.publish, ['UserCreated', 'EmailSent']);
|
|
47
|
+
*/
|
|
48
|
+
export function assertEventOrder(
|
|
49
|
+
publishMock: Mock,
|
|
50
|
+
expectedEventTypes: string[]
|
|
51
|
+
): void {
|
|
52
|
+
const actualEventTypes = publishMock.mock.calls
|
|
53
|
+
.map(call => (call[0] as { type: string }).type)
|
|
54
|
+
.filter(type => expectedEventTypes.includes(type));
|
|
55
|
+
|
|
56
|
+
expect(actualEventTypes).toEqual(expectedEventTypes);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Assert that an event was published with specific payload
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* assertEventPublished(mockEventBus, 'UserCreated', { userId: '123' });
|
|
64
|
+
*/
|
|
65
|
+
export function assertEventPublished(
|
|
66
|
+
eventBusMock: { publish: Mock } | Mock,
|
|
67
|
+
eventType: string,
|
|
68
|
+
expectedPayload?: Record<string, unknown>
|
|
69
|
+
): void {
|
|
70
|
+
const publishMock = 'publish' in eventBusMock ? eventBusMock.publish : eventBusMock;
|
|
71
|
+
const calls = publishMock.mock.calls;
|
|
72
|
+
|
|
73
|
+
const matchingEvent = calls.find(call => {
|
|
74
|
+
const event = call[0] as { type: string; payload?: unknown };
|
|
75
|
+
return event.type === eventType;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(matchingEvent).toBeDefined();
|
|
79
|
+
|
|
80
|
+
if (expectedPayload && matchingEvent) {
|
|
81
|
+
const actualPayload = (matchingEvent[0] as { payload: unknown }).payload;
|
|
82
|
+
expect(actualPayload).toMatchObject(expectedPayload);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Assert that no event of a specific type was published
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* assertEventNotPublished(mockEventBus, 'UserDeleted');
|
|
91
|
+
*/
|
|
92
|
+
export function assertEventNotPublished(
|
|
93
|
+
eventBusMock: { publish: Mock } | Mock,
|
|
94
|
+
eventType: string
|
|
95
|
+
): void {
|
|
96
|
+
const publishMock = 'publish' in eventBusMock ? eventBusMock.publish : eventBusMock;
|
|
97
|
+
const calls = publishMock.mock.calls;
|
|
98
|
+
|
|
99
|
+
const matchingEvent = calls.find(call => {
|
|
100
|
+
const event = call[0] as { type: string };
|
|
101
|
+
return event.type === eventType;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(matchingEvent).toBeUndefined();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assert that mocks were called in a specific order
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* assertMocksCalledInOrder([mockValidate, mockSave, mockNotify]);
|
|
112
|
+
*/
|
|
113
|
+
export function assertMocksCalledInOrder(mocks: Mock[]): void {
|
|
114
|
+
const orders = mocks.map(mock => {
|
|
115
|
+
if (mock.mock.invocationCallOrder.length === 0) {
|
|
116
|
+
return Infinity;
|
|
117
|
+
}
|
|
118
|
+
return Math.min(...mock.mock.invocationCallOrder);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
for (let i = 1; i < orders.length; i++) {
|
|
122
|
+
expect(orders[i]).toBeGreaterThan(orders[i - 1]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Assert that a mock was called exactly n times with specific arguments
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* assertCalledNTimesWith(mockFn, 3, ['arg1', 'arg2']);
|
|
131
|
+
*/
|
|
132
|
+
export function assertCalledNTimesWith(
|
|
133
|
+
mock: Mock,
|
|
134
|
+
times: number,
|
|
135
|
+
args: unknown[]
|
|
136
|
+
): void {
|
|
137
|
+
const matchingCalls = mock.mock.calls.filter(
|
|
138
|
+
call => JSON.stringify(call) === JSON.stringify(args)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(matchingCalls).toHaveLength(times);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Assert that async operations completed within time limit
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* await assertCompletesWithin(async () => await slowOp(), 1000);
|
|
149
|
+
*/
|
|
150
|
+
export async function assertCompletesWithin(
|
|
151
|
+
operation: () => Promise<unknown>,
|
|
152
|
+
maxMs: number
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const start = performance.now();
|
|
155
|
+
await operation();
|
|
156
|
+
const duration = performance.now() - start;
|
|
157
|
+
|
|
158
|
+
expect(duration).toBeLessThanOrEqual(maxMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Assert that an operation throws a specific error
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* await assertThrowsError(
|
|
166
|
+
* async () => await riskyOp(),
|
|
167
|
+
* ValidationError,
|
|
168
|
+
* 'Invalid input'
|
|
169
|
+
* );
|
|
170
|
+
*/
|
|
171
|
+
export async function assertThrowsError<E extends Error>(
|
|
172
|
+
operation: () => Promise<unknown>,
|
|
173
|
+
ErrorType: new (...args: unknown[]) => E,
|
|
174
|
+
messagePattern?: string | RegExp
|
|
175
|
+
): Promise<E> {
|
|
176
|
+
let error: E | undefined;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await operation();
|
|
180
|
+
} catch (e) {
|
|
181
|
+
error = e as E;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(error).toBeInstanceOf(ErrorType);
|
|
185
|
+
|
|
186
|
+
if (messagePattern && error) {
|
|
187
|
+
if (typeof messagePattern === 'string') {
|
|
188
|
+
expect(error.message).toContain(messagePattern);
|
|
189
|
+
} else {
|
|
190
|
+
expect(error.message).toMatch(messagePattern);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return error!;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Assert that no sensitive data appears in logs
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* assertNoSensitiveData(mockLogger.logs, ['password', 'token', 'secret']);
|
|
202
|
+
*/
|
|
203
|
+
export function assertNoSensitiveData(
|
|
204
|
+
logs: Array<{ message: string; context?: Record<string, unknown> }>,
|
|
205
|
+
sensitivePatterns: string[]
|
|
206
|
+
): void {
|
|
207
|
+
for (const log of logs) {
|
|
208
|
+
const content = JSON.stringify(log).toLowerCase();
|
|
209
|
+
|
|
210
|
+
for (const pattern of sensitivePatterns) {
|
|
211
|
+
expect(content).not.toContain(pattern.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Assert that a value matches a snapshot with custom serialization
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* assertMatchesSnapshot(result, { ignoreFields: ['timestamp', 'id'] });
|
|
221
|
+
*/
|
|
222
|
+
export function assertMatchesSnapshot(
|
|
223
|
+
value: unknown,
|
|
224
|
+
options: SnapshotOptions = {}
|
|
225
|
+
): void {
|
|
226
|
+
const { ignoreFields = [], transform } = options;
|
|
227
|
+
|
|
228
|
+
let processed = value;
|
|
229
|
+
|
|
230
|
+
if (ignoreFields.length > 0 && typeof processed === 'object' && processed !== null) {
|
|
231
|
+
processed = removeFields(processed as Record<string, unknown>, ignoreFields);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (transform) {
|
|
235
|
+
processed = transform(processed);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
expect(processed).toMatchSnapshot();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Snapshot options interface
|
|
243
|
+
*/
|
|
244
|
+
export interface SnapshotOptions {
|
|
245
|
+
ignoreFields?: string[];
|
|
246
|
+
transform?: (value: unknown) => unknown;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove fields from object for snapshot comparison
|
|
251
|
+
*/
|
|
252
|
+
function removeFields(obj: Record<string, unknown>, fields: string[]): Record<string, unknown> {
|
|
253
|
+
const result = { ...obj };
|
|
254
|
+
|
|
255
|
+
for (const field of fields) {
|
|
256
|
+
delete result[field];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const [key, value] of Object.entries(result)) {
|
|
260
|
+
if (typeof value === 'object' && value !== null) {
|
|
261
|
+
result[key] = removeFields(value as Record<string, unknown>, fields);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Assert that performance metrics meet V3 targets
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* assertV3PerformanceTargets({
|
|
273
|
+
* searchSpeedup: 160,
|
|
274
|
+
* memoryReduction: 0.55,
|
|
275
|
+
* });
|
|
276
|
+
*/
|
|
277
|
+
export function assertV3PerformanceTargets(metrics: V3PerformanceMetrics): void {
|
|
278
|
+
// Search speedup: 150x - 12500x
|
|
279
|
+
if (metrics.searchSpeedup !== undefined) {
|
|
280
|
+
expect(metrics.searchSpeedup).toBeGreaterThanOrEqual(150);
|
|
281
|
+
expect(metrics.searchSpeedup).toBeLessThanOrEqual(12500);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Flash attention speedup: 2.49x - 7.47x
|
|
285
|
+
if (metrics.flashAttentionSpeedup !== undefined) {
|
|
286
|
+
expect(metrics.flashAttentionSpeedup).toBeGreaterThanOrEqual(2.49);
|
|
287
|
+
expect(metrics.flashAttentionSpeedup).toBeLessThanOrEqual(7.47);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Memory reduction: >= 50%
|
|
291
|
+
if (metrics.memoryReduction !== undefined) {
|
|
292
|
+
expect(metrics.memoryReduction).toBeGreaterThanOrEqual(0.50);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Startup time: < 500ms
|
|
296
|
+
if (metrics.startupTimeMs !== undefined) {
|
|
297
|
+
expect(metrics.startupTimeMs).toBeLessThan(500);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Response time: sub-100ms
|
|
301
|
+
if (metrics.responseTimeMs !== undefined) {
|
|
302
|
+
expect(metrics.responseTimeMs).toBeLessThan(100);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* V3 performance metrics interface
|
|
308
|
+
*/
|
|
309
|
+
export interface V3PerformanceMetrics {
|
|
310
|
+
searchSpeedup?: number;
|
|
311
|
+
flashAttentionSpeedup?: number;
|
|
312
|
+
memoryReduction?: number;
|
|
313
|
+
startupTimeMs?: number;
|
|
314
|
+
responseTimeMs?: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Assert that a domain object is valid
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* assertValidDomainObject(user, UserSchema);
|
|
322
|
+
*/
|
|
323
|
+
export function assertValidDomainObject<T>(
|
|
324
|
+
object: T,
|
|
325
|
+
validator: (obj: T) => { valid: boolean; errors?: string[] }
|
|
326
|
+
): void {
|
|
327
|
+
const result = validator(object);
|
|
328
|
+
|
|
329
|
+
if (!result.valid) {
|
|
330
|
+
throw new Error(`Invalid domain object: ${result.errors?.join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Assert that a mock was only called with allowed arguments
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* assertOnlyCalledWithAllowed(mockFn, [['valid1'], ['valid2']]);
|
|
339
|
+
*/
|
|
340
|
+
export function assertOnlyCalledWithAllowed(
|
|
341
|
+
mock: Mock,
|
|
342
|
+
allowedCalls: unknown[][]
|
|
343
|
+
): void {
|
|
344
|
+
const calls = mock.mock.calls;
|
|
345
|
+
|
|
346
|
+
for (const call of calls) {
|
|
347
|
+
const isAllowed = allowedCalls.some(
|
|
348
|
+
allowed => JSON.stringify(call) === JSON.stringify(allowed)
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
if (!isAllowed) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Mock was called with unexpected arguments: ${JSON.stringify(call)}\n` +
|
|
354
|
+
`Allowed: ${JSON.stringify(allowedCalls)}`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Assert that an array contains elements in partial order
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* assertPartialOrder(events, [
|
|
365
|
+
* { type: 'Start' },
|
|
366
|
+
* { type: 'Process' },
|
|
367
|
+
* { type: 'End' },
|
|
368
|
+
* ]);
|
|
369
|
+
*/
|
|
370
|
+
export function assertPartialOrder<T>(
|
|
371
|
+
actual: T[],
|
|
372
|
+
expectedOrder: Partial<T>[]
|
|
373
|
+
): void {
|
|
374
|
+
let lastIndex = -1;
|
|
375
|
+
|
|
376
|
+
for (const expected of expectedOrder) {
|
|
377
|
+
const index = actual.findIndex((item, i) =>
|
|
378
|
+
i > lastIndex &&
|
|
379
|
+
Object.entries(expected as Record<string, unknown>).every(
|
|
380
|
+
([key, value]) => (item as Record<string, unknown>)[key] === value
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
if (index === -1) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Expected to find ${JSON.stringify(expected)} after index ${lastIndex} in array`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
lastIndex = index;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Assert that all items in a collection pass a predicate
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* assertAllPass(results, result => result.success);
|
|
399
|
+
*/
|
|
400
|
+
export function assertAllPass<T>(
|
|
401
|
+
items: T[],
|
|
402
|
+
predicate: (item: T, index: number) => boolean,
|
|
403
|
+
message?: string
|
|
404
|
+
): void {
|
|
405
|
+
for (let i = 0; i < items.length; i++) {
|
|
406
|
+
if (!predicate(items[i], i)) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
message ?? `Item at index ${i} failed predicate: ${JSON.stringify(items[i])}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Assert that none of the items in a collection pass a predicate
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* assertNonePass(results, result => result.error);
|
|
419
|
+
*/
|
|
420
|
+
export function assertNonePass<T>(
|
|
421
|
+
items: T[],
|
|
422
|
+
predicate: (item: T, index: number) => boolean,
|
|
423
|
+
message?: string
|
|
424
|
+
): void {
|
|
425
|
+
for (let i = 0; i < items.length; i++) {
|
|
426
|
+
if (predicate(items[i], i)) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
message ?? `Item at index ${i} passed predicate but should not have: ${JSON.stringify(items[i])}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Assert that two arrays have the same elements regardless of order
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* assertSameElements([1, 2, 3], [3, 1, 2]);
|
|
439
|
+
*/
|
|
440
|
+
export function assertSameElements<T>(actual: T[], expected: T[]): void {
|
|
441
|
+
expect(actual).toHaveLength(expected.length);
|
|
442
|
+
|
|
443
|
+
const actualSorted = [...actual].sort((a, b) =>
|
|
444
|
+
JSON.stringify(a).localeCompare(JSON.stringify(b))
|
|
445
|
+
);
|
|
446
|
+
const expectedSorted = [...expected].sort((a, b) =>
|
|
447
|
+
JSON.stringify(a).localeCompare(JSON.stringify(b))
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
expect(actualSorted).toEqual(expectedSorted);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Assert that a mock returns expected results in sequence
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* await assertMockReturnsSequence(mockFn, [1, 2, 3]);
|
|
458
|
+
*/
|
|
459
|
+
export async function assertMockReturnsSequence(
|
|
460
|
+
mock: Mock,
|
|
461
|
+
expectedResults: unknown[]
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
for (const expected of expectedResults) {
|
|
464
|
+
const result = await mock();
|
|
465
|
+
expect(result).toEqual(expected);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Assert state transition is valid
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* assertValidStateTransition(
|
|
474
|
+
* 'pending',
|
|
475
|
+
* 'running',
|
|
476
|
+
* { pending: ['running', 'cancelled'], running: ['completed', 'failed'] }
|
|
477
|
+
* );
|
|
478
|
+
*/
|
|
479
|
+
export function assertValidStateTransition<T extends string>(
|
|
480
|
+
from: T,
|
|
481
|
+
to: T,
|
|
482
|
+
allowedTransitions: Record<T, T[]>
|
|
483
|
+
): void {
|
|
484
|
+
const allowed = allowedTransitions[from];
|
|
485
|
+
|
|
486
|
+
if (!allowed || !allowed.includes(to)) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Invalid state transition from '${from}' to '${to}'. ` +
|
|
489
|
+
`Allowed transitions from '${from}': ${allowed?.join(', ') ?? 'none'}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Assert that a retry policy was followed
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* assertRetryPattern(mockFn, { attempts: 3, backoffPattern: 'exponential' });
|
|
499
|
+
*/
|
|
500
|
+
export function assertRetryPattern(
|
|
501
|
+
mock: Mock,
|
|
502
|
+
options: RetryPatternOptions
|
|
503
|
+
): void {
|
|
504
|
+
const calls = mock.mock.calls;
|
|
505
|
+
|
|
506
|
+
expect(calls).toHaveLength(options.attempts);
|
|
507
|
+
|
|
508
|
+
if (options.backoffPattern === 'exponential' && calls.length > 1) {
|
|
509
|
+
// Check that intervals roughly follow exponential pattern
|
|
510
|
+
const invocationOrder = mock.mock.invocationCallOrder;
|
|
511
|
+
for (let i = 2; i < invocationOrder.length; i++) {
|
|
512
|
+
const prevGap = invocationOrder[i - 1] - invocationOrder[i - 2];
|
|
513
|
+
const currentGap = invocationOrder[i] - invocationOrder[i - 1];
|
|
514
|
+
// Allow some variance in timing
|
|
515
|
+
expect(currentGap).toBeGreaterThanOrEqual(prevGap * 0.8);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Retry pattern options interface
|
|
522
|
+
*/
|
|
523
|
+
export interface RetryPatternOptions {
|
|
524
|
+
attempts: number;
|
|
525
|
+
backoffPattern?: 'linear' | 'exponential' | 'constant';
|
|
526
|
+
initialDelayMs?: number;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Assert that a dependency was properly injected
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* assertDependencyInjected(service, 'repository', mockRepository);
|
|
534
|
+
*/
|
|
535
|
+
export function assertDependencyInjected<T extends object>(
|
|
536
|
+
subject: T,
|
|
537
|
+
propertyName: keyof T,
|
|
538
|
+
expectedDependency: unknown
|
|
539
|
+
): void {
|
|
540
|
+
expect(subject[propertyName]).toBe(expectedDependency);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Custom Vitest matcher declarations
|
|
545
|
+
* Note: Main declarations in setup.ts - these extend CustomMatchers
|
|
546
|
+
*/
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Register custom Vitest matchers
|
|
550
|
+
*/
|
|
551
|
+
export function registerCustomMatchers(): void {
|
|
552
|
+
expect.extend({
|
|
553
|
+
toHaveBeenCalledWithPattern(received: Mock, pattern: Record<string, unknown>) {
|
|
554
|
+
const calls = received.mock.calls;
|
|
555
|
+
const pass = calls.some(call => {
|
|
556
|
+
const callArg = call[0] as Record<string, unknown>;
|
|
557
|
+
return Object.entries(pattern).every(([key, expected]) =>
|
|
558
|
+
JSON.stringify(callArg[key]) === JSON.stringify(expected)
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
pass,
|
|
564
|
+
message: () => pass
|
|
565
|
+
? `Expected mock not to have been called with pattern ${JSON.stringify(pattern)}`
|
|
566
|
+
: `Expected mock to have been called with pattern ${JSON.stringify(pattern)}`,
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
toHaveEventType(received: { type: string }, eventType: string) {
|
|
571
|
+
const pass = received.type === eventType;
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
pass,
|
|
575
|
+
message: () => pass
|
|
576
|
+
? `Expected event not to have type ${eventType}`
|
|
577
|
+
: `Expected event to have type ${eventType}, but got ${received.type}`,
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
toMeetV3PerformanceTargets(received: V3PerformanceMetrics) {
|
|
582
|
+
const issues: string[] = [];
|
|
583
|
+
|
|
584
|
+
if (received.searchSpeedup !== undefined) {
|
|
585
|
+
if (received.searchSpeedup < 150) {
|
|
586
|
+
issues.push(`Search speedup ${received.searchSpeedup}x is below minimum 150x`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (received.flashAttentionSpeedup !== undefined) {
|
|
591
|
+
if (received.flashAttentionSpeedup < 2.49) {
|
|
592
|
+
issues.push(`Flash attention speedup ${received.flashAttentionSpeedup}x is below minimum 2.49x`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (received.memoryReduction !== undefined) {
|
|
597
|
+
if (received.memoryReduction < 0.50) {
|
|
598
|
+
issues.push(`Memory reduction ${received.memoryReduction * 100}% is below target 50%`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (received.startupTimeMs !== undefined) {
|
|
603
|
+
if (received.startupTimeMs >= 500) {
|
|
604
|
+
issues.push(`Startup time ${received.startupTimeMs}ms exceeds target 500ms`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
pass: issues.length === 0,
|
|
610
|
+
message: () => issues.length === 0
|
|
611
|
+
? 'Performance metrics meet V3 targets'
|
|
612
|
+
: `Performance issues: ${issues.join('; ')}`,
|
|
613
|
+
};
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|