@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.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