@orkestrel/test 0.0.13 → 0.0.15
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 +11 -13
- package/dist/src/browser/index.d.ts +41 -6
- package/dist/src/browser/index.js +5 -1
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +79 -23
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +79 -44
- package/dist/src/core/index.d.ts +79 -44
- package/dist/src/core/index.js +79 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +188 -6
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +190 -7
- package/dist/src/server/index.d.ts +190 -7
- package/dist/src/server/index.js +188 -6
- package/dist/src/server/index.js.map +1 -1
- package/package.json +15 -13
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* @returns The exhaustion error, unthrown.
|
|
10
10
|
* @remarks Both of `retryUntil`'s elapsed checks raise this one message, so an edit to it lands in
|
|
11
11
|
* one place. The rendered value is appended only when the retry produced one.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { buildRetryExhausted } from '@orkestrel/test'
|
|
16
|
+
*
|
|
17
|
+
* const exhausted = buildRetryExhausted('registry answers', 30, 31, '"starting"', undefined)
|
|
18
|
+
*
|
|
19
|
+
* exhausted.message
|
|
20
|
+
* // 'Retry "registry answers" did not succeed within 30ms (waited 31ms) (last value: "starting")'
|
|
21
|
+
* ```
|
|
12
22
|
*/
|
|
13
23
|
export declare function buildRetryExhausted(description: string, budget: number, elapsed: number, last: string | undefined, cause: unknown): Error;
|
|
14
24
|
|
|
@@ -30,6 +40,16 @@ export declare function captureError(thunk: () => unknown): unknown;
|
|
|
30
40
|
* `<subject> interval must be finite and non-negative`.
|
|
31
41
|
* @remarks Every member of the wait family resolves its own defaults first and passes the resolved
|
|
32
42
|
* numbers here, so each keeps its own defaults while one contract states what a bound must be.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { checkBounds } from '@orkestrel/test'
|
|
47
|
+
*
|
|
48
|
+
* checkBounds('Wait', 1000, 10) // undefined
|
|
49
|
+
*
|
|
50
|
+
* // Throws Error: Retry budget must be finite and non-negative
|
|
51
|
+
* checkBounds('Retry', -1, 10)
|
|
52
|
+
* ```
|
|
33
53
|
*/
|
|
34
54
|
export declare function checkBounds(subject: string, budget: number, interval: number): void;
|
|
35
55
|
|
|
@@ -157,6 +177,18 @@ export declare function createTeardown(): TeardownInterface;
|
|
|
157
177
|
* @returns The decoded values in physical-line order.
|
|
158
178
|
* @throws An `Error` naming the malformed physical line, with the native `SyntaxError` as its
|
|
159
179
|
* `cause`.
|
|
180
|
+
* @remarks An empty line contributes no value, and a trailing carriage return is dropped before the
|
|
181
|
+
* line is parsed, so text written with either line ending decodes the same.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { decodeJSONLines } from '@orkestrel/test'
|
|
186
|
+
*
|
|
187
|
+
* decodeJSONLines('{"ready":true}\n7\n') // [{ ready: true }, 7]
|
|
188
|
+
*
|
|
189
|
+
* // Throws Error: Invalid JSON on line 3
|
|
190
|
+
* decodeJSONLines('{}\n\n{')
|
|
191
|
+
* ```
|
|
160
192
|
*/
|
|
161
193
|
export declare function decodeJSONLines(text: string): readonly unknown[];
|
|
162
194
|
|
|
@@ -169,6 +201,21 @@ export declare function decodeJSONLines(text: string): readonly unknown[];
|
|
|
169
201
|
* @remarks The dropped registration's cleanup controller is aborted as it leaves, so the scope
|
|
170
202
|
* subscription installed beside it leaves with it. Removing the installed listener from the signal
|
|
171
203
|
* stays with the caller, because only the scope-abort path has one to remove.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* import type { SignalRegistration } from '@orkestrel/test'
|
|
208
|
+
* import { dropRegistration } from '@orkestrel/test'
|
|
209
|
+
*
|
|
210
|
+
* const listener: EventListener = () => undefined
|
|
211
|
+
* const installed: EventListenerObject = { handleEvent: () => undefined }
|
|
212
|
+
* const cleanup = new AbortController()
|
|
213
|
+
* const registrations: SignalRegistration[] = [[listener, installed, true, cleanup]]
|
|
214
|
+
*
|
|
215
|
+
* dropRegistration(registrations, installed)?.[1] === installed // true
|
|
216
|
+
* cleanup.signal.aborted // true
|
|
217
|
+
* dropRegistration(registrations, installed) // undefined
|
|
218
|
+
* ```
|
|
172
219
|
*/
|
|
173
220
|
export declare function dropRegistration(registrations: SignalRegistration[], installed: EventListener | EventListenerObject): SignalRegistration | undefined;
|
|
174
221
|
|
|
@@ -176,6 +223,8 @@ export declare function dropRegistration(registrations: SignalRegistration[], in
|
|
|
176
223
|
* Subscribes handlers to a typed event source.
|
|
177
224
|
*
|
|
178
225
|
* @typeParam TMap - The event names and argument tuples the source delivers.
|
|
226
|
+
* @remarks The subscribe half is all this asks for, so a source that also removes handlers, emits,
|
|
227
|
+
* or counts subscriptions satisfies it unchanged.
|
|
179
228
|
*/
|
|
180
229
|
export declare interface EventSourceInterface<TMap extends Record<string, readonly unknown[]>> {
|
|
181
230
|
/**
|
|
@@ -251,18 +300,6 @@ export declare function executeScenario<TState extends string, TEvent extends st
|
|
|
251
300
|
*/
|
|
252
301
|
export declare function executeScenarios<TState extends string, TEvent extends string, TContext>(scenarios: ReadonlyArray<StateScenario<TState, TEvent, TContext>>, build: (scenario: StateScenario<TState, TEvent, TContext>) => TContext | Promise<TContext>): Promise<void>;
|
|
253
302
|
|
|
254
|
-
/**
|
|
255
|
-
* Represents one operation that raised a failure instead of producing a value.
|
|
256
|
-
*
|
|
257
|
-
* @typeParam E - The failure type.
|
|
258
|
-
*/
|
|
259
|
-
export declare interface Failure<E> {
|
|
260
|
-
/** Holds the discriminant that names the failed arm. */
|
|
261
|
-
readonly success: false;
|
|
262
|
-
/** Holds the failure the operation raised. */
|
|
263
|
-
readonly error: E;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
303
|
/**
|
|
267
304
|
* Normalizes headers into a frozen plain record.
|
|
268
305
|
*
|
|
@@ -308,6 +345,18 @@ export declare function invokeUnchecked<T>(target: unknown, method: unknown, arg
|
|
|
308
345
|
* wiring each recorder to exactly the event where it stores that recorder. A direct caller must
|
|
309
346
|
* establish the same pairing before it relies on the narrowing. This guard takes the listed events
|
|
310
347
|
* through a reference parameter rather than using the canonical single-value guard form.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* import { createRecorder, isRecorderMapComplete } from '@orkestrel/test'
|
|
352
|
+
*
|
|
353
|
+
* type ReadyEvents = { readonly ready: readonly [name: string, step: number] }
|
|
354
|
+
*
|
|
355
|
+
* const value: unknown = { ready: createRecorder<readonly [name: string, step: number]>() }
|
|
356
|
+
*
|
|
357
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>(value, ['ready']) // true
|
|
358
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>({ ready: 1 }, ['ready']) // false
|
|
359
|
+
* ```
|
|
311
360
|
*/
|
|
312
361
|
export declare function isRecorderMapComplete<TMap extends Record<string, readonly unknown[]>, TName extends keyof TMap>(value: unknown, events: readonly TName[]): value is RecorderMap<TMap, TName>;
|
|
313
362
|
|
|
@@ -344,11 +393,6 @@ export declare type JSONSafe<T> = unknown extends T ? T : T extends string | num
|
|
|
344
393
|
readonly [K in keyof T]: K extends symbol ? never : JSONSafe<T[K]>;
|
|
345
394
|
} : never;
|
|
346
395
|
|
|
347
|
-
/** Covers any value JSON can represent, so a round trip through JSON preserves the type. */
|
|
348
|
-
export declare type JSONValue = string | number | boolean | null | readonly JSONValue[] | {
|
|
349
|
-
readonly [key: string]: JSONValue;
|
|
350
|
-
};
|
|
351
|
-
|
|
352
396
|
/**
|
|
353
397
|
* Reads a property from an unknown object or function.
|
|
354
398
|
*
|
|
@@ -374,7 +418,11 @@ export declare interface RecorderInterface<TArgs extends readonly unknown[]> {
|
|
|
374
418
|
readonly count: number;
|
|
375
419
|
/** Holds the callback to hand to the code under test. */
|
|
376
420
|
readonly handler: (...args: TArgs) => void;
|
|
377
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Discards the recorded calls and keeps the recorder usable.
|
|
423
|
+
*
|
|
424
|
+
* @remarks The list is truncated in place, so a `calls` reference taken earlier empties too.
|
|
425
|
+
*/
|
|
378
426
|
clear(): void;
|
|
379
427
|
}
|
|
380
428
|
|
|
@@ -389,12 +437,13 @@ export declare type RecorderMap<TMap extends Record<string, readonly unknown[]>,
|
|
|
389
437
|
};
|
|
390
438
|
|
|
391
439
|
/**
|
|
392
|
-
*
|
|
440
|
+
* Narrows a value away from `null` and `undefined`, throwing when it is absent.
|
|
393
441
|
*
|
|
394
442
|
* @typeParam T - The required value type.
|
|
395
443
|
* @param value - The value to check.
|
|
396
444
|
* @param message - The error message used when the value is absent. Default: `'Value is required'`.
|
|
397
445
|
* @returns The present value.
|
|
446
|
+
* @throws An `Error` carrying `message` when the value is `null` or `undefined`.
|
|
398
447
|
*/
|
|
399
448
|
export declare function requireValue<T>(value: T | null | undefined, message?: string): T;
|
|
400
449
|
|
|
@@ -417,28 +466,22 @@ export declare interface ResourceFactoryInterface {
|
|
|
417
466
|
* Creates a numbered resource.
|
|
418
467
|
*
|
|
419
468
|
* @returns The next monotonically increasing id.
|
|
469
|
+
* @remarks The id is the creation record's length plus one, so it counts allocations rather than
|
|
470
|
+
* live resources: a destroyed id is never reissued, and clearing `created` restarts the numbering
|
|
471
|
+
* at `1`.
|
|
420
472
|
*/
|
|
421
473
|
create(): number;
|
|
422
474
|
/**
|
|
423
475
|
* Destroys a numbered resource.
|
|
424
476
|
*
|
|
425
477
|
* @param id - The resource id to destroy.
|
|
478
|
+
* @remarks It records the id and nothing else: it frees nothing, refuses nothing, and accepts an
|
|
479
|
+
* id that was never created, so a suite asserts on the record rather than on a refusal.
|
|
426
480
|
*/
|
|
427
481
|
destroy(id: number): void;
|
|
428
482
|
}
|
|
429
483
|
|
|
430
|
-
/**
|
|
431
|
-
* Represents the outcome of one operation: the value it produced, or the failure it raised.
|
|
432
|
-
*
|
|
433
|
-
* @typeParam T - The produced value type.
|
|
434
|
-
* @typeParam E - The failure type. Defaults to `Error`.
|
|
435
|
-
* @remarks `success` is the discriminant, so a caller narrows on it before reading `value` or
|
|
436
|
-
* `error`. This package declares no runtime dependency, so this is the one outcome contract its own
|
|
437
|
-
* members read rather than an anonymous union written at each call site.
|
|
438
|
-
*/
|
|
439
|
-
export declare type Result<T, E = Error> = Success<T> | Failure<E>;
|
|
440
|
-
|
|
441
|
-
/** Configures a bounded retry. */
|
|
484
|
+
/** Configures a bounded retry, adding an optional producer-call limit to a bounded wait's bounds. */
|
|
442
485
|
export declare interface RetryOptions extends WaitOptions {
|
|
443
486
|
/** Caps the number of producer calls. When omitted, only the time budget bounds the retry. */
|
|
444
487
|
readonly attempts?: number;
|
|
@@ -579,6 +622,7 @@ export declare interface StateScenario<TState extends string, TEvent extends str
|
|
|
579
622
|
*
|
|
580
623
|
* @param context - The fixture this row drives.
|
|
581
624
|
* @param state - The transition's `to` state.
|
|
625
|
+
* @remarks Whatever it throws is renamed with the row's name and rethrown.
|
|
582
626
|
*/
|
|
583
627
|
assert(context: TContext, state: TState): Promise<void> | void;
|
|
584
628
|
}
|
|
@@ -603,18 +647,6 @@ export declare interface StateTransition<TState extends string, TEvent extends s
|
|
|
603
647
|
readonly to: TState;
|
|
604
648
|
}
|
|
605
649
|
|
|
606
|
-
/**
|
|
607
|
-
* Represents one operation that produced a value.
|
|
608
|
-
*
|
|
609
|
-
* @typeParam T - The produced value type.
|
|
610
|
-
*/
|
|
611
|
-
export declare interface Success<T> {
|
|
612
|
-
/** Holds the discriminant that names the produced arm. */
|
|
613
|
-
readonly success: true;
|
|
614
|
-
/** Holds the value the operation produced. */
|
|
615
|
-
readonly value: T;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
650
|
/** Represents the work one teardown entry performs when the list is destroyed. */
|
|
619
651
|
export declare type TeardownHandler = () => void | Promise<void>;
|
|
620
652
|
|
|
@@ -626,6 +658,8 @@ export declare interface TeardownInterface {
|
|
|
626
658
|
* Registers a handler to run when the list is destroyed.
|
|
627
659
|
*
|
|
628
660
|
* @param handler - The work to perform.
|
|
661
|
+
* @remarks Registration order is what `destroy` reverses, so the newest registration is undone
|
|
662
|
+
* first.
|
|
629
663
|
*/
|
|
630
664
|
add(handler: TeardownHandler): void;
|
|
631
665
|
/**
|
|
@@ -689,7 +723,8 @@ export declare function waitForDelay(ms?: number): Promise<void>;
|
|
|
689
723
|
export declare function waitForEvent<TArgs extends readonly unknown[]>(subscribe: EventSubscriber<TArgs>, description: string, options?: WaitOptions): Promise<TArgs>;
|
|
690
724
|
|
|
691
725
|
/**
|
|
692
|
-
* Configures a bounded asynchronous wait
|
|
726
|
+
* Configures a bounded asynchronous wait with an elapsed-time limit, a delay between readings, and
|
|
727
|
+
* an abort signal.
|
|
693
728
|
*
|
|
694
729
|
* @remarks
|
|
695
730
|
* A default belongs to the function that reads these bounds rather than to the shape, because the
|
package/dist/src/core/index.d.ts
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* @returns The exhaustion error, unthrown.
|
|
10
10
|
* @remarks Both of `retryUntil`'s elapsed checks raise this one message, so an edit to it lands in
|
|
11
11
|
* one place. The rendered value is appended only when the retry produced one.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { buildRetryExhausted } from '@orkestrel/test'
|
|
16
|
+
*
|
|
17
|
+
* const exhausted = buildRetryExhausted('registry answers', 30, 31, '"starting"', undefined)
|
|
18
|
+
*
|
|
19
|
+
* exhausted.message
|
|
20
|
+
* // 'Retry "registry answers" did not succeed within 30ms (waited 31ms) (last value: "starting")'
|
|
21
|
+
* ```
|
|
12
22
|
*/
|
|
13
23
|
export declare function buildRetryExhausted(description: string, budget: number, elapsed: number, last: string | undefined, cause: unknown): Error;
|
|
14
24
|
|
|
@@ -30,6 +40,16 @@ export declare function captureError(thunk: () => unknown): unknown;
|
|
|
30
40
|
* `<subject> interval must be finite and non-negative`.
|
|
31
41
|
* @remarks Every member of the wait family resolves its own defaults first and passes the resolved
|
|
32
42
|
* numbers here, so each keeps its own defaults while one contract states what a bound must be.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { checkBounds } from '@orkestrel/test'
|
|
47
|
+
*
|
|
48
|
+
* checkBounds('Wait', 1000, 10) // undefined
|
|
49
|
+
*
|
|
50
|
+
* // Throws Error: Retry budget must be finite and non-negative
|
|
51
|
+
* checkBounds('Retry', -1, 10)
|
|
52
|
+
* ```
|
|
33
53
|
*/
|
|
34
54
|
export declare function checkBounds(subject: string, budget: number, interval: number): void;
|
|
35
55
|
|
|
@@ -157,6 +177,18 @@ export declare function createTeardown(): TeardownInterface;
|
|
|
157
177
|
* @returns The decoded values in physical-line order.
|
|
158
178
|
* @throws An `Error` naming the malformed physical line, with the native `SyntaxError` as its
|
|
159
179
|
* `cause`.
|
|
180
|
+
* @remarks An empty line contributes no value, and a trailing carriage return is dropped before the
|
|
181
|
+
* line is parsed, so text written with either line ending decodes the same.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { decodeJSONLines } from '@orkestrel/test'
|
|
186
|
+
*
|
|
187
|
+
* decodeJSONLines('{"ready":true}\n7\n') // [{ ready: true }, 7]
|
|
188
|
+
*
|
|
189
|
+
* // Throws Error: Invalid JSON on line 3
|
|
190
|
+
* decodeJSONLines('{}\n\n{')
|
|
191
|
+
* ```
|
|
160
192
|
*/
|
|
161
193
|
export declare function decodeJSONLines(text: string): readonly unknown[];
|
|
162
194
|
|
|
@@ -169,6 +201,21 @@ export declare function decodeJSONLines(text: string): readonly unknown[];
|
|
|
169
201
|
* @remarks The dropped registration's cleanup controller is aborted as it leaves, so the scope
|
|
170
202
|
* subscription installed beside it leaves with it. Removing the installed listener from the signal
|
|
171
203
|
* stays with the caller, because only the scope-abort path has one to remove.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* import type { SignalRegistration } from '@orkestrel/test'
|
|
208
|
+
* import { dropRegistration } from '@orkestrel/test'
|
|
209
|
+
*
|
|
210
|
+
* const listener: EventListener = () => undefined
|
|
211
|
+
* const installed: EventListenerObject = { handleEvent: () => undefined }
|
|
212
|
+
* const cleanup = new AbortController()
|
|
213
|
+
* const registrations: SignalRegistration[] = [[listener, installed, true, cleanup]]
|
|
214
|
+
*
|
|
215
|
+
* dropRegistration(registrations, installed)?.[1] === installed // true
|
|
216
|
+
* cleanup.signal.aborted // true
|
|
217
|
+
* dropRegistration(registrations, installed) // undefined
|
|
218
|
+
* ```
|
|
172
219
|
*/
|
|
173
220
|
export declare function dropRegistration(registrations: SignalRegistration[], installed: EventListener | EventListenerObject): SignalRegistration | undefined;
|
|
174
221
|
|
|
@@ -176,6 +223,8 @@ export declare function dropRegistration(registrations: SignalRegistration[], in
|
|
|
176
223
|
* Subscribes handlers to a typed event source.
|
|
177
224
|
*
|
|
178
225
|
* @typeParam TMap - The event names and argument tuples the source delivers.
|
|
226
|
+
* @remarks The subscribe half is all this asks for, so a source that also removes handlers, emits,
|
|
227
|
+
* or counts subscriptions satisfies it unchanged.
|
|
179
228
|
*/
|
|
180
229
|
export declare interface EventSourceInterface<TMap extends Record<string, readonly unknown[]>> {
|
|
181
230
|
/**
|
|
@@ -251,18 +300,6 @@ export declare function executeScenario<TState extends string, TEvent extends st
|
|
|
251
300
|
*/
|
|
252
301
|
export declare function executeScenarios<TState extends string, TEvent extends string, TContext>(scenarios: ReadonlyArray<StateScenario<TState, TEvent, TContext>>, build: (scenario: StateScenario<TState, TEvent, TContext>) => TContext | Promise<TContext>): Promise<void>;
|
|
253
302
|
|
|
254
|
-
/**
|
|
255
|
-
* Represents one operation that raised a failure instead of producing a value.
|
|
256
|
-
*
|
|
257
|
-
* @typeParam E - The failure type.
|
|
258
|
-
*/
|
|
259
|
-
export declare interface Failure<E> {
|
|
260
|
-
/** Holds the discriminant that names the failed arm. */
|
|
261
|
-
readonly success: false;
|
|
262
|
-
/** Holds the failure the operation raised. */
|
|
263
|
-
readonly error: E;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
303
|
/**
|
|
267
304
|
* Normalizes headers into a frozen plain record.
|
|
268
305
|
*
|
|
@@ -308,6 +345,18 @@ export declare function invokeUnchecked<T>(target: unknown, method: unknown, arg
|
|
|
308
345
|
* wiring each recorder to exactly the event where it stores that recorder. A direct caller must
|
|
309
346
|
* establish the same pairing before it relies on the narrowing. This guard takes the listed events
|
|
310
347
|
* through a reference parameter rather than using the canonical single-value guard form.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* import { createRecorder, isRecorderMapComplete } from '@orkestrel/test'
|
|
352
|
+
*
|
|
353
|
+
* type ReadyEvents = { readonly ready: readonly [name: string, step: number] }
|
|
354
|
+
*
|
|
355
|
+
* const value: unknown = { ready: createRecorder<readonly [name: string, step: number]>() }
|
|
356
|
+
*
|
|
357
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>(value, ['ready']) // true
|
|
358
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>({ ready: 1 }, ['ready']) // false
|
|
359
|
+
* ```
|
|
311
360
|
*/
|
|
312
361
|
export declare function isRecorderMapComplete<TMap extends Record<string, readonly unknown[]>, TName extends keyof TMap>(value: unknown, events: readonly TName[]): value is RecorderMap<TMap, TName>;
|
|
313
362
|
|
|
@@ -344,11 +393,6 @@ export declare type JSONSafe<T> = unknown extends T ? T : T extends string | num
|
|
|
344
393
|
readonly [K in keyof T]: K extends symbol ? never : JSONSafe<T[K]>;
|
|
345
394
|
} : never;
|
|
346
395
|
|
|
347
|
-
/** Covers any value JSON can represent, so a round trip through JSON preserves the type. */
|
|
348
|
-
export declare type JSONValue = string | number | boolean | null | readonly JSONValue[] | {
|
|
349
|
-
readonly [key: string]: JSONValue;
|
|
350
|
-
};
|
|
351
|
-
|
|
352
396
|
/**
|
|
353
397
|
* Reads a property from an unknown object or function.
|
|
354
398
|
*
|
|
@@ -374,7 +418,11 @@ export declare interface RecorderInterface<TArgs extends readonly unknown[]> {
|
|
|
374
418
|
readonly count: number;
|
|
375
419
|
/** Holds the callback to hand to the code under test. */
|
|
376
420
|
readonly handler: (...args: TArgs) => void;
|
|
377
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Discards the recorded calls and keeps the recorder usable.
|
|
423
|
+
*
|
|
424
|
+
* @remarks The list is truncated in place, so a `calls` reference taken earlier empties too.
|
|
425
|
+
*/
|
|
378
426
|
clear(): void;
|
|
379
427
|
}
|
|
380
428
|
|
|
@@ -389,12 +437,13 @@ export declare type RecorderMap<TMap extends Record<string, readonly unknown[]>,
|
|
|
389
437
|
};
|
|
390
438
|
|
|
391
439
|
/**
|
|
392
|
-
*
|
|
440
|
+
* Narrows a value away from `null` and `undefined`, throwing when it is absent.
|
|
393
441
|
*
|
|
394
442
|
* @typeParam T - The required value type.
|
|
395
443
|
* @param value - The value to check.
|
|
396
444
|
* @param message - The error message used when the value is absent. Default: `'Value is required'`.
|
|
397
445
|
* @returns The present value.
|
|
446
|
+
* @throws An `Error` carrying `message` when the value is `null` or `undefined`.
|
|
398
447
|
*/
|
|
399
448
|
export declare function requireValue<T>(value: T | null | undefined, message?: string): T;
|
|
400
449
|
|
|
@@ -417,28 +466,22 @@ export declare interface ResourceFactoryInterface {
|
|
|
417
466
|
* Creates a numbered resource.
|
|
418
467
|
*
|
|
419
468
|
* @returns The next monotonically increasing id.
|
|
469
|
+
* @remarks The id is the creation record's length plus one, so it counts allocations rather than
|
|
470
|
+
* live resources: a destroyed id is never reissued, and clearing `created` restarts the numbering
|
|
471
|
+
* at `1`.
|
|
420
472
|
*/
|
|
421
473
|
create(): number;
|
|
422
474
|
/**
|
|
423
475
|
* Destroys a numbered resource.
|
|
424
476
|
*
|
|
425
477
|
* @param id - The resource id to destroy.
|
|
478
|
+
* @remarks It records the id and nothing else: it frees nothing, refuses nothing, and accepts an
|
|
479
|
+
* id that was never created, so a suite asserts on the record rather than on a refusal.
|
|
426
480
|
*/
|
|
427
481
|
destroy(id: number): void;
|
|
428
482
|
}
|
|
429
483
|
|
|
430
|
-
/**
|
|
431
|
-
* Represents the outcome of one operation: the value it produced, or the failure it raised.
|
|
432
|
-
*
|
|
433
|
-
* @typeParam T - The produced value type.
|
|
434
|
-
* @typeParam E - The failure type. Defaults to `Error`.
|
|
435
|
-
* @remarks `success` is the discriminant, so a caller narrows on it before reading `value` or
|
|
436
|
-
* `error`. This package declares no runtime dependency, so this is the one outcome contract its own
|
|
437
|
-
* members read rather than an anonymous union written at each call site.
|
|
438
|
-
*/
|
|
439
|
-
export declare type Result<T, E = Error> = Success<T> | Failure<E>;
|
|
440
|
-
|
|
441
|
-
/** Configures a bounded retry. */
|
|
484
|
+
/** Configures a bounded retry, adding an optional producer-call limit to a bounded wait's bounds. */
|
|
442
485
|
export declare interface RetryOptions extends WaitOptions {
|
|
443
486
|
/** Caps the number of producer calls. When omitted, only the time budget bounds the retry. */
|
|
444
487
|
readonly attempts?: number;
|
|
@@ -579,6 +622,7 @@ export declare interface StateScenario<TState extends string, TEvent extends str
|
|
|
579
622
|
*
|
|
580
623
|
* @param context - The fixture this row drives.
|
|
581
624
|
* @param state - The transition's `to` state.
|
|
625
|
+
* @remarks Whatever it throws is renamed with the row's name and rethrown.
|
|
582
626
|
*/
|
|
583
627
|
assert(context: TContext, state: TState): Promise<void> | void;
|
|
584
628
|
}
|
|
@@ -603,18 +647,6 @@ export declare interface StateTransition<TState extends string, TEvent extends s
|
|
|
603
647
|
readonly to: TState;
|
|
604
648
|
}
|
|
605
649
|
|
|
606
|
-
/**
|
|
607
|
-
* Represents one operation that produced a value.
|
|
608
|
-
*
|
|
609
|
-
* @typeParam T - The produced value type.
|
|
610
|
-
*/
|
|
611
|
-
export declare interface Success<T> {
|
|
612
|
-
/** Holds the discriminant that names the produced arm. */
|
|
613
|
-
readonly success: true;
|
|
614
|
-
/** Holds the value the operation produced. */
|
|
615
|
-
readonly value: T;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
650
|
/** Represents the work one teardown entry performs when the list is destroyed. */
|
|
619
651
|
export declare type TeardownHandler = () => void | Promise<void>;
|
|
620
652
|
|
|
@@ -626,6 +658,8 @@ export declare interface TeardownInterface {
|
|
|
626
658
|
* Registers a handler to run when the list is destroyed.
|
|
627
659
|
*
|
|
628
660
|
* @param handler - The work to perform.
|
|
661
|
+
* @remarks Registration order is what `destroy` reverses, so the newest registration is undone
|
|
662
|
+
* first.
|
|
629
663
|
*/
|
|
630
664
|
add(handler: TeardownHandler): void;
|
|
631
665
|
/**
|
|
@@ -689,7 +723,8 @@ export declare function waitForDelay(ms?: number): Promise<void>;
|
|
|
689
723
|
export declare function waitForEvent<TArgs extends readonly unknown[]>(subscribe: EventSubscriber<TArgs>, description: string, options?: WaitOptions): Promise<TArgs>;
|
|
690
724
|
|
|
691
725
|
/**
|
|
692
|
-
* Configures a bounded asynchronous wait
|
|
726
|
+
* Configures a bounded asynchronous wait with an elapsed-time limit, a delay between readings, and
|
|
727
|
+
* an abort signal.
|
|
693
728
|
*
|
|
694
729
|
* @remarks
|
|
695
730
|
* A default belongs to the function that reads these bounds rather than to the shape, because the
|
package/dist/src/core/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { attempt, holds, isArray, isDefined, isError, isFiniteNumber, isFunction, isInteger, isNumber, isObject, isSymbol } from "@orkestrel/contract";
|
|
1
2
|
//#region src/core/constants.ts
|
|
2
3
|
/**
|
|
3
4
|
* Names the attributes a statechart harness publishes, keyed by the fact each one carries.
|
|
@@ -65,19 +66,29 @@ var STATECHART_STATUSES = Object.freeze([
|
|
|
65
66
|
* wiring each recorder to exactly the event where it stores that recorder. A direct caller must
|
|
66
67
|
* establish the same pairing before it relies on the narrowing. This guard takes the listed events
|
|
67
68
|
* through a reference parameter rather than using the canonical single-value guard form.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* import { createRecorder, isRecorderMapComplete } from '@orkestrel/test'
|
|
73
|
+
*
|
|
74
|
+
* type ReadyEvents = { readonly ready: readonly [name: string, step: number] }
|
|
75
|
+
*
|
|
76
|
+
* const value: unknown = { ready: createRecorder<readonly [name: string, step: number]>() }
|
|
77
|
+
*
|
|
78
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>(value, ['ready']) // true
|
|
79
|
+
* isRecorderMapComplete<ReadyEvents, 'ready'>({ ready: 1 }, ['ready']) // false
|
|
80
|
+
* ```
|
|
68
81
|
*/
|
|
69
82
|
function isRecorderMapComplete(value, events) {
|
|
70
|
-
|
|
71
|
-
if (
|
|
83
|
+
return holds(() => {
|
|
84
|
+
if (!isObject(value)) return false;
|
|
72
85
|
return events.every((event) => {
|
|
73
86
|
if (!Object.hasOwn(value, event)) return false;
|
|
74
87
|
const recorder = Reflect.get(value, event);
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
88
|
+
if (!isObject(recorder)) return false;
|
|
89
|
+
return isFunction(Reflect.get(recorder, "handler")) && isArray(Reflect.get(recorder, "calls"));
|
|
77
90
|
});
|
|
78
|
-
}
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
91
|
+
});
|
|
81
92
|
}
|
|
82
93
|
//#endregion
|
|
83
94
|
//#region src/core/helpers.ts
|
|
@@ -91,10 +102,20 @@ function isRecorderMapComplete(value, events) {
|
|
|
91
102
|
* `<subject> interval must be finite and non-negative`.
|
|
92
103
|
* @remarks Every member of the wait family resolves its own defaults first and passes the resolved
|
|
93
104
|
* numbers here, so each keeps its own defaults while one contract states what a bound must be.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* import { checkBounds } from '@orkestrel/test'
|
|
109
|
+
*
|
|
110
|
+
* checkBounds('Wait', 1000, 10) // undefined
|
|
111
|
+
*
|
|
112
|
+
* // Throws Error: Retry budget must be finite and non-negative
|
|
113
|
+
* checkBounds('Retry', -1, 10)
|
|
114
|
+
* ```
|
|
94
115
|
*/
|
|
95
116
|
function checkBounds(subject, budget, interval) {
|
|
96
|
-
if (!
|
|
97
|
-
if (!
|
|
117
|
+
if (!isFiniteNumber(budget) || budget < 0) throw new Error(`${subject} budget must be finite and non-negative`);
|
|
118
|
+
if (!isFiniteNumber(interval) || interval < 0) throw new Error(`${subject} interval must be finite and non-negative`);
|
|
98
119
|
}
|
|
99
120
|
/**
|
|
100
121
|
* Builds the error {@link retryUntil} raises when its elapsed-time budget runs out.
|
|
@@ -107,6 +128,16 @@ function checkBounds(subject, budget, interval) {
|
|
|
107
128
|
* @returns The exhaustion error, unthrown.
|
|
108
129
|
* @remarks Both of `retryUntil`'s elapsed checks raise this one message, so an edit to it lands in
|
|
109
130
|
* one place. The rendered value is appended only when the retry produced one.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { buildRetryExhausted } from '@orkestrel/test'
|
|
135
|
+
*
|
|
136
|
+
* const exhausted = buildRetryExhausted('registry answers', 30, 31, '"starting"', undefined)
|
|
137
|
+
*
|
|
138
|
+
* exhausted.message
|
|
139
|
+
* // 'Retry "registry answers" did not succeed within 30ms (waited 31ms) (last value: "starting")'
|
|
140
|
+
* ```
|
|
110
141
|
*/
|
|
111
142
|
function buildRetryExhausted(description, budget, elapsed, last, cause) {
|
|
112
143
|
return new Error(`Retry "${description}" did not succeed within ${budget}ms (waited ${elapsed}ms)${last === void 0 ? "" : ` (last value: ${last})`}`, { cause });
|
|
@@ -120,6 +151,21 @@ function buildRetryExhausted(description, budget, elapsed, last, cause) {
|
|
|
120
151
|
* @remarks The dropped registration's cleanup controller is aborted as it leaves, so the scope
|
|
121
152
|
* subscription installed beside it leaves with it. Removing the installed listener from the signal
|
|
122
153
|
* stays with the caller, because only the scope-abort path has one to remove.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* import type { SignalRegistration } from '@orkestrel/test'
|
|
158
|
+
* import { dropRegistration } from '@orkestrel/test'
|
|
159
|
+
*
|
|
160
|
+
* const listener: EventListener = () => undefined
|
|
161
|
+
* const installed: EventListenerObject = { handleEvent: () => undefined }
|
|
162
|
+
* const cleanup = new AbortController()
|
|
163
|
+
* const registrations: SignalRegistration[] = [[listener, installed, true, cleanup]]
|
|
164
|
+
*
|
|
165
|
+
* dropRegistration(registrations, installed)?.[1] === installed // true
|
|
166
|
+
* cleanup.signal.aborted // true
|
|
167
|
+
* dropRegistration(registrations, installed) // undefined
|
|
168
|
+
* ```
|
|
123
169
|
*/
|
|
124
170
|
function dropRegistration(registrations, installed) {
|
|
125
171
|
const index = registrations.findIndex((registration) => registration[1] === installed);
|
|
@@ -197,7 +243,7 @@ async function retryUntil(description, produce, satisfied, options) {
|
|
|
197
243
|
const interval = options?.interval ?? 10;
|
|
198
244
|
const attempts = options?.attempts;
|
|
199
245
|
checkBounds("Retry", budget, interval);
|
|
200
|
-
if (attempts
|
|
246
|
+
if (isDefined(attempts) && (!isInteger(attempts) || attempts < 1)) throw new Error("Retry attempts must be a positive integer");
|
|
201
247
|
const start = performance.now();
|
|
202
248
|
let count = 0;
|
|
203
249
|
let cause;
|
|
@@ -338,6 +384,18 @@ async function waitForEvent(subscribe, description, options) {
|
|
|
338
384
|
* @returns The decoded values in physical-line order.
|
|
339
385
|
* @throws An `Error` naming the malformed physical line, with the native `SyntaxError` as its
|
|
340
386
|
* `cause`.
|
|
387
|
+
* @remarks An empty line contributes no value, and a trailing carriage return is dropped before the
|
|
388
|
+
* line is parsed, so text written with either line ending decodes the same.
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* ```ts
|
|
392
|
+
* import { decodeJSONLines } from '@orkestrel/test'
|
|
393
|
+
*
|
|
394
|
+
* decodeJSONLines('{"ready":true}\n7\n') // [{ ready: true }, 7]
|
|
395
|
+
*
|
|
396
|
+
* // Throws Error: Invalid JSON on line 3
|
|
397
|
+
* decodeJSONLines('{}\n\n{')
|
|
398
|
+
* ```
|
|
341
399
|
*/
|
|
342
400
|
function decodeJSONLines(text) {
|
|
343
401
|
const values = [];
|
|
@@ -360,22 +418,20 @@ function decodeJSONLines(text) {
|
|
|
360
418
|
* @returns The thrown value, or `undefined` when the thunk completes.
|
|
361
419
|
*/
|
|
362
420
|
function captureError(thunk) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
} catch (error) {
|
|
366
|
-
return error;
|
|
367
|
-
}
|
|
421
|
+
const outcome = attempt(thunk);
|
|
422
|
+
return outcome.success ? void 0 : outcome.error;
|
|
368
423
|
}
|
|
369
424
|
/**
|
|
370
|
-
*
|
|
425
|
+
* Narrows a value away from `null` and `undefined`, throwing when it is absent.
|
|
371
426
|
*
|
|
372
427
|
* @typeParam T - The required value type.
|
|
373
428
|
* @param value - The value to check.
|
|
374
429
|
* @param message - The error message used when the value is absent. Default: `'Value is required'`.
|
|
375
430
|
* @returns The present value.
|
|
431
|
+
* @throws An `Error` carrying `message` when the value is `null` or `undefined`.
|
|
376
432
|
*/
|
|
377
433
|
function requireValue(value, message = "Value is required") {
|
|
378
|
-
if (value
|
|
434
|
+
if (!isDefined(value)) throw new Error(message);
|
|
379
435
|
return value;
|
|
380
436
|
}
|
|
381
437
|
/**
|
|
@@ -422,17 +478,17 @@ async function collectStream(stream) {
|
|
|
422
478
|
*/
|
|
423
479
|
function roundTripJSON(value) {
|
|
424
480
|
const serialized = JSON.stringify(value, (_key, current) => {
|
|
425
|
-
if (current === void 0 ||
|
|
426
|
-
if (
|
|
481
|
+
if (current === void 0 || isFunction(current) || isSymbol(current)) throw new Error("JSON values must not contain undefined, functions, or symbols");
|
|
482
|
+
if (isNumber(current) && !isFiniteNumber(current)) throw new Error("JSON values must contain finite numbers");
|
|
427
483
|
return current;
|
|
428
484
|
});
|
|
429
485
|
const parsed = JSON.parse(serialized);
|
|
430
486
|
const pending = [parsed];
|
|
431
487
|
while (pending.length > 0) {
|
|
432
488
|
const current = pending.pop();
|
|
433
|
-
if (
|
|
434
|
-
if (
|
|
435
|
-
else if (
|
|
489
|
+
if (isNumber(current) && !isFiniteNumber(current)) throw new Error("JSON values must contain finite numbers");
|
|
490
|
+
if (isArray(current)) for (const child of current) pending.push(child);
|
|
491
|
+
else if (isObject(current)) for (const child of Object.values(current)) pending.push(child);
|
|
436
492
|
}
|
|
437
493
|
return parsed;
|
|
438
494
|
}
|
|
@@ -478,7 +534,7 @@ async function executeScenario(scenario, context) {
|
|
|
478
534
|
await scenario.act(context, transition.event);
|
|
479
535
|
await scenario.assert(context, transition.to);
|
|
480
536
|
} catch (cause) {
|
|
481
|
-
const message = cause
|
|
537
|
+
const message = isError(cause) ? cause.message : `threw a non-error ${typeof cause} value`;
|
|
482
538
|
throw new Error(`${transition.name}: ${message}`, { cause });
|
|
483
539
|
}
|
|
484
540
|
}
|