@step-forge/step-forge 0.0.21 → 0.0.23

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.
Files changed (45) hide show
  1. package/RUNTIME.md +73 -12
  2. package/dist/analyzer-DIZPMxzN.cjs +441 -0
  3. package/dist/analyzer-DIZPMxzN.cjs.map +1 -0
  4. package/dist/analyzer-DnfK54Dw.js +417 -0
  5. package/dist/analyzer-DnfK54Dw.js.map +1 -0
  6. package/dist/analyzer-cli.js +1 -1
  7. package/dist/{analyzer-DYfdoSIS.js → analyzer-gYyAKIpM.js} +61 -25
  8. package/dist/analyzer-gYyAKIpM.js.map +1 -0
  9. package/dist/analyzer.d.ts +2 -0
  10. package/dist/analyzer.js +1 -1
  11. package/dist/cli.cjs +753 -84
  12. package/dist/cli.cjs.map +1 -1
  13. package/dist/cli.js +751 -84
  14. package/dist/cli.js.map +1 -1
  15. package/dist/{hooks-Dar49TtT.d.ts → config-C7PCYgYy.d.cts} +65 -16
  16. package/dist/{hooks-Dar49TtT.d.cts → config-C7PCYgYy.d.ts} +65 -16
  17. package/dist/{engine-DPRxs6eC.js → engine-DPVLEHBi.js} +8 -26
  18. package/dist/engine-DPVLEHBi.js.map +1 -0
  19. package/dist/{engine-DWAIlwWp.cjs → engine-vqA-eL_T.cjs} +8 -26
  20. package/dist/engine-vqA-eL_T.cjs.map +1 -0
  21. package/dist/{gherkinParser-CHpkEwii.cjs → gherkinParser-_ZgvELdy.cjs} +142 -3
  22. package/dist/gherkinParser-_ZgvELdy.cjs.map +1 -0
  23. package/dist/{gherkinParser-CKARHgt4.js → gherkinParser-i-Q7M6mi.js} +98 -4
  24. package/dist/gherkinParser-i-Q7M6mi.js.map +1 -0
  25. package/dist/hooks-BDCMKeNq.js +71 -0
  26. package/dist/{hooks-CywugMQQ.js.map → hooks-BDCMKeNq.js.map} +1 -1
  27. package/dist/{hooks-CGYzwDOv.cjs → hooks-Be0cjULN.cjs} +20 -31
  28. package/dist/{hooks-CGYzwDOv.cjs.map → hooks-Be0cjULN.cjs.map} +1 -1
  29. package/dist/runtime.cjs +3 -3
  30. package/dist/runtime.d.cts +11 -48
  31. package/dist/runtime.d.ts +11 -48
  32. package/dist/runtime.js +3 -3
  33. package/dist/step-forge.cjs +22 -377
  34. package/dist/step-forge.cjs.map +1 -1
  35. package/dist/step-forge.d.cts +13 -4
  36. package/dist/step-forge.d.ts +13 -4
  37. package/dist/step-forge.js +17 -371
  38. package/dist/step-forge.js.map +1 -1
  39. package/package.json +2 -2
  40. package/dist/analyzer-DYfdoSIS.js.map +0 -1
  41. package/dist/engine-DPRxs6eC.js.map +0 -1
  42. package/dist/engine-DWAIlwWp.cjs.map +0 -1
  43. package/dist/gherkinParser-CHpkEwii.cjs.map +0 -1
  44. package/dist/gherkinParser-CKARHgt4.js.map +0 -1
  45. package/dist/hooks-CywugMQQ.js +0 -82
package/dist/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- import { i as runHooks, n as ensureGlobalHooks, o as globalRegistry, r as globalHookRegistry } from "./hooks-CywugMQQ.js";
3
- import { i as BasicWorld, n as parseFeatureFiles, r as globFiles } from "./gherkinParser-CKARHgt4.js";
4
- import { i as runScenario, r as compileRegistry } from "./engine-DPRxs6eC.js";
5
- import { access } from "node:fs/promises";
2
+ import { i as runHooksParallel, n as globalHookRegistry, o as globalRegistry, r as runHooks } from "./hooks-BDCMKeNq.js";
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
+ import { i as runScenario, r as compileRegistry } from "./engine-DPVLEHBi.js";
6
5
  import * as path from "node:path";
7
6
  import { relative } from "node:path";
8
- import { parseArgs } from "node:util";
9
7
  import { pathToFileURL } from "node:url";
8
+ import { access } from "node:fs/promises";
9
+ import * as fs from "node:fs";
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";
@@ -58,6 +61,7 @@ function resolveConfig(cwd, file, cli) {
58
61
  world: pick("world"),
59
62
  concurrency: pick("concurrency") ?? 1,
60
63
  reporter: pick("reporter") ?? "pretty",
64
+ verbose: pick("verbose") ?? false,
61
65
  name: pick("name"),
62
66
  tags: pick("tags")
63
67
  };
@@ -163,52 +167,83 @@ function selectScenarios(scenarios, options) {
163
167
  }
164
168
  //#endregion
165
169
  //#region src/runtime/reporters.ts
166
- const useColor = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
167
- 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;
168
172
  const c = {
169
- green: wrap(32, 39),
170
- red: wrap(31, 39),
171
- yellow: wrap(33, 39),
172
- cyan: wrap(36, 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("✓"),
178
181
  failed: c.red("✗"),
179
182
  skipped: c.yellow("-")
180
183
  };
181
- /** Indent every line of a block by `pad` spaces. */
184
+ /** / / - for a whole scenario (skipped = every step skipped). */
185
+ function scenarioMark(result) {
186
+ if (result.status === "failed") return c.red("✗");
187
+ if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
188
+ return c.green("✓");
189
+ }
190
+ /** Dot per scenario for the live heartbeat: `.` pass / `F` fail / `-` skip. */
191
+ function scenarioDot(result) {
192
+ if (result.status === "failed") return c.red("F");
193
+ if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
194
+ return c.green(".");
195
+ }
196
+ /** Indent every non-empty line of a block by `pad` spaces. */
182
197
  function indent(text, pad) {
183
198
  const prefix = " ".repeat(pad);
184
199
  return text.split("\n").map((line) => line ? prefix + line : line).join("\n");
185
200
  }
186
- /** Render one scenario as a Cucumber-style tree of steps. */
187
- function renderScenario(result) {
188
- const lines = [];
201
+ /**
202
+ * The error, trimmed to user frames: `Name: message` followed by only the
203
+ * caller's own stack frames (library/engine and `node_modules` frames removed),
204
+ * source-mapped by Bun to the original `.ts`. Falls back to just the message
205
+ * when nothing user-owned is left (e.g. an undefined-step error).
206
+ */
207
+ function renderError(error, cwd) {
208
+ const header = `${error.name}: ${error.message}`;
209
+ const frames = userFrames(error.stack).map((f) => ` at ${relativeFrame(f, cwd)}`);
210
+ return frames.length ? `${header}\n${frames.join("\n")}` : header;
211
+ }
212
+ /**
213
+ * The detail block shown beneath a failing step: the `.feature` line, the step
214
+ * definition location, and the trimmed error — the two coordinates that make a
215
+ * failure easy to chase (where in the feature, which step definition).
216
+ */
217
+ function failureDetail(stepResult, result, cwd) {
218
+ const lines = [`feature: ${relative(cwd, result.scenario.file)}:${stepResult.step.line}`];
219
+ if (stepResult.source) lines.push(`defined: ${relativeLocation(stepResult.source, cwd)}`);
220
+ if (stepResult.error) lines.push(renderError(stepResult.error, cwd));
221
+ return indent(c.red(lines.join("\n")), 6);
222
+ }
223
+ /**
224
+ * One scenario as a Cucumber-style block: a marked header with its `.feature`
225
+ * location, then each step with its mark (and, in `verbose`, the step
226
+ * definition location as a trailing comment). A failing step is followed by its
227
+ * failure detail; a scenario-level error (a failing hook, no step to blame) is
228
+ * appended at the end.
229
+ */
230
+ function renderScenario(result, cwd, opts) {
189
231
  const label = result.scenario.outline ? `${result.scenario.outline.name} › ${result.scenario.name}` : result.scenario.name;
190
- lines.push(` Scenario: ${label}`);
232
+ const lines = [`${scenarioMark(result)} ${c.bold(label)}`];
233
+ const loc = result.scenario.line ? `${relative(cwd, result.scenario.file)}:${result.scenario.line}` : relative(cwd, result.scenario.file);
234
+ lines.push(indent(c.dim(loc), 4));
235
+ lines.push("");
191
236
  for (const step of result.scenario.steps) {
192
- const status = result.steps.find((s) => s.step === step)?.status ?? "skipped";
193
- lines.push(` ${STATUS_MARK[status]} ${c.dim(step.effectiveKeyword)} ${step.text}`);
194
- }
195
- if (result.error) {
196
- const stack = result.error.stack ?? String(result.error);
197
- lines.push(indent(c.red(stack), 6));
237
+ const sr = result.steps.find((s) => s.step === step);
238
+ const status = sr?.status ?? "skipped";
239
+ const comment = opts.stepSource && sr?.source ? c.dim(` # ${relativeLocation(sr.source, cwd)}`) : "";
240
+ lines.push(` ${STATUS_MARK[status]} ${c.dim(step.effectiveKeyword)} ${step.text}${comment}`);
241
+ if (sr?.status === "failed") lines.push(failureDetail(sr, result, cwd));
198
242
  }
243
+ const stepFailed = result.steps.some((s) => s.status === "failed");
244
+ if (result.error && !stepFailed) lines.push(indent(c.red(renderError(result.error, cwd)), 4));
199
245
  return lines.join("\n");
200
246
  }
201
- /** Group results by feature file, preserving first-seen order. */
202
- function groupByFeature(results) {
203
- const groups = /* @__PURE__ */ new Map();
204
- for (const result of results) {
205
- const key = result.scenario.file;
206
- const bucket = groups.get(key);
207
- if (bucket) bucket.push(result);
208
- else groups.set(key, [result]);
209
- }
210
- return groups;
211
- }
212
247
  function tally(results) {
213
248
  const totals = {
214
249
  scenarios: {
@@ -230,71 +265,84 @@ function tally(results) {
230
265
  }
231
266
  return totals;
232
267
  }
233
- /** The shared end-of-run summary block: counts + duration. */
234
268
  function renderSummary(results, durationMs) {
235
269
  const t = tally(results);
236
270
  const scenarioTotal = t.scenarios.passed + t.scenarios.failed + t.scenarios.skipped;
237
271
  const stepTotal = t.steps.passed + t.steps.failed + t.steps.skipped;
238
272
  const part = (n, label, color) => n > 0 ? color(`${n} ${label}`) : null;
239
- const scenarioParts = [
240
- part(t.scenarios.passed, "passed", c.green),
241
- part(t.scenarios.failed, "failed", c.red),
242
- part(t.scenarios.skipped, "skipped", c.yellow)
243
- ].filter((x) => x !== null);
244
- const stepParts = [
245
- part(t.steps.passed, "passed", c.green),
246
- part(t.steps.failed, "failed", c.red),
247
- part(t.steps.skipped, "skipped", c.yellow)
248
- ].filter((x) => x !== null);
249
- const seconds = (durationMs / 1e3).toFixed(2);
273
+ const line = (total, noun, counts) => {
274
+ const parts = [
275
+ part(counts.passed, "passed", c.green),
276
+ part(counts.failed, "failed", c.red),
277
+ part(counts.skipped, "skipped", c.yellow)
278
+ ].filter((x) => x !== null);
279
+ return `${total} ${noun}${total === 1 ? "" : "s"} (${parts.join(", ")})`;
280
+ };
250
281
  return [
251
- `${scenarioTotal} scenario${scenarioTotal === 1 ? "" : "s"} (${scenarioParts.join(", ")})`,
252
- `${stepTotal} step${stepTotal === 1 ? "" : "s"} (${stepParts.join(", ")})`,
253
- c.dim(`${seconds}s`)
282
+ line(scenarioTotal, "scenario", t.scenarios),
283
+ line(stepTotal, "step", t.steps),
284
+ c.dim(`${(durationMs / 1e3).toFixed(2)}s`)
254
285
  ].join("\n");
255
286
  }
287
+ function write$1(text) {
288
+ process.stdout.write(text);
289
+ }
290
+ /** Feature-grouped tree of every scenario (used by `--verbose`). */
291
+ function renderFullTree(results, cwd) {
292
+ const groups = /* @__PURE__ */ new Map();
293
+ for (const r of results) {
294
+ const bucket = groups.get(r.scenario.file) ?? [];
295
+ bucket.push(r);
296
+ groups.set(r.scenario.file, bucket);
297
+ }
298
+ const out = [];
299
+ for (const [file, group] of groups) {
300
+ out.push(c.bold(`Feature: ${relative(cwd, file)}`), "");
301
+ for (const r of group) out.push(indent(renderScenario(r, cwd, { stepSource: true }), 2), "");
302
+ }
303
+ return out.join("\n");
304
+ }
305
+ /** The failing scenarios only (used by the default, non-verbose output). */
306
+ function renderFailures(results, cwd) {
307
+ const failed = results.filter((r) => r.status === "failed");
308
+ if (!failed.length) return "";
309
+ return failed.map((r) => renderScenario(r, cwd, { stepSource: false })).join("\n\n") + "\n\n";
310
+ }
256
311
  /**
257
- * Default reporter: prints the full feature → scenario step tree grouped by
258
- * file, then the summary. Buffers to `onComplete` so concurrent scenarios don't
259
- * interleave mid-tree.
312
+ * Default reporter. Non-verbose: a dot per scenario as a heartbeat, then the
313
+ * failing scenarios in Cucumber-style detail, then the summary. Verbose: the
314
+ * full feature → scenario → step tree instead of just failures.
260
315
  */
261
- function prettyReporter(cwd = process.cwd()) {
262
- return { onComplete(results, durationMs) {
263
- const out = [];
264
- for (const [file, group] of groupByFeature(results)) {
265
- const featureName = group[0].scenario.file;
266
- out.push(c.bold(`Feature: ${relative(cwd, featureName || file)}`));
267
- for (const result of group) out.push(renderScenario(result));
268
- out.push("");
316
+ function prettyReporter(opts = {}) {
317
+ const cwd = opts.cwd ?? process.cwd();
318
+ const verbose = opts.verbose ?? false;
319
+ return {
320
+ onScenarioEnd(result) {
321
+ if (!verbose) write$1(scenarioDot(result));
322
+ },
323
+ onComplete(results, durationMs) {
324
+ write$1(`${verbose ? `\n${renderFullTree(results, cwd)}\n` : `\n\n${renderFailures(results, cwd)}`}${renderSummary(results, durationMs)}\n`);
269
325
  }
270
- out.push(renderSummary(results, durationMs));
271
- process.stdout.write(out.join("\n") + "\n");
272
- } };
326
+ };
273
327
  }
274
328
  /**
275
- * Compact reporter: one character per scenario as it finishes (`.`/`F`/`-`),
276
- * then failures in detail and the summary. Best for large suites.
329
+ * Compact reporter: a dot per scenario, then failures in detail and the
330
+ * summary. Always minimal `verbose` is accepted for interface parity but does
331
+ * not expand the output (use `pretty --verbose` for the full tree).
277
332
  */
278
- function progressReporter(cwd = process.cwd()) {
333
+ function progressReporter(opts = {}) {
334
+ const cwd = opts.cwd ?? process.cwd();
279
335
  return {
280
336
  onScenarioEnd(result) {
281
- const mark = result.status === "failed" ? c.red("F") : result.steps.every((s) => s.status === "skipped") ? c.yellow("-") : c.green(".");
282
- process.stdout.write(mark);
337
+ write$1(scenarioDot(result));
283
338
  },
284
339
  onComplete(results, durationMs) {
285
- const failures = results.filter((r) => r.status === "failed");
286
- const out = ["", ""];
287
- if (failures.length) {
288
- out.push(c.bold("Failures:"), "");
289
- for (const result of failures) out.push(c.bold(`Feature: ${relative(cwd, result.scenario.file)}`), renderScenario(result), "");
290
- }
291
- out.push(renderSummary(results, durationMs));
292
- process.stdout.write(out.join("\n") + "\n");
340
+ write$1(`\n\n${renderFailures(results, cwd)}${renderSummary(results, durationMs)}\n`);
293
341
  }
294
342
  };
295
343
  }
296
- function makeReporter(name, cwd) {
297
- return name === "progress" ? progressReporter(cwd) : prettyReporter(cwd);
344
+ function makeReporter(name, opts = {}) {
345
+ return name === "progress" ? progressReporter(opts) : prettyReporter(opts);
298
346
  }
299
347
  //#endregion
300
348
  //#region src/runtime/runner.ts
@@ -349,7 +397,10 @@ async function runPool(items, limit, worker) {
349
397
  * compiled-once step table with global/feature hooks around the batch. Never
350
398
  * throws for test failures — inspect `RunResult.passed`.
351
399
  */
352
- async function run(config, reporter = makeReporter(config.reporter, config.cwd)) {
400
+ async function run(config, reporter = makeReporter(config.reporter, {
401
+ cwd: config.cwd,
402
+ verbose: config.verbose
403
+ })) {
353
404
  const start = typeof performance !== "undefined" ? performance.now() : Date.now();
354
405
  const [featureFiles, stepFiles] = await Promise.all([globFiles(config.features, config.cwd), globFiles(config.steps, config.cwd)]);
355
406
  await importSteps(stepFiles);
@@ -359,7 +410,7 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
359
410
  name: config.name,
360
411
  tags: config.tags
361
412
  });
362
- await ensureGlobalHooks(globalHookRegistry);
413
+ await runHooksParallel("global", "before", globalHookRegistry);
363
414
  await runHooks("feature", "before", globalHookRegistry);
364
415
  const results = await runPool(selected, config.concurrency, async (scenario) => {
365
416
  const result = scenario.tags.includes("@skip") ? skippedResult(scenario) : await runScenario(scenario, compiled, makeWorld, globalHookRegistry);
@@ -367,6 +418,7 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
367
418
  return result;
368
419
  });
369
420
  await runHooks("feature", "after", globalHookRegistry);
421
+ await runHooksParallel("global", "after", globalHookRegistry);
370
422
  const durationMs = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start;
371
423
  reporter.onComplete(results, durationMs);
372
424
  return {
@@ -376,6 +428,603 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
376
428
  };
377
429
  }
378
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, selected[0], 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
+ for (const scenario of feature.scenarios) {
940
+ const scenarioName = scenario.outline ? `${scenario.outline.name} › ${scenario.name}` : scenario.name;
941
+ suggestions.push({
942
+ badge: "scenario",
943
+ label: scenarioName,
944
+ search: `${featureLabel} ${scenarioName}`.toLowerCase(),
945
+ value: {
946
+ kind: "scenario",
947
+ file: scenario.file,
948
+ name: scenario.name,
949
+ line: scenario.line
950
+ }
951
+ });
952
+ }
953
+ }
954
+ return suggestions;
955
+ }
956
+ /** Resolve a choice to its scenarios against the current parse. */
957
+ function resolveScenarios(choice, scenarios) {
958
+ switch (choice.kind) {
959
+ case "tag": return scenarios.filter((s) => s.tags.includes(choice.tag));
960
+ case "feature": return scenarios.filter((s) => s.file === choice.file);
961
+ case "scenario": return scenarios.filter((s) => s.file === choice.file && s.name === choice.name && (choice.line === void 0 || s.line === choice.line));
962
+ }
963
+ }
964
+ function choiceLabel(choice) {
965
+ switch (choice.kind) {
966
+ case "tag": return choice.tag;
967
+ case "feature": return choice.name || path.basename(choice.file);
968
+ case "scenario": return choice.name;
969
+ }
970
+ }
971
+ /**
972
+ * The `step-forge` argv that reproduces this selection in a child process. The
973
+ * base flags forward the resolved config (steps/world/concurrency) so the child
974
+ * matches the parent's setup regardless of its own config file; the scope flags
975
+ * narrow to the chosen population:
976
+ * - tag → all configured features, filtered by `-t <tag>`
977
+ * - feature → that single feature file
978
+ * - scenario → that feature file, name-anchored with `-n "/^…$/"`
979
+ * A single scenario runs verbose; a population uses the configured reporter.
980
+ */
981
+ function runArgs(choice, first, single, config) {
982
+ const args = [];
983
+ for (const glob of config.steps) args.push("-s", glob);
984
+ if (config.world) args.push("-w", config.world);
985
+ args.push("-c", String(config.concurrency));
986
+ switch (choice.kind) {
987
+ case "tag":
988
+ args.push(...config.features, "-t", choice.tag);
989
+ break;
990
+ case "feature":
991
+ args.push(choice.file);
992
+ break;
993
+ case "scenario":
994
+ args.push(choice.file, "-n", `/^${escapeRegExp(first.name)}$/`);
995
+ break;
996
+ }
997
+ if (single) args.push("-v");
998
+ else {
999
+ args.push("-r", config.reporter);
1000
+ if (config.verbose) args.push("-v");
1001
+ }
1002
+ return args;
1003
+ }
1004
+ /** Escape a scenario name for use inside an anchored `-n` regex. */
1005
+ function escapeRegExp(text) {
1006
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1007
+ }
1008
+ /** Local wall-clock time as `HH:MM:SS`, so each run's header is dated. */
1009
+ function clock() {
1010
+ const d = /* @__PURE__ */ new Date();
1011
+ return [
1012
+ d.getHours(),
1013
+ d.getMinutes(),
1014
+ d.getSeconds()
1015
+ ].map((n) => String(n).padStart(2, "0")).join(":");
1016
+ }
1017
+ function printDiagnostics(diagnostics, cwd) {
1018
+ if (diagnostics.length === 0) return;
1019
+ process.stdout.write(bold(" analyzer:\n"));
1020
+ for (const d of diagnostics) {
1021
+ const mark = d.severity === "error" ? red("✗") : d.severity === "warning" ? yellow("!") : dim("i");
1022
+ const loc = `${path.relative(cwd, d.file)}:${d.range.startLine}`;
1023
+ process.stdout.write(` ${mark} ${d.message} ${dim(`(${loc})`)}\n`);
1024
+ }
1025
+ process.stdout.write("\n");
1026
+ }
1027
+ //#endregion
379
1028
  //#region src/runtime/cli.ts
380
1029
  const HELP = `step-forge — native TypeScript runner for Gherkin step definitions
381
1030
 
@@ -389,6 +1038,9 @@ Options:
389
1038
  -w, --world <module> World factory module (default export () => world)
390
1039
  -c, --concurrency <n> Max scenarios in flight (default: 1, i.e. serial)
391
1040
  -r, --reporter <name> "pretty" (default) or "progress"
1041
+ -v, --verbose Report every scenario, not just failures
1042
+ -i, --interactive Interactive watch mode: pick a tag/feature/scenario
1043
+ and re-run it on every file change
392
1044
  --config <path> Config file directory (default: cwd)
393
1045
  -h, --help Show this help
394
1046
 
@@ -425,6 +1077,14 @@ function parseCli(argv) {
425
1077
  type: "string",
426
1078
  short: "r"
427
1079
  },
1080
+ verbose: {
1081
+ type: "boolean",
1082
+ short: "v"
1083
+ },
1084
+ interactive: {
1085
+ type: "boolean",
1086
+ short: "i"
1087
+ },
428
1088
  config: { type: "string" },
429
1089
  help: {
430
1090
  type: "boolean",
@@ -442,6 +1102,7 @@ function parseCli(argv) {
442
1102
  if (values.name) overrides.name = values.name;
443
1103
  if (values.steps) overrides.steps = values.steps;
444
1104
  if (values.world) overrides.world = values.world;
1105
+ if (values.verbose) overrides.verbose = true;
445
1106
  if (values.reporter) {
446
1107
  if (values.reporter !== "pretty" && values.reporter !== "progress") throw new Error(`Unknown reporter: ${values.reporter}`);
447
1108
  overrides.reporter = values.reporter;
@@ -453,12 +1114,18 @@ function parseCli(argv) {
453
1114
  }
454
1115
  return {
455
1116
  cwd: values.config ? path.resolve(process.cwd(), values.config) : process.cwd(),
456
- overrides
1117
+ overrides,
1118
+ interactive: values.interactive ?? false
457
1119
  };
458
1120
  }
459
1121
  async function main() {
460
- const { cwd, overrides } = parseCli(process.argv.slice(2));
461
- const { passed } = await run(resolveConfig(cwd, await loadConfigFile(cwd), overrides));
1122
+ const { cwd, overrides, interactive } = parseCli(process.argv.slice(2));
1123
+ const config = resolveConfig(cwd, await loadConfigFile(cwd), overrides);
1124
+ if (interactive) {
1125
+ await runInteractive(config);
1126
+ return;
1127
+ }
1128
+ const { passed } = await run(config);
462
1129
  process.exitCode = passed ? 0 : 1;
463
1130
  }
464
1131
  main().catch((err) => {