@luckystack/test-runner 0.1.4 → 0.1.5

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.
@@ -1,438 +1,438 @@
1
- # Extension Hooks: Reporter, Layer and Fixture Registries
2
-
3
- The four built-in layers (contract, auth, rate-limit, fuzz) cover the framework envelope and the security guards every LuckyStack project ships. The extension registry is where consumers add their own checks — CORS enforcement, multi-tenant isolation, business rules, GDPR data-flow checks — and pipe the combined results into Slack, a build dashboard, or any webhook. The hooks are intentionally narrow: three slots (layers, fixtures, reporters), each replace-by-key, all clearable from a single test-only function.
4
-
5
- ## Slots
6
-
7
- | Slot | Purpose | Cardinality |
8
- | --- | --- | --- |
9
- | Test layers | Additional per-endpoint checks. Each layer has a unique `name`. | Many. Replace-by-name. |
10
- | Fixtures | Realistic valid/invalid payloads keyed by a type identifier. | Many. Replace-by-typeKey. |
11
- | Reporter | Single sink for per-result and per-summary events plus optional webhook. | One. Replace by re-registering or unregister with `null`. |
12
-
13
- The registries are in-memory and process-local. They survive across calls within one process and reset only via `resetTestExtensionsForTests()`. There is no persistence — registration happens at module load.
14
-
15
- ## Functions
16
-
17
- ### `logContractResult(result)`
18
-
19
- Pretty-print one `ContractCheckResult` to stdout. Wire it as `onResult` in any of the four built-in sweeps to get live per-endpoint output.
20
-
21
- Signature:
22
-
23
- ```ts
24
- logContractResult(result: ContractCheckResult): void
25
- ```
26
-
27
- Output format:
28
-
29
- - Pass success: `[PASS] POST /api/billing/getInvoice/v1 12ms http=200`
30
- - Pass typed error: `[PASS] POST /api/auth/login/v1 8ms http=200 (error: auth.required)`
31
- - Skip: `[SKIP] POST /api/uploads/fileUpload/v1 0ms Explicitly skipped`
32
- - Fail: `[FAIL] POST /api/billing/cancelSubscription/v1 14ms http=500 reason=fuzz payload produced 5xx: ...`
33
-
34
- Status tag is one of `[PASS]`, `[FAIL]`, `[SKIP]`. `http=-` appears when no HTTP status is captured (network error, abort). `durationMs` is always present.
35
-
36
- ### `logContractSummary(summary)`
37
-
38
- Pretty-print one `RunContractSummary` to stdout. Call it once at the end of a sweep — typically after `logContractResult` has streamed the individual lines.
39
-
40
- Signature:
41
-
42
- ```ts
43
- logContractSummary(summary: RunContractSummary): void
44
- ```
45
-
46
- Output:
47
-
48
- ```
49
- Contract tests: 47/50 passed, 2 failed, 1 skipped
50
-
51
- Failures:
52
- - /api/billing/cancelSubscription/v1: fuzz payload produced 5xx: { nested: ...
53
- - /api/integrations/stripeWebhook/v1: Response missing standard `status` envelope
54
- ```
55
-
56
- The header line is always printed. The failures block is only printed when `summary.failed > 0`. Skipped endpoints are counted but not enumerated.
57
-
58
- ### `registerTestLayer(layer)`
59
-
60
- Add (or replace by name) a custom test layer in the registry.
61
-
62
- Signature:
63
-
64
- ```ts
65
- registerTestLayer(layer: TestLayer): void
66
-
67
- interface TestLayer {
68
- name: string;
69
- run: (input: TestLayerInput) => Promise<TestLayerResult> | TestLayerResult;
70
- }
71
-
72
- interface TestLayerInput {
73
- endpoint: string; // resolved route, e.g. 'api/billing/getInvoice/v1'
74
- method?: string; // HTTP method, undefined for sync endpoints
75
- authToken?: string; // bearer/session token for auth-aware checks
76
- }
77
-
78
- interface TestLayerResult {
79
- passed: boolean;
80
- message?: string; // short reason key, i18n-style on failure
81
- metadata?: Record<string, unknown>;
82
- }
83
- ```
84
-
85
- Replace-by-name: registering a layer with a name that already exists overwrites the previous entry. Use this to update a layer mid-run, e.g. after fetching a fresh auth token.
86
-
87
- The runner does **not** currently wire registered layers into the built-in sweeps automatically. Today they live in the registry as a coordination surface for custom test harnesses — read them with `listTestLayers()`, iterate `walkEndpoints`, and call `layer.run(input)` per endpoint. The roadmap is for a future `runRegisteredLayers({ apiMethodMap, baseUrl })` entry point to walk the registry as a fifth (or N-th) built-in layer.
88
-
89
- ### `listTestLayers()`
90
-
91
- Return the current registry snapshot.
92
-
93
- Signature:
94
-
95
- ```ts
96
- listTestLayers(): TestLayer[]
97
- ```
98
-
99
- Order is registration order (Map iteration order). The returned array is a fresh copy — mutating it does not affect the registry.
100
-
101
- ### `registerTestReporter(reporter)`
102
-
103
- Set the reporter slot, or clear it by passing `null`.
104
-
105
- Signature:
106
-
107
- ```ts
108
- registerTestReporter(reporter: TestReporter | null): void
109
-
110
- interface TestReporter {
111
- onResult?: (result: TestResult) => void | Promise<void>;
112
- onSummary?: (summary: TestSummary) => void | Promise<void>;
113
- webhookUrl?: string;
114
- webhookAuth?: { type: 'bearer'; token: string };
115
- }
116
-
117
- interface TestResult {
118
- layer: string;
119
- endpoint: string;
120
- passed: boolean;
121
- message?: string;
122
- durationMs?: number;
123
- metadata?: Record<string, unknown>;
124
- }
125
-
126
- interface TestSummary {
127
- totalLayers: number;
128
- totalEndpoints: number;
129
- passed: number;
130
- failed: number;
131
- results: TestResult[];
132
- }
133
- ```
134
-
135
- Replace-by-overwrite: only one reporter is active. To compose multiple sinks, write a single reporter that fans out internally.
136
-
137
- Like layers, the reporter is a coordination surface today: the built-in sweeps emit `ContractCheckResult` shapes, not `TestResult`. The registered reporter is intended for the cross-layer aggregator that lives in your test harness — collect results from each built-in sweep, map them into `TestResult`, and emit through this reporter so all sinks see one stream.
138
-
139
- ### `getTestReporter()`
140
-
141
- Read the active reporter or `null` if none is registered.
142
-
143
- Signature:
144
-
145
- ```ts
146
- getTestReporter(): TestReporter | null
147
- ```
148
-
149
- ### `resetTestExtensionsForTests()`
150
-
151
- Clear all three registries (layers, fixtures, reporter). Intended for the runner's own internal tests — calling it from a production test harness will wipe everything other modules registered at load time, which usually breaks fixture-dependent layers.
152
-
153
- Signature:
154
-
155
- ```ts
156
- resetTestExtensionsForTests(): void
157
- ```
158
-
159
- The name advertises the intent: do not import this from production paths. There is no convenience "clear only layers" — by design, to keep the surface small.
160
-
161
- ## Webhook contract
162
-
163
- When `reporter.webhookUrl` is set, the harness that drives the reporter is expected to POST the JSON-serialised `TestSummary` to that URL at the end of the sweep. The runner's own built-in sweeps do not call the webhook themselves — that's the job of the consumer's aggregator. Recommended client behavior:
164
-
165
- - **Method:** `POST`.
166
- - **Headers:** `Content-Type: application/json`, plus `Authorization: Bearer <token>` when `webhookAuth?.type === 'bearer'`.
167
- - **Body:** `JSON.stringify(summary)`. `TestSummary.results` can be large — clip or compress on the producer side if the receiver has body limits.
168
- - **Timeout:** wrap the POST in `tryCatch` with a sensible per-call timeout (e.g. 5s). A webhook failure should not fail the sweep.
169
- - **Retries:** none by default. Add idempotency at the receiver if you want retries; the runner is a one-shot test driver, not a queue.
170
-
171
- Concrete example of a webhook-emitting reporter:
172
-
173
- ```ts
174
- import { registerTestReporter } from '@luckystack/test-runner';
175
-
176
- registerTestReporter({
177
- webhookUrl: 'https://hooks.slack.com/services/T000/B000/XXX',
178
- onSummary: async (summary) => {
179
- const url = 'https://hooks.slack.com/services/T000/B000/XXX';
180
- await fetch(url, {
181
- method: 'POST',
182
- headers: { 'Content-Type': 'application/json' },
183
- body: JSON.stringify({
184
- text: `LuckyStack tests: ${summary.passed}/${summary.totalEndpoints} passed`,
185
- }),
186
- });
187
- },
188
- });
189
- ```
190
-
191
- ## Lifecycle and error isolation
192
-
193
- - **Reporter callbacks** (`onResult`, `onSummary`) and **layer callbacks** (`run`) execute inline on the caller's task. The runner does not wrap them in `tryCatch`. A throw propagates out of the sweep and skips subsequent endpoints — handle errors inside your callback if you want isolation.
194
- - **Async callbacks** are NOT awaited by the built-in sweeps' `onResult` plumbing. If you do async work, push results into an in-memory buffer and flush in `onSummary` (which the aggregator can await).
195
- - **Order:** `onResult` fires per individual result in sweep order; `onSummary` fires exactly once at the end of the aggregator's run. Built-in sweeps emit `ContractCheckResult` through their own `onResult` parameter — the cross-layer reporter sees `TestResult` only when your aggregator forwards mapped results to it.
196
-
197
- ## Types
198
-
199
- ```ts
200
- interface TestLayer {
201
- name: string;
202
- run: (input: TestLayerInput) => Promise<TestLayerResult> | TestLayerResult;
203
- }
204
-
205
- interface TestLayerInput {
206
- endpoint: string;
207
- method?: string;
208
- authToken?: string;
209
- }
210
-
211
- interface TestLayerResult {
212
- passed: boolean;
213
- message?: string;
214
- metadata?: Record<string, unknown>;
215
- }
216
-
217
- interface TestFixture<TPayload = unknown> {
218
- valid: TPayload[];
219
- invalid: TPayload[];
220
- }
221
-
222
- interface TestResult {
223
- layer: string;
224
- endpoint: string;
225
- passed: boolean;
226
- message?: string;
227
- durationMs?: number;
228
- metadata?: Record<string, unknown>;
229
- }
230
-
231
- interface TestSummary {
232
- totalLayers: number;
233
- totalEndpoints: number;
234
- passed: number;
235
- failed: number;
236
- results: TestResult[];
237
- }
238
-
239
- interface TestReporter {
240
- onResult?: (result: TestResult) => void | Promise<void>;
241
- onSummary?: (summary: TestSummary) => void | Promise<void>;
242
- webhookUrl?: string;
243
- webhookAuth?: { type: 'bearer'; token: string };
244
- }
245
- ```
246
-
247
- `ContractCheckResult` and `RunContractSummary` (the shapes emitted by the four built-in sweeps) live in `contract-tests.md`.
248
-
249
- ## Examples
250
-
251
- ### Live progress with the built-in pretty printer
252
-
253
- ```ts
254
- import {
255
- runContractTests,
256
- runFuzzTests,
257
- logContractResult,
258
- logContractSummary,
259
- } from '@luckystack/test-runner';
260
-
261
- const contract = await runContractTests({
262
- apiMethodMap,
263
- baseUrl,
264
- onResult: logContractResult,
265
- });
266
- logContractSummary(contract);
267
-
268
- const fuzz = await runFuzzTests({
269
- apiMethodMap,
270
- baseUrl,
271
- onResult: logContractResult,
272
- });
273
- logContractSummary(fuzz);
274
- ```
275
-
276
- ### Custom CORS-enforcement layer
277
-
278
- ```ts
279
- import { registerTestLayer } from '@luckystack/test-runner';
280
-
281
- registerTestLayer({
282
- name: 'cors',
283
- run: async ({ endpoint }) => {
284
- const response = await fetch(`${process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80'}/${endpoint}`, {
285
- method: 'OPTIONS',
286
- headers: {
287
- Origin: 'https://evil.example.com',
288
- 'Access-Control-Request-Method': 'POST',
289
- },
290
- });
291
- const allowed = response.headers.get('Access-Control-Allow-Origin');
292
- if (allowed === '*' || allowed === 'https://evil.example.com') {
293
- return {
294
- passed: false,
295
- message: 'cors.openOrigin',
296
- metadata: { allowed },
297
- };
298
- }
299
- return { passed: true };
300
- },
301
- });
302
- ```
303
-
304
- A test harness then walks `listTestLayers()` per endpoint and aggregates results through the registered reporter.
305
-
306
- ### Multi-tenant isolation layer (combining fixtures and layers)
307
-
308
- ```ts
309
- import {
310
- registerTestFixture,
311
- registerTestLayer,
312
- getTestFixture,
313
- } from '@luckystack/test-runner';
314
-
315
- registerTestFixture('api/billing/getInvoice/v1', {
316
- valid: [{ invoiceId: 'inv_tenant_a' }],
317
- invalid: [{ invoiceId: 'inv_tenant_b_owned_by_other_tenant' }],
318
- });
319
-
320
- registerTestLayer({
321
- name: 'multi-tenant-isolation',
322
- run: async ({ endpoint, authToken }) => {
323
- const fixture = getTestFixture(endpoint);
324
- if (!fixture || fixture.invalid.length === 0) return { passed: true };
325
-
326
- const probe = fixture.invalid[0];
327
- const response = await fetch(`${process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80'}/${endpoint}`, {
328
- method: 'POST',
329
- headers: {
330
- 'Content-Type': 'application/json',
331
- Authorization: `Bearer ${authToken ?? ''}`,
332
- },
333
- body: JSON.stringify(probe),
334
- });
335
- const body = await response.json() as { status?: string; errorCode?: string };
336
- if (body.status === 'success') {
337
- return {
338
- passed: false,
339
- message: 'tenant.crossAccess',
340
- metadata: { probe },
341
- };
342
- }
343
- return { passed: true };
344
- },
345
- });
346
- ```
347
-
348
- ### Slack webhook reporter
349
-
350
- ```ts
351
- import { registerTestReporter } from '@luckystack/test-runner';
352
-
353
- registerTestReporter({
354
- webhookUrl: process.env.SLACK_WEBHOOK_URL,
355
- onSummary: async (summary) => {
356
- const url = process.env.SLACK_WEBHOOK_URL;
357
- if (!url) return;
358
- const text = summary.failed > 0
359
- ? `LuckyStack tests FAILED: ${summary.failed}/${summary.totalEndpoints} endpoints`
360
- : `LuckyStack tests passed: ${summary.passed}/${summary.totalEndpoints}`;
361
- await fetch(url, {
362
- method: 'POST',
363
- headers: { 'Content-Type': 'application/json' },
364
- body: JSON.stringify({ text }),
365
- });
366
- },
367
- });
368
- ```
369
-
370
- ### Buffering reporter that forwards built-in sweep results
371
-
372
- The built-in sweeps emit `ContractCheckResult`. The reporter sees `TestResult`. Bridge them in your aggregator:
373
-
374
- ```ts
375
- import {
376
- runContractTests,
377
- runAuthEnforcementTests,
378
- registerTestReporter,
379
- getTestReporter,
380
- } from '@luckystack/test-runner';
381
-
382
- const buffered: TestResult[] = [];
383
-
384
- registerTestReporter({
385
- onResult: (r) => buffered.push(r),
386
- onSummary: async (summary) => {
387
- console.log('Final summary:', summary);
388
- },
389
- });
390
-
391
- const forward = (layer: string) => (result: ContractCheckResult) => {
392
- getTestReporter()?.onResult?.({
393
- layer,
394
- endpoint: result.endpoint.fullPath,
395
- passed: result.status === 'pass',
396
- message: result.reason,
397
- durationMs: result.durationMs,
398
- metadata: {
399
- httpStatus: result.httpStatus,
400
- errorCode: result.errorCode,
401
- responseStatus: result.responseStatus,
402
- },
403
- });
404
- };
405
-
406
- const contract = await runContractTests({
407
- apiMethodMap,
408
- baseUrl,
409
- onResult: forward('contract'),
410
- });
411
- const auth = await runAuthEnforcementTests({
412
- apiMethodMap,
413
- apiMetaMap,
414
- baseUrl,
415
- onResult: forward('auth'),
416
- });
417
-
418
- await getTestReporter()?.onSummary?.({
419
- totalLayers: 2,
420
- totalEndpoints: contract.total + auth.total,
421
- passed: buffered.filter(r => r.passed).length,
422
- failed: buffered.filter(r => !r.passed).length,
423
- results: buffered,
424
- });
425
- ```
426
-
427
- ## Edge cases and gotchas
428
-
429
- - **Throwing inside `onResult` kills the sweep.** The built-in sweeps call `input.onResult?.(result)` without `tryCatch`. Wrap your callback in your own `tryCatch` if it can throw.
430
- - **Async `onResult` is fire-and-forget for built-in sweeps.** Returning a promise does not pause the iteration. Push into a buffer; await in `onSummary`.
431
- - **`registerTestReporter(null)` removes the reporter.** `getTestReporter()` returns `null` after that. There is no "remove specific callback" — the slot is whole-object replace-only.
432
- - **`registerTestLayer` with a duplicate name silently replaces** the previous layer. To verify, call `listTestLayers()` and check the length / `name`s before kicking off the sweep.
433
- - **`resetTestExtensionsForTests()` is destructive.** It clears layers, fixtures, AND the reporter. Use it in `beforeEach`-style hooks for the runner's own tests, never in production harnesses.
434
- - **Webhook failures.** The runner does not own the webhook POST. Your reporter implements it; handle 4xx/5xx and timeouts there. A webhook that throws while inside `onSummary` will bubble up to your aggregator.
435
- - **`TestResult.layer` is a string, not a union.** That keeps the registry permissive: built-in layers can also feed in (`'contract'`, `'auth'`, `'rate-limit'`, `'fuzz'`) plus any custom names you registered. Establish a project-wide convention for layer names so the reporter can route on them.
436
- - **No deregistration for fixtures.** `registerTestFixture(typeKey, { valid: [], invalid: [] })` is the closest you get; pass empty arrays to neutralize. `resetTestExtensionsForTests()` is the nuclear option.
437
- - **Layer `run` callbacks are not concurrent.** A consumer aggregator that iterates `listTestLayers()` per endpoint is expected to await each layer sequentially. If you want concurrency, write it explicitly in your aggregator (with `Promise.allSettled`) — the registry imposes no policy.
438
- - **`logContractResult` uses `console.log` only.** No file output, no log levels. To capture output, redirect stdout or write your own reporter on top of the result shape.
1
+ # Extension Hooks: Reporter, Layer and Fixture Registries
2
+
3
+ The four built-in layers (contract, auth, rate-limit, fuzz) cover the framework envelope and the security guards every LuckyStack project ships. The extension registry is where consumers add their own checks — CORS enforcement, multi-tenant isolation, business rules, GDPR data-flow checks — and pipe the combined results into Slack, a build dashboard, or any webhook. The hooks are intentionally narrow: three slots (layers, fixtures, reporters), each replace-by-key, all clearable from a single test-only function.
4
+
5
+ ## Slots
6
+
7
+ | Slot | Purpose | Cardinality |
8
+ | --- | --- | --- |
9
+ | Test layers | Additional per-endpoint checks. Each layer has a unique `name`. | Many. Replace-by-name. |
10
+ | Fixtures | Realistic valid/invalid payloads keyed by a type identifier. | Many. Replace-by-typeKey. |
11
+ | Reporter | Single sink for per-result and per-summary events plus optional webhook. | One. Replace by re-registering or unregister with `null`. |
12
+
13
+ The registries are in-memory and process-local. They survive across calls within one process and reset only via `resetTestExtensionsForTests()`. There is no persistence — registration happens at module load.
14
+
15
+ ## Functions
16
+
17
+ ### `logContractResult(result)`
18
+
19
+ Pretty-print one `ContractCheckResult` to stdout. Wire it as `onResult` in any of the four built-in sweeps to get live per-endpoint output.
20
+
21
+ Signature:
22
+
23
+ ```ts
24
+ logContractResult(result: ContractCheckResult): void
25
+ ```
26
+
27
+ Output format:
28
+
29
+ - Pass success: `[PASS] POST /api/billing/getInvoice/v1 12ms http=200`
30
+ - Pass typed error: `[PASS] POST /api/auth/login/v1 8ms http=200 (error: auth.required)`
31
+ - Skip: `[SKIP] POST /api/uploads/fileUpload/v1 0ms Explicitly skipped`
32
+ - Fail: `[FAIL] POST /api/billing/cancelSubscription/v1 14ms http=500 reason=fuzz payload produced 5xx: ...`
33
+
34
+ Status tag is one of `[PASS]`, `[FAIL]`, `[SKIP]`. `http=-` appears when no HTTP status is captured (network error, abort). `durationMs` is always present.
35
+
36
+ ### `logContractSummary(summary)`
37
+
38
+ Pretty-print one `RunContractSummary` to stdout. Call it once at the end of a sweep — typically after `logContractResult` has streamed the individual lines.
39
+
40
+ Signature:
41
+
42
+ ```ts
43
+ logContractSummary(summary: RunContractSummary): void
44
+ ```
45
+
46
+ Output:
47
+
48
+ ```
49
+ Contract tests: 47/50 passed, 2 failed, 1 skipped
50
+
51
+ Failures:
52
+ - /api/billing/cancelSubscription/v1: fuzz payload produced 5xx: { nested: ...
53
+ - /api/integrations/stripeWebhook/v1: Response missing standard `status` envelope
54
+ ```
55
+
56
+ The header line is always printed. The failures block is only printed when `summary.failed > 0`. Skipped endpoints are counted but not enumerated.
57
+
58
+ ### `registerTestLayer(layer)`
59
+
60
+ Add (or replace by name) a custom test layer in the registry.
61
+
62
+ Signature:
63
+
64
+ ```ts
65
+ registerTestLayer(layer: TestLayer): void
66
+
67
+ interface TestLayer {
68
+ name: string;
69
+ run: (input: TestLayerInput) => Promise<TestLayerResult> | TestLayerResult;
70
+ }
71
+
72
+ interface TestLayerInput {
73
+ endpoint: string; // resolved route, e.g. 'api/billing/getInvoice/v1'
74
+ method?: string; // HTTP method, undefined for sync endpoints
75
+ authToken?: string; // bearer/session token for auth-aware checks
76
+ }
77
+
78
+ interface TestLayerResult {
79
+ passed: boolean;
80
+ message?: string; // short reason key, i18n-style on failure
81
+ metadata?: Record<string, unknown>;
82
+ }
83
+ ```
84
+
85
+ Replace-by-name: registering a layer with a name that already exists overwrites the previous entry. Use this to update a layer mid-run, e.g. after fetching a fresh auth token.
86
+
87
+ The runner does **not** currently wire registered layers into the built-in sweeps automatically. Today they live in the registry as a coordination surface for custom test harnesses — read them with `listTestLayers()`, iterate `walkEndpoints`, and call `layer.run(input)` per endpoint. The roadmap is for a future `runRegisteredLayers({ apiMethodMap, baseUrl })` entry point to walk the registry as a fifth (or N-th) built-in layer.
88
+
89
+ ### `listTestLayers()`
90
+
91
+ Return the current registry snapshot.
92
+
93
+ Signature:
94
+
95
+ ```ts
96
+ listTestLayers(): TestLayer[]
97
+ ```
98
+
99
+ Order is registration order (Map iteration order). The returned array is a fresh copy — mutating it does not affect the registry.
100
+
101
+ ### `registerTestReporter(reporter)`
102
+
103
+ Set the reporter slot, or clear it by passing `null`.
104
+
105
+ Signature:
106
+
107
+ ```ts
108
+ registerTestReporter(reporter: TestReporter | null): void
109
+
110
+ interface TestReporter {
111
+ onResult?: (result: TestResult) => void | Promise<void>;
112
+ onSummary?: (summary: TestSummary) => void | Promise<void>;
113
+ webhookUrl?: string;
114
+ webhookAuth?: { type: 'bearer'; token: string };
115
+ }
116
+
117
+ interface TestResult {
118
+ layer: string;
119
+ endpoint: string;
120
+ passed: boolean;
121
+ message?: string;
122
+ durationMs?: number;
123
+ metadata?: Record<string, unknown>;
124
+ }
125
+
126
+ interface TestSummary {
127
+ totalLayers: number;
128
+ totalEndpoints: number;
129
+ passed: number;
130
+ failed: number;
131
+ results: TestResult[];
132
+ }
133
+ ```
134
+
135
+ Replace-by-overwrite: only one reporter is active. To compose multiple sinks, write a single reporter that fans out internally.
136
+
137
+ Like layers, the reporter is a coordination surface today: the built-in sweeps emit `ContractCheckResult` shapes, not `TestResult`. The registered reporter is intended for the cross-layer aggregator that lives in your test harness — collect results from each built-in sweep, map them into `TestResult`, and emit through this reporter so all sinks see one stream.
138
+
139
+ ### `getTestReporter()`
140
+
141
+ Read the active reporter or `null` if none is registered.
142
+
143
+ Signature:
144
+
145
+ ```ts
146
+ getTestReporter(): TestReporter | null
147
+ ```
148
+
149
+ ### `resetTestExtensionsForTests()`
150
+
151
+ Clear all three registries (layers, fixtures, reporter). Intended for the runner's own internal tests — calling it from a production test harness will wipe everything other modules registered at load time, which usually breaks fixture-dependent layers.
152
+
153
+ Signature:
154
+
155
+ ```ts
156
+ resetTestExtensionsForTests(): void
157
+ ```
158
+
159
+ The name advertises the intent: do not import this from production paths. There is no convenience "clear only layers" — by design, to keep the surface small.
160
+
161
+ ## Webhook contract
162
+
163
+ When `reporter.webhookUrl` is set, the harness that drives the reporter is expected to POST the JSON-serialised `TestSummary` to that URL at the end of the sweep. The runner's own built-in sweeps do not call the webhook themselves — that's the job of the consumer's aggregator. Recommended client behavior:
164
+
165
+ - **Method:** `POST`.
166
+ - **Headers:** `Content-Type: application/json`, plus `Authorization: Bearer <token>` when `webhookAuth?.type === 'bearer'`.
167
+ - **Body:** `JSON.stringify(summary)`. `TestSummary.results` can be large — clip or compress on the producer side if the receiver has body limits.
168
+ - **Timeout:** wrap the POST in `tryCatch` with a sensible per-call timeout (e.g. 5s). A webhook failure should not fail the sweep.
169
+ - **Retries:** none by default. Add idempotency at the receiver if you want retries; the runner is a one-shot test driver, not a queue.
170
+
171
+ Concrete example of a webhook-emitting reporter:
172
+
173
+ ```ts
174
+ import { registerTestReporter } from '@luckystack/test-runner';
175
+
176
+ registerTestReporter({
177
+ webhookUrl: 'https://hooks.slack.com/services/T000/B000/XXX',
178
+ onSummary: async (summary) => {
179
+ const url = 'https://hooks.slack.com/services/T000/B000/XXX';
180
+ await fetch(url, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({
184
+ text: `LuckyStack tests: ${summary.passed}/${summary.totalEndpoints} passed`,
185
+ }),
186
+ });
187
+ },
188
+ });
189
+ ```
190
+
191
+ ## Lifecycle and error isolation
192
+
193
+ - **Reporter callbacks** (`onResult`, `onSummary`) and **layer callbacks** (`run`) execute inline on the caller's task. The runner does not wrap them in `tryCatch`. A throw propagates out of the sweep and skips subsequent endpoints — handle errors inside your callback if you want isolation.
194
+ - **Async callbacks** are NOT awaited by the built-in sweeps' `onResult` plumbing. If you do async work, push results into an in-memory buffer and flush in `onSummary` (which the aggregator can await).
195
+ - **Order:** `onResult` fires per individual result in sweep order; `onSummary` fires exactly once at the end of the aggregator's run. Built-in sweeps emit `ContractCheckResult` through their own `onResult` parameter — the cross-layer reporter sees `TestResult` only when your aggregator forwards mapped results to it.
196
+
197
+ ## Types
198
+
199
+ ```ts
200
+ interface TestLayer {
201
+ name: string;
202
+ run: (input: TestLayerInput) => Promise<TestLayerResult> | TestLayerResult;
203
+ }
204
+
205
+ interface TestLayerInput {
206
+ endpoint: string;
207
+ method?: string;
208
+ authToken?: string;
209
+ }
210
+
211
+ interface TestLayerResult {
212
+ passed: boolean;
213
+ message?: string;
214
+ metadata?: Record<string, unknown>;
215
+ }
216
+
217
+ interface TestFixture<TPayload = unknown> {
218
+ valid: TPayload[];
219
+ invalid: TPayload[];
220
+ }
221
+
222
+ interface TestResult {
223
+ layer: string;
224
+ endpoint: string;
225
+ passed: boolean;
226
+ message?: string;
227
+ durationMs?: number;
228
+ metadata?: Record<string, unknown>;
229
+ }
230
+
231
+ interface TestSummary {
232
+ totalLayers: number;
233
+ totalEndpoints: number;
234
+ passed: number;
235
+ failed: number;
236
+ results: TestResult[];
237
+ }
238
+
239
+ interface TestReporter {
240
+ onResult?: (result: TestResult) => void | Promise<void>;
241
+ onSummary?: (summary: TestSummary) => void | Promise<void>;
242
+ webhookUrl?: string;
243
+ webhookAuth?: { type: 'bearer'; token: string };
244
+ }
245
+ ```
246
+
247
+ `ContractCheckResult` and `RunContractSummary` (the shapes emitted by the four built-in sweeps) live in `contract-tests.md`.
248
+
249
+ ## Examples
250
+
251
+ ### Live progress with the built-in pretty printer
252
+
253
+ ```ts
254
+ import {
255
+ runContractTests,
256
+ runFuzzTests,
257
+ logContractResult,
258
+ logContractSummary,
259
+ } from '@luckystack/test-runner';
260
+
261
+ const contract = await runContractTests({
262
+ apiMethodMap,
263
+ baseUrl,
264
+ onResult: logContractResult,
265
+ });
266
+ logContractSummary(contract);
267
+
268
+ const fuzz = await runFuzzTests({
269
+ apiMethodMap,
270
+ baseUrl,
271
+ onResult: logContractResult,
272
+ });
273
+ logContractSummary(fuzz);
274
+ ```
275
+
276
+ ### Custom CORS-enforcement layer
277
+
278
+ ```ts
279
+ import { registerTestLayer } from '@luckystack/test-runner';
280
+
281
+ registerTestLayer({
282
+ name: 'cors',
283
+ run: async ({ endpoint }) => {
284
+ const response = await fetch(`${process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80'}/${endpoint}`, {
285
+ method: 'OPTIONS',
286
+ headers: {
287
+ Origin: 'https://evil.example.com',
288
+ 'Access-Control-Request-Method': 'POST',
289
+ },
290
+ });
291
+ const allowed = response.headers.get('Access-Control-Allow-Origin');
292
+ if (allowed === '*' || allowed === 'https://evil.example.com') {
293
+ return {
294
+ passed: false,
295
+ message: 'cors.openOrigin',
296
+ metadata: { allowed },
297
+ };
298
+ }
299
+ return { passed: true };
300
+ },
301
+ });
302
+ ```
303
+
304
+ A test harness then walks `listTestLayers()` per endpoint and aggregates results through the registered reporter.
305
+
306
+ ### Multi-tenant isolation layer (combining fixtures and layers)
307
+
308
+ ```ts
309
+ import {
310
+ registerTestFixture,
311
+ registerTestLayer,
312
+ getTestFixture,
313
+ } from '@luckystack/test-runner';
314
+
315
+ registerTestFixture('api/billing/getInvoice/v1', {
316
+ valid: [{ invoiceId: 'inv_tenant_a' }],
317
+ invalid: [{ invoiceId: 'inv_tenant_b_owned_by_other_tenant' }],
318
+ });
319
+
320
+ registerTestLayer({
321
+ name: 'multi-tenant-isolation',
322
+ run: async ({ endpoint, authToken }) => {
323
+ const fixture = getTestFixture(endpoint);
324
+ if (!fixture || fixture.invalid.length === 0) return { passed: true };
325
+
326
+ const probe = fixture.invalid[0];
327
+ const response = await fetch(`${process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80'}/${endpoint}`, {
328
+ method: 'POST',
329
+ headers: {
330
+ 'Content-Type': 'application/json',
331
+ Authorization: `Bearer ${authToken ?? ''}`,
332
+ },
333
+ body: JSON.stringify(probe),
334
+ });
335
+ const body = await response.json() as { status?: string; errorCode?: string };
336
+ if (body.status === 'success') {
337
+ return {
338
+ passed: false,
339
+ message: 'tenant.crossAccess',
340
+ metadata: { probe },
341
+ };
342
+ }
343
+ return { passed: true };
344
+ },
345
+ });
346
+ ```
347
+
348
+ ### Slack webhook reporter
349
+
350
+ ```ts
351
+ import { registerTestReporter } from '@luckystack/test-runner';
352
+
353
+ registerTestReporter({
354
+ webhookUrl: process.env.SLACK_WEBHOOK_URL,
355
+ onSummary: async (summary) => {
356
+ const url = process.env.SLACK_WEBHOOK_URL;
357
+ if (!url) return;
358
+ const text = summary.failed > 0
359
+ ? `LuckyStack tests FAILED: ${summary.failed}/${summary.totalEndpoints} endpoints`
360
+ : `LuckyStack tests passed: ${summary.passed}/${summary.totalEndpoints}`;
361
+ await fetch(url, {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({ text }),
365
+ });
366
+ },
367
+ });
368
+ ```
369
+
370
+ ### Buffering reporter that forwards built-in sweep results
371
+
372
+ The built-in sweeps emit `ContractCheckResult`. The reporter sees `TestResult`. Bridge them in your aggregator:
373
+
374
+ ```ts
375
+ import {
376
+ runContractTests,
377
+ runAuthEnforcementTests,
378
+ registerTestReporter,
379
+ getTestReporter,
380
+ } from '@luckystack/test-runner';
381
+
382
+ const buffered: TestResult[] = [];
383
+
384
+ registerTestReporter({
385
+ onResult: (r) => buffered.push(r),
386
+ onSummary: async (summary) => {
387
+ console.log('Final summary:', summary);
388
+ },
389
+ });
390
+
391
+ const forward = (layer: string) => (result: ContractCheckResult) => {
392
+ getTestReporter()?.onResult?.({
393
+ layer,
394
+ endpoint: result.endpoint.fullPath,
395
+ passed: result.status === 'pass',
396
+ message: result.reason,
397
+ durationMs: result.durationMs,
398
+ metadata: {
399
+ httpStatus: result.httpStatus,
400
+ errorCode: result.errorCode,
401
+ responseStatus: result.responseStatus,
402
+ },
403
+ });
404
+ };
405
+
406
+ const contract = await runContractTests({
407
+ apiMethodMap,
408
+ baseUrl,
409
+ onResult: forward('contract'),
410
+ });
411
+ const auth = await runAuthEnforcementTests({
412
+ apiMethodMap,
413
+ apiMetaMap,
414
+ baseUrl,
415
+ onResult: forward('auth'),
416
+ });
417
+
418
+ await getTestReporter()?.onSummary?.({
419
+ totalLayers: 2,
420
+ totalEndpoints: contract.total + auth.total,
421
+ passed: buffered.filter(r => r.passed).length,
422
+ failed: buffered.filter(r => !r.passed).length,
423
+ results: buffered,
424
+ });
425
+ ```
426
+
427
+ ## Edge cases and gotchas
428
+
429
+ - **Throwing inside `onResult` kills the sweep.** The built-in sweeps call `input.onResult?.(result)` without `tryCatch`. Wrap your callback in your own `tryCatch` if it can throw.
430
+ - **Async `onResult` is fire-and-forget for built-in sweeps.** Returning a promise does not pause the iteration. Push into a buffer; await in `onSummary`.
431
+ - **`registerTestReporter(null)` removes the reporter.** `getTestReporter()` returns `null` after that. There is no "remove specific callback" — the slot is whole-object replace-only.
432
+ - **`registerTestLayer` with a duplicate name silently replaces** the previous layer. To verify, call `listTestLayers()` and check the length / `name`s before kicking off the sweep.
433
+ - **`resetTestExtensionsForTests()` is destructive.** It clears layers, fixtures, AND the reporter. Use it in `beforeEach`-style hooks for the runner's own tests, never in production harnesses.
434
+ - **Webhook failures.** The runner does not own the webhook POST. Your reporter implements it; handle 4xx/5xx and timeouts there. A webhook that throws while inside `onSummary` will bubble up to your aggregator.
435
+ - **`TestResult.layer` is a string, not a union.** That keeps the registry permissive: built-in layers can also feed in (`'contract'`, `'auth'`, `'rate-limit'`, `'fuzz'`) plus any custom names you registered. Establish a project-wide convention for layer names so the reporter can route on them.
436
+ - **No deregistration for fixtures.** `registerTestFixture(typeKey, { valid: [], invalid: [] })` is the closest you get; pass empty arrays to neutralize. `resetTestExtensionsForTests()` is the nuclear option.
437
+ - **Layer `run` callbacks are not concurrent.** A consumer aggregator that iterates `listTestLayers()` per endpoint is expected to await each layer sequentially. If you want concurrency, write it explicitly in your aggregator (with `Promise.allSettled`) — the registry imposes no policy.
438
+ - **`logContractResult` uses `console.log` only.** No file output, no log levels. To capture output, redirect stdout or write your own reporter on top of the result shape.