@step-forge/step-forge 0.0.24 → 0.0.25-alpha.1

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/cli.js CHANGED
@@ -167,7 +167,7 @@ function selectScenarios(scenarios, options) {
167
167
  }
168
168
  //#endregion
169
169
  //#region src/runtime/reporters.ts
170
- const useColor$2 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
170
+ const useColor$2 = !process.env.NO_COLOR && (!!process.env.FORCE_COLOR || (process.stdout.isTTY ?? false) === true);
171
171
  const wrap$1 = (open, close) => (s) => useColor$2 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
172
172
  const c = {
173
173
  green: wrap$1(32, 39),
@@ -181,16 +181,28 @@ const STATUS_MARK = {
181
181
  failed: c.red("✗"),
182
182
  skipped: c.yellow("-")
183
183
  };
184
+ /** Display status of a whole scenario (skipped = every step skipped). */
185
+ function scenarioStatus(result) {
186
+ if (result.status === "failed") return "failed";
187
+ if (result.steps.every((s) => s.status === "skipped")) return "skipped";
188
+ return "passed";
189
+ }
190
+ /** The scenario's display name, prefixed with its outline name for outline rows. */
191
+ function scenarioLabel(result) {
192
+ return result.scenario.outline ? `${result.scenario.outline.name} › ${result.scenario.name}` : result.scenario.name;
193
+ }
184
194
  /** ✓ / ✗ / - for a whole scenario (skipped = every step skipped). */
185
195
  function scenarioMark(result) {
186
- if (result.status === "failed") return c.red("✗");
187
- if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
196
+ const status = scenarioStatus(result);
197
+ if (status === "failed") return c.red("");
198
+ if (status === "skipped") return c.yellow("-");
188
199
  return c.green("✓");
189
200
  }
190
201
  /** Dot per scenario for the live heartbeat: `.` pass / `F` fail / `-` skip. */
191
202
  function scenarioDot(result) {
192
- if (result.status === "failed") return c.red("F");
193
- if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
203
+ const status = scenarioStatus(result);
204
+ if (status === "failed") return c.red("F");
205
+ if (status === "skipped") return c.yellow("-");
194
206
  return c.green(".");
195
207
  }
196
208
  /** Indent every non-empty line of a block by `pad` spaces. */
@@ -228,8 +240,7 @@ function failureDetail(stepResult, result, cwd) {
228
240
  * appended at the end.
229
241
  */
230
242
  function renderScenario(result, cwd, opts) {
231
- const label = result.scenario.outline ? `${result.scenario.outline.name} ${result.scenario.name}` : result.scenario.name;
232
- const lines = [`${scenarioMark(result)} ${c.bold(label)}`];
243
+ const lines = [`${scenarioMark(result)} ${c.bold(scenarioLabel(result))}`];
233
244
  const loc = result.scenario.line ? `${relative(cwd, result.scenario.file)}:${result.scenario.line}` : relative(cwd, result.scenario.file);
234
245
  lines.push(indent(c.dim(loc), 4));
235
246
  lines.push("");
@@ -344,6 +355,40 @@ function progressReporter(opts = {}) {
344
355
  function makeReporter(name, opts = {}) {
345
356
  return name === "progress" ? progressReporter(opts) : prettyReporter(opts);
346
357
  }
358
+ /**
359
+ * Internal reporter for interactive mode. Emits one {@link RunEvent} per line
360
+ * (NDJSON) and nothing human-facing, so a parent process can parse the stream
361
+ * and own the rendering. Failure detail reuses {@link renderScenario} verbatim,
362
+ * so the TUI's failures pane matches the non-interactive output.
363
+ */
364
+ function eventsReporter(opts = {}) {
365
+ const cwd = opts.cwd ?? process.cwd();
366
+ const emit = (evt) => write$1(`${JSON.stringify(evt)}\n`);
367
+ return {
368
+ onScenarioEnd(result) {
369
+ const steps = {
370
+ passed: 0,
371
+ failed: 0,
372
+ skipped: 0
373
+ };
374
+ for (const s of result.steps) steps[s.status]++;
375
+ const status = scenarioStatus(result);
376
+ emit({
377
+ t: "scenario",
378
+ status,
379
+ name: scenarioLabel(result),
380
+ steps,
381
+ detail: status === "failed" ? renderScenario(result, cwd, { stepSource: false }) : void 0
382
+ });
383
+ },
384
+ onComplete(_results, durationMs) {
385
+ emit({
386
+ t: "complete",
387
+ durationMs
388
+ });
389
+ }
390
+ };
391
+ }
347
392
  //#endregion
348
393
  //#region src/runtime/runner.ts
349
394
  /**
@@ -429,10 +474,30 @@ async function run(config, reporter = makeReporter(config.reporter, {
429
474
  }
430
475
  //#endregion
431
476
  //#region src/runtime/prompt.ts
477
+ function freshResults() {
478
+ return {
479
+ running: false,
480
+ scenarios: {
481
+ passed: 0,
482
+ failed: 0,
483
+ skipped: 0
484
+ },
485
+ steps: {
486
+ passed: 0,
487
+ failed: 0,
488
+ skipped: 0
489
+ },
490
+ dots: [],
491
+ failures: [],
492
+ notes: []
493
+ };
494
+ }
432
495
  const useColor$1 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
433
496
  const wrap = (open, close) => (s) => useColor$1 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
434
497
  const color = {
435
498
  green: wrap(32, 39),
499
+ red: wrap(31, 39),
500
+ yellow: wrap(33, 39),
436
501
  cyan: wrap(36, 39),
437
502
  dim: wrap(2, 22),
438
503
  bold: wrap(1, 22),
@@ -456,18 +521,18 @@ var Prompt = class {
456
521
  cursor;
457
522
  selected = 0;
458
523
  window = 0;
459
- /** Optional dim status line shown just above the input (set by the caller). */
524
+ /** Optional dim status line shown just below the input (set by the caller). */
460
525
  status = "";
461
- /** Lines the pinned block currently occupies, so a redraw can erase them. */
462
- rendered = 0;
526
+ results = freshResults();
527
+ failScroll = 0;
463
528
  started = false;
464
- /** While true the block is hidden so a run's output can own the screen. */
465
- suspended = false;
466
529
  keyListener;
530
+ resizeListener;
531
+ signalCleanup;
467
532
  constructor(options) {
468
533
  this.handlers = options.handlers;
469
534
  this.prefix = options.prefix ?? "› ";
470
- this.maxVisible = options.maxVisible ?? 10;
535
+ this.maxVisible = options.maxVisible ?? 8;
471
536
  this.query = options.initialQuery ?? "";
472
537
  this.cursor = this.query.length;
473
538
  }
@@ -480,46 +545,99 @@ var Prompt = class {
480
545
  get value() {
481
546
  return this.query;
482
547
  }
483
- /** Enter raw mode, attach the keypress listener, and draw the block. */
548
+ /** Start a fresh run: reset counts/dots/failures and record its header. */
549
+ resetResults(header) {
550
+ this.results = freshResults();
551
+ this.results.header = header;
552
+ this.results.running = true;
553
+ this.failScroll = 0;
554
+ this.render();
555
+ }
556
+ /** Append a note line above the dots (diagnostics, errors, empty notices). */
557
+ note(line) {
558
+ this.results.notes.push(line);
559
+ this.render();
560
+ }
561
+ /** Push a standalone block into the scrollable failures pane (e.g. a crash). */
562
+ failureBlock(text) {
563
+ this.results.failures.push(text);
564
+ this.render();
565
+ }
566
+ /** Record one finished scenario: tally it, add its dot, keep any failure block. */
567
+ scenario(status, steps, detail) {
568
+ this.results.scenarios[status]++;
569
+ this.results.steps.passed += steps.passed;
570
+ this.results.steps.failed += steps.failed;
571
+ this.results.steps.skipped += steps.skipped;
572
+ this.results.dots.push(dotFor(status));
573
+ if (detail) this.results.failures.push(detail);
574
+ this.render();
575
+ }
576
+ /** Mark the run finished and record its wall-clock duration. */
577
+ complete(durationMs) {
578
+ this.results.running = false;
579
+ this.results.durationMs = durationMs;
580
+ this.render();
581
+ }
582
+ /** Enter the alternate screen + raw mode, attach listeners, and paint. */
484
583
  start() {
485
584
  if (this.started) return;
486
585
  this.started = true;
487
586
  readline.emitKeypressEvents(process.stdin);
488
587
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
588
+ write("\x1B[?1049h");
489
589
  this.keyListener = (str, key) => this.onKey(str, key ?? {});
490
590
  process.stdin.on("keypress", this.keyListener);
491
591
  process.stdin.resume();
592
+ this.resizeListener = () => this.render();
593
+ process.stdout.on("resize", this.resizeListener);
594
+ this.installSafetyNet();
492
595
  this.render();
493
596
  }
494
- /** Erase the block, restore cooked mode, and detach the listener. */
597
+ /** Restore the terminal: leave the alt buffer, cooked mode, cursor visible. */
495
598
  stop() {
496
599
  if (!this.started) return;
497
600
  this.started = false;
498
- this.erase();
499
- write("\x1B[?25h");
500
601
  if (this.keyListener) process.stdin.off("keypress", this.keyListener);
501
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
602
+ if (this.resizeListener) process.stdout.off("resize", this.resizeListener);
603
+ this.signalCleanup?.();
604
+ this.restoreTerminal();
502
605
  process.stdin.pause();
503
606
  }
607
+ /** Force a repaint (after mutating `status`, say). No-op until started. */
608
+ redraw() {
609
+ if (this.started) this.render();
610
+ }
504
611
  /**
505
- * Erase the pinned block and hide it for the duration of an (async) run, so
506
- * everything written to stdout in the meantime scrolls where the prompt was.
507
- * Keypresses still update state but don't repaint until {@link endOutput}.
612
+ * Leave raw mode and the alternate screen buffer and show the cursor.
613
+ * Idempotent and synchronous so it is safe from an `exit`/signal handler
614
+ * a crash or `kill` must never strand the terminal in raw/alt-buffer mode.
508
615
  */
509
- beginOutput() {
510
- if (!this.started) return;
511
- this.erase();
512
- this.suspended = true;
513
- }
514
- /** Redraw the pinned block below whatever output {@link beginOutput} let through. */
515
- endOutput() {
516
- if (!this.started) return;
517
- this.suspended = false;
518
- this.render();
616
+ restoreTerminal() {
617
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
618
+ write("\x1B[?25h");
619
+ write("\x1B[?1049l");
519
620
  }
520
- /** Force a redraw (after mutating `status`, say). No-op while suspended. */
521
- redraw() {
522
- if (this.started && !this.suspended) this.render();
621
+ /**
622
+ * Register process-level handlers so the terminal is always restored, even on
623
+ * `kill` (SIGTERM), a crash (`exit` fires with a non-zero code), or a stray
624
+ * SIGINT. In raw mode Ctrl-C arrives as a keypress (handled by `onKey`), not a
625
+ * signal, so these are a safety net rather than the normal quit path.
626
+ */
627
+ installSafetyNet() {
628
+ const onExit = () => this.restoreTerminal();
629
+ const onSignal = () => {
630
+ this.restoreTerminal();
631
+ process.exit(130);
632
+ };
633
+ process.on("exit", onExit);
634
+ process.on("SIGINT", onSignal);
635
+ process.on("SIGTERM", onSignal);
636
+ this.signalCleanup = () => {
637
+ process.off("exit", onExit);
638
+ process.off("SIGINT", onSignal);
639
+ process.off("SIGTERM", onSignal);
640
+ };
523
641
  }
524
642
  onKey(str, key) {
525
643
  if (key.ctrl && (key.name === "c" || key.name === "d")) {
@@ -542,6 +660,12 @@ var Prompt = class {
542
660
  case "down":
543
661
  this.move(1);
544
662
  return;
663
+ case "pageup":
664
+ this.scrollFailures(-1);
665
+ return;
666
+ case "pagedown":
667
+ this.scrollFailures(1);
668
+ return;
545
669
  case "left":
546
670
  this.cursor = Math.max(0, this.cursor - 1);
547
671
  this.render();
@@ -594,6 +718,12 @@ var Prompt = class {
594
718
  this.reWindow();
595
719
  this.render();
596
720
  }
721
+ /** Scroll the failures pane by a page (clamped in {@link render}). */
722
+ scrollFailures(pages) {
723
+ this.failScroll = Math.max(0, this.failScroll + pages * this.failPage);
724
+ this.render();
725
+ }
726
+ failPage = 1;
597
727
  refilter() {
598
728
  const q = this.query.trim().toLowerCase();
599
729
  this.matches = this.all.map((s, index) => ({
@@ -608,9 +738,18 @@ var Prompt = class {
608
738
  if (this.selected < this.window) this.window = this.selected;
609
739
  else if (this.selected >= this.window + this.maxVisible) this.window = this.selected - this.maxVisible + 1;
610
740
  }
611
- /** Build the block's lines, top (suggestions) to bottom (input). */
612
- buildLines() {
741
+ rows() {
742
+ return process.stdout.rows && process.stdout.rows > 0 ? process.stdout.rows : 24;
743
+ }
744
+ cols() {
745
+ return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
746
+ }
747
+ /** Prompt + selection lines. The input line is always row 0. */
748
+ topLines() {
613
749
  const lines = [];
750
+ lines.push(`${color.bold(this.prefix)}${this.query}`);
751
+ if (this.status) lines.push(color.dim(this.status));
752
+ lines.push("");
614
753
  const total = this.matches.length;
615
754
  if (this.query.trim() && total === 0) lines.push(color.dim(" no matching tag, feature, or scenario"));
616
755
  const end = Math.min(this.window + this.maxVisible, total);
@@ -621,37 +760,123 @@ var Prompt = class {
621
760
  lines.push(active ? color.inverse(`❯ ${row}`) : ` ${row}`);
622
761
  }
623
762
  if (total > end) lines.push(color.dim(` …and ${total - end} more`));
624
- if (this.status) lines.push(color.dim(this.status));
625
- lines.push(`${color.bold(this.prefix)}${this.query}`);
763
+ return lines;
764
+ }
765
+ /** Stats (header + summary) and the wrapped dots, above the failures pane. */
766
+ resultLines(cols) {
767
+ const lines = ["", divider("results", cols)];
768
+ const r = this.results;
769
+ if (!r.header) {
770
+ lines.push(color.dim(" no run yet — press enter to run the highlighted selection"));
771
+ return lines;
772
+ }
773
+ const done = r.scenarios.passed + r.scenarios.failed + r.scenarios.skipped;
774
+ lines.push(` ${color.bold(`▶ ${r.header.label}`)} ${color.dim(`(run #${r.header.runCount} · ${r.header.clock})`)}`);
775
+ const scenarioTotal = r.running ? `${done}/${r.header.scenarioCount}` : done;
776
+ lines.push(` ${countLine(String(scenarioTotal), "scenario", r.scenarios)}`);
777
+ const stepTotal = r.steps.passed + r.steps.failed + r.steps.skipped;
778
+ lines.push(` ${countLine(String(stepTotal), "step", r.steps)}`);
779
+ lines.push(r.running ? color.dim(" running…") : color.dim(` ${((r.durationMs ?? 0) / 1e3).toFixed(2)}s`));
780
+ for (const n of r.notes) lines.push(n);
781
+ if (r.dots.length) {
782
+ lines.push("");
783
+ const per = Math.max(1, cols - 2);
784
+ const dotRows = [];
785
+ for (let i = 0; i < r.dots.length; i += per) dotRows.push(` ${r.dots.slice(i, i + per).join("")}`);
786
+ const maxDotRows = 6;
787
+ if (dotRows.length > maxDotRows) {
788
+ const hidden = dotRows.length - maxDotRows;
789
+ lines.push(color.dim(` …${hidden} earlier row${hidden === 1 ? "" : "s"}`));
790
+ lines.push(...dotRows.slice(-6));
791
+ } else lines.push(...dotRows);
792
+ }
626
793
  return lines;
627
794
  }
628
795
  render() {
629
- if (this.suspended) return;
630
- write("\x1B[?25l");
631
- this.moveToBlockStart();
632
- write("\x1B[0J");
633
- const lines = this.buildLines();
634
- write(lines.join("\r\n"));
635
- this.rendered = lines.length;
636
- write(`\r\x1b[${stringWidth(this.prefix) + this.cursor}C`);
796
+ if (!this.started) return;
797
+ const cols = this.cols();
798
+ const rows = this.rows();
799
+ const top = this.topLines();
800
+ const caretCol = Math.min(stringWidth(this.prefix) + this.cursor + 1, cols);
801
+ const above = [...top, ...this.resultLines(cols)];
802
+ const failLines = flattenFailures(this.results.failures);
803
+ const lines = [...above];
804
+ if (failLines.length) {
805
+ lines.push("", divider(`failures (${this.results.failures.length})`, cols));
806
+ const pane = Math.max(1, rows - lines.length - 1);
807
+ this.failPage = pane;
808
+ const maxScroll = Math.max(0, failLines.length - pane);
809
+ const off = Math.min(this.failScroll, maxScroll);
810
+ this.failScroll = off;
811
+ lines.push(...failLines.slice(off, off + pane));
812
+ if (maxScroll > 0) lines.push(color.dim(` [${off + 1}-${off + Math.min(pane, failLines.length - off)}/${failLines.length}] · PgUp/PgDn to scroll`));
813
+ }
814
+ write("\x1B[?25l\x1B[H");
815
+ write(lines.slice(0, rows).map((l) => `${clip(l, cols)}\x1b[K`).join("\r\n"));
816
+ write("\x1B[J");
817
+ write(`\x1b[1;${Math.max(1, caretCol)}H`);
637
818
  write("\x1B[?25h");
638
819
  }
639
- /** Erase the block and leave the caret at the block's top-left. */
640
- erase() {
641
- this.moveToBlockStart();
642
- write("\x1B[0J");
643
- this.rendered = 0;
644
- }
645
- /** Move the caret to column 0 of the block's first line. */
646
- moveToBlockStart() {
647
- if (this.rendered > 1) write(`\x1b[${this.rendered - 1}A`);
648
- write("\r");
649
- }
650
820
  };
821
+ /** A green `.` / red `F` / yellow `-` for a finished scenario. */
822
+ function dotFor(status) {
823
+ if (status === "failed") return color.red("F");
824
+ if (status === "skipped") return color.yellow("-");
825
+ return color.green(".");
826
+ }
827
+ /** `<total> <noun>s (X passed, Y failed, Z skipped)`, zero parts omitted. */
828
+ function countLine(total, noun, counts) {
829
+ const part = (n, label, paint) => n > 0 ? paint(`${n} ${label}`) : null;
830
+ const parts = [
831
+ part(counts.passed, "passed", color.green),
832
+ part(counts.failed, "failed", color.red),
833
+ part(counts.skipped, "skipped", color.yellow)
834
+ ].filter((x) => x !== null);
835
+ return `${total} ${noun}${total === "1" ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
836
+ }
837
+ /** Flatten failure blocks into lines, a blank line between blocks. */
838
+ function flattenFailures(blocks) {
839
+ const out = [];
840
+ blocks.forEach((block, i) => {
841
+ if (i > 0) out.push("");
842
+ out.push(...block.split("\n"));
843
+ });
844
+ return out;
845
+ }
846
+ /** A dim `── label ─────` rule spanning the given width. */
847
+ function divider(label, cols) {
848
+ const head = `── ${label} `;
849
+ const fill = Math.max(0, cols - head.length);
850
+ return color.dim(head + "─".repeat(fill));
851
+ }
651
852
  /** Visible width, ignoring the ANSI escapes our color helpers may inject. */
652
853
  function stringWidth(s) {
653
854
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
654
855
  }
856
+ /**
857
+ * Truncate `s` to `max` visible columns, preserving ANSI color escapes (which
858
+ * have zero width) and re-resetting at the cut so a clipped color can't bleed.
859
+ */
860
+ function clip(s, max) {
861
+ let width = 0;
862
+ let out = "";
863
+ const escape = /^\x1b\[[0-9;]*m/;
864
+ let i = 0;
865
+ while (i < s.length) {
866
+ const rest = s.slice(i);
867
+ const m = escape.exec(rest);
868
+ if (m) {
869
+ out += m[0];
870
+ i += m[0].length;
871
+ continue;
872
+ }
873
+ if (width >= max) return `${out}\x1b[0m`;
874
+ out += s[i];
875
+ width++;
876
+ i++;
877
+ }
878
+ return out;
879
+ }
655
880
  function write(s) {
656
881
  process.stdout.write(s);
657
882
  }
@@ -816,7 +1041,6 @@ var InteractiveSession = class {
816
1041
  return;
817
1042
  }
818
1043
  this.running = true;
819
- this.prompt.beginOutput();
820
1044
  this.runLoop();
821
1045
  }
822
1046
  async runLoop() {
@@ -826,11 +1050,12 @@ var InteractiveSession = class {
826
1050
  if (this.armed) await this.executeOnce(this.armed);
827
1051
  } while (this.rerunQueued && this.armed && !this.dirty);
828
1052
  } catch (err) {
829
- process.stdout.write(red(`\n run failed: ${err instanceof Error ? err.message : err}\n`));
1053
+ this.prompt.failureBlock(red(`run failed: ${err instanceof Error ? err.message : err}`));
1054
+ this.prompt.complete(0);
830
1055
  } finally {
831
1056
  this.running = false;
832
1057
  this.prompt.status = this.status();
833
- this.prompt.endOutput();
1058
+ this.prompt.redraw();
834
1059
  }
835
1060
  }
836
1061
  /**
@@ -843,65 +1068,112 @@ var InteractiveSession = class {
843
1068
  */
844
1069
  async executeOnce(choice) {
845
1070
  const selected = resolveScenarios(choice, this.scenarios());
846
- const count = `${selected.length} scenario${selected.length === 1 ? "" : "s"}`;
847
- process.stdout.write(`\n${bold(`▶ ${choiceLabel(choice)}`)} ${dim(`(${count} · run #${++this.runCount} · ${clock()})`)}\n\n`);
1071
+ this.prompt.resetResults({
1072
+ label: choiceLabel(choice),
1073
+ scenarioCount: selected.length,
1074
+ runCount: ++this.runCount,
1075
+ clock: clock()
1076
+ });
848
1077
  if (selected.length === 0) {
849
- process.stdout.write(yellow(" no scenarios match this selection\n"));
1078
+ this.prompt.note(yellow(" no scenarios match this selection"));
1079
+ this.prompt.complete(0);
850
1080
  return;
851
1081
  }
852
- const single = selected.length === 1;
853
- if (single) await this.analyzeScenario(selected[0]);
854
- await this.spawnRun(runArgs(choice, single, this.config));
1082
+ if (selected.length === 1) for (const line of await this.analyzeScenario(selected[0])) this.prompt.note(line);
1083
+ await this.spawnRun(runArgs(choice, this.config));
855
1084
  }
856
1085
  /** Every scenario across the current catalog, flattened. */
857
1086
  scenarios() {
858
1087
  return this.catalog.flatMap((f) => f.scenarios);
859
1088
  }
860
1089
  /**
861
- * Run `step-forge` as a child process with `args`, streaming its output to the
862
- * terminal (where the erased prompt was). Re-invokes the very CLI that's
863
- * running us (`process.execPath` + `argv[1]`), so it works identically from
864
- * the source tree and the built bin. Never rejects — a spawn error is reported
865
- * and swallowed so the watch loop survives.
1090
+ * Run `step-forge --events` as a child process and feed its NDJSON event
1091
+ * stream into the results region as it arrives live tallies, dots, and
1092
+ * failure blocks. Re-invokes the very CLI that's running us (`process.execPath`
1093
+ * + `argv[1]`), so it works identically from the source tree and the built bin.
1094
+ * `FORCE_COLOR` keeps the child's rendered failure blocks coloured even though
1095
+ * its stdout is a pipe. Never rejects — errors are surfaced and swallowed so
1096
+ * the watch loop survives.
866
1097
  */
867
1098
  spawnRun(args) {
868
1099
  return new Promise((resolve) => {
869
1100
  const child = spawn(process.execPath, [process.argv[1], ...args], {
870
1101
  cwd: this.config.cwd,
1102
+ env: {
1103
+ ...process.env,
1104
+ FORCE_COLOR: "1"
1105
+ },
871
1106
  stdio: [
872
1107
  "ignore",
873
- "inherit",
874
- "inherit"
1108
+ "pipe",
1109
+ "pipe"
875
1110
  ]
876
1111
  });
1112
+ let completed = false;
1113
+ let durationMs = 0;
1114
+ let stdoutBuf = "";
1115
+ let stderr = "";
1116
+ child.stdout.setEncoding("utf8");
1117
+ child.stdout.on("data", (chunk) => {
1118
+ stdoutBuf += chunk;
1119
+ let nl;
1120
+ while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
1121
+ const line = stdoutBuf.slice(0, nl);
1122
+ stdoutBuf = stdoutBuf.slice(nl + 1);
1123
+ if (!line.trim()) continue;
1124
+ let evt;
1125
+ try {
1126
+ evt = JSON.parse(line);
1127
+ } catch {
1128
+ continue;
1129
+ }
1130
+ if (evt.t === "scenario") this.prompt.scenario(evt.status, evt.steps, evt.detail);
1131
+ else if (evt.t === "complete") {
1132
+ completed = true;
1133
+ durationMs = evt.durationMs;
1134
+ }
1135
+ }
1136
+ });
1137
+ child.stderr.setEncoding("utf8");
1138
+ child.stderr.on("data", (chunk) => {
1139
+ stderr += chunk;
1140
+ });
877
1141
  child.on("error", (err) => {
878
- process.stdout.write(red(` could not start runner: ${err.message}\n`));
1142
+ this.prompt.failureBlock(red(`could not start runner: ${err.message}`));
1143
+ this.prompt.complete(0);
1144
+ resolve();
1145
+ });
1146
+ child.on("close", () => {
1147
+ if (completed) this.prompt.complete(durationMs);
1148
+ else {
1149
+ const detail = stderr.trim() || "(no output)";
1150
+ this.prompt.failureBlock(red(`runner exited without reporting results:\n${detail}`));
1151
+ this.prompt.complete(0);
1152
+ }
879
1153
  resolve();
880
1154
  });
881
- child.on("close", () => resolve());
882
1155
  });
883
1156
  }
884
1157
  /**
885
- * Run the static analyzer over a single scenario and print any dependency /
886
- * undefined / ambiguous diagnostics. The analyzer needs the optional
887
- * `typescript` peer for AST extraction; if it's absent we note that and skip,
888
- * never failing the run.
1158
+ * Run the static analyzer over a single scenario and return any dependency /
1159
+ * undefined / ambiguous diagnostics as note lines for the results region. The
1160
+ * analyzer needs the optional `typescript` peer for AST extraction; if it's
1161
+ * absent we note that and skip, never failing the run.
889
1162
  */
890
1163
  async analyzeScenario(scenario) {
891
1164
  let analyzer;
892
1165
  try {
893
1166
  analyzer = await import("./analyzer-DnfK54Dw.js").then((n) => n.n);
894
1167
  } catch {
895
- process.stdout.write(dim(" analysis skipped: install `typescript` to enable it\n\n"));
896
- return;
1168
+ return [dim(" analysis skipped: install `typescript` to enable it")];
897
1169
  }
898
1170
  try {
899
1171
  const stepFiles = await globFiles(this.config.steps, this.config.cwd);
900
1172
  const defs = analyzer.extractStepDefinitions(stepFiles);
901
1173
  const matched = analyzer.matchScenarioSteps(scenario, defs);
902
- printDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
1174
+ return formatDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
903
1175
  } catch (err) {
904
- process.stdout.write(dim(` analysis unavailable: ${err instanceof Error ? err.message : err}\n\n`));
1176
+ return [dim(` analysis unavailable: ${err instanceof Error ? err.message : err}`)];
905
1177
  }
906
1178
  }
907
1179
  status() {
@@ -979,9 +1251,10 @@ function choiceLabel(choice) {
979
1251
  * - feature → that single feature file
980
1252
  * - scenario → that feature file, name-anchored with `-n "/^…$/"` (the name is
981
1253
  * the outline name for an outline, so all its rows run)
982
- * A single scenario runs verbose; a population uses the configured reporter.
1254
+ * Always `--events`: the child emits its NDJSON run stream and the TUI renders
1255
+ * the results region itself.
983
1256
  */
984
- function runArgs(choice, single, config) {
1257
+ function runArgs(choice, config) {
985
1258
  const args = [];
986
1259
  for (const glob of config.steps) args.push("-s", glob);
987
1260
  if (config.world) args.push("-w", config.world);
@@ -997,11 +1270,7 @@ function runArgs(choice, single, config) {
997
1270
  args.push(choice.file, "-n", `/^${escapeRegExp(choice.name)}$/`);
998
1271
  break;
999
1272
  }
1000
- if (single) args.push("-v");
1001
- else {
1002
- args.push("-r", config.reporter);
1003
- if (config.verbose) args.push("-v");
1004
- }
1273
+ args.push("--events");
1005
1274
  return args;
1006
1275
  }
1007
1276
  /** Escape a scenario name for use inside an anchored `-n` regex. */
@@ -1017,15 +1286,15 @@ function clock() {
1017
1286
  d.getSeconds()
1018
1287
  ].map((n) => String(n).padStart(2, "0")).join(":");
1019
1288
  }
1020
- function printDiagnostics(diagnostics, cwd) {
1021
- if (diagnostics.length === 0) return;
1022
- process.stdout.write(bold(" analyzer:\n"));
1289
+ function formatDiagnostics(diagnostics, cwd) {
1290
+ if (diagnostics.length === 0) return [];
1291
+ const lines = [bold(" analyzer:")];
1023
1292
  for (const d of diagnostics) {
1024
1293
  const mark = d.severity === "error" ? red("✗") : d.severity === "warning" ? yellow("!") : dim("i");
1025
1294
  const loc = `${path.relative(cwd, d.file)}:${d.range.startLine}`;
1026
- process.stdout.write(` ${mark} ${d.message} ${dim(`(${loc})`)}\n`);
1295
+ lines.push(` ${mark} ${d.message} ${dim(`(${loc})`)}`);
1027
1296
  }
1028
- process.stdout.write("\n");
1297
+ return lines;
1029
1298
  }
1030
1299
  //#endregion
1031
1300
  //#region src/runtime/cli.ts
@@ -1088,6 +1357,7 @@ function parseCli(argv) {
1088
1357
  type: "boolean",
1089
1358
  short: "i"
1090
1359
  },
1360
+ events: { type: "boolean" },
1091
1361
  config: { type: "string" },
1092
1362
  help: {
1093
1363
  type: "boolean",
@@ -1118,17 +1388,18 @@ function parseCli(argv) {
1118
1388
  return {
1119
1389
  cwd: values.config ? path.resolve(process.cwd(), values.config) : process.cwd(),
1120
1390
  overrides,
1121
- interactive: values.interactive ?? false
1391
+ interactive: values.interactive ?? false,
1392
+ events: values.events ?? false
1122
1393
  };
1123
1394
  }
1124
1395
  async function main() {
1125
- const { cwd, overrides, interactive } = parseCli(process.argv.slice(2));
1396
+ const { cwd, overrides, interactive, events } = parseCli(process.argv.slice(2));
1126
1397
  const config = resolveConfig(cwd, await loadConfigFile(cwd), overrides);
1127
1398
  if (interactive) {
1128
1399
  await runInteractive(config);
1129
1400
  return;
1130
1401
  }
1131
- const { passed } = await run(config);
1402
+ const { passed } = events ? await run(config, eventsReporter({ cwd: config.cwd })) : await run(config);
1132
1403
  process.exitCode = passed ? 0 : 1;
1133
1404
  }
1134
1405
  main().catch((err) => {