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