@glubean/engine 0.7.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.
- package/dist/engine.d.ts +46 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1391 -0
- package/dist/engine.js.map +1 -0
- package/dist/http-trace.d.ts +38 -0
- package/dist/http-trace.d.ts.map +1 -0
- package/dist/http-trace.js +128 -0
- package/dist/http-trace.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/node-host.d.ts +14 -0
- package/dist/node-host.d.ts.map +1 -0
- package/dist/node-host.js +29 -0
- package/dist/node-host.js.map +1 -0
- package/dist/types.d.ts +378 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +42 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,1391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunnerCore — the environment-agnostic run-loop (Stage 1 spike).
|
|
3
|
+
*
|
|
4
|
+
* Everything per-run lives in an explicit ExecutionScope; the engine touches NO
|
|
5
|
+
* module-level mutable state. Per-run isolation is delegated entirely to the
|
|
6
|
+
* injected Carrier port: run() wraps the run-loop in runWithRuntime(scope.runtime),
|
|
7
|
+
* so SDK getRuntime() consumers (configure() / session / configured-http) resolve
|
|
8
|
+
* to the active scope. node installs an ALS carrier (true cross-await isolation);
|
|
9
|
+
* the negative-control test installs the globalThis single-slot carrier and the
|
|
10
|
+
* SAME interleaving test must then leak — proving the gate is sensitive.
|
|
11
|
+
*
|
|
12
|
+
* HTTP (Decision B): each run gets a REAL ky instance built with the host-injected
|
|
13
|
+
* fetch + scope-bound trace hooks (keyed by Request, not ky NormalizedOptions —
|
|
14
|
+
* codex P1-2). ky owns the http facade; we only alias the SDK's public `prefixUrl`
|
|
15
|
+
* to ky 2's `prefix` at the boundary (codex P2-5). Narrow run-loop only: simple +
|
|
16
|
+
* linear steps. No branch / poll / retry / timeout / workflow (Stage 2).
|
|
17
|
+
*/
|
|
18
|
+
import ky from "ky";
|
|
19
|
+
import { captureRequestBody, inferJsonSchema, truncateBody, truncateDeep } from "./http-trace.js";
|
|
20
|
+
import { Expectation } from "@glubean/sdk";
|
|
21
|
+
import { installCarrier, runWithRuntime } from "@glubean/sdk/internal";
|
|
22
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head"]);
|
|
23
|
+
/** URL pathname, or the raw url if it doesn't parse (node parity: trace target). */
|
|
24
|
+
function pathnameOf(url) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(url).pathname;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** URL pathname, or undefined if it doesn't parse (node parity: the http_duration_ms
|
|
33
|
+
* metric drops the `path` tag when the url is unparseable, harness.ts:1116-1127). */
|
|
34
|
+
function tryPathname(url) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(url).pathname;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Layer per-run overrides over a host env object WITHOUT a destructive spread, so
|
|
44
|
+
* a fallback-Proxy provider (node: .env → process.env) keeps its fallback for keys
|
|
45
|
+
* the run never overrides. An empty-string override is treated as "unset" (node
|
|
46
|
+
* parity: empty = missing → defer to the provider). (codex P2 / plan 0005 §E)
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Mirror the node harness's validator semantics for ctx.vars/secrets.require's
|
|
50
|
+
* optional validator callback (codex P2): true / undefined / null = valid; a string
|
|
51
|
+
* = custom error message; anything else (false) = generic validation failure.
|
|
52
|
+
*/
|
|
53
|
+
function runEngineValidator(result, key, kind) {
|
|
54
|
+
if (result === true || result === undefined || result === null)
|
|
55
|
+
return;
|
|
56
|
+
if (typeof result === "string")
|
|
57
|
+
throw new Error(`Invalid ${kind} "${key}": ${result}`);
|
|
58
|
+
throw new Error(`Invalid ${kind} "${key}": validation failed`);
|
|
59
|
+
}
|
|
60
|
+
function branchOf(step) {
|
|
61
|
+
const b = step.branch;
|
|
62
|
+
return b && typeof b === "object" && Array.isArray(b.cases) ? b : null;
|
|
63
|
+
}
|
|
64
|
+
function pollOf(step) {
|
|
65
|
+
const p = step.poll;
|
|
66
|
+
return p && typeof p === "object" && typeof p.fn === "function" && typeof p.until === "function" ? p : null;
|
|
67
|
+
}
|
|
68
|
+
/** Count LEAF steps (node parity: leafTotal) — branch cases + default recurse;
|
|
69
|
+
* used for the `total` on step_start. A linear list counts as its length. */
|
|
70
|
+
function countLeafSteps(steps) {
|
|
71
|
+
let n = 0;
|
|
72
|
+
for (const s of steps) {
|
|
73
|
+
const br = branchOf(s);
|
|
74
|
+
if (br) {
|
|
75
|
+
for (const c of br.cases)
|
|
76
|
+
n += countLeafSteps(c.steps);
|
|
77
|
+
n += countLeafSteps(br.default ?? []);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
n += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
/** Per-step timeout error — same message/name as the node harness (harness.ts:446)
|
|
86
|
+
* so step_end.error + classifyErrorReason match. */
|
|
87
|
+
class StepTimeoutError extends Error {
|
|
88
|
+
constructor(stepName, timeoutMs) {
|
|
89
|
+
super(`Step "${stepName}" timed out after ${timeoutMs}ms`);
|
|
90
|
+
this.name = "StepTimeoutError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* ctx.skip() sentinel (node parity: harness.ts SkipError). Caught in the run-loop
|
|
95
|
+
* and turned into a `skipped` verdict — never leaves the engine (the host re-raises
|
|
96
|
+
* its OWN SkipError from the result so the dispatcher's skip status matches). The
|
|
97
|
+
* name matches the node harness so any classifier agrees.
|
|
98
|
+
*/
|
|
99
|
+
class SkipError extends Error {
|
|
100
|
+
reason;
|
|
101
|
+
constructor(reason) {
|
|
102
|
+
super(reason ? `Test skipped: ${reason}` : "Test skipped");
|
|
103
|
+
this.reason = reason;
|
|
104
|
+
this.name = "SkipError";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* ctx.fail() sentinel (node parity: harness.ts FailError). ctx.fail emits a failed
|
|
109
|
+
* assertion THEN throws this; in a simple test it propagates as a throw (→ host
|
|
110
|
+
* re-raises → failed), in a step/branch leg it is caught as the step error message.
|
|
111
|
+
*/
|
|
112
|
+
class FailError extends Error {
|
|
113
|
+
reason;
|
|
114
|
+
constructor(reason) {
|
|
115
|
+
super(reason);
|
|
116
|
+
this.reason = reason;
|
|
117
|
+
this.name = "FailError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Core schema validation (node parity: harness.ts:runSchemaValidation). Duck-types
|
|
122
|
+
* `safeParse` (preferred) / `parse` so it stays browser-safe — no zod import. Always
|
|
123
|
+
* emits a schema_validation event (via the injected emitter), then routes a FAILURE
|
|
124
|
+
* by severity through the injected assert/warn (so it shares the run-loop's assertion
|
|
125
|
+
* counters + stepIndex). `fatal` emits a failed assertion then throws to abort. Shared
|
|
126
|
+
* by ctx.validate and (Phase 4f) the HTTP schema hooks.
|
|
127
|
+
*/
|
|
128
|
+
function runSchemaValidation(data, schema, label, severity, deps) {
|
|
129
|
+
let success = false;
|
|
130
|
+
let parsed;
|
|
131
|
+
let issues = [];
|
|
132
|
+
if (typeof schema.safeParse === "function") {
|
|
133
|
+
const result = schema.safeParse(data);
|
|
134
|
+
if (result.success) {
|
|
135
|
+
success = true;
|
|
136
|
+
parsed = result.data;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
issues = (result.error?.issues ?? []).map((i) => ({ message: i.message, ...(i.path ? { path: i.path } : {}) }));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (typeof schema.parse === "function") {
|
|
143
|
+
try {
|
|
144
|
+
parsed = schema.parse(data);
|
|
145
|
+
success = true;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const errObj = err;
|
|
149
|
+
if (errObj?.issues && Array.isArray(errObj.issues)) {
|
|
150
|
+
issues = errObj.issues.map((i) => ({ message: i.message ?? String(i), ...(i.path ? { path: i.path } : {}) }));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
issues = [{ message: err instanceof Error ? err.message : String(err) }];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
issues = [{ message: "Schema has neither safeParse nor parse method" }];
|
|
159
|
+
}
|
|
160
|
+
deps.emitSchemaValidation({ label, success, severity, ...(issues.length > 0 ? { issues } : {}) });
|
|
161
|
+
if (!success) {
|
|
162
|
+
const issuesSummary = issues
|
|
163
|
+
.map((i) => (i.path ? i.path.join(".") + ": " : "") + i.message)
|
|
164
|
+
.join("; ");
|
|
165
|
+
const msg = `Schema validation failed: ${label} — ${issuesSummary}`;
|
|
166
|
+
switch (severity) {
|
|
167
|
+
case "error":
|
|
168
|
+
deps.assert(false, msg);
|
|
169
|
+
break;
|
|
170
|
+
case "warn":
|
|
171
|
+
deps.warn(false, msg);
|
|
172
|
+
break;
|
|
173
|
+
case "fatal":
|
|
174
|
+
deps.assert(false, msg);
|
|
175
|
+
throw new FailError(msg);
|
|
176
|
+
}
|
|
177
|
+
return { success: false, issues };
|
|
178
|
+
}
|
|
179
|
+
return { success: true, data: parsed };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Serialize a step's return state for the step_end event with the node harness's
|
|
183
|
+
* 4 KB guard: keep the value if it serializes ≤ 4096 bytes, else a truncated marker;
|
|
184
|
+
* a non-serializable value becomes a marker (codex parity).
|
|
185
|
+
*/
|
|
186
|
+
function serializeReturnState(value) {
|
|
187
|
+
try {
|
|
188
|
+
const s = JSON.stringify(value);
|
|
189
|
+
if (typeof s !== "string")
|
|
190
|
+
return "[non-serializable]";
|
|
191
|
+
return s.length <= 4096 ? value : `[truncated: ${s.length} bytes]`;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return "[non-serializable]";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function layerEnv(provider, overlay) {
|
|
198
|
+
if (!overlay || Object.keys(overlay).length === 0)
|
|
199
|
+
return provider;
|
|
200
|
+
return new Proxy(provider, {
|
|
201
|
+
get(target, prop) {
|
|
202
|
+
const o = overlay[prop];
|
|
203
|
+
return o !== undefined && o !== "" ? o : target[prop];
|
|
204
|
+
},
|
|
205
|
+
has(target, prop) {
|
|
206
|
+
// Empty overlay value = unset → defer to the provider (so ctx.vars.require,
|
|
207
|
+
// which checks `k in vars`, still throws for a genuinely-missing key). (codex P2)
|
|
208
|
+
const o = overlay[prop];
|
|
209
|
+
if (o !== undefined && o !== "")
|
|
210
|
+
return true;
|
|
211
|
+
return prop in target;
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
export class RunnerCore {
|
|
216
|
+
services;
|
|
217
|
+
constructor(services) {
|
|
218
|
+
this.services = services;
|
|
219
|
+
// Install the host carrier as the SDK's global carrier so getRuntime()
|
|
220
|
+
// (configure/session/configured-http) resolves through it.
|
|
221
|
+
installCarrier(services.carrier);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Run one test in an isolated scope. The whole run-loop — including every async
|
|
225
|
+
* continuation in user code — executes inside runWithRuntime(scope.runtime).
|
|
226
|
+
*/
|
|
227
|
+
run(def, input = {}) {
|
|
228
|
+
// Honor an explicitly skipped test: never execute it (no side effects) —
|
|
229
|
+
// node-harness parity (codex B4 P2).
|
|
230
|
+
if (def.meta.skip) {
|
|
231
|
+
// No status EVENT — status emission is host policy (the runner emits the
|
|
232
|
+
// skipped status at dispatch; the browser derives it from this result).
|
|
233
|
+
return Promise.resolve({
|
|
234
|
+
id: def.meta.id,
|
|
235
|
+
name: def.meta.name ?? def.meta.id,
|
|
236
|
+
status: "skipped",
|
|
237
|
+
assertions: { total: 0, passed: 0 },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const scope = this.createScope(def, input);
|
|
241
|
+
return runWithRuntime(scope.runtime, () => this.runLoop(def, scope));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Runtime-authoritative resolution of a module namespace into runnable TestDefs
|
|
245
|
+
* (SoT §3.2: the static scanner is UI-only; this is the权威 resolver). Handles
|
|
246
|
+
* plain Test, Test[], TestBuilder, and EachBuilder — each `test.each` row
|
|
247
|
+
* becomes its own def. Narrow Stage-1 surface (no workflow/contract expansion).
|
|
248
|
+
*/
|
|
249
|
+
resolve(namespace) {
|
|
250
|
+
const defs = [];
|
|
251
|
+
for (const value of Object.values(namespace ?? {}))
|
|
252
|
+
collectDefs(value, defs);
|
|
253
|
+
return defs;
|
|
254
|
+
}
|
|
255
|
+
createScope(def, input) {
|
|
256
|
+
const { env, events } = this.services;
|
|
257
|
+
// Layer per-run input OVER the host EnvProvider WITHOUT a destructive spread,
|
|
258
|
+
// so a host whose vars()/secrets() is a fallback Proxy (node: .env → process.env)
|
|
259
|
+
// keeps that fallback for keys the run never overrides (codex P2 / plan 0005 §E).
|
|
260
|
+
const vars = layerEnv(env.vars(), input.vars);
|
|
261
|
+
const secrets = layerEnv(env.secrets(), input.secrets);
|
|
262
|
+
const session = { ...(input.session ?? {}) };
|
|
263
|
+
// Raw snapshot for ctx.vars.all() (node parity: {...rawVars}). Use the host's RAW
|
|
264
|
+
// map (varsAll) so empty/overlay vars aren't lost to the fallback Proxy; layer the
|
|
265
|
+
// per-run input on top. Falls back to spreading vars() for hosts without varsAll
|
|
266
|
+
// (e.g. a browser host whose vars() is already a plain map). (codex Phase-8 P2)
|
|
267
|
+
const varsAll = { ...(env.varsAll ? env.varsAll() : env.vars()), ...(input.vars ?? {}) };
|
|
268
|
+
const scope = {
|
|
269
|
+
runtime: undefined,
|
|
270
|
+
testMeta: { id: def.meta.id, tags: def.meta.tags ?? [] },
|
|
271
|
+
stepIndex: 0,
|
|
272
|
+
currentStepIndex: null,
|
|
273
|
+
retryCount: input.retryCount ?? 0,
|
|
274
|
+
assertions: { total: 0, passed: 0 },
|
|
275
|
+
// assigned just below once the scope exists (the ky trace hook reads the live
|
|
276
|
+
// scope.currentStepIndex so HTTP traces inside a step carry stepIndex).
|
|
277
|
+
http: undefined,
|
|
278
|
+
session,
|
|
279
|
+
varsAll,
|
|
280
|
+
};
|
|
281
|
+
const http = this.createScopedKy(scope);
|
|
282
|
+
scope.http = http;
|
|
283
|
+
// Fresh runtime object per run (configure().http WeakMap-caches per identity).
|
|
284
|
+
scope.runtime = {
|
|
285
|
+
vars,
|
|
286
|
+
secrets,
|
|
287
|
+
session, // same ref as scope.session, so ctx.session.set is visible to session.get
|
|
288
|
+
http: http,
|
|
289
|
+
get test() {
|
|
290
|
+
return scope.testMeta;
|
|
291
|
+
},
|
|
292
|
+
log: (message, data) => events.emit({ type: "log", id: def.meta.id, message, data }),
|
|
293
|
+
// configure() plugins resolve trace/action/event from the CARRIER runtime, not
|
|
294
|
+
// the ctx object (configure/plugin.ts: `runtime.trace ?? noop`). The legacy
|
|
295
|
+
// harness wires these via setRuntime (`trace: ctx.trace`, …); forward them here
|
|
296
|
+
// too so an engine-routed plugin test keeps its trace/action/event evidence —
|
|
297
|
+
// else plugins (e.g. @glubean/grpc) silently no-op (codex 4c-e P2). Delegated to
|
|
298
|
+
// scope.ctxRef (back-filled before any user code resolves a plugin) so they
|
|
299
|
+
// carry the run's stepIndex, exactly like ctx.trace/action/event.
|
|
300
|
+
trace: (t) => scope.ctxRef?.trace(t),
|
|
301
|
+
action: (a) => scope.ctxRef?.action(a),
|
|
302
|
+
event: (ev) => scope.ctxRef?.event(ev),
|
|
303
|
+
};
|
|
304
|
+
return scope;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Build a per-run ky instance: host fetch + scope-bound trace hooks. Per-request
|
|
308
|
+
* state is keyed by ky 2's stable `options.context` object (NOT the Request, which
|
|
309
|
+
* a hook may replace when an auth helper rebuilds it — codex ky2 P2 / plan 0005 §D).
|
|
310
|
+
* throwHttpErrors:false matches the node harness default so 4xx/5xx surface as
|
|
311
|
+
* responses, not throws. The auto-trace routes through scope.ctxRef.trace (→ trace
|
|
312
|
+
* event + derived action) + ctx.metric("http_duration_ms"), so it shares the run's
|
|
313
|
+
* stepIndex attribution (node parity: harness.ts:1010-1133).
|
|
314
|
+
*/
|
|
315
|
+
createScopedKy(scope) {
|
|
316
|
+
const { fetch: hostFetch, scheduler } = this.services;
|
|
317
|
+
const emitFullTrace = this.services.http?.emitFullTrace ?? false;
|
|
318
|
+
const inferSchema = this.services.http?.inferSchema ?? false;
|
|
319
|
+
const truncateArrays = this.services.http?.truncateArrays ?? false;
|
|
320
|
+
// Per-request state keyed by ky 2's stable options.context (not the Request).
|
|
321
|
+
const reqState = new WeakMap();
|
|
322
|
+
const instance = ky.create({
|
|
323
|
+
fetch: hostFetch,
|
|
324
|
+
throwHttpErrors: false,
|
|
325
|
+
// Match the node harness default: no implicit retries (authors opt in
|
|
326
|
+
// per-request). ky 2 retries GET/HEAD by default, which would mask
|
|
327
|
+
// transient failures and emit extra requests/traces. (codex engine P2)
|
|
328
|
+
retry: 0,
|
|
329
|
+
hooks: {
|
|
330
|
+
beforeRequest: [
|
|
331
|
+
async ({ request, options }) => {
|
|
332
|
+
reqState.set(options.context, {
|
|
333
|
+
startTime: scheduler.now(),
|
|
334
|
+
// Capture the request body (ky 2 hides options.json) for full-trace.
|
|
335
|
+
body: emitFullTrace ? await captureRequestBody(request) : undefined,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
afterResponse: [
|
|
340
|
+
async ({ request, options, response }) => {
|
|
341
|
+
const ctx = scope.ctxRef;
|
|
342
|
+
// No ctx yet (a request before makeCtx) should never happen during a run;
|
|
343
|
+
// bail rather than emit an un-attributed trace.
|
|
344
|
+
if (!ctx)
|
|
345
|
+
return response;
|
|
346
|
+
const state = reqState.get(options.context);
|
|
347
|
+
const durationMs = Math.round(scheduler.now() - (state?.startTime ?? scheduler.now()));
|
|
348
|
+
// `request` is the final (possibly hook-replaced) request — correct target.
|
|
349
|
+
const pathname = pathnameOf(request.url);
|
|
350
|
+
const trace = {
|
|
351
|
+
protocol: "http",
|
|
352
|
+
target: `${request.method} ${pathname}`,
|
|
353
|
+
status: response.status,
|
|
354
|
+
durationMs,
|
|
355
|
+
ok: response.status < 400,
|
|
356
|
+
// Deprecated HTTP back-compat fields (node parity: harness.ts:1043).
|
|
357
|
+
method: request.method,
|
|
358
|
+
url: request.url,
|
|
359
|
+
duration: durationMs,
|
|
360
|
+
};
|
|
361
|
+
// Operation name from the GraphQL client (X-Glubean-Op header).
|
|
362
|
+
const glubeanOp = request.headers.get("x-glubean-op");
|
|
363
|
+
if (glubeanOp)
|
|
364
|
+
trace.name = glubeanOp;
|
|
365
|
+
// Full-trace capture (node parity: harness.ts:1062-1106), gated by
|
|
366
|
+
// emitFullTrace. Reads a CLONE so the user's res.json()/text() still works.
|
|
367
|
+
if (emitFullTrace) {
|
|
368
|
+
trace.requestHeaders = Object.fromEntries(request.headers.entries());
|
|
369
|
+
if (state?.body !== undefined)
|
|
370
|
+
trace.requestBody = truncateBody(state.body);
|
|
371
|
+
trace.responseHeaders = Object.fromEntries(response.headers.entries());
|
|
372
|
+
try {
|
|
373
|
+
const cloned = response.clone();
|
|
374
|
+
const contentType = response.headers.get("content-type") || "";
|
|
375
|
+
let parsedBody;
|
|
376
|
+
if (contentType.includes("json"))
|
|
377
|
+
parsedBody = await cloned.json();
|
|
378
|
+
else if (contentType.includes("text") || contentType.includes("xml"))
|
|
379
|
+
parsedBody = await cloned.text();
|
|
380
|
+
if (parsedBody !== undefined) {
|
|
381
|
+
// Schema inference runs on the FULL body before any truncation.
|
|
382
|
+
if (inferSchema && typeof parsedBody === "object" && parsedBody !== null) {
|
|
383
|
+
trace.responseSchema = inferJsonSchema(parsedBody);
|
|
384
|
+
}
|
|
385
|
+
trace.responseBody = truncateArrays ? truncateDeep(parsedBody) : truncateBody(parsedBody);
|
|
386
|
+
}
|
|
387
|
+
// Binary content types are intentionally skipped.
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Ignore clone/parse errors — trace still emits without the body.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// ctx.trace emits the trace event + the derived `http:request` action.
|
|
394
|
+
ctx.trace(trace);
|
|
395
|
+
// Auto-metric for response time (node parity: harness.ts:1116).
|
|
396
|
+
const urlPath = tryPathname(request.url);
|
|
397
|
+
ctx.metric("http_duration_ms", durationMs, {
|
|
398
|
+
unit: "ms",
|
|
399
|
+
tags: urlPath !== undefined ? { method: request.method, path: urlPath } : { method: request.method },
|
|
400
|
+
});
|
|
401
|
+
return response;
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
return wrapScopedKy(instance, scope);
|
|
407
|
+
}
|
|
408
|
+
async runLoop(def, scope) {
|
|
409
|
+
const { events, scheduler } = this.services;
|
|
410
|
+
events.emit({
|
|
411
|
+
type: "start",
|
|
412
|
+
id: scope.testMeta.id,
|
|
413
|
+
name: def.meta.name ?? def.meta.id,
|
|
414
|
+
tags: scope.testMeta.tags,
|
|
415
|
+
...(scope.retryCount > 0 ? { retryCount: scope.retryCount } : {}),
|
|
416
|
+
});
|
|
417
|
+
const ctx = this.makeCtx(scope);
|
|
418
|
+
// Back-fill so the scope-bound ky hooks (built in createScope, before makeCtx)
|
|
419
|
+
// can route the HTTP auto-trace through ctx.trace / ctx.metric at request time.
|
|
420
|
+
scope.ctxRef = ctx;
|
|
421
|
+
let threw = false;
|
|
422
|
+
let thrownError;
|
|
423
|
+
let thrownStack;
|
|
424
|
+
let thrownName;
|
|
425
|
+
// A step that throws is CAUGHT + recorded as a failed step (node parity: the
|
|
426
|
+
// run still "completes"); it must still make the test's verdict error.
|
|
427
|
+
let stepFailed = false;
|
|
428
|
+
let stepFailMsg;
|
|
429
|
+
// First branch-decision failure → the test-level failure message (node parity:
|
|
430
|
+
// the post-loop throw prefers it over "One or more steps failed").
|
|
431
|
+
let branchDecisionError;
|
|
432
|
+
// ctx.skip() reached us: in a steps run a step/branch-predicate skip sets this
|
|
433
|
+
// (no throw — skips remaining steps + skips the whole test after teardown, node
|
|
434
|
+
// parity). A simple-test / setup skip propagates as a throw to the catch below.
|
|
435
|
+
let skipRequest;
|
|
436
|
+
let skipped = false;
|
|
437
|
+
let skipReason;
|
|
438
|
+
try {
|
|
439
|
+
if (def.type === "simple") {
|
|
440
|
+
if (!def.fn)
|
|
441
|
+
throw new Error(`test "${def.meta.id}": missing fn`);
|
|
442
|
+
await def.fn(ctx);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
let state;
|
|
446
|
+
const stepTotal = countLeafSteps(def.steps ?? []);
|
|
447
|
+
let stepSeq = 0; // monotonic LEAF step index (continues across branches)
|
|
448
|
+
let branchSeq = 0; // separate index for branch decision events
|
|
449
|
+
// Recursive skipped-step emission (node parity: emitSkippedTree) — a skipped
|
|
450
|
+
// step_end per leaf (no attempts/retriesUsed); branch cases recurse.
|
|
451
|
+
const emitSkippedTree = (steps) => {
|
|
452
|
+
for (const s of steps) {
|
|
453
|
+
const br = branchOf(s);
|
|
454
|
+
if (br) {
|
|
455
|
+
for (const c of br.cases)
|
|
456
|
+
emitSkippedTree(c.steps);
|
|
457
|
+
emitSkippedTree((br.default ?? []));
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
events.emit({
|
|
461
|
+
type: "step_end",
|
|
462
|
+
id: scope.testMeta.id,
|
|
463
|
+
index: stepSeq++,
|
|
464
|
+
name: s.meta.name,
|
|
465
|
+
status: "skipped",
|
|
466
|
+
durationMs: 0,
|
|
467
|
+
assertions: 0,
|
|
468
|
+
failedAssertions: 0,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
// Run one normal (non-branch) step with retry/timeout (node parity).
|
|
474
|
+
const runNormalStep = async (step) => {
|
|
475
|
+
const idx = stepSeq++;
|
|
476
|
+
scope.currentStepIndex = idx;
|
|
477
|
+
const startedAt = scheduler.now();
|
|
478
|
+
events.emit({ type: "step_start", id: scope.testMeta.id, index: idx, name: step.meta.name, total: stepTotal });
|
|
479
|
+
const sm = step.meta ?? {};
|
|
480
|
+
const configuredRetries = Number.isFinite(sm.retries) ? Math.max(0, Math.floor(sm.retries)) : 0;
|
|
481
|
+
const retryDelayMs = Number.isFinite(sm.retryDelay) ? Math.max(0, sm.retryDelay) : configuredRetries > 0 ? 1000 : 0;
|
|
482
|
+
const backoff = Number.isFinite(sm.backoff) ? Math.max(1, sm.backoff) : 1;
|
|
483
|
+
const stepTimeoutMs = Number.isFinite(sm.timeout) && sm.timeout > 0 ? Math.floor(sm.timeout) : undefined;
|
|
484
|
+
const maxAttempts = configuredRetries + 1;
|
|
485
|
+
let stepError;
|
|
486
|
+
let stepReturnState;
|
|
487
|
+
let attemptsUsed = 0;
|
|
488
|
+
let lastAssertions = 0;
|
|
489
|
+
let lastFailedAssertions = 0;
|
|
490
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
491
|
+
attemptsUsed = attempt;
|
|
492
|
+
stepError = undefined;
|
|
493
|
+
stepReturnState = undefined;
|
|
494
|
+
const aBefore = scope.assertions.total;
|
|
495
|
+
const fBefore = scope.assertions.total - scope.assertions.passed;
|
|
496
|
+
let timedOut = false;
|
|
497
|
+
try {
|
|
498
|
+
const call = step.fn(ctx, state);
|
|
499
|
+
if (stepTimeoutMs === undefined) {
|
|
500
|
+
const next = await call;
|
|
501
|
+
if (next !== undefined) {
|
|
502
|
+
state = next;
|
|
503
|
+
stepReturnState = next;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
let timer;
|
|
508
|
+
try {
|
|
509
|
+
const next = await Promise.race([
|
|
510
|
+
call,
|
|
511
|
+
new Promise((_, reject) => {
|
|
512
|
+
timer = setTimeout(() => reject(new StepTimeoutError(step.meta.name, stepTimeoutMs)), stepTimeoutMs);
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
if (next !== undefined) {
|
|
516
|
+
state = next;
|
|
517
|
+
stepReturnState = next;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
if (timer !== undefined)
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
if (e instanceof SkipError) {
|
|
528
|
+
// ctx.skip() inside a step skips the WHOLE test — not a step failure,
|
|
529
|
+
// and never retried (node parity: harness.ts:2219).
|
|
530
|
+
skipRequest = e;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
// A thrown step is caught + recorded as a failed step (node parity:
|
|
534
|
+
// the run still "completes"; the test fails via stepsFailed).
|
|
535
|
+
stepError = e instanceof Error ? e.message : String(e);
|
|
536
|
+
timedOut = e instanceof StepTimeoutError;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
lastAssertions = scope.assertions.total - aBefore;
|
|
540
|
+
lastFailedAssertions = scope.assertions.total - scope.assertions.passed - fBefore;
|
|
541
|
+
if (skipRequest)
|
|
542
|
+
break; // skip is terminal — never retry a skip
|
|
543
|
+
const attemptFailed = !!stepError || lastFailedAssertions > 0;
|
|
544
|
+
if (!attemptFailed || timedOut)
|
|
545
|
+
break; // success, or a timeout (terminal — no retry)
|
|
546
|
+
if (attempt < maxAttempts) {
|
|
547
|
+
const delay = Math.min(retryDelayMs * backoff ** (attempt - 1), 30_000);
|
|
548
|
+
const reason = stepError ? stepError : `${lastFailedAssertions} failed assertion(s)`;
|
|
549
|
+
events.emit({
|
|
550
|
+
type: "log",
|
|
551
|
+
id: scope.testMeta.id,
|
|
552
|
+
stepIndex: idx,
|
|
553
|
+
message: `Retrying step "${step.meta.name}" (${attempt + 1}/${maxAttempts}) after failure: ${reason}${delay > 0 ? ` (waiting ${delay}ms)` : ""}`,
|
|
554
|
+
});
|
|
555
|
+
if (delay > 0)
|
|
556
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ctx.skip() in this step skips the whole test — UNLESS a failed assertion
|
|
560
|
+
// was already recorded before the skip, in which case the failure wins (a
|
|
561
|
+
// skip must not mask a real failure; node parity: harness.ts:2275). On a
|
|
562
|
+
// clean skip: emit a skipped step_end (with attempts/retriesUsed) and return;
|
|
563
|
+
// runStepList sees skipRequest set and skips the remaining steps.
|
|
564
|
+
if (skipRequest && lastFailedAssertions === 0) {
|
|
565
|
+
events.emit({
|
|
566
|
+
type: "step_end",
|
|
567
|
+
id: scope.testMeta.id,
|
|
568
|
+
index: idx,
|
|
569
|
+
name: step.meta.name,
|
|
570
|
+
status: "skipped",
|
|
571
|
+
durationMs: scheduler.now() - startedAt,
|
|
572
|
+
assertions: lastAssertions,
|
|
573
|
+
failedAssertions: 0,
|
|
574
|
+
attempts: attemptsUsed,
|
|
575
|
+
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
576
|
+
});
|
|
577
|
+
scope.currentStepIndex = null;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// A prior failure overrides the skip — report this step (+ the test) failed.
|
|
581
|
+
skipRequest = undefined;
|
|
582
|
+
const failed = !!stepError || lastFailedAssertions > 0;
|
|
583
|
+
events.emit({
|
|
584
|
+
type: "step_end",
|
|
585
|
+
id: scope.testMeta.id,
|
|
586
|
+
index: idx,
|
|
587
|
+
name: step.meta.name,
|
|
588
|
+
status: failed ? "failed" : "passed",
|
|
589
|
+
durationMs: scheduler.now() - startedAt,
|
|
590
|
+
assertions: lastAssertions,
|
|
591
|
+
failedAssertions: lastFailedAssertions,
|
|
592
|
+
attempts: attemptsUsed,
|
|
593
|
+
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
594
|
+
...(stepError ? { error: stepError } : {}),
|
|
595
|
+
...(stepReturnState !== undefined ? { returnState: serializeReturnState(stepReturnState) } : {}),
|
|
596
|
+
});
|
|
597
|
+
scope.currentStepIndex = null;
|
|
598
|
+
if (failed) {
|
|
599
|
+
stepFailed = true;
|
|
600
|
+
if (stepError && stepFailMsg === undefined)
|
|
601
|
+
stepFailMsg = stepError;
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
// Recursive step-list runner (node parity: runStepList). A prior failure
|
|
605
|
+
// skips the rest (skipped step_end tree). branch handling lands in Phase 2.
|
|
606
|
+
// Branch step (test().condition / .switchOn / .switchCond): make the decision
|
|
607
|
+
// ONCE, emit a `branch` event, run the taken case's sub-steps as first-class
|
|
608
|
+
// steps, and skip every non-taken case + (if a case matched) the default —
|
|
609
|
+
// in registry order so stepSeq stays aligned (node parity: runBranchStep).
|
|
610
|
+
const runBranchStep = async (step, branch) => {
|
|
611
|
+
scope.currentStepIndex = null;
|
|
612
|
+
const failedBefore = scope.assertions.total - scope.assertions.passed;
|
|
613
|
+
let takenIndex = "default";
|
|
614
|
+
let takenValue;
|
|
615
|
+
const total = branch.cases.length;
|
|
616
|
+
const baseEvent = { id: scope.testMeta.id, name: step.meta.name, ...(branch.message ? { message: branch.message } : {}) };
|
|
617
|
+
const failDecision = (errMessage) => {
|
|
618
|
+
events.emit({ type: "branch", ...baseEvent, index: branchSeq++, takenIndex: "default", total, error: errMessage });
|
|
619
|
+
stepFailed = true;
|
|
620
|
+
if (branchDecisionError === undefined)
|
|
621
|
+
branchDecisionError = `branch "${step.meta.name}": ${errMessage}`;
|
|
622
|
+
for (const c of branch.cases)
|
|
623
|
+
emitSkippedTree(c.steps);
|
|
624
|
+
emitSkippedTree(branch.default ?? []);
|
|
625
|
+
};
|
|
626
|
+
try {
|
|
627
|
+
if (branch.mode === "value") {
|
|
628
|
+
// Subject lens evaluated EXACTLY ONCE (lenses may be impure); await so
|
|
629
|
+
// an async lens resolves to its scalar before matching.
|
|
630
|
+
const subject = branch.subject ? await branch.subject(ctx, state) : undefined;
|
|
631
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
632
|
+
if (subject === branch.cases[ci].value) {
|
|
633
|
+
takenIndex = ci;
|
|
634
|
+
takenValue = branch.cases[ci].value;
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
641
|
+
const pred = branch.cases[ci].predicate;
|
|
642
|
+
if (!pred)
|
|
643
|
+
continue;
|
|
644
|
+
const result = await pred(ctx, state);
|
|
645
|
+
if (typeof result !== "boolean") {
|
|
646
|
+
throw new Error(`condition / switchCond predicate must return a boolean; got ${result === null ? "null" : typeof result}`);
|
|
647
|
+
}
|
|
648
|
+
if (result) {
|
|
649
|
+
takenIndex = ci;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
const decisionFailed = scope.assertions.total - scope.assertions.passed - failedBefore;
|
|
657
|
+
if (e instanceof SkipError && decisionFailed === 0) {
|
|
658
|
+
// ctx.skip() in a predicate/lens skips the WHOLE test (node parity:
|
|
659
|
+
// harness.ts:2573) — emit a default-taken branch with NO error + skip
|
|
660
|
+
// every sub-step; runStepList then skips the remaining steps.
|
|
661
|
+
skipRequest = e;
|
|
662
|
+
events.emit({ type: "branch", ...baseEvent, index: branchSeq++, takenIndex: "default", total });
|
|
663
|
+
for (const c of branch.cases)
|
|
664
|
+
emitSkippedTree(c.steps);
|
|
665
|
+
emitSkippedTree(branch.default ?? []);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// Decision failure (§7.4): the test fails; never silently take a branch. A
|
|
669
|
+
// skip preceded by a failed assertion reports that failure (failure wins).
|
|
670
|
+
failDecision(e instanceof SkipError
|
|
671
|
+
? `${decisionFailed} failed assertion(s) before ctx.skip() in branch decision`
|
|
672
|
+
: e instanceof Error
|
|
673
|
+
? e.message
|
|
674
|
+
: String(e));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// A ctx.assert(false) failure recorded WHILE deciding fails the branch (the
|
|
678
|
+
// assertion event is outside any step, so step-authority would miss it).
|
|
679
|
+
const decisionFailed = scope.assertions.total - scope.assertions.passed - failedBefore;
|
|
680
|
+
if (decisionFailed > 0) {
|
|
681
|
+
failDecision(`${decisionFailed} failed assertion(s) during branch decision`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
events.emit({
|
|
685
|
+
type: "branch",
|
|
686
|
+
...baseEvent,
|
|
687
|
+
index: branchSeq++,
|
|
688
|
+
takenIndex,
|
|
689
|
+
...(takenValue !== undefined ? { takenValue } : {}),
|
|
690
|
+
total,
|
|
691
|
+
});
|
|
692
|
+
// Leaves in registry order: cases 0..N then default — run the taken one,
|
|
693
|
+
// skip the rest IN PLACE (keeps stepSeq aligned with discovery order).
|
|
694
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
695
|
+
if (ci === takenIndex)
|
|
696
|
+
await runStepList(branch.cases[ci].steps);
|
|
697
|
+
else
|
|
698
|
+
emitSkippedTree(branch.cases[ci].steps);
|
|
699
|
+
}
|
|
700
|
+
if (takenIndex === "default")
|
|
701
|
+
await runStepList(branch.default ?? []);
|
|
702
|
+
else
|
|
703
|
+
emitSkippedTree(branch.default ?? []);
|
|
704
|
+
};
|
|
705
|
+
// Execute a poll step (test().poll): a first-class leaf step whose body is a
|
|
706
|
+
// bounded retry of `fn` until `until` holds (or a bound exhausts → it fails).
|
|
707
|
+
// Each attempt is RACED against its budget (best-effort: arbitrary user fn/until
|
|
708
|
+
// can't be force-cancelled, but the step never waits past the budget). Emits a
|
|
709
|
+
// `poll` event (attempts/elapsed/satisfied/exhausted) + the normal step_start/
|
|
710
|
+
// step_end. Assertions accumulate across attempts (node parity: harness.ts:2345).
|
|
711
|
+
const runPollStep = async (step, poll) => {
|
|
712
|
+
const idx = stepSeq++;
|
|
713
|
+
scope.currentStepIndex = idx;
|
|
714
|
+
const aBefore = scope.assertions.total;
|
|
715
|
+
const fBefore = scope.assertions.total - scope.assertions.passed;
|
|
716
|
+
const stepStart = scheduler.now();
|
|
717
|
+
events.emit({ type: "step_start", id: scope.testMeta.id, index: idx, name: step.meta.name, total: stepTotal });
|
|
718
|
+
// Local budget sentinel so a budget timeout is distinguishable from a genuine
|
|
719
|
+
// fn/until error or a SkipError.
|
|
720
|
+
class PollBudgetTimeout extends Error {
|
|
721
|
+
}
|
|
722
|
+
const raceBudget = (p, budgetMs) => {
|
|
723
|
+
if (!Number.isFinite(budgetMs))
|
|
724
|
+
return p;
|
|
725
|
+
return new Promise((resolve, reject) => {
|
|
726
|
+
const t = setTimeout(() => reject(new PollBudgetTimeout()), Math.max(0, budgetMs));
|
|
727
|
+
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
const everyMs = poll.every ?? 1000;
|
|
731
|
+
const backoff = poll.backoff ?? 1;
|
|
732
|
+
const perAttempt = poll.perAttemptTimeout ?? Infinity;
|
|
733
|
+
const start = scheduler.now();
|
|
734
|
+
const deadline = poll.timeout !== undefined ? start + poll.timeout : Infinity;
|
|
735
|
+
let attempt = 0;
|
|
736
|
+
let delay = everyMs;
|
|
737
|
+
let satisfied = false;
|
|
738
|
+
let exhausted = false;
|
|
739
|
+
let pollError;
|
|
740
|
+
let lastRes;
|
|
741
|
+
// A poll-phase throw must surface a non-empty message: pollError is tested for
|
|
742
|
+
// truthiness below to decide failure, so an empty message would be a silent pass.
|
|
743
|
+
const pollErrMsg = (e) => {
|
|
744
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
745
|
+
return m || `poll "${step.meta.name}" threw an empty error`;
|
|
746
|
+
};
|
|
747
|
+
for (;;) {
|
|
748
|
+
attempt += 1;
|
|
749
|
+
const remainingTotal = deadline - scheduler.now();
|
|
750
|
+
if (remainingTotal <= 0) {
|
|
751
|
+
exhausted = true;
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
const attemptBudget = Math.min(perAttempt, remainingTotal);
|
|
755
|
+
const attemptStart = scheduler.now();
|
|
756
|
+
// Attempt fn (best-effort raced).
|
|
757
|
+
try {
|
|
758
|
+
lastRes = await raceBudget(Promise.resolve(poll.fn(ctx, state)), attemptBudget);
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
if (err instanceof SkipError) {
|
|
762
|
+
skipRequest = err;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
if (err instanceof PollBudgetTimeout) {
|
|
766
|
+
exhausted = true;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
pollError = pollErrMsg(err);
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
if (scheduler.now() > deadline || scheduler.now() - attemptStart > perAttempt) {
|
|
773
|
+
exhausted = true;
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
// Exit predicate (raced to the remaining attempt budget).
|
|
777
|
+
let done;
|
|
778
|
+
try {
|
|
779
|
+
const predBudget = Math.min(deadline - scheduler.now(), perAttempt - (scheduler.now() - attemptStart));
|
|
780
|
+
const out = await raceBudget(Promise.resolve(poll.until(ctx, lastRes, state)), Math.max(0, predBudget));
|
|
781
|
+
if (typeof out !== "boolean") {
|
|
782
|
+
pollError = `poll "${step.meta.name}": until must return a boolean; got ${out === null ? "null" : typeof out}`;
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
done = out;
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
if (err instanceof SkipError) {
|
|
789
|
+
skipRequest = err;
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
if (err instanceof PollBudgetTimeout) {
|
|
793
|
+
exhausted = true;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
pollError = pollErrMsg(err);
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
if (done) {
|
|
800
|
+
satisfied = true;
|
|
801
|
+
if (poll.out) {
|
|
802
|
+
// A throwing out-mapper fails the poll through the normal path (poll
|
|
803
|
+
// event + step_end below), not by escaping.
|
|
804
|
+
try {
|
|
805
|
+
const next = poll.out(state, lastRes);
|
|
806
|
+
if (next !== undefined)
|
|
807
|
+
state = next;
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
pollError = pollErrMsg(err);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
// Not satisfied → check bounds, then wait.
|
|
816
|
+
if (poll.maxAttempts && attempt >= poll.maxAttempts) {
|
|
817
|
+
exhausted = true;
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
if (Number.isFinite(deadline) && scheduler.now() + delay >= deadline) {
|
|
821
|
+
exhausted = true;
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
825
|
+
delay = Math.min(delay * backoff, 30_000);
|
|
826
|
+
}
|
|
827
|
+
const durationMs = scheduler.now() - stepStart;
|
|
828
|
+
const failedAssertions = scope.assertions.total - scope.assertions.passed - fBefore;
|
|
829
|
+
const assertions = scope.assertions.total - aBefore;
|
|
830
|
+
// ctx.skip() in fn/until skips the whole test (unless a failure/exhaustion was
|
|
831
|
+
// already recorded — failure wins, node parity: harness.ts:2464).
|
|
832
|
+
if (skipRequest && failedAssertions === 0 && !pollError && !exhausted) {
|
|
833
|
+
events.emit({
|
|
834
|
+
type: "step_end",
|
|
835
|
+
id: scope.testMeta.id,
|
|
836
|
+
index: idx,
|
|
837
|
+
name: step.meta.name,
|
|
838
|
+
status: "skipped",
|
|
839
|
+
durationMs,
|
|
840
|
+
assertions,
|
|
841
|
+
failedAssertions: 0,
|
|
842
|
+
attempts: attempt,
|
|
843
|
+
});
|
|
844
|
+
scope.currentStepIndex = null;
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
skipRequest = undefined;
|
|
848
|
+
if (exhausted && !pollError) {
|
|
849
|
+
pollError = `poll "${step.meta.name}" exhausted: condition not met after ${attempt} attempt(s)`;
|
|
850
|
+
}
|
|
851
|
+
const failed = !!pollError || failedAssertions > 0 || !satisfied;
|
|
852
|
+
events.emit({
|
|
853
|
+
type: "poll",
|
|
854
|
+
id: scope.testMeta.id,
|
|
855
|
+
index: idx,
|
|
856
|
+
name: step.meta.name,
|
|
857
|
+
attempts: attempt,
|
|
858
|
+
elapsedMs: Math.round(scheduler.now() - start),
|
|
859
|
+
satisfied,
|
|
860
|
+
exhausted,
|
|
861
|
+
...(pollError ? { error: pollError } : {}),
|
|
862
|
+
});
|
|
863
|
+
events.emit({
|
|
864
|
+
type: "step_end",
|
|
865
|
+
id: scope.testMeta.id,
|
|
866
|
+
index: idx,
|
|
867
|
+
name: step.meta.name,
|
|
868
|
+
status: failed ? "failed" : "passed",
|
|
869
|
+
durationMs,
|
|
870
|
+
assertions,
|
|
871
|
+
failedAssertions,
|
|
872
|
+
attempts: attempt,
|
|
873
|
+
...(pollError ? { error: pollError } : {}),
|
|
874
|
+
});
|
|
875
|
+
scope.currentStepIndex = null;
|
|
876
|
+
if (failed) {
|
|
877
|
+
stepFailed = true;
|
|
878
|
+
if (pollError && stepFailMsg === undefined)
|
|
879
|
+
stepFailMsg = pollError;
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
// Recursive step-list runner (node parity: runStepList). A prior failure
|
|
883
|
+
// skips the rest (skipped step_end tree); branch steps make a decision + recurse.
|
|
884
|
+
const runStepList = async (steps) => {
|
|
885
|
+
for (const step of steps) {
|
|
886
|
+
if (stepFailed || skipRequest) {
|
|
887
|
+
emitSkippedTree([step]);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const branch = branchOf(step);
|
|
891
|
+
if (branch) {
|
|
892
|
+
await runBranchStep(step, branch);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const poll = pollOf(step);
|
|
896
|
+
if (poll) {
|
|
897
|
+
await runPollStep(step, poll);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
await runNormalStep(step);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
try {
|
|
904
|
+
if (def.setup) {
|
|
905
|
+
events.emit({ type: "log", id: scope.testMeta.id, message: "Running setup..." });
|
|
906
|
+
state = await def.setup(ctx);
|
|
907
|
+
}
|
|
908
|
+
await runStepList((def.steps ?? []));
|
|
909
|
+
}
|
|
910
|
+
finally {
|
|
911
|
+
// teardown runs even when setup/a step throws (builder contract); its
|
|
912
|
+
// own errors never fail the run (parity with the node harness). (codex P2)
|
|
913
|
+
if (def.teardown) {
|
|
914
|
+
events.emit({ type: "log", id: scope.testMeta.id, message: "Running teardown..." });
|
|
915
|
+
try {
|
|
916
|
+
await def.teardown(ctx, state);
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
events.emit({
|
|
920
|
+
type: "log",
|
|
921
|
+
id: scope.testMeta.id,
|
|
922
|
+
message: `Teardown error: ${e instanceof Error ? e.message : String(e)}`,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// A step / branch-predicate called ctx.skip() (recorded without a throw):
|
|
928
|
+
// after teardown the whole test is skipped (node parity: harness.ts:2692).
|
|
929
|
+
if (skipRequest) {
|
|
930
|
+
skipped = true;
|
|
931
|
+
skipReason = skipRequest.reason;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
catch (e) {
|
|
936
|
+
if (e instanceof SkipError) {
|
|
937
|
+
// ctx.skip() from a simple test (or setup) propagates as a throw → skipped.
|
|
938
|
+
skipped = true;
|
|
939
|
+
skipReason = e.reason;
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
threw = true;
|
|
943
|
+
thrownError = e instanceof Error ? e.message : String(e);
|
|
944
|
+
thrownStack = e instanceof Error ? e.stack : undefined;
|
|
945
|
+
thrownName = e instanceof Error ? e.name : undefined;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// ctx.skip() wins over any partial assertion bookkeeping: a skipped test has no
|
|
949
|
+
// verdict (the host re-raises its own SkipError(reason) so the dispatcher emits
|
|
950
|
+
// the same skipped status as legacy). assertions are carried for completeness.
|
|
951
|
+
if (skipped) {
|
|
952
|
+
return {
|
|
953
|
+
id: scope.testMeta.id,
|
|
954
|
+
name: def.meta.name ?? def.meta.id,
|
|
955
|
+
status: "skipped",
|
|
956
|
+
...(skipReason !== undefined ? { skipReason } : {}),
|
|
957
|
+
assertions: { ...scope.assertions },
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
// The engine does NOT emit a status EVENT — status emission is host policy:
|
|
961
|
+
// the runner emits "completed" at dispatch (and re-raises a throw so its own
|
|
962
|
+
// dispatcher reports "failed" + exit 1), the browser derives status from the
|
|
963
|
+
// RESULT. Emitting here would force one shape on both hosts and double the
|
|
964
|
+
// runner's status event (plan 0005 / codex P2).
|
|
965
|
+
const assertionsFailed = scope.assertions.total > scope.assertions.passed;
|
|
966
|
+
// Steps tests fail via stepFailed (the LAST attempt of each step) — NOT the
|
|
967
|
+
// cumulative assertion count, so a step that fails then RETRIES to success does
|
|
968
|
+
// not fail the run. Simple tests use the cumulative count (soft-fail).
|
|
969
|
+
const verdict = threw || (def.type === "steps" ? stepFailed : assertionsFailed) ? "error" : "ok";
|
|
970
|
+
return {
|
|
971
|
+
id: scope.testMeta.id,
|
|
972
|
+
name: def.meta.name ?? def.meta.id,
|
|
973
|
+
status: verdict,
|
|
974
|
+
threw,
|
|
975
|
+
// A branch decision failure promotes its message over "One or more steps failed"
|
|
976
|
+
// (node parity: harness throws `branchDecisionError ?? "One or more steps failed"`).
|
|
977
|
+
...(stepFailed ? { stepsFailed: true, stepsFailMessage: branchDecisionError ?? "One or more steps failed" } : {}),
|
|
978
|
+
error: verdict === "ok" ? undefined : threw ? thrownError : (stepFailMsg ?? "assertion failed"),
|
|
979
|
+
// Preserve the user's original throw stack + name so a host can re-raise with
|
|
980
|
+
// them (stack diagnostics + failure classification parity — codex P2).
|
|
981
|
+
...(threw && thrownStack ? { errorStack: thrownStack } : {}),
|
|
982
|
+
...(threw && thrownName ? { errorName: thrownName } : {}),
|
|
983
|
+
assertions: { ...scope.assertions },
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
makeCtx(scope) {
|
|
987
|
+
const emit = (e) => this.services.events.emit(e);
|
|
988
|
+
// Aggressive array/string truncation of a PASSING assertion's actual/expected,
|
|
989
|
+
// to save tokens (node parity: harness.ts:718 — only on pass, full kept on fail).
|
|
990
|
+
const truncateArrays = this.services.http?.truncateArrays ?? false;
|
|
991
|
+
// Events emitted DURING a step carry its 0-based index (node parity); outside a
|
|
992
|
+
// step (simple tests / setup / teardown) they carry none.
|
|
993
|
+
const emitStep = (e) => emit((scope.currentStepIndex !== null ? { ...e, stepIndex: scope.currentStepIndex } : e));
|
|
994
|
+
// Extracted so ctx.validate's failure routing reuses the SAME assert/warn paths
|
|
995
|
+
// as ctx.assert / ctx.warn (node parity: harness's runSchemaValidation calls
|
|
996
|
+
// ctx.assert / ctx.warn) — same counters, same stepIndex attribution.
|
|
997
|
+
const assertFn = (condition, message, details) => {
|
|
998
|
+
const result = condition && typeof condition === "object" && "passed" in condition
|
|
999
|
+
? condition
|
|
1000
|
+
: { passed: !!condition };
|
|
1001
|
+
const d = (details ?? {});
|
|
1002
|
+
// `in` not `??` so an intentional null actual/expected keeps its
|
|
1003
|
+
// diagnostic value (codex B4 P3).
|
|
1004
|
+
const actual = "actual" in result ? result.actual : d.actual;
|
|
1005
|
+
const expected = "expected" in result ? result.expected : d.expected;
|
|
1006
|
+
scope.assertions.total += 1;
|
|
1007
|
+
if (result.passed)
|
|
1008
|
+
scope.assertions.passed += 1;
|
|
1009
|
+
emitStep({
|
|
1010
|
+
type: "assertion",
|
|
1011
|
+
id: scope.testMeta.id,
|
|
1012
|
+
passed: result.passed,
|
|
1013
|
+
// Truncate on PASS to save tokens; keep full on FAIL for debugging (node parity).
|
|
1014
|
+
actual: result.passed && truncateArrays ? truncateDeep(actual) : actual,
|
|
1015
|
+
expected: result.passed && truncateArrays ? truncateDeep(expected) : expected,
|
|
1016
|
+
// Parity with the node harness default (gap C).
|
|
1017
|
+
message: message ?? (result.passed ? "Assertion passed" : "Assertion failed"),
|
|
1018
|
+
});
|
|
1019
|
+
};
|
|
1020
|
+
const warnFn = (condition, message) =>
|
|
1021
|
+
// First-class warning event (node parity) — NOT a log. condition=true means OK.
|
|
1022
|
+
emitStep({ type: "warning", id: scope.testMeta.id, condition: !!condition, message: message ?? "" });
|
|
1023
|
+
// Extracted so ctx.trace's derived action reuses the SAME action path as ctx.action.
|
|
1024
|
+
const actionFn = (a) => emitStep({ type: "action", id: scope.testMeta.id, data: a });
|
|
1025
|
+
return {
|
|
1026
|
+
http: scope.http,
|
|
1027
|
+
// Route through assertFn (like the node harness routes expect → ctx.assert,
|
|
1028
|
+
// harness.ts:725-737) so the counters, default message, and truncate-on-pass
|
|
1029
|
+
// behave identically — one assertion implementation.
|
|
1030
|
+
expect: (actual) => new Expectation(actual, (r) => assertFn({ passed: r.passed, actual: r.actual, expected: r.expected }, r.message)),
|
|
1031
|
+
assert: assertFn,
|
|
1032
|
+
warn: warnFn,
|
|
1033
|
+
validate: (data, schema, label, options) => {
|
|
1034
|
+
const r = runSchemaValidation(data, schema, label ?? "data", options?.severity ?? "error", {
|
|
1035
|
+
emitSchemaValidation: (p) => emitStep({ type: "schema_validation", id: scope.testMeta.id, ...p }),
|
|
1036
|
+
assert: (passed, msg) => assertFn(passed, msg),
|
|
1037
|
+
warn: (cond, msg) => warnFn(cond, msg),
|
|
1038
|
+
});
|
|
1039
|
+
return r.success ? r.data : undefined;
|
|
1040
|
+
},
|
|
1041
|
+
vars: {
|
|
1042
|
+
get: (k) => scope.runtime.vars[k],
|
|
1043
|
+
require: (k, validate) => {
|
|
1044
|
+
// Empty = missing (node parity: a "" .env value with no fallback is unset).
|
|
1045
|
+
// Check the VALUE, not `k in vars`, since a fallback Proxy may report an
|
|
1046
|
+
// empty key as present (codex). Message matches the node harness.
|
|
1047
|
+
const v = scope.runtime.vars[k];
|
|
1048
|
+
if (v === undefined || v === "")
|
|
1049
|
+
throw new Error(`Missing required var: ${k}`);
|
|
1050
|
+
if (validate)
|
|
1051
|
+
runEngineValidator(validate(v), k, "var");
|
|
1052
|
+
return v;
|
|
1053
|
+
},
|
|
1054
|
+
// A copy of all vars (node parity: harness ctx.vars.all → {...rawVars}). Returns
|
|
1055
|
+
// the RAW snapshot (host raw map + input overlay), NOT a spread of the fallback
|
|
1056
|
+
// Proxy — so empty / overlay-only vars survive identically (codex Phase-8 P2).
|
|
1057
|
+
all: () => ({ ...scope.varsAll }),
|
|
1058
|
+
},
|
|
1059
|
+
secrets: {
|
|
1060
|
+
get: (k) => scope.runtime.secrets[k],
|
|
1061
|
+
require: (k, validate) => {
|
|
1062
|
+
const v = scope.runtime.secrets[k];
|
|
1063
|
+
if (v === undefined || v === "")
|
|
1064
|
+
throw new Error(`Missing required secret: ${k}`);
|
|
1065
|
+
if (validate)
|
|
1066
|
+
runEngineValidator(validate(v), k, "secret");
|
|
1067
|
+
return v;
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
session: {
|
|
1071
|
+
get: (k) => scope.session[k],
|
|
1072
|
+
// node parity: harness.ts:652 — throws with the exact same message.
|
|
1073
|
+
require: (k) => {
|
|
1074
|
+
const v = scope.session[k];
|
|
1075
|
+
if (v === undefined) {
|
|
1076
|
+
throw new Error(`Session key '${k}' is required but not set. Check your session.ts setup.`);
|
|
1077
|
+
}
|
|
1078
|
+
return v;
|
|
1079
|
+
},
|
|
1080
|
+
set: (k, v) => {
|
|
1081
|
+
scope.session[k] = v;
|
|
1082
|
+
// Surface the update so a host can propagate it (node: forward to sibling
|
|
1083
|
+
// tests; browser: update its store). Without this it'd be lost (codex P2).
|
|
1084
|
+
emit({ type: "session_set", id: scope.testMeta.id, key: k, value: v });
|
|
1085
|
+
},
|
|
1086
|
+
entries: () => ({ ...scope.session }),
|
|
1087
|
+
},
|
|
1088
|
+
log: (message, data) => emitStep({ type: "log", id: scope.testMeta.id, message, data }),
|
|
1089
|
+
// ctx.metric — numeric perf metric. unit/tags are emitted as-is (undefined keys
|
|
1090
|
+
// drop on the wire) to match the node harness exactly (harness.ts:818).
|
|
1091
|
+
metric: (name, value, options) => emitStep({ type: "metric", id: scope.testMeta.id, name, value, unit: options?.unit, tags: options?.tags }),
|
|
1092
|
+
// ctx.action — typed interaction record (node parity: harness.ts:790).
|
|
1093
|
+
action: actionFn,
|
|
1094
|
+
// ctx.trace — protocol trace event + a derived `{protocol}:request` action
|
|
1095
|
+
// (node parity: harness.ts:767-787). The HTTP auto-trace routes through here.
|
|
1096
|
+
trace: (request) => {
|
|
1097
|
+
emitStep({ type: "trace", id: scope.testMeta.id, data: request });
|
|
1098
|
+
const protocol = request.protocol ?? "http";
|
|
1099
|
+
const actionTarget = request.target ??
|
|
1100
|
+
(request.method && request.url ? `${request.method} ${pathnameOf(request.url)}` : "unknown");
|
|
1101
|
+
const actionDuration = request.durationMs ?? request.duration ?? 0;
|
|
1102
|
+
const actionOk = request.ok ?? (typeof request.status === "number" && request.status < 400);
|
|
1103
|
+
actionFn({
|
|
1104
|
+
category: `${protocol}:request`,
|
|
1105
|
+
target: actionTarget,
|
|
1106
|
+
duration: actionDuration,
|
|
1107
|
+
status: actionOk ? "ok" : "error",
|
|
1108
|
+
detail: { status: request.status },
|
|
1109
|
+
});
|
|
1110
|
+
},
|
|
1111
|
+
// ctx.event — generic structured event. The workflow first-class unwrap is
|
|
1112
|
+
// workflow-only → node-legacy (workflow is never engine-routed), so the engine
|
|
1113
|
+
// always emits the generic shape (node parity: harness.ts:804 fall-through).
|
|
1114
|
+
event: (ev) => emitStep({ type: "event", id: scope.testMeta.id, data: ev }),
|
|
1115
|
+
// ctx.setTimeout(ms) — a CONTROL event (not step-scoped): the parent re-arms its
|
|
1116
|
+
// SIGTERM deadline (node parity: harness.ts:898). No stepIndex, like legacy.
|
|
1117
|
+
setTimeout: (ms) => emit({ type: "timeout_update", id: scope.testMeta.id, timeout: ms }),
|
|
1118
|
+
// ctx.pollUntil — poll fn until truthy or timeout; on timeout call onTimeout
|
|
1119
|
+
// (silent) or throw (node parity: harness.ts:860). Emits no events itself (fn's
|
|
1120
|
+
// assertions emit as usual). Uses the injected clock + setTimeout for the wait.
|
|
1121
|
+
pollUntil: async (options, fn) => {
|
|
1122
|
+
const { timeoutMs, intervalMs = 1000, onTimeout } = options;
|
|
1123
|
+
const deadline = this.services.scheduler.now() + timeoutMs;
|
|
1124
|
+
let lastError;
|
|
1125
|
+
while (this.services.scheduler.now() < deadline) {
|
|
1126
|
+
try {
|
|
1127
|
+
if (await fn())
|
|
1128
|
+
return; // truthy → done
|
|
1129
|
+
}
|
|
1130
|
+
catch (err) {
|
|
1131
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1132
|
+
}
|
|
1133
|
+
const remaining = deadline - this.services.scheduler.now();
|
|
1134
|
+
if (remaining <= 0)
|
|
1135
|
+
break;
|
|
1136
|
+
await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)));
|
|
1137
|
+
}
|
|
1138
|
+
if (onTimeout) {
|
|
1139
|
+
onTimeout(lastError);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
throw new Error(`pollUntil timed out after ${timeoutMs}ms${lastError ? `: ${lastError.message}` : ""}`);
|
|
1143
|
+
},
|
|
1144
|
+
// ctx.skip(reason?) — throws; the run-loop turns it into a `skipped` verdict.
|
|
1145
|
+
skip: (reason) => {
|
|
1146
|
+
throw new SkipError(reason);
|
|
1147
|
+
},
|
|
1148
|
+
// ctx.fail(message) — emit a failed assertion THEN throw. The assertion is
|
|
1149
|
+
// emitted WITHOUT a stepIndex even inside a step (node parity: harness ctx.fail
|
|
1150
|
+
// uses the bare emit, not the step-scoped one), but it still bumps the assertion
|
|
1151
|
+
// counter so a step/branch leg records the failure even if the throw is caught.
|
|
1152
|
+
fail: (message) => {
|
|
1153
|
+
scope.assertions.total += 1;
|
|
1154
|
+
emit({ type: "assertion", id: scope.testMeta.id, passed: false, message });
|
|
1155
|
+
throw new FailError(message);
|
|
1156
|
+
},
|
|
1157
|
+
retryCount: scope.retryCount,
|
|
1158
|
+
// node parity: harness ctx.getMemoryUsage → process.memoryUsage(). Reached via
|
|
1159
|
+
// globalThis (NOT a node:* import) so the engine stays browser-safe — null where
|
|
1160
|
+
// process.memoryUsage is unavailable (browser).
|
|
1161
|
+
getMemoryUsage: () => {
|
|
1162
|
+
const proc = globalThis.process;
|
|
1163
|
+
return typeof proc?.memoryUsage === "function" ? proc.memoryUsage() : null;
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/** Resolve a SchemaEntry to {schema, severity} — a bare schema defaults to "error"
|
|
1169
|
+
* (node parity: harness resolveSchemaEntry). */
|
|
1170
|
+
function resolveSchemaEntry(entry) {
|
|
1171
|
+
if (entry && typeof entry === "object" && "schema" in entry && entry.schema != null) {
|
|
1172
|
+
const obj = entry;
|
|
1173
|
+
return { schema: obj.schema, severity: obj.severity ?? "error" };
|
|
1174
|
+
}
|
|
1175
|
+
return { schema: entry, severity: "error" };
|
|
1176
|
+
}
|
|
1177
|
+
/** Normalize a HeadersInit (Headers / plain object / tuple array) → Record<string,
|
|
1178
|
+
* string> for schema validation (node parity: harness normalizeHeadersForValidation). */
|
|
1179
|
+
function normalizeHeadersForValidation(headers) {
|
|
1180
|
+
if (headers == null)
|
|
1181
|
+
return {};
|
|
1182
|
+
if (typeof Headers !== "undefined" && headers instanceof Headers)
|
|
1183
|
+
return Object.fromEntries(headers.entries());
|
|
1184
|
+
if (Array.isArray(headers)) {
|
|
1185
|
+
return Object.fromEntries(headers.filter((p) => Array.isArray(p) && p.length === 2));
|
|
1186
|
+
}
|
|
1187
|
+
if (typeof headers === "object") {
|
|
1188
|
+
const out = {};
|
|
1189
|
+
for (const [k, v] of Object.entries(headers))
|
|
1190
|
+
if (v != null)
|
|
1191
|
+
out[k] = String(v);
|
|
1192
|
+
return out;
|
|
1193
|
+
}
|
|
1194
|
+
return {};
|
|
1195
|
+
}
|
|
1196
|
+
/** Strip a leading '/' from a relative path so ky's `prefix` join works (node parity:
|
|
1197
|
+
* harness normalizeUrl). A protocol-relative '//host' is left intact. */
|
|
1198
|
+
function normalizeUrl(input) {
|
|
1199
|
+
if (typeof input === "string" && input.startsWith("/") && !input.startsWith("//"))
|
|
1200
|
+
return input.slice(1);
|
|
1201
|
+
return input;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Wrap a per-run ky instance into the public ctx.http facade. A Proxy preserves the
|
|
1205
|
+
* full KyInstance surface and adds, on calls + `.extend()`:
|
|
1206
|
+
* - prefixUrl → ky 2 `prefix` + empty-searchParams removal (node parity: harness
|
|
1207
|
+
* normalizeOptions / normalizeUrl).
|
|
1208
|
+
* - automatic schema validation: request body / query / request headers BEFORE the
|
|
1209
|
+
* request, response headers via an injected afterResponse hook, and response body
|
|
1210
|
+
* by monkey-patching the response promise's `.json()` — all routed through the
|
|
1211
|
+
* run's ctx.validate (→ schema_validation event + severity routing), node parity:
|
|
1212
|
+
* harness wrapKy / runPreRequestSchemaValidation / wrapResponseWithSchema.
|
|
1213
|
+
*/
|
|
1214
|
+
function wrapScopedKy(instance, scope) {
|
|
1215
|
+
// prefixUrl → ky 2 `prefix`, and drop an empty searchParams (no bare '?'). `schema`
|
|
1216
|
+
// is RETAINED here (callWithSchema reads it, then strips it before handing to ky).
|
|
1217
|
+
const normalizeKyOptions = (opts) => {
|
|
1218
|
+
if (!opts || typeof opts !== "object")
|
|
1219
|
+
return opts;
|
|
1220
|
+
const n = { ...opts };
|
|
1221
|
+
if ("prefixUrl" in n) {
|
|
1222
|
+
n.prefix = n.prefixUrl;
|
|
1223
|
+
delete n.prefixUrl;
|
|
1224
|
+
}
|
|
1225
|
+
const sp = n.searchParams;
|
|
1226
|
+
if (sp != null) {
|
|
1227
|
+
const empty = (typeof URLSearchParams !== "undefined" && sp instanceof URLSearchParams && sp.toString() === "") ||
|
|
1228
|
+
(typeof sp === "string" && sp === "") ||
|
|
1229
|
+
(typeof sp === "object" && !(sp instanceof URLSearchParams) && !Array.isArray(sp) && Object.keys(sp).length === 0);
|
|
1230
|
+
if (empty)
|
|
1231
|
+
delete n.searchParams;
|
|
1232
|
+
}
|
|
1233
|
+
return n;
|
|
1234
|
+
};
|
|
1235
|
+
// Route a schema validation through the run's ctx (→ schema_validation event +
|
|
1236
|
+
// severity routing). A request before makeCtx never happens during a run, so the
|
|
1237
|
+
// optional-chain is just defensive.
|
|
1238
|
+
const validate = (data, entry, label) => {
|
|
1239
|
+
const { schema, severity } = resolveSchemaEntry(entry);
|
|
1240
|
+
scope.ctxRef?.validate(data, schema, label, { severity });
|
|
1241
|
+
};
|
|
1242
|
+
const preRequest = (opts) => {
|
|
1243
|
+
const schemaOpts = opts?.schema;
|
|
1244
|
+
if (!schemaOpts)
|
|
1245
|
+
return;
|
|
1246
|
+
if (schemaOpts.query && opts?.searchParams != null)
|
|
1247
|
+
validate(opts.searchParams, schemaOpts.query, "query params");
|
|
1248
|
+
if (schemaOpts.request && opts?.json !== undefined)
|
|
1249
|
+
validate(opts.json, schemaOpts.request, "request body");
|
|
1250
|
+
if (schemaOpts.requestHeaders && opts?.headers != null) {
|
|
1251
|
+
validate(normalizeHeadersForValidation(opts.headers), schemaOpts.requestHeaders, "request headers");
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
// Strip the non-ky `schema` option, injecting a response-headers afterResponse hook
|
|
1255
|
+
// when a responseHeaders schema is present (fires once per final response).
|
|
1256
|
+
const toKyOptions = (opts) => {
|
|
1257
|
+
if (!opts)
|
|
1258
|
+
return opts;
|
|
1259
|
+
const schemaOpts = opts.schema;
|
|
1260
|
+
const { schema: _schema, ...rest } = opts;
|
|
1261
|
+
let kyOptions = rest;
|
|
1262
|
+
if (schemaOpts?.responseHeaders) {
|
|
1263
|
+
const entry = schemaOpts.responseHeaders;
|
|
1264
|
+
const hook = ({ response }) => validate(normalizeHeadersForValidation(response.headers), entry, "response headers");
|
|
1265
|
+
const hooks = (kyOptions.hooks ?? {});
|
|
1266
|
+
kyOptions = { ...kyOptions, hooks: { ...hooks, afterResponse: [...(hooks.afterResponse ?? []), hook] } };
|
|
1267
|
+
}
|
|
1268
|
+
return kyOptions;
|
|
1269
|
+
};
|
|
1270
|
+
// Monkey-patch the ky ResponsePromise's `.json()` shortcut to validate the parsed
|
|
1271
|
+
// body. EXACT node-harness parity (harness.ts:1249-1268): only the promise shortcut
|
|
1272
|
+
// (`http.get(url, {schema}).json()`) is patched, NOT the resolved Response — so an
|
|
1273
|
+
// `await`-first-then-`res.json()` flow bypasses response-schema validation on BOTH
|
|
1274
|
+
// legs identically (codex 4f P2). This is a shared legacy limitation, not an engine
|
|
1275
|
+
// divergence; "fixing" it engine-only would break byte-parity. Any real fix must
|
|
1276
|
+
// land in both legs (post-cutover), so the engine intentionally mirrors legacy here.
|
|
1277
|
+
const wrapResponse = (promise, opts) => {
|
|
1278
|
+
const schemaOpts = opts?.schema;
|
|
1279
|
+
if (!schemaOpts?.response)
|
|
1280
|
+
return promise;
|
|
1281
|
+
const entry = schemaOpts.response;
|
|
1282
|
+
const originalJson = promise.json.bind(promise);
|
|
1283
|
+
promise.json = async () => {
|
|
1284
|
+
const body = await originalJson();
|
|
1285
|
+
validate(body, entry, "response body");
|
|
1286
|
+
return body;
|
|
1287
|
+
};
|
|
1288
|
+
return promise;
|
|
1289
|
+
};
|
|
1290
|
+
const callWithSchema = (kyCall, input, opts) => {
|
|
1291
|
+
const normalized = normalizeKyOptions(opts);
|
|
1292
|
+
preRequest(normalized);
|
|
1293
|
+
const promise = kyCall(normalizeUrl(input), toKyOptions(normalized));
|
|
1294
|
+
return wrapResponse(promise, normalized);
|
|
1295
|
+
};
|
|
1296
|
+
return new Proxy(instance, {
|
|
1297
|
+
apply: (target, _thisArg, args) => callWithSchema((u, o) => target(u, o), args[0], args[1]),
|
|
1298
|
+
get: (target, prop, recv) => {
|
|
1299
|
+
if (prop === "extend") {
|
|
1300
|
+
return (opts) => wrapScopedKy(target.extend(typeof opts === "function"
|
|
1301
|
+
? ((parent) => normalizeKyOptions(opts(parent)))
|
|
1302
|
+
: normalizeKyOptions(opts)), scope);
|
|
1303
|
+
}
|
|
1304
|
+
const value = Reflect.get(target, prop, recv);
|
|
1305
|
+
if (typeof value === "function" && typeof prop === "string" && HTTP_METHODS.has(prop)) {
|
|
1306
|
+
return (url, opts) => callWithSchema((u, o) => value.call(target, u, o), url, opts);
|
|
1307
|
+
}
|
|
1308
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
// Only the Stage-1 supported markers — a `workflow-builder` also has __glubean_type
|
|
1313
|
+
// + build(), but the narrow engine has no workflow ctx, so building it would yield
|
|
1314
|
+
// a def that fails at runtime. Leave workflows unresolved until Stage 2. (codex B4 P2)
|
|
1315
|
+
function isBuilder(v) {
|
|
1316
|
+
const marker = v?.__glubean_type;
|
|
1317
|
+
return (!!v &&
|
|
1318
|
+
typeof v === "object" &&
|
|
1319
|
+
typeof v.build === "function" &&
|
|
1320
|
+
(marker === "builder" || marker === "each-builder"));
|
|
1321
|
+
}
|
|
1322
|
+
function isSdkTest(v) {
|
|
1323
|
+
return (!!v &&
|
|
1324
|
+
typeof v === "object" &&
|
|
1325
|
+
"meta" in v &&
|
|
1326
|
+
"type" in v &&
|
|
1327
|
+
(v.type === "simple" || v.type === "steps"));
|
|
1328
|
+
}
|
|
1329
|
+
// TestBuilder → Test, EachBuilder → Test[]. Otherwise pass through (workflow
|
|
1330
|
+
// builders are NOT built — see isBuilder).
|
|
1331
|
+
function autoResolveDef(v) {
|
|
1332
|
+
return isBuilder(v) ? v.build() : v;
|
|
1333
|
+
}
|
|
1334
|
+
// A built workflow handle is an array tagged __glubean_type "workflow" (builder.ts).
|
|
1335
|
+
// Workflows are Stage 2 (no workflow ctx here), so exclude them entirely. (codex B4 P2)
|
|
1336
|
+
function isWorkflowHandle(v) {
|
|
1337
|
+
return !!v && typeof v === "object" && v.__glubean_type === "workflow";
|
|
1338
|
+
}
|
|
1339
|
+
// test.extend() tests carry a non-empty `fixtures` map. Fixtures are Stage 2;
|
|
1340
|
+
// excluding keeps us from running an extended test without its fixtures. (codex B4 P2)
|
|
1341
|
+
function hasFixtures(v) {
|
|
1342
|
+
const f = v?.fixtures;
|
|
1343
|
+
return !!f && typeof f === "object" && Object.keys(f).length > 0;
|
|
1344
|
+
}
|
|
1345
|
+
// A resolved value the narrow Stage-1 engine can actually run.
|
|
1346
|
+
function pushIfRunnable(v, out) {
|
|
1347
|
+
if (isWorkflowHandle(v) || !isSdkTest(v) || hasFixtures(v))
|
|
1348
|
+
return;
|
|
1349
|
+
out.push(toTestDef(v));
|
|
1350
|
+
}
|
|
1351
|
+
function collectDefs(value, out) {
|
|
1352
|
+
if (isWorkflowHandle(value))
|
|
1353
|
+
return;
|
|
1354
|
+
const resolved = autoResolveDef(value);
|
|
1355
|
+
if (isWorkflowHandle(resolved))
|
|
1356
|
+
return;
|
|
1357
|
+
if (isSdkTest(resolved)) {
|
|
1358
|
+
pushIfRunnable(resolved, out);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (Array.isArray(resolved)) {
|
|
1362
|
+
for (const item of resolved) {
|
|
1363
|
+
if (isWorkflowHandle(item))
|
|
1364
|
+
continue;
|
|
1365
|
+
const r = autoResolveDef(item);
|
|
1366
|
+
if (isWorkflowHandle(r))
|
|
1367
|
+
continue;
|
|
1368
|
+
if (Array.isArray(r)) {
|
|
1369
|
+
for (const inner of r)
|
|
1370
|
+
pushIfRunnable(inner, out);
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
pushIfRunnable(r, out);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
export function toTestDef(t) {
|
|
1379
|
+
const tags = Array.isArray(t.meta.tags) ? t.meta.tags : typeof t.meta.tags === "string" ? [t.meta.tags] : undefined;
|
|
1380
|
+
// SDK step fns take the SDK TestContext; the engine provides the narrow subset
|
|
1381
|
+
// at runtime, so the cast is sound for the Stage-1 surface.
|
|
1382
|
+
return {
|
|
1383
|
+
meta: { id: t.meta.id, name: t.meta.name, tags, skip: t.meta.skip, only: t.meta.only },
|
|
1384
|
+
type: t.type,
|
|
1385
|
+
fn: t.fn,
|
|
1386
|
+
setup: t.setup,
|
|
1387
|
+
steps: t.steps,
|
|
1388
|
+
teardown: t.teardown,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
//# sourceMappingURL=engine.js.map
|