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