@glubean/cli 0.2.2 → 0.2.4

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.
@@ -1,15 +1,15 @@
1
- import { discoverSessionFile, evaluateThresholds, MetricCollector, normalizePositiveTimeoutMs, RunOrchestrator, TestExecutor, toSingleExecutionOptions, buildRunContext, } from "@glubean/runner";
1
+ import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRunContext, } from "@glubean/runner";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
4
- import { pathToFileURL } from "node:url";
5
4
  import { glob } from "node:fs/promises";
6
5
  import { loadConfig, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
7
- import { loadEnvFile } from "../lib/env.js";
6
+ import { loadProjectEnv } from "@glubean/runner";
8
7
  import { resolveEnvFileName } from "../lib/active_env.js";
9
8
  import { shouldSkipTest } from "../lib/skip.js";
10
9
  import { CLI_VERSION } from "../version.js";
11
10
  import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
12
- import { extractContractFromFile } from "@glubean/scanner";
11
+ import { extractContractFromFile, loadProjectOverlays } from "@glubean/scanner";
12
+ import { applyEnvTemplating } from "@glubean/runner";
13
13
  // ANSI color codes for pretty output
14
14
  const colors = {
15
15
  reset: "\x1b[0m",
@@ -54,10 +54,24 @@ const DEFAULT_EXTENSIONS = ["ts"];
54
54
  function isGlob(target) {
55
55
  return /[*?{[]/.test(target);
56
56
  }
57
- const TEST_FILE_SUFFIXES = [".test.ts", ".contract.ts", ".flow.ts"];
57
+ // Test files: anything that may CONTRIBUTE runnable tests OR overlay
58
+ // registrations during a run. `.bootstrap.ts` files don't produce
59
+ // runnables themselves, but they MUST be loaded so `contract.bootstrap()`
60
+ // calls execute and register overlays before discovery runs (attachment-
61
+ // model §7.4). `discoverTests()` is responsible for distinguishing
62
+ // bootstrap-only files from runnable-emitting ones.
63
+ const TEST_FILE_SUFFIXES = [
64
+ ".test.ts",
65
+ ".contract.ts",
66
+ ".flow.ts",
67
+ ".bootstrap.ts",
68
+ ];
58
69
  function isGlubeanTestFile(name) {
59
70
  return TEST_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
60
71
  }
72
+ function isBootstrapOnlyFile(name) {
73
+ return name.endsWith(".bootstrap.ts");
74
+ }
61
75
  async function walkTestFiles(dir, result) {
62
76
  const entries = await readdir(dir, { withFileTypes: true });
63
77
  for (const entry of entries) {
@@ -104,6 +118,14 @@ async function resolveTestFiles(target) {
104
118
  return [abs];
105
119
  }
106
120
  export async function discoverTests(filePath) {
121
+ // `.bootstrap.ts` files register overlays as a side-effect of import; they
122
+ // produce no runnable tests of their own. We don't even need to import here
123
+ // because the project-wide `loadProjectOverlays()` ran before discovery.
124
+ // Returning [] here keeps these files in the walker (so filtered runs
125
+ // still evaluate them transitively) without surfacing phantom test entries.
126
+ if (isBootstrapOnlyFile(filePath)) {
127
+ return [];
128
+ }
107
129
  const content = await readFile(filePath, "utf-8");
108
130
  if (filePath.includes(".contract.") || filePath.includes(".flow.")) {
109
131
  // Runtime extraction via shared function (supports .with() syntax).
@@ -128,20 +150,18 @@ export async function discoverTests(filePath) {
128
150
  }
129
151
  }
130
152
  // Each flow has a single orchestrator Test (setup → steps → teardown).
131
- // Discover it as one runnable entry with the flow id.
132
- if (result.flows) {
133
- for (const flow of result.flows) {
134
- results.push({
135
- exportName: flow.exportName,
136
- meta: {
137
- id: flow.id,
138
- description: flow.description,
139
- // Flow-level meta.skip was propagated to TestMeta.deferred
140
- // by the flow builder (contract-core.ts), surfaced via the
141
- // extracted projection's description where applicable.
142
- },
143
- });
144
- }
153
+ // Discover it as one runnable entry with the flow id. Post-Phase 2f
154
+ // flows live as `kind: "flow"` entries inside `result.attachments`.
155
+ for (const att of result.attachments) {
156
+ if (att.kind !== "flow")
157
+ continue;
158
+ results.push({
159
+ exportName: att.exportName,
160
+ meta: {
161
+ id: att.flow.id,
162
+ description: att.flow.description,
163
+ },
164
+ });
145
165
  }
146
166
  if (results.length > 0)
147
167
  return results;
@@ -308,7 +328,13 @@ export async function runCommand(target, options = {}) {
308
328
  process.exit(1);
309
329
  }
310
330
  }
311
- const envVars = await loadEnvFile(envPath);
331
+ // Canonical env loading: reads both .env and .env.secrets, expands
332
+ // `${NAME}` references (same file forward refs, cross-file refs, and
333
+ // process.env fallback), splits back into {vars, secrets} with secrets
334
+ // winning on collision. See @glubean/runner:loadProjectEnv.
335
+ const { vars: envVars, secrets } = await loadProjectEnv(rootDir, envFileName);
336
+ // Warn separately on the missing-secrets case so users get a visual
337
+ // signal — loadProjectEnv itself treats missing files as silent empties.
312
338
  const secretsPath = resolve(rootDir, `${envFileName}.secrets`);
313
339
  let secretsExist = true;
314
340
  try {
@@ -317,7 +343,6 @@ export async function runCommand(target, options = {}) {
317
343
  catch {
318
344
  secretsExist = false;
319
345
  }
320
- const secrets = secretsExist ? await loadEnvFile(secretsPath) : {};
321
346
  if (!secretsExist && Object.keys(envVars).length > 0) {
322
347
  console.warn(`${colors.yellow}Warning: secrets file '${envFileName}.secrets' not found in ${rootDir}${colors.reset}`);
323
348
  }
@@ -369,6 +394,29 @@ export async function runCommand(target, options = {}) {
369
394
  process.exit(1);
370
395
  }
371
396
  }
397
+ // ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
398
+ // CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
399
+ // in this process. If the file uses plugin-registered names like
400
+ // `contract.graphql.with(...)` before the plugin's manifest is installed,
401
+ // the import throws ("Cannot read properties of undefined (reading
402
+ // 'with')"). ProjectRunner calls bootstrap() too, but that happens AFTER
403
+ // our discovery — too late. Matching MCP's `glubean_openapi` pattern here:
404
+ // explicit bootstrap before any parent-process contract file import. The
405
+ // call is idempotent (bootstrap tracks loadState internally), so
406
+ // ProjectRunner's internal call is a no-op second visit.
407
+ await bootstrap(rootDir);
408
+ // ── Eager-load overlay registrations (attachment-model §7.4) ────────────
409
+ // A filtered run (e.g. `glubean run path/to/single.contract.ts`) would
410
+ // otherwise miss sibling `*.bootstrap.ts` overlay registrations that
411
+ // wrap cases in the filtered-in contract. §7.4 mandates eager loading
412
+ // before any test runs so overlay registration is deterministic.
413
+ // Idempotent: ProjectRunner re-invokes the same helper (no-op second
414
+ // visit thanks to mtime-keyed module cache).
415
+ const overlayLoad = await loadProjectOverlays(rootDir);
416
+ for (const err of overlayLoad.errors) {
417
+ console.error(`${colors.yellow}⚠ Bootstrap overlay failed to load:${colors.reset} ${err.file}`);
418
+ console.error(`${colors.dim}${err.error}${colors.reset}`);
419
+ }
372
420
  // ── Discover tests across all files ─────────────────────────────────────
373
421
  console.log(`${colors.dim}Discovering tests...${colors.reset}`);
374
422
  const allFileTests = [];
@@ -452,6 +500,113 @@ export async function runCommand(target, options = {}) {
452
500
  console.log(`${colors.dim}${parts.join(" + ")} (${testsToRun.length}/${totalDiscovered} tests)${colors.reset}`);
453
501
  }
454
502
  console.log(`\n${colors.bold}Running ${testsToRun.length} test(s)...${colors.reset}\n`);
503
+ // ── Spike 3: runner input channels (attachment-model §8) ────────────────
504
+ // `--input-json` / `--bootstrap-json` / `--force-standalone` apply to a
505
+ // single targeted case; require --filter to resolve to exactly one test.
506
+ // Maps are JSON-encoded `{ [testId]: <value> }` and passed to the harness
507
+ // subprocess via env vars (which the harness reads in `setExplicitInput`
508
+ // / `setBootstrapInput` / `setForceStandalone` calls before user import).
509
+ const hasInputFlag = options.inputJson !== undefined ||
510
+ options.bootstrapJson !== undefined ||
511
+ options.forceStandalone === true;
512
+ if (hasInputFlag) {
513
+ // §5.1 invariant: explicit input always wins; overlay (and therefore
514
+ // its bootstrap-params channel) is NOT invoked. Per the proposal's
515
+ // "no run-bootstrap-for-side-effects-then-use-my-input mode" rule,
516
+ // the two channels are exclusive at the surface boundary too —
517
+ // dispatcher would silently drop the bootstrap input otherwise.
518
+ if (options.inputJson !== undefined &&
519
+ options.bootstrapJson !== undefined) {
520
+ console.error(`\n${colors.red}❌ --input-json and --bootstrap-json are mutually exclusive.${colors.reset}\n` +
521
+ `${colors.dim}Per attachment-model §5.1: explicit input bypasses the overlay, so bootstrap params would be ignored. Pick one channel per run.${colors.reset}\n`);
522
+ process.exit(1);
523
+ }
524
+ if (testsToRun.length !== 1) {
525
+ console.error(`\n${colors.red}❌ --input-json / --bootstrap-json / --force-standalone require ` +
526
+ `--filter to match exactly one testId. Matched ${testsToRun.length} tests.${colors.reset}\n`);
527
+ if (testsToRun.length > 1) {
528
+ const ids = testsToRun.map((t) => t.test.meta.id).slice(0, 10);
529
+ console.error(`${colors.dim}First matches: ${ids.join(", ")}${ids.length < testsToRun.length ? "…" : ""}${colors.reset}`);
530
+ }
531
+ process.exit(1);
532
+ }
533
+ const targetTestId = testsToRun[0].test.meta.id;
534
+ async function resolveJsonFlag(raw, flagName) {
535
+ let text;
536
+ if (raw.startsWith("@")) {
537
+ const filePath = resolve(raw.slice(1));
538
+ try {
539
+ text = await readFile(filePath, "utf-8");
540
+ }
541
+ catch (err) {
542
+ console.error(`\n${colors.red}❌ ${flagName}: could not read ${filePath}: ` +
543
+ `${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
544
+ process.exit(1);
545
+ }
546
+ }
547
+ else {
548
+ text = raw;
549
+ }
550
+ try {
551
+ return JSON.parse(text);
552
+ }
553
+ catch (err) {
554
+ console.error(`\n${colors.red}❌ ${flagName}: invalid JSON: ` +
555
+ `${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
556
+ process.exit(1);
557
+ }
558
+ }
559
+ // Templating env: project-loaded vars+secrets + process.env. Secrets
560
+ // win over vars; process.env wins over both (matches `loadProjectEnv`'s
561
+ // own precedence). §8 — substitution happens before schema validation.
562
+ const templatingEnv = {
563
+ ...envVars,
564
+ ...secrets,
565
+ ...process.env,
566
+ };
567
+ if (options.inputJson !== undefined) {
568
+ const parsed = await resolveJsonFlag(options.inputJson, "--input-json");
569
+ let templated;
570
+ try {
571
+ templated = applyEnvTemplating(parsed, templatingEnv);
572
+ }
573
+ catch (err) {
574
+ console.error(`\n${colors.red}❌ --input-json: ${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
575
+ process.exit(1);
576
+ }
577
+ process.env["GLUBEAN_RUNNER_EXPLICIT_INPUT_MAP"] = JSON.stringify({
578
+ [targetTestId]: templated,
579
+ });
580
+ console.log(`${colors.dim} --input-json: ${targetTestId}${colors.reset}`);
581
+ }
582
+ if (options.bootstrapJson !== undefined) {
583
+ const parsed = await resolveJsonFlag(options.bootstrapJson, "--bootstrap-json");
584
+ let templated;
585
+ try {
586
+ templated = applyEnvTemplating(parsed, templatingEnv);
587
+ }
588
+ catch (err) {
589
+ console.error(`\n${colors.red}❌ --bootstrap-json: ${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
590
+ process.exit(1);
591
+ }
592
+ process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"] = JSON.stringify({
593
+ [targetTestId]: templated,
594
+ });
595
+ console.log(`${colors.dim} --bootstrap-json: ${targetTestId}${colors.reset}`);
596
+ }
597
+ if (options.forceStandalone === true) {
598
+ process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"] = JSON.stringify([
599
+ targetTestId,
600
+ ]);
601
+ console.warn(`${colors.yellow}⚠ --force-standalone enabled for ${targetTestId} (debug)${colors.reset}`);
602
+ }
603
+ }
604
+ else {
605
+ // Clear stale state from prior runs in the same process.
606
+ delete process.env["GLUBEAN_RUNNER_EXPLICIT_INPUT_MAP"];
607
+ delete process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"];
608
+ delete process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"];
609
+ }
455
610
  if (options.pick) {
456
611
  process.env.GLUBEAN_PICK = options.pick;
457
612
  console.log(`${colors.dim} pick: ${options.pick}${colors.reset}`);
@@ -460,10 +615,9 @@ export async function runCommand(target, options = {}) {
460
615
  delete process.env.GLUBEAN_PICK;
461
616
  }
462
617
  const shared = toSharedRunConfig(effectiveRun);
463
- const executor = TestExecutor.fromSharedConfig(shared, {
464
- cwd: rootDir,
465
- ...(options.inspectBrk && { inspectBrk: options.inspectBrk }),
466
- });
618
+ // Note: TestExecutor construction is delegated to ProjectRunner below
619
+ // (it builds one via TestExecutor.fromSharedConfig with identical cwd +
620
+ // inspectBrk params when no executor option is passed).
467
621
  let passed = 0;
468
622
  let failed = 0;
469
623
  let skipped = 0;
@@ -490,40 +644,15 @@ export async function runCommand(target, options = {}) {
490
644
  group.push(entry);
491
645
  fileGroups.set(entry.filePath, group);
492
646
  }
493
- // ── Session discovery and setup ───────────────────────────────────────────
647
+ // ── Session + execution + teardown via ProjectRunner ─────────────────────
648
+ //
649
+ // Replaces the prior inline RunOrchestrator + per-file TestExecutor loop
650
+ // (~540 lines) with a single event-stream consumer. Per-event presentation
651
+ // handlers (trace / assertion / step / etc.) are byte-for-byte unchanged;
652
+ // only the outer wiring swaps from direct executor.run(...) to the facade.
653
+ //
654
+ // See internal/30-execution/2026-04-23-rf-1b-cli-migration/execution-log.md.
494
655
  const sessionState = {};
495
- const sessionFile = options.noSession
496
- ? undefined
497
- : discoverSessionFile(startDir, rootDir);
498
- const orchestrator = new RunOrchestrator(executor);
499
- if (sessionFile) {
500
- console.log(`${colors.dim}Session: ${relative(process.cwd(), sessionFile)}${colors.reset}`);
501
- let sessionFailed = false;
502
- for await (const event of orchestrator.runSessionSetup(sessionFile, { vars: envVars, secrets, interactive }, toSingleExecutionOptions(shared))) {
503
- if (event.type === "session:set") {
504
- sessionState[event.key] = event.value;
505
- }
506
- else if (event.type === "status" && event.status === "failed") {
507
- sessionFailed = true;
508
- console.log(` ${colors.red}✗ Session setup failed${event.error ? `: ${event.error}` : ""}${colors.reset}`);
509
- }
510
- else if (event.type === "log") {
511
- console.log(` ${colors.dim}[session] ${event.message}${colors.reset}`);
512
- }
513
- }
514
- if (sessionFailed) {
515
- // Best-effort teardown before exiting
516
- for await (const _event of orchestrator.runSessionTeardown(sessionFile, { vars: envVars, secrets }, sessionState, toSingleExecutionOptions(shared))) {
517
- // Silently consume teardown events
518
- }
519
- console.log(`\n${colors.red}Session setup failed. All tests skipped.${colors.reset}`);
520
- process.exit(1);
521
- }
522
- const keyCount = Object.keys(sessionState).length;
523
- if (keyCount > 0) {
524
- console.log(`${colors.dim} ${keyCount} session value${keyCount > 1 ? "s" : ""} set${colors.reset}`);
525
- }
526
- }
527
656
  const compactUrl = (url) => {
528
657
  try {
529
658
  const u = new URL(url);
@@ -543,372 +672,475 @@ export async function runCommand(target, options = {}) {
543
672
  return `${colors.dim}${status}${colors.reset}`;
544
673
  return `${colors.green}${status}${colors.reset}`;
545
674
  };
546
- for (const [groupFilePath, fileTests] of fileGroups) {
547
- if (isMultiFile) {
548
- const relPath = relative(process.cwd(), groupFilePath);
549
- console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
675
+ // Per-test state, scoped across file:event boundaries. Reset on each
676
+ // "start" event inside file:event handlers.
677
+ let currentGroupFilePath = "";
678
+ let currentTestMap;
679
+ let testId = "";
680
+ let testName = "";
681
+ let testItem = null;
682
+ let startTime = Date.now();
683
+ let testEvents = [];
684
+ let assertions = [];
685
+ let success = false;
686
+ let errorMsg;
687
+ let peakMemoryMB;
688
+ let stepAssertionCount = 0;
689
+ let stepTraceLines = [];
690
+ let testStarted = false;
691
+ const addLogEntry = (type, message, data) => {
692
+ if (effectiveRun.logFile) {
693
+ logEntries.push({
694
+ timestamp: new Date().toISOString(),
695
+ testId,
696
+ testName,
697
+ type,
698
+ message,
699
+ data,
700
+ });
550
701
  }
551
- if (failureLimit !== undefined && failed >= failureLimit) {
552
- for (const { test } of fileTests) {
553
- skipped++;
554
- const name = test.meta.name || test.meta.id;
555
- console.log(` ${colors.yellow}○${colors.reset} ${name} ${colors.dim}(skipped — fail-fast)${colors.reset}`);
556
- }
557
- continue;
702
+ };
703
+ const finalizeTest = () => {
704
+ if (!testStarted)
705
+ return;
706
+ testStarted = false;
707
+ const duration = Date.now() - startTime;
708
+ const allAssertionsPassed = assertions.every((a) => a.passed);
709
+ const finalSuccess = success && allAssertionsPassed;
710
+ collectedRuns.push({
711
+ testId,
712
+ testName,
713
+ tags: testItem?.meta.tags,
714
+ filePath: currentGroupFilePath,
715
+ events: testEvents,
716
+ success: finalSuccess,
717
+ durationMs: duration,
718
+ groupId: testItem?.meta.groupId,
719
+ });
720
+ addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
721
+ duration,
722
+ success: finalSuccess,
723
+ peakMemoryMB,
724
+ });
725
+ const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
726
+ if (peakMB > overallPeakMemoryMB) {
727
+ overallPeakMemoryMB = peakMB;
558
728
  }
559
- // ── Skip tests based on requires/defaultRun capability profile ──
560
- const runnableTests = [];
561
- for (const ft of fileTests) {
562
- const skipReason = shouldSkipTest(ft.test.meta, capabilityProfile);
563
- if (skipReason) {
564
- skipped++;
565
- const name = ft.test.meta.name || ft.test.meta.id;
566
- console.log(` ${colors.yellow}⊘${colors.reset} ${name} ${colors.dim}— skipped (${skipReason})${colors.reset}`);
567
- // Record as a skipped test run for result output
568
- collectedRuns.push({
569
- testId: ft.test.meta.id,
570
- testName: name,
571
- tags: ft.test.meta.tags,
572
- filePath: groupFilePath,
573
- events: [{ type: "status", status: "skipped", reason: skipReason }],
574
- success: true,
575
- durationMs: 0,
576
- groupId: ft.test.meta.groupId,
577
- });
578
- continue;
579
- }
580
- runnableTests.push(ft);
729
+ const testHttpCalls = testEvents.filter((e) => e.type === "trace").length;
730
+ const testSteps = testEvents.filter((e) => e.type === "step_end").length;
731
+ const miniStats = [];
732
+ miniStats.push(`${duration}ms`);
733
+ if (testHttpCalls > 0)
734
+ miniStats.push(`${testHttpCalls} calls`);
735
+ if (assertions.length > 0)
736
+ miniStats.push(`${assertions.length} checks`);
737
+ if (testSteps > 0)
738
+ miniStats.push(`${testSteps} steps`);
739
+ if (finalSuccess) {
740
+ console.log(` ${colors.green}✓ PASSED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
741
+ passed++;
581
742
  }
582
- if (runnableTests.length === 0)
583
- continue;
584
- const testIds = runnableTests.map((ft) => ft.test.meta.id);
585
- const exportNames = {};
586
- for (const ft of runnableTests) {
587
- exportNames[ft.test.meta.id] = ft.exportName;
743
+ else {
744
+ console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
745
+ failed++;
588
746
  }
589
- const testMap = new Map(runnableTests.map((ft) => [ft.test.meta.id, ft]));
590
- const testFileUrl = pathToFileURL(groupFilePath).toString();
591
- const batchTimeout = runnableTests.reduce((sum, ft) => {
592
- return sum +
593
- (normalizePositiveTimeoutMs(ft.test.meta.timeout) ??
594
- shared.perTestTimeoutMs ?? 30_000);
595
- }, 0);
596
- let testId = "";
597
- let testName = "";
598
- let testItem = null;
599
- let startTime = Date.now();
600
- let testEvents = [];
601
- let assertions = [];
602
- let success = false;
603
- let errorMsg;
604
- let peakMemoryMB;
605
- let stepAssertionCount = 0;
606
- let stepTraceLines = [];
607
- let testStarted = false;
608
- const addLogEntry = (type, message, data) => {
609
- if (effectiveRun.logFile) {
610
- logEntries.push({
611
- timestamp: new Date().toISOString(),
612
- testId,
613
- testName,
614
- type,
615
- message,
616
- data,
617
- });
747
+ if (peakMB > MEMORY_WARNING_THRESHOLD_MB) {
748
+ if (peakMB > CLOUD_MEMORY_LIMITS.free) {
749
+ console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) exceeds Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
618
750
  }
619
- };
620
- const finalizeTest = () => {
621
- if (!testStarted)
622
- return;
623
- testStarted = false;
624
- const duration = Date.now() - startTime;
625
- const allAssertionsPassed = assertions.every((a) => a.passed);
626
- const finalSuccess = success && allAssertionsPassed;
627
- collectedRuns.push({
628
- testId,
629
- testName,
630
- tags: testItem?.meta.tags,
631
- filePath: groupFilePath,
632
- events: testEvents,
633
- success: finalSuccess,
634
- durationMs: duration,
635
- groupId: testItem?.meta.groupId,
636
- });
637
- addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
638
- duration,
639
- success: finalSuccess,
640
- peakMemoryMB,
641
- });
642
- const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
643
- if (peakMB > overallPeakMemoryMB) {
644
- overallPeakMemoryMB = peakMB;
751
+ else {
752
+ console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
645
753
  }
646
- const testHttpCalls = testEvents.filter((e) => e.type === "trace").length;
647
- const testSteps = testEvents.filter((e) => e.type === "step_end").length;
648
- const miniStats = [];
649
- miniStats.push(`${duration}ms`);
650
- if (testHttpCalls > 0)
651
- miniStats.push(`${testHttpCalls} calls`);
652
- if (assertions.length > 0)
653
- miniStats.push(`${assertions.length} checks`);
654
- if (testSteps > 0)
655
- miniStats.push(`${testSteps} steps`);
656
- if (finalSuccess) {
657
- console.log(` ${colors.green}✓ PASSED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
658
- passed++;
754
+ }
755
+ for (const assertion of assertions) {
756
+ if (!assertion.passed) {
757
+ console.log(` ${colors.red}✗ ${assertion.message}${colors.reset}`);
758
+ if (assertion.expected !== undefined || assertion.actual !== undefined) {
759
+ if (assertion.expected !== undefined) {
760
+ console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
761
+ }
762
+ if (assertion.actual !== undefined) {
763
+ console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
764
+ }
765
+ }
766
+ }
767
+ }
768
+ if (errorMsg) {
769
+ console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
770
+ }
771
+ };
772
+ // Pre-filter tests by capability profile so file:start can emit the
773
+ // ⊘ lines inline (preserves the pre-migration output layout where these
774
+ // lines appear between the file header and the first runnable test of
775
+ // the file). `runnableByFile` is what actually feeds ProjectRunner.
776
+ const fileCapabilitySkips = new Map();
777
+ const runnableByFile = new Map();
778
+ for (const [filePath, fileTests] of fileGroups) {
779
+ const skips = [];
780
+ const runnable = [];
781
+ for (const ft of fileTests) {
782
+ const reason = shouldSkipTest(ft.test.meta, capabilityProfile);
783
+ if (reason) {
784
+ skips.push({ ft, reason });
659
785
  }
660
786
  else {
661
- console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
662
- failed++;
787
+ runnable.push(ft);
663
788
  }
664
- if (peakMB > MEMORY_WARNING_THRESHOLD_MB) {
665
- if (peakMB > CLOUD_MEMORY_LIMITS.free) {
666
- console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) exceeds Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
789
+ }
790
+ if (skips.length > 0)
791
+ fileCapabilitySkips.set(filePath, skips);
792
+ if (runnable.length > 0)
793
+ runnableByFile.set(filePath, runnable);
794
+ }
795
+ // Flatten in fileGroups insertion order so ProjectRunner processes files
796
+ // in the same order the old inline loop did.
797
+ const runnableTests = [];
798
+ for (const filePath of fileGroups.keys()) {
799
+ const runnable = runnableByFile.get(filePath);
800
+ if (runnable)
801
+ runnableTests.push(...runnable);
802
+ }
803
+ // Files ProjectRunner actually started. Any fileGroups entry that never
804
+ // gets file:start is a fail-fast skip — handled post run:complete.
805
+ const startedFiles = new Set();
806
+ const runner = new ProjectRunner({
807
+ rootDir,
808
+ sharedConfig: shared,
809
+ sessionStartDir: startDir,
810
+ vars: envVars,
811
+ secrets,
812
+ // Cast — CLI's DiscoveredTestMeta.requires is a plain `string | undefined`
813
+ // (scanner output, openly typed). ProjectRunnerTest narrows it to the
814
+ // CaseRequires literal union. Widening happens upstream at scanner.
815
+ tests: runnableTests.map((t) => ({
816
+ filePath: t.filePath,
817
+ exportName: t.exportName,
818
+ meta: t.test.meta,
819
+ })),
820
+ noSession: !!options.noSession,
821
+ interactive,
822
+ ...(options.inspectBrk !== undefined && { inspectBrk: options.inspectBrk }),
823
+ metricCollector,
824
+ });
825
+ for await (const ev of runner.run()) {
826
+ switch (ev.type) {
827
+ case "bootstrap:start":
828
+ case "bootstrap:done":
829
+ case "discovery:done":
830
+ case "session:setup:start":
831
+ case "session:teardown:start":
832
+ case "session:teardown:done":
833
+ // Silent — either internal plumbing, or already covered by a more
834
+ // specific event (e.g. session:discovered already printed the
835
+ // "Session: <path>" header before setup:start arrived).
836
+ break;
837
+ case "bootstrap:failed":
838
+ console.error(`\n${colors.red}Bootstrap failed: ${ev.error.message}${colors.reset}`);
839
+ process.exit(1);
840
+ break;
841
+ case "session:discovered":
842
+ if (ev.sessionFile) {
843
+ console.log(`${colors.dim}Session: ${relative(process.cwd(), ev.sessionFile)}${colors.reset}`);
667
844
  }
668
- else {
669
- console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
845
+ break;
846
+ case "session:setup:event": {
847
+ const se = ev.event;
848
+ if (se.type === "session:set") {
849
+ sessionState[se.key] = se.value;
670
850
  }
851
+ else if (se.type === "status" && se.status === "failed") {
852
+ console.log(` ${colors.red}✗ Session setup failed${se.error ? `: ${se.error}` : ""}${colors.reset}`);
853
+ }
854
+ else if (se.type === "log") {
855
+ console.log(` ${colors.dim}[session] ${se.message}${colors.reset}`);
856
+ }
857
+ break;
671
858
  }
672
- for (const assertion of assertions) {
673
- if (!assertion.passed) {
674
- console.log(` ${colors.red}✗ ${assertion.message}${colors.reset}`);
675
- if (assertion.expected !== undefined || assertion.actual !== undefined) {
676
- if (assertion.expected !== undefined) {
677
- console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
678
- }
679
- if (assertion.actual !== undefined) {
680
- console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
681
- }
682
- }
859
+ case "session:setup:done": {
860
+ const count = ev.stateKeys.length;
861
+ if (count > 0) {
862
+ console.log(`${colors.dim} ${count} session value${count > 1 ? "s" : ""} set${colors.reset}`);
683
863
  }
864
+ break;
684
865
  }
685
- if (errorMsg) {
686
- console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
866
+ case "session:setup:failed":
867
+ console.log(`\n${colors.red}Session setup failed. All tests skipped.${colors.reset}`);
868
+ process.exit(1);
869
+ break;
870
+ case "session:teardown:event": {
871
+ const te = ev.event;
872
+ if (te.type === "log") {
873
+ console.log(` ${colors.dim}[session] ${te.message}${colors.reset}`);
874
+ }
875
+ else if (te.type === "status" && te.status === "failed") {
876
+ console.log(` ${colors.yellow}⚠ Session teardown failed${te.error ? `: ${te.error}` : ""}${colors.reset}`);
877
+ }
878
+ break;
687
879
  }
688
- };
689
- for await (const event of executor.run(testFileUrl, "", {
690
- vars: envVars,
691
- secrets,
692
- ...(Object.keys(sessionState).length > 0 && { session: sessionState }),
693
- }, {
694
- ...toSingleExecutionOptions(shared),
695
- timeout: batchTimeout,
696
- testIds,
697
- exportNames,
698
- // Pass concurrency to harness when the batch has parallel-marked tests.
699
- // The harness uses p-queue to run them concurrently within a single process.
700
- ...(runnableTests.some((ft) => ft.test.meta.parallel) && shared.concurrency > 1
701
- ? { concurrency: shared.concurrency }
702
- : {}),
703
- })) {
704
- switch (event.type) {
705
- case "start": {
706
- const entry = testMap.get(event.id);
707
- testId = event.id;
708
- testName = entry?.test.meta.name || event.name || event.id;
709
- testItem = entry?.test || null;
710
- startTime = Date.now();
711
- testEvents = [];
712
- assertions = [];
713
- success = false;
714
- errorMsg = undefined;
715
- peakMemoryMB = undefined;
716
- stepAssertionCount = 0;
717
- stepTraceLines = [];
718
- testStarted = true;
719
- const tags = testItem?.meta.tags?.length
720
- ? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
721
- : "";
722
- console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
723
- if (testItem?.meta.description) {
724
- console.log(` ${colors.dim}${testItem.meta.description}${colors.reset}`);
880
+ case "file:start": {
881
+ currentGroupFilePath = ev.filePath;
882
+ startedFiles.add(ev.filePath);
883
+ const runnable = runnableByFile.get(ev.filePath) ?? [];
884
+ currentTestMap = new Map(runnable.map((ft) => [ft.test.meta.id, ft]));
885
+ if (isMultiFile) {
886
+ const relPath = relative(process.cwd(), ev.filePath);
887
+ console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
888
+ }
889
+ // Inline capability-skip display — preserves pre-migration layout
890
+ // where lines sit between the file header and the first runnable
891
+ // test of the file.
892
+ const skips = fileCapabilitySkips.get(ev.filePath);
893
+ if (skips) {
894
+ for (const { ft, reason } of skips) {
895
+ skipped++;
896
+ const name = ft.test.meta.name || ft.test.meta.id;
897
+ console.log(` ${colors.yellow}⊘${colors.reset} ${name} ${colors.dim}— skipped (${reason})${colors.reset}`);
898
+ collectedRuns.push({
899
+ testId: ft.test.meta.id,
900
+ testName: name,
901
+ tags: ft.test.meta.tags,
902
+ filePath: ev.filePath,
903
+ events: [{ type: "status", status: "skipped", reason }],
904
+ success: true,
905
+ durationMs: 0,
906
+ groupId: ft.test.meta.groupId,
907
+ });
725
908
  }
726
- break;
727
909
  }
728
- case "status":
729
- success = event.status === "completed";
730
- if (event.error) {
731
- errorMsg = event.error;
732
- addLogEntry("error", event.error);
910
+ break;
911
+ }
912
+ case "file:event": {
913
+ const event = ev.event;
914
+ switch (event.type) {
915
+ case "start": {
916
+ const entry = currentTestMap?.get(event.id);
917
+ testId = event.id;
918
+ testName = entry?.test.meta.name || event.name || event.id;
919
+ testItem = entry?.test || null;
920
+ startTime = Date.now();
921
+ testEvents = [];
922
+ assertions = [];
923
+ success = false;
924
+ errorMsg = undefined;
925
+ peakMemoryMB = undefined;
926
+ stepAssertionCount = 0;
927
+ stepTraceLines = [];
928
+ testStarted = true;
929
+ const tags = testItem?.meta.tags?.length
930
+ ? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
931
+ : "";
932
+ console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
933
+ if (testItem?.meta.description) {
934
+ console.log(` ${colors.dim}${testItem.meta.description}${colors.reset}`);
935
+ }
936
+ break;
733
937
  }
734
- if (event.peakMemoryMB)
735
- peakMemoryMB = event.peakMemoryMB;
736
- finalizeTest();
737
- break;
738
- case "error":
739
- success = false;
740
- if (!errorMsg)
741
- errorMsg = event.message;
742
- addLogEntry("error", event.message);
743
- break;
744
- case "log":
745
- addLogEntry("log", event.message);
746
- if (event.message.startsWith("Loading test module:"))
938
+ case "status":
939
+ success = event.status === "completed";
940
+ if (event.error) {
941
+ errorMsg = event.error;
942
+ addLogEntry("error", event.error);
943
+ }
944
+ if (event.peakMemoryMB)
945
+ peakMemoryMB = event.peakMemoryMB;
946
+ finalizeTest();
947
+ break;
948
+ case "error":
949
+ success = false;
950
+ if (!errorMsg)
951
+ errorMsg = event.message;
952
+ addLogEntry("error", event.message);
953
+ break;
954
+ case "log":
955
+ addLogEntry("log", event.message);
956
+ if (event.message.startsWith("Loading test module:"))
957
+ break;
958
+ console.log(` ${colors.dim}${event.message}${colors.reset}`);
959
+ break;
960
+ case "assertion":
961
+ assertions.push({
962
+ passed: event.passed,
963
+ message: event.message,
964
+ actual: event.actual,
965
+ expected: event.expected,
966
+ });
967
+ stepAssertionCount++;
968
+ addLogEntry("assertion", event.message, {
969
+ passed: event.passed,
970
+ actual: event.actual,
971
+ expected: event.expected,
972
+ });
973
+ if (effectiveRun.verbose) {
974
+ const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
975
+ console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
976
+ }
977
+ break;
978
+ case "trace": {
979
+ const traceTarget = event.data.target ?? `${event.data.method ?? "?"} ${event.data.url ?? "?"}`;
980
+ const traceDuration = event.data.durationMs ?? event.data.duration ?? 0;
981
+ const traceProtocol = event.data.protocol ?? "http";
982
+ const traceMsg = `${traceTarget} → ${event.data.status} (${traceDuration}ms)`;
983
+ addLogEntry("trace", traceMsg, event.data);
984
+ traceCollector.push({
985
+ testId,
986
+ protocol: traceProtocol,
987
+ target: traceTarget,
988
+ method: event.data.method,
989
+ url: event.data.url,
990
+ status: event.data.status,
991
+ });
992
+ const displayTarget = event.data.method && event.data.url
993
+ ? `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)}`
994
+ : `${colors.dim}${traceTarget}${colors.reset}`;
995
+ const compactTrace = `${displayTarget} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${traceDuration}ms${colors.reset}`;
996
+ stepTraceLines.push(compactTrace);
997
+ console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
998
+ if (effectiveRun.verbose && event.data.requestBody) {
999
+ console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
1000
+ }
1001
+ if (effectiveRun.verbose && event.data.responseBody) {
1002
+ const body = JSON.stringify(event.data.responseBody);
1003
+ console.log(` ${colors.dim}res: ${body.slice(0, 120)}${body.length > 120 ? "…" : ""}${colors.reset}`);
1004
+ }
747
1005
  break;
748
- console.log(` ${colors.dim}${event.message}${colors.reset}`);
749
- break;
750
- case "assertion":
751
- assertions.push({
752
- passed: event.passed,
753
- message: event.message,
754
- actual: event.actual,
755
- expected: event.expected,
756
- });
757
- stepAssertionCount++;
758
- addLogEntry("assertion", event.message, {
759
- passed: event.passed,
760
- actual: event.actual,
761
- expected: event.expected,
762
- });
763
- if (effectiveRun.verbose) {
764
- const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
765
- console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
766
1006
  }
767
- break;
768
- case "trace": {
769
- const traceTarget = event.data.target ?? `${event.data.method ?? "?"} ${event.data.url ?? "?"}`;
770
- const traceDuration = event.data.durationMs ?? event.data.duration ?? 0;
771
- const traceProtocol = event.data.protocol ?? "http";
772
- const traceMsg = `${traceTarget} ${event.data.status} (${traceDuration}ms)`;
773
- addLogEntry("trace", traceMsg, event.data);
774
- traceCollector.push({
775
- testId,
776
- protocol: traceProtocol,
777
- target: traceTarget,
778
- method: event.data.method,
779
- url: event.data.url,
780
- status: event.data.status,
781
- });
782
- const displayTarget = event.data.method && event.data.url
783
- ? `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)}`
784
- : `${colors.dim}${traceTarget}${colors.reset}`;
785
- const compactTrace = `${displayTarget} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${traceDuration}ms${colors.reset}`;
786
- stepTraceLines.push(compactTrace);
787
- console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
788
- if (effectiveRun.verbose && event.data.requestBody) {
789
- console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
1007
+ case "action": {
1008
+ const a = event.data;
1009
+ if (a.category === "http:request")
1010
+ break;
1011
+ const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
1012
+ const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
1013
+ addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
1014
+ console.log(` ${colors.dim}↳${colors.reset} ${colors.cyan}${a.category}${colors.reset} ${a.target} ${colors.dim}${a.duration}ms${colors.reset} ${statusColor}${statusIcon}${colors.reset}`);
1015
+ break;
790
1016
  }
791
- if (effectiveRun.verbose && event.data.responseBody) {
792
- const body = JSON.stringify(event.data.responseBody);
793
- console.log(` ${colors.dim}res: ${body.slice(0, 120)}${body.length > 120 ? "" : ""}${colors.reset}`);
1017
+ case "event": {
1018
+ const evData = event.data;
1019
+ addLogEntry("event", `[${evData.type}]`, evData);
1020
+ if (effectiveRun.verbose) {
1021
+ const summary = JSON.stringify(evData.data).slice(0, 80);
1022
+ console.log(` ${colors.dim}[${evData.type}] ${summary}${colors.reset}`);
1023
+ }
1024
+ break;
794
1025
  }
795
- break;
796
- }
797
- case "action": {
798
- const a = event.data;
799
- if (a.category === "http:request")
1026
+ case "metric": {
1027
+ // ProjectRunner already accumulates into metricCollector (passed
1028
+ // in above). CLI only handles verbose display + log entry.
1029
+ const unit = event.unit ? ` ${event.unit}` : "";
1030
+ const tagStr = event.tags
1031
+ ? ` ${colors.dim}{${Object.entries(event.tags)
1032
+ .map(([k, v]) => `${k}=${v}`)
1033
+ .join(", ")}}${colors.reset}`
1034
+ : "";
1035
+ const metricMsg = `${event.name} = ${event.value}${unit}`;
1036
+ addLogEntry("metric", metricMsg, {
1037
+ name: event.name,
1038
+ value: event.value,
1039
+ unit: event.unit,
1040
+ tags: event.tags,
1041
+ });
1042
+ if (effectiveRun.verbose) {
1043
+ console.log(` ${colors.blue}📊 ${metricMsg}${colors.reset}${tagStr}`);
1044
+ }
800
1045
  break;
801
- const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
802
- const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
803
- addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
804
- console.log(` ${colors.dim}↳${colors.reset} ${colors.cyan}${a.category}${colors.reset} ${a.target} ${colors.dim}${a.duration}ms${colors.reset} ${statusColor}${statusIcon}${colors.reset}`);
805
- break;
806
- }
807
- case "event": {
808
- const ev = event.data;
809
- addLogEntry("event", `[${ev.type}]`, ev);
810
- if (effectiveRun.verbose) {
811
- const summary = JSON.stringify(ev.data).slice(0, 80);
812
- console.log(` ${colors.dim}[${ev.type}] ${summary}${colors.reset}`);
813
1046
  }
814
- break;
815
- }
816
- case "metric": {
817
- metricCollector.add(event.name, event.value);
818
- const unit = event.unit ? ` ${event.unit}` : "";
819
- const tagStr = event.tags
820
- ? ` ${colors.dim}{${Object.entries(event.tags)
821
- .map(([k, v]) => `${k}=${v}`)
822
- .join(", ")}}${colors.reset}`
823
- : "";
824
- const metricMsg = `${event.name} = ${event.value}${unit}`;
825
- addLogEntry("metric", metricMsg, {
826
- name: event.name,
827
- value: event.value,
828
- unit: event.unit,
829
- tags: event.tags,
830
- });
831
- if (effectiveRun.verbose) {
832
- console.log(` ${colors.blue}📊 ${metricMsg}${colors.reset}${tagStr}`);
1047
+ case "step_start":
1048
+ stepAssertionCount = 0;
1049
+ stepTraceLines = [];
1050
+ console.log(` ${colors.cyan}┌${colors.reset} ${colors.dim}step ${event.index + 1}/${event.total}${colors.reset} ${colors.bold}${event.name}${colors.reset}`);
1051
+ break;
1052
+ case "step_end": {
1053
+ const stepIcon = event.status === "passed"
1054
+ ? `${colors.green}✓${colors.reset}`
1055
+ : event.status === "failed"
1056
+ ? `${colors.red}✗${colors.reset}`
1057
+ : `${colors.yellow}○${colors.reset}`;
1058
+ const stepParts = [];
1059
+ if (event.durationMs !== undefined)
1060
+ stepParts.push(`${event.durationMs}ms`);
1061
+ if (event.assertions > 0)
1062
+ stepParts.push(`${event.assertions} assertions`);
1063
+ const httpInStep = stepTraceLines.length;
1064
+ if (httpInStep > 0)
1065
+ stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
1066
+ console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
1067
+ if (event.error) {
1068
+ console.log(` ${colors.red}${event.error}${colors.reset}`);
1069
+ }
1070
+ break;
833
1071
  }
834
- break;
835
- }
836
- case "step_start":
837
- stepAssertionCount = 0;
838
- stepTraceLines = [];
839
- console.log(` ${colors.cyan}┌${colors.reset} ${colors.dim}step ${event.index + 1}/${event.total}${colors.reset} ${colors.bold}${event.name}${colors.reset}`);
840
- break;
841
- case "step_end": {
842
- const stepIcon = event.status === "passed"
843
- ? `${colors.green}✓${colors.reset}`
844
- : event.status === "failed"
845
- ? `${colors.red}✗${colors.reset}`
846
- : `${colors.yellow}○${colors.reset}`;
847
- const stepParts = [];
848
- if (event.durationMs !== undefined)
849
- stepParts.push(`${event.durationMs}ms`);
850
- if (event.assertions > 0)
851
- stepParts.push(`${event.assertions} assertions`);
852
- const httpInStep = stepTraceLines.length;
853
- if (httpInStep > 0)
854
- stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
855
- console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
856
- if (event.error) {
857
- console.log(` ${colors.red}${event.error}${colors.reset}`);
1072
+ case "summary":
1073
+ runStats.httpRequestTotal += event.data.httpRequestTotal;
1074
+ runStats.httpErrorTotal += event.data.httpErrorTotal;
1075
+ runStats.assertionTotal += event.data.assertionTotal;
1076
+ runStats.assertionFailed += event.data.assertionFailed;
1077
+ runStats.warningTotal += event.data.warningTotal;
1078
+ runStats.warningTriggered += event.data.warningTriggered;
1079
+ runStats.stepTotal += event.data.stepTotal;
1080
+ runStats.stepPassed += event.data.stepPassed;
1081
+ runStats.stepFailed += event.data.stepFailed;
1082
+ break;
1083
+ case "warning": {
1084
+ const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
1085
+ console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
1086
+ break;
858
1087
  }
859
- break;
1088
+ case "schema_validation":
1089
+ if (effectiveRun.verbose) {
1090
+ const icon = event.success ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
1091
+ console.log(` ${icon} ${colors.dim}schema: ${event.label}${colors.reset}`);
1092
+ }
1093
+ break;
1094
+ case "session:set":
1095
+ // ProjectRunner accumulates internally for cross-file forwarding;
1096
+ // CLI keeps its copy only for symmetry with pre-migration code
1097
+ // paths (useful e.g. for debug logging).
1098
+ sessionState[event.key] = event.value;
1099
+ continue;
860
1100
  }
861
- case "summary":
862
- runStats.httpRequestTotal += event.data.httpRequestTotal;
863
- runStats.httpErrorTotal += event.data.httpErrorTotal;
864
- runStats.assertionTotal += event.data.assertionTotal;
865
- runStats.assertionFailed += event.data.assertionFailed;
866
- runStats.warningTotal += event.data.warningTotal;
867
- runStats.warningTriggered += event.data.warningTriggered;
868
- runStats.stepTotal += event.data.stepTotal;
869
- runStats.stepPassed += event.data.stepPassed;
870
- runStats.stepFailed += event.data.stepFailed;
871
- break;
872
- case "warning": {
873
- const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
874
- console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
875
- break;
1101
+ if (testStarted)
1102
+ testEvents.push(event);
1103
+ break;
1104
+ }
1105
+ case "file:complete":
1106
+ // Mirror the old inline loop's tail cleanup: if the harness died
1107
+ // mid-test or emitted no start event, promote the leftover state
1108
+ // to a visible failure row.
1109
+ if (!testStarted && errorMsg) {
1110
+ console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
1111
+ failed++;
876
1112
  }
877
- case "schema_validation":
878
- if (effectiveRun.verbose) {
879
- const icon = event.success ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
880
- console.log(` ${icon} ${colors.dim}schema: ${event.label}${colors.reset}`);
1113
+ if (testStarted) {
1114
+ if (!errorMsg)
1115
+ errorMsg = "Process exited before test completed";
1116
+ finalizeTest();
1117
+ }
1118
+ break;
1119
+ case "run:complete":
1120
+ // Fail-fast skip display: any file ProjectRunner never started
1121
+ // (because the failure limit kicked in between file groups) gets
1122
+ // the old "○ (skipped — fail-fast)" lines here, preserving the
1123
+ // pre-migration output layout.
1124
+ if (failureLimit !== undefined && ev.failedCount >= failureLimit) {
1125
+ for (const [filePath, fileTests] of fileGroups) {
1126
+ if (startedFiles.has(filePath))
1127
+ continue;
1128
+ if (isMultiFile) {
1129
+ const relPath = relative(process.cwd(), filePath);
1130
+ console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
1131
+ }
1132
+ for (const { test } of fileTests) {
1133
+ skipped++;
1134
+ const name = test.meta.name || test.meta.id;
1135
+ console.log(` ${colors.yellow}○${colors.reset} ${name} ${colors.dim}(skipped — fail-fast)${colors.reset}`);
1136
+ }
881
1137
  }
882
- break;
883
- case "session:set":
884
- // Accumulate session writes from tests for subsequent files
885
- sessionState[event.key] = event.value;
886
- // Do NOT fall through to testEvents — internal only
887
- continue;
888
- }
889
- if (testStarted)
890
- testEvents.push(event);
891
- }
892
- if (!testStarted && errorMsg) {
893
- // Harness failed before emitting any test start (e.g. module load error)
894
- console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
895
- failed++;
896
- }
897
- if (testStarted) {
898
- if (!errorMsg)
899
- errorMsg = "Process exited before test completed";
900
- finalizeTest();
901
- }
902
- }
903
- // ── Session teardown ───────────────────────────────────────────────────
904
- if (sessionFile) {
905
- for await (const event of orchestrator.runSessionTeardown(sessionFile, { vars: envVars, secrets }, sessionState, toSingleExecutionOptions(shared))) {
906
- if (event.type === "log") {
907
- console.log(` ${colors.dim}[session] ${event.message}${colors.reset}`);
908
- }
909
- else if (event.type === "status" && event.status === "failed") {
910
- console.log(` ${colors.yellow}⚠ Session teardown failed${event.error ? `: ${event.error}` : ""}${colors.reset}`);
911
- }
1138
+ }
1139
+ break;
1140
+ case "run:failed":
1141
+ // Terminal failure — actual exit already happened in
1142
+ // bootstrap:failed / session:setup:failed above.
1143
+ break;
912
1144
  }
913
1145
  }
914
1146
  const totalDurationMs = Date.now() - totalStartTime;