@luckystack/test-runner 0.1.0

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.
@@ -0,0 +1,259 @@
1
+ # Contract Tests
2
+
3
+ The contract layer is the entry point of `@luckystack/test-runner`. It walks every endpoint that the devkit emits into `apiMethodMap.generated.ts`, fires a single well-formed request, and asserts the response conforms to the framework envelope. It is the cheapest and broadest of the four layers — it does NOT prove business logic, it proves the route exists, decodes input without crashing, and replies with `{ status: 'success' | 'error', errorCode? }`.
4
+
5
+ ## When to use it
6
+
7
+ - CI on every PR: catches `_api/<page>/<name>_v<n>.ts` files that have a typo in `main`, a broken import, or a renamed Zod field.
8
+ - After regenerating the type map (`npx @luckystack/devkit gen`): confirms the routing table the runtime resolves still matches the file system.
9
+ - During refactors of `@luckystack/api` request handling: contract failures here are a fast signal that the envelope shape regressed.
10
+
11
+ ## Functions
12
+
13
+ ### `walkEndpoints(apiMethodMap)`
14
+
15
+ Flatten the three-level generated map (`page -> name -> version -> method`) into a flat list of endpoint descriptors. Used internally by every `run*Tests` sweep and exported so consumers can build custom layers on top of the same iteration order.
16
+
17
+ Signature:
18
+
19
+ ```ts
20
+ walkEndpoints(apiMethodMap: ApiMethodMap): EndpointDescriptor[]
21
+
22
+ type ApiMethodMap = Record<string, Record<string, Record<string, string>>>;
23
+ ```
24
+
25
+ The order is deterministic: it follows `Object.entries` over the generated map, which the devkit emits sorted by page name, then api name, then version. Empty maps return `[]` (not an error).
26
+
27
+ ### `runContractCheck(input)`
28
+
29
+ Run the contract assertion against one endpoint.
30
+
31
+ Signature:
32
+
33
+ ```ts
34
+ runContractCheck(input: ContractCheckInput): Promise<ContractCheckResult>
35
+
36
+ interface ContractCheckInput {
37
+ endpoint: EndpointDescriptor;
38
+ baseUrl: string;
39
+ inputFor?: (endpoint: EndpointDescriptor) => unknown;
40
+ headers?: Record<string, string>;
41
+ requestTimeoutMs?: number; // default 5000
42
+ }
43
+ ```
44
+
45
+ Flow:
46
+
47
+ 1. Build URL: `${baseUrl.replace(/\/$/, '')}/${endpoint.fullPath}` so trailing slashes on `baseUrl` are tolerated.
48
+ 2. Build body: `inputFor?.(endpoint) ?? {}`. For `GET` the body is dropped entirely; otherwise it is `JSON.stringify`d.
49
+ 3. Send the request with `AbortController` wired to `requestTimeoutMs`. A timeout surfaces as a `fetch` error rather than hanging the sweep.
50
+ 4. Parse the JSON body. Any parse error is a contract failure (the framework always returns JSON).
51
+ 5. Assert `parsed.status` is exactly `'success'` or `'error'`. Anything else (`null`, `undefined`, missing key, a custom shape) fails with `reason: 'Response missing standard `status` envelope'`.
52
+ 6. If `parsed.status === 'error'`, assert `parsed.errorCode` is truthy. Missing `errorCode` on an error response is a contract violation — every framework error path is required to set one.
53
+
54
+ Pass criterion: a response that round-trips through the framework envelope, regardless of whether it's success or a typed error.
55
+
56
+ ### `runContractTests(input)`
57
+
58
+ Full sweep version of `runContractCheck`. Iterates `walkEndpoints(input.apiMethodMap)` serially and aggregates results.
59
+
60
+ Signature:
61
+
62
+ ```ts
63
+ runContractTests(input: RunContractTestsInput): Promise<RunContractSummary>
64
+
65
+ interface RunContractTestsInput {
66
+ apiMethodMap: ApiMethodMap;
67
+ baseUrl: string;
68
+ skip?: string[];
69
+ inputFor?: (endpoint: EndpointDescriptor) => unknown;
70
+ headers?: Record<string, string>;
71
+ onResult?: (result: ContractCheckResult) => void;
72
+ }
73
+ ```
74
+
75
+ Behavior:
76
+
77
+ - **Serial, not parallel.** The runner walks endpoints one by one. This avoids tripping shared rate-limit buckets, keeps log output linear, and stops endpoint-ordering bugs from being masked by races. A pool option may land later; do not assume concurrency.
78
+ - **`skip` matching is two-tier.** `skip: ['billing/getInvoice']` skips every version of that endpoint. `skip: ['billing/getInvoice/v2']` skips only v2. Both forms compose: pass both to skip every version AND keep the specific one explicit.
79
+ - **`onResult` is fired after every endpoint** including skipped ones, so progress reporters update in real time. Throwing inside `onResult` propagates out of the sweep — wrap your own handler in `tryCatch` if you want it isolated.
80
+ - **Skipped results carry `status: 'skipped'` with `reason: 'Explicitly skipped'`** and `durationMs: 0`. They count toward `summary.skipped`, not `passed` or `failed`.
81
+
82
+ Returned summary:
83
+
84
+ ```ts
85
+ interface RunContractSummary {
86
+ total: number; // results.length, including skipped
87
+ passed: number;
88
+ failed: number;
89
+ skipped: number;
90
+ results: ContractCheckResult[];
91
+ }
92
+ ```
93
+
94
+ ### `sampleSchemaInput(schema)`
95
+
96
+ Walk a Zod schema and produce a deterministic minimal-valid value. The contract layer does not call this automatically — consumers use it inside a custom `inputFor` when an endpoint's Zod validator rejects an empty object.
97
+
98
+ Signature:
99
+
100
+ ```ts
101
+ sampleSchemaInput(schema: z.ZodTypeAny): unknown
102
+ ```
103
+
104
+ Type-to-sample table:
105
+
106
+ | Zod node | Sample |
107
+ | --- | --- |
108
+ | `ZodString` | `'test'` |
109
+ | `ZodNumber` | `0` |
110
+ | `ZodBoolean` | `false` |
111
+ | `ZodNull` | `null` |
112
+ | `ZodUndefined` | `undefined` |
113
+ | `ZodAny` / `ZodUnknown` | `null` |
114
+ | `ZodLiteral` | the literal value |
115
+ | `ZodOptional` / `ZodNullable` | `undefined` (field omitted in objects) |
116
+ | `ZodUnion` | first option, recursively sampled |
117
+ | `ZodArray` | `[]` |
118
+ | `ZodObject` | object with every non-`undefined` child sampled |
119
+ | `ZodRecord` | `{}` |
120
+ | `ZodDate` | `new Date().toISOString()` |
121
+ | anything else | `null` |
122
+
123
+ `ZodOptional`/`ZodNullable` returning `undefined` is deliberate — the object branch drops keys whose sample is `undefined`, so optional fields stay absent rather than being sent as `null`. The result is the smallest payload that the validator will accept.
124
+
125
+ This is NOT a property-based generator. It always returns the same value for the same schema. Swap in `fast-check` if you need randomized fuzz that respects the schema.
126
+
127
+ ## Types
128
+
129
+ ```ts
130
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
131
+
132
+ interface EndpointDescriptor {
133
+ page: string;
134
+ name: string;
135
+ version: string;
136
+ method: HttpMethod;
137
+ fullPath: string; // `api/<page>/<name>/<version>`
138
+ }
139
+
140
+ interface ContractCheckResult {
141
+ endpoint: EndpointDescriptor;
142
+ status: 'pass' | 'fail' | 'skipped';
143
+ httpStatus?: number;
144
+ responseStatus?: 'success' | 'error' | 'unknown';
145
+ errorCode?: string;
146
+ reason?: string;
147
+ durationMs: number;
148
+ }
149
+
150
+ interface RunContractSummary {
151
+ total: number;
152
+ passed: number;
153
+ failed: number;
154
+ skipped: number;
155
+ results: ContractCheckResult[];
156
+ }
157
+ ```
158
+
159
+ `responseStatus: 'unknown'` only appears on failed results when the JSON either failed to parse or did not carry a `status` field. `errorCode` is set on pass results when `responseStatus === 'error'` and on fail results when the auth/rate-limit layers' `errorCode` mismatch path runs (contract layer never sets it on fail).
160
+
161
+ ## Result shapes by branch
162
+
163
+ - **Pass / success**: `{ status: 'pass', httpStatus, responseStatus: 'success', errorCode: undefined, durationMs }`.
164
+ - **Pass / typed error**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: '<code>', durationMs }`. A typed framework error still satisfies the contract.
165
+ - **Fail / fetch error**: `{ status: 'fail', reason: <Error.message>, durationMs }`. No `httpStatus`. Network refused, DNS failure, or `AbortController` firing all land here.
166
+ - **Fail / JSON parse**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'JSON parse failed: ...', durationMs }`.
167
+ - **Fail / missing envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
168
+ - **Fail / error without code**: `{ status: 'fail', httpStatus, responseStatus: 'error', reason: 'Error response missing `errorCode`', durationMs }`.
169
+ - **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
170
+
171
+ ## Examples
172
+
173
+ ### CI sweep with default inputs
174
+
175
+ ```ts
176
+ import { apiMethodMap } from './generated/apiMethodMap.generated';
177
+ import {
178
+ runContractTests,
179
+ logContractResult,
180
+ logContractSummary,
181
+ } from '@luckystack/test-runner';
182
+
183
+ const summary = await runContractTests({
184
+ apiMethodMap,
185
+ baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
186
+ onResult: logContractResult,
187
+ });
188
+
189
+ logContractSummary(summary);
190
+
191
+ if (summary.failed > 0) {
192
+ process.exit(1);
193
+ }
194
+ ```
195
+
196
+ ### Endpoint-specific input via `inputFor`
197
+
198
+ Most validators reject `{}`. Use `inputFor` to return a shape the validator will accept for the specific endpoints that need it; fall through to `{}` for everything else.
199
+
200
+ ```ts
201
+ import { sampleSchemaInput } from '@luckystack/test-runner';
202
+ import { sendInviteSchema } from 'src/organization-settings/_api/sendInvite_v1';
203
+
204
+ await runContractTests({
205
+ apiMethodMap,
206
+ baseUrl,
207
+ inputFor: (endpoint) => {
208
+ if (endpoint.fullPath === 'api/organization-settings/sendInvite/v1') {
209
+ return sampleSchemaInput(sendInviteSchema);
210
+ }
211
+ return {}; // default
212
+ },
213
+ });
214
+ ```
215
+
216
+ ### Single-endpoint debug loop
217
+
218
+ ```ts
219
+ import { runContractCheck, logContractResult } from '@luckystack/test-runner';
220
+
221
+ const result = await runContractCheck({
222
+ endpoint: {
223
+ page: 'billing',
224
+ name: 'getInvoice',
225
+ version: 'v1',
226
+ method: 'POST',
227
+ fullPath: 'api/billing/getInvoice/v1',
228
+ },
229
+ baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
230
+ inputFor: () => ({ invoiceId: 'inv_1' }),
231
+ headers: { Cookie: 'session=abc' },
232
+ requestTimeoutMs: 15000, // bump for slow endpoints
233
+ });
234
+
235
+ logContractResult(result);
236
+ ```
237
+
238
+ ### Skip noisy endpoints
239
+
240
+ ```ts
241
+ await runContractTests({
242
+ apiMethodMap,
243
+ baseUrl,
244
+ skip: [
245
+ 'uploads/fileUpload', // needs multipart, not JSON
246
+ 'billing/getInvoice/v1', // deprecated, kept for backwards compat
247
+ ],
248
+ });
249
+ ```
250
+
251
+ ## Edge cases and gotchas
252
+
253
+ - **`GET` endpoints ignore `inputFor`.** The handler still calls `inputFor`, but the body is dropped before `fetch` sees it. Query-string fuzz is not in scope for the contract layer.
254
+ - **`headers` are merged after `Content-Type: application/json`.** If you pass `Content-Type` yourself you will override the default; the framework parses based on the header so this is intentional.
255
+ - **`baseUrl` trailing slash is normalized once.** `http://localhost/` and `http://localhost` produce identical requests. Embedded `//` further down the path is not collapsed.
256
+ - **`requestTimeoutMs` is per-request, not per-sweep.** A 30-endpoint sweep with the default 5000ms timeout can run for over two minutes if every endpoint stalls. The sweep itself has no overall timeout.
257
+ - **An endpoint that returns `204 No Content`** fails the contract because there is no JSON to parse. The framework never emits 204 from `_api/*`; this only happens if you've added a custom route handler that intercepts the path.
258
+ - **A response with `status: 'success'` and an extra `errorCode`** still passes. The contract layer only validates the envelope keys it cares about; unknown extras are ignored.
259
+ - **Endpoints absent from `apiMetaMap` are still walked.** The contract layer never reads `apiMetaMap`. Auth and rate-limit layers do — see those docs.
@@ -0,0 +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.