@glubean/cli 0.2.2 → 0.2.3

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,10 +1,9 @@
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";
@@ -308,7 +307,13 @@ export async function runCommand(target, options = {}) {
308
307
  process.exit(1);
309
308
  }
310
309
  }
311
- const envVars = await loadEnvFile(envPath);
310
+ // Canonical env loading: reads both .env and .env.secrets, expands
311
+ // `${NAME}` references (same file forward refs, cross-file refs, and
312
+ // process.env fallback), splits back into {vars, secrets} with secrets
313
+ // winning on collision. See @glubean/runner:loadProjectEnv.
314
+ const { vars: envVars, secrets } = await loadProjectEnv(rootDir, envFileName);
315
+ // Warn separately on the missing-secrets case so users get a visual
316
+ // signal — loadProjectEnv itself treats missing files as silent empties.
312
317
  const secretsPath = resolve(rootDir, `${envFileName}.secrets`);
313
318
  let secretsExist = true;
314
319
  try {
@@ -317,7 +322,6 @@ export async function runCommand(target, options = {}) {
317
322
  catch {
318
323
  secretsExist = false;
319
324
  }
320
- const secrets = secretsExist ? await loadEnvFile(secretsPath) : {};
321
325
  if (!secretsExist && Object.keys(envVars).length > 0) {
322
326
  console.warn(`${colors.yellow}Warning: secrets file '${envFileName}.secrets' not found in ${rootDir}${colors.reset}`);
323
327
  }
@@ -369,6 +373,17 @@ export async function runCommand(target, options = {}) {
369
373
  process.exit(1);
370
374
  }
371
375
  }
376
+ // ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
377
+ // CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
378
+ // in this process. If the file uses plugin-registered names like
379
+ // `contract.graphql.with(...)` before the plugin's manifest is installed,
380
+ // the import throws ("Cannot read properties of undefined (reading
381
+ // 'with')"). ProjectRunner calls bootstrap() too, but that happens AFTER
382
+ // our discovery — too late. Matching MCP's `glubean_openapi` pattern here:
383
+ // explicit bootstrap before any parent-process contract file import. The
384
+ // call is idempotent (bootstrap tracks loadState internally), so
385
+ // ProjectRunner's internal call is a no-op second visit.
386
+ await bootstrap(rootDir);
372
387
  // ── Discover tests across all files ─────────────────────────────────────
373
388
  console.log(`${colors.dim}Discovering tests...${colors.reset}`);
374
389
  const allFileTests = [];
@@ -460,10 +475,9 @@ export async function runCommand(target, options = {}) {
460
475
  delete process.env.GLUBEAN_PICK;
461
476
  }
462
477
  const shared = toSharedRunConfig(effectiveRun);
463
- const executor = TestExecutor.fromSharedConfig(shared, {
464
- cwd: rootDir,
465
- ...(options.inspectBrk && { inspectBrk: options.inspectBrk }),
466
- });
478
+ // Note: TestExecutor construction is delegated to ProjectRunner below
479
+ // (it builds one via TestExecutor.fromSharedConfig with identical cwd +
480
+ // inspectBrk params when no executor option is passed).
467
481
  let passed = 0;
468
482
  let failed = 0;
469
483
  let skipped = 0;
@@ -490,40 +504,15 @@ export async function runCommand(target, options = {}) {
490
504
  group.push(entry);
491
505
  fileGroups.set(entry.filePath, group);
492
506
  }
493
- // ── Session discovery and setup ───────────────────────────────────────────
507
+ // ── Session + execution + teardown via ProjectRunner ─────────────────────
508
+ //
509
+ // Replaces the prior inline RunOrchestrator + per-file TestExecutor loop
510
+ // (~540 lines) with a single event-stream consumer. Per-event presentation
511
+ // handlers (trace / assertion / step / etc.) are byte-for-byte unchanged;
512
+ // only the outer wiring swaps from direct executor.run(...) to the facade.
513
+ //
514
+ // See internal/30-execution/2026-04-23-rf-1b-cli-migration/execution-log.md.
494
515
  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
516
  const compactUrl = (url) => {
528
517
  try {
529
518
  const u = new URL(url);
@@ -543,372 +532,475 @@ export async function runCommand(target, options = {}) {
543
532
  return `${colors.dim}${status}${colors.reset}`;
544
533
  return `${colors.green}${status}${colors.reset}`;
545
534
  };
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}`);
535
+ // Per-test state, scoped across file:event boundaries. Reset on each
536
+ // "start" event inside file:event handlers.
537
+ let currentGroupFilePath = "";
538
+ let currentTestMap;
539
+ let testId = "";
540
+ let testName = "";
541
+ let testItem = null;
542
+ let startTime = Date.now();
543
+ let testEvents = [];
544
+ let assertions = [];
545
+ let success = false;
546
+ let errorMsg;
547
+ let peakMemoryMB;
548
+ let stepAssertionCount = 0;
549
+ let stepTraceLines = [];
550
+ let testStarted = false;
551
+ const addLogEntry = (type, message, data) => {
552
+ if (effectiveRun.logFile) {
553
+ logEntries.push({
554
+ timestamp: new Date().toISOString(),
555
+ testId,
556
+ testName,
557
+ type,
558
+ message,
559
+ data,
560
+ });
550
561
  }
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;
562
+ };
563
+ const finalizeTest = () => {
564
+ if (!testStarted)
565
+ return;
566
+ testStarted = false;
567
+ const duration = Date.now() - startTime;
568
+ const allAssertionsPassed = assertions.every((a) => a.passed);
569
+ const finalSuccess = success && allAssertionsPassed;
570
+ collectedRuns.push({
571
+ testId,
572
+ testName,
573
+ tags: testItem?.meta.tags,
574
+ filePath: currentGroupFilePath,
575
+ events: testEvents,
576
+ success: finalSuccess,
577
+ durationMs: duration,
578
+ groupId: testItem?.meta.groupId,
579
+ });
580
+ addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
581
+ duration,
582
+ success: finalSuccess,
583
+ peakMemoryMB,
584
+ });
585
+ const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
586
+ if (peakMB > overallPeakMemoryMB) {
587
+ overallPeakMemoryMB = peakMB;
558
588
  }
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);
589
+ const testHttpCalls = testEvents.filter((e) => e.type === "trace").length;
590
+ const testSteps = testEvents.filter((e) => e.type === "step_end").length;
591
+ const miniStats = [];
592
+ miniStats.push(`${duration}ms`);
593
+ if (testHttpCalls > 0)
594
+ miniStats.push(`${testHttpCalls} calls`);
595
+ if (assertions.length > 0)
596
+ miniStats.push(`${assertions.length} checks`);
597
+ if (testSteps > 0)
598
+ miniStats.push(`${testSteps} steps`);
599
+ if (finalSuccess) {
600
+ console.log(` ${colors.green}✓ PASSED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
601
+ passed++;
581
602
  }
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;
603
+ else {
604
+ console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
605
+ failed++;
588
606
  }
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
- });
607
+ if (peakMB > MEMORY_WARNING_THRESHOLD_MB) {
608
+ if (peakMB > CLOUD_MEMORY_LIMITS.free) {
609
+ console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) exceeds Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
618
610
  }
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;
611
+ else {
612
+ console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
645
613
  }
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++;
614
+ }
615
+ for (const assertion of assertions) {
616
+ if (!assertion.passed) {
617
+ console.log(` ${colors.red}✗ ${assertion.message}${colors.reset}`);
618
+ if (assertion.expected !== undefined || assertion.actual !== undefined) {
619
+ if (assertion.expected !== undefined) {
620
+ console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
621
+ }
622
+ if (assertion.actual !== undefined) {
623
+ console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
624
+ }
625
+ }
626
+ }
627
+ }
628
+ if (errorMsg) {
629
+ console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
630
+ }
631
+ };
632
+ // Pre-filter tests by capability profile so file:start can emit the
633
+ // ⊘ lines inline (preserves the pre-migration output layout where these
634
+ // lines appear between the file header and the first runnable test of
635
+ // the file). `runnableByFile` is what actually feeds ProjectRunner.
636
+ const fileCapabilitySkips = new Map();
637
+ const runnableByFile = new Map();
638
+ for (const [filePath, fileTests] of fileGroups) {
639
+ const skips = [];
640
+ const runnable = [];
641
+ for (const ft of fileTests) {
642
+ const reason = shouldSkipTest(ft.test.meta, capabilityProfile);
643
+ if (reason) {
644
+ skips.push({ ft, reason });
659
645
  }
660
646
  else {
661
- console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
662
- failed++;
647
+ runnable.push(ft);
663
648
  }
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}`);
649
+ }
650
+ if (skips.length > 0)
651
+ fileCapabilitySkips.set(filePath, skips);
652
+ if (runnable.length > 0)
653
+ runnableByFile.set(filePath, runnable);
654
+ }
655
+ // Flatten in fileGroups insertion order so ProjectRunner processes files
656
+ // in the same order the old inline loop did.
657
+ const runnableTests = [];
658
+ for (const filePath of fileGroups.keys()) {
659
+ const runnable = runnableByFile.get(filePath);
660
+ if (runnable)
661
+ runnableTests.push(...runnable);
662
+ }
663
+ // Files ProjectRunner actually started. Any fileGroups entry that never
664
+ // gets file:start is a fail-fast skip — handled post run:complete.
665
+ const startedFiles = new Set();
666
+ const runner = new ProjectRunner({
667
+ rootDir,
668
+ sharedConfig: shared,
669
+ sessionStartDir: startDir,
670
+ vars: envVars,
671
+ secrets,
672
+ // Cast — CLI's DiscoveredTestMeta.requires is a plain `string | undefined`
673
+ // (scanner output, openly typed). ProjectRunnerTest narrows it to the
674
+ // CaseRequires literal union. Widening happens upstream at scanner.
675
+ tests: runnableTests.map((t) => ({
676
+ filePath: t.filePath,
677
+ exportName: t.exportName,
678
+ meta: t.test.meta,
679
+ })),
680
+ noSession: !!options.noSession,
681
+ interactive,
682
+ ...(options.inspectBrk !== undefined && { inspectBrk: options.inspectBrk }),
683
+ metricCollector,
684
+ });
685
+ for await (const ev of runner.run()) {
686
+ switch (ev.type) {
687
+ case "bootstrap:start":
688
+ case "bootstrap:done":
689
+ case "discovery:done":
690
+ case "session:setup:start":
691
+ case "session:teardown:start":
692
+ case "session:teardown:done":
693
+ // Silent — either internal plumbing, or already covered by a more
694
+ // specific event (e.g. session:discovered already printed the
695
+ // "Session: <path>" header before setup:start arrived).
696
+ break;
697
+ case "bootstrap:failed":
698
+ console.error(`\n${colors.red}Bootstrap failed: ${ev.error.message}${colors.reset}`);
699
+ process.exit(1);
700
+ break;
701
+ case "session:discovered":
702
+ if (ev.sessionFile) {
703
+ console.log(`${colors.dim}Session: ${relative(process.cwd(), ev.sessionFile)}${colors.reset}`);
704
+ }
705
+ break;
706
+ case "session:setup:event": {
707
+ const se = ev.event;
708
+ if (se.type === "session:set") {
709
+ sessionState[se.key] = se.value;
667
710
  }
668
- else {
669
- console.log(` ${colors.yellow} Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
711
+ else if (se.type === "status" && se.status === "failed") {
712
+ console.log(` ${colors.red} Session setup failed${se.error ? `: ${se.error}` : ""}${colors.reset}`);
670
713
  }
714
+ else if (se.type === "log") {
715
+ console.log(` ${colors.dim}[session] ${se.message}${colors.reset}`);
716
+ }
717
+ break;
671
718
  }
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
- }
719
+ case "session:setup:done": {
720
+ const count = ev.stateKeys.length;
721
+ if (count > 0) {
722
+ console.log(`${colors.dim} ${count} session value${count > 1 ? "s" : ""} set${colors.reset}`);
683
723
  }
724
+ break;
684
725
  }
685
- if (errorMsg) {
686
- console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
726
+ case "session:setup:failed":
727
+ console.log(`\n${colors.red}Session setup failed. All tests skipped.${colors.reset}`);
728
+ process.exit(1);
729
+ break;
730
+ case "session:teardown:event": {
731
+ const te = ev.event;
732
+ if (te.type === "log") {
733
+ console.log(` ${colors.dim}[session] ${te.message}${colors.reset}`);
734
+ }
735
+ else if (te.type === "status" && te.status === "failed") {
736
+ console.log(` ${colors.yellow}⚠ Session teardown failed${te.error ? `: ${te.error}` : ""}${colors.reset}`);
737
+ }
738
+ break;
687
739
  }
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}`);
740
+ case "file:start": {
741
+ currentGroupFilePath = ev.filePath;
742
+ startedFiles.add(ev.filePath);
743
+ const runnable = runnableByFile.get(ev.filePath) ?? [];
744
+ currentTestMap = new Map(runnable.map((ft) => [ft.test.meta.id, ft]));
745
+ if (isMultiFile) {
746
+ const relPath = relative(process.cwd(), ev.filePath);
747
+ console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
748
+ }
749
+ // Inline capability-skip display — preserves pre-migration layout
750
+ // where lines sit between the file header and the first runnable
751
+ // test of the file.
752
+ const skips = fileCapabilitySkips.get(ev.filePath);
753
+ if (skips) {
754
+ for (const { ft, reason } of skips) {
755
+ skipped++;
756
+ const name = ft.test.meta.name || ft.test.meta.id;
757
+ console.log(` ${colors.yellow}⊘${colors.reset} ${name} ${colors.dim}— skipped (${reason})${colors.reset}`);
758
+ collectedRuns.push({
759
+ testId: ft.test.meta.id,
760
+ testName: name,
761
+ tags: ft.test.meta.tags,
762
+ filePath: ev.filePath,
763
+ events: [{ type: "status", status: "skipped", reason }],
764
+ success: true,
765
+ durationMs: 0,
766
+ groupId: ft.test.meta.groupId,
767
+ });
725
768
  }
726
- break;
727
769
  }
728
- case "status":
729
- success = event.status === "completed";
730
- if (event.error) {
731
- errorMsg = event.error;
732
- addLogEntry("error", event.error);
770
+ break;
771
+ }
772
+ case "file:event": {
773
+ const event = ev.event;
774
+ switch (event.type) {
775
+ case "start": {
776
+ const entry = currentTestMap?.get(event.id);
777
+ testId = event.id;
778
+ testName = entry?.test.meta.name || event.name || event.id;
779
+ testItem = entry?.test || null;
780
+ startTime = Date.now();
781
+ testEvents = [];
782
+ assertions = [];
783
+ success = false;
784
+ errorMsg = undefined;
785
+ peakMemoryMB = undefined;
786
+ stepAssertionCount = 0;
787
+ stepTraceLines = [];
788
+ testStarted = true;
789
+ const tags = testItem?.meta.tags?.length
790
+ ? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
791
+ : "";
792
+ console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
793
+ if (testItem?.meta.description) {
794
+ console.log(` ${colors.dim}${testItem.meta.description}${colors.reset}`);
795
+ }
796
+ break;
733
797
  }
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:"))
798
+ case "status":
799
+ success = event.status === "completed";
800
+ if (event.error) {
801
+ errorMsg = event.error;
802
+ addLogEntry("error", event.error);
803
+ }
804
+ if (event.peakMemoryMB)
805
+ peakMemoryMB = event.peakMemoryMB;
806
+ finalizeTest();
807
+ break;
808
+ case "error":
809
+ success = false;
810
+ if (!errorMsg)
811
+ errorMsg = event.message;
812
+ addLogEntry("error", event.message);
813
+ break;
814
+ case "log":
815
+ addLogEntry("log", event.message);
816
+ if (event.message.startsWith("Loading test module:"))
817
+ break;
818
+ console.log(` ${colors.dim}${event.message}${colors.reset}`);
819
+ break;
820
+ case "assertion":
821
+ assertions.push({
822
+ passed: event.passed,
823
+ message: event.message,
824
+ actual: event.actual,
825
+ expected: event.expected,
826
+ });
827
+ stepAssertionCount++;
828
+ addLogEntry("assertion", event.message, {
829
+ passed: event.passed,
830
+ actual: event.actual,
831
+ expected: event.expected,
832
+ });
833
+ if (effectiveRun.verbose) {
834
+ const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
835
+ console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
836
+ }
837
+ break;
838
+ case "trace": {
839
+ const traceTarget = event.data.target ?? `${event.data.method ?? "?"} ${event.data.url ?? "?"}`;
840
+ const traceDuration = event.data.durationMs ?? event.data.duration ?? 0;
841
+ const traceProtocol = event.data.protocol ?? "http";
842
+ const traceMsg = `${traceTarget} → ${event.data.status} (${traceDuration}ms)`;
843
+ addLogEntry("trace", traceMsg, event.data);
844
+ traceCollector.push({
845
+ testId,
846
+ protocol: traceProtocol,
847
+ target: traceTarget,
848
+ method: event.data.method,
849
+ url: event.data.url,
850
+ status: event.data.status,
851
+ });
852
+ const displayTarget = event.data.method && event.data.url
853
+ ? `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)}`
854
+ : `${colors.dim}${traceTarget}${colors.reset}`;
855
+ const compactTrace = `${displayTarget} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${traceDuration}ms${colors.reset}`;
856
+ stepTraceLines.push(compactTrace);
857
+ console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
858
+ if (effectiveRun.verbose && event.data.requestBody) {
859
+ console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
860
+ }
861
+ if (effectiveRun.verbose && event.data.responseBody) {
862
+ const body = JSON.stringify(event.data.responseBody);
863
+ console.log(` ${colors.dim}res: ${body.slice(0, 120)}${body.length > 120 ? "…" : ""}${colors.reset}`);
864
+ }
747
865
  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
866
  }
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}`);
867
+ case "action": {
868
+ const a = event.data;
869
+ if (a.category === "http:request")
870
+ break;
871
+ const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
872
+ const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
873
+ addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
874
+ 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}`);
875
+ break;
790
876
  }
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}`);
877
+ case "event": {
878
+ const evData = event.data;
879
+ addLogEntry("event", `[${evData.type}]`, evData);
880
+ if (effectiveRun.verbose) {
881
+ const summary = JSON.stringify(evData.data).slice(0, 80);
882
+ console.log(` ${colors.dim}[${evData.type}] ${summary}${colors.reset}`);
883
+ }
884
+ break;
794
885
  }
795
- break;
796
- }
797
- case "action": {
798
- const a = event.data;
799
- if (a.category === "http:request")
886
+ case "metric": {
887
+ // ProjectRunner already accumulates into metricCollector (passed
888
+ // in above). CLI only handles verbose display + log entry.
889
+ const unit = event.unit ? ` ${event.unit}` : "";
890
+ const tagStr = event.tags
891
+ ? ` ${colors.dim}{${Object.entries(event.tags)
892
+ .map(([k, v]) => `${k}=${v}`)
893
+ .join(", ")}}${colors.reset}`
894
+ : "";
895
+ const metricMsg = `${event.name} = ${event.value}${unit}`;
896
+ addLogEntry("metric", metricMsg, {
897
+ name: event.name,
898
+ value: event.value,
899
+ unit: event.unit,
900
+ tags: event.tags,
901
+ });
902
+ if (effectiveRun.verbose) {
903
+ console.log(` ${colors.blue}📊 ${metricMsg}${colors.reset}${tagStr}`);
904
+ }
800
905
  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
906
  }
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}`);
907
+ case "step_start":
908
+ stepAssertionCount = 0;
909
+ stepTraceLines = [];
910
+ console.log(` ${colors.cyan}┌${colors.reset} ${colors.dim}step ${event.index + 1}/${event.total}${colors.reset} ${colors.bold}${event.name}${colors.reset}`);
911
+ break;
912
+ case "step_end": {
913
+ const stepIcon = event.status === "passed"
914
+ ? `${colors.green}✓${colors.reset}`
915
+ : event.status === "failed"
916
+ ? `${colors.red}✗${colors.reset}`
917
+ : `${colors.yellow}○${colors.reset}`;
918
+ const stepParts = [];
919
+ if (event.durationMs !== undefined)
920
+ stepParts.push(`${event.durationMs}ms`);
921
+ if (event.assertions > 0)
922
+ stepParts.push(`${event.assertions} assertions`);
923
+ const httpInStep = stepTraceLines.length;
924
+ if (httpInStep > 0)
925
+ stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
926
+ console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
927
+ if (event.error) {
928
+ console.log(` ${colors.red}${event.error}${colors.reset}`);
929
+ }
930
+ break;
833
931
  }
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}`);
932
+ case "summary":
933
+ runStats.httpRequestTotal += event.data.httpRequestTotal;
934
+ runStats.httpErrorTotal += event.data.httpErrorTotal;
935
+ runStats.assertionTotal += event.data.assertionTotal;
936
+ runStats.assertionFailed += event.data.assertionFailed;
937
+ runStats.warningTotal += event.data.warningTotal;
938
+ runStats.warningTriggered += event.data.warningTriggered;
939
+ runStats.stepTotal += event.data.stepTotal;
940
+ runStats.stepPassed += event.data.stepPassed;
941
+ runStats.stepFailed += event.data.stepFailed;
942
+ break;
943
+ case "warning": {
944
+ const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
945
+ console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
946
+ break;
858
947
  }
859
- break;
948
+ case "schema_validation":
949
+ if (effectiveRun.verbose) {
950
+ const icon = event.success ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
951
+ console.log(` ${icon} ${colors.dim}schema: ${event.label}${colors.reset}`);
952
+ }
953
+ break;
954
+ case "session:set":
955
+ // ProjectRunner accumulates internally for cross-file forwarding;
956
+ // CLI keeps its copy only for symmetry with pre-migration code
957
+ // paths (useful e.g. for debug logging).
958
+ sessionState[event.key] = event.value;
959
+ continue;
860
960
  }
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;
961
+ if (testStarted)
962
+ testEvents.push(event);
963
+ break;
964
+ }
965
+ case "file:complete":
966
+ // Mirror the old inline loop's tail cleanup: if the harness died
967
+ // mid-test or emitted no start event, promote the leftover state
968
+ // to a visible failure row.
969
+ if (!testStarted && errorMsg) {
970
+ console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
971
+ failed++;
972
+ }
973
+ if (testStarted) {
974
+ if (!errorMsg)
975
+ errorMsg = "Process exited before test completed";
976
+ finalizeTest();
876
977
  }
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}`);
978
+ break;
979
+ case "run:complete":
980
+ // Fail-fast skip display: any file ProjectRunner never started
981
+ // (because the failure limit kicked in between file groups) gets
982
+ // the old "○ (skipped — fail-fast)" lines here, preserving the
983
+ // pre-migration output layout.
984
+ if (failureLimit !== undefined && ev.failedCount >= failureLimit) {
985
+ for (const [filePath, fileTests] of fileGroups) {
986
+ if (startedFiles.has(filePath))
987
+ continue;
988
+ if (isMultiFile) {
989
+ const relPath = relative(process.cwd(), filePath);
990
+ console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
991
+ }
992
+ for (const { test } of fileTests) {
993
+ skipped++;
994
+ const name = test.meta.name || test.meta.id;
995
+ console.log(` ${colors.yellow}○${colors.reset} ${name} ${colors.dim}(skipped — fail-fast)${colors.reset}`);
996
+ }
881
997
  }
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
- }
998
+ }
999
+ break;
1000
+ case "run:failed":
1001
+ // Terminal failure — actual exit already happened in
1002
+ // bootstrap:failed / session:setup:failed above.
1003
+ break;
912
1004
  }
913
1005
  }
914
1006
  const totalDurationMs = Date.now() - totalStartTime;