@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.
@@ -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
- /** Discards the recorded calls and keeps the recorder usable. */
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
- * Requires a value to be present.
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
@@ -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
- /** Discards the recorded calls and keeps the recorder usable. */
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
- * Requires a value to be present.
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
@@ -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
- try {
71
- if (typeof value !== "object" || value === null) return false;
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 (typeof recorder !== "object" || recorder === null) return false;
76
- return typeof Reflect.get(recorder, "handler") === "function" && Array.isArray(Reflect.get(recorder, "calls"));
88
+ if (!isObject(recorder)) return false;
89
+ return isFunction(Reflect.get(recorder, "handler")) && isArray(Reflect.get(recorder, "calls"));
77
90
  });
78
- } catch {
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 (!Number.isFinite(budget) || budget < 0) throw new Error(`${subject} budget must be finite and non-negative`);
97
- if (!Number.isFinite(interval) || interval < 0) throw new Error(`${subject} interval must be finite and non-negative`);
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 !== void 0 && (!Number.isInteger(attempts) || attempts < 1)) throw new Error("Retry attempts must be a positive integer");
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
- try {
364
- thunk();
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
- * Requires a value to be present.
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 === null || value === void 0) throw new Error(message);
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 || typeof current === "function" || typeof current === "symbol") throw new Error("JSON values must not contain undefined, functions, or symbols");
426
- if (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
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 (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
434
- if (Array.isArray(current)) for (const child of current) pending.push(child);
435
- else if (typeof current === "object" && current !== null) for (const child of Object.values(current)) pending.push(child);
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 instanceof Error ? cause.message : `threw a non-error ${typeof cause} value`;
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
  }