@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.
- package/CHANGELOG.md +70 -1
- package/README.md +47 -3
- package/package.json +16 -7
- package/src/all.ts +32 -7
- package/src/context.ts +3 -0
- package/src/contracts.ts +1 -16
- package/src/examples.ts +19 -11
- package/src/harness-cli.ts +16 -3
- package/src/harness-http.ts +263 -0
- package/src/harness-mcp.ts +2 -0
- package/src/index.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/scenario.ts +3 -5
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +356 -0
- package/src/types.ts +136 -1
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -36
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -99
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -72
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -238
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -76
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -118
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -17
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -75
- package/dist/contracts.js.map +0 -1
- package/dist/crosses.d.ts +0 -38
- package/dist/crosses.d.ts.map +0 -1
- package/dist/crosses.js +0 -223
- package/dist/crosses.js.map +0 -1
- package/dist/detours.d.ts +0 -13
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -109
- package/dist/detours.js.map +0 -1
- package/dist/effective-examples.d.ts +0 -30
- package/dist/effective-examples.d.ts.map +0 -1
- package/dist/effective-examples.js +0 -227
- package/dist/effective-examples.js.map +0 -1
- package/dist/examples.d.ts +0 -23
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -240
- package/dist/examples.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -205
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -54
- package/dist/harness-mcp.js.map +0 -1
- package/dist/index.d.ts +0 -18
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -19
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/scenario.d.ts +0 -37
- package/dist/scenario.d.ts.map +0 -1
- package/dist/scenario.js +0 -235
- package/dist/scenario.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -113
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/all.test.ts +0 -254
- package/src/__tests__/context.test.ts +0 -170
- package/src/__tests__/contracts.test.ts +0 -239
- package/src/__tests__/crosses.test.ts +0 -587
- package/src/__tests__/detours.test.ts +0 -212
- package/src/__tests__/effective-examples.test.ts +0 -203
- package/src/__tests__/examples.test.ts +0 -636
- package/src/__tests__/harness-cli.test.ts +0 -90
- package/src/__tests__/harness-mcp.test.ts +0 -37
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/partial-match.test.ts +0 -126
- package/src/__tests__/scenario.test.ts +0 -381
- package/src/__tests__/trail.test.ts +0 -99
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- 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
|
+
};
|
package/src/harness-mcp.ts
CHANGED
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
|
-
//
|
|
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,
|
|
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
|
+
};
|