@gcoredev/fastedge-test 0.1.7 → 0.2.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.
Files changed (40) hide show
  1. package/dist/frontend/assets/{index-BCXfEMSq.js → index-CiqeJ9rz.js} +24 -24
  2. package/dist/frontend/index.html +1 -1
  3. package/dist/lib/index.cjs +130 -62
  4. package/dist/lib/index.d.ts +1 -0
  5. package/dist/lib/index.js +130 -62
  6. package/dist/lib/runner/HeaderManager.d.ts +6 -4
  7. package/dist/lib/runner/HostFunctions.d.ts +5 -5
  8. package/dist/lib/runner/HttpWasmRunner.d.ts +13 -4
  9. package/dist/lib/runner/IStateManager.d.ts +7 -7
  10. package/dist/lib/runner/IWasmRunner.d.ts +17 -9
  11. package/dist/lib/runner/PropertyResolver.d.ts +3 -3
  12. package/dist/lib/runner/ProxyWasmRunner.d.ts +5 -2
  13. package/dist/lib/runner/standalone.d.ts +1 -1
  14. package/dist/lib/runner/types.d.ts +17 -8
  15. package/dist/lib/schemas/api.d.ts +0 -8
  16. package/dist/lib/schemas/config.d.ts +0 -13
  17. package/dist/lib/schemas/index.d.ts +2 -2
  18. package/dist/lib/test-framework/assertions.d.ts +18 -4
  19. package/dist/lib/test-framework/index.cjs +18593 -111
  20. package/dist/lib/test-framework/index.d.ts +2 -0
  21. package/dist/lib/test-framework/index.js +18610 -100
  22. package/dist/lib/test-framework/mock-origins.d.ts +56 -0
  23. package/dist/lib/test-framework/types.d.ts +1 -5
  24. package/dist/server.js +33 -33
  25. package/docs/API.md +19 -49
  26. package/docs/DEBUGGER.md +6 -7
  27. package/docs/INDEX.md +4 -1
  28. package/docs/RUNNER.md +96 -81
  29. package/docs/TEST_CONFIG.md +9 -22
  30. package/docs/TEST_FRAMEWORK.md +206 -31
  31. package/docs/WEBSOCKET.md +25 -21
  32. package/docs/quickstart.md +1 -13
  33. package/package.json +4 -1
  34. package/schemas/api-config.schema.json +0 -24
  35. package/schemas/api-send.schema.json +0 -20
  36. package/schemas/fastedge-config.test.schema.json +0 -24
  37. package/schemas/full-flow-result.schema.json +17 -7
  38. package/schemas/hook-call.schema.json +16 -6
  39. package/schemas/hook-result.schema.json +16 -6
  40. package/schemas/http-response.schema.json +227 -5
@@ -1,6 +1,6 @@
1
1
  # Test Configuration
2
2
 
3
- Configuration file reference for `fastedge-config.test.json` — the per-test JSON file that defines the WASM binary, request, response, CDN properties, and environment variable loading for a single test scenario.
3
+ Configuration file reference for `fastedge-config.test.json` — the per-test JSON file that defines the WASM binary, request, CDN properties, and environment variable loading for a single test scenario.
4
4
 
5
5
  ## Overview
6
6
 
@@ -8,8 +8,8 @@ Each test scenario is described by a `fastedge-config.test.json` file. The file
8
8
 
9
9
  The config schema is a union of two variants selected by `appType`:
10
10
 
11
- - **`proxy-wasm`** (CDN mode, default): The WASM module intercepts an upstream HTTP request. Uses `request.url` (full URL). Supports a mock origin `response`.
12
- - **`http-wasm`**: The WASM module acts as an origin HTTP server. Uses `request.path` (path only). No `response` field.
11
+ - **`proxy-wasm`** (CDN mode, default): The WASM module intercepts an upstream HTTP request. Uses `request.url` (full URL). The upstream response is generated at runtime — either by a real fetch against `request.url`, or by the built-in responder when `request.url === "built-in"`.
12
+ - **`http-wasm`**: The WASM module acts as an origin HTTP server. Uses `request.path` (path only).
13
13
 
14
14
  **Required fields** (per JSON Schema `required` arrays):
15
15
  - Top-level: `properties`, `appType`, and `request`
@@ -36,9 +36,6 @@ The config schema is a union of two variants selected by `appType`:
36
36
  | `request.path` | `string` | **Yes** (HTTP-WASM only) | — | Request path (e.g. `"/api/submit"`). HTTP-WASM mode only. The WASM module acts as the origin server. |
37
37
  | `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. |
38
38
  | `request.body` | `string` | Yes (schema) / runtime default | `""` | Request body as a plain string. Use an empty string for requests with no body. |
39
- | `response` | `object` | No | — | Mock origin response for CDN mode. Not applicable to HTTP-WASM. |
40
- | `response.headers` | `object` | Yes (if `response` present) | `{}` | Key/value map of mock origin response headers. |
41
- | `response.body` | `string` | Yes (if `response` present) | `""` | Mock origin response body as a plain string. |
42
39
  | `properties` | `object` | **Yes** (schema) / runtime default | `{}` | CDN property key/value pairs passed to the WASM execution context. Values may be any JSON type. |
43
40
  | `dotenv` | `object` | No | — | Dotenv file loading configuration. |
44
41
  | `dotenv.enabled` | `boolean` | No | — | Whether to load a `.env` file before execution. |
@@ -203,37 +200,32 @@ An HTTP-WASM scenario simulating a `POST` request with a JSON body. `appType` mu
203
200
  }
204
201
  ```
205
202
 
206
- ### Custom Origin Response
203
+ ### Built-In Responder (No Network)
207
204
 
208
- A CDN scenario where the mock origin returns a specific response. Use this to test how the WASM handler transforms or conditionally passes through origin responses.
205
+ A CDN scenario that uses the test runner's built-in responder instead of reaching out to a real origin. Set `request.url` to `"built-in"` the runner generates a local response and skips the upstream fetch. This is the fastest way to exercise a CDN WASM app without depending on network reachability.
209
206
 
210
207
  ```json
211
208
  {
212
209
  "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json",
213
- "description": "CDN handler with custom mock origin response",
210
+ "description": "CDN handler against the built-in responder",
214
211
  "appType": "proxy-wasm",
215
212
  "wasm": {
216
213
  "path": "./dist/handler.wasm"
217
214
  },
218
215
  "request": {
219
216
  "method": "GET",
220
- "url": "https://example.com/cached-resource",
217
+ "url": "built-in",
221
218
  "headers": {},
222
219
  "body": ""
223
220
  },
224
- "response": {
225
- "headers": {
226
- "content-type": "application/json",
227
- "cache-control": "max-age=86400"
228
- },
229
- "body": "{\"status\": \"ok\", \"data\": []}"
230
- },
231
221
  "properties": {
232
222
  "CACHE_TTL": 86400
233
223
  }
234
224
  }
235
225
  ```
236
226
 
227
+ The default built-in response is a full JSON echo of the request. Override via control headers on the request: `x-debugger-status` sets the HTTP status; `x-debugger-content: status-only` omits the body; `x-debugger-content: body-only` echoes only the request body (with its inferred content-type).
228
+
237
229
  ## IDE Integration
238
230
 
239
231
  Adding `$schema` to your config file enables JSON Schema validation and autocompletion in VSCode and any editor that supports the JSON Language Server.
@@ -290,10 +282,6 @@ type CdnConfig = {
290
282
  headers: Record<string, string>; // default: {}
291
283
  body: string; // default: ""
292
284
  };
293
- response?: {
294
- headers: Record<string, string>; // default: {}
295
- body: string; // default: ""
296
- };
297
285
  properties: Record<string, unknown>; // default: {}
298
286
  dotenv?: {
299
287
  enabled?: boolean;
@@ -333,7 +321,6 @@ const config = await loadConfigFile("./fastedge-config.test.json");
333
321
 
334
322
  if (config.appType === "proxy-wasm") {
335
323
  console.log(config.request.url); // string — CDN full URL
336
- console.log(config.response); // ResponseConfig | undefined
337
324
  } else {
338
325
  console.log(config.request.path); // string — HTTP-WASM path
339
326
  console.log(config.httpPort); // number | undefined
@@ -1,6 +1,6 @@
1
1
  # Test Framework API
2
2
 
3
- High-level API for defining and running proxy-wasm test suites with `@gcoredev/fastedge-test`.
3
+ High-level test framework API for defining and running WASM test suites with `@gcoredev/fastedge-test`.
4
4
 
5
5
  ## Import
6
6
 
@@ -12,6 +12,7 @@ import {
12
12
  runFlow,
13
13
  runHttpRequest,
14
14
  loadConfigFile,
15
+ mockOrigins,
15
16
  assertRequestHeader,
16
17
  assertNoRequestHeader,
17
18
  assertResponseHeader,
@@ -44,6 +45,8 @@ import type {
44
45
  FlowOptions,
45
46
  HttpRequestOptions,
46
47
  RunnerConfig,
48
+ MockOriginsHandle,
49
+ MockOriginsOptions,
47
50
  } from "@gcoredev/fastedge-test/test";
48
51
  ```
49
52
 
@@ -111,10 +114,6 @@ interface FlowOptions {
111
114
  method?: string; // Default: "GET"
112
115
  requestHeaders?: Record<string, string>;
113
116
  requestBody?: string; // Default: ""
114
- responseStatus?: number; // Default: 200
115
- responseStatusText?: string; // Default: "OK"
116
- responseHeaders?: Record<string, string>; // Default: {}
117
- responseBody?: string; // Default: ""
118
117
  properties?: Record<string, unknown>; // Default: {}
119
118
  enforceProductionPropertyRules?: boolean; // Default: true
120
119
  }
@@ -122,17 +121,40 @@ interface FlowOptions {
122
121
 
123
122
  | Field | Default | Description |
124
123
  | -------------------------------- | ------- | --------------------------------------------------------------------------- |
125
- | `url` | — | Full URL including scheme and host; used to derive pseudo-headers |
124
+ | `url` | — | Full URL including scheme and host, or `"built-in"` for the local responder |
126
125
  | `method` | `"GET"` | HTTP method |
127
126
  | `requestHeaders` | `{}` | Additional request headers; pseudo-headers here override derived values |
128
127
  | `requestBody` | `""` | Request body string |
129
- | `responseStatus` | `200` | Simulated upstream response status code |
130
- | `responseStatusText` | `"OK"` | Simulated upstream response status text |
131
- | `responseHeaders` | `{}` | Simulated upstream response headers |
132
- | `responseBody` | `""` | Simulated upstream response body |
133
128
  | `properties` | `{}` | Proxy-wasm properties to inject |
134
129
  | `enforceProductionPropertyRules` | `true` | When true, denies access to properties not available in production FastEdge |
135
130
 
131
+ The upstream response is generated at runtime by a real fetch against `url`, or by the built-in responder when `url === "built-in"`. See the [Origin Mocking](#origin-mocking) section for controlling responses in tests.
132
+
133
+ ### MockOriginsHandle & MockOriginsOptions
134
+
135
+ Returned by `mockOrigins()` to scope an undici `MockAgent` to a single test (see [Origin Mocking](#origin-mocking) for full usage).
136
+
137
+ ```typescript
138
+ interface MockOriginsOptions {
139
+ allowNetConnect?: boolean | (string | RegExp)[]; // Default: false
140
+ }
141
+
142
+ interface MockOriginsHandle {
143
+ origin(url: string): MockPool;
144
+ readonly agent: MockAgent;
145
+ close(): Promise<void>;
146
+ assertAllCalled(): void;
147
+ }
148
+ ```
149
+
150
+ | Field | Description |
151
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
152
+ | `options.allowNetConnect` | Opt requests out of the default `disableNetConnect` block. `true` allows all; an array allow-lists origins or patterns |
153
+ | `handle.origin(url)` | Get or create a `MockPool` for an origin; chain `.intercept({path, method}).reply(...)` on it |
154
+ | `handle.agent` | Raw `MockAgent` escape hatch for `.persist()` / `.times()` / `.delay()` / body matchers |
155
+ | `handle.close()` | Restore the previous global dispatcher and close the agent; idempotent |
156
+ | `handle.assertAllCalled()` | Throw if any registered interceptor was never matched by a real request |
157
+
136
158
  ### HttpRequestOptions
137
159
 
138
160
  Object-based options for `runHttpRequest()`. Used with HTTP WASM apps (as opposed to CDN proxy-wasm filter apps tested with `runFlow`).
@@ -161,6 +183,26 @@ Re-exported from the runner. Controls WASM execution behaviour. See [RUNNER.md](
161
183
  import type { RunnerConfig } from "@gcoredev/fastedge-test/test";
162
184
  ```
163
185
 
186
+ ```typescript
187
+ interface RunnerConfig {
188
+ dotenv?: {
189
+ enabled?: boolean;
190
+ path?: string;
191
+ };
192
+ enforceProductionPropertyRules?: boolean;
193
+ runnerType?: "http-wasm" | "proxy-wasm";
194
+ httpPort?: number;
195
+ }
196
+ ```
197
+
198
+ | Field | Type | Description |
199
+ | -------------------------------- | ------------------------------- | ------------------------------------------------------------------------- |
200
+ | `dotenv.enabled` | `boolean` | Enable dotenv loading |
201
+ | `dotenv.path` | `string` | Directory to load dotenv files from; defaults to process CWD when omitted |
202
+ | `enforceProductionPropertyRules` | `boolean` | Override production property enforcement for the runner; default `true` |
203
+ | `runnerType` | `"http-wasm" \| "proxy-wasm"` | Override automatic WASM type detection |
204
+ | `httpPort` | `number` | Pin the HTTP server to a specific port (HTTP WASM only; throws if in use) |
205
+
164
206
  ## Functions
165
207
 
166
208
  ### defineTestSuite
@@ -244,11 +286,11 @@ The returned `FullFlowResult` has this shape:
244
286
 
245
287
  ```typescript
246
288
  type FullFlowResult = {
247
- hookResults: Record<string, HookResult>; // keyed by camelCase hook name
289
+ hookResults: Record<string, HookResult>;
248
290
  finalResponse: {
249
291
  status: number;
250
292
  statusText: string;
251
- headers: Record<string, string>;
293
+ headers: Record<string, string | string[]>;
252
294
  body: string;
253
295
  contentType: string;
254
296
  isBase64?: boolean;
@@ -259,12 +301,12 @@ type FullFlowResult = {
259
301
 
260
302
  Hook results are accessed by camelCase key:
261
303
 
262
- | Key | Hook |
263
- | -------------------- | -------------------------- |
264
- | `onRequestHeaders` | `on_request_headers` hook |
265
- | `onRequestBody` | `on_request_body` hook |
266
- | `onResponseHeaders` | `on_response_headers` hook |
267
- | `onResponseBody` | `on_response_body` hook |
304
+ | Key | Hook |
305
+ | --------------------- | ---------------------------- |
306
+ | `onRequestHeaders` | `on_request_headers` hook |
307
+ | `onRequestBody` | `on_request_body` hook |
308
+ | `onResponseHeaders` | `on_response_headers` hook |
309
+ | `onResponseBody` | `on_response_body` hook |
268
310
 
269
311
  ```typescript
270
312
  const result = await runFlow(runner, {
@@ -272,8 +314,6 @@ const result = await runFlow(runner, {
272
314
  method: "POST",
273
315
  requestHeaders: { "content-type": "application/json" },
274
316
  requestBody: '{"key":"value"}',
275
- responseStatus: 201,
276
- responseHeaders: { "x-upstream": "backend-1" },
277
317
  });
278
318
 
279
319
  // Access hook results
@@ -281,8 +321,8 @@ const reqHook = result.hookResults.onRequestHeaders;
281
321
  const resHook = result.hookResults.onResponseHeaders;
282
322
 
283
323
  // Access final response
284
- console.log(result.finalResponse.status); // 201
285
- console.log(result.finalResponse.contentType); // e.g. "application/json"
324
+ console.log(result.finalResponse.status);
325
+ console.log(result.finalResponse.contentType);
286
326
  ```
287
327
 
288
328
  ### runHttpRequest
@@ -307,7 +347,7 @@ const response = await runHttpRequest(runner, { path: "/login" });
307
347
  assertHttpStatus(response, 302);
308
348
  assertHttpHeader(response, "location", "/dashboard");
309
349
 
310
- const redirected = await runHttpRequest(runner, { path: response.headers["location"] });
350
+ const redirected = await runHttpRequest(runner, { path: response.headers["location"] as string });
311
351
  assertHttpStatus(redirected, 200);
312
352
  ```
313
353
 
@@ -336,6 +376,23 @@ Reads and validates a `fastedge-config.test.json` file. Returns the parsed `Test
336
376
  const config = await loadConfigFile("./fastedge-config.test.json");
337
377
  ```
338
378
 
379
+ ### mockOrigins
380
+
381
+ ```typescript
382
+ function mockOrigins(options?: MockOriginsOptions): MockOriginsHandle
383
+ ```
384
+
385
+ Install an undici `MockAgent` as the global fetch dispatcher for the duration of a test. Every origin fetch and every `proxy_http_call` upstream the runner makes routes through it, so interceptors registered on the returned handle match all of them. Blocks unmocked requests by default.
386
+
387
+ See [Origin Mocking](#origin-mocking) for the full usage pattern including lifecycle, multi-upstream setup, and the HTTP-WASM `allowNetConnect` caveat.
388
+
389
+ ```typescript
390
+ const mocks = mockOrigins();
391
+ mocks.origin("https://api.example.com").intercept({ path: "/users" }).reply(200, "[]");
392
+ // ... run your test ...
393
+ await mocks.close();
394
+ ```
395
+
339
396
  ## Assertion Helpers
340
397
 
341
398
  All assertion helpers throw an `Error` on failure, making them compatible with any test framework (vitest, jest, node:assert) or plain try/catch.
@@ -343,11 +400,11 @@ All assertion helpers throw an `Error` on failure, making them compatible with a
343
400
  ### Request Headers
344
401
 
345
402
  ```typescript
346
- function assertRequestHeader(result: HookResult, name: string, expected?: string): void
403
+ function assertRequestHeader(result: HookResult, name: string, expected?: string | string[]): void
347
404
  function assertNoRequestHeader(result: HookResult, name: string): void
348
405
  ```
349
406
 
350
- `assertRequestHeader` asserts the named header exists in the hook's output request headers. If `expected` is provided, also asserts the value matches exactly.
407
+ `assertRequestHeader` asserts the named header exists (case-insensitive) in the hook's output request headers. When `expected` is a `string` and the header is multi-valued, passes if any value matches (`.includes()` semantics). When `expected` is a `string[]`, requires an exact array match.
351
408
 
352
409
  `assertNoRequestHeader` asserts the named header is absent.
353
410
 
@@ -362,7 +419,7 @@ assertNoRequestHeader(hookResult, "x-internal-secret"); // absent
362
419
  ### Response Headers
363
420
 
364
421
  ```typescript
365
- function assertResponseHeader(result: HookResult, name: string, expected?: string): void
422
+ function assertResponseHeader(result: HookResult, name: string, expected?: string | string[]): void
366
423
  function assertNoResponseHeader(result: HookResult, name: string): void
367
424
  ```
368
425
 
@@ -380,9 +437,11 @@ assertNoResponseHeader(hookResult, "server");
380
437
 
381
438
  ```typescript
382
439
  function assertFinalStatus(result: FullFlowResult, expected: number): void
383
- function assertFinalHeader(result: FullFlowResult, name: string, expected?: string): void
440
+ function assertFinalHeader(result: FullFlowResult, name: string, expected?: string | string[]): void
384
441
  ```
385
442
 
443
+ Multi-value semantics on `assertFinalHeader` match `assertRequestHeader`: a `string` expected matches any value when the actual header is multi-valued; a `string[]` expected requires an exact array match. This preserves the RFC 6265 contract for `Set-Cookie` and any other legitimately-repeatable headers.
444
+
386
445
  `assertFinalStatus` asserts the final response status code after the full flow completes.
387
446
 
388
447
  `assertFinalHeader` asserts a header in `result.finalResponse.headers`. If `expected` is provided, also asserts the value.
@@ -464,7 +523,7 @@ These assertions operate on an `HttpResponse` returned by `runHttpRequest`. Use
464
523
 
465
524
  ```typescript
466
525
  function assertHttpStatus(response: HttpResponse, expected: number): void
467
- function assertHttpHeader(response: HttpResponse, name: string, expected?: string): void
526
+ function assertHttpHeader(response: HttpResponse, name: string, expected?: string | string[]): void
468
527
  function assertHttpNoHeader(response: HttpResponse, name: string): void
469
528
  function assertHttpBody(response: HttpResponse, expected: string): void
470
529
  function assertHttpBodyContains(response: HttpResponse, substring: string): void
@@ -476,7 +535,18 @@ function assertHttpNoLog(response: HttpResponse, messageSubstring: string): void
476
535
 
477
536
  `assertHttpStatus` — asserts the response status code.
478
537
 
479
- `assertHttpHeader` — asserts the named header exists (case-insensitive). If `expected` is provided, also asserts the value matches exactly.
538
+ `assertHttpHeader` — asserts the named header exists (case-insensitive). If `expected` is a `string` and the header is multi-valued (e.g. `set-cookie`), passes if any value matches (`.includes()` semantics). If `expected` is a `string[]`, requires an exact array match.
539
+
540
+ ```typescript
541
+ // Single-valued header — exact match
542
+ assertHttpHeader(response, "content-type", "application/json");
543
+
544
+ // Multi-valued header — one-of-many match
545
+ assertHttpHeader(response, "set-cookie", "sid=abc; Path=/");
546
+
547
+ // Multi-valued header — exact array
548
+ assertHttpHeader(response, "set-cookie", ["sid=abc; Path=/", "theme=dark; Path=/"]);
549
+ ```
480
550
 
481
551
  `assertHttpNoHeader` — asserts the named header is absent (case-insensitive).
482
552
 
@@ -507,6 +577,113 @@ const data = assertHttpJson<{ items: unknown[] }>(response);
507
577
  console.log(data.items.length);
508
578
  ```
509
579
 
580
+ ## Origin Mocking
581
+
582
+ The runner's origin fetch inside `callFullFlow` and every `proxy_http_call` upstream fetch both go through Node's global `fetch`, which routes through undici's global dispatcher. `mockOrigins()` installs a [`MockAgent`](https://undici.nodejs.org/#/docs/api/MockAgent) as that dispatcher, so interceptors registered on the returned handle match every request the runner makes — origin fetches in full-flow mode, upstream calls from WASM via `proxy_http_call`, anything else the runner eventually emits.
583
+
584
+ ### Basic Usage
585
+
586
+ ```typescript
587
+ import { mockOrigins } from "@gcoredev/fastedge-test/test";
588
+
589
+ let mocks: MockOriginsHandle | null = null;
590
+
591
+ beforeEach(() => {
592
+ mocks = mockOrigins();
593
+ });
594
+
595
+ afterEach(async () => {
596
+ await mocks?.close();
597
+ mocks = null;
598
+ });
599
+
600
+ it("renders a retry UI when the origin returns 503", async () => {
601
+ mocks!
602
+ .origin("https://origin.example.com")
603
+ .intercept({ path: "/api/resource" })
604
+ .reply(503, "upstream down");
605
+
606
+ const result = await runFlow(runner, {
607
+ url: "https://origin.example.com/api/resource",
608
+ });
609
+
610
+ assertFinalStatus(result, 503);
611
+ mocks!.assertAllCalled();
612
+ });
613
+ ```
614
+
615
+ `handle.origin(url)` returns an undici [`MockPool`](https://undici.nodejs.org/#/docs/api/MockPool) for that origin. Despite reading like "HTTP GET", `MockAgent.get` is a `Map.get`-style lookup — the HTTP method lives on the subsequent `.intercept({ method })` call and defaults to `GET`. The method field accepts any HTTP verb as a string, a `RegExp`, or a predicate function.
616
+
617
+ ### Multi-Upstream with `proxy_http_call`
618
+
619
+ Every upstream the WASM initiates via `proxy_http_call` goes through the same global dispatcher, so multiple origins can be stacked in one setup:
620
+
621
+ ```typescript
622
+ mocks!
623
+ .origin("https://auth.example.com")
624
+ .intercept({ path: "/token", method: "POST" })
625
+ .reply(200, '{"jwt":"xyz"}');
626
+
627
+ mocks!
628
+ .origin("https://analytics.example.com")
629
+ .intercept({ path: "/event", method: "POST" })
630
+ .reply(204);
631
+
632
+ mocks!
633
+ .origin("https://origin.example.com")
634
+ .intercept({ path: "/" })
635
+ .reply(200, "hello");
636
+
637
+ const result = await runFlow(runner, {
638
+ url: "https://origin.example.com/",
639
+ });
640
+
641
+ // Fails if any registered interceptor was never hit
642
+ mocks!.assertAllCalled();
643
+ ```
644
+
645
+ ### Lifecycle and `assertAllCalled`
646
+
647
+ The handle installs the MockAgent as the global dispatcher on construction and restores the previous dispatcher on `close()`. One handle per test is the expected pattern; `beforeEach` / `afterEach` keeps each test isolated. Calling `close()` more than once is safe — later calls are no-ops.
648
+
649
+ `handle.assertAllCalled()` throws if any registered interceptor was never matched. Use it at the end of a test (or in `afterEach`) to catch setup drift — mocks that were registered but never exercised because the WASM under test took a different code path.
650
+
651
+ ### `allowNetConnect` and the HTTP-WASM caveat
652
+
653
+ By default, `mockOrigins()` calls `MockAgent.disableNetConnect()` — every request that doesn't match a registered interceptor is rejected. This is the safer default: missing mocks become loud errors instead of silent live network calls in CI.
654
+
655
+ **HTTP-WASM tests do not compose with the default.** `HttpWasmRunner.execute()` forwards requests to a spawned `fastedge-run` subprocess on `localhost:<port>`, and that localhost fetch is also blocked by `disableNetConnect()`. Use `allowNetConnect` to exempt localhost:
656
+
657
+ ```typescript
658
+ mocks = mockOrigins({
659
+ allowNetConnect: [/^127\.0\.0\.1/, /^localhost/],
660
+ });
661
+ ```
662
+
663
+ This preserves the block-by-default safety for all real origins while allowing the runner's own process-to-process fetch through. For pure-CDN test suites using `runFlow` only, the default is correct and this option is not needed.
664
+
665
+ ### Advanced: the raw `MockAgent`
666
+
667
+ `handle.agent` exposes the underlying `MockAgent` unchanged. Use it for features the wrapper doesn't re-export — `.persist()` (match repeatedly), `.times(n)` (match exactly N times), `.delay(ms)` (simulate latency), custom body matchers, request body predicates, etc. See the [undici MockAgent docs](https://undici.nodejs.org/#/docs/api/MockAgent) for the full DSL.
668
+
669
+ ```typescript
670
+ mocks!.agent
671
+ .get("https://flaky.example.com")
672
+ .intercept({ path: "/api" })
673
+ .reply(503, "down")
674
+ .times(2);
675
+
676
+ mocks!.agent
677
+ .get("https://flaky.example.com")
678
+ .intercept({ path: "/api" })
679
+ .reply(200, "ok")
680
+ .persist();
681
+ ```
682
+
683
+ ### Pseudo-headers and the outbound fetch
684
+
685
+ The runner strips HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) from the outbound fetch before it leaves the process. WASM hooks still see them via `proxy_get_header_map_value` during hook execution; the HTTP/1.1 fetch that actually reaches the origin does not carry them. This mirrors production FastEdge behaviour and means `runFlow` — which derives and injects the pseudo-headers from `url` and `method` — composes with `mockOrigins()` out of the box: interceptors match on path and method only, exactly as undici expects.
686
+
510
687
  ## CI Integration
511
688
 
512
689
  `runAndExit` is the primary entry point for CI pipelines. It exits with code `0` on full pass and `1` on any failure, compatible with standard CI exit-code conventions.
@@ -590,8 +767,6 @@ const suite = defineTestSuite({
590
767
  async run(runner) {
591
768
  const result = await runFlow(runner, {
592
769
  url: "https://cdn.example.com/static/app.js",
593
- responseStatus: 200,
594
- responseHeaders: { "content-type": "application/javascript" },
595
770
  });
596
771
 
597
772
  const res = result.hookResults.onResponseHeaders;
package/docs/WEBSOCKET.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Real-time event stream from the `@gcoredev/fastedge-test` server to connected clients.
4
4
 
5
+ > **Note on header values.** All header fields in this protocol use `Record<string, string | string[]>` — single-valued headers are a `string`, multi-valued headers (notably `Set-Cookie` per RFC 6265) are a `string[]`. HTTP-wasm response headers additionally allow `undefined` values (`Record<string, string | string[] | undefined>`), though `undefined` entries are dropped during JSON serialization. JSON examples below use `Record<string, string>` for brevity.
6
+
5
7
  ## Connection
6
8
 
7
9
  Connect to the WebSocket server at:
@@ -107,16 +109,16 @@ interface RequestStartedEvent {
107
109
  data: {
108
110
  url: string;
109
111
  method: string;
110
- headers: Record<string, string>;
112
+ headers: Record<string, string | string[]>;
111
113
  };
112
114
  }
113
115
  ```
114
116
 
115
- | Field | Type | Description |
116
- | --------- | ------------------------ | --------------------------------- |
117
- | `url` | `string` | Full request URL |
118
- | `method` | `string` | HTTP method (`GET`, `POST`, etc.) |
119
- | `headers` | `Record<string, string>` | Request headers |
117
+ | Field | Type | Description |
118
+ | --------- | ------------------------------------ | --------------------------------- |
119
+ | `url` | `string` | Full request URL |
120
+ | `method` | `string` | HTTP method (`GET`, `POST`, etc.) |
121
+ | `headers` | `Record<string, string \| string[]>` | Request headers |
120
122
 
121
123
  **Example:**
122
124
 
@@ -154,12 +156,12 @@ interface HookExecutedEvent {
154
156
  returnCode: number | null;
155
157
  logCount: number;
156
158
  input: {
157
- request: { headers: Record<string, string>; body: string };
158
- response: { headers: Record<string, string>; body: string };
159
+ request: { headers: Record<string, string | string[]>; body: string };
160
+ response: { headers: Record<string, string | string[]>; body: string };
159
161
  };
160
162
  output: {
161
- request: { headers: Record<string, string>; body: string };
162
- response: { headers: Record<string, string>; body: string };
163
+ request: { headers: Record<string, string | string[]>; body: string };
164
+ response: { headers: Record<string, string | string[]>; body: string };
163
165
  };
164
166
  };
165
167
  }
@@ -226,7 +228,7 @@ interface RequestCompletedEvent {
226
228
  finalResponse: {
227
229
  status: number;
228
230
  statusText: string;
229
- headers: Record<string, string>;
231
+ headers: Record<string, string | string[]>;
230
232
  body: string;
231
233
  contentType: string;
232
234
  isBase64?: boolean;
@@ -241,7 +243,7 @@ interface RequestCompletedEvent {
241
243
  | `hookResults` | `Record<string, any>` | Per-hook execution results, keyed by hook name |
242
244
  | `finalResponse.status` | `number` | HTTP status code |
243
245
  | `finalResponse.statusText` | `string` | HTTP status text |
244
- | `finalResponse.headers` | `Record<string, string>` | Response headers |
246
+ | `finalResponse.headers` | `Record<string, string \| string[]>` | Response headers |
245
247
  | `finalResponse.body` | `string` | Response body (may be base64 if `isBase64` is `true`) |
246
248
  | `finalResponse.contentType` | `string` | Content-Type of the response |
247
249
  | `finalResponse.isBase64` | `boolean \| undefined` | Whether `body` is base64-encoded |
@@ -361,7 +363,7 @@ interface HttpWasmRequestCompletedEvent {
361
363
  response: {
362
364
  status: number;
363
365
  statusText: string;
364
- headers: Record<string, string>;
366
+ headers: Record<string, string | string[] | undefined>;
365
367
  body: string;
366
368
  contentType: string | null;
367
369
  isBase64?: boolean;
@@ -370,14 +372,16 @@ interface HttpWasmRequestCompletedEvent {
370
372
  }
371
373
  ```
372
374
 
373
- | Field | Type | Description |
374
- | ---------------------- | ------------------------ | ----------------------------------------------------- |
375
- | `response.status` | `number` | HTTP status code |
376
- | `response.statusText` | `string` | HTTP status text |
377
- | `response.headers` | `Record<string, string>` | Response headers |
378
- | `response.body` | `string` | Response body (may be base64 if `isBase64` is `true`) |
379
- | `response.contentType` | `string \| null` | Content-Type, or `null` if absent |
380
- | `response.isBase64` | `boolean \| undefined` | Whether `body` is base64-encoded |
375
+ `response.headers` mirrors Node's `IncomingHttpHeaders` — `undefined` values are dropped during JSON serialization and will not appear on the wire.
376
+
377
+ | Field | Type | Description |
378
+ | ---------------------- | ------------------------------------------------- | ----------------------------------------------------- |
379
+ | `response.status` | `number` | HTTP status code |
380
+ | `response.statusText` | `string` | HTTP status text |
381
+ | `response.headers` | `Record<string, string \| string[] \| undefined>` | Response headers (`undefined` values omitted in JSON) |
382
+ | `response.body` | `string` | Response body (may be base64 if `isBase64` is `true`) |
383
+ | `response.contentType` | `string \| null` | Content-Type, or `null` if absent |
384
+ | `response.isBase64` | `boolean \| undefined` | Whether `body` is base64-encoded |
381
385
 
382
386
  **Example:**
383
387
 
@@ -62,8 +62,6 @@ const suite = defineTestSuite({
62
62
  const result = await runFlow(runner, {
63
63
  url: "https://example.com/",
64
64
  method: "GET",
65
- responseStatus: 200,
66
- responseBody: "hello",
67
65
  });
68
66
 
69
67
  const header = result.finalResponse.headers["x-custom-header"];
@@ -102,10 +100,6 @@ try {
102
100
  ":scheme": "https",
103
101
  },
104
102
  "", // request body
105
- {}, // response headers
106
- "", // response body
107
- 200, // response status
108
- "OK", // response status text
109
103
  {}, // properties
110
104
  true, // enforceProductionPropertyRules
111
105
  );
@@ -130,7 +124,7 @@ See [RUNNER.md](RUNNER.md) for the full `IWasmRunner` interface, `RunnerConfig`
130
124
 
131
125
  ## Configuration
132
126
 
133
- Place a `fastedge-config.test.json` file in `.fastedge-debug/` at your project root to define default request parameters, response stubs, and WASM properties for the interactive debugger.
127
+ Place a `fastedge-config.test.json` file in `.fastedge-debug/` at your project root to define the default request and WASM properties for the interactive debugger.
134
128
 
135
129
  ```json
136
130
  {
@@ -147,12 +141,6 @@ Place a `fastedge-config.test.json` file in `.fastedge-debug/` at your project r
147
141
  },
148
142
  "body": ""
149
143
  },
150
- "response": {
151
- "headers": {
152
- "content-type": "text/html"
153
- },
154
- "body": "<html><body>Hello</body></html>"
155
- },
156
144
  "properties": {
157
145
  "my-property": "value"
158
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcoredev/fastedge-test",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,11 +11,13 @@
11
11
  },
12
12
  "exports": {
13
13
  ".": {
14
+ "types": "./dist/lib/index.d.ts",
14
15
  "import": "./dist/lib/index.js",
15
16
  "require": "./dist/lib/index.cjs"
16
17
  },
17
18
  "./server": "./dist/server.js",
18
19
  "./test": {
20
+ "types": "./dist/lib/test-framework/index.d.ts",
19
21
  "import": "./dist/lib/test-framework/index.js",
20
22
  "require": "./dist/lib/test-framework/index.cjs"
21
23
  },
@@ -83,6 +85,7 @@
83
85
  "immer": "^11.1.3",
84
86
  "react": "^19.2.3",
85
87
  "react-dom": "^19.2.3",
88
+ "undici": "^6.0.0",
86
89
  "ws": "^8.19.0",
87
90
  "zod": "^4.3.6",
88
91
  "zod-to-json-schema": "^3.25.1",