@gcoredev/fastedge-test 0.1.6 → 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 (43) hide show
  1. package/dist/frontend/assets/{index-BpdzhbRl.js → index-CiqeJ9rz.js} +24 -24
  2. package/dist/frontend/index.html +1 -1
  3. package/dist/lib/index.cjs +164 -66
  4. package/dist/lib/index.d.ts +1 -0
  5. package/dist/lib/index.js +164 -66
  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 +22 -5
  9. package/dist/lib/runner/IStateManager.d.ts +7 -7
  10. package/dist/lib/runner/IWasmRunner.d.ts +42 -10
  11. package/dist/lib/runner/PortManager.d.ts +4 -1
  12. package/dist/lib/runner/PropertyResolver.d.ts +3 -3
  13. package/dist/lib/runner/ProxyWasmRunner.d.ts +5 -2
  14. package/dist/lib/runner/standalone.d.ts +1 -1
  15. package/dist/lib/runner/types.d.ts +17 -8
  16. package/dist/lib/schemas/api.d.ts +2 -8
  17. package/dist/lib/schemas/config.d.ts +2 -13
  18. package/dist/lib/schemas/index.d.ts +2 -2
  19. package/dist/lib/test-framework/assertions.d.ts +18 -4
  20. package/dist/lib/test-framework/index.cjs +18634 -115
  21. package/dist/lib/test-framework/index.d.ts +2 -0
  22. package/dist/lib/test-framework/index.js +18651 -104
  23. package/dist/lib/test-framework/mock-origins.d.ts +56 -0
  24. package/dist/lib/test-framework/suite-runner.d.ts +16 -0
  25. package/dist/lib/test-framework/types.d.ts +1 -5
  26. package/dist/server.js +33 -33
  27. package/docs/API.md +48 -54
  28. package/docs/DEBUGGER.md +7 -8
  29. package/docs/INDEX.md +4 -1
  30. package/docs/RUNNER.md +126 -74
  31. package/docs/TEST_CONFIG.md +79 -40
  32. package/docs/TEST_FRAMEWORK.md +235 -36
  33. package/docs/WEBSOCKET.md +25 -21
  34. package/docs/quickstart.md +1 -13
  35. package/package.json +4 -1
  36. package/schemas/api-config.schema.json +5 -24
  37. package/schemas/api-load.schema.json +5 -0
  38. package/schemas/api-send.schema.json +0 -20
  39. package/schemas/fastedge-config.test.schema.json +5 -24
  40. package/schemas/full-flow-result.schema.json +17 -7
  41. package/schemas/hook-call.schema.json +16 -6
  42. package/schemas/hook-result.schema.json +16 -6
  43. package/schemas/http-response.schema.json +227 -5
@@ -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
 
@@ -108,34 +111,53 @@ Object-based options for `runFlow()`. HTTP/2 pseudo-headers (`:method`, `:path`,
108
111
  ```typescript
109
112
  interface FlowOptions {
110
113
  url: string;
111
- method?: string; // Default: "GET"
114
+ method?: string; // Default: "GET"
112
115
  requestHeaders?: Record<string, string>;
113
- requestBody?: string; // Default: ""
114
- responseStatus?: number; // Default: 200
115
- responseStatusText?: string; // Default: "OK"
116
- responseHeaders?: Record<string, string>; // Default: {}
117
- responseBody?: string; // Default: ""
118
- properties?: Record<string, unknown>; // Default: {}
119
- enforceProductionPropertyRules?: boolean; // Default: true
116
+ requestBody?: string; // Default: ""
117
+ properties?: Record<string, unknown>; // Default: {}
118
+ enforceProductionPropertyRules?: boolean; // Default: true
120
119
  }
121
120
  ```
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
- Object-based options for `runHttpRequest()`. Used with HTTP WASM apps (as opposed to CDN proxy-wasm filter apps).
160
+ Object-based options for `runHttpRequest()`. Used with HTTP WASM apps (as opposed to CDN proxy-wasm filter apps tested with `runFlow`).
139
161
 
140
162
  ```typescript
141
163
  interface HttpRequestOptions {
@@ -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
@@ -243,24 +285,28 @@ Executes a complete request/response flow through the WASM filter. Object-based
243
285
  The returned `FullFlowResult` has this shape:
244
286
 
245
287
  ```typescript
246
- interface FullFlowResult {
247
- hookResults: Record<string, HookResult>; // keyed by camelCase hook name
288
+ type FullFlowResult = {
289
+ hookResults: Record<string, HookResult>;
248
290
  finalResponse: {
249
291
  status: number;
250
- headers: Record<string, string>;
292
+ statusText: string;
293
+ headers: Record<string, string | string[]>;
251
294
  body: string;
295
+ contentType: string;
296
+ isBase64?: boolean;
252
297
  };
253
- }
298
+ calculatedProperties?: Record<string, unknown>;
299
+ };
254
300
  ```
255
301
 
256
302
  Hook results are accessed by camelCase key:
257
303
 
258
- | Key | Hook |
259
- | ------------------- | -------------------------- |
260
- | `onRequestHeaders` | `on_request_headers` hook |
261
- | `onRequestBody` | `on_request_body` hook |
262
- | `onResponseHeaders` | `on_response_headers` hook |
263
- | `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 |
264
310
 
265
311
  ```typescript
266
312
  const result = await runFlow(runner, {
@@ -268,8 +314,6 @@ const result = await runFlow(runner, {
268
314
  method: "POST",
269
315
  requestHeaders: { "content-type": "application/json" },
270
316
  requestBody: '{"key":"value"}',
271
- responseStatus: 201,
272
- responseHeaders: { "x-upstream": "backend-1" },
273
317
  });
274
318
 
275
319
  // Access hook results
@@ -277,7 +321,8 @@ const reqHook = result.hookResults.onRequestHeaders;
277
321
  const resHook = result.hookResults.onResponseHeaders;
278
322
 
279
323
  // Access final response
280
- console.log(result.finalResponse.status); // 201
324
+ console.log(result.finalResponse.status);
325
+ console.log(result.finalResponse.contentType);
281
326
  ```
282
327
 
283
328
  ### runHttpRequest
@@ -288,7 +333,26 @@ function runHttpRequest(runner: IWasmRunner, options: HttpRequestOptions): Promi
288
333
 
289
334
  Executes a single HTTP request through an HTTP WASM app. Object-based wrapper around the runner's `execute` method. Use this for WASM apps that handle HTTP requests directly, as opposed to CDN proxy-wasm filter apps tested with `runFlow`.
290
335
 
336
+ **Redirects are not followed.** The underlying fetch uses `redirect: "manual"`, so a 302 (or any 3xx) returned by the WASM is surfaced verbatim — `response.status` is `302` and `response.headers.location` is preserved. This matches FastEdge edge behaviour, where redirects are returned to the client rather than followed server-side.
337
+
338
+ `runHttpRequest` only targets the WASM app under test. `options.path` is a path on the local `fastedge-run` server, not a full URL. Following a redirect depends on the shape of `response.headers.location`:
339
+
340
+ - **Relative** (e.g. `/auth/complete`) — pass it directly as `path` in a second `runHttpRequest` call.
341
+ - **Absolute, same host** — extract `pathname + search` via `new URL()` and re-issue with that path.
342
+ - **Absolute, external host** — cannot be followed through the runner; assert on status and `Location` and stop there.
343
+
344
+ ```typescript
345
+ // Assert on a 302 redirect and follow it (relative Location)
346
+ const response = await runHttpRequest(runner, { path: "/login" });
347
+ assertHttpStatus(response, 302);
348
+ assertHttpHeader(response, "location", "/dashboard");
349
+
350
+ const redirected = await runHttpRequest(runner, { path: response.headers["location"] as string });
351
+ assertHttpStatus(redirected, 200);
352
+ ```
353
+
291
354
  ```typescript
355
+ // Standard request
292
356
  const response = await runHttpRequest(runner, {
293
357
  path: "/api/greet",
294
358
  method: "GET",
@@ -312,6 +376,23 @@ Reads and validates a `fastedge-config.test.json` file. Returns the parsed `Test
312
376
  const config = await loadConfigFile("./fastedge-config.test.json");
313
377
  ```
314
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
+
315
396
  ## Assertion Helpers
316
397
 
317
398
  All assertion helpers throw an `Error` on failure, making them compatible with any test framework (vitest, jest, node:assert) or plain try/catch.
@@ -319,11 +400,11 @@ All assertion helpers throw an `Error` on failure, making them compatible with a
319
400
  ### Request Headers
320
401
 
321
402
  ```typescript
322
- function assertRequestHeader(result: HookResult, name: string, expected?: string): void
403
+ function assertRequestHeader(result: HookResult, name: string, expected?: string | string[]): void
323
404
  function assertNoRequestHeader(result: HookResult, name: string): void
324
405
  ```
325
406
 
326
- `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.
327
408
 
328
409
  `assertNoRequestHeader` asserts the named header is absent.
329
410
 
@@ -338,7 +419,7 @@ assertNoRequestHeader(hookResult, "x-internal-secret"); // absent
338
419
  ### Response Headers
339
420
 
340
421
  ```typescript
341
- function assertResponseHeader(result: HookResult, name: string, expected?: string): void
422
+ function assertResponseHeader(result: HookResult, name: string, expected?: string | string[]): void
342
423
  function assertNoResponseHeader(result: HookResult, name: string): void
343
424
  ```
344
425
 
@@ -356,9 +437,11 @@ assertNoResponseHeader(hookResult, "server");
356
437
 
357
438
  ```typescript
358
439
  function assertFinalStatus(result: FullFlowResult, expected: number): void
359
- function assertFinalHeader(result: FullFlowResult, name: string, expected?: string): void
440
+ function assertFinalHeader(result: FullFlowResult, name: string, expected?: string | string[]): void
360
441
  ```
361
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
+
362
445
  `assertFinalStatus` asserts the final response status code after the full flow completes.
363
446
 
364
447
  `assertFinalHeader` asserts a header in `result.finalResponse.headers`. If `expected` is provided, also asserts the value.
@@ -440,7 +523,7 @@ These assertions operate on an `HttpResponse` returned by `runHttpRequest`. Use
440
523
 
441
524
  ```typescript
442
525
  function assertHttpStatus(response: HttpResponse, expected: number): void
443
- function assertHttpHeader(response: HttpResponse, name: string, expected?: string): void
526
+ function assertHttpHeader(response: HttpResponse, name: string, expected?: string | string[]): void
444
527
  function assertHttpNoHeader(response: HttpResponse, name: string): void
445
528
  function assertHttpBody(response: HttpResponse, expected: string): void
446
529
  function assertHttpBodyContains(response: HttpResponse, substring: string): void
@@ -452,7 +535,18 @@ function assertHttpNoLog(response: HttpResponse, messageSubstring: string): void
452
535
 
453
536
  `assertHttpStatus` — asserts the response status code.
454
537
 
455
- `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
+ ```
456
550
 
457
551
  `assertHttpNoHeader` — asserts the named header is absent (case-insensitive).
458
552
 
@@ -483,6 +577,113 @@ const data = assertHttpJson<{ items: unknown[] }>(response);
483
577
  console.log(data.items.length);
484
578
  ```
485
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
+
486
687
  ## CI Integration
487
688
 
488
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.
@@ -566,8 +767,6 @@ const suite = defineTestSuite({
566
767
  async run(runner) {
567
768
  const result = await runFlow(runner, {
568
769
  url: "https://cdn.example.com/static/app.js",
569
- responseStatus: 200,
570
- responseHeaders: { "content-type": "application/javascript" },
571
770
  });
572
771
 
573
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.6",
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",
@@ -52,6 +52,11 @@
52
52
  "type": "string",
53
53
  "const": "http-wasm"
54
54
  },
55
+ "httpPort": {
56
+ "type": "integer",
57
+ "minimum": 1024,
58
+ "maximum": 65535
59
+ },
55
60
  "request": {
56
61
  "type": "object",
57
62
  "properties": {
@@ -174,30 +179,6 @@
174
179
  "body"
175
180
  ],
176
181
  "additionalProperties": false
177
- },
178
- "response": {
179
- "type": "object",
180
- "properties": {
181
- "headers": {
182
- "default": {},
183
- "type": "object",
184
- "propertyNames": {
185
- "type": "string"
186
- },
187
- "additionalProperties": {
188
- "type": "string"
189
- }
190
- },
191
- "body": {
192
- "default": "",
193
- "type": "string"
194
- }
195
- },
196
- "required": [
197
- "headers",
198
- "body"
199
- ],
200
- "additionalProperties": false
201
182
  }
202
183
  },
203
184
  "required": [
@@ -19,6 +19,11 @@
19
19
  }
20
20
  },
21
21
  "additionalProperties": false
22
+ },
23
+ "httpPort": {
24
+ "type": "integer",
25
+ "minimum": 1024,
26
+ "maximum": 65535
22
27
  }
23
28
  },
24
29
  "additionalProperties": false
@@ -41,26 +41,6 @@
41
41
  },
42
42
  "additionalProperties": false
43
43
  },
44
- "response": {
45
- "type": "object",
46
- "properties": {
47
- "headers": {
48
- "default": {},
49
- "type": "object",
50
- "propertyNames": {
51
- "type": "string"
52
- },
53
- "additionalProperties": {
54
- "type": "string"
55
- }
56
- },
57
- "body": {
58
- "default": "",
59
- "type": "string"
60
- }
61
- },
62
- "additionalProperties": false
63
- },
64
44
  "properties": {
65
45
  "default": {},
66
46
  "type": "object",