@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 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
@@ -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
+ }