@gcoredev/fastedge-test 0.1.7 → 0.2.1

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 (44) hide show
  1. package/dist/fastedge-cli/METADATA.json +1 -1
  2. package/dist/fastedge-cli/fastedge-run-darwin-arm64 +0 -0
  3. package/dist/fastedge-cli/fastedge-run-linux-x64 +0 -0
  4. package/dist/fastedge-cli/fastedge-run.exe +0 -0
  5. package/dist/frontend/assets/{index-BCXfEMSq.js → index-CiqeJ9rz.js} +24 -24
  6. package/dist/frontend/index.html +1 -1
  7. package/dist/lib/index.cjs +292 -140
  8. package/dist/lib/index.d.ts +1 -0
  9. package/dist/lib/index.js +292 -140
  10. package/dist/lib/runner/HeaderManager.d.ts +7 -4
  11. package/dist/lib/runner/HostFunctions.d.ts +5 -5
  12. package/dist/lib/runner/HttpWasmRunner.d.ts +13 -4
  13. package/dist/lib/runner/IStateManager.d.ts +7 -7
  14. package/dist/lib/runner/IWasmRunner.d.ts +17 -9
  15. package/dist/lib/runner/PropertyResolver.d.ts +3 -3
  16. package/dist/lib/runner/ProxyWasmRunner.d.ts +6 -3
  17. package/dist/lib/runner/standalone.d.ts +1 -1
  18. package/dist/lib/runner/types.d.ts +17 -8
  19. package/dist/lib/schemas/api.d.ts +0 -8
  20. package/dist/lib/schemas/config.d.ts +0 -13
  21. package/dist/lib/schemas/index.d.ts +2 -2
  22. package/dist/lib/test-framework/assertions.d.ts +18 -4
  23. package/dist/lib/test-framework/index.cjs +18754 -189
  24. package/dist/lib/test-framework/index.d.ts +2 -0
  25. package/dist/lib/test-framework/index.js +18771 -178
  26. package/dist/lib/test-framework/mock-origins.d.ts +56 -0
  27. package/dist/lib/test-framework/types.d.ts +1 -5
  28. package/dist/server.js +33 -33
  29. package/docs/API.md +23 -53
  30. package/docs/DEBUGGER.md +7 -7
  31. package/docs/INDEX.md +4 -1
  32. package/docs/RUNNER.md +79 -64
  33. package/docs/TEST_CONFIG.md +28 -41
  34. package/docs/TEST_FRAMEWORK.md +205 -32
  35. package/docs/WEBSOCKET.md +25 -21
  36. package/docs/quickstart.md +1 -13
  37. package/package.json +4 -1
  38. package/schemas/api-config.schema.json +0 -24
  39. package/schemas/api-send.schema.json +0 -20
  40. package/schemas/fastedge-config.test.schema.json +0 -24
  41. package/schemas/full-flow-result.schema.json +17 -7
  42. package/schemas/hook-call.schema.json +16 -6
  43. package/schemas/hook-result.schema.json +16 -6
  44. 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`
@@ -22,28 +22,25 @@ The config schema is a union of two variants selected by `appType`:
22
22
 
23
23
  ### Top-Level Fields
24
24
 
25
- | JSON Path | Type | Required (Schema) | Default | Description |
26
- | -------------------- | --------- | -------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
27
- | `$schema` | `string` | No | — | URI pointing to the JSON Schema file for IDE autocompletion and validation. |
28
- | `description` | `string` | No | — | Human-readable label for this test scenario. |
29
- | `wasm` | `object` | No | — | WASM binary configuration. Required when running without a programmatic `wasmBuffer`. |
30
- | `wasm.path` | `string` | Yes (if `wasm` present) | — | Path to the compiled `.wasm` binary, relative to the config file or absolute. |
31
- | `wasm.description` | `string` | No | — | Human-readable label for the WASM binary. |
32
- | `appType` | `string` | Yes (schema) / CDN has runtime default | `"proxy-wasm"` | App variant. `"proxy-wasm"` for CDN mode; `"http-wasm"` for HTTP mode. HTTP-WASM has no default. |
33
- | `request` | `object` | **Yes** | — | Incoming HTTP request to simulate. |
34
- | `request.method` | `string` | Yes (schema) / runtime default | `"GET"` | HTTP method (e.g. `"GET"`, `"POST"`). |
35
- | `request.url` | `string` | **Yes** (CDN only) | — | Full URL for the simulated upstream request (e.g. `"https://example.com/api"`). CDN mode only. |
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
- | `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. |
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
- | `properties` | `object` | **Yes** (schema) / runtime default | `{}` | CDN property key/value pairs passed to the WASM execution context. Values may be any JSON type. |
43
- | `dotenv` | `object` | No | — | Dotenv file loading configuration. |
44
- | `dotenv.enabled` | `boolean` | No | — | Whether to load a `.env` file before execution. |
45
- | `dotenv.path` | `string` | No | — | Path to the `.env` file. If omitted, resolves `.env` relative to the config file directory. |
46
- | `httpPort` | `integer` | No | — | HTTP-WASM only. Pin the subprocess to a specific port (1024–65535) instead of dynamic allocation from the 8100–8199 pool. Throws if the port is busy. |
25
+ | JSON Path | Type | Required (Schema) | Default | Description |
26
+ | ------------------ | --------- | --------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
27
+ | `$schema` | `string` | No | — | URI pointing to the JSON Schema file for IDE autocompletion and validation. |
28
+ | `description` | `string` | No | — | Human-readable label for this test scenario. |
29
+ | `wasm` | `object` | No | — | WASM binary configuration. Required when running without a programmatic `wasmBuffer`. |
30
+ | `wasm.path` | `string` | Yes (if `wasm` present) | — | Path to the compiled `.wasm` binary, relative to the config file or absolute. |
31
+ | `wasm.description` | `string` | No | — | Human-readable label for the WASM binary. |
32
+ | `appType` | `string` | Yes (schema) / CDN has runtime default | `"proxy-wasm"` | App variant. `"proxy-wasm"` for CDN mode; `"http-wasm"` for HTTP mode. HTTP-WASM has no default. |
33
+ | `request` | `object` | **Yes** | — | Incoming HTTP request to simulate. |
34
+ | `request.method` | `string` | Yes (schema) / runtime default | `"GET"` | HTTP method (e.g. `"GET"`, `"POST"`). |
35
+ | `request.url` | `string` | **Yes** (CDN only) | — | Full URL for the simulated upstream request (e.g. `"https://example.com/api"`). CDN mode only. |
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 and receives only the path portion of the request. |
37
+ | `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. |
38
+ | `request.body` | `string` | Yes (schema) / runtime default | `""` | Request body as a plain string. Use an empty string for requests with no body. |
39
+ | `properties` | `object` | **Yes** (schema) / runtime default | `{}` | CDN property key/value pairs passed to the WASM execution context. Values may be any JSON type. |
40
+ | `dotenv` | `object` | No | | Dotenv file loading configuration. |
41
+ | `dotenv.enabled` | `boolean` | No | | Whether to load a `.env` file before execution. |
42
+ | `dotenv.path` | `string` | No | — | Path to the `.env` file. If omitted, resolves `.env` relative to the config file directory. |
43
+ | `httpPort` | `integer` | No | — | HTTP-WASM only. Pin the subprocess to a specific port (1024–65535) instead of dynamic allocation from the 8100–8199 pool. Throws if the port is busy. |
47
44
 
48
45
  ### Required vs. Default Distinction
49
46
 
@@ -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,12 +437,12 @@ 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
 
386
443
  `assertFinalStatus` asserts the final response status code after the full flow completes.
387
444
 
388
- `assertFinalHeader` asserts a header in `result.finalResponse.headers`. If `expected` is provided, also asserts the value.
445
+ `assertFinalHeader` asserts a header in `result.finalResponse.headers`. If `expected` is provided, also asserts the value. Multi-value semantics 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.
389
446
 
390
447
  ```typescript
391
448
  assertFinalStatus(result, 200);
@@ -464,7 +521,7 @@ These assertions operate on an `HttpResponse` returned by `runHttpRequest`. Use
464
521
 
465
522
  ```typescript
466
523
  function assertHttpStatus(response: HttpResponse, expected: number): void
467
- function assertHttpHeader(response: HttpResponse, name: string, expected?: string): void
524
+ function assertHttpHeader(response: HttpResponse, name: string, expected?: string | string[]): void
468
525
  function assertHttpNoHeader(response: HttpResponse, name: string): void
469
526
  function assertHttpBody(response: HttpResponse, expected: string): void
470
527
  function assertHttpBodyContains(response: HttpResponse, substring: string): void
@@ -476,7 +533,18 @@ function assertHttpNoLog(response: HttpResponse, messageSubstring: string): void
476
533
 
477
534
  `assertHttpStatus` — asserts the response status code.
478
535
 
479
- `assertHttpHeader` — asserts the named header exists (case-insensitive). If `expected` is provided, also asserts the value matches exactly.
536
+ `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.
537
+
538
+ ```typescript
539
+ // Single-valued header — exact match
540
+ assertHttpHeader(response, "content-type", "application/json");
541
+
542
+ // Multi-valued header — one-of-many match
543
+ assertHttpHeader(response, "set-cookie", "sid=abc; Path=/");
544
+
545
+ // Multi-valued header — exact array
546
+ assertHttpHeader(response, "set-cookie", ["sid=abc; Path=/", "theme=dark; Path=/"]);
547
+ ```
480
548
 
481
549
  `assertHttpNoHeader` — asserts the named header is absent (case-insensitive).
482
550
 
@@ -507,6 +575,113 @@ const data = assertHttpJson<{ items: unknown[] }>(response);
507
575
  console.log(data.items.length);
508
576
  ```
509
577
 
578
+ ## Origin Mocking
579
+
580
+ 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.
581
+
582
+ ### Basic Usage
583
+
584
+ ```typescript
585
+ import { mockOrigins } from "@gcoredev/fastedge-test/test";
586
+
587
+ let mocks: MockOriginsHandle | null = null;
588
+
589
+ beforeEach(() => {
590
+ mocks = mockOrigins();
591
+ });
592
+
593
+ afterEach(async () => {
594
+ await mocks?.close();
595
+ mocks = null;
596
+ });
597
+
598
+ it("renders a retry UI when the origin returns 503", async () => {
599
+ mocks!
600
+ .origin("https://origin.example.com")
601
+ .intercept({ path: "/api/resource" })
602
+ .reply(503, "upstream down");
603
+
604
+ const result = await runFlow(runner, {
605
+ url: "https://origin.example.com/api/resource",
606
+ });
607
+
608
+ assertFinalStatus(result, 503);
609
+ mocks!.assertAllCalled();
610
+ });
611
+ ```
612
+
613
+ `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.
614
+
615
+ ### Multi-Upstream with `proxy_http_call`
616
+
617
+ Every upstream the WASM initiates via `proxy_http_call` goes through the same global dispatcher, so multiple origins can be stacked in one setup:
618
+
619
+ ```typescript
620
+ mocks!
621
+ .origin("https://auth.example.com")
622
+ .intercept({ path: "/token", method: "POST" })
623
+ .reply(200, '{"jwt":"xyz"}');
624
+
625
+ mocks!
626
+ .origin("https://analytics.example.com")
627
+ .intercept({ path: "/event", method: "POST" })
628
+ .reply(204);
629
+
630
+ mocks!
631
+ .origin("https://origin.example.com")
632
+ .intercept({ path: "/" })
633
+ .reply(200, "hello");
634
+
635
+ const result = await runFlow(runner, {
636
+ url: "https://origin.example.com/",
637
+ });
638
+
639
+ // Fails if any registered interceptor was never hit
640
+ mocks!.assertAllCalled();
641
+ ```
642
+
643
+ ### Lifecycle and `assertAllCalled`
644
+
645
+ 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.
646
+
647
+ `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.
648
+
649
+ ### HTTP-WASM `allowNetConnect` caveat
650
+
651
+ 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.
652
+
653
+ **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:
654
+
655
+ ```typescript
656
+ mocks = mockOrigins({
657
+ allowNetConnect: [/^127\.0\.0\.1/, /^localhost/],
658
+ });
659
+ ```
660
+
661
+ 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.
662
+
663
+ ### Advanced: the raw `MockAgent`
664
+
665
+ `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.
666
+
667
+ ```typescript
668
+ mocks!.agent
669
+ .get("https://flaky.example.com")
670
+ .intercept({ path: "/api" })
671
+ .reply(503, "down")
672
+ .times(2);
673
+
674
+ mocks!.agent
675
+ .get("https://flaky.example.com")
676
+ .intercept({ path: "/api" })
677
+ .reply(200, "ok")
678
+ .persist();
679
+ ```
680
+
681
+ ### Pseudo-headers and the outbound fetch
682
+
683
+ 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.
684
+
510
685
  ## CI Integration
511
686
 
512
687
  `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 +765,6 @@ const suite = defineTestSuite({
590
765
  async run(runner) {
591
766
  const result = await runFlow(runner, {
592
767
  url: "https://cdn.example.com/static/app.js",
593
- responseStatus: 200,
594
- responseHeaders: { "content-type": "application/javascript" },
595
768
  });
596
769
 
597
770
  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