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