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