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

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
@@ -12,6 +12,7 @@ let node_util = require("node:util");
12
12
  let node_child_process = require("node:child_process");
13
13
  let node_readline = require("node:readline");
14
14
  node_readline = require_gherkinParser.__toESM(node_readline, 1);
15
+ let node_stream = require("node:stream");
15
16
  //#region src/runtime/config.ts
16
17
  const DEFAULT_FEATURES = "**/*.feature";
17
18
  const DEFAULT_STEPS = "**/*.steps.ts";
@@ -169,7 +170,7 @@ function selectScenarios(scenarios, options) {
169
170
  }
170
171
  //#endregion
171
172
  //#region src/runtime/reporters.ts
172
- const useColor$2 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
173
+ const useColor$2 = !process.env.NO_COLOR && (!!process.env.FORCE_COLOR || (process.stdout.isTTY ?? false) === true);
173
174
  const wrap$1 = (open, close) => (s) => useColor$2 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
174
175
  const c = {
175
176
  green: wrap$1(32, 39),
@@ -183,16 +184,28 @@ const STATUS_MARK = {
183
184
  failed: c.red("✗"),
184
185
  skipped: c.yellow("-")
185
186
  };
187
+ /** Display status of a whole scenario (skipped = every step skipped). */
188
+ function scenarioStatus(result) {
189
+ if (result.status === "failed") return "failed";
190
+ if (result.steps.every((s) => s.status === "skipped")) return "skipped";
191
+ return "passed";
192
+ }
193
+ /** The scenario's display name, prefixed with its outline name for outline rows. */
194
+ function scenarioLabel(result) {
195
+ return result.scenario.outline ? `${result.scenario.outline.name} › ${result.scenario.name}` : result.scenario.name;
196
+ }
186
197
  /** ✓ / ✗ / - for a whole scenario (skipped = every step skipped). */
187
198
  function scenarioMark(result) {
188
- if (result.status === "failed") return c.red("✗");
189
- if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
199
+ const status = scenarioStatus(result);
200
+ if (status === "failed") return c.red("");
201
+ if (status === "skipped") return c.yellow("-");
190
202
  return c.green("✓");
191
203
  }
192
204
  /** Dot per scenario for the live heartbeat: `.` pass / `F` fail / `-` skip. */
193
205
  function scenarioDot(result) {
194
- if (result.status === "failed") return c.red("F");
195
- if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
206
+ const status = scenarioStatus(result);
207
+ if (status === "failed") return c.red("F");
208
+ if (status === "skipped") return c.yellow("-");
196
209
  return c.green(".");
197
210
  }
198
211
  /** Indent every non-empty line of a block by `pad` spaces. */
@@ -230,8 +243,7 @@ function failureDetail(stepResult, result, cwd) {
230
243
  * appended at the end.
231
244
  */
232
245
  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)}`];
246
+ const lines = [`${scenarioMark(result)} ${c.bold(scenarioLabel(result))}`];
235
247
  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
248
  lines.push(indent(c.dim(loc), 4));
237
249
  lines.push("");
@@ -346,6 +358,40 @@ function progressReporter(opts = {}) {
346
358
  function makeReporter(name, opts = {}) {
347
359
  return name === "progress" ? progressReporter(opts) : prettyReporter(opts);
348
360
  }
361
+ /**
362
+ * Internal reporter for interactive mode. Emits one {@link RunEvent} per line
363
+ * (NDJSON) and nothing human-facing, so a parent process can parse the stream
364
+ * and own the rendering. Failure detail reuses {@link renderScenario} verbatim,
365
+ * so the TUI's failures pane matches the non-interactive output.
366
+ */
367
+ function eventsReporter(opts = {}) {
368
+ const cwd = opts.cwd ?? process.cwd();
369
+ const emit = (evt) => write$1(`${JSON.stringify(evt)}\n`);
370
+ return {
371
+ onScenarioEnd(result) {
372
+ const steps = {
373
+ passed: 0,
374
+ failed: 0,
375
+ skipped: 0
376
+ };
377
+ for (const s of result.steps) steps[s.status]++;
378
+ const status = scenarioStatus(result);
379
+ emit({
380
+ t: "scenario",
381
+ status,
382
+ name: scenarioLabel(result),
383
+ steps,
384
+ detail: status === "failed" ? renderScenario(result, cwd, { stepSource: false }) : void 0
385
+ });
386
+ },
387
+ onComplete(_results, durationMs) {
388
+ emit({
389
+ t: "complete",
390
+ durationMs
391
+ });
392
+ }
393
+ };
394
+ }
349
395
  //#endregion
350
396
  //#region src/runtime/runner.ts
351
397
  /**
@@ -431,10 +477,30 @@ async function run(config, reporter = makeReporter(config.reporter, {
431
477
  }
432
478
  //#endregion
433
479
  //#region src/runtime/prompt.ts
480
+ function freshResults() {
481
+ return {
482
+ running: false,
483
+ scenarios: {
484
+ passed: 0,
485
+ failed: 0,
486
+ skipped: 0
487
+ },
488
+ steps: {
489
+ passed: 0,
490
+ failed: 0,
491
+ skipped: 0
492
+ },
493
+ dots: [],
494
+ failures: [],
495
+ notes: []
496
+ };
497
+ }
434
498
  const useColor$1 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
435
499
  const wrap = (open, close) => (s) => useColor$1 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
436
500
  const color = {
437
501
  green: wrap(32, 39),
502
+ red: wrap(31, 39),
503
+ yellow: wrap(33, 39),
438
504
  cyan: wrap(36, 39),
439
505
  dim: wrap(2, 22),
440
506
  bold: wrap(1, 22),
@@ -458,18 +524,23 @@ var Prompt = class {
458
524
  cursor;
459
525
  selected = 0;
460
526
  window = 0;
461
- /** Optional dim status line shown just above the input (set by the caller). */
527
+ /** Optional dim status line shown just below the input (set by the caller). */
462
528
  status = "";
463
- /** Lines the pinned block currently occupies, so a redraw can erase them. */
464
- rendered = 0;
529
+ results = freshResults();
530
+ failScroll = 0;
465
531
  started = false;
466
- /** While true the block is hidden so a run's output can own the screen. */
467
- suspended = false;
532
+ /** Filtered key stream: raw stdin minus the mouse sequences we handle. */
533
+ input;
468
534
  keyListener;
535
+ dataListener;
536
+ resizeListener;
537
+ signalCleanup;
538
+ /** Carry an incomplete mouse sequence split across stdin chunks. */
539
+ mousePending = "";
469
540
  constructor(options) {
470
541
  this.handlers = options.handlers;
471
542
  this.prefix = options.prefix ?? "› ";
472
- this.maxVisible = options.maxVisible ?? 10;
543
+ this.maxVisible = options.maxVisible ?? 8;
473
544
  this.query = options.initialQuery ?? "";
474
545
  this.cursor = this.query.length;
475
546
  }
@@ -482,46 +553,106 @@ var Prompt = class {
482
553
  get value() {
483
554
  return this.query;
484
555
  }
485
- /** Enter raw mode, attach the keypress listener, and draw the block. */
556
+ /** Start a fresh run: reset counts/dots/failures and record its header. */
557
+ resetResults(header) {
558
+ this.results = freshResults();
559
+ this.results.header = header;
560
+ this.results.running = true;
561
+ this.failScroll = 0;
562
+ this.render();
563
+ }
564
+ /** Append a note line above the dots (diagnostics, errors, empty notices). */
565
+ note(line) {
566
+ this.results.notes.push(line);
567
+ this.render();
568
+ }
569
+ /** Push a standalone block into the scrollable failures pane (e.g. a crash). */
570
+ failureBlock(text) {
571
+ this.results.failures.push(text);
572
+ this.render();
573
+ }
574
+ /** Record one finished scenario: tally it, add its dot, keep any failure block. */
575
+ scenario(status, steps, detail) {
576
+ this.results.scenarios[status]++;
577
+ this.results.steps.passed += steps.passed;
578
+ this.results.steps.failed += steps.failed;
579
+ this.results.steps.skipped += steps.skipped;
580
+ this.results.dots.push(dotFor(status));
581
+ if (detail) this.results.failures.push(detail);
582
+ this.render();
583
+ }
584
+ /** Mark the run finished and record its wall-clock duration. */
585
+ complete(durationMs) {
586
+ this.results.running = false;
587
+ this.results.durationMs = durationMs;
588
+ this.render();
589
+ }
590
+ /** Enter the alternate screen + raw mode, attach listeners, and paint. */
486
591
  start() {
487
592
  if (this.started) return;
488
593
  this.started = true;
489
- node_readline.emitKeypressEvents(process.stdin);
490
594
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
595
+ write("\x1B[?1049h\x1B[?1000h\x1B[?1006h");
596
+ this.input = new node_stream.PassThrough();
597
+ node_readline.emitKeypressEvents(this.input);
491
598
  this.keyListener = (str, key) => this.onKey(str, key ?? {});
492
- process.stdin.on("keypress", this.keyListener);
599
+ this.input.on("keypress", this.keyListener);
600
+ this.dataListener = (chunk) => this.onStdinData(chunk);
601
+ process.stdin.on("data", this.dataListener);
493
602
  process.stdin.resume();
603
+ this.resizeListener = () => this.render();
604
+ process.stdout.on("resize", this.resizeListener);
605
+ this.installSafetyNet();
494
606
  this.render();
495
607
  }
496
- /** Erase the block, restore cooked mode, and detach the listener. */
608
+ /** Restore the terminal: leave the alt buffer, cooked mode, cursor visible. */
497
609
  stop() {
498
610
  if (!this.started) return;
499
611
  this.started = false;
500
- this.erase();
501
- write("\x1B[?25h");
502
- if (this.keyListener) process.stdin.off("keypress", this.keyListener);
503
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
612
+ if (this.dataListener) process.stdin.off("data", this.dataListener);
613
+ if (this.keyListener) this.input?.off("keypress", this.keyListener);
614
+ if (this.resizeListener) process.stdout.off("resize", this.resizeListener);
615
+ this.signalCleanup?.();
616
+ this.restoreTerminal();
504
617
  process.stdin.pause();
618
+ this.input = void 0;
619
+ this.mousePending = "";
620
+ }
621
+ /** Force a repaint (after mutating `status`, say). No-op until started. */
622
+ redraw() {
623
+ if (this.started) this.render();
505
624
  }
506
625
  /**
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}.
626
+ * Leave raw mode and the alternate screen buffer and show the cursor.
627
+ * Idempotent and synchronous so it is safe from an `exit`/signal handler
628
+ * a crash or `kill` must never strand the terminal in raw/alt-buffer mode.
510
629
  */
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();
630
+ restoreTerminal() {
631
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
632
+ write("\x1B[?1006l\x1B[?1000l");
633
+ write("\x1B[?25h");
634
+ write("\x1B[?1049l");
521
635
  }
522
- /** Force a redraw (after mutating `status`, say). No-op while suspended. */
523
- redraw() {
524
- if (this.started && !this.suspended) this.render();
636
+ /**
637
+ * Register process-level handlers so the terminal is always restored, even on
638
+ * `kill` (SIGTERM), a crash (`exit` fires with a non-zero code), or a stray
639
+ * SIGINT. In raw mode Ctrl-C arrives as a keypress (handled by `onKey`), not a
640
+ * signal, so these are a safety net rather than the normal quit path.
641
+ */
642
+ installSafetyNet() {
643
+ const onExit = () => this.restoreTerminal();
644
+ const onSignal = () => {
645
+ this.restoreTerminal();
646
+ process.exit(130);
647
+ };
648
+ process.on("exit", onExit);
649
+ process.on("SIGINT", onSignal);
650
+ process.on("SIGTERM", onSignal);
651
+ this.signalCleanup = () => {
652
+ process.off("exit", onExit);
653
+ process.off("SIGINT", onSignal);
654
+ process.off("SIGTERM", onSignal);
655
+ };
525
656
  }
526
657
  onKey(str, key) {
527
658
  if (key.ctrl && (key.name === "c" || key.name === "d")) {
@@ -544,6 +675,12 @@ var Prompt = class {
544
675
  case "down":
545
676
  this.move(1);
546
677
  return;
678
+ case "pageup":
679
+ this.scrollFailures(-1);
680
+ return;
681
+ case "pagedown":
682
+ this.scrollFailures(1);
683
+ return;
547
684
  case "left":
548
685
  this.cursor = Math.max(0, this.cursor - 1);
549
686
  this.render();
@@ -596,6 +733,57 @@ var Prompt = class {
596
733
  this.reWindow();
597
734
  this.render();
598
735
  }
736
+ /** Scroll the failures pane by whole pages (PgUp/PgDn). */
737
+ scrollFailures(pages) {
738
+ this.adjustFailScroll(pages * this.failPage);
739
+ }
740
+ failPage = 1;
741
+ /** Move the failures viewport by `deltaLines` (clamped in {@link render}). */
742
+ adjustFailScroll(deltaLines) {
743
+ this.failScroll = Math.max(0, this.failScroll + deltaLines);
744
+ this.render();
745
+ }
746
+ /**
747
+ * Split a raw stdin chunk: decode SGR/legacy mouse sequences here (only the
748
+ * wheel is acted on — it scrolls the failures pane by a few lines) and forward
749
+ * everything else to readline. An incomplete trailing mouse sequence is held
750
+ * in {@link mousePending} for the next chunk. `latin1` keeps every byte 1:1 so
751
+ * the forwarded bytes reassemble exactly (including multibyte input).
752
+ */
753
+ onStdinData(chunk) {
754
+ const buf = this.mousePending + chunk.toString("latin1");
755
+ let out = "";
756
+ let i = 0;
757
+ while (i < buf.length) {
758
+ if (buf.startsWith("\x1B[<", i)) {
759
+ const rel = /[Mm]/.exec(buf.slice(i + 3));
760
+ if (!rel) break;
761
+ const end = i + 3 + rel.index + 1;
762
+ this.handleMouse(buf.slice(i, end));
763
+ i = end;
764
+ continue;
765
+ }
766
+ if (buf.startsWith("\x1B[M", i)) {
767
+ if (i + 6 > buf.length) break;
768
+ this.handleMouse(buf.slice(i, i + 6));
769
+ i += 6;
770
+ continue;
771
+ }
772
+ out += buf[i];
773
+ i++;
774
+ }
775
+ this.mousePending = buf.slice(i);
776
+ if (out) this.input?.write(Buffer.from(out, "latin1"));
777
+ }
778
+ /** Act on a decoded mouse sequence — wheel up/down scrolls failures; else ignore. */
779
+ handleMouse(seq) {
780
+ let button = null;
781
+ const sgr = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(seq);
782
+ if (sgr) button = parseInt(sgr[1], 10);
783
+ else if (seq.length === 6) button = seq.charCodeAt(3) - 32;
784
+ if (button === null || (button & 64) === 0) return;
785
+ this.adjustFailScroll((button & 1) === 0 ? -3 : 3);
786
+ }
599
787
  refilter() {
600
788
  const q = this.query.trim().toLowerCase();
601
789
  this.matches = this.all.map((s, index) => ({
@@ -610,9 +798,18 @@ var Prompt = class {
610
798
  if (this.selected < this.window) this.window = this.selected;
611
799
  else if (this.selected >= this.window + this.maxVisible) this.window = this.selected - this.maxVisible + 1;
612
800
  }
613
- /** Build the block's lines, top (suggestions) to bottom (input). */
614
- buildLines() {
801
+ rows() {
802
+ return process.stdout.rows && process.stdout.rows > 0 ? process.stdout.rows : 24;
803
+ }
804
+ cols() {
805
+ return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
806
+ }
807
+ /** Prompt + selection lines. The input line is always row 0. */
808
+ topLines() {
615
809
  const lines = [];
810
+ lines.push(`${color.bold(this.prefix)}${this.query}`);
811
+ if (this.status) lines.push(color.dim(this.status));
812
+ lines.push("");
616
813
  const total = this.matches.length;
617
814
  if (this.query.trim() && total === 0) lines.push(color.dim(" no matching tag, feature, or scenario"));
618
815
  const end = Math.min(this.window + this.maxVisible, total);
@@ -623,37 +820,123 @@ var Prompt = class {
623
820
  lines.push(active ? color.inverse(`❯ ${row}`) : ` ${row}`);
624
821
  }
625
822
  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}`);
823
+ return lines;
824
+ }
825
+ /** Stats (header + summary) and the wrapped dots, above the failures pane. */
826
+ resultLines(cols) {
827
+ const lines = ["", divider("results", cols)];
828
+ const r = this.results;
829
+ if (!r.header) {
830
+ lines.push(color.dim(" no run yet — press enter to run the highlighted selection"));
831
+ return lines;
832
+ }
833
+ const done = r.scenarios.passed + r.scenarios.failed + r.scenarios.skipped;
834
+ lines.push(` ${color.bold(`▶ ${r.header.label}`)} ${color.dim(`(run #${r.header.runCount} · ${r.header.clock})`)}`);
835
+ const scenarioTotal = r.running ? `${done}/${r.header.scenarioCount}` : done;
836
+ lines.push(` ${countLine(String(scenarioTotal), "scenario", r.scenarios)}`);
837
+ const stepTotal = r.steps.passed + r.steps.failed + r.steps.skipped;
838
+ lines.push(` ${countLine(String(stepTotal), "step", r.steps)}`);
839
+ lines.push(r.running ? color.dim(" running…") : color.dim(` ${((r.durationMs ?? 0) / 1e3).toFixed(2)}s`));
840
+ for (const n of r.notes) lines.push(n);
841
+ if (r.dots.length) {
842
+ lines.push("");
843
+ const per = Math.max(1, cols - 2);
844
+ const dotRows = [];
845
+ for (let i = 0; i < r.dots.length; i += per) dotRows.push(` ${r.dots.slice(i, i + per).join("")}`);
846
+ const maxDotRows = 6;
847
+ if (dotRows.length > maxDotRows) {
848
+ const hidden = dotRows.length - maxDotRows;
849
+ lines.push(color.dim(` …${hidden} earlier row${hidden === 1 ? "" : "s"}`));
850
+ lines.push(...dotRows.slice(-6));
851
+ } else lines.push(...dotRows);
852
+ }
628
853
  return lines;
629
854
  }
630
855
  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`);
856
+ if (!this.started) return;
857
+ const cols = this.cols();
858
+ const rows = this.rows();
859
+ const top = this.topLines();
860
+ const caretCol = Math.min(stringWidth(this.prefix) + this.cursor + 1, cols);
861
+ const above = [...top, ...this.resultLines(cols)];
862
+ const failLines = flattenFailures(this.results.failures);
863
+ const lines = [...above];
864
+ if (failLines.length) {
865
+ lines.push("", divider(`failures (${this.results.failures.length})`, cols));
866
+ const pane = Math.max(1, rows - lines.length - 1);
867
+ this.failPage = pane;
868
+ const maxScroll = Math.max(0, failLines.length - pane);
869
+ const off = Math.min(this.failScroll, maxScroll);
870
+ this.failScroll = off;
871
+ lines.push(...failLines.slice(off, off + pane));
872
+ if (maxScroll > 0) lines.push(color.dim(` [${off + 1}-${off + Math.min(pane, failLines.length - off)}/${failLines.length}] · PgUp/PgDn to scroll`));
873
+ }
874
+ write("\x1B[?25l\x1B[H");
875
+ write(lines.slice(0, rows).map((l) => `${clip(l, cols)}\x1b[K`).join("\r\n"));
876
+ write("\x1B[J");
877
+ write(`\x1b[1;${Math.max(1, caretCol)}H`);
639
878
  write("\x1B[?25h");
640
879
  }
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
880
  };
881
+ /** A green `.` / red `F` / yellow `-` for a finished scenario. */
882
+ function dotFor(status) {
883
+ if (status === "failed") return color.red("F");
884
+ if (status === "skipped") return color.yellow("-");
885
+ return color.green(".");
886
+ }
887
+ /** `<total> <noun>s (X passed, Y failed, Z skipped)`, zero parts omitted. */
888
+ function countLine(total, noun, counts) {
889
+ const part = (n, label, paint) => n > 0 ? paint(`${n} ${label}`) : null;
890
+ const parts = [
891
+ part(counts.passed, "passed", color.green),
892
+ part(counts.failed, "failed", color.red),
893
+ part(counts.skipped, "skipped", color.yellow)
894
+ ].filter((x) => x !== null);
895
+ return `${total} ${noun}${total === "1" ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
896
+ }
897
+ /** Flatten failure blocks into lines, a blank line between blocks. */
898
+ function flattenFailures(blocks) {
899
+ const out = [];
900
+ blocks.forEach((block, i) => {
901
+ if (i > 0) out.push("");
902
+ out.push(...block.split("\n"));
903
+ });
904
+ return out;
905
+ }
906
+ /** A dim `── label ─────` rule spanning the given width. */
907
+ function divider(label, cols) {
908
+ const head = `── ${label} `;
909
+ const fill = Math.max(0, cols - head.length);
910
+ return color.dim(head + "─".repeat(fill));
911
+ }
653
912
  /** Visible width, ignoring the ANSI escapes our color helpers may inject. */
654
913
  function stringWidth(s) {
655
914
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
656
915
  }
916
+ /**
917
+ * Truncate `s` to `max` visible columns, preserving ANSI color escapes (which
918
+ * have zero width) and re-resetting at the cut so a clipped color can't bleed.
919
+ */
920
+ function clip(s, max) {
921
+ let width = 0;
922
+ let out = "";
923
+ const escape = /^\x1b\[[0-9;]*m/;
924
+ let i = 0;
925
+ while (i < s.length) {
926
+ const rest = s.slice(i);
927
+ const m = escape.exec(rest);
928
+ if (m) {
929
+ out += m[0];
930
+ i += m[0].length;
931
+ continue;
932
+ }
933
+ if (width >= max) return `${out}\x1b[0m`;
934
+ out += s[i];
935
+ width++;
936
+ i++;
937
+ }
938
+ return out;
939
+ }
657
940
  function write(s) {
658
941
  process.stdout.write(s);
659
942
  }
@@ -818,7 +1101,6 @@ var InteractiveSession = class {
818
1101
  return;
819
1102
  }
820
1103
  this.running = true;
821
- this.prompt.beginOutput();
822
1104
  this.runLoop();
823
1105
  }
824
1106
  async runLoop() {
@@ -828,11 +1110,12 @@ var InteractiveSession = class {
828
1110
  if (this.armed) await this.executeOnce(this.armed);
829
1111
  } while (this.rerunQueued && this.armed && !this.dirty);
830
1112
  } catch (err) {
831
- process.stdout.write(red(`\n run failed: ${err instanceof Error ? err.message : err}\n`));
1113
+ this.prompt.failureBlock(red(`run failed: ${err instanceof Error ? err.message : err}`));
1114
+ this.prompt.complete(0);
832
1115
  } finally {
833
1116
  this.running = false;
834
1117
  this.prompt.status = this.status();
835
- this.prompt.endOutput();
1118
+ this.prompt.redraw();
836
1119
  }
837
1120
  }
838
1121
  /**
@@ -845,65 +1128,112 @@ var InteractiveSession = class {
845
1128
  */
846
1129
  async executeOnce(choice) {
847
1130
  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`);
1131
+ this.prompt.resetResults({
1132
+ label: choiceLabel(choice),
1133
+ scenarioCount: selected.length,
1134
+ runCount: ++this.runCount,
1135
+ clock: clock()
1136
+ });
850
1137
  if (selected.length === 0) {
851
- process.stdout.write(yellow(" no scenarios match this selection\n"));
1138
+ this.prompt.note(yellow(" no scenarios match this selection"));
1139
+ this.prompt.complete(0);
852
1140
  return;
853
1141
  }
854
- const single = selected.length === 1;
855
- if (single) await this.analyzeScenario(selected[0]);
856
- await this.spawnRun(runArgs(choice, single, this.config));
1142
+ if (selected.length === 1) for (const line of await this.analyzeScenario(selected[0])) this.prompt.note(line);
1143
+ await this.spawnRun(runArgs(choice, this.config));
857
1144
  }
858
1145
  /** Every scenario across the current catalog, flattened. */
859
1146
  scenarios() {
860
1147
  return this.catalog.flatMap((f) => f.scenarios);
861
1148
  }
862
1149
  /**
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.
1150
+ * Run `step-forge --events` as a child process and feed its NDJSON event
1151
+ * stream into the results region as it arrives live tallies, dots, and
1152
+ * failure blocks. Re-invokes the very CLI that's running us (`process.execPath`
1153
+ * + `argv[1]`), so it works identically from the source tree and the built bin.
1154
+ * `FORCE_COLOR` keeps the child's rendered failure blocks coloured even though
1155
+ * its stdout is a pipe. Never rejects — errors are surfaced and swallowed so
1156
+ * the watch loop survives.
868
1157
  */
869
1158
  spawnRun(args) {
870
1159
  return new Promise((resolve) => {
871
1160
  const child = (0, node_child_process.spawn)(process.execPath, [process.argv[1], ...args], {
872
1161
  cwd: this.config.cwd,
1162
+ env: {
1163
+ ...process.env,
1164
+ FORCE_COLOR: "1"
1165
+ },
873
1166
  stdio: [
874
1167
  "ignore",
875
- "inherit",
876
- "inherit"
1168
+ "pipe",
1169
+ "pipe"
877
1170
  ]
878
1171
  });
1172
+ let completed = false;
1173
+ let durationMs = 0;
1174
+ let stdoutBuf = "";
1175
+ let stderr = "";
1176
+ child.stdout.setEncoding("utf8");
1177
+ child.stdout.on("data", (chunk) => {
1178
+ stdoutBuf += chunk;
1179
+ let nl;
1180
+ while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
1181
+ const line = stdoutBuf.slice(0, nl);
1182
+ stdoutBuf = stdoutBuf.slice(nl + 1);
1183
+ if (!line.trim()) continue;
1184
+ let evt;
1185
+ try {
1186
+ evt = JSON.parse(line);
1187
+ } catch {
1188
+ continue;
1189
+ }
1190
+ if (evt.t === "scenario") this.prompt.scenario(evt.status, evt.steps, evt.detail);
1191
+ else if (evt.t === "complete") {
1192
+ completed = true;
1193
+ durationMs = evt.durationMs;
1194
+ }
1195
+ }
1196
+ });
1197
+ child.stderr.setEncoding("utf8");
1198
+ child.stderr.on("data", (chunk) => {
1199
+ stderr += chunk;
1200
+ });
879
1201
  child.on("error", (err) => {
880
- process.stdout.write(red(` could not start runner: ${err.message}\n`));
1202
+ this.prompt.failureBlock(red(`could not start runner: ${err.message}`));
1203
+ this.prompt.complete(0);
1204
+ resolve();
1205
+ });
1206
+ child.on("close", () => {
1207
+ if (completed) this.prompt.complete(durationMs);
1208
+ else {
1209
+ const detail = stderr.trim() || "(no output)";
1210
+ this.prompt.failureBlock(red(`runner exited without reporting results:\n${detail}`));
1211
+ this.prompt.complete(0);
1212
+ }
881
1213
  resolve();
882
1214
  });
883
- child.on("close", () => resolve());
884
1215
  });
885
1216
  }
886
1217
  /**
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.
1218
+ * Run the static analyzer over a single scenario and return any dependency /
1219
+ * undefined / ambiguous diagnostics as note lines for the results region. The
1220
+ * analyzer needs the optional `typescript` peer for AST extraction; if it's
1221
+ * absent we note that and skip, never failing the run.
891
1222
  */
892
1223
  async analyzeScenario(scenario) {
893
1224
  let analyzer;
894
1225
  try {
895
1226
  analyzer = await Promise.resolve().then(() => require("./analyzer-DIZPMxzN.cjs")).then((n) => n.analyzer_exports);
896
1227
  } catch {
897
- process.stdout.write(dim(" analysis skipped: install `typescript` to enable it\n\n"));
898
- return;
1228
+ return [dim(" analysis skipped: install `typescript` to enable it")];
899
1229
  }
900
1230
  try {
901
1231
  const stepFiles = await require_gherkinParser.globFiles(this.config.steps, this.config.cwd);
902
1232
  const defs = analyzer.extractStepDefinitions(stepFiles);
903
1233
  const matched = analyzer.matchScenarioSteps(scenario, defs);
904
- printDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
1234
+ return formatDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
905
1235
  } catch (err) {
906
- process.stdout.write(dim(` analysis unavailable: ${err instanceof Error ? err.message : err}\n\n`));
1236
+ return [dim(` analysis unavailable: ${err instanceof Error ? err.message : err}`)];
907
1237
  }
908
1238
  }
909
1239
  status() {
@@ -981,9 +1311,10 @@ function choiceLabel(choice) {
981
1311
  * - feature → that single feature file
982
1312
  * - scenario → that feature file, name-anchored with `-n "/^…$/"` (the name is
983
1313
  * the outline name for an outline, so all its rows run)
984
- * A single scenario runs verbose; a population uses the configured reporter.
1314
+ * Always `--events`: the child emits its NDJSON run stream and the TUI renders
1315
+ * the results region itself.
985
1316
  */
986
- function runArgs(choice, single, config) {
1317
+ function runArgs(choice, config) {
987
1318
  const args = [];
988
1319
  for (const glob of config.steps) args.push("-s", glob);
989
1320
  if (config.world) args.push("-w", config.world);
@@ -999,11 +1330,7 @@ function runArgs(choice, single, config) {
999
1330
  args.push(choice.file, "-n", `/^${escapeRegExp(choice.name)}$/`);
1000
1331
  break;
1001
1332
  }
1002
- if (single) args.push("-v");
1003
- else {
1004
- args.push("-r", config.reporter);
1005
- if (config.verbose) args.push("-v");
1006
- }
1333
+ args.push("--events");
1007
1334
  return args;
1008
1335
  }
1009
1336
  /** Escape a scenario name for use inside an anchored `-n` regex. */
@@ -1019,15 +1346,15 @@ function clock() {
1019
1346
  d.getSeconds()
1020
1347
  ].map((n) => String(n).padStart(2, "0")).join(":");
1021
1348
  }
1022
- function printDiagnostics(diagnostics, cwd) {
1023
- if (diagnostics.length === 0) return;
1024
- process.stdout.write(bold(" analyzer:\n"));
1349
+ function formatDiagnostics(diagnostics, cwd) {
1350
+ if (diagnostics.length === 0) return [];
1351
+ const lines = [bold(" analyzer:")];
1025
1352
  for (const d of diagnostics) {
1026
1353
  const mark = d.severity === "error" ? red("✗") : d.severity === "warning" ? yellow("!") : dim("i");
1027
1354
  const loc = `${node_path.relative(cwd, d.file)}:${d.range.startLine}`;
1028
- process.stdout.write(` ${mark} ${d.message} ${dim(`(${loc})`)}\n`);
1355
+ lines.push(` ${mark} ${d.message} ${dim(`(${loc})`)}`);
1029
1356
  }
1030
- process.stdout.write("\n");
1357
+ return lines;
1031
1358
  }
1032
1359
  //#endregion
1033
1360
  //#region src/runtime/cli.ts
@@ -1090,6 +1417,7 @@ function parseCli(argv) {
1090
1417
  type: "boolean",
1091
1418
  short: "i"
1092
1419
  },
1420
+ events: { type: "boolean" },
1093
1421
  config: { type: "string" },
1094
1422
  help: {
1095
1423
  type: "boolean",
@@ -1120,17 +1448,18 @@ function parseCli(argv) {
1120
1448
  return {
1121
1449
  cwd: values.config ? node_path.resolve(process.cwd(), values.config) : process.cwd(),
1122
1450
  overrides,
1123
- interactive: values.interactive ?? false
1451
+ interactive: values.interactive ?? false,
1452
+ events: values.events ?? false
1124
1453
  };
1125
1454
  }
1126
1455
  async function main() {
1127
- const { cwd, overrides, interactive } = parseCli(process.argv.slice(2));
1456
+ const { cwd, overrides, interactive, events } = parseCli(process.argv.slice(2));
1128
1457
  const config = resolveConfig(cwd, await loadConfigFile(cwd), overrides);
1129
1458
  if (interactive) {
1130
1459
  await runInteractive(config);
1131
1460
  return;
1132
1461
  }
1133
- const { passed } = await run(config);
1462
+ const { passed } = events ? await run(config, eventsReporter({ cwd: config.cwd })) : await run(config);
1134
1463
  process.exitCode = passed ? 0 : 1;
1135
1464
  }
1136
1465
  main().catch((err) => {