@glubean/runner 0.4.1 → 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-bridge.d.ts +51 -0
- package/dist/engine-bridge.d.ts.map +1 -0
- package/dist/engine-bridge.js +210 -0
- package/dist/engine-bridge.js.map +1 -0
- package/dist/executor.d.ts +68 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +12 -0
- package/dist/executor.js.map +1 -1
- package/dist/generate_summary.d.ts +15 -0
- package/dist/generate_summary.d.ts.map +1 -1
- package/dist/generate_summary.js +52 -1
- package/dist/generate_summary.js.map +1 -1
- package/dist/harness.js +333 -21
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/resolve.d.ts +10 -11
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +28 -9
- package/dist/resolve.js.map +1 -1
- package/dist/workflow/execute.d.ts +267 -0
- package/dist/workflow/execute.d.ts.map +1 -0
- package/dist/workflow/execute.js +1475 -0
- package/dist/workflow/execute.js.map +1 -0
- package/package.json +8 -4
package/dist/generate_summary.js
CHANGED
|
@@ -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.
|
|
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":"
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
(
|
|
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,
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
}
|