@go-go-scope/testing 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/dist/index.d.mts +422 -0
- package/dist/index.mjs +442 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @go-go-scope/testing
|
|
2
|
+
|
|
3
|
+
Testing utilities for go-go-scope - mocks, spies, time control, and test helpers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D @go-go-scope/testing
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Mock Scope
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { createMockScope } from '@go-go-scope/testing'
|
|
17
|
+
|
|
18
|
+
const s = createMockScope({
|
|
19
|
+
autoAdvanceTimers: true,
|
|
20
|
+
deterministic: true
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const [err, result] = await s.task(() => Promise.resolve('done'))
|
|
24
|
+
expect(result).toBe('done')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Spies
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { createSpy } from '@go-go-scope/testing'
|
|
31
|
+
|
|
32
|
+
const spy = createSpy().mockReturnValue('mocked')
|
|
33
|
+
const result = spy()
|
|
34
|
+
expect(result).toBe('mocked')
|
|
35
|
+
expect(spy.wasCalled()).toBe(true)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Time Control
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { createTimeTravelController } from '@go-go-scope/testing'
|
|
42
|
+
|
|
43
|
+
const time = createTimeTravelController()
|
|
44
|
+
|
|
45
|
+
// Schedule operations
|
|
46
|
+
const results: number[] = []
|
|
47
|
+
time.setTimeout(() => results.push(1), 100)
|
|
48
|
+
time.setTimeout(() => results.push(2), 200)
|
|
49
|
+
|
|
50
|
+
// Jump to specific time
|
|
51
|
+
time.jumpTo(150)
|
|
52
|
+
expect(results).toEqual([1]) // Only first timer fired
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Mock Channels
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { createMockChannel } from '@go-go-scope/testing'
|
|
59
|
+
|
|
60
|
+
const mockCh = createMockChannel<number>()
|
|
61
|
+
mockCh.setReceiveValues([1, 2, 3])
|
|
62
|
+
|
|
63
|
+
const ch = mockCh.channel
|
|
64
|
+
const value = await ch.receive()
|
|
65
|
+
expect(value).toBe(1)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import * as go_go_scope from 'go-go-scope';
|
|
2
|
+
import { Scope, TaskOptions } from 'go-go-scope';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Time travel testing utilities for go-go-scope
|
|
6
|
+
*
|
|
7
|
+
* Allows controlling time in tests for deterministic timeout and retry testing.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A controller for manipulating time in tests.
|
|
11
|
+
* Use this to fast-forward through delays and timeouts without waiting.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const time = createTimeController()
|
|
16
|
+
*
|
|
17
|
+
* await using s = scope({ timeout: 5000 })
|
|
18
|
+
* const taskPromise = s.task(() => longOperation(), { timeout: 10000 })
|
|
19
|
+
*
|
|
20
|
+
* // Fast forward 10 seconds instantly
|
|
21
|
+
* time.advance(10000)
|
|
22
|
+
*
|
|
23
|
+
* // Task will have timed out
|
|
24
|
+
* const [err] = await taskPromise
|
|
25
|
+
* expect(err?.message).toContain('timeout')
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
interface TimeController {
|
|
29
|
+
/** Current simulated time in milliseconds */
|
|
30
|
+
readonly now: number;
|
|
31
|
+
/**
|
|
32
|
+
* Advance time by the specified amount.
|
|
33
|
+
* Any pending timeouts scheduled within this time window will fire.
|
|
34
|
+
*/
|
|
35
|
+
advance(ms: number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Jump to a specific absolute time.
|
|
38
|
+
* All pending timeouts up to that point will fire.
|
|
39
|
+
*/
|
|
40
|
+
jumpTo(timestamp: number): void;
|
|
41
|
+
/**
|
|
42
|
+
* Run all pending timeouts immediately.
|
|
43
|
+
*/
|
|
44
|
+
runAll(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Reset time to 0 and clear all pending timeouts.
|
|
47
|
+
*/
|
|
48
|
+
reset(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Create a Promise that resolves after the specified delay.
|
|
51
|
+
* Respects time control.
|
|
52
|
+
*/
|
|
53
|
+
delay(ms: number): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Install this controller as the global time source.
|
|
56
|
+
* Call `uninstall()` to restore original behavior.
|
|
57
|
+
*/
|
|
58
|
+
install(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Uninstall this controller and restore original global time.
|
|
61
|
+
*/
|
|
62
|
+
uninstall(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create a time controller for testing.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* describe('with time control', () => {
|
|
70
|
+
* const time = createTimeController()
|
|
71
|
+
*
|
|
72
|
+
* afterEach(() => {
|
|
73
|
+
* time.reset()
|
|
74
|
+
* })
|
|
75
|
+
*
|
|
76
|
+
* test('timeouts work', async () => {
|
|
77
|
+
* await using s = scope({ timeout: 5000 })
|
|
78
|
+
*
|
|
79
|
+
* // Fast forward past timeout
|
|
80
|
+
* time.advance(5001)
|
|
81
|
+
*
|
|
82
|
+
* expect(s.signal.aborted).toBe(true)
|
|
83
|
+
* })
|
|
84
|
+
* })
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
declare function createTimeController(): TimeController;
|
|
88
|
+
/**
|
|
89
|
+
* Test helper that creates a scope with time control enabled.
|
|
90
|
+
* Useful for testing timeout and retry logic.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* test('task retries', async () => {
|
|
95
|
+
* const { scope, time } = createTestScope({ timeout: 5000 })
|
|
96
|
+
*
|
|
97
|
+
* let attempts = 0
|
|
98
|
+
* const task = scope.task(() => {
|
|
99
|
+
* attempts++
|
|
100
|
+
* if (attempts < 3) throw new Error('fail')
|
|
101
|
+
* return 'success'
|
|
102
|
+
* }, { retry: { maxRetries: 3, delay: 1000 } })
|
|
103
|
+
*
|
|
104
|
+
* // Fast forward through retries
|
|
105
|
+
* time.advance(1000) // First retry
|
|
106
|
+
* time.advance(1000) // Second retry
|
|
107
|
+
*
|
|
108
|
+
* const [err, result] = await task
|
|
109
|
+
* expect(result).toBe('success')
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function createTestScope(options?: {
|
|
114
|
+
timeout?: number;
|
|
115
|
+
concurrency?: number;
|
|
116
|
+
}): Promise<{
|
|
117
|
+
scope: go_go_scope.Scope;
|
|
118
|
+
time: TimeController;
|
|
119
|
+
}>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Test utilities for go-go-scope
|
|
123
|
+
*
|
|
124
|
+
* Provides helper functions and mocks for testing code that uses go-go-scope.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* import { createMockScope } from '@go-go-scope/testing'
|
|
129
|
+
*
|
|
130
|
+
* const s = createMockScope({
|
|
131
|
+
* autoAdvanceTimers: true,
|
|
132
|
+
* deterministic: true
|
|
133
|
+
* })
|
|
134
|
+
*
|
|
135
|
+
* await s.task(() => fetchData())
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Options for creating a mock scope
|
|
141
|
+
*/
|
|
142
|
+
interface MockScopeOptions {
|
|
143
|
+
/** Automatically advance timers for async operations */
|
|
144
|
+
autoAdvanceTimers?: boolean;
|
|
145
|
+
/** Use deterministic random seeds for reproducible tests */
|
|
146
|
+
deterministic?: boolean;
|
|
147
|
+
/** Pre-configured services to inject */
|
|
148
|
+
services?: Record<string, unknown>;
|
|
149
|
+
/** Services to override (for mocking existing services) */
|
|
150
|
+
overrides?: Record<string, unknown>;
|
|
151
|
+
/** Initial aborted state */
|
|
152
|
+
aborted?: boolean;
|
|
153
|
+
/** Abort reason if aborted */
|
|
154
|
+
abortReason?: unknown;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Task call record for tracking
|
|
158
|
+
*/
|
|
159
|
+
interface TaskCall {
|
|
160
|
+
fn: (ctx: {
|
|
161
|
+
services: Record<string, never>;
|
|
162
|
+
signal: AbortSignal;
|
|
163
|
+
}) => Promise<unknown>;
|
|
164
|
+
options?: TaskOptions;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Extended Scope with mock capabilities
|
|
168
|
+
*/
|
|
169
|
+
interface MockScope extends Scope<Record<string, never>> {
|
|
170
|
+
/** Record of task calls made to this scope */
|
|
171
|
+
taskCalls: TaskCall[];
|
|
172
|
+
/** Options used to create this mock scope */
|
|
173
|
+
options: MockScopeOptions;
|
|
174
|
+
/** Abort the scope with optional reason */
|
|
175
|
+
abort: (reason?: unknown) => void;
|
|
176
|
+
/** Get all recorded task calls */
|
|
177
|
+
getTaskCalls: () => TaskCall[];
|
|
178
|
+
/** Clear recorded task calls */
|
|
179
|
+
clearTaskCalls: () => void;
|
|
180
|
+
/** Override a service with a mock implementation */
|
|
181
|
+
mockService: <K extends string, T>(key: K, value: T) => MockScope;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Creates a mock scope for testing purposes.
|
|
185
|
+
*
|
|
186
|
+
* The mock scope provides:
|
|
187
|
+
* - Controlled timer advancement
|
|
188
|
+
* - Deterministic execution
|
|
189
|
+
* - Easy cancellation testing
|
|
190
|
+
* - Spy capabilities on task execution
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* import { createMockScope } from '@go-go-scope/testing'
|
|
195
|
+
* import { describe, test, expect } from 'vitest'
|
|
196
|
+
*
|
|
197
|
+
* describe('my feature', () => {
|
|
198
|
+
* test('should complete task', async () => {
|
|
199
|
+
* const s = createMockScope()
|
|
200
|
+
*
|
|
201
|
+
* const [err, result] = await s.task(() => Promise.resolve('done'))
|
|
202
|
+
*
|
|
203
|
+
* expect(err).toBeUndefined()
|
|
204
|
+
* expect(result).toBe('done')
|
|
205
|
+
* })
|
|
206
|
+
* })
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
declare function createMockScope(options?: MockScopeOptions): MockScope;
|
|
210
|
+
/**
|
|
211
|
+
* Creates a controlled timer environment for testing async operations.
|
|
212
|
+
* Useful for testing timeout and retry logic.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```typescript
|
|
216
|
+
* import { createControlledTimer } from '@go-go-scope/testing'
|
|
217
|
+
*
|
|
218
|
+
* const timer = createControlledTimer()
|
|
219
|
+
*
|
|
220
|
+
* // In your test
|
|
221
|
+
* timer.advance(1000) // Advance by 1 second
|
|
222
|
+
* await timer.flush() // Flush all pending timers
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
declare function createControlledTimer(): {
|
|
226
|
+
/** Current simulated time */
|
|
227
|
+
readonly currentTime: number;
|
|
228
|
+
/** Schedule a callback to run after delay */
|
|
229
|
+
setTimeout(callback: () => void, delay: number): number;
|
|
230
|
+
/** Cancel a scheduled callback */
|
|
231
|
+
clearTimeout(id: number): void;
|
|
232
|
+
/** Advance time by specified milliseconds */
|
|
233
|
+
advance(ms: number): void;
|
|
234
|
+
/** Run all pending timers immediately */
|
|
235
|
+
flush(): Promise<void>;
|
|
236
|
+
/** Reset all timers */
|
|
237
|
+
reset(): void;
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* Waits for all promises in the scope to settle.
|
|
241
|
+
* Useful for testing async operations.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```typescript
|
|
245
|
+
* import { flushPromises } from '@go-go-scope/testing'
|
|
246
|
+
*
|
|
247
|
+
* test('async operation', async () => {
|
|
248
|
+
* const promise = s.task(async () => {
|
|
249
|
+
* await delay(100)
|
|
250
|
+
* return 'done'
|
|
251
|
+
* })
|
|
252
|
+
*
|
|
253
|
+
* await flushPromises()
|
|
254
|
+
*
|
|
255
|
+
* const [err, result] = await promise
|
|
256
|
+
* expect(result).toBe('done')
|
|
257
|
+
* })
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
declare function flushPromises(): Promise<void>;
|
|
261
|
+
/**
|
|
262
|
+
* Creates a spy function for testing.
|
|
263
|
+
* Tracks calls and can return mock values.
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* import { createSpy } from '@go-go-scope/testing'
|
|
268
|
+
*
|
|
269
|
+
* const spy = createSpy().mockReturnValue('mocked')
|
|
270
|
+
*
|
|
271
|
+
* const result = spy()
|
|
272
|
+
* expect(result).toBe('mocked')
|
|
273
|
+
* expect(spy).toHaveBeenCalledTimes(1)
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
interface Spy<TArgs extends unknown[], TReturn> {
|
|
277
|
+
(...args: TArgs): TReturn;
|
|
278
|
+
calls: {
|
|
279
|
+
args: TArgs;
|
|
280
|
+
result: TReturn;
|
|
281
|
+
}[];
|
|
282
|
+
mockImplementation: (fn: (...args: TArgs) => TReturn) => Spy<TArgs, TReturn>;
|
|
283
|
+
mockReturnValue: (value: TReturn) => Spy<TArgs, TReturn>;
|
|
284
|
+
mockReset: () => Spy<TArgs, TReturn>;
|
|
285
|
+
getCalls: () => {
|
|
286
|
+
args: TArgs;
|
|
287
|
+
result: TReturn;
|
|
288
|
+
}[];
|
|
289
|
+
wasCalled: () => boolean;
|
|
290
|
+
wasCalledWith: (...expectedArgs: TArgs) => boolean;
|
|
291
|
+
}
|
|
292
|
+
declare function createSpy<TArgs extends unknown[], TReturn>(): Spy<TArgs, TReturn>;
|
|
293
|
+
/**
|
|
294
|
+
* Asserts that a scope has been properly disposed.
|
|
295
|
+
* Checks that all resources are cleaned up.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```typescript
|
|
299
|
+
* import { assertScopeDisposed } from '@go-go-scope/testing'
|
|
300
|
+
*
|
|
301
|
+
* test('should dispose properly', async () => {
|
|
302
|
+
* const s = scope()
|
|
303
|
+
* s.task(() => doSomething())
|
|
304
|
+
*
|
|
305
|
+
* await assertScopeDisposed(s)
|
|
306
|
+
* })
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
declare function assertScopeDisposed(scope: Scope): Promise<void>;
|
|
310
|
+
/**
|
|
311
|
+
* Mock channel for testing.
|
|
312
|
+
* Provides fine-grained control over channel behavior.
|
|
313
|
+
*/
|
|
314
|
+
interface MockChannel<T> {
|
|
315
|
+
/** The channel instance */
|
|
316
|
+
channel: {
|
|
317
|
+
send: (value: T) => Promise<boolean>;
|
|
318
|
+
receive: () => Promise<T | undefined>;
|
|
319
|
+
close: () => void;
|
|
320
|
+
[Symbol.asyncIterator]: () => AsyncIterator<T>;
|
|
321
|
+
};
|
|
322
|
+
/** Pre-configured values to be received */
|
|
323
|
+
mockReceiveValues: T[];
|
|
324
|
+
/** Record of sent values */
|
|
325
|
+
sentValues: T[];
|
|
326
|
+
/** Whether the channel is closed */
|
|
327
|
+
isClosed: boolean;
|
|
328
|
+
/** Set values to be received */
|
|
329
|
+
setReceiveValues: (values: T[]) => void;
|
|
330
|
+
/** Simulate receiving a value */
|
|
331
|
+
mockReceive: (value: T) => void;
|
|
332
|
+
/** Get all sent values */
|
|
333
|
+
getSentValues: () => T[];
|
|
334
|
+
/** Clear sent values */
|
|
335
|
+
clearSentValues: () => void;
|
|
336
|
+
/** Close the mock channel */
|
|
337
|
+
close: () => void;
|
|
338
|
+
/** Reset to initial state */
|
|
339
|
+
reset: () => void;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Creates a mock channel for testing.
|
|
343
|
+
* Useful for testing code that uses channels without actual async operations.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```typescript
|
|
347
|
+
* import { createMockChannel } from '@go-go-scope/testing'
|
|
348
|
+
*
|
|
349
|
+
* test('channel communication', async () => {
|
|
350
|
+
* const mockCh = createMockChannel<number>()
|
|
351
|
+
* mockCh.setReceiveValues([1, 2, 3])
|
|
352
|
+
*
|
|
353
|
+
* const ch = mockCh.channel
|
|
354
|
+
* const value = await ch.receive()
|
|
355
|
+
* expect(value).toBe(1)
|
|
356
|
+
* })
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
declare function createMockChannel<T>(): MockChannel<T>;
|
|
360
|
+
/**
|
|
361
|
+
* Time travel controller for deterministic testing of time-based operations.
|
|
362
|
+
* More powerful than createControlledTimer - allows jumping to specific times,
|
|
363
|
+
* rewinding, and inspecting the timeline.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```typescript
|
|
367
|
+
* import { createTimeTravelController } from '@go-go-scope/testing'
|
|
368
|
+
*
|
|
369
|
+
* test('time-based operations', async () => {
|
|
370
|
+
* const time = createTimeTravelController()
|
|
371
|
+
*
|
|
372
|
+
* // Schedule operations
|
|
373
|
+
* const results: number[] = []
|
|
374
|
+
* time.setTimeout(() => results.push(1), 100)
|
|
375
|
+
* time.setTimeout(() => results.push(2), 200)
|
|
376
|
+
*
|
|
377
|
+
* // Jump to specific time
|
|
378
|
+
* time.jumpTo(150)
|
|
379
|
+
* expect(results).toEqual([1]) // Only first timer fired
|
|
380
|
+
*
|
|
381
|
+
* // Continue to end
|
|
382
|
+
* time.advance(100)
|
|
383
|
+
* expect(results).toEqual([1, 2])
|
|
384
|
+
* })
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
declare function createTimeTravelController(): {
|
|
388
|
+
/** Current simulated time in milliseconds */
|
|
389
|
+
readonly now: number;
|
|
390
|
+
/** Get the event timeline (sorted by time) */
|
|
391
|
+
readonly timeline: Array<{
|
|
392
|
+
id: number;
|
|
393
|
+
time: number;
|
|
394
|
+
description?: string;
|
|
395
|
+
}>;
|
|
396
|
+
/** Get the execution history */
|
|
397
|
+
readonly history: {
|
|
398
|
+
time: number;
|
|
399
|
+
action: string;
|
|
400
|
+
}[];
|
|
401
|
+
/** Schedule a callback to run after delay */
|
|
402
|
+
setTimeout(callback: () => void, delay: number, description?: string): number;
|
|
403
|
+
/** Cancel a scheduled callback */
|
|
404
|
+
clearTimeout(id: number): boolean;
|
|
405
|
+
/** Set an interval that repeats */
|
|
406
|
+
setInterval(callback: () => void, delay: number, description?: string): number;
|
|
407
|
+
/** Advance time by specified milliseconds */
|
|
408
|
+
advance(ms: number): void;
|
|
409
|
+
/** Jump to a specific absolute time */
|
|
410
|
+
jumpTo(targetTime: number): void;
|
|
411
|
+
/** Get the next scheduled event time, or Infinity if none */
|
|
412
|
+
nextEventTime(): number;
|
|
413
|
+
/** Run all events until no more remain */
|
|
414
|
+
runAll(): void;
|
|
415
|
+
/** Reset to initial state */
|
|
416
|
+
reset(): void;
|
|
417
|
+
/** Print the timeline for debugging */
|
|
418
|
+
printTimeline(): void;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
export { assertScopeDisposed, createControlledTimer, createMockChannel, createMockScope, createSpy, createTestScope, createTimeController, createTimeTravelController, flushPromises };
|
|
422
|
+
export type { MockChannel, MockScope, MockScopeOptions, Spy, TaskCall };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { Scope } from 'go-go-scope';
|
|
2
|
+
|
|
3
|
+
function createTimeController() {
|
|
4
|
+
let currentTime = 0;
|
|
5
|
+
const pendingTimeouts = [];
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
let originalDateNow;
|
|
8
|
+
let originalSetTimeout;
|
|
9
|
+
let originalClearTimeout;
|
|
10
|
+
let installed = false;
|
|
11
|
+
const processTimeouts = (upToTime) => {
|
|
12
|
+
pendingTimeouts.sort((a, b) => a.time - b.time);
|
|
13
|
+
while (pendingTimeouts.length > 0) {
|
|
14
|
+
const next = pendingTimeouts[0];
|
|
15
|
+
if (!next || next.time > upToTime) break;
|
|
16
|
+
pendingTimeouts.shift();
|
|
17
|
+
currentTime = next.time;
|
|
18
|
+
next.callback();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
get now() {
|
|
23
|
+
return currentTime;
|
|
24
|
+
},
|
|
25
|
+
advance(ms) {
|
|
26
|
+
const targetTime = currentTime + ms;
|
|
27
|
+
processTimeouts(targetTime);
|
|
28
|
+
currentTime = targetTime;
|
|
29
|
+
},
|
|
30
|
+
jumpTo(timestamp) {
|
|
31
|
+
if (timestamp < currentTime) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Cannot jump backwards in time: ${timestamp} < ${currentTime}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
processTimeouts(timestamp);
|
|
37
|
+
currentTime = timestamp;
|
|
38
|
+
},
|
|
39
|
+
runAll() {
|
|
40
|
+
if (pendingTimeouts.length === 0) return;
|
|
41
|
+
pendingTimeouts.sort((a, b) => a.time - b.time);
|
|
42
|
+
const lastTimeout = pendingTimeouts.at(-1);
|
|
43
|
+
if (lastTimeout) {
|
|
44
|
+
processTimeouts(lastTimeout.time);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
reset() {
|
|
48
|
+
pendingTimeouts.length = 0;
|
|
49
|
+
currentTime = 0;
|
|
50
|
+
nextId = 1;
|
|
51
|
+
},
|
|
52
|
+
delay(ms) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const id = nextId++;
|
|
55
|
+
pendingTimeouts.push({
|
|
56
|
+
id,
|
|
57
|
+
callback: resolve,
|
|
58
|
+
time: currentTime + ms
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
install() {
|
|
63
|
+
if (installed) return;
|
|
64
|
+
installed = true;
|
|
65
|
+
originalDateNow = Date.now;
|
|
66
|
+
originalSetTimeout = globalThis.setTimeout;
|
|
67
|
+
originalClearTimeout = globalThis.clearTimeout;
|
|
68
|
+
Date.now = () => currentTime;
|
|
69
|
+
globalThis.setTimeout = (callback, delay = 0) => {
|
|
70
|
+
const id = nextId++;
|
|
71
|
+
pendingTimeouts.push({
|
|
72
|
+
id,
|
|
73
|
+
callback,
|
|
74
|
+
time: currentTime + delay
|
|
75
|
+
});
|
|
76
|
+
return id;
|
|
77
|
+
};
|
|
78
|
+
globalThis.clearTimeout = (id) => {
|
|
79
|
+
const index = pendingTimeouts.findIndex(
|
|
80
|
+
(t) => t.id === id
|
|
81
|
+
);
|
|
82
|
+
if (index !== -1) {
|
|
83
|
+
pendingTimeouts.splice(index, 1);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
uninstall() {
|
|
88
|
+
if (!installed) return;
|
|
89
|
+
installed = false;
|
|
90
|
+
if (originalDateNow) Date.now = originalDateNow;
|
|
91
|
+
if (originalSetTimeout) globalThis.setTimeout = originalSetTimeout;
|
|
92
|
+
if (originalClearTimeout) globalThis.clearTimeout = originalClearTimeout;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function createTestScope(options) {
|
|
97
|
+
const time = createTimeController();
|
|
98
|
+
time.install();
|
|
99
|
+
const { scope } = await import('go-go-scope');
|
|
100
|
+
const s = scope(options);
|
|
101
|
+
return { scope: s, time };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createMockScope(options = {}) {
|
|
105
|
+
const scopeOptions = {
|
|
106
|
+
name: "mock-scope"
|
|
107
|
+
};
|
|
108
|
+
const baseScope = new Scope(scopeOptions);
|
|
109
|
+
const mockScope = baseScope;
|
|
110
|
+
mockScope.taskCalls = [];
|
|
111
|
+
mockScope.options = options;
|
|
112
|
+
const originalTask = baseScope.task.bind(baseScope);
|
|
113
|
+
mockScope.task = (fn, taskOptions) => {
|
|
114
|
+
mockScope.taskCalls.push({ fn, options: taskOptions });
|
|
115
|
+
return originalTask(fn, taskOptions);
|
|
116
|
+
};
|
|
117
|
+
mockScope.abort = (reason) => {
|
|
118
|
+
baseScope.abortController.abort(reason);
|
|
119
|
+
};
|
|
120
|
+
mockScope.getTaskCalls = function() {
|
|
121
|
+
return this.taskCalls;
|
|
122
|
+
};
|
|
123
|
+
mockScope.clearTaskCalls = function() {
|
|
124
|
+
this.taskCalls = [];
|
|
125
|
+
};
|
|
126
|
+
if (options.services) {
|
|
127
|
+
for (const [key, value] of Object.entries(options.services)) {
|
|
128
|
+
mockScope[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (options.overrides) {
|
|
132
|
+
for (const [key, value] of Object.entries(options.overrides)) {
|
|
133
|
+
mockScope[key] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
mockScope.mockService = function(key, value) {
|
|
137
|
+
this[key] = value;
|
|
138
|
+
return this;
|
|
139
|
+
};
|
|
140
|
+
if (options.aborted) {
|
|
141
|
+
mockScope.abort(options.abortReason);
|
|
142
|
+
}
|
|
143
|
+
return mockScope;
|
|
144
|
+
}
|
|
145
|
+
function createControlledTimer() {
|
|
146
|
+
let currentTime = 0;
|
|
147
|
+
const pendingTimers = [];
|
|
148
|
+
let nextId = 1;
|
|
149
|
+
return {
|
|
150
|
+
/** Current simulated time */
|
|
151
|
+
get currentTime() {
|
|
152
|
+
return currentTime;
|
|
153
|
+
},
|
|
154
|
+
/** Schedule a callback to run after delay */
|
|
155
|
+
setTimeout(callback, delay) {
|
|
156
|
+
const id = nextId++;
|
|
157
|
+
pendingTimers.push({
|
|
158
|
+
id,
|
|
159
|
+
callback,
|
|
160
|
+
time: currentTime + delay
|
|
161
|
+
});
|
|
162
|
+
pendingTimers.sort((a, b) => a.time - b.time);
|
|
163
|
+
return id;
|
|
164
|
+
},
|
|
165
|
+
/** Cancel a scheduled callback */
|
|
166
|
+
clearTimeout(id) {
|
|
167
|
+
const index = pendingTimers.findIndex((t) => t.id === id);
|
|
168
|
+
if (index !== -1) {
|
|
169
|
+
pendingTimers.splice(index, 1);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
/** Advance time by specified milliseconds */
|
|
173
|
+
advance(ms) {
|
|
174
|
+
const targetTime = currentTime + ms;
|
|
175
|
+
while (pendingTimers.length > 0) {
|
|
176
|
+
const timer = pendingTimers[0];
|
|
177
|
+
if (!timer || timer.time > targetTime) break;
|
|
178
|
+
pendingTimers.shift();
|
|
179
|
+
currentTime = timer.time;
|
|
180
|
+
timer.callback();
|
|
181
|
+
}
|
|
182
|
+
currentTime = targetTime;
|
|
183
|
+
},
|
|
184
|
+
/** Run all pending timers immediately */
|
|
185
|
+
flush() {
|
|
186
|
+
while (pendingTimers.length > 0) {
|
|
187
|
+
const timer = pendingTimers.shift();
|
|
188
|
+
if (timer) {
|
|
189
|
+
currentTime = timer.time;
|
|
190
|
+
timer.callback();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return Promise.resolve();
|
|
194
|
+
},
|
|
195
|
+
/** Reset all timers */
|
|
196
|
+
reset() {
|
|
197
|
+
pendingTimers.length = 0;
|
|
198
|
+
currentTime = 0;
|
|
199
|
+
nextId = 1;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function flushPromises() {
|
|
204
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
205
|
+
}
|
|
206
|
+
function createSpy() {
|
|
207
|
+
const calls = [];
|
|
208
|
+
let mockImplementation;
|
|
209
|
+
let mockReturnValue;
|
|
210
|
+
let returnValueSet = false;
|
|
211
|
+
const spy = function(...args) {
|
|
212
|
+
let result;
|
|
213
|
+
if (mockImplementation) {
|
|
214
|
+
result = mockImplementation(...args);
|
|
215
|
+
} else if (returnValueSet) {
|
|
216
|
+
result = mockReturnValue;
|
|
217
|
+
} else {
|
|
218
|
+
result = void 0;
|
|
219
|
+
}
|
|
220
|
+
calls.push({ args, result });
|
|
221
|
+
return result;
|
|
222
|
+
};
|
|
223
|
+
spy.calls = calls;
|
|
224
|
+
spy.mockImplementation = (fn) => {
|
|
225
|
+
mockImplementation = fn;
|
|
226
|
+
return spy;
|
|
227
|
+
};
|
|
228
|
+
spy.mockReturnValue = (value) => {
|
|
229
|
+
mockReturnValue = value;
|
|
230
|
+
returnValueSet = true;
|
|
231
|
+
return spy;
|
|
232
|
+
};
|
|
233
|
+
spy.mockReset = () => {
|
|
234
|
+
calls.length = 0;
|
|
235
|
+
mockImplementation = void 0;
|
|
236
|
+
mockReturnValue = void 0;
|
|
237
|
+
returnValueSet = false;
|
|
238
|
+
return spy;
|
|
239
|
+
};
|
|
240
|
+
spy.getCalls = () => calls;
|
|
241
|
+
spy.wasCalled = () => calls.length > 0;
|
|
242
|
+
spy.wasCalledWith = (...expectedArgs) => calls.some(
|
|
243
|
+
(call) => JSON.stringify(call.args) === JSON.stringify(expectedArgs)
|
|
244
|
+
);
|
|
245
|
+
return spy;
|
|
246
|
+
}
|
|
247
|
+
async function assertScopeDisposed(scope) {
|
|
248
|
+
await scope[Symbol.asyncDispose]();
|
|
249
|
+
if (!scope.signal.aborted) {
|
|
250
|
+
throw new Error("Scope signal was not aborted after disposal");
|
|
251
|
+
}
|
|
252
|
+
if (!scope.isDisposed) {
|
|
253
|
+
throw new Error("Scope did not report as disposed");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function createMockChannel() {
|
|
257
|
+
const sentValues = [];
|
|
258
|
+
let receiveValues = [];
|
|
259
|
+
let receiveIndex = 0;
|
|
260
|
+
let closed = false;
|
|
261
|
+
const mockChannel = {
|
|
262
|
+
channel: {
|
|
263
|
+
send: async (value) => {
|
|
264
|
+
if (closed) return false;
|
|
265
|
+
sentValues.push(value);
|
|
266
|
+
return true;
|
|
267
|
+
},
|
|
268
|
+
receive: async () => {
|
|
269
|
+
if (closed) return void 0;
|
|
270
|
+
if (receiveIndex < receiveValues.length) {
|
|
271
|
+
return receiveValues[receiveIndex++];
|
|
272
|
+
}
|
|
273
|
+
return void 0;
|
|
274
|
+
},
|
|
275
|
+
close: () => {
|
|
276
|
+
closed = true;
|
|
277
|
+
},
|
|
278
|
+
[Symbol.asyncIterator]: () => ({
|
|
279
|
+
async next() {
|
|
280
|
+
if (closed || receiveIndex >= receiveValues.length) {
|
|
281
|
+
return { done: true, value: void 0 };
|
|
282
|
+
}
|
|
283
|
+
return { done: false, value: receiveValues[receiveIndex++] };
|
|
284
|
+
},
|
|
285
|
+
async return() {
|
|
286
|
+
return { done: true, value: void 0 };
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
},
|
|
290
|
+
mockReceiveValues: receiveValues,
|
|
291
|
+
get sentValues() {
|
|
292
|
+
return sentValues;
|
|
293
|
+
},
|
|
294
|
+
get isClosed() {
|
|
295
|
+
return closed;
|
|
296
|
+
},
|
|
297
|
+
setReceiveValues: (values) => {
|
|
298
|
+
receiveValues = values;
|
|
299
|
+
receiveIndex = 0;
|
|
300
|
+
},
|
|
301
|
+
mockReceive: (value) => {
|
|
302
|
+
receiveValues.push(value);
|
|
303
|
+
},
|
|
304
|
+
getSentValues: () => [...sentValues],
|
|
305
|
+
clearSentValues: () => {
|
|
306
|
+
sentValues.length = 0;
|
|
307
|
+
},
|
|
308
|
+
close: () => {
|
|
309
|
+
closed = true;
|
|
310
|
+
},
|
|
311
|
+
reset: () => {
|
|
312
|
+
sentValues.length = 0;
|
|
313
|
+
receiveValues = [];
|
|
314
|
+
receiveIndex = 0;
|
|
315
|
+
closed = false;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
return mockChannel;
|
|
319
|
+
}
|
|
320
|
+
function createTimeTravelController() {
|
|
321
|
+
let currentTime = 0;
|
|
322
|
+
const timeline = [];
|
|
323
|
+
const history = [];
|
|
324
|
+
let nextId = 1;
|
|
325
|
+
return {
|
|
326
|
+
/** Current simulated time in milliseconds */
|
|
327
|
+
get now() {
|
|
328
|
+
return currentTime;
|
|
329
|
+
},
|
|
330
|
+
/** Get the event timeline (sorted by time) */
|
|
331
|
+
get timeline() {
|
|
332
|
+
return [...timeline].sort((a, b) => a.time - b.time).map((e) => ({ id: e.id, time: e.time, description: e.description }));
|
|
333
|
+
},
|
|
334
|
+
/** Get the execution history */
|
|
335
|
+
get history() {
|
|
336
|
+
return [...history];
|
|
337
|
+
},
|
|
338
|
+
/** Schedule a callback to run after delay */
|
|
339
|
+
setTimeout(callback, delay, description) {
|
|
340
|
+
const id = nextId++;
|
|
341
|
+
timeline.push({
|
|
342
|
+
id,
|
|
343
|
+
time: currentTime + delay,
|
|
344
|
+
callback,
|
|
345
|
+
description
|
|
346
|
+
});
|
|
347
|
+
return id;
|
|
348
|
+
},
|
|
349
|
+
/** Cancel a scheduled callback */
|
|
350
|
+
clearTimeout(id) {
|
|
351
|
+
const index = timeline.findIndex((e) => e.id === id);
|
|
352
|
+
if (index !== -1) {
|
|
353
|
+
timeline.splice(index, 1);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
},
|
|
358
|
+
/** Set an interval that repeats */
|
|
359
|
+
setInterval(callback, delay, description) {
|
|
360
|
+
const id = nextId++;
|
|
361
|
+
let nextTime = currentTime + delay;
|
|
362
|
+
const scheduleNext = () => {
|
|
363
|
+
timeline.push({
|
|
364
|
+
id,
|
|
365
|
+
time: nextTime,
|
|
366
|
+
callback: () => {
|
|
367
|
+
callback();
|
|
368
|
+
nextTime += delay;
|
|
369
|
+
scheduleNext();
|
|
370
|
+
},
|
|
371
|
+
description: `${description || "interval"} (repeat)`
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
scheduleNext();
|
|
375
|
+
return id;
|
|
376
|
+
},
|
|
377
|
+
/** Advance time by specified milliseconds */
|
|
378
|
+
advance(ms) {
|
|
379
|
+
const targetTime = currentTime + ms;
|
|
380
|
+
this.jumpTo(targetTime);
|
|
381
|
+
},
|
|
382
|
+
/** Jump to a specific absolute time */
|
|
383
|
+
jumpTo(targetTime) {
|
|
384
|
+
if (targetTime < currentTime) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Cannot jump backwards from ${currentTime} to ${targetTime}`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
timeline.sort((a, b) => a.time - b.time);
|
|
390
|
+
while (timeline.length > 0) {
|
|
391
|
+
const event = timeline[0];
|
|
392
|
+
if (!event || event.time > targetTime) break;
|
|
393
|
+
timeline.shift();
|
|
394
|
+
currentTime = event.time;
|
|
395
|
+
history.push({
|
|
396
|
+
time: currentTime,
|
|
397
|
+
action: event.description || `event-${event.id}`
|
|
398
|
+
});
|
|
399
|
+
event.callback();
|
|
400
|
+
}
|
|
401
|
+
currentTime = targetTime;
|
|
402
|
+
},
|
|
403
|
+
/** Get the next scheduled event time, or Infinity if none */
|
|
404
|
+
nextEventTime() {
|
|
405
|
+
if (timeline.length === 0) return Infinity;
|
|
406
|
+
timeline.sort((a, b) => a.time - b.time);
|
|
407
|
+
return timeline[0]?.time ?? Infinity;
|
|
408
|
+
},
|
|
409
|
+
/** Run all events until no more remain */
|
|
410
|
+
runAll() {
|
|
411
|
+
while (timeline.length > 0) {
|
|
412
|
+
timeline.sort((a, b) => a.time - b.time);
|
|
413
|
+
const event = timeline.shift();
|
|
414
|
+
if (event) {
|
|
415
|
+
currentTime = event.time;
|
|
416
|
+
history.push({
|
|
417
|
+
time: currentTime,
|
|
418
|
+
action: event.description || `event-${event.id}`
|
|
419
|
+
});
|
|
420
|
+
event.callback();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
/** Reset to initial state */
|
|
425
|
+
reset() {
|
|
426
|
+
timeline.length = 0;
|
|
427
|
+
history.length = 0;
|
|
428
|
+
currentTime = 0;
|
|
429
|
+
nextId = 1;
|
|
430
|
+
},
|
|
431
|
+
/** Print the timeline for debugging */
|
|
432
|
+
printTimeline() {
|
|
433
|
+
const sorted = [...timeline].sort((a, b) => a.time - b.time);
|
|
434
|
+
console.log("Timeline:");
|
|
435
|
+
sorted.forEach((e) => {
|
|
436
|
+
console.log(` ${e.time}ms: ${e.description || `event-${e.id}`}`);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export { assertScopeDisposed, createControlledTimer, createMockChannel, createMockScope, createSpy, createTestScope, createTimeController, createTimeTravelController, flushPromises };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@go-go-scope/testing",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Testing utilities for go-go-scope - mocks, spies, and time control",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "pkgroll --clean-dist",
|
|
16
|
+
"lint": "biome check --write src/",
|
|
17
|
+
"test": "echo 'No tests yet'",
|
|
18
|
+
"clean": "rm -rf dist"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"testing",
|
|
22
|
+
"mocks",
|
|
23
|
+
"spies",
|
|
24
|
+
"go-go-scope",
|
|
25
|
+
"test-utilities"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24.0.0"
|
|
29
|
+
},
|
|
30
|
+
"author": "thelinuxlich",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/thelinuxlich/go-go-scope.git",
|
|
35
|
+
"directory": "packages/testing"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"go-go-scope": "workspace:*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "^2.4.4",
|
|
49
|
+
"@types/node": "^24",
|
|
50
|
+
"pkgroll": "^2.26.3",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^4.0.18"
|
|
53
|
+
}
|
|
54
|
+
}
|