@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
package/docs/RUNNER.md CHANGED
@@ -102,10 +102,6 @@ interface IWasmRunner {
102
102
  method: string,
103
103
  headers: Record<string, string>,
104
104
  body: string,
105
- responseHeaders: Record<string, string>,
106
- responseBody: string,
107
- responseStatus: number,
108
- responseStatusText: string,
109
105
  properties: Record<string, unknown>,
110
106
  enforceProductionPropertyRules: boolean
111
107
  ): Promise<FullFlowResult>;
@@ -126,6 +122,14 @@ load(bufferOrPath: Buffer | string, config?: RunnerConfig): Promise<void>
126
122
 
127
123
  Calling `load()` again on the same runner replaces the current module and restarts any underlying process.
128
124
 
125
+ **`httpPort` pinning (HTTP-WASM only).** When `config.httpPort` is set, the spawned `fastedge-run` process is bound to that specific port instead of allocating from the dynamic pool (8100–8199). If the port is already in use, `load()` throws:
126
+
127
+ ```
128
+ fastedge-run port <N> is not available — release it or choose a different httpPort in fastedge-config.test.json
129
+ ```
130
+
131
+ There is no fallback to dynamic allocation — pinning is only useful if the address is stable. Intended for Codespaces/Docker port-forwarding setups, live-preview URLs, or external tooling that requires a fixed target. For proxy-wasm runners, `httpPort` is ignored.
132
+
129
133
  ### execute (HTTP-WASM)
130
134
 
131
135
  Executes an HTTP request through the WASM module. Only available on `HttpWasmRunner` (http-wasm). Calling this on a `ProxyWasmRunner` throws.
@@ -134,7 +138,33 @@ Executes an HTTP request through the WASM module. Only available on `HttpWasmRun
134
138
  execute(request: HttpRequest): Promise<HttpResponse>
135
139
  ```
136
140
 
137
- The runner forwards the request to the locally spawned `fastedge-run` process and returns the response including any logs captured from the process.
141
+ `request.path` is a path on the locally spawned `fastedge-run` server, not a full URL. The runner forwards the request to that local process and returns the response including logs captured from the process stdout/stderr.
142
+
143
+ **Redirects are not followed.** The underlying fetch uses `redirect: "manual"`, so 3xx responses reach the caller intact — status code and `Location` header — rather than being transparently followed. This matches FastEdge edge behavior, where redirects are returned to the client rather than followed server-side, and lets tests assert on redirect status and `Location`.
144
+
145
+ To follow a redirect, inspect `response.headers.location` and handle by `Location` shape:
146
+
147
+ - **Relative Location** (e.g. `/new-path`) — reuse directly as `request.path`.
148
+ - **Absolute same-host Location** (e.g. `http://localhost:8100/new-path`) — parse via `new URL()` and re-issue with `pathname + search` as `request.path`.
149
+ - **Absolute cross-host Location** (e.g. `https://other.example.com/`) — cannot be followed through the runner; the 3xx response is the terminal state for the test.
150
+
151
+ ```typescript
152
+ import { createRunner } from '@gcoredev/fastedge-test';
153
+
154
+ const runner = await createRunner('./my-http-app.wasm');
155
+
156
+ // Relative Location: reuse directly as path
157
+ let response = await runner.execute({ path: '/moved', method: 'GET', headers: {} });
158
+ if (response.status >= 300 && response.status < 400 && response.headers['location']) {
159
+ response = await runner.execute({
160
+ path: response.headers['location'] as string, // e.g. "/new-location"
161
+ method: 'GET',
162
+ headers: {},
163
+ });
164
+ }
165
+
166
+ await runner.cleanup();
167
+ ```
138
168
 
139
169
  ### callHook (Proxy-WASM)
140
170
 
@@ -158,10 +188,6 @@ callFullFlow(
158
188
  method: string,
159
189
  headers: Record<string, string>,
160
190
  body: string,
161
- responseHeaders: Record<string, string>,
162
- responseBody: string,
163
- responseStatus: number,
164
- responseStatusText: string,
165
191
  properties: Record<string, unknown>,
166
192
  enforceProductionPropertyRules: boolean
167
193
  ): Promise<FullFlowResult>
@@ -175,16 +201,14 @@ callFullFlow(
175
201
  | `method` | `string` | HTTP method |
176
202
  | `headers` | `Record<string, string>` | Request headers |
177
203
  | `body` | `string` | Request body |
178
- | `responseHeaders` | `Record<string, string>` | Upstream response headers (used as initial state for response hooks) |
179
- | `responseBody` | `string` | Upstream response body |
180
- | `responseStatus` | `number` | Upstream response status code |
181
- | `responseStatusText` | `string` | Upstream response status text |
182
204
  | `properties` | `Record<string, unknown>` | Shared properties passed to all hooks |
183
205
  | `enforceProductionPropertyRules` | `boolean` | When `true`, restricts property access to match CDN production behavior |
184
206
 
207
+ The upstream response is generated at runtime — either by a real HTTP fetch against `url`, or by the built-in responder when `url === "built-in"`. There is no caller-provided mock response.
208
+
185
209
  Hook execution order: `onRequestHeaders` → `onRequestBody` → *(real HTTP fetch or built-in responder)* → `onResponseHeaders` → `onResponseBody`.
186
210
 
187
- **Local response short-circuit:** If a WASM module calls `send_http_response` (proxy-wasm: `proxy_send_local_response`) during `onRequestHeaders` or `onRequestBody` and returns `StopIteration` (return code `1`), the remaining hooks and origin fetch are **skipped**. The `finalResponse` in the result is built from the locally-sent status, headers, and body — matching CDN production behavior. This is how redirect modules (e.g., geo-redirect) and early error responses work.
211
+ **Local response short-circuit.** If a WASM module calls `proxy_send_local_response` during `onRequestHeaders` or `onRequestBody` and returns `StopIteration` (return code `1`), the remaining hooks and origin fetch are skipped. The `finalResponse` in the result is built from the locally-sent status, headers, and body — matching CDN production behavior. This is how redirect modules (e.g., geo-redirect) and early error responses work.
188
212
 
189
213
  Only available on `ProxyWasmRunner`. Calling on `HttpWasmRunner` throws.
190
214
 
@@ -272,23 +296,25 @@ When testing proxy-wasm modules without a real origin server, pass `BUILTIN_SHOR
272
296
 
273
297
  ```typescript
274
298
  import { createRunner, BUILTIN_SHORTHAND } from '@gcoredev/fastedge-test';
299
+ import type { FullFlowResult } from '@gcoredev/fastedge-test';
275
300
 
276
301
  const runner = await createRunner('./my-cdn-app.wasm');
277
- const result = await runner.callFullFlow(
302
+ const result: FullFlowResult = await runner.callFullFlow(
278
303
  BUILTIN_SHORTHAND, // no origin fetch
279
304
  'GET',
280
305
  { 'accept': 'application/json' },
281
306
  '',
282
- {}, '', 200, 'OK', {}, true
307
+ {}, // properties
308
+ true, // enforceProductionPropertyRules
283
309
  );
284
310
  ```
285
311
 
286
312
  **Built-in responder behavior** — controlled by request headers set before the origin phase:
287
313
 
288
- | Header | Effect |
289
- | -------------------- | ------------------------------------------------------------------------------- |
290
- | `x-debugger-status` | HTTP status code for the generated response (default: `200`) |
291
- | `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) |
314
+ | Header | Effect |
315
+ | -------------------- | -------------------------------------------------------------------------------- |
316
+ | `x-debugger-status` | HTTP status code for the generated response (default: `200`) |
317
+ | `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) |
292
318
 
293
319
  When `x-debugger-content` is omitted, the built-in responder returns a JSON echo of the request method, headers, body, and URL. Both control headers are stripped before response hooks execute so they do not appear in hook input state.
294
320
 
@@ -306,15 +332,17 @@ interface RunnerConfig {
306
332
  };
307
333
  enforceProductionPropertyRules?: boolean;
308
334
  runnerType?: WasmType;
335
+ httpPort?: number;
309
336
  }
310
337
  ```
311
338
 
312
- | Field | Type | Default | Description |
313
- | -------------------------------- | ---------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
- | `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files |
315
- | `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD — correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). |
316
- | `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior |
317
- | `runnerType` | `WasmType` | auto-detected | Override WASM type detection |
339
+ | Field | Type | Default | Description |
340
+ | -------------------------------- | ---------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
341
+ | `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files |
342
+ | `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD — correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). |
343
+ | `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior |
344
+ | `runnerType` | `WasmType` | *auto-detected* | Override WASM type detection |
345
+ | `httpPort` | `number` | `undefined` | HTTP-WASM only. Pin the spawned `fastedge-run` subprocess to a specific port instead of allocating from the dynamic pool (8100–8199). `load()` throws if the port is busy — there is no fallback to dynamic allocation. Intended for Codespaces/Docker port-forwarding or external tooling requiring a fixed address. Ignored for proxy-wasm. |
318
346
 
319
347
  ### HttpRequest & HttpResponse
320
348
 
@@ -329,7 +357,11 @@ interface HttpRequest {
329
357
  interface HttpResponse {
330
358
  status: number;
331
359
  statusText: string;
332
- headers: Record<string, string>;
360
+ // Node's IncomingHttpHeaders (from "node:http"):
361
+ // known single-valued headers (content-type, location, etag, …) are typed as string
362
+ // set-cookie is always string[] when present
363
+ // unknown headers are string | string[] | undefined
364
+ headers: IncomingHttpHeaders;
333
365
  body: string;
334
366
  contentType: string | null;
335
367
  isBase64?: boolean;
@@ -343,20 +375,37 @@ interface HttpResponse {
343
375
 
344
376
  `HttpResponse.logs` contains log entries captured from the `fastedge-run` process stdout/stderr during the request.
345
377
 
378
+ **Multi-valued headers.** `Set-Cookie` is preserved as a `string[]` — each `Set-Cookie` header emitted by the WASM app (or an upstream origin) becomes a separate array entry. This matches RFC 6265 §3 and Node's fetch behavior. Example:
379
+
380
+ ```typescript
381
+ const response = await runner.execute({ path: '/login', method: 'POST', headers: {} });
382
+ const cookies = response.headers['set-cookie']; // string[] | undefined
383
+ for (const cookie of cookies ?? []) {
384
+ console.log(cookie);
385
+ }
386
+ ```
387
+
388
+ Single-valued headers read as plain strings with no narrowing needed:
389
+
390
+ ```typescript
391
+ const location = response.headers['location']; // string | undefined
392
+ const contentType = response.headers['content-type']; // string | undefined
393
+ ```
394
+
346
395
  ### HookCall
347
396
 
348
397
  ```typescript
349
398
  type HookCall = {
350
399
  hook: string;
351
400
  request: {
352
- headers: HeaderMap;
401
+ headers: HeaderRecord;
353
402
  body: string;
354
403
  method?: string;
355
404
  path?: string;
356
405
  scheme?: string;
357
406
  };
358
- response: {
359
- headers: HeaderMap;
407
+ response?: {
408
+ headers: HeaderRecord;
360
409
  body: string;
361
410
  status?: number;
362
411
  statusText?: string;
@@ -367,14 +416,16 @@ type HookCall = {
367
416
  };
368
417
  ```
369
418
 
370
- | Field | Description |
371
- | -------------------------------- | --------------------------------------------------------------------------------------------------- |
372
- | `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` |
373
- | `request` | Request state passed to the hook |
374
- | `response` | Response state passed to the hook |
375
- | `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) |
376
- | `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. |
377
- | `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. |
419
+ | Field | Description |
420
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
421
+ | `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` |
422
+ | `request` | Request state passed to the hook |
423
+ | `response` | Seed state for response hooks called via `callHook()`. Ignored by `callFullFlow()` and by request hooks — the full-flow path generates the upstream response at runtime. |
424
+ | `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) |
425
+ | `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. |
426
+ | `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. |
427
+
428
+ `HeaderRecord` is `Record<string, string | string[]>` — multi-valued headers (e.g. multiple `Set-Cookie`) are represented as `string[]`.
378
429
 
379
430
  ### HookResult
380
431
 
@@ -383,26 +434,26 @@ type HookResult = {
383
434
  returnCode: number | null;
384
435
  logs: { level: number; message: string }[];
385
436
  input: {
386
- request: { headers: HeaderMap; body: string };
387
- response: { headers: HeaderMap; body: string };
437
+ request: { headers: HeaderRecord; body: string };
438
+ response: { headers: HeaderRecord; body: string };
388
439
  properties?: Record<string, unknown>;
389
440
  };
390
441
  output: {
391
- request: { headers: HeaderMap; body: string };
392
- response: { headers: HeaderMap; body: string };
442
+ request: { headers: HeaderRecord; body: string };
443
+ response: { headers: HeaderRecord; body: string };
393
444
  properties?: Record<string, unknown>;
394
445
  };
395
446
  properties: Record<string, unknown>;
396
447
  };
397
448
  ```
398
449
 
399
- | Field | Description |
400
- | ------------ | ------------------------------------------------------------------------------------------ |
401
- | `returnCode` | The numeric value returned by the WASM hook export, or `null` if the export was not found |
402
- | `logs` | Log entries emitted via `proxy_log` during hook execution |
403
- | `input` | Request/response state as seen by the hook before execution |
404
- | `output` | Request/response state after hook execution (reflects WASM mutations) |
405
- | `properties` | All shared properties after hook execution |
450
+ | Field | Description |
451
+ | ------------ | ------------------------------------------------------------------------------------------- |
452
+ | `returnCode` | The numeric value returned by the WASM hook export, or `null` if the export was not found |
453
+ | `logs` | Log entries emitted via `proxy_log` during hook execution |
454
+ | `input` | Request/response state as seen by the hook before execution |
455
+ | `output` | Request/response state after hook execution (reflects WASM mutations) |
456
+ | `properties` | All shared properties after hook execution |
406
457
 
407
458
  ### FullFlowResult
408
459
 
@@ -412,7 +463,7 @@ type FullFlowResult = {
412
463
  finalResponse: {
413
464
  status: number;
414
465
  statusText: string;
415
- headers: HeaderMap;
466
+ headers: HeaderRecord;
416
467
  body: string;
417
468
  contentType: string;
418
469
  isBase64?: boolean;
@@ -421,19 +472,23 @@ type FullFlowResult = {
421
472
  };
422
473
  ```
423
474
 
424
- | Field | Description |
425
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
426
- | `hookResults` | A `Record` keyed by hook name (`"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"`), each containing a `HookResult` |
427
- | `finalResponse` | The final response after all hooks have executed, or the local response if a hook short-circuited (see `callFullFlow`). `body` is base64-encoded when `isBase64` is `true`. |
428
- | `calculatedProperties` | Runtime properties computed from the request URL (e.g. `request.path`, `request.host`) |
475
+ | Field | Description |
476
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
477
+ | `hookResults` | A `Record` keyed by hook name (`"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"`), each containing a `HookResult` |
478
+ | `finalResponse` | The final response after all hooks have executed, or the local response if a hook short-circuited (see `callFullFlow`). `body` is base64-encoded when `isBase64` is `true`. |
479
+ | `calculatedProperties` | Runtime properties computed from the request URL (e.g. `request.path`, `request.host`) |
429
480
 
430
481
  ### Supporting Types
431
482
 
432
483
  ```typescript
433
484
  type WasmType = 'http-wasm' | 'proxy-wasm';
434
485
 
486
+ // Single-valued headers only — used as callFullFlow input parameters
435
487
  type HeaderMap = Record<string, string>;
436
488
 
489
+ // Single- or multi-valued headers — used in HookCall, HookResult, and FullFlowResult
490
+ type HeaderRecord = Record<string, string | string[]>;
491
+
437
492
  type LogEntry = {
438
493
  level: number;
439
494
  message: string;
@@ -461,7 +516,7 @@ interface IStateManager {
461
516
  emitRequestStarted(
462
517
  url: string,
463
518
  method: string,
464
- headers: Record<string, string>,
519
+ headers: Record<string, string | string[]>,
465
520
  source?: EventSource,
466
521
  ): void;
467
522
 
@@ -470,12 +525,12 @@ interface IStateManager {
470
525
  returnCode: number | null,
471
526
  logCount: number,
472
527
  input: {
473
- request: { headers: Record<string, string>; body: string };
474
- response: { headers: Record<string, string>; body: string };
528
+ request: { headers: Record<string, string | string[]>; body: string };
529
+ response: { headers: Record<string, string | string[]>; body: string };
475
530
  },
476
531
  output: {
477
- request: { headers: Record<string, string>; body: string };
478
- response: { headers: Record<string, string>; body: string };
532
+ request: { headers: Record<string, string | string[]>; body: string };
533
+ response: { headers: Record<string, string | string[]>; body: string };
479
534
  },
480
535
  source?: EventSource,
481
536
  ): void;
@@ -485,7 +540,7 @@ interface IStateManager {
485
540
  finalResponse: {
486
541
  status: number;
487
542
  statusText: string;
488
- headers: Record<string, string>;
543
+ headers: Record<string, string | string[]>;
489
544
  body: string;
490
545
  contentType: string;
491
546
  isBase64?: boolean;
@@ -507,7 +562,7 @@ interface IStateManager {
507
562
  response: {
508
563
  status: number;
509
564
  statusText: string;
510
- headers: Record<string, string>;
565
+ headers: Record<string, string | string[] | undefined>;
511
566
  body: string;
512
567
  contentType: string | null;
513
568
  isBase64?: boolean;
@@ -538,19 +593,15 @@ async function testCdnApp() {
538
593
  try {
539
594
  // Execute the full CDN request/response lifecycle
540
595
  const result: FullFlowResult = await runner.callFullFlow(
541
- 'https://example.com/api/data', // request URL
542
- 'GET', // method
543
- { 'accept': 'application/json' }, // request headers
544
- '', // request body
545
- { 'content-type': 'application/json' }, // upstream response headers
546
- '{"key":"value"}', // upstream response body
547
- 200, // upstream response status
548
- 'OK', // upstream response status text
596
+ 'https://example.com/api/data', // request URL
597
+ 'GET', // method
598
+ { 'accept': 'application/json' }, // request headers
599
+ '', // request body
549
600
  {
550
601
  'request.path': '/api/data',
551
602
  'request.host': 'example.com',
552
- }, // shared properties
553
- true, // enforce production property rules
603
+ }, // shared properties
604
+ true, // enforce production property rules
554
605
  );
555
606
 
556
607
  // Inspect hook results
@@ -606,11 +657,12 @@ async function testCdnAppOffline() {
606
657
  try {
607
658
  // Use built-in responder — no origin server required
608
659
  const result: FullFlowResult = await runner.callFullFlow(
609
- BUILTIN_SHORTHAND, // generates a local response instead of fetching
660
+ BUILTIN_SHORTHAND, // generates a local response instead of fetching
610
661
  'GET',
611
662
  { 'accept': 'application/json' },
612
663
  '',
613
- {}, '', 200, 'OK', {}, true,
664
+ {}, // properties
665
+ true, // enforceProductionPropertyRules
614
666
  );
615
667
 
616
668
  console.log('Final status:', result.finalResponse.status);
@@ -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,27 +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. |
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
+ | `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. |
46
44
 
47
45
  ### Required vs. Default Distinction
48
46
 
@@ -64,6 +62,55 @@ When `dotenv.enabled` is `true`, the runner loads a `.env` file and merges its c
64
62
 
65
63
  **Security note**: Do not commit `.env` files containing secrets. Add `.env` to `.gitignore` and use `dotenv.enabled: true` with `dotenv.path` pointing to a file that exists only in the local or CI environment.
66
64
 
65
+ ## HTTP Port Pinning
66
+
67
+ `httpPort` is an optional integer field available only in HTTP-WASM configs (`appType: "http-wasm"`). It pins the `fastedge-run` subprocess to a fixed port instead of dynamically allocating one from the pool (8100–8199). The schema rejects `httpPort` on proxy-wasm configs.
68
+
69
+ ### Default Behaviour
70
+
71
+ Without `httpPort`, the runner allocates a port from the 8100–8199 range for each test run. The allocated port is not stable across runs.
72
+
73
+ ### When to Use Port Pinning
74
+
75
+ - **Codespaces / Docker port-forwarding**: Port-forward rules reference a specific port number. Without pinning, the port changes between runs and breaks the forwarding rule.
76
+ - **Stable live-preview URLs**: Browser tabs or external tooling pointing at a fixed URL (e.g. `http://localhost:8250`) require a stable port.
77
+ - **External tooling integration**: Monitoring, load testing, or proxy configurations that hard-code a target address.
78
+
79
+ ### Fail-Fast Semantics
80
+
81
+ If the pinned port is already in use, `HttpWasmRunner.load()` throws immediately:
82
+
83
+ ```
84
+ fastedge-run port 8250 is not available — release it or choose a different httpPort in fastedge-config.test.json
85
+ ```
86
+
87
+ There is no fallback to dynamic allocation. Free the port or choose a different one before running.
88
+
89
+ ### Port Range Considerations
90
+
91
+ Pinning a port inside the 8100–8199 dynamic pool is allowed by the schema but risks collisions with other concurrent debug sessions using dynamic allocation. For production-like setups or shared environments, choose a port outside the pool (e.g. 8250 or any unused port above 8199).
92
+
93
+ ### Example
94
+
95
+ ```json
96
+ {
97
+ "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json",
98
+ "description": "HTTP-WASM handler with pinned port for Codespaces",
99
+ "appType": "http-wasm",
100
+ "wasm": {
101
+ "path": "./dist/http-handler.wasm"
102
+ },
103
+ "request": {
104
+ "method": "GET",
105
+ "path": "/health",
106
+ "headers": {},
107
+ "body": ""
108
+ },
109
+ "properties": {},
110
+ "httpPort": 8250
111
+ }
112
+ ```
113
+
67
114
  ## Examples
68
115
 
69
116
  ### Minimal CDN Configuration
@@ -153,37 +200,32 @@ An HTTP-WASM scenario simulating a `POST` request with a JSON body. `appType` mu
153
200
  }
154
201
  ```
155
202
 
156
- ### Custom Origin Response
203
+ ### Built-In Responder (No Network)
157
204
 
158
- 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.
159
206
 
160
207
  ```json
161
208
  {
162
209
  "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json",
163
- "description": "CDN handler with custom mock origin response",
210
+ "description": "CDN handler against the built-in responder",
164
211
  "appType": "proxy-wasm",
165
212
  "wasm": {
166
213
  "path": "./dist/handler.wasm"
167
214
  },
168
215
  "request": {
169
216
  "method": "GET",
170
- "url": "https://example.com/cached-resource",
217
+ "url": "built-in",
171
218
  "headers": {},
172
219
  "body": ""
173
220
  },
174
- "response": {
175
- "headers": {
176
- "content-type": "application/json",
177
- "cache-control": "max-age=86400"
178
- },
179
- "body": "{\"status\": \"ok\", \"data\": []}"
180
- },
181
221
  "properties": {
182
222
  "CACHE_TTL": 86400
183
223
  }
184
224
  }
185
225
  ```
186
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
+
187
229
  ## IDE Integration
188
230
 
189
231
  Adding `$schema` to your config file enables JSON Schema validation and autocompletion in VSCode and any editor that supports the JSON Language Server.
@@ -240,10 +282,6 @@ type CdnConfig = {
240
282
  headers: Record<string, string>; // default: {}
241
283
  body: string; // default: ""
242
284
  };
243
- response?: {
244
- headers: Record<string, string>; // default: {}
245
- body: string; // default: ""
246
- };
247
285
  properties: Record<string, unknown>; // default: {}
248
286
  dotenv?: {
249
287
  enabled?: boolean;
@@ -265,6 +303,7 @@ type HttpConfig = {
265
303
  headers: Record<string, string>; // default: {}
266
304
  body: string; // default: ""
267
305
  };
306
+ httpPort?: number; // pin subprocess port (1024–65535); throws if busy
268
307
  properties: Record<string, unknown>; // default: {}
269
308
  dotenv?: {
270
309
  enabled?: boolean;
@@ -282,9 +321,9 @@ const config = await loadConfigFile("./fastedge-config.test.json");
282
321
 
283
322
  if (config.appType === "proxy-wasm") {
284
323
  console.log(config.request.url); // string — CDN full URL
285
- console.log(config.response); // ResponseConfig | undefined
286
324
  } else {
287
325
  console.log(config.request.path); // string — HTTP-WASM path
326
+ console.log(config.httpPort); // number | undefined
288
327
  }
289
328
  ```
290
329