@ontrails/testing 1.0.0-beta.2 → 1.0.0-beta.21

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +396 -5
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +45 -21
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -56
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -68
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Contract-driven testing
2
2
  export { testAll } from './all.js';
3
3
  export { testExamples } from './examples.js';
4
- export { testHike } from './hike.js';
4
+ export { testComposes } from './composes.js';
5
5
  export { testTrail } from './trail.js';
6
6
  export { testContracts } from './contracts.js';
7
7
  export { testDetours } from './detours.js';
@@ -10,31 +10,39 @@ export { testDetours } from './detours.js';
10
10
  export {
11
11
  assertErrorMatch,
12
12
  assertFullMatch,
13
+ assertPartialMatch,
13
14
  assertSchemaMatch,
15
+ errResultMatch,
14
16
  expectErr,
15
17
  expectOk,
18
+ okResultMatch,
16
19
  } from './assertions.js';
17
20
 
21
+ // Scenario testing
22
+ export { executeScenarioSteps, ref, scenario } from './scenario.js';
23
+
18
24
  // Mock factories
19
- export { createTestContext } from './context.js';
25
+ export {
26
+ createComposeContext,
27
+ createTestContext,
28
+ defaultCreatePermit,
29
+ } from './context.js';
20
30
  export { createTestLogger } from './logger.js';
21
31
 
22
- // Surface harnesses
23
- export { createCliHarness } from './harness-cli.js';
24
- export { createMcpHarness } from './harness-mcp.js';
25
-
26
32
  // Types
27
- export type { TestHikeOptions } from './hike.js';
33
+ export type { CreateComposeContextOptions } from './context.js';
34
+ export type {
35
+ PermittedTrail,
36
+ MinimalPermit,
37
+ TestExecutionOptions,
38
+ } from './context.js';
39
+ export type { TestComposeOptions } from './composes.js';
28
40
 
29
41
  export type {
30
- HikeScenario,
42
+ ComposeScenario,
43
+ RefToken,
44
+ ScenarioStep,
31
45
  TestScenario,
32
46
  TestLogger,
33
47
  TestTrailContextOptions,
34
- CliHarness,
35
- CliHarnessOptions,
36
- CliHarnessResult,
37
- McpHarness,
38
- McpHarnessOptions,
39
- McpHarnessResult,
40
48
  } from './types.js';
package/src/logger.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  * Test logger that captures log records for assertion.
3
3
  */
4
4
 
5
- import type { LogLevel, LogMetadata, LogRecord } from '@ontrails/logging';
5
+ import type { LogLevel, LogRecord } from '@ontrails/observe';
6
6
 
7
7
  import type { TestLogger } from './types.js';
8
8
 
9
+ type LogMetadata = Readonly<Record<string, unknown>>;
10
+
9
11
  // ---------------------------------------------------------------------------
10
12
  // Level ordering for filtering
11
13
  // ---------------------------------------------------------------------------
package/src/mcp.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createMcpHarness } from './harness-mcp.js';
2
+ export type {
3
+ McpHarness,
4
+ McpHarnessOptions,
5
+ McpHarnessResult,
6
+ } from './harness-mcp.js';
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Multi-step scenario runner for composition testing.
3
+ *
4
+ * Scenarios express multi-trail flows as structured data — arrays of steps
5
+ * with compose-step references via `ref()`. Each step invokes a trail through
6
+ * the normal execution pipeline (validation, layers, blaze, Result).
7
+ */
8
+
9
+ import { describe, test } from 'bun:test';
10
+
11
+ import type {
12
+ AnyTrail,
13
+ ComposeBatchOptions,
14
+ ComposeFn,
15
+ ExecuteTrailOptions,
16
+ ResourceOverrideMap,
17
+ Result,
18
+ Topo,
19
+ } from '@ontrails/core';
20
+ import {
21
+ buildComposeValidationSchema,
22
+ claimNextComposeBatchIndex,
23
+ createComposeBatchValidationResults,
24
+ executeTrail,
25
+ InternalError,
26
+ normalizeComposeBatchConcurrency,
27
+ Result as R,
28
+ } from '@ontrails/core';
29
+
30
+ import { assertPartialMatch, expectOk } from './assertions.js';
31
+ import { createTestContext, createMockResources } from './context.js';
32
+ import type { RefToken, ScenarioStep } from './types.js';
33
+
34
+ type ScenarioComposeTarget = string | { readonly id: string };
35
+ type ScenarioComposeCall = readonly [ScenarioComposeTarget, unknown];
36
+ type TestingExecuteTrailOptions = ExecuteTrailOptions & {
37
+ readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
38
+ };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // ref() — compose-step reference marker
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Create a reference marker for compose-step data in scenario inputs.
46
+ *
47
+ * `ref('create.id')` resolves to the `id` field of the step aliased as
48
+ * `create`. Dot-paths are supported for nested access.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * scenario('Fork flow', app, [
53
+ * { compose: createGist, input: { name: 'Hello' }, as: 'original' },
54
+ * { compose: forkGist, input: { id: ref('original.id') } },
55
+ * ]);
56
+ * ```
57
+ */
58
+ export const ref = (path: string): RefToken => ({ __ref: true, path });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Internals
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Type guard for RefToken. */
65
+ const isRef = (value: unknown): value is RefToken =>
66
+ typeof value === 'object' &&
67
+ value !== null &&
68
+ '__ref' in value &&
69
+ (value as Record<string, unknown>)['__ref'] === true &&
70
+ 'path' in value;
71
+
72
+ /**
73
+ * Resolve a dot-path against the outputs map.
74
+ *
75
+ * `ref('create.id')` splits into step name `create` and field path `id`.
76
+ * The first segment is the step alias; remaining segments are property lookups.
77
+ */
78
+ /**
79
+ * Walk remaining segments of a dot-path, drilling into the step output.
80
+ */
81
+ const drillPath = (
82
+ path: string,
83
+ segments: readonly string[],
84
+ start: unknown
85
+ ): unknown => {
86
+ let current: unknown = start;
87
+ for (let i = 1; i < segments.length; i += 1) {
88
+ const segment = segments[i];
89
+ if (segment === undefined) {
90
+ break;
91
+ }
92
+ if (typeof current !== 'object' || current === null) {
93
+ throw new Error(
94
+ `ref('${path}'): cannot access '${segment}' on ${typeof current}`
95
+ );
96
+ }
97
+ current = (current as Record<string, unknown>)[segment];
98
+ }
99
+ return current;
100
+ };
101
+
102
+ const resolvePath = (path: string, outputs: Map<string, unknown>): unknown => {
103
+ const segments = path.split('.');
104
+ const [stepName] = segments;
105
+ if (stepName === undefined) {
106
+ throw new Error(`ref(): empty path`);
107
+ }
108
+
109
+ const stepOutput = outputs.get(stepName);
110
+ if (stepOutput === undefined) {
111
+ throw new Error(
112
+ `ref('${path}'): no step output found for alias '${stepName}'`
113
+ );
114
+ }
115
+
116
+ return drillPath(path, segments, stepOutput);
117
+ };
118
+
119
+ /**
120
+ * Recursively walk a value, replacing RefToken instances with resolved values.
121
+ */
122
+ export const deriveRefs = (
123
+ value: unknown,
124
+ outputs: Map<string, unknown>
125
+ ): unknown => {
126
+ if (isRef(value)) {
127
+ return resolvePath(value.path, outputs);
128
+ }
129
+
130
+ if (Array.isArray(value)) {
131
+ return value.map((item) => deriveRefs(item, outputs));
132
+ }
133
+
134
+ if (typeof value === 'object' && value !== null) {
135
+ const result: Record<string, unknown> = {};
136
+ for (const [key, val] of Object.entries(value)) {
137
+ result[key] = deriveRefs(val, outputs);
138
+ }
139
+ return result;
140
+ }
141
+
142
+ return value;
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // scenario() — the public API
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Define a multi-step scenario test for composition flows.
151
+ *
152
+ * Each step invokes a trail through the normal execution pipeline.
153
+ * Steps can reference prior step outputs via `ref()`. If any step
154
+ * fails, the scenario stops and reports which step failed.
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * scenario('Create and show', app, [
159
+ * { compose: createItem, input: { name: 'Test' }, as: 'created' },
160
+ * { compose: showItem, input: { id: ref('created.id') },
161
+ * expectedMatch: { found: true } },
162
+ * ]);
163
+ * ```
164
+ */
165
+ /** Assert the result of a step against its expectations and record output. */
166
+ const assertStepExpectations = async (
167
+ step: ScenarioStep,
168
+ result: Result<unknown, Error>,
169
+ outputs: Map<string, unknown>
170
+ ): Promise<void> => {
171
+ const value = expectOk(result);
172
+ if (step.expected !== undefined) {
173
+ const { expect } = await import('bun:test');
174
+ expect(value).toEqual(deriveRefs(step.expected, outputs));
175
+ } else if (step.expectedMatch !== undefined) {
176
+ assertPartialMatch(result, deriveRefs(step.expectedMatch, outputs));
177
+ }
178
+ if (step.as !== undefined) {
179
+ outputs.set(step.as, value);
180
+ }
181
+ };
182
+
183
+ const executeUnlimitedComposeBatch = async (
184
+ calls: readonly ScenarioComposeCall[],
185
+ runCall: (
186
+ call: ScenarioComposeCall,
187
+ branchIndex: number
188
+ ) => Promise<Result<unknown, Error>>
189
+ ): Promise<Result<unknown, Error>[]> =>
190
+ await Promise.all(
191
+ calls.map((call, branchIndex) => runCall(call, branchIndex))
192
+ );
193
+
194
+ const executeLimitedComposeBatch = async (
195
+ calls: readonly ScenarioComposeCall[],
196
+ runCall: (
197
+ call: ScenarioComposeCall,
198
+ branchIndex: number
199
+ ) => Promise<Result<unknown, Error>>,
200
+ limit: number
201
+ ): Promise<Result<unknown, Error>[]> => {
202
+ const results = Array.from<Result<unknown, Error>>({ length: calls.length });
203
+ const nextIndex = { value: 0 };
204
+
205
+ const runWorker = async () => {
206
+ while (true) {
207
+ const branchIndex = claimNextComposeBatchIndex(nextIndex, calls);
208
+ if (branchIndex === undefined) {
209
+ return;
210
+ }
211
+
212
+ const call = calls[branchIndex];
213
+ if (call === undefined) {
214
+ // Defensive: `claimNextComposeBatchIndex` only returns indices within
215
+ // bounds, so this slot should always be populated. If it ever isn't,
216
+ // surface a clear InternalError in place of the missing slot and keep
217
+ // the worker loop running so sibling branches still get processed.
218
+ results[branchIndex] = R.err(
219
+ new InternalError(
220
+ `unreachable: concurrent compose batch call missing at index ${branchIndex}`
221
+ )
222
+ );
223
+ continue;
224
+ }
225
+
226
+ results[branchIndex] = await runCall(call, branchIndex);
227
+ }
228
+ };
229
+
230
+ await Promise.all(Array.from({ length: limit }, runWorker));
231
+ return results;
232
+ };
233
+
234
+ /**
235
+ * Build a compose function that resolves trails from the topo and executes
236
+ * them through the standard pipeline. Mirrors the pattern in composes.ts
237
+ * `executeFromMap` but without recording or injection.
238
+ */
239
+ const createScenarioCompose = (
240
+ app: Topo,
241
+ resources?: ResourceOverrideMap
242
+ ): ComposeFn => {
243
+ const invokeCompose = async (
244
+ idOrTrail: ScenarioComposeTarget,
245
+ input: unknown,
246
+ self: ComposeFn
247
+ ) => {
248
+ const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
249
+ const trailDef: AnyTrail | undefined = app.get(id);
250
+ if (trailDef === undefined) {
251
+ return R.err(
252
+ new InternalError(`compose: trail "${id}" not found in topo`)
253
+ );
254
+ }
255
+ const baseCtx = createTestContext();
256
+ const options: TestingExecuteTrailOptions = {
257
+ ctx: { ...baseCtx, compose: self },
258
+ resources,
259
+ topo: app,
260
+ validationSchema: buildComposeValidationSchema(trailDef),
261
+ };
262
+ return await executeTrail(trailDef, input, options);
263
+ };
264
+
265
+ const executeComposeBatch = async (
266
+ calls: readonly ScenarioComposeCall[],
267
+ self: ComposeFn,
268
+ options?: ComposeBatchOptions
269
+ ): Promise<Result<unknown, Error>[]> => {
270
+ if (calls.length === 0) {
271
+ return [];
272
+ }
273
+
274
+ const concurrency = normalizeComposeBatchConcurrency(options);
275
+ if (concurrency.isErr()) {
276
+ return createComposeBatchValidationResults(calls, concurrency.error);
277
+ }
278
+
279
+ const runCall = async (
280
+ [target, batchInput]: ScenarioComposeCall,
281
+ _branchIndex: number
282
+ ) => await invokeCompose(target, batchInput, self);
283
+
284
+ const limit = concurrency.value ?? calls.length;
285
+ return limit >= calls.length
286
+ ? await executeUnlimitedComposeBatch(calls, runCall)
287
+ : await executeLimitedComposeBatch(calls, runCall, limit);
288
+ };
289
+
290
+ const compose = async function compose(
291
+ idOrTrail: ScenarioComposeTarget | readonly ScenarioComposeCall[],
292
+ inputOrOptions?: unknown
293
+ ) {
294
+ if (Array.isArray(idOrTrail)) {
295
+ return await executeComposeBatch(
296
+ idOrTrail,
297
+ compose as ComposeFn,
298
+ inputOrOptions as ComposeBatchOptions | undefined
299
+ );
300
+ }
301
+
302
+ return await invokeCompose(
303
+ idOrTrail as ScenarioComposeTarget,
304
+ inputOrOptions,
305
+ compose as ComposeFn
306
+ );
307
+ } as ComposeFn;
308
+
309
+ return compose;
310
+ };
311
+
312
+ /**
313
+ * Execute a single scenario step: run the trail, assert expectations,
314
+ * and record outputs.
315
+ */
316
+ const executeStep = async (
317
+ step: ScenarioStep,
318
+ index: number,
319
+ app: Topo,
320
+ outputs: Map<string, unknown>,
321
+ resources?: ResourceOverrideMap
322
+ ): Promise<void> => {
323
+ if (step.as !== undefined && outputs.has(step.as)) {
324
+ throw new Error(
325
+ `scenario: duplicate step alias "${step.as}" — each alias must be unique`
326
+ );
327
+ }
328
+
329
+ const scenarioCompose = createScenarioCompose(app, resources);
330
+ const baseCtx = createTestContext();
331
+ const resolvedInput = deriveRefs(step.input, outputs);
332
+ const result = await executeTrail(step.compose, resolvedInput, {
333
+ ctx: { ...baseCtx, compose: scenarioCompose },
334
+ resources,
335
+ topo: app,
336
+ });
337
+
338
+ if (result.isErr()) {
339
+ throw new Error(
340
+ `Step ${String(index + 1)} ("${step.as ?? step.compose.id}") failed: ${result.error.message}`
341
+ );
342
+ }
343
+
344
+ await assertStepExpectations(step, result, outputs);
345
+ };
346
+
347
+ /**
348
+ * Execute scenario steps sequentially, resolving mock resources once upfront.
349
+ *
350
+ * Exported for direct use in tests that need to assert on step execution
351
+ * without the describe/test wrapper that `scenario()` provides.
352
+ */
353
+ export const executeScenarioSteps = async (
354
+ app: Topo,
355
+ steps: readonly ScenarioStep[]
356
+ ): Promise<void> => {
357
+ const outputs = new Map<string, unknown>();
358
+ const resources = await createMockResources(app);
359
+
360
+ for (const [index, step] of steps.entries()) {
361
+ await executeStep(step, index, app, outputs, resources);
362
+ }
363
+ };
364
+
365
+ export const scenario = (
366
+ name: string,
367
+ app: Topo,
368
+ steps: readonly ScenarioStep[]
369
+ ): void => {
370
+ describe(name, () => {
371
+ test('executes all steps', async () => {
372
+ await executeScenarioSteps(app, steps);
373
+ });
374
+ });
375
+ };
package/src/signals.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ import type {
4
+ FireFn,
5
+ TrailContext,
6
+ TrailExample,
7
+ TrailExampleSignalAssertion,
8
+ } from '@ontrails/core';
9
+ import { summarizeSignalPayload } from '@ontrails/core';
10
+
11
+ export interface RecordedSignal {
12
+ readonly payload: unknown;
13
+ readonly signalId: string;
14
+ }
15
+
16
+ export interface SignalAssertionHarness {
17
+ readonly assert: () => void;
18
+ readonly ctx: TrailContext;
19
+ }
20
+
21
+ const noop = (): void => undefined;
22
+
23
+ const resolveSignalId = (signal: unknown): string => {
24
+ if (typeof signal === 'string') {
25
+ return signal;
26
+ }
27
+ if (
28
+ typeof signal === 'object' &&
29
+ signal !== null &&
30
+ 'id' in signal &&
31
+ typeof (signal as { readonly id: unknown }).id === 'string'
32
+ ) {
33
+ return (signal as { readonly id: string }).id;
34
+ }
35
+ return '<unknown signal>';
36
+ };
37
+
38
+ const assertionSignalId = (assertion: TrailExampleSignalAssertion): string =>
39
+ resolveSignalId(assertion.signal);
40
+
41
+ const formatPayloadSummary = (payload: unknown): string => {
42
+ const summary = summarizeSignalPayload(payload);
43
+ const parts = [
44
+ `redacted=${summary.redacted}`,
45
+ `shape=${summary.shape}`,
46
+ `digest=${summary.digest}`,
47
+ ];
48
+ if (summary.topLevelEntryCount !== undefined) {
49
+ parts.push(`topLevelEntryCount=${summary.topLevelEntryCount}`);
50
+ }
51
+ return `{${parts.join(' ')}}`;
52
+ };
53
+
54
+ const formatAssertion = (assertion: TrailExampleSignalAssertion): string => {
55
+ const parts = [`signal=${assertionSignalId(assertion)}`];
56
+ if (assertion.payload !== undefined) {
57
+ parts.push(`payloadSummary=${formatPayloadSummary(assertion.payload)}`);
58
+ }
59
+ if (assertion.payloadMatch !== undefined) {
60
+ parts.push(
61
+ `payloadMatchSummary=${formatPayloadSummary(assertion.payloadMatch)}`
62
+ );
63
+ }
64
+ if (assertion.times !== undefined) {
65
+ parts.push(`times=${assertion.times}`);
66
+ }
67
+ return parts.join(' ');
68
+ };
69
+
70
+ const formatObserved = (observed: readonly RecordedSignal[]): string => {
71
+ if (observed.length === 0) {
72
+ return '<none>';
73
+ }
74
+ return observed
75
+ .map(
76
+ (record) =>
77
+ `${record.signalId} payloadSummary=${formatPayloadSummary(record.payload)}`
78
+ )
79
+ .join('; ');
80
+ };
81
+
82
+ const subsetArrayMatches = (
83
+ actual: readonly unknown[],
84
+ expected: readonly unknown[]
85
+ ): boolean => {
86
+ const consumed = new Set<number>();
87
+ for (const expectedItem of expected) {
88
+ const matchIndex = actual.findIndex(
89
+ (actualItem, index) =>
90
+ !consumed.has(index) &&
91
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with subsetMatches
92
+ subsetMatches(actualItem, expectedItem)
93
+ );
94
+ if (matchIndex === -1) {
95
+ return false;
96
+ }
97
+ consumed.add(matchIndex);
98
+ }
99
+ return true;
100
+ };
101
+
102
+ const subsetObjectMatches = (
103
+ actual: Record<string, unknown>,
104
+ expected: Record<string, unknown>
105
+ ): boolean =>
106
+ Object.keys(expected).every(
107
+ (key) =>
108
+ key in actual &&
109
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with subsetMatches
110
+ subsetMatches(actual[key], expected[key])
111
+ );
112
+
113
+ const subsetMatches = (actual: unknown, expected: unknown): boolean => {
114
+ if (Array.isArray(expected)) {
115
+ return Array.isArray(actual) && subsetArrayMatches(actual, expected);
116
+ }
117
+ if (expected !== null && typeof expected === 'object') {
118
+ return (
119
+ actual !== null &&
120
+ typeof actual === 'object' &&
121
+ !Array.isArray(actual) &&
122
+ subsetObjectMatches(
123
+ actual as Record<string, unknown>,
124
+ expected as Record<string, unknown>
125
+ )
126
+ );
127
+ }
128
+ return isDeepStrictEqual(actual, expected);
129
+ };
130
+
131
+ const payloadMatches = (
132
+ payload: unknown,
133
+ assertion: TrailExampleSignalAssertion
134
+ ): boolean => {
135
+ if (
136
+ assertion.payload !== undefined &&
137
+ !isDeepStrictEqual(payload, assertion.payload)
138
+ ) {
139
+ return false;
140
+ }
141
+ if (
142
+ assertion.payloadMatch !== undefined &&
143
+ !subsetMatches(payload, assertion.payloadMatch)
144
+ ) {
145
+ return false;
146
+ }
147
+ return true;
148
+ };
149
+
150
+ const signalMatches = (
151
+ record: RecordedSignal,
152
+ assertion: TrailExampleSignalAssertion
153
+ ): boolean =>
154
+ record.signalId === assertionSignalId(assertion) &&
155
+ payloadMatches(record.payload, assertion);
156
+
157
+ const assertValidTimes = (assertion: TrailExampleSignalAssertion): number => {
158
+ const times = assertion.times ?? 1;
159
+ if (!Number.isInteger(times) || times < 1) {
160
+ throw new Error(
161
+ `Signal assertion has invalid times value: ${formatAssertion(assertion)}`
162
+ );
163
+ }
164
+ return times;
165
+ };
166
+
167
+ const assertSignalAssertion = (
168
+ example: TrailExample<unknown, unknown>,
169
+ assertion: TrailExampleSignalAssertion,
170
+ observed: readonly RecordedSignal[],
171
+ consumed: Set<number>
172
+ ): void => {
173
+ const times = assertValidTimes(assertion);
174
+ for (let count = 0; count < times; count += 1) {
175
+ const matchIndex = observed.findIndex(
176
+ (record, index) =>
177
+ !consumed.has(index) && signalMatches(record, assertion)
178
+ );
179
+ if (matchIndex === -1) {
180
+ throw new Error(
181
+ `Example "${example.name}" expected signal ${formatAssertion(
182
+ assertion
183
+ )}; observed ${formatObserved(observed)}`
184
+ );
185
+ }
186
+ consumed.add(matchIndex);
187
+ }
188
+ };
189
+
190
+ export const assertSignalAssertions = (
191
+ example: TrailExample<unknown, unknown>,
192
+ observed: readonly RecordedSignal[]
193
+ ): void => {
194
+ const consumed = new Set<number>();
195
+ for (const assertion of example.signals ?? []) {
196
+ assertSignalAssertion(example, assertion, observed, consumed);
197
+ }
198
+ };
199
+
200
+ export const withSignalAssertions = (
201
+ ctx: TrailContext,
202
+ example: TrailExample<unknown, unknown>
203
+ ): SignalAssertionHarness => {
204
+ if (example.signals === undefined || example.signals.length === 0) {
205
+ return { assert: noop, ctx };
206
+ }
207
+
208
+ const observed: RecordedSignal[] = [];
209
+ const baseFire = ctx.fire as
210
+ | ((signal: unknown, payload: unknown) => Promise<void>)
211
+ | undefined;
212
+ const fire = (async (signal: unknown, payload: unknown): Promise<void> => {
213
+ observed.push({ payload, signalId: resolveSignalId(signal) });
214
+ await baseFire?.(signal, payload);
215
+ }) as FireFn;
216
+
217
+ return {
218
+ assert: () => assertSignalAssertions(example, observed),
219
+ ctx: { ...ctx, fire },
220
+ };
221
+ };