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