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