@step-forge/step-forge 0.0.22 → 0.0.24
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 +32 -0
- package/dist/analyzer-DIZPMxzN.cjs +441 -0
- package/dist/analyzer-DIZPMxzN.cjs.map +1 -0
- package/dist/analyzer-DnfK54Dw.js +417 -0
- package/dist/analyzer-DnfK54Dw.js.map +1 -0
- package/dist/analyzer-cli.js +1 -1
- package/dist/{analyzer-byS8yRrY.js → analyzer-gYyAKIpM.js} +10 -2
- package/dist/analyzer-gYyAKIpM.js.map +1 -0
- package/dist/analyzer.js +1 -1
- package/dist/cli.cjs +633 -16
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +631 -16
- package/dist/cli.js.map +1 -1
- package/dist/{gherkinParser-BT40q_i3.cjs → gherkinParser-_ZgvELdy.cjs} +46 -2
- package/dist/gherkinParser-_ZgvELdy.cjs.map +1 -0
- package/dist/{gherkinParser-NcttZgN4.js → gherkinParser-i-Q7M6mi.js} +26 -3
- package/dist/gherkinParser-i-Q7M6mi.js.map +1 -0
- package/dist/step-forge.cjs +7 -399
- package/dist/step-forge.cjs.map +1 -1
- package/dist/step-forge.js +2 -393
- package/dist/step-forge.js.map +1 -1
- package/package.json +1 -1
- package/dist/analyzer-byS8yRrY.js.map +0 -1
- package/dist/gherkinParser-BT40q_i3.cjs.map +0 -1
- package/dist/gherkinParser-NcttZgN4.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { i as runHooksParallel, n as globalHookRegistry, o as globalRegistry, r as runHooks } from "./hooks-BDCMKeNq.js";
|
|
3
|
-
import {
|
|
3
|
+
import { a as BasicWorld, c as relativeLocation, i as globFiles, l as userFrames, r as parseFeatureFiles, s as relativeFrame, t as parseFeatureCatalog } from "./gherkinParser-i-Q7M6mi.js";
|
|
4
4
|
import { i as runScenario, r as compileRegistry } from "./engine-DPVLEHBi.js";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { relative } from "node:path";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { access } from "node:fs/promises";
|
|
9
|
+
import * as fs from "node:fs";
|
|
9
10
|
import { parseArgs } from "node:util";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import * as readline from "node:readline";
|
|
10
13
|
//#region src/runtime/config.ts
|
|
11
14
|
const DEFAULT_FEATURES = "**/*.feature";
|
|
12
15
|
const DEFAULT_STEPS = "**/*.steps.ts";
|
|
@@ -164,14 +167,14 @@ function selectScenarios(scenarios, options) {
|
|
|
164
167
|
}
|
|
165
168
|
//#endregion
|
|
166
169
|
//#region src/runtime/reporters.ts
|
|
167
|
-
const useColor = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
168
|
-
const wrap = (open, close) => (s) => useColor ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
170
|
+
const useColor$2 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
171
|
+
const wrap$1 = (open, close) => (s) => useColor$2 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
169
172
|
const c = {
|
|
170
|
-
green: wrap(32, 39),
|
|
171
|
-
red: wrap(31, 39),
|
|
172
|
-
yellow: wrap(33, 39),
|
|
173
|
-
dim: wrap(2, 22),
|
|
174
|
-
bold: wrap(1, 22)
|
|
173
|
+
green: wrap$1(32, 39),
|
|
174
|
+
red: wrap$1(31, 39),
|
|
175
|
+
yellow: wrap$1(33, 39),
|
|
176
|
+
dim: wrap$1(2, 22),
|
|
177
|
+
bold: wrap$1(1, 22)
|
|
175
178
|
};
|
|
176
179
|
const STATUS_MARK = {
|
|
177
180
|
passed: c.green("✓"),
|
|
@@ -281,7 +284,7 @@ function renderSummary(results, durationMs) {
|
|
|
281
284
|
c.dim(`${(durationMs / 1e3).toFixed(2)}s`)
|
|
282
285
|
].join("\n");
|
|
283
286
|
}
|
|
284
|
-
function write(text) {
|
|
287
|
+
function write$1(text) {
|
|
285
288
|
process.stdout.write(text);
|
|
286
289
|
}
|
|
287
290
|
/** Feature-grouped tree of every scenario (used by `--verbose`). */
|
|
@@ -315,10 +318,10 @@ function prettyReporter(opts = {}) {
|
|
|
315
318
|
const verbose = opts.verbose ?? false;
|
|
316
319
|
return {
|
|
317
320
|
onScenarioEnd(result) {
|
|
318
|
-
if (!verbose) write(scenarioDot(result));
|
|
321
|
+
if (!verbose) write$1(scenarioDot(result));
|
|
319
322
|
},
|
|
320
323
|
onComplete(results, durationMs) {
|
|
321
|
-
write(`${verbose ? `\n${renderFullTree(results, cwd)}\n` : `\n\n${renderFailures(results, cwd)}`}${renderSummary(results, durationMs)}\n`);
|
|
324
|
+
write$1(`${verbose ? `\n${renderFullTree(results, cwd)}\n` : `\n\n${renderFailures(results, cwd)}`}${renderSummary(results, durationMs)}\n`);
|
|
322
325
|
}
|
|
323
326
|
};
|
|
324
327
|
}
|
|
@@ -331,10 +334,10 @@ function progressReporter(opts = {}) {
|
|
|
331
334
|
const cwd = opts.cwd ?? process.cwd();
|
|
332
335
|
return {
|
|
333
336
|
onScenarioEnd(result) {
|
|
334
|
-
write(scenarioDot(result));
|
|
337
|
+
write$1(scenarioDot(result));
|
|
335
338
|
},
|
|
336
339
|
onComplete(results, durationMs) {
|
|
337
|
-
write(`\n\n${renderFailures(results, cwd)}${renderSummary(results, durationMs)}\n`);
|
|
340
|
+
write$1(`\n\n${renderFailures(results, cwd)}${renderSummary(results, durationMs)}\n`);
|
|
338
341
|
}
|
|
339
342
|
};
|
|
340
343
|
}
|
|
@@ -425,6 +428,606 @@ async function run(config, reporter = makeReporter(config.reporter, {
|
|
|
425
428
|
};
|
|
426
429
|
}
|
|
427
430
|
//#endregion
|
|
431
|
+
//#region src/runtime/prompt.ts
|
|
432
|
+
const useColor$1 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
433
|
+
const wrap = (open, close) => (s) => useColor$1 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
434
|
+
const color = {
|
|
435
|
+
green: wrap(32, 39),
|
|
436
|
+
cyan: wrap(36, 39),
|
|
437
|
+
dim: wrap(2, 22),
|
|
438
|
+
bold: wrap(1, 22),
|
|
439
|
+
inverse: wrap(7, 27)
|
|
440
|
+
};
|
|
441
|
+
/** Ranked match: lower rank sorts first; `-1` means "no match" (excluded). */
|
|
442
|
+
function rank(search, query) {
|
|
443
|
+
if (!query) return 0;
|
|
444
|
+
const idx = search.indexOf(query);
|
|
445
|
+
if (idx === -1) return -1;
|
|
446
|
+
if (idx === 0) return 0;
|
|
447
|
+
return /[\s:›@/]/.test(search[idx - 1]) ? 1 : 2;
|
|
448
|
+
}
|
|
449
|
+
var Prompt = class {
|
|
450
|
+
handlers;
|
|
451
|
+
prefix;
|
|
452
|
+
maxVisible;
|
|
453
|
+
all = [];
|
|
454
|
+
matches = [];
|
|
455
|
+
query;
|
|
456
|
+
cursor;
|
|
457
|
+
selected = 0;
|
|
458
|
+
window = 0;
|
|
459
|
+
/** Optional dim status line shown just above the input (set by the caller). */
|
|
460
|
+
status = "";
|
|
461
|
+
/** Lines the pinned block currently occupies, so a redraw can erase them. */
|
|
462
|
+
rendered = 0;
|
|
463
|
+
started = false;
|
|
464
|
+
/** While true the block is hidden so a run's output can own the screen. */
|
|
465
|
+
suspended = false;
|
|
466
|
+
keyListener;
|
|
467
|
+
constructor(options) {
|
|
468
|
+
this.handlers = options.handlers;
|
|
469
|
+
this.prefix = options.prefix ?? "› ";
|
|
470
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
471
|
+
this.query = options.initialQuery ?? "";
|
|
472
|
+
this.cursor = this.query.length;
|
|
473
|
+
}
|
|
474
|
+
/** Replace the suggestion pool (e.g. after the feature cache rebuilds). */
|
|
475
|
+
setSuggestions(suggestions) {
|
|
476
|
+
this.all = suggestions;
|
|
477
|
+
this.refilter();
|
|
478
|
+
if (this.started) this.render();
|
|
479
|
+
}
|
|
480
|
+
get value() {
|
|
481
|
+
return this.query;
|
|
482
|
+
}
|
|
483
|
+
/** Enter raw mode, attach the keypress listener, and draw the block. */
|
|
484
|
+
start() {
|
|
485
|
+
if (this.started) return;
|
|
486
|
+
this.started = true;
|
|
487
|
+
readline.emitKeypressEvents(process.stdin);
|
|
488
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
489
|
+
this.keyListener = (str, key) => this.onKey(str, key ?? {});
|
|
490
|
+
process.stdin.on("keypress", this.keyListener);
|
|
491
|
+
process.stdin.resume();
|
|
492
|
+
this.render();
|
|
493
|
+
}
|
|
494
|
+
/** Erase the block, restore cooked mode, and detach the listener. */
|
|
495
|
+
stop() {
|
|
496
|
+
if (!this.started) return;
|
|
497
|
+
this.started = false;
|
|
498
|
+
this.erase();
|
|
499
|
+
write("\x1B[?25h");
|
|
500
|
+
if (this.keyListener) process.stdin.off("keypress", this.keyListener);
|
|
501
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
502
|
+
process.stdin.pause();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Erase the pinned block and hide it for the duration of an (async) run, so
|
|
506
|
+
* everything written to stdout in the meantime scrolls where the prompt was.
|
|
507
|
+
* Keypresses still update state but don't repaint until {@link endOutput}.
|
|
508
|
+
*/
|
|
509
|
+
beginOutput() {
|
|
510
|
+
if (!this.started) return;
|
|
511
|
+
this.erase();
|
|
512
|
+
this.suspended = true;
|
|
513
|
+
}
|
|
514
|
+
/** Redraw the pinned block below whatever output {@link beginOutput} let through. */
|
|
515
|
+
endOutput() {
|
|
516
|
+
if (!this.started) return;
|
|
517
|
+
this.suspended = false;
|
|
518
|
+
this.render();
|
|
519
|
+
}
|
|
520
|
+
/** Force a redraw (after mutating `status`, say). No-op while suspended. */
|
|
521
|
+
redraw() {
|
|
522
|
+
if (this.started && !this.suspended) this.render();
|
|
523
|
+
}
|
|
524
|
+
onKey(str, key) {
|
|
525
|
+
if (key.ctrl && (key.name === "c" || key.name === "d")) {
|
|
526
|
+
this.handlers.onQuit();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
switch (key.name) {
|
|
530
|
+
case "return":
|
|
531
|
+
case "enter": {
|
|
532
|
+
const value = this.matches[this.selected]?.value ?? null;
|
|
533
|
+
this.handlers.onSubmit(value, this.query);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
case "escape":
|
|
537
|
+
this.handlers.onEscape();
|
|
538
|
+
return;
|
|
539
|
+
case "up":
|
|
540
|
+
this.move(-1);
|
|
541
|
+
return;
|
|
542
|
+
case "down":
|
|
543
|
+
this.move(1);
|
|
544
|
+
return;
|
|
545
|
+
case "left":
|
|
546
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
547
|
+
this.render();
|
|
548
|
+
return;
|
|
549
|
+
case "right":
|
|
550
|
+
this.cursor = Math.min(this.query.length, this.cursor + 1);
|
|
551
|
+
this.render();
|
|
552
|
+
return;
|
|
553
|
+
case "home":
|
|
554
|
+
this.cursor = 0;
|
|
555
|
+
this.render();
|
|
556
|
+
return;
|
|
557
|
+
case "end":
|
|
558
|
+
this.cursor = this.query.length;
|
|
559
|
+
this.render();
|
|
560
|
+
return;
|
|
561
|
+
case "backspace":
|
|
562
|
+
this.deleteBefore();
|
|
563
|
+
return;
|
|
564
|
+
case "delete":
|
|
565
|
+
this.deleteAfter();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (str && str.length === 1 && str >= " " && !key.ctrl && !key.meta) this.insert(str);
|
|
569
|
+
}
|
|
570
|
+
insert(ch) {
|
|
571
|
+
this.query = this.query.slice(0, this.cursor) + ch + this.query.slice(this.cursor);
|
|
572
|
+
this.cursor += ch.length;
|
|
573
|
+
this.afterEdit();
|
|
574
|
+
}
|
|
575
|
+
deleteBefore() {
|
|
576
|
+
if (this.cursor === 0) return;
|
|
577
|
+
this.query = this.query.slice(0, this.cursor - 1) + this.query.slice(this.cursor);
|
|
578
|
+
this.cursor--;
|
|
579
|
+
this.afterEdit();
|
|
580
|
+
}
|
|
581
|
+
deleteAfter() {
|
|
582
|
+
if (this.cursor >= this.query.length) return;
|
|
583
|
+
this.query = this.query.slice(0, this.cursor) + this.query.slice(this.cursor + 1);
|
|
584
|
+
this.afterEdit();
|
|
585
|
+
}
|
|
586
|
+
afterEdit() {
|
|
587
|
+
this.refilter();
|
|
588
|
+
this.render();
|
|
589
|
+
this.handlers.onEdit(this.query);
|
|
590
|
+
}
|
|
591
|
+
move(delta) {
|
|
592
|
+
if (this.matches.length === 0) return;
|
|
593
|
+
this.selected = Math.min(this.matches.length - 1, Math.max(0, this.selected + delta));
|
|
594
|
+
this.reWindow();
|
|
595
|
+
this.render();
|
|
596
|
+
}
|
|
597
|
+
refilter() {
|
|
598
|
+
const q = this.query.trim().toLowerCase();
|
|
599
|
+
this.matches = this.all.map((s, index) => ({
|
|
600
|
+
s,
|
|
601
|
+
index,
|
|
602
|
+
r: rank(s.search, q)
|
|
603
|
+
})).filter((m) => m.r !== -1).sort((a, b) => a.r - b.r || a.index - b.index).map((m) => m.s);
|
|
604
|
+
this.selected = 0;
|
|
605
|
+
this.window = 0;
|
|
606
|
+
}
|
|
607
|
+
reWindow() {
|
|
608
|
+
if (this.selected < this.window) this.window = this.selected;
|
|
609
|
+
else if (this.selected >= this.window + this.maxVisible) this.window = this.selected - this.maxVisible + 1;
|
|
610
|
+
}
|
|
611
|
+
/** Build the block's lines, top (suggestions) to bottom (input). */
|
|
612
|
+
buildLines() {
|
|
613
|
+
const lines = [];
|
|
614
|
+
const total = this.matches.length;
|
|
615
|
+
if (this.query.trim() && total === 0) lines.push(color.dim(" no matching tag, feature, or scenario"));
|
|
616
|
+
const end = Math.min(this.window + this.maxVisible, total);
|
|
617
|
+
for (let i = this.window; i < end; i++) {
|
|
618
|
+
const s = this.matches[i];
|
|
619
|
+
const active = i === this.selected;
|
|
620
|
+
const row = `${color.cyan(s.badge.padEnd(9))} ${s.label}`;
|
|
621
|
+
lines.push(active ? color.inverse(`❯ ${row}`) : ` ${row}`);
|
|
622
|
+
}
|
|
623
|
+
if (total > end) lines.push(color.dim(` …and ${total - end} more`));
|
|
624
|
+
if (this.status) lines.push(color.dim(this.status));
|
|
625
|
+
lines.push(`${color.bold(this.prefix)}${this.query}`);
|
|
626
|
+
return lines;
|
|
627
|
+
}
|
|
628
|
+
render() {
|
|
629
|
+
if (this.suspended) return;
|
|
630
|
+
write("\x1B[?25l");
|
|
631
|
+
this.moveToBlockStart();
|
|
632
|
+
write("\x1B[0J");
|
|
633
|
+
const lines = this.buildLines();
|
|
634
|
+
write(lines.join("\r\n"));
|
|
635
|
+
this.rendered = lines.length;
|
|
636
|
+
write(`\r\x1b[${stringWidth(this.prefix) + this.cursor}C`);
|
|
637
|
+
write("\x1B[?25h");
|
|
638
|
+
}
|
|
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
|
+
};
|
|
651
|
+
/** Visible width, ignoring the ANSI escapes our color helpers may inject. */
|
|
652
|
+
function stringWidth(s) {
|
|
653
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
654
|
+
}
|
|
655
|
+
function write(s) {
|
|
656
|
+
process.stdout.write(s);
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/runtime/watcher.ts
|
|
660
|
+
/** Glob metacharacters — the same set {@link globFiles} treats as "magic". */
|
|
661
|
+
const MAGIC = /[*?[\]{}!()]/;
|
|
662
|
+
/** File extensions worth reacting to: features and step/world modules. */
|
|
663
|
+
const WATCHED_EXT = /\.(feature|ts|mts|js|mjs|cts|cjs)$/;
|
|
664
|
+
/**
|
|
665
|
+
* Watch the directories implied by feature/step globs and invoke `onChange`
|
|
666
|
+
* (debounced, coalesced) whenever a relevant file changes. Roots are the literal
|
|
667
|
+
* directory prefixes of each glob — e.g. `features/**\/*.feature` watches
|
|
668
|
+
* `features/` recursively — deduped so nested roots aren't watched twice.
|
|
669
|
+
*
|
|
670
|
+
* Recursive watching is supported on macOS and Windows and on modern Linux
|
|
671
|
+
* (Node ≥ 20 / Bun); on older Linux only the top level of each root is seen.
|
|
672
|
+
*/
|
|
673
|
+
function watchFeatures(globs, cwd, onChange, debounceMs = 120) {
|
|
674
|
+
const roots = watchRoots(globs, cwd);
|
|
675
|
+
const watchers = [];
|
|
676
|
+
let timer;
|
|
677
|
+
const schedule = (filename) => {
|
|
678
|
+
if (filename && !WATCHED_EXT.test(filename)) return;
|
|
679
|
+
if (timer) clearTimeout(timer);
|
|
680
|
+
timer = setTimeout(onChange, debounceMs);
|
|
681
|
+
};
|
|
682
|
+
for (const root of roots) try {
|
|
683
|
+
const w = fs.watch(root, { recursive: true }, (_event, filename) => schedule(filename));
|
|
684
|
+
w.on("error", () => {});
|
|
685
|
+
watchers.push(w);
|
|
686
|
+
} catch {}
|
|
687
|
+
return { close() {
|
|
688
|
+
if (timer) clearTimeout(timer);
|
|
689
|
+
for (const w of watchers) w.close();
|
|
690
|
+
} };
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The set of directories to watch: each glob's literal prefix (the path up to
|
|
694
|
+
* its first magic character), resolved against `cwd`, deduped, with any root
|
|
695
|
+
* that nests inside another dropped. Falls back to `cwd` when nothing resolves.
|
|
696
|
+
*/
|
|
697
|
+
function watchRoots(globs, cwd) {
|
|
698
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
699
|
+
for (const glob of globs) dirs.add(path.resolve(cwd, literalPrefix(glob)));
|
|
700
|
+
const roots = [...dirs].sort();
|
|
701
|
+
const pruned = roots.filter((r, i) => !roots.some((other, j) => j < i && isInside(r, other)));
|
|
702
|
+
return pruned.length ? pruned : [cwd];
|
|
703
|
+
}
|
|
704
|
+
/** The directory portion of a glob before its first magic segment. */
|
|
705
|
+
function literalPrefix(glob) {
|
|
706
|
+
const segments = glob.split("/");
|
|
707
|
+
const literal = [];
|
|
708
|
+
for (const seg of segments) {
|
|
709
|
+
if (MAGIC.test(seg)) break;
|
|
710
|
+
literal.push(seg);
|
|
711
|
+
}
|
|
712
|
+
const joined = literal.join("/");
|
|
713
|
+
if (!joined) return ".";
|
|
714
|
+
return MAGIC.test(glob) ? joined : path.dirname(joined);
|
|
715
|
+
}
|
|
716
|
+
/** True when `child` is `parent` itself or nested beneath it. */
|
|
717
|
+
function isInside(child, parent) {
|
|
718
|
+
if (child === parent) return true;
|
|
719
|
+
const rel = path.relative(parent, child);
|
|
720
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
721
|
+
}
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/runtime/interactive.ts
|
|
724
|
+
const useColor = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
725
|
+
const bold = (s) => useColor ? `\x1b[1m${s}\x1b[22m` : s;
|
|
726
|
+
const dim = (s) => useColor ? `\x1b[2m${s}\x1b[22m` : s;
|
|
727
|
+
const red = (s) => useColor ? `\x1b[31m${s}\x1b[39m` : s;
|
|
728
|
+
const yellow = (s) => useColor ? `\x1b[33m${s}\x1b[39m` : s;
|
|
729
|
+
/**
|
|
730
|
+
* Entry point for `step-forge -i`. Brings up the always-live typeahead prompt,
|
|
731
|
+
* watches the configured feature/step directories, and re-runs the armed
|
|
732
|
+
* population on every relevant file change. Requires a TTY.
|
|
733
|
+
*/
|
|
734
|
+
async function runInteractive(config) {
|
|
735
|
+
if (!process.stdin.isTTY) {
|
|
736
|
+
process.stderr.write("Interactive mode (-i) requires a TTY; stdin is not a terminal.\n");
|
|
737
|
+
process.exitCode = 1;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await new InteractiveSession(config).start();
|
|
741
|
+
}
|
|
742
|
+
var InteractiveSession = class {
|
|
743
|
+
config;
|
|
744
|
+
prompt;
|
|
745
|
+
watcher;
|
|
746
|
+
catalog = [];
|
|
747
|
+
armed = null;
|
|
748
|
+
dirty = false;
|
|
749
|
+
running = false;
|
|
750
|
+
rerunQueued = false;
|
|
751
|
+
/** Increments per run so back-to-back identical output is still distinguishable. */
|
|
752
|
+
runCount = 0;
|
|
753
|
+
done;
|
|
754
|
+
constructor(config) {
|
|
755
|
+
this.config = config;
|
|
756
|
+
this.prompt = new Prompt({
|
|
757
|
+
initialQuery: config.tags ?? config.name ?? "",
|
|
758
|
+
handlers: {
|
|
759
|
+
onSubmit: (value) => this.onSubmit(value),
|
|
760
|
+
onEdit: () => this.onEdit(),
|
|
761
|
+
onEscape: () => this.onEscape(),
|
|
762
|
+
onQuit: () => this.onQuit()
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
async start() {
|
|
767
|
+
await this.rebuildCatalog();
|
|
768
|
+
this.prompt.status = this.status();
|
|
769
|
+
this.prompt.start();
|
|
770
|
+
this.watcher = watchFeatures([...this.config.features, ...this.config.steps], this.config.cwd, () => void this.onFsChange());
|
|
771
|
+
await new Promise((resolve) => {
|
|
772
|
+
this.done = resolve;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
onSubmit(value) {
|
|
776
|
+
if (!value) return;
|
|
777
|
+
this.armed = value;
|
|
778
|
+
this.dirty = false;
|
|
779
|
+
this.prompt.status = this.status();
|
|
780
|
+
this.triggerRun();
|
|
781
|
+
}
|
|
782
|
+
onEdit() {
|
|
783
|
+
if (this.armed) this.dirty = true;
|
|
784
|
+
this.prompt.status = this.status();
|
|
785
|
+
this.prompt.redraw();
|
|
786
|
+
}
|
|
787
|
+
onEscape() {
|
|
788
|
+
if (!this.armed) return;
|
|
789
|
+
this.armed = null;
|
|
790
|
+
this.dirty = false;
|
|
791
|
+
this.prompt.status = this.status();
|
|
792
|
+
this.prompt.redraw();
|
|
793
|
+
}
|
|
794
|
+
onQuit() {
|
|
795
|
+
this.watcher?.close();
|
|
796
|
+
this.prompt.stop();
|
|
797
|
+
process.stdout.write("\n");
|
|
798
|
+
process.exitCode = 0;
|
|
799
|
+
this.done?.();
|
|
800
|
+
}
|
|
801
|
+
async onFsChange() {
|
|
802
|
+
await this.rebuildCatalog();
|
|
803
|
+
if (this.armed && !this.dirty) this.triggerRun();
|
|
804
|
+
}
|
|
805
|
+
async rebuildCatalog() {
|
|
806
|
+
try {
|
|
807
|
+
const featureFiles = await globFiles(this.config.features, this.config.cwd);
|
|
808
|
+
this.catalog = parseFeatureCatalog(featureFiles);
|
|
809
|
+
} catch {}
|
|
810
|
+
this.prompt.setSuggestions(buildSuggestions(this.catalog));
|
|
811
|
+
}
|
|
812
|
+
triggerRun() {
|
|
813
|
+
if (!this.armed) return;
|
|
814
|
+
if (this.running) {
|
|
815
|
+
this.rerunQueued = true;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
this.running = true;
|
|
819
|
+
this.prompt.beginOutput();
|
|
820
|
+
this.runLoop();
|
|
821
|
+
}
|
|
822
|
+
async runLoop() {
|
|
823
|
+
try {
|
|
824
|
+
do {
|
|
825
|
+
this.rerunQueued = false;
|
|
826
|
+
if (this.armed) await this.executeOnce(this.armed);
|
|
827
|
+
} while (this.rerunQueued && this.armed && !this.dirty);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
process.stdout.write(red(`\n run failed: ${err instanceof Error ? err.message : err}\n`));
|
|
830
|
+
} finally {
|
|
831
|
+
this.running = false;
|
|
832
|
+
this.prompt.status = this.status();
|
|
833
|
+
this.prompt.endOutput();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* One run of `choice`: resolve its scenarios against the current parse, then
|
|
838
|
+
* shell out to a **fresh** `step-forge` process scoped to just that
|
|
839
|
+
* population. A child process is used deliberately — Bun caches ES modules for
|
|
840
|
+
* the life of a process and ignores query-string cache-busting, so re-running
|
|
841
|
+
* in-process would never pick up edited step code. A new process re-reads
|
|
842
|
+
* every step/feature file, which is exactly what a watch loop needs.
|
|
843
|
+
*/
|
|
844
|
+
async executeOnce(choice) {
|
|
845
|
+
const selected = resolveScenarios(choice, this.scenarios());
|
|
846
|
+
const count = `${selected.length} scenario${selected.length === 1 ? "" : "s"}`;
|
|
847
|
+
process.stdout.write(`\n${bold(`▶ ${choiceLabel(choice)}`)} ${dim(`(${count} · run #${++this.runCount} · ${clock()})`)}\n\n`);
|
|
848
|
+
if (selected.length === 0) {
|
|
849
|
+
process.stdout.write(yellow(" no scenarios match this selection\n"));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const single = selected.length === 1;
|
|
853
|
+
if (single) await this.analyzeScenario(selected[0]);
|
|
854
|
+
await this.spawnRun(runArgs(choice, single, this.config));
|
|
855
|
+
}
|
|
856
|
+
/** Every scenario across the current catalog, flattened. */
|
|
857
|
+
scenarios() {
|
|
858
|
+
return this.catalog.flatMap((f) => f.scenarios);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Run `step-forge` as a child process with `args`, streaming its output to the
|
|
862
|
+
* terminal (where the erased prompt was). Re-invokes the very CLI that's
|
|
863
|
+
* running us (`process.execPath` + `argv[1]`), so it works identically from
|
|
864
|
+
* the source tree and the built bin. Never rejects — a spawn error is reported
|
|
865
|
+
* and swallowed so the watch loop survives.
|
|
866
|
+
*/
|
|
867
|
+
spawnRun(args) {
|
|
868
|
+
return new Promise((resolve) => {
|
|
869
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
870
|
+
cwd: this.config.cwd,
|
|
871
|
+
stdio: [
|
|
872
|
+
"ignore",
|
|
873
|
+
"inherit",
|
|
874
|
+
"inherit"
|
|
875
|
+
]
|
|
876
|
+
});
|
|
877
|
+
child.on("error", (err) => {
|
|
878
|
+
process.stdout.write(red(` could not start runner: ${err.message}\n`));
|
|
879
|
+
resolve();
|
|
880
|
+
});
|
|
881
|
+
child.on("close", () => resolve());
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Run the static analyzer over a single scenario and print any dependency /
|
|
886
|
+
* undefined / ambiguous diagnostics. The analyzer needs the optional
|
|
887
|
+
* `typescript` peer for AST extraction; if it's absent we note that and skip,
|
|
888
|
+
* never failing the run.
|
|
889
|
+
*/
|
|
890
|
+
async analyzeScenario(scenario) {
|
|
891
|
+
let analyzer;
|
|
892
|
+
try {
|
|
893
|
+
analyzer = await import("./analyzer-DnfK54Dw.js").then((n) => n.n);
|
|
894
|
+
} catch {
|
|
895
|
+
process.stdout.write(dim(" analysis skipped: install `typescript` to enable it\n\n"));
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
const stepFiles = await globFiles(this.config.steps, this.config.cwd);
|
|
900
|
+
const defs = analyzer.extractStepDefinitions(stepFiles);
|
|
901
|
+
const matched = analyzer.matchScenarioSteps(scenario, defs);
|
|
902
|
+
printDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
process.stdout.write(dim(` analysis unavailable: ${err instanceof Error ? err.message : err}\n\n`));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
status() {
|
|
908
|
+
if (!this.armed) return " type to filter · ↑↓ move · enter run · ctrl-c quit";
|
|
909
|
+
if (this.dirty) return " ⏸ suspended (query edited) · enter runs the selection · esc cancel";
|
|
910
|
+
return ` ▶ watching ${choiceLabel(this.armed)} · save a file to re-run · enter forces · esc change · ctrl-c quit`;
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
/** Build the typeahead pool: every tag, feature, and scenario in the catalog. */
|
|
914
|
+
function buildSuggestions(catalog) {
|
|
915
|
+
const suggestions = [];
|
|
916
|
+
const tags = /* @__PURE__ */ new Set();
|
|
917
|
+
for (const feature of catalog) for (const scenario of feature.scenarios) for (const tag of scenario.tags) tags.add(tag);
|
|
918
|
+
for (const tag of [...tags].sort()) suggestions.push({
|
|
919
|
+
badge: "#tag",
|
|
920
|
+
label: tag,
|
|
921
|
+
search: tag.toLowerCase(),
|
|
922
|
+
value: {
|
|
923
|
+
kind: "tag",
|
|
924
|
+
tag
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
for (const feature of catalog) {
|
|
928
|
+
const featureLabel = feature.name || path.basename(feature.file);
|
|
929
|
+
suggestions.push({
|
|
930
|
+
badge: "feature",
|
|
931
|
+
label: featureLabel,
|
|
932
|
+
search: `${feature.name} ${feature.file}`.toLowerCase(),
|
|
933
|
+
value: {
|
|
934
|
+
kind: "feature",
|
|
935
|
+
file: feature.file,
|
|
936
|
+
name: feature.name
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
const seen = /* @__PURE__ */ new Set();
|
|
940
|
+
for (const scenario of feature.scenarios) {
|
|
941
|
+
const name = scenario.outline ? scenario.outline.name : scenario.name;
|
|
942
|
+
if (seen.has(name)) continue;
|
|
943
|
+
seen.add(name);
|
|
944
|
+
suggestions.push({
|
|
945
|
+
badge: scenario.outline ? "outline" : "scenario",
|
|
946
|
+
label: name,
|
|
947
|
+
search: `${featureLabel} ${name}`.toLowerCase(),
|
|
948
|
+
value: {
|
|
949
|
+
kind: "scenario",
|
|
950
|
+
file: scenario.file,
|
|
951
|
+
name
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return suggestions;
|
|
957
|
+
}
|
|
958
|
+
/** Resolve a choice to its scenarios against the current parse. */
|
|
959
|
+
function resolveScenarios(choice, scenarios) {
|
|
960
|
+
switch (choice.kind) {
|
|
961
|
+
case "tag": return scenarios.filter((s) => s.tags.includes(choice.tag));
|
|
962
|
+
case "feature": return scenarios.filter((s) => s.file === choice.file);
|
|
963
|
+
case "scenario": return scenarios.filter((s) => s.file === choice.file && (s.name === choice.name || s.outline?.name === choice.name));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function choiceLabel(choice) {
|
|
967
|
+
switch (choice.kind) {
|
|
968
|
+
case "tag": return choice.tag;
|
|
969
|
+
case "feature": return choice.name || path.basename(choice.file);
|
|
970
|
+
case "scenario": return choice.name;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The `step-forge` argv that reproduces this selection in a child process. The
|
|
975
|
+
* base flags forward the resolved config (steps/world/concurrency) so the child
|
|
976
|
+
* matches the parent's setup regardless of its own config file; the scope flags
|
|
977
|
+
* narrow to the chosen population:
|
|
978
|
+
* - tag → all configured features, filtered by `-t <tag>`
|
|
979
|
+
* - feature → that single feature file
|
|
980
|
+
* - scenario → that feature file, name-anchored with `-n "/^…$/"` (the name is
|
|
981
|
+
* the outline name for an outline, so all its rows run)
|
|
982
|
+
* A single scenario runs verbose; a population uses the configured reporter.
|
|
983
|
+
*/
|
|
984
|
+
function runArgs(choice, single, config) {
|
|
985
|
+
const args = [];
|
|
986
|
+
for (const glob of config.steps) args.push("-s", glob);
|
|
987
|
+
if (config.world) args.push("-w", config.world);
|
|
988
|
+
args.push("-c", String(config.concurrency));
|
|
989
|
+
switch (choice.kind) {
|
|
990
|
+
case "tag":
|
|
991
|
+
args.push(...config.features, "-t", choice.tag);
|
|
992
|
+
break;
|
|
993
|
+
case "feature":
|
|
994
|
+
args.push(choice.file);
|
|
995
|
+
break;
|
|
996
|
+
case "scenario":
|
|
997
|
+
args.push(choice.file, "-n", `/^${escapeRegExp(choice.name)}$/`);
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
if (single) args.push("-v");
|
|
1001
|
+
else {
|
|
1002
|
+
args.push("-r", config.reporter);
|
|
1003
|
+
if (config.verbose) args.push("-v");
|
|
1004
|
+
}
|
|
1005
|
+
return args;
|
|
1006
|
+
}
|
|
1007
|
+
/** Escape a scenario name for use inside an anchored `-n` regex. */
|
|
1008
|
+
function escapeRegExp(text) {
|
|
1009
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1010
|
+
}
|
|
1011
|
+
/** Local wall-clock time as `HH:MM:SS`, so each run's header is dated. */
|
|
1012
|
+
function clock() {
|
|
1013
|
+
const d = /* @__PURE__ */ new Date();
|
|
1014
|
+
return [
|
|
1015
|
+
d.getHours(),
|
|
1016
|
+
d.getMinutes(),
|
|
1017
|
+
d.getSeconds()
|
|
1018
|
+
].map((n) => String(n).padStart(2, "0")).join(":");
|
|
1019
|
+
}
|
|
1020
|
+
function printDiagnostics(diagnostics, cwd) {
|
|
1021
|
+
if (diagnostics.length === 0) return;
|
|
1022
|
+
process.stdout.write(bold(" analyzer:\n"));
|
|
1023
|
+
for (const d of diagnostics) {
|
|
1024
|
+
const mark = d.severity === "error" ? red("✗") : d.severity === "warning" ? yellow("!") : dim("i");
|
|
1025
|
+
const loc = `${path.relative(cwd, d.file)}:${d.range.startLine}`;
|
|
1026
|
+
process.stdout.write(` ${mark} ${d.message} ${dim(`(${loc})`)}\n`);
|
|
1027
|
+
}
|
|
1028
|
+
process.stdout.write("\n");
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
428
1031
|
//#region src/runtime/cli.ts
|
|
429
1032
|
const HELP = `step-forge — native TypeScript runner for Gherkin step definitions
|
|
430
1033
|
|
|
@@ -439,6 +1042,8 @@ Options:
|
|
|
439
1042
|
-c, --concurrency <n> Max scenarios in flight (default: 1, i.e. serial)
|
|
440
1043
|
-r, --reporter <name> "pretty" (default) or "progress"
|
|
441
1044
|
-v, --verbose Report every scenario, not just failures
|
|
1045
|
+
-i, --interactive Interactive watch mode: pick a tag/feature/scenario
|
|
1046
|
+
and re-run it on every file change
|
|
442
1047
|
--config <path> Config file directory (default: cwd)
|
|
443
1048
|
-h, --help Show this help
|
|
444
1049
|
|
|
@@ -479,6 +1084,10 @@ function parseCli(argv) {
|
|
|
479
1084
|
type: "boolean",
|
|
480
1085
|
short: "v"
|
|
481
1086
|
},
|
|
1087
|
+
interactive: {
|
|
1088
|
+
type: "boolean",
|
|
1089
|
+
short: "i"
|
|
1090
|
+
},
|
|
482
1091
|
config: { type: "string" },
|
|
483
1092
|
help: {
|
|
484
1093
|
type: "boolean",
|
|
@@ -508,12 +1117,18 @@ function parseCli(argv) {
|
|
|
508
1117
|
}
|
|
509
1118
|
return {
|
|
510
1119
|
cwd: values.config ? path.resolve(process.cwd(), values.config) : process.cwd(),
|
|
511
|
-
overrides
|
|
1120
|
+
overrides,
|
|
1121
|
+
interactive: values.interactive ?? false
|
|
512
1122
|
};
|
|
513
1123
|
}
|
|
514
1124
|
async function main() {
|
|
515
|
-
const { cwd, overrides } = parseCli(process.argv.slice(2));
|
|
516
|
-
const
|
|
1125
|
+
const { cwd, overrides, interactive } = parseCli(process.argv.slice(2));
|
|
1126
|
+
const config = resolveConfig(cwd, await loadConfigFile(cwd), overrides);
|
|
1127
|
+
if (interactive) {
|
|
1128
|
+
await runInteractive(config);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const { passed } = await run(config);
|
|
517
1132
|
process.exitCode = passed ? 0 : 1;
|
|
518
1133
|
}
|
|
519
1134
|
main().catch((err) => {
|