@ontrails/testing 1.0.0-beta.15 → 1.0.0-beta.17

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 (95) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/README.md +47 -3
  3. package/package.json +16 -7
  4. package/src/all.ts +32 -7
  5. package/src/context.ts +3 -0
  6. package/src/contracts.ts +1 -16
  7. package/src/examples.ts +19 -11
  8. package/src/harness-cli.ts +16 -3
  9. package/src/harness-http.ts +263 -0
  10. package/src/harness-mcp.ts +2 -0
  11. package/src/index.ts +18 -1
  12. package/src/logger.ts +3 -1
  13. package/src/scenario.ts +3 -5
  14. package/src/signals.ts +221 -0
  15. package/src/surface-parity.ts +356 -0
  16. package/src/types.ts +136 -1
  17. package/.turbo/turbo-build.log +0 -1
  18. package/.turbo/turbo-lint.log +0 -3
  19. package/.turbo/turbo-typecheck.log +0 -1
  20. package/dist/all.d.ts +0 -36
  21. package/dist/all.d.ts.map +0 -1
  22. package/dist/all.js +0 -99
  23. package/dist/all.js.map +0 -1
  24. package/dist/assertions.d.ts +0 -72
  25. package/dist/assertions.d.ts.map +0 -1
  26. package/dist/assertions.js +0 -238
  27. package/dist/assertions.js.map +0 -1
  28. package/dist/context.d.ts +0 -76
  29. package/dist/context.d.ts.map +0 -1
  30. package/dist/context.js +0 -118
  31. package/dist/context.js.map +0 -1
  32. package/dist/contracts.d.ts +0 -17
  33. package/dist/contracts.d.ts.map +0 -1
  34. package/dist/contracts.js +0 -75
  35. package/dist/contracts.js.map +0 -1
  36. package/dist/crosses.d.ts +0 -38
  37. package/dist/crosses.d.ts.map +0 -1
  38. package/dist/crosses.js +0 -223
  39. package/dist/crosses.js.map +0 -1
  40. package/dist/detours.d.ts +0 -13
  41. package/dist/detours.d.ts.map +0 -1
  42. package/dist/detours.js +0 -109
  43. package/dist/detours.js.map +0 -1
  44. package/dist/effective-examples.d.ts +0 -30
  45. package/dist/effective-examples.d.ts.map +0 -1
  46. package/dist/effective-examples.js +0 -227
  47. package/dist/effective-examples.js.map +0 -1
  48. package/dist/examples.d.ts +0 -23
  49. package/dist/examples.d.ts.map +0 -1
  50. package/dist/examples.js +0 -240
  51. package/dist/examples.js.map +0 -1
  52. package/dist/harness-cli.d.ts +0 -21
  53. package/dist/harness-cli.d.ts.map +0 -1
  54. package/dist/harness-cli.js +0 -205
  55. package/dist/harness-cli.js.map +0 -1
  56. package/dist/harness-mcp.d.ts +0 -21
  57. package/dist/harness-mcp.d.ts.map +0 -1
  58. package/dist/harness-mcp.js +0 -54
  59. package/dist/harness-mcp.js.map +0 -1
  60. package/dist/index.d.ts +0 -18
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/index.js +0 -19
  63. package/dist/index.js.map +0 -1
  64. package/dist/logger.d.ts +0 -15
  65. package/dist/logger.d.ts.map +0 -1
  66. package/dist/logger.js +0 -87
  67. package/dist/logger.js.map +0 -1
  68. package/dist/scenario.d.ts +0 -37
  69. package/dist/scenario.d.ts.map +0 -1
  70. package/dist/scenario.js +0 -235
  71. package/dist/scenario.js.map +0 -1
  72. package/dist/trail.d.ts +0 -20
  73. package/dist/trail.d.ts.map +0 -1
  74. package/dist/trail.js +0 -80
  75. package/dist/trail.js.map +0 -1
  76. package/dist/types.d.ts +0 -113
  77. package/dist/types.d.ts.map +0 -1
  78. package/dist/types.js +0 -5
  79. package/dist/types.js.map +0 -1
  80. package/src/__tests__/all.test.ts +0 -254
  81. package/src/__tests__/context.test.ts +0 -170
  82. package/src/__tests__/contracts.test.ts +0 -239
  83. package/src/__tests__/crosses.test.ts +0 -587
  84. package/src/__tests__/detours.test.ts +0 -212
  85. package/src/__tests__/effective-examples.test.ts +0 -203
  86. package/src/__tests__/examples.test.ts +0 -636
  87. package/src/__tests__/harness-cli.test.ts +0 -90
  88. package/src/__tests__/harness-mcp.test.ts +0 -37
  89. package/src/__tests__/logger.test.ts +0 -136
  90. package/src/__tests__/partial-match.test.ts +0 -126
  91. package/src/__tests__/scenario.test.ts +0 -381
  92. package/src/__tests__/trail.test.ts +0 -99
  93. package/tsconfig.json +0 -9
  94. package/tsconfig.tests.json +0 -10
  95. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,263 @@
1
+ /**
2
+ * HTTP integration test harness.
3
+ *
4
+ * Builds framework-agnostic HTTP routes from a graph and executes them
5
+ * directly, without Hono or a listening server.
6
+ */
7
+
8
+ import { deriveHttpRoutes } from '@ontrails/http';
9
+ import type { HttpMethod, HttpRouteDefinition } from '@ontrails/http';
10
+ import type { TrailContext, TrailContextInit } from '@ontrails/core';
11
+ import { NotFoundError, projectPublicSurfaceError } from '@ontrails/core';
12
+
13
+ import { mergeTestContext } from './context.js';
14
+ import type {
15
+ HttpHarness,
16
+ HttpHarnessOptions,
17
+ HttpHarnessRequest,
18
+ HttpHarnessRequestOptions,
19
+ HttpHarnessResult,
20
+ } from './types.js';
21
+
22
+ const TEST_ORIGIN = 'http://ontrails.test';
23
+
24
+ const normalizeMethod = (method: HttpMethod): HttpMethod =>
25
+ method.toUpperCase() as HttpMethod;
26
+
27
+ const collectQueryParams = (url: URL): Record<string, unknown> => {
28
+ const query: Record<string, unknown> = {};
29
+ const seen = new Set<string>();
30
+
31
+ for (const key of url.searchParams.keys()) {
32
+ if (seen.has(key)) {
33
+ continue;
34
+ }
35
+ seen.add(key);
36
+ const values = url.searchParams.getAll(key);
37
+ query[key] = values.length > 1 ? values : values[0];
38
+ }
39
+
40
+ return query;
41
+ };
42
+
43
+ const findRoute = (
44
+ routes: readonly HttpRouteDefinition[],
45
+ method: HttpMethod,
46
+ path: string
47
+ ): HttpRouteDefinition | undefined =>
48
+ routes.find((route) => route.method === method && route.path === path);
49
+
50
+ const mapError = (error: Error): HttpHarnessResult => {
51
+ const projection = projectPublicSurfaceError('http', error);
52
+ const body = {
53
+ error: {
54
+ category: projection.category,
55
+ code: projection.name,
56
+ message: projection.message,
57
+ },
58
+ };
59
+ return {
60
+ body,
61
+ error: body.error,
62
+ ok: false,
63
+ status: projection.code,
64
+ };
65
+ };
66
+
67
+ const mapSuccess = (data: unknown): HttpHarnessResult => ({
68
+ body: { data },
69
+ data,
70
+ ok: true,
71
+ status: 200,
72
+ });
73
+
74
+ const isWebhookParseResult = (
75
+ input: unknown
76
+ ): input is {
77
+ readonly error?: Error | undefined;
78
+ isErr(): boolean;
79
+ readonly value?: unknown | undefined;
80
+ } =>
81
+ typeof input === 'object' &&
82
+ input !== null &&
83
+ 'isErr' in input &&
84
+ typeof input.isErr === 'function';
85
+
86
+ const mergeContextInit = (
87
+ base: TrailContextInit | undefined,
88
+ ctx: Partial<TrailContext> | undefined
89
+ ): TrailContextInit => ({
90
+ ...base,
91
+ ...mergeTestContext({ ...base, ...ctx }),
92
+ });
93
+
94
+ const createHarnessContextFactory = (
95
+ options: HttpHarnessOptions
96
+ ): (() => TrailContextInit | Promise<TrailContextInit>) => {
97
+ const { createContext, ctx } = options;
98
+ return async () => {
99
+ const base = await createContext?.();
100
+ return mergeContextInit(base, ctx);
101
+ };
102
+ };
103
+
104
+ const buildInput = (
105
+ route: HttpRouteDefinition,
106
+ url: URL,
107
+ request: HttpHarnessRequest
108
+ ): unknown => {
109
+ if (route.inputSource === 'query') {
110
+ return {
111
+ ...collectQueryParams(url),
112
+ ...request.query,
113
+ };
114
+ }
115
+ return request.body ?? {};
116
+ };
117
+
118
+ const executeRouteWithInput = async (
119
+ route: HttpRouteDefinition,
120
+ input: unknown,
121
+ request: HttpHarnessRequest
122
+ ): Promise<HttpHarnessResult> => {
123
+ const result = await route.execute(
124
+ input,
125
+ request.requestId,
126
+ request.abortSignal,
127
+ { headers: request.headers }
128
+ );
129
+
130
+ if (result.isErr()) {
131
+ return mapError(result.error);
132
+ }
133
+
134
+ return mapSuccess(result.value);
135
+ };
136
+
137
+ const executeRoute = async (
138
+ route: HttpRouteDefinition,
139
+ url: URL,
140
+ request: HttpHarnessRequest
141
+ ): Promise<HttpHarnessResult> => {
142
+ const parsedInput = buildInput(route, url, request);
143
+ if (route.inputSource === 'webhook' && route.parseWebhookInput) {
144
+ const parsed = route.parseWebhookInput(parsedInput);
145
+ if (isWebhookParseResult(parsed)) {
146
+ if (parsed.isErr()) {
147
+ return mapError(parsed.error ?? new Error('Invalid webhook input'));
148
+ }
149
+ return await executeRouteWithInput(route, parsed.value, request);
150
+ }
151
+ return await executeRouteWithInput(route, parsed, request);
152
+ }
153
+
154
+ return await executeRouteWithInput(route, parsedInput, request);
155
+ };
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // createHttpHarness
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Create an HTTP harness for integration testing.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * import { createHttpHarness } from '@ontrails/testing';
167
+ *
168
+ * const http = createHttpHarness({ graph });
169
+ * const result = await http.get('/entity/show', { name: 'Alpha' });
170
+ * expect(result.status).toBe(200);
171
+ * ```
172
+ */
173
+ export const createHttpHarness = (
174
+ harnessOptions: HttpHarnessOptions
175
+ ): HttpHarness => {
176
+ const { ctx: _ctx, graph, ...deriveOptions } = harnessOptions;
177
+ const routesResult = deriveHttpRoutes(graph, {
178
+ ...deriveOptions,
179
+ createContext: createHarnessContextFactory(harnessOptions),
180
+ });
181
+ if (routesResult.isErr()) {
182
+ throw routesResult.error;
183
+ }
184
+ const routes = routesResult.value;
185
+
186
+ const request = async (
187
+ rawRequest: HttpHarnessRequest
188
+ ): Promise<HttpHarnessResult> => {
189
+ const method = normalizeMethod(rawRequest.method);
190
+ const url = new URL(rawRequest.path, TEST_ORIGIN);
191
+ const route = findRoute(routes, method, url.pathname);
192
+
193
+ if (!route) {
194
+ return mapError(
195
+ new NotFoundError(`No HTTP route found for ${method} ${url.pathname}`)
196
+ );
197
+ }
198
+
199
+ return await executeRoute(route, url, { ...rawRequest, method });
200
+ };
201
+
202
+ return {
203
+ delete: async (
204
+ path: string,
205
+ body?: unknown,
206
+ requestOptions?: HttpHarnessRequestOptions
207
+ ) =>
208
+ await request({
209
+ ...requestOptions,
210
+ body,
211
+ method: 'DELETE',
212
+ path,
213
+ }),
214
+ get: async (
215
+ path: string,
216
+ query?: Record<string, unknown>,
217
+ requestOptions?: HttpHarnessRequestOptions
218
+ ) =>
219
+ await request({
220
+ ...requestOptions,
221
+ method: 'GET',
222
+ path,
223
+ query: {
224
+ ...requestOptions?.query,
225
+ ...query,
226
+ },
227
+ }),
228
+ patch: async (
229
+ path: string,
230
+ body?: unknown,
231
+ requestOptions?: HttpHarnessRequestOptions
232
+ ) =>
233
+ await request({
234
+ ...requestOptions,
235
+ body,
236
+ method: 'PATCH',
237
+ path,
238
+ }),
239
+ post: async (
240
+ path: string,
241
+ body?: unknown,
242
+ requestOptions?: HttpHarnessRequestOptions
243
+ ) =>
244
+ await request({
245
+ ...requestOptions,
246
+ body,
247
+ method: 'POST',
248
+ path,
249
+ }),
250
+ put: async (
251
+ path: string,
252
+ body?: unknown,
253
+ requestOptions?: HttpHarnessRequestOptions
254
+ ) =>
255
+ await request({
256
+ ...requestOptions,
257
+ body,
258
+ method: 'PUT',
259
+ path,
260
+ }),
261
+ request,
262
+ };
263
+ };
@@ -63,6 +63,8 @@ export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
63
63
  return {
64
64
  content: result.content,
65
65
  isError: result.isError ?? false,
66
+ meta: result._meta,
67
+ structuredContent: result.structuredContent,
66
68
  };
67
69
  },
68
70
  };
package/src/index.ts CHANGED
@@ -6,6 +6,10 @@ export { testCrosses } from './crosses.js';
6
6
  export { testTrail } from './trail.js';
7
7
  export { testContracts } from './contracts.js';
8
8
  export { testDetours } from './detours.js';
9
+ export {
10
+ runSurfaceParityExample,
11
+ testSurfaceParity,
12
+ } from './surface-parity.js';
9
13
 
10
14
  // Assertions
11
15
  export {
@@ -30,8 +34,9 @@ export {
30
34
  } from './context.js';
31
35
  export { createTestLogger } from './logger.js';
32
36
 
33
- // Trailhead harnesses
37
+ // Surface harnesses
34
38
  export { createCliHarness } from './harness-cli.js';
39
+ export { createHttpHarness } from './harness-http.js';
35
40
  export { createMcpHarness } from './harness-mcp.js';
36
41
 
37
42
  // Types
@@ -54,7 +59,19 @@ export type {
54
59
  CliHarness,
55
60
  CliHarnessOptions,
56
61
  CliHarnessResult,
62
+ HttpHarness,
63
+ HttpHarnessErrorBody,
64
+ HttpHarnessOptions,
65
+ HttpHarnessRequest,
66
+ HttpHarnessRequestOptions,
67
+ HttpHarnessResult,
68
+ HttpHarnessSuccessBody,
57
69
  McpHarness,
58
70
  McpHarnessOptions,
59
71
  McpHarnessResult,
72
+ NormalizedSurfaceParityResult,
73
+ SurfaceParityExclusion,
74
+ SurfaceParityOptions,
75
+ SurfaceParitySurface,
60
76
  } from './types.js';
77
+ export type { SurfaceParityComparison } from './surface-parity.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/scenario.ts CHANGED
@@ -18,15 +18,13 @@ import type {
18
18
  } from '@ontrails/core';
19
19
  import {
20
20
  buildCrossValidationSchema,
21
+ claimNextCrossBatchIndex,
22
+ createCrossBatchValidationResults,
21
23
  executeTrail,
22
24
  InternalError,
25
+ normalizeCrossBatchConcurrency,
23
26
  Result as R,
24
27
  } from '@ontrails/core';
25
- import {
26
- claimNextCrossBatchIndex,
27
- createCrossBatchValidationResults,
28
- normalizeCrossBatchConcurrency,
29
- } from '@ontrails/core/internal/cross-batch';
30
28
 
31
29
  import { assertPartialMatch, expectOk } from './assertions.js';
32
30
  import { createTestContext, createMockResources } from './context.js';
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
+ };