@glubean/runner 0.5.0 → 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.
@@ -18,6 +18,8 @@ export function generateSummary(events) {
18
18
  let schemaValidationTotal = 0;
19
19
  let schemaValidationFailed = 0;
20
20
  let schemaValidationWarnings = 0;
21
+ const nodeVerdicts = [];
22
+ const lastIndexByNodeId = new Map();
21
23
  for (const e of events) {
22
24
  switch (e.type) {
23
25
  case "assertion":
@@ -42,6 +44,23 @@ export function generateSummary(events) {
42
44
  else if (e.status === "skipped")
43
45
  stepSkipped++;
44
46
  break;
47
+ case "node_end": {
48
+ // A group bracket is DISPLAY-ONLY (phase4 §2): its members each emit
49
+ // their own node_end — counting the container would double-count and
50
+ // dilute grades. Render layers still see the bracket on the timeline.
51
+ if (e.kind === "group")
52
+ break;
53
+ const verdict = { status: e.status, grade: e.grade };
54
+ const retryOfPrevious = e.attempt !== undefined && e.attempt > 1 && lastIndexByNodeId.has(e.nodeId);
55
+ if (retryOfPrevious) {
56
+ nodeVerdicts[lastIndexByNodeId.get(e.nodeId)] = verdict;
57
+ }
58
+ else {
59
+ lastIndexByNodeId.set(e.nodeId, nodeVerdicts.length);
60
+ nodeVerdicts.push(verdict);
61
+ }
62
+ break;
63
+ }
45
64
  case "warning":
46
65
  warningTotal++;
47
66
  if (!e.condition)
@@ -64,12 +83,36 @@ export function generateSummary(events) {
64
83
  const httpErrorRate = httpRequestTotal > 0
65
84
  ? Math.round((httpErrorTotal / httpRequestTotal) * 10000) / 10000
66
85
  : 0;
86
+ // Resolve per-node verdicts (retry chains already folded above).
87
+ let nodePassed = 0;
88
+ let nodeFailed = 0;
89
+ let nodeSkipped = 0;
90
+ const nodeGrades = { full: 0, partial: 0, trace: 0, opaque: 0 };
91
+ for (const verdict of nodeVerdicts) {
92
+ if (verdict.status === "passed")
93
+ nodePassed++;
94
+ else if (verdict.status === "failed")
95
+ nodeFailed++;
96
+ else
97
+ nodeSkipped++;
98
+ nodeGrades[verdict.grade]++;
99
+ }
100
+ const nodeTotal = nodeVerdicts.length;
67
101
  // Derive success:
68
102
  // 1. Any error/status event → failure (crash, timeout, process exit)
69
103
  // These event types are not in TimelineEvent but may be present
70
104
  // when callers pass ExecutionEvent[] or GlubeanEvent[] via `as any`.
71
105
  // 2. If step_end events exist, use them as authority
72
- // 3. Otherwise fall back to assertion results
106
+ // 3. Else if node_end events exist (vNext workflow): node verdicts AND
107
+ // assertion counts must BOTH be clean. Verdicts catch what assertions
108
+ // can't (a thrown-node failure leaves no failed assertion); assertions
109
+ // catch what verdicts can't (a setup soft-failure skips every node, so
110
+ // no node is "failed" — and the wrapping test's error event is NOT in
111
+ // the timeline array, so a recomputed summary would otherwise pass).
112
+ // Retry noise can't poison this: a quarantined attempt's failed asserts
113
+ // never reach the host timeline (sdk S2.4c) — every failed assertion
114
+ // present is verdict-relevant.
115
+ // 4. Otherwise fall back to assertion results
73
116
  let success;
74
117
  const hasHardFailure = events.some((e) => {
75
118
  const t = e.type;
@@ -98,6 +141,9 @@ export function generateSummary(events) {
98
141
  if (hasStepEnds) {
99
142
  success = stepFailed === 0;
100
143
  }
144
+ else if (nodeTotal > 0) {
145
+ success = nodeFailed === 0 && assertionFailed === 0;
146
+ }
101
147
  else {
102
148
  success = assertionFailed === 0;
103
149
  }
@@ -112,6 +158,11 @@ export function generateSummary(events) {
112
158
  stepPassed,
113
159
  stepFailed,
114
160
  stepSkipped,
161
+ nodeTotal,
162
+ nodePassed,
163
+ nodeFailed,
164
+ nodeSkipped,
165
+ nodeGrades,
115
166
  warningTotal,
116
167
  warningTriggered,
117
168
  schemaValidationTotal,
@@ -1 +1 @@
1
- {"version":3,"file":"generate_summary.js","sourceRoot":"","sources":["../src/generate_summary.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"generate_summary.js","sourceRoot":"","sources":["../src/generate_summary.ts"],"names":[],"mappings":"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"}
package/dist/harness.js CHANGED
@@ -8,9 +8,14 @@
8
8
  import { parseArgs } from "node:util";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  import { inferJsonSchema, truncateDeep } from "./schema_inference.js";
11
+ import { USE_ENGINE, createEngineCore, runViaEngine, engineRoutesId } from "./engine-bridge.js";
11
12
  import { bootstrap } from "./bootstrap.js";
12
13
  import { loadProjectOverlays } from "@glubean/scanner";
13
14
  import { setRuntime, setExplicitInput, setBootstrapInput, setForceStandalone, } from "@glubean/sdk/internal";
15
+ // Workflow executor — now node-only and owned by this package (plan 0007). The host
16
+ // drives a built workflow via runWorkflow (invocation inversion) and attributes inline
17
+ // ctx.http traces to the active node scope via __activeWorkflowNodeCtx (the ALS rebind).
18
+ import { runWorkflow, WorkflowPhaseFailedError, __activeWorkflowNodeCtx, } from "./workflow/execute.js";
14
19
  import ky from "ky";
15
20
  import { isTestBranchStep, isTestPollStep } from "@glubean/sdk";
16
21
  import { Expectation } from "@glubean/sdk/expect";
@@ -189,14 +194,136 @@ function incrAssertions(passed) {
189
194
  * Use this for all test-scoped event output to ensure concurrent events can be
190
195
  * attributed to the correct test.
191
196
  */
197
+ /** Events that STEER THE PARENT while the test is still running — the
198
+ * executor re-arms the subprocess timeout on `timeout_update`, and
199
+ * ProjectRunner forwards `session:set` to sibling files. Holding them in the
200
+ * parallel buffer until the test finishes would defeat them (a long test
201
+ * would be killed at the OLD deadline — codex S2.12 R24 P2), so they bypass
202
+ * buffering. Out-of-order arrival is fine: both are keyed/merged by the
203
+ * parent, not attributed to a contiguous test block. */
204
+ const CONTROL_EVENT_TYPES = new Set(["timeout_update", "session:set"]);
192
205
  function emitEvent(event) {
193
206
  const trc = currentTestCtx();
194
- if (trc) {
195
- console.log(JSON.stringify({ ...event, testId: trc.testId }));
207
+ const json = JSON.stringify(trc ? { ...event, testId: trc.testId } : event);
208
+ if (CONTROL_EVENT_TYPES.has(event.type)) {
209
+ console.log(json);
210
+ return;
196
211
  }
197
- else {
198
- console.log(JSON.stringify(event));
212
+ writeEventLine(json);
213
+ }
214
+ // ── Parallel-batch event buffering (codex S2.12 R22 P1) ─────────────────────
215
+ // Under batchConcurrency > 1, tests run concurrently and their stdout events
216
+ // would interleave — downstream collectors (CLI render, result JSON) keep
217
+ // per-test state that assumes each test's start..status block is CONTIGUOUS.
218
+ // Each parallel task runs inside this ALS with its own line buffer; the task
219
+ // flushes the whole buffer atomically (sync loop — no awaits) when it
220
+ // finishes. Sequential runs never enter the ALS: zero behavior change.
221
+ const parallelEventBuffer = new AsyncLocalStorage();
222
+ function writeEventLine(json) {
223
+ const buf = parallelEventBuffer.getStore();
224
+ if (buf)
225
+ buf.push(json);
226
+ else
227
+ console.log(json);
228
+ }
229
+ /**
230
+ * Write a wire event produced by the engine path (runner-on-engine, plan 0005).
231
+ * Mirrors emitEvent's control-event bypass, but the event already carries its
232
+ * testId (the engine path runs outside the testContext ALS, so testId is sourced
233
+ * from the engine event's own id, not currentTestCtx()).
234
+ */
235
+ function emitEngineWire(ev) {
236
+ // Mirror the legacy ctx.session.set: update the subprocess-local sessionData so
237
+ // sibling tests in this process see it (batch mode), in addition to forwarding
238
+ // the control event to the parent (codex P2).
239
+ if (ev.type === "session:set") {
240
+ sessionData[ev.key] = ev.value;
199
241
  }
242
+ const json = JSON.stringify(ev);
243
+ if (CONTROL_EVENT_TYPES.has(ev.type)) {
244
+ console.log(json);
245
+ return;
246
+ }
247
+ writeEventLine(json);
248
+ }
249
+ // ── vNext workflow per-node evidence → first-class timeline events ──────────
250
+ // The SDK's workflow executor emits node evidence as namespaced GlubeanEvents
251
+ // over ctx.event (workflow:node_start / node_end / poll_attempt — see
252
+ // sdk/src/workflow/execute.ts §17 #9). The harness unwraps the three known
253
+ // shapes into first-class timeline events so node id + grade reach
254
+ // generateSummary and the Cloud payload directly (§17 #9/#10 consumption).
255
+ // Anything else — other `workflow:*` names, malformed payloads — returns null
256
+ // and stays a generic pass-through `event` (a misshapen payload must not mint
257
+ // a misshapen first-class event).
258
+ const NODE_STATUSES = new Set(["passed", "failed", "skipped"]);
259
+ const NODE_GRADES = new Set(["full", "partial", "trace", "opaque"]);
260
+ const POLL_OUTCOMES = new Set(["satisfied", "probe", "failed"]);
261
+ function workflowEventToTimeline(ev) {
262
+ if (ev.type !== "workflow:node_start" &&
263
+ ev.type !== "workflow:node_end" &&
264
+ ev.type !== "workflow:poll_attempt" &&
265
+ ev.type !== "workflow:branch_decision") {
266
+ return null;
267
+ }
268
+ const d = ev.data;
269
+ if (!d || typeof d.nodeId !== "string")
270
+ return null;
271
+ const attemptFields = {
272
+ ...(typeof d.attempt === "number" ? { attempt: d.attempt } : {}),
273
+ ...(typeof d.attempts === "number" ? { attempts: d.attempts } : {}),
274
+ };
275
+ if (ev.type === "workflow:node_start") {
276
+ return {
277
+ type: "node_start",
278
+ nodeId: d.nodeId,
279
+ kind: typeof d.kind === "string" ? d.kind : "unknown",
280
+ name: typeof d.name === "string" ? d.name : d.nodeId,
281
+ ...attemptFields,
282
+ };
283
+ }
284
+ if (ev.type === "workflow:node_end") {
285
+ if (typeof d.status !== "string" || !NODE_STATUSES.has(d.status))
286
+ return null;
287
+ if (typeof d.grade !== "string" || !NODE_GRADES.has(d.grade))
288
+ return null;
289
+ return {
290
+ type: "node_end",
291
+ nodeId: d.nodeId,
292
+ kind: typeof d.kind === "string" ? d.kind : "unknown",
293
+ name: typeof d.name === "string" ? d.name : d.nodeId,
294
+ status: d.status,
295
+ grade: d.grade,
296
+ durationMs: typeof d.durationMs === "number" ? d.durationMs : 0,
297
+ ...(typeof d.error === "string" ? { error: d.error } : {}),
298
+ ...attemptFields,
299
+ };
300
+ }
301
+ if (ev.type === "workflow:poll_attempt") {
302
+ if (typeof d.attempt !== "number" ||
303
+ typeof d.outcome !== "string" ||
304
+ !POLL_OUTCOMES.has(d.outcome)) {
305
+ return null;
306
+ }
307
+ return {
308
+ type: "poll_attempt",
309
+ nodeId: d.nodeId,
310
+ attempt: d.attempt,
311
+ outcome: d.outcome,
312
+ durationMs: typeof d.durationMs === "number" ? d.durationMs : 0,
313
+ };
314
+ }
315
+ // workflow:branch_decision (addendum §9 — branch/switch/route taken case)
316
+ if ((d.mode !== "predicate" && d.mode !== "value") ||
317
+ (typeof d.takenIndex !== "number" && d.takenIndex !== "default")) {
318
+ return null;
319
+ }
320
+ return {
321
+ type: "branch_decision",
322
+ nodeId: d.nodeId,
323
+ mode: d.mode,
324
+ takenIndex: d.takenIndex,
325
+ ...(typeof d.takenLabel === "string" ? { takenLabel: d.takenLabel } : {}),
326
+ };
200
327
  }
201
328
  /**
202
329
  * Start monitoring memory usage.
@@ -552,8 +679,18 @@ const ctx = {
552
679
  ...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
553
680
  });
554
681
  },
555
- // Structured event emission
682
+ // Structured event emission. The vNext workflow executor's per-node
683
+ // evidence (§17 #9) rides this channel as namespaced GlubeanEvents — the
684
+ // harness UNWRAPS the three known types into first-class timeline events
685
+ // (node id + grade reach generateSummary / the Cloud payload directly,
686
+ // not as a double-wrapped custom blob). Any other type — including other
687
+ // `workflow:*` names — stays a generic pass-through event.
556
688
  event: (ev) => {
689
+ const firstClass = workflowEventToTimeline(ev);
690
+ if (firstClass) {
691
+ emitEvent(firstClass);
692
+ return;
693
+ }
557
694
  emitEvent({
558
695
  type: "event",
559
696
  data: ev,
@@ -657,6 +794,24 @@ const ctx = {
657
794
  },
658
795
  };
659
796
  const requestTraceMap = new WeakMap();
797
+ // Capture the outgoing request body for full-trace mode. ky 2 no longer exposes
798
+ // `options.json`, so read it off a clone of the Request (parsed if JSON).
799
+ async function captureRequestBody(request) {
800
+ try {
801
+ const text = await request.clone().text();
802
+ if (!text)
803
+ return undefined;
804
+ try {
805
+ return JSON.parse(text);
806
+ }
807
+ catch {
808
+ return text;
809
+ }
810
+ }
811
+ catch {
812
+ return undefined;
813
+ }
814
+ }
660
815
  /** Max serialized body size (chars) to include in trace events. */
661
816
  const TRACE_BODY_MAX_SIZE = 1_048_576; // 1MB
662
817
  /**
@@ -722,18 +877,18 @@ const kyInstance = ky.create({
722
877
  retry: 0,
723
878
  hooks: {
724
879
  beforeRequest: [
725
- (_request, options) => {
726
- requestTraceMap.set(options, {
880
+ async ({ request, options }) => {
881
+ requestTraceMap.set(options.context, {
727
882
  startTime: performance.now(),
728
- body: emitFullTrace
729
- ? (options.json ?? options.body ?? undefined)
730
- : undefined,
883
+ body: emitFullTrace ? await captureRequestBody(request) : undefined,
731
884
  });
732
885
  },
733
886
  ],
734
887
  afterResponse: [
735
- async (request, _options, response) => {
736
- const trace = requestTraceMap.get(_options);
888
+ async ({ request, options, response }) => {
889
+ // `request` here is the final (possibly hook-replaced) request — correct
890
+ // for the trace target; the trace state is keyed by the stable context.
891
+ const trace = requestTraceMap.get(options.context);
737
892
  const duration = Math.round(performance.now() - (trace?.startTime ?? performance.now()));
738
893
  // Increment HTTP counters for summary
739
894
  {
@@ -807,17 +962,22 @@ const kyInstance = ky.create({
807
962
  }
808
963
  // Per-request state is on the options object; no global cleanup needed.
809
964
  }
810
- ctx.trace(traceData);
965
+ // Attribute to the active workflow node's scope when one is executing
966
+ // (the SDK's ctx.http rebind, §17 #10/#12): inline HTTP inside a
967
+ // workflow node promotes its grade and obeys the late-evidence
968
+ // quarantine. Outside a workflow node this is the closure ctx as ever.
969
+ const sink = __activeWorkflowNodeCtx() ?? ctx;
970
+ sink.trace(traceData);
811
971
  // Auto-metric for response time
812
972
  try {
813
973
  const pathname = new URL(request.url).pathname;
814
- ctx.metric("http_duration_ms", duration, {
974
+ sink.metric("http_duration_ms", duration, {
815
975
  unit: "ms",
816
976
  tags: { method: request.method, path: pathname },
817
977
  });
818
978
  }
819
979
  catch {
820
- ctx.metric("http_duration_ms", duration, {
980
+ sink.metric("http_duration_ms", duration, {
821
981
  unit: "ms",
822
982
  tags: { method: request.method },
823
983
  });
@@ -844,6 +1004,13 @@ function normalizeOptions(options) {
844
1004
  if (!options)
845
1005
  return options;
846
1006
  const normalized = { ...options };
1007
+ // ky 2 renamed `prefixUrl` → `prefix` (with the same join semantics we rely on
1008
+ // for "users" / "/users"). Glubean keeps `prefixUrl` as its public option and
1009
+ // translates here at the ky boundary (codex ky2 P2-5; not `baseUrl`).
1010
+ if (normalized.prefixUrl !== undefined) {
1011
+ normalized.prefix = normalized.prefixUrl;
1012
+ delete normalized.prefixUrl;
1013
+ }
847
1014
  // Remove empty searchParams so ky doesn't append a bare '?'
848
1015
  if (normalized.searchParams != null) {
849
1016
  if (normalized.searchParams instanceof URLSearchParams) {
@@ -947,7 +1114,7 @@ function wrapKy(instance, label = "base") {
947
1114
  const responseHeadersSchema = normalized?.schema?.responseHeaders;
948
1115
  if (responseHeadersSchema) {
949
1116
  const { schema, severity } = resolveSchemaEntry(responseHeadersSchema);
950
- const headersHook = (_req, _opts, response) => {
1117
+ const headersHook = ({ response }) => {
951
1118
  const headersObj = normalizeHeadersForValidation(response.headers);
952
1119
  runSchemaValidation(headersObj, schema, "response headers", severity);
953
1120
  };
@@ -1007,6 +1174,28 @@ function withEnvFallback(explicit) {
1007
1174
  },
1008
1175
  });
1009
1176
  }
1177
+ // runner-on-engine (plan 0005): when GLUBEAN_USE_ENGINE=1 the engine drives the
1178
+ // run-loop (executeNewTest delegates to RunnerCore). Construct it BEFORE
1179
+ // setRuntime so RunnerCore's ALS carrier is the one the SDK runtime fallback gets
1180
+ // set on — module-load configure() and the engine's runWithRuntime() then share a
1181
+ // single carrier (plan 0005 §接缝设计 / codex P1-5). Default OFF → no behavior change.
1182
+ const engineCore = USE_ENGINE
1183
+ ? createEngineCore(emitEngineWire, {
1184
+ // Pass the SAME fallback Proxies the legacy ctx/runtime use (.env →
1185
+ // process.env), so engine-mode ctx.vars/secrets keep the system-env fallback
1186
+ // (codex P2 / plan 0005 §E). The engine layers per-run input over these
1187
+ // without destroying the Proxy.
1188
+ vars: withEnvFallback(rawVars),
1189
+ secrets: withEnvFallback(rawSecrets),
1190
+ // RAW vars (un-proxied) so ctx.vars.all() returns {...rawVars} exactly — legacy
1191
+ // parity for empty/overlay vars (codex Phase-8 P2).
1192
+ varsRaw: rawVars,
1193
+ // Forward the trace policy so the engine's ky auto-trace capture matches legacy.
1194
+ emitFullTrace: !!emitFullTrace,
1195
+ inferSchema: !!inferSchema,
1196
+ truncateArrays: !!truncateArrays,
1197
+ })
1198
+ : undefined;
1010
1199
  setRuntime({
1011
1200
  vars: withEnvFallback(rawVars),
1012
1201
  secrets: withEnvFallback(rawSecrets),
@@ -1269,8 +1458,8 @@ try {
1269
1458
  testObj = findTestByExport(userModule, exportNamesMap[id]);
1270
1459
  }
1271
1460
  if (!testObj) {
1272
- console.log(JSON.stringify({ type: "start", id, name: id, testId: id }));
1273
- console.log(JSON.stringify({ type: "status", status: "failed", id, testId: id, error: `Test "${id}" not found in module` }));
1461
+ writeEventLine(JSON.stringify({ type: "start", id, name: id, testId: id }));
1462
+ writeEventLine(JSON.stringify({ type: "status", status: "failed", id, testId: id, error: `Test "${id}" not found in module` }));
1274
1463
  hasFailure = true;
1275
1464
  return;
1276
1465
  }
@@ -1279,12 +1468,12 @@ try {
1279
1468
  }
1280
1469
  catch (error) {
1281
1470
  if (error instanceof SkipError) {
1282
- console.log(JSON.stringify({ type: "status", status: "skipped", id, testId: id, reason: error.reason }));
1471
+ writeEventLine(JSON.stringify({ type: "status", status: "skipped", id, testId: id, reason: error.reason }));
1283
1472
  }
1284
1473
  else {
1285
1474
  hasFailure = true;
1286
1475
  const reason = classifyErrorReason(error);
1287
- console.log(JSON.stringify({
1476
+ writeEventLine(JSON.stringify({
1288
1477
  type: "status", status: "failed", id, testId: id,
1289
1478
  error: error instanceof Error ? error.message : String(error),
1290
1479
  stack: error instanceof Error ? error.stack : undefined,
@@ -1334,7 +1523,20 @@ try {
1334
1523
  const { default: PQueue } = await import("p-queue");
1335
1524
  const queue = new PQueue({ concurrency: batchConcurrency });
1336
1525
  for (const id of expandedIds) {
1337
- void queue.add(() => runOneTest(id));
1526
+ void queue.add(() => {
1527
+ const buf = [];
1528
+ return parallelEventBuffer.run(buf, async () => {
1529
+ try {
1530
+ await runOneTest(id);
1531
+ }
1532
+ finally {
1533
+ // Atomic flush: the loop is synchronous, so this test's whole
1534
+ // event block lands contiguously on stdout.
1535
+ for (const line of buf)
1536
+ console.log(line);
1537
+ }
1538
+ });
1539
+ });
1338
1540
  }
1339
1541
  await queue.onIdle();
1340
1542
  }
@@ -1483,7 +1685,96 @@ async function withFixtures(fixtures, baseCtx, runTest) {
1483
1685
  *
1484
1686
  * @param test The Test object to execute
1485
1687
  */
1688
+ /**
1689
+ * Structural gate for the engine path (plan 0005): Phase 0 routes ONLY simple
1690
+ * tests. Steps stay on legacy until the engine emits step_start/step_end + per-step
1691
+ * index/retry/timeout (Phase 1); branch/poll (Phase 2/3); test.extend() fixtures
1692
+ * and workflow later. Routing a steps test now would lose its step timeline and
1693
+ * skew summaries (codex P2). ctx-surface gaps (validate/metric/…) are managed by
1694
+ * only exercising migrated features under the flag.
1695
+ */
1696
+ function engineSupports(test) {
1697
+ if (test.fixtures && Object.keys(test.fixtures).length > 0)
1698
+ return false;
1699
+ // Built workflow / contract wrappers are simple-shaped Tests, but their fn emits
1700
+ // workflow:* events the LEGACY harness unwraps into node/poll timeline events (and
1701
+ // inbound contract is node-only) — the browser-safe engine doesn't, so keep them on
1702
+ // legacy even under route-all (codex Phase-3 P2; plan 0005 §scope). The SDK marks
1703
+ // these wrappers with __glubean_kind.
1704
+ if (test.__glubean_kind)
1705
+ return false;
1706
+ if (test.type === "simple")
1707
+ return true;
1708
+ if (test.type === "steps") {
1709
+ // Linear steps + retry/timeout (Phase 1) + branch (Phase 2) + poll (Phase 3).
1710
+ return stepsEngineSupported(test.steps ?? []);
1711
+ }
1712
+ return false;
1713
+ }
1714
+ function stepsEngineSupported(steps) {
1715
+ for (const step of steps) {
1716
+ // poll steps (Phase 3) are now engine-supported; recurse into branch cases for
1717
+ // any still-unsupported shape (none today — kept as a structural guard).
1718
+ if (isTestBranchStep(step)) {
1719
+ for (const c of step.branch.cases)
1720
+ if (!stepsEngineSupported(c.steps))
1721
+ return false;
1722
+ if (!stepsEngineSupported(step.branch.default ?? []))
1723
+ return false;
1724
+ }
1725
+ }
1726
+ return true;
1727
+ }
1486
1728
  async function executeNewTest(test) {
1729
+ // runner-on-engine (plan 0005): route to the engine only when (a) the engine is
1730
+ // active, (b) this test id is on the per-test allowlist (GLUBEAN_ENGINE_TESTIDS;
1731
+ // "*" = all at cutover) — so the flag never sends arbitrary production tests
1732
+ // through an incomplete ctx (codex), and (c) the test's shape is supported. The
1733
+ // engine owns its scope/carrier/per-event-id and runs OUTSIDE the legacy
1734
+ // testContext ALS (testId comes from each event's own id). Anything else → legacy.
1735
+ if (engineCore && engineRoutesId(test.meta.id) && engineSupports(test)) {
1736
+ // Wrap with the same memory monitoring as the legacy path so the final status
1737
+ // carries peakMemoryBytes/peakMemoryMB (codex P2).
1738
+ startMemoryMonitoring();
1739
+ const result = await runViaEngine(engineCore, test, { session: sessionData, retryCount });
1740
+ const peakBytes = stopMemoryMonitoring();
1741
+ // Status emission mirrors the legacy split: a throw re-raises so the dispatcher
1742
+ // reports "failed" + exit 1 (the engine swallows throws into a result); success
1743
+ // and skip emit their status here (plan 0005 / codex P2). A soft assertion
1744
+ // failure is NOT a throw → it "completed" (pass/fail is derived from the
1745
+ // assertion events downstream, as in the legacy path).
1746
+ if (result.threw) {
1747
+ const err = new Error(result.error ?? "test threw");
1748
+ // Re-raise with the user's original name + stack so classifyErrorReason() and
1749
+ // diagnostics match the legacy path (codex P2).
1750
+ if (result.errorName)
1751
+ err.name = result.errorName;
1752
+ if (result.errorStack)
1753
+ err.stack = result.errorStack;
1754
+ throw err;
1755
+ }
1756
+ if (result.stepsFailed) {
1757
+ // Node parity (harness.ts:2686): a steps test with any failed step throws after
1758
+ // teardown so the dispatcher reports failed + exit 1 — unlike a simple test's
1759
+ // soft assertion ("completes"). A branch-decision failure carries its own message.
1760
+ throw new Error(result.stepsFailMessage ?? "One or more steps failed");
1761
+ }
1762
+ if (result.status === "skipped") {
1763
+ // Re-raise a harness SkipError(reason) so the SAME dispatcher catch that
1764
+ // handles a legacy ctx.skip() emits the skipped status (with `reason`) — byte
1765
+ // parity with legacy, which throws skipRequest out to the dispatcher (plan 0005).
1766
+ throw new SkipError(result.skipReason);
1767
+ }
1768
+ emitEngineWire({
1769
+ type: "status",
1770
+ status: "completed",
1771
+ id: test.meta.id,
1772
+ testId: test.meta.id,
1773
+ peakMemoryBytes: peakBytes,
1774
+ peakMemoryMB: (peakBytes / 1024 / 1024).toFixed(2),
1775
+ });
1776
+ return;
1777
+ }
1487
1778
  const testTags = normalizeTestTags(test.meta.tags);
1488
1779
  const testMeta = { id: test.meta.id, tags: testTags };
1489
1780
  const trc = new TestRunContext(test.meta.id, testMeta);
@@ -1506,6 +1797,27 @@ async function executeNewTest(test) {
1506
1797
  // Core test body — receives the effective ctx (base or fixture-augmented)
1507
1798
  const runTestBody = async (effectiveCtx) => {
1508
1799
  if (test.type === "simple") {
1800
+ // Invocation inversion (plan 0007): a built workflow is a first-class DEF, not a
1801
+ // self-executing test. The SDK marks the wrapper `__glubean_kind === "workflow"`
1802
+ // and attaches the Workflow IR to `__glubean_workflow`; the host run-loop drives
1803
+ // it through the workflow executor here — like RunnerCore.run() dispatches simple
1804
+ // vs steps. This replaces the SDK's old wfTest.fn (which called runWorkflow
1805
+ // itself); the VERDICT mapping is identical, just host-owned: a skipped run →
1806
+ // ctx.skip(reason) (throws GlubeanSkipError, caught by the same dispatcher); a
1807
+ // failed run → rethrow the cause so the dispatcher reports failed + exit 1.
1808
+ const wfIr = test.__glubean_workflow;
1809
+ if (wfIr) {
1810
+ const result = await runWorkflow(wfIr, effectiveCtx);
1811
+ if (result.status === "skipped") {
1812
+ // Prefer the user-authored runtime ctx.skip(reason), then the authored
1813
+ // meta.skip, then a generic fallback (parity with the old wfTest.fn).
1814
+ effectiveCtx.skip(result.skipReason ?? wfIr.meta.skip ?? `workflow "${wfIr.meta.id}" skipped`);
1815
+ }
1816
+ if (result.status === "failed") {
1817
+ throw result.error ?? new WorkflowPhaseFailedError(wfIr.meta.id, "workflow");
1818
+ }
1819
+ return;
1820
+ }
1509
1821
  if (!test.fn) {
1510
1822
  throw new Error(`Invalid test "${test.meta.id}": missing fn`);
1511
1823
  }