@gcoredev/fastedge-test 0.0.1-beta.0 → 0.1.0-beta.2
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.
- package/README.md +6 -6
- package/bin/fastedge-debug.js +2 -0
- package/dist/fastedge-cli/METADATA.json +1 -3
- package/dist/fastedge-cli/{fastedge-run-linux-x64-unkown → fastedge-run-darwin-arm64} +0 -0
- package/dist/fastedge-cli/fastedge-run-linux-x64 +0 -0
- package/dist/fastedge-cli/fastedge-run.exe +0 -0
- package/dist/frontend/assets/index-CEFjsU8e.js +35 -0
- package/dist/frontend/assets/index-DdlINQc_.css +1 -0
- package/dist/frontend/index.html +2 -2
- package/dist/lib/index.cjs +329 -112
- package/dist/lib/index.js +331 -115
- package/dist/lib/runner/HostFunctions.d.ts +8 -0
- package/dist/lib/runner/HttpWasmRunner.d.ts +34 -14
- package/dist/lib/runner/IStateManager.d.ts +3 -2
- package/dist/lib/runner/IWasmRunner.d.ts +18 -1
- package/dist/lib/runner/NullStateManager.d.ts +1 -0
- package/dist/lib/runner/PortManager.d.ts +17 -19
- package/dist/lib/runner/ProxyWasmRunner.d.ts +7 -0
- package/dist/lib/runner/standalone.d.ts +1 -1
- package/dist/lib/schemas/api.d.ts +8 -2
- package/dist/lib/schemas/config.d.ts +4 -1
- package/dist/lib/test-framework/index.cjs +330 -114
- package/dist/lib/test-framework/index.js +332 -117
- package/dist/lib/test-framework/suite-runner.d.ts +1 -1
- package/dist/server.js +30 -30
- package/docs/API.md +758 -360
- package/docs/DEBUGGER.md +151 -0
- package/docs/INDEX.md +111 -0
- package/docs/RUNNER.md +582 -0
- package/docs/TEST_CONFIG.md +242 -0
- package/docs/TEST_FRAMEWORK.md +384 -284
- package/docs/WEBSOCKET.md +499 -0
- package/docs/quickstart.md +171 -0
- package/llms.txt +72 -14
- package/package.json +17 -6
- package/schemas/api-config.schema.json +12 -5
- package/schemas/api-load.schema.json +11 -6
- package/schemas/{test-config.schema.json → fastedge-config.test.schema.json} +12 -5
- package/dist/fastedge-cli/.gitkeep +0 -0
- package/dist/frontend/assets/index-CnXStFTd.css +0 -1
- package/dist/frontend/assets/index-FR9Oqsow.js +0 -37
- package/docs/HYBRID_LOADING.md +0 -546
- package/docs/LOCAL_SERVER.md +0 -153
package/docs/TEST_FRAMEWORK.md
CHANGED
|
@@ -1,420 +1,520 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Test Framework API
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
High-level API for defining and running proxy-wasm test suites with `@gcoredev/fastedge-test`.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Import
|
|
6
6
|
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
```typescript
|
|
8
|
+
import {
|
|
9
|
+
defineTestSuite,
|
|
10
|
+
runTestSuite,
|
|
11
|
+
runAndExit,
|
|
12
|
+
runFlow,
|
|
13
|
+
loadConfigFile,
|
|
14
|
+
assertRequestHeader,
|
|
15
|
+
assertResponseHeader,
|
|
16
|
+
assertFinalStatus,
|
|
17
|
+
assertReturnCode,
|
|
18
|
+
assertLog,
|
|
19
|
+
} from "@gcoredev/fastedge-test/test";
|
|
20
|
+
import type { TestSuite, TestCase, SuiteResult, FlowOptions } from "@gcoredev/fastedge-test/test";
|
|
11
21
|
```
|
|
12
22
|
|
|
13
|
-
##
|
|
23
|
+
## Types
|
|
24
|
+
|
|
25
|
+
### TestSuite
|
|
26
|
+
|
|
27
|
+
A discriminated union requiring exactly one of `wasmPath` or `wasmBuffer`. Supplying both, or neither, is a TypeScript compile-time error.
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
```typescript
|
|
30
|
+
type TestSuiteBase = {
|
|
31
|
+
runnerConfig?: RunnerConfig;
|
|
32
|
+
tests: TestCase[];
|
|
33
|
+
};
|
|
19
34
|
|
|
20
|
-
|
|
35
|
+
type TestSuite =
|
|
36
|
+
| (TestSuiteBase & { wasmPath: string; wasmBuffer?: never })
|
|
37
|
+
| (TestSuiteBase & { wasmBuffer: Buffer; wasmPath?: never });
|
|
38
|
+
```
|
|
21
39
|
|
|
22
|
-
|
|
40
|
+
| Field | Type | Required | Description |
|
|
41
|
+
| -------------- | -------------- | -------- | ----------------------------------------------------------- |
|
|
42
|
+
| `wasmPath` | `string` | One of | Filesystem path to the `.wasm` file |
|
|
43
|
+
| `wasmBuffer` | `Buffer` | One of | Pre-loaded WASM binary |
|
|
44
|
+
| `tests` | `TestCase[]` | Yes | Test cases to execute; must be non-empty |
|
|
45
|
+
| `runnerConfig` | `RunnerConfig` | No | Optional runner configuration (see [RUNNER.md](RUNNER.md)) |
|
|
23
46
|
|
|
24
|
-
|
|
47
|
+
### TestCase
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
A single test case. The `run` function receives a fully loaded `IWasmRunner` instance. Throw an error (or use assertion helpers) to fail the test.
|
|
27
50
|
|
|
28
51
|
```typescript
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from '@gcoredev/fastedge-test/test';
|
|
52
|
+
interface TestCase {
|
|
53
|
+
name: string;
|
|
54
|
+
run: (runner: IWasmRunner) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
wasmPath: './build/my-cdn-app.wasm',
|
|
38
|
-
tests: [
|
|
39
|
-
{
|
|
40
|
-
name: 'injects x-custom header on request',
|
|
41
|
-
run: async (runner) => {
|
|
42
|
-
const result = await runFlow(runner, {
|
|
43
|
-
url: 'https://example.com/page',
|
|
44
|
-
method: 'GET',
|
|
45
|
-
requestHeaders: { 'user-agent': 'Mozilla/5.0' },
|
|
46
|
-
responseBody: '<html>content</html>',
|
|
47
|
-
responseStatus: 200,
|
|
48
|
-
});
|
|
58
|
+
### TestResult & SuiteResult
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
{
|
|
58
|
-
name: 'returns 403 for blocked paths',
|
|
59
|
-
run: async (runner) => {
|
|
60
|
-
const result = await runFlow(runner, {
|
|
61
|
-
url: 'https://example.com/admin',
|
|
62
|
-
method: 'GET',
|
|
63
|
-
});
|
|
60
|
+
```typescript
|
|
61
|
+
interface TestResult {
|
|
62
|
+
name: string;
|
|
63
|
+
passed: boolean;
|
|
64
|
+
error?: string; // Present when passed is false
|
|
65
|
+
durationMs: number;
|
|
66
|
+
}
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
interface SuiteResult {
|
|
69
|
+
passed: number;
|
|
70
|
+
failed: number;
|
|
71
|
+
total: number;
|
|
72
|
+
durationMs: number;
|
|
73
|
+
results: TestResult[];
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### FlowOptions
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
Object-based options for `runFlow()`. HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) are derived from `url` and `method` automatically. Any pseudo-header supplied in `requestHeaders` overrides the derived default.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
interface FlowOptions {
|
|
83
|
+
url: string;
|
|
84
|
+
method?: string; // Default: "GET"
|
|
85
|
+
requestHeaders?: Record<string, string>;
|
|
86
|
+
requestBody?: string; // Default: ""
|
|
87
|
+
responseStatus?: number; // Default: 200
|
|
88
|
+
responseStatusText?: string; // Default: "OK"
|
|
89
|
+
responseHeaders?: Record<string, string>; // Default: {}
|
|
90
|
+
responseBody?: string; // Default: ""
|
|
91
|
+
properties?: Record<string, unknown>; // Default: {}
|
|
92
|
+
enforceProductionPropertyRules?: boolean; // Default: true
|
|
93
|
+
}
|
|
73
94
|
```
|
|
74
95
|
|
|
75
|
-
|
|
96
|
+
| Field | Default | Description |
|
|
97
|
+
| -------------------------------- | ------- | --------------------------------------------------------------------------- |
|
|
98
|
+
| `url` | — | Full URL including scheme and host; used to derive pseudo-headers |
|
|
99
|
+
| `method` | `"GET"` | HTTP method |
|
|
100
|
+
| `requestHeaders` | `{}` | Additional request headers; pseudo-headers here override derived values |
|
|
101
|
+
| `requestBody` | `""` | Request body string |
|
|
102
|
+
| `responseStatus` | `200` | Simulated upstream response status code |
|
|
103
|
+
| `responseStatusText` | `"OK"` | Simulated upstream response status text |
|
|
104
|
+
| `responseHeaders` | `{}` | Simulated upstream response headers |
|
|
105
|
+
| `responseBody` | `""` | Simulated upstream response body |
|
|
106
|
+
| `properties` | `{}` | Proxy-wasm properties to inject |
|
|
107
|
+
| `enforceProductionPropertyRules` | `true` | When true, denies access to properties not available in production FastEdge |
|
|
76
108
|
|
|
77
|
-
|
|
109
|
+
### RunnerConfig
|
|
110
|
+
|
|
111
|
+
Re-exported from the runner. Controls WASM execution behaviour. See [RUNNER.md](RUNNER.md) for the full definition.
|
|
78
112
|
|
|
79
113
|
```typescript
|
|
80
|
-
import {
|
|
114
|
+
import type { RunnerConfig } from "@gcoredev/fastedge-test/test";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Functions
|
|
118
|
+
|
|
119
|
+
### defineTestSuite
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
function defineTestSuite(suite: TestSuite): TestSuite
|
|
123
|
+
```
|
|
81
124
|
|
|
125
|
+
Validates and returns a typed `TestSuite` definition. Throws if neither `wasmPath` nor `wasmBuffer` is provided, or if `tests` is empty.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
82
128
|
const suite = defineTestSuite({
|
|
83
|
-
wasmPath:
|
|
129
|
+
wasmPath: "./build/my-filter.wasm",
|
|
84
130
|
tests: [
|
|
85
131
|
{
|
|
86
|
-
name:
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
method: 'GET',
|
|
91
|
-
headers: { 'content-type': 'application/json' },
|
|
132
|
+
name: "adds x-request-id header",
|
|
133
|
+
async run(runner) {
|
|
134
|
+
const result = await runFlow(runner, {
|
|
135
|
+
url: "https://example.com/api",
|
|
92
136
|
});
|
|
93
|
-
|
|
94
|
-
if (response.status !== 200) {
|
|
95
|
-
throw new Error(`Expected 200, got ${response.status}`);
|
|
96
|
-
}
|
|
137
|
+
assertRequestHeader(result.hookResults.onRequestHeaders, "x-request-id");
|
|
97
138
|
},
|
|
98
139
|
},
|
|
99
140
|
],
|
|
100
141
|
});
|
|
101
|
-
|
|
102
|
-
await runTestSuite(suite);
|
|
103
142
|
```
|
|
104
143
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## `defineTestSuite(config)`
|
|
110
|
-
|
|
111
|
-
Validates and returns a typed suite definition. Throws if the config is invalid.
|
|
144
|
+
### runTestSuite
|
|
112
145
|
|
|
113
146
|
```typescript
|
|
114
|
-
|
|
115
|
-
| { wasmPath: string; runnerConfig?: RunnerConfig; tests: TestCase[] }
|
|
116
|
-
| { wasmBuffer: Buffer; runnerConfig?: RunnerConfig; tests: TestCase[] }
|
|
147
|
+
function runTestSuite(suite: TestSuite): Promise<SuiteResult>
|
|
117
148
|
```
|
|
118
149
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
### `RunnerConfig`
|
|
150
|
+
Executes all test cases in the suite sequentially. Each test receives a **fresh runner instance** — tests are fully isolated. A thrown error or failed assertion marks that test as failed; remaining tests still execute.
|
|
122
151
|
|
|
123
152
|
```typescript
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
const suite = defineTestSuite({ wasmPath: "./filter.wasm", tests: [...] });
|
|
154
|
+
const result = await runTestSuite(suite);
|
|
155
|
+
|
|
156
|
+
console.log(`${result.passed}/${result.total} passed`);
|
|
157
|
+
for (const r of result.results) {
|
|
158
|
+
if (!r.passed) console.error(`FAIL: ${r.name} — ${r.error}`);
|
|
127
159
|
}
|
|
128
160
|
```
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
## `runTestSuite(suite)`
|
|
133
|
-
|
|
134
|
-
Executes all test cases and returns a `SuiteResult`. Tests run sequentially; each gets its own runner instance.
|
|
162
|
+
### runAndExit
|
|
135
163
|
|
|
136
164
|
```typescript
|
|
137
|
-
|
|
138
|
-
passed: number;
|
|
139
|
-
failed: number;
|
|
140
|
-
total: number;
|
|
141
|
-
durationMs: number;
|
|
142
|
-
results: TestResult[];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
interface TestResult {
|
|
146
|
-
name: string;
|
|
147
|
-
passed: boolean;
|
|
148
|
-
error?: string; // Set when passed is false
|
|
149
|
-
durationMs: number;
|
|
150
|
-
}
|
|
165
|
+
function runAndExit(suite: TestSuite): Promise<never>
|
|
151
166
|
```
|
|
152
167
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
## `runAndExit(suite)` — CI / Standalone Scripts
|
|
156
|
-
|
|
157
|
-
Runs the suite, prints a summary to stdout, and exits the process. Exit code 0 = all passed, 1 = any failures.
|
|
168
|
+
Runs the suite, prints a summary to stdout, then calls `process.exit(0)` if all tests pass or `process.exit(1)` if any fail. Intended for standalone Node.js test scripts run in CI.
|
|
158
169
|
|
|
159
170
|
```typescript
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const suite = defineTestSuite({ ... });
|
|
171
|
+
// test.mjs
|
|
172
|
+
import { defineTestSuite, runAndExit } from "@gcoredev/fastedge-test/test";
|
|
163
173
|
|
|
174
|
+
const suite = defineTestSuite({ wasmPath: "./filter.wasm", tests: [...] });
|
|
164
175
|
await runAndExit(suite);
|
|
165
|
-
// Output:
|
|
166
|
-
// ✓ injects x-custom header (12ms)
|
|
167
|
-
// ✗ returns 403 for blocked paths (5ms)
|
|
168
|
-
// Expected final response status 403, got 200
|
|
169
|
-
//
|
|
170
|
-
// 1/2 passed in 17ms
|
|
171
176
|
```
|
|
172
177
|
|
|
173
|
-
|
|
178
|
+
Output format:
|
|
174
179
|
|
|
175
|
-
|
|
180
|
+
```
|
|
181
|
+
✓ adds x-request-id header (12ms)
|
|
182
|
+
✗ blocks requests without auth (5ms)
|
|
183
|
+
Expected request header 'authorization' to be absent, but found 'Bearer token'
|
|
176
184
|
|
|
177
|
-
|
|
185
|
+
1/2 passed in 17ms
|
|
186
|
+
```
|
|
178
187
|
|
|
179
|
-
|
|
188
|
+
### runFlow
|
|
180
189
|
|
|
181
190
|
```typescript
|
|
182
|
-
|
|
183
|
-
url: string;
|
|
184
|
-
method?: string; // Default: 'GET'
|
|
185
|
-
requestHeaders?: Record<string, string>;
|
|
186
|
-
requestBody?: string;
|
|
187
|
-
responseStatus?: number; // Default: 200
|
|
188
|
-
responseStatusText?: string; // Default: 'OK'
|
|
189
|
-
responseHeaders?: Record<string, string>;
|
|
190
|
-
responseBody?: string;
|
|
191
|
-
properties?: Record<string, unknown>;
|
|
192
|
-
enforceProductionPropertyRules?: boolean; // Default: true
|
|
193
|
-
}
|
|
191
|
+
function runFlow(runner: IWasmRunner, options: FlowOptions): Promise<FullFlowResult>
|
|
194
192
|
```
|
|
195
193
|
|
|
196
|
-
|
|
194
|
+
Executes a complete request/response flow through the WASM filter. Object-based wrapper around the runner's low-level `callFullFlow` method — callers do not need to construct pseudo-headers manually.
|
|
195
|
+
|
|
196
|
+
The returned `FullFlowResult` has this shape:
|
|
197
197
|
|
|
198
198
|
```typescript
|
|
199
199
|
interface FullFlowResult {
|
|
200
|
-
hookResults: Record<string, HookResult>; //
|
|
200
|
+
hookResults: Record<string, HookResult>; // keyed by camelCase hook name
|
|
201
201
|
finalResponse: {
|
|
202
202
|
status: number;
|
|
203
|
-
statusText: string;
|
|
204
203
|
headers: Record<string, string>;
|
|
205
204
|
body: string;
|
|
206
|
-
contentType: string;
|
|
207
|
-
isBase64?: boolean;
|
|
208
205
|
};
|
|
209
|
-
calculatedProperties?: Record<string, unknown>;
|
|
210
206
|
}
|
|
211
207
|
```
|
|
212
208
|
|
|
213
|
-
|
|
209
|
+
Hook results are accessed by camelCase key:
|
|
214
210
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
response: { headers: Record<string, string>; body: string };
|
|
222
|
-
};
|
|
223
|
-
output: {
|
|
224
|
-
request: { headers: Record<string, string>; body: string };
|
|
225
|
-
response: { headers: Record<string, string>; body: string };
|
|
226
|
-
};
|
|
227
|
-
properties: Record<string, unknown>;
|
|
228
|
-
}
|
|
229
|
-
```
|
|
211
|
+
| Key | Hook |
|
|
212
|
+
| --------------------- | ---------------------------- |
|
|
213
|
+
| `onRequestHeaders` | `on_request_headers` hook |
|
|
214
|
+
| `onRequestBody` | `on_request_body` hook |
|
|
215
|
+
| `onResponseHeaders` | `on_response_headers` hook |
|
|
216
|
+
| `onResponseBody` | `on_response_body` hook |
|
|
230
217
|
|
|
231
|
-
|
|
218
|
+
```typescript
|
|
219
|
+
const result = await runFlow(runner, {
|
|
220
|
+
url: "https://api.example.com/v1/resource",
|
|
221
|
+
method: "POST",
|
|
222
|
+
requestHeaders: { "content-type": "application/json" },
|
|
223
|
+
requestBody: '{"key":"value"}',
|
|
224
|
+
responseStatus: 201,
|
|
225
|
+
responseHeaders: { "x-upstream": "backend-1" },
|
|
226
|
+
});
|
|
232
227
|
|
|
233
|
-
|
|
228
|
+
// Access hook results
|
|
229
|
+
const reqHook = result.hookResults.onRequestHeaders;
|
|
230
|
+
const resHook = result.hookResults.onResponseHeaders;
|
|
234
231
|
|
|
235
|
-
|
|
232
|
+
// Access final response
|
|
233
|
+
console.log(result.finalResponse.status); // 201
|
|
234
|
+
```
|
|
236
235
|
|
|
237
|
-
###
|
|
236
|
+
### loadConfigFile
|
|
238
237
|
|
|
239
238
|
```typescript
|
|
240
|
-
|
|
241
|
-
assertRequestHeader(hookResult, 'x-custom')
|
|
242
|
-
assertRequestHeader(hookResult, 'x-custom', 'expected-value')
|
|
243
|
-
|
|
244
|
-
// Assert a header is absent
|
|
245
|
-
assertNoRequestHeader(hookResult, 'x-should-not-exist')
|
|
239
|
+
function loadConfigFile(configPath: string): Promise<TestConfig>
|
|
246
240
|
```
|
|
247
241
|
|
|
248
|
-
|
|
242
|
+
Reads and validates a `fastedge-config.test.json` file. Returns the parsed `TestConfig` or throws with a descriptive error. See [TEST_CONFIG.md](TEST_CONFIG.md) for the full config schema.
|
|
249
243
|
|
|
250
244
|
```typescript
|
|
251
|
-
|
|
252
|
-
assertNoResponseHeader(hookResult, 'x-sensitive')
|
|
245
|
+
const config = await loadConfigFile("./fastedge-config.test.json");
|
|
253
246
|
```
|
|
254
247
|
|
|
255
|
-
|
|
248
|
+
## Assertion Helpers
|
|
256
249
|
|
|
257
|
-
|
|
258
|
-
assertFinalStatus(fullFlowResult, 200)
|
|
259
|
-
assertFinalHeader(fullFlowResult, 'content-type', 'application/json')
|
|
260
|
-
```
|
|
250
|
+
All assertion helpers throw an `Error` on failure, making them compatible with any test framework (vitest, jest, node:assert) or plain try/catch.
|
|
261
251
|
|
|
262
|
-
###
|
|
252
|
+
### Request Headers
|
|
263
253
|
|
|
264
254
|
```typescript
|
|
265
|
-
|
|
255
|
+
function assertRequestHeader(result: HookResult, name: string, expected?: string): void
|
|
256
|
+
function assertNoRequestHeader(result: HookResult, name: string): void
|
|
266
257
|
```
|
|
267
258
|
|
|
268
|
-
|
|
259
|
+
`assertRequestHeader` asserts the named header exists in the hook's output request headers. If `expected` is provided, also asserts the value matches exactly.
|
|
260
|
+
|
|
261
|
+
`assertNoRequestHeader` asserts the named header is absent.
|
|
269
262
|
|
|
270
263
|
```typescript
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
264
|
+
const hookResult = result.hookResults.onRequestHeaders;
|
|
265
|
+
|
|
266
|
+
assertRequestHeader(hookResult, "x-forwarded-for"); // exists
|
|
267
|
+
assertRequestHeader(hookResult, "x-country-code", "DE"); // exists with value
|
|
268
|
+
assertNoRequestHeader(hookResult, "x-internal-secret"); // absent
|
|
274
269
|
```
|
|
275
270
|
|
|
276
|
-
###
|
|
271
|
+
### Response Headers
|
|
277
272
|
|
|
278
273
|
```typescript
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const violated = hasPropertyAccessViolation(hookResult) // Boolean
|
|
274
|
+
function assertResponseHeader(result: HookResult, name: string, expected?: string): void
|
|
275
|
+
function assertNoResponseHeader(result: HookResult, name: string): void
|
|
282
276
|
```
|
|
283
277
|
|
|
284
|
-
|
|
278
|
+
Same semantics as the request header variants, but operates on the hook's output response headers.
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
const hookResult = result.hookResults.onResponseHeaders;
|
|
285
282
|
|
|
286
|
-
|
|
283
|
+
assertResponseHeader(hookResult, "cache-control");
|
|
284
|
+
assertResponseHeader(hookResult, "content-type", "application/json");
|
|
285
|
+
assertNoResponseHeader(hookResult, "server");
|
|
286
|
+
```
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
### Final Response
|
|
289
289
|
|
|
290
290
|
```typescript
|
|
291
|
-
|
|
291
|
+
function assertFinalStatus(result: FullFlowResult, expected: number): void
|
|
292
|
+
function assertFinalHeader(result: FullFlowResult, name: string, expected?: string): void
|
|
293
|
+
```
|
|
292
294
|
|
|
293
|
-
|
|
295
|
+
`assertFinalStatus` asserts the final response status code after the full flow completes.
|
|
294
296
|
|
|
295
|
-
|
|
296
|
-
wasmPath: './build/app.wasm',
|
|
297
|
-
tests: [
|
|
298
|
-
{
|
|
299
|
-
name: 'uses env var from config',
|
|
300
|
-
run: async (runner) => {
|
|
301
|
-
// config.envVars, config.secrets, config.properties available here
|
|
302
|
-
const result = await runFlow(runner, {
|
|
303
|
-
url: 'https://example.com',
|
|
304
|
-
properties: config.properties,
|
|
305
|
-
});
|
|
306
|
-
// ...
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
|
-
],
|
|
310
|
-
});
|
|
297
|
+
`assertFinalHeader` asserts a header in `result.finalResponse.headers`. If `expected` is provided, also asserts the value.
|
|
311
298
|
|
|
312
|
-
|
|
299
|
+
```typescript
|
|
300
|
+
assertFinalStatus(result, 200);
|
|
301
|
+
assertFinalHeader(result, "x-cache", "HIT");
|
|
302
|
+
assertFinalHeader(result, "content-encoding"); // exists, any value
|
|
313
303
|
```
|
|
314
304
|
|
|
315
|
-
|
|
305
|
+
### Return Code
|
|
316
306
|
|
|
317
|
-
|
|
307
|
+
```typescript
|
|
308
|
+
function assertReturnCode(result: HookResult, expected: number): void
|
|
309
|
+
```
|
|
318
310
|
|
|
319
|
-
|
|
311
|
+
Asserts the hook's return code. Common values: `0` = Continue, `1` = Pause/StopIteration.
|
|
320
312
|
|
|
321
313
|
```typescript
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// From a file path
|
|
325
|
-
const runner = await createRunner('./build/app.wasm');
|
|
326
|
-
|
|
327
|
-
// From a buffer (e.g. fetched from a URL or built in-memory)
|
|
328
|
-
const buffer = await fs.readFile('./build/app.wasm');
|
|
329
|
-
const runner = await createRunnerFromBuffer(buffer);
|
|
330
|
-
|
|
331
|
-
// CDN: run the full request/response flow
|
|
332
|
-
const result = await runner.callFullFlow(
|
|
333
|
-
'https://example.com', // url
|
|
334
|
-
'GET', // method
|
|
335
|
-
{}, // request headers
|
|
336
|
-
'', // request body
|
|
337
|
-
{}, // response headers
|
|
338
|
-
'', // response body
|
|
339
|
-
200, // response status
|
|
340
|
-
'OK', // response status text
|
|
341
|
-
{}, // properties
|
|
342
|
-
true, // enforce production property rules
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
// HTTP-WASM: execute a request
|
|
346
|
-
const response = await runner.execute({
|
|
347
|
-
path: '/api/hello',
|
|
348
|
-
method: 'GET',
|
|
349
|
-
headers: {},
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// Always clean up when done
|
|
353
|
-
await runner.cleanup();
|
|
314
|
+
assertReturnCode(result.hookResults.onRequestHeaders, 0); // filter continued
|
|
315
|
+
assertReturnCode(result.hookResults.onRequestHeaders, 1); // filter paused
|
|
354
316
|
```
|
|
355
317
|
|
|
356
|
-
|
|
318
|
+
### Logs
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
function assertLog(result: HookResult, messageSubstring: string): void
|
|
322
|
+
function assertNoLog(result: HookResult, messageSubstring: string): void
|
|
323
|
+
function logsContain(result: HookResult, messageSubstring: string): boolean
|
|
324
|
+
```
|
|
357
325
|
|
|
358
|
-
|
|
326
|
+
`assertLog` asserts at least one log entry contains `messageSubstring`.
|
|
359
327
|
|
|
360
|
-
|
|
328
|
+
`assertNoLog` asserts no log entry contains `messageSubstring`.
|
|
361
329
|
|
|
362
|
-
|
|
330
|
+
`logsContain` is a non-throwing predicate — useful for conditional checks.
|
|
363
331
|
|
|
364
332
|
```typescript
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const result = await runFlow(runner, { url: 'https://example.com/' });
|
|
374
|
-
assertFinalStatus(result, 200);
|
|
375
|
-
} finally {
|
|
376
|
-
await runner.cleanup();
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
});
|
|
333
|
+
const hookResult = result.hookResults.onRequestHeaders;
|
|
334
|
+
|
|
335
|
+
assertLog(hookResult, "cache miss");
|
|
336
|
+
assertNoLog(hookResult, "error");
|
|
337
|
+
|
|
338
|
+
if (logsContain(hookResult, "debug:")) {
|
|
339
|
+
// conditional logic based on log presence
|
|
340
|
+
}
|
|
380
341
|
```
|
|
381
342
|
|
|
382
|
-
|
|
343
|
+
### Properties
|
|
383
344
|
|
|
384
345
|
```typescript
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
expect(results.failed).toBe(0);
|
|
389
|
-
});
|
|
346
|
+
function hasPropertyAccessViolation(result: HookResult): boolean
|
|
347
|
+
function assertPropertyAllowed(result: HookResult, propertyPath: string): void
|
|
348
|
+
function assertPropertyDenied(result: HookResult, propertyPath: string): void
|
|
390
349
|
```
|
|
391
350
|
|
|
392
|
-
|
|
351
|
+
Property access violations appear as log messages containing `"Property access denied"`.
|
|
393
352
|
|
|
394
|
-
|
|
353
|
+
`hasPropertyAccessViolation` returns true if any such message exists.
|
|
395
354
|
|
|
396
|
-
|
|
355
|
+
`assertPropertyAllowed` throws if the named property path was denied.
|
|
356
|
+
|
|
357
|
+
`assertPropertyDenied` throws if the named property path was not denied.
|
|
397
358
|
|
|
398
359
|
```typescript
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
360
|
+
const hookResult = result.hookResults.onRequestHeaders;
|
|
361
|
+
|
|
362
|
+
assertPropertyAllowed(hookResult, "request.path");
|
|
363
|
+
assertPropertyDenied(hookResult, "upstream.address");
|
|
364
|
+
|
|
365
|
+
if (hasPropertyAccessViolation(hookResult)) {
|
|
366
|
+
console.warn("At least one property access was denied");
|
|
367
|
+
}
|
|
402
368
|
```
|
|
403
369
|
|
|
404
|
-
|
|
370
|
+
## CI Integration
|
|
371
|
+
|
|
372
|
+
`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.
|
|
373
|
+
|
|
374
|
+
**package.json script:**
|
|
405
375
|
|
|
406
376
|
```json
|
|
407
377
|
{
|
|
408
|
-
"
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
"properties": {}
|
|
378
|
+
"scripts": {
|
|
379
|
+
"test:wasm": "node --experimental-vm-modules test/suite.mjs"
|
|
380
|
+
}
|
|
412
381
|
}
|
|
413
382
|
```
|
|
414
383
|
|
|
415
|
-
|
|
384
|
+
**GitHub Actions example:**
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
- name: Run WASM tests
|
|
388
|
+
run: npm run test:wasm
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Makefile example:**
|
|
392
|
+
|
|
393
|
+
```makefile
|
|
394
|
+
test:
|
|
395
|
+
node test/suite.mjs
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
For programmatic use (e.g. collecting results before exiting), use `runTestSuite` directly and inspect `SuiteResult.failed`:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const result = await runTestSuite(suite);
|
|
402
|
+
process.exitCode = result.failed > 0 ? 1 : 0;
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Complete Example
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
import {
|
|
409
|
+
defineTestSuite,
|
|
410
|
+
runAndExit,
|
|
411
|
+
runFlow,
|
|
412
|
+
assertRequestHeader,
|
|
413
|
+
assertNoRequestHeader,
|
|
414
|
+
assertResponseHeader,
|
|
415
|
+
assertFinalStatus,
|
|
416
|
+
assertFinalHeader,
|
|
417
|
+
assertReturnCode,
|
|
418
|
+
assertLog,
|
|
419
|
+
assertPropertyAllowed,
|
|
420
|
+
assertPropertyDenied,
|
|
421
|
+
} from "@gcoredev/fastedge-test/test";
|
|
422
|
+
|
|
423
|
+
const suite = defineTestSuite({
|
|
424
|
+
wasmPath: "./build/cdn-filter.wasm",
|
|
425
|
+
tests: [
|
|
426
|
+
{
|
|
427
|
+
name: "injects geo headers on request",
|
|
428
|
+
async run(runner) {
|
|
429
|
+
const result = await runFlow(runner, {
|
|
430
|
+
url: "https://cdn.example.com/image.png",
|
|
431
|
+
method: "GET",
|
|
432
|
+
properties: {
|
|
433
|
+
"request.geo.country_code": "DE",
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const req = result.hookResults.onRequestHeaders;
|
|
438
|
+
assertReturnCode(req, 0);
|
|
439
|
+
assertRequestHeader(req, "x-country-code", "DE");
|
|
440
|
+
assertNoRequestHeader(req, "x-internal-token");
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
{
|
|
445
|
+
name: "sets cache-control on response",
|
|
446
|
+
async run(runner) {
|
|
447
|
+
const result = await runFlow(runner, {
|
|
448
|
+
url: "https://cdn.example.com/static/app.js",
|
|
449
|
+
responseStatus: 200,
|
|
450
|
+
responseHeaders: { "content-type": "application/javascript" },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const res = result.hookResults.onResponseHeaders;
|
|
454
|
+
assertResponseHeader(res, "cache-control", "public, max-age=31536000");
|
|
455
|
+
assertFinalStatus(result, 200);
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
{
|
|
460
|
+
name: "blocks requests missing required auth header",
|
|
461
|
+
async run(runner) {
|
|
462
|
+
const result = await runFlow(runner, {
|
|
463
|
+
url: "https://cdn.example.com/api/private",
|
|
464
|
+
method: "POST",
|
|
465
|
+
requestHeaders: { "content-type": "application/json" },
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
assertFinalStatus(result, 403);
|
|
469
|
+
assertFinalHeader(result, "x-block-reason", "missing-auth");
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
{
|
|
474
|
+
name: "logs cache decision",
|
|
475
|
+
async run(runner) {
|
|
476
|
+
const result = await runFlow(runner, {
|
|
477
|
+
url: "https://cdn.example.com/data.json",
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const req = result.hookResults.onRequestHeaders;
|
|
481
|
+
assertLog(req, "cache-check:");
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
{
|
|
486
|
+
name: "allows access to request path property",
|
|
487
|
+
async run(runner) {
|
|
488
|
+
const result = await runFlow(runner, {
|
|
489
|
+
url: "https://cdn.example.com/path/to/resource",
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const req = result.hookResults.onRequestHeaders;
|
|
493
|
+
assertPropertyAllowed(req, "request.path");
|
|
494
|
+
assertPropertyDenied(req, "upstream.address");
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
{
|
|
499
|
+
name: "passes through with pre-loaded buffer",
|
|
500
|
+
async run(runner) {
|
|
501
|
+
const result = await runFlow(runner, {
|
|
502
|
+
url: "https://cdn.example.com/",
|
|
503
|
+
responseStatus: 304,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
assertFinalStatus(result, 304);
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await runAndExit(suite);
|
|
513
|
+
```
|
|
416
514
|
|
|
417
|
-
##
|
|
515
|
+
## See Also
|
|
418
516
|
|
|
419
|
-
-
|
|
420
|
-
-
|
|
517
|
+
- [RUNNER.md](RUNNER.md) — Low-level `IWasmRunner` interface, `RunnerConfig`, and `callFullFlow`
|
|
518
|
+
- [API.md](API.md) — REST API for running tests via HTTP
|
|
519
|
+
- [TEST_CONFIG.md](TEST_CONFIG.md) — `fastedge-config.test.json` schema and `loadConfigFile` config options
|
|
520
|
+
- [quickstart.md](quickstart.md) — Installation and first test walkthrough
|