@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.
- package/RUNTIME.md +73 -12
- 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-DYfdoSIS.js → analyzer-gYyAKIpM.js} +61 -25
- package/dist/analyzer-gYyAKIpM.js.map +1 -0
- package/dist/analyzer.d.ts +2 -0
- package/dist/analyzer.js +1 -1
- package/dist/cli.cjs +753 -84
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +751 -84
- package/dist/cli.js.map +1 -1
- package/dist/{hooks-Dar49TtT.d.ts → config-C7PCYgYy.d.cts} +65 -16
- package/dist/{hooks-Dar49TtT.d.cts → config-C7PCYgYy.d.ts} +65 -16
- package/dist/{engine-DPRxs6eC.js → engine-DPVLEHBi.js} +8 -26
- package/dist/engine-DPVLEHBi.js.map +1 -0
- package/dist/{engine-DWAIlwWp.cjs → engine-vqA-eL_T.cjs} +8 -26
- package/dist/engine-vqA-eL_T.cjs.map +1 -0
- package/dist/{gherkinParser-CHpkEwii.cjs → gherkinParser-_ZgvELdy.cjs} +142 -3
- package/dist/gherkinParser-_ZgvELdy.cjs.map +1 -0
- package/dist/{gherkinParser-CKARHgt4.js → gherkinParser-i-Q7M6mi.js} +98 -4
- package/dist/gherkinParser-i-Q7M6mi.js.map +1 -0
- package/dist/hooks-BDCMKeNq.js +71 -0
- package/dist/{hooks-CywugMQQ.js.map → hooks-BDCMKeNq.js.map} +1 -1
- package/dist/{hooks-CGYzwDOv.cjs → hooks-Be0cjULN.cjs} +20 -31
- package/dist/{hooks-CGYzwDOv.cjs.map → hooks-Be0cjULN.cjs.map} +1 -1
- package/dist/runtime.cjs +3 -3
- package/dist/runtime.d.cts +11 -48
- package/dist/runtime.d.ts +11 -48
- package/dist/runtime.js +3 -3
- package/dist/step-forge.cjs +22 -377
- package/dist/step-forge.cjs.map +1 -1
- package/dist/step-forge.d.cts +13 -4
- package/dist/step-forge.d.ts +13 -4
- package/dist/step-forge.js +17 -371
- package/dist/step-forge.js.map +1 -1
- package/package.json +2 -2
- package/dist/analyzer-DYfdoSIS.js.map +0 -1
- package/dist/engine-DPRxs6eC.js.map +0 -1
- package/dist/engine-DWAIlwWp.cjs.map +0 -1
- package/dist/gherkinParser-CHpkEwii.cjs.map +0 -1
- package/dist/gherkinParser-CKARHgt4.js.map +0 -1
- package/dist/hooks-CywugMQQ.js +0 -82
package/dist/cli.cjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
const require_gherkinParser = require("./gherkinParser-
|
|
3
|
-
const require_hooks = require("./hooks-
|
|
4
|
-
const require_engine = require("./engine-
|
|
5
|
-
let node_fs_promises = require("node:fs/promises");
|
|
2
|
+
const require_gherkinParser = require("./gherkinParser-_ZgvELdy.cjs");
|
|
3
|
+
const require_hooks = require("./hooks-Be0cjULN.cjs");
|
|
4
|
+
const require_engine = require("./engine-vqA-eL_T.cjs");
|
|
6
5
|
let node_path = require("node:path");
|
|
7
6
|
node_path = require_gherkinParser.__toESM(node_path, 1);
|
|
8
|
-
let node_util = require("node:util");
|
|
9
7
|
let node_url = require("node:url");
|
|
8
|
+
let node_fs_promises = require("node:fs/promises");
|
|
9
|
+
let node_fs = require("node:fs");
|
|
10
|
+
node_fs = require_gherkinParser.__toESM(node_fs, 1);
|
|
11
|
+
let node_util = require("node:util");
|
|
12
|
+
let node_child_process = require("node:child_process");
|
|
13
|
+
let node_readline = require("node:readline");
|
|
14
|
+
node_readline = require_gherkinParser.__toESM(node_readline, 1);
|
|
10
15
|
//#region src/runtime/config.ts
|
|
11
16
|
const DEFAULT_FEATURES = "**/*.feature";
|
|
12
17
|
const DEFAULT_STEPS = "**/*.steps.ts";
|
|
@@ -58,6 +63,7 @@ function resolveConfig(cwd, file, cli) {
|
|
|
58
63
|
world: pick("world"),
|
|
59
64
|
concurrency: pick("concurrency") ?? 1,
|
|
60
65
|
reporter: pick("reporter") ?? "pretty",
|
|
66
|
+
verbose: pick("verbose") ?? false,
|
|
61
67
|
name: pick("name"),
|
|
62
68
|
tags: pick("tags")
|
|
63
69
|
};
|
|
@@ -163,52 +169,83 @@ function selectScenarios(scenarios, options) {
|
|
|
163
169
|
}
|
|
164
170
|
//#endregion
|
|
165
171
|
//#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;
|
|
172
|
+
const useColor$2 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
173
|
+
const wrap$1 = (open, close) => (s) => useColor$2 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
168
174
|
const c = {
|
|
169
|
-
green: wrap(32, 39),
|
|
170
|
-
red: wrap(31, 39),
|
|
171
|
-
yellow: wrap(33, 39),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
bold: wrap(1, 22)
|
|
175
|
+
green: wrap$1(32, 39),
|
|
176
|
+
red: wrap$1(31, 39),
|
|
177
|
+
yellow: wrap$1(33, 39),
|
|
178
|
+
dim: wrap$1(2, 22),
|
|
179
|
+
bold: wrap$1(1, 22)
|
|
175
180
|
};
|
|
176
181
|
const STATUS_MARK = {
|
|
177
182
|
passed: c.green("✓"),
|
|
178
183
|
failed: c.red("✗"),
|
|
179
184
|
skipped: c.yellow("-")
|
|
180
185
|
};
|
|
181
|
-
/**
|
|
186
|
+
/** ✓ / ✗ / - for a whole scenario (skipped = every step skipped). */
|
|
187
|
+
function scenarioMark(result) {
|
|
188
|
+
if (result.status === "failed") return c.red("✗");
|
|
189
|
+
if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
|
|
190
|
+
return c.green("✓");
|
|
191
|
+
}
|
|
192
|
+
/** Dot per scenario for the live heartbeat: `.` pass / `F` fail / `-` skip. */
|
|
193
|
+
function scenarioDot(result) {
|
|
194
|
+
if (result.status === "failed") return c.red("F");
|
|
195
|
+
if (result.steps.every((s) => s.status === "skipped")) return c.yellow("-");
|
|
196
|
+
return c.green(".");
|
|
197
|
+
}
|
|
198
|
+
/** Indent every non-empty line of a block by `pad` spaces. */
|
|
182
199
|
function indent(text, pad) {
|
|
183
200
|
const prefix = " ".repeat(pad);
|
|
184
201
|
return text.split("\n").map((line) => line ? prefix + line : line).join("\n");
|
|
185
202
|
}
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The error, trimmed to user frames: `Name: message` followed by only the
|
|
205
|
+
* caller's own stack frames (library/engine and `node_modules` frames removed),
|
|
206
|
+
* source-mapped by Bun to the original `.ts`. Falls back to just the message
|
|
207
|
+
* when nothing user-owned is left (e.g. an undefined-step error).
|
|
208
|
+
*/
|
|
209
|
+
function renderError(error, cwd) {
|
|
210
|
+
const header = `${error.name}: ${error.message}`;
|
|
211
|
+
const frames = require_gherkinParser.userFrames(error.stack).map((f) => ` at ${require_gherkinParser.relativeFrame(f, cwd)}`);
|
|
212
|
+
return frames.length ? `${header}\n${frames.join("\n")}` : header;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The detail block shown beneath a failing step: the `.feature` line, the step
|
|
216
|
+
* definition location, and the trimmed error — the two coordinates that make a
|
|
217
|
+
* failure easy to chase (where in the feature, which step definition).
|
|
218
|
+
*/
|
|
219
|
+
function failureDetail(stepResult, result, cwd) {
|
|
220
|
+
const lines = [`feature: ${(0, node_path.relative)(cwd, result.scenario.file)}:${stepResult.step.line}`];
|
|
221
|
+
if (stepResult.source) lines.push(`defined: ${require_gherkinParser.relativeLocation(stepResult.source, cwd)}`);
|
|
222
|
+
if (stepResult.error) lines.push(renderError(stepResult.error, cwd));
|
|
223
|
+
return indent(c.red(lines.join("\n")), 6);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* One scenario as a Cucumber-style block: a marked header with its `.feature`
|
|
227
|
+
* location, then each step with its mark (and, in `verbose`, the step
|
|
228
|
+
* definition location as a trailing comment). A failing step is followed by its
|
|
229
|
+
* failure detail; a scenario-level error (a failing hook, no step to blame) is
|
|
230
|
+
* appended at the end.
|
|
231
|
+
*/
|
|
232
|
+
function renderScenario(result, cwd, opts) {
|
|
189
233
|
const label = result.scenario.outline ? `${result.scenario.outline.name} › ${result.scenario.name}` : result.scenario.name;
|
|
190
|
-
lines
|
|
234
|
+
const lines = [`${scenarioMark(result)} ${c.bold(label)}`];
|
|
235
|
+
const loc = result.scenario.line ? `${(0, node_path.relative)(cwd, result.scenario.file)}:${result.scenario.line}` : (0, node_path.relative)(cwd, result.scenario.file);
|
|
236
|
+
lines.push(indent(c.dim(loc), 4));
|
|
237
|
+
lines.push("");
|
|
191
238
|
for (const step of result.scenario.steps) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
lines.push(indent(c.red(stack), 6));
|
|
239
|
+
const sr = result.steps.find((s) => s.step === step);
|
|
240
|
+
const status = sr?.status ?? "skipped";
|
|
241
|
+
const comment = opts.stepSource && sr?.source ? c.dim(` # ${require_gherkinParser.relativeLocation(sr.source, cwd)}`) : "";
|
|
242
|
+
lines.push(` ${STATUS_MARK[status]} ${c.dim(step.effectiveKeyword)} ${step.text}${comment}`);
|
|
243
|
+
if (sr?.status === "failed") lines.push(failureDetail(sr, result, cwd));
|
|
198
244
|
}
|
|
245
|
+
const stepFailed = result.steps.some((s) => s.status === "failed");
|
|
246
|
+
if (result.error && !stepFailed) lines.push(indent(c.red(renderError(result.error, cwd)), 4));
|
|
199
247
|
return lines.join("\n");
|
|
200
248
|
}
|
|
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
249
|
function tally(results) {
|
|
213
250
|
const totals = {
|
|
214
251
|
scenarios: {
|
|
@@ -230,71 +267,84 @@ function tally(results) {
|
|
|
230
267
|
}
|
|
231
268
|
return totals;
|
|
232
269
|
}
|
|
233
|
-
/** The shared end-of-run summary block: counts + duration. */
|
|
234
270
|
function renderSummary(results, durationMs) {
|
|
235
271
|
const t = tally(results);
|
|
236
272
|
const scenarioTotal = t.scenarios.passed + t.scenarios.failed + t.scenarios.skipped;
|
|
237
273
|
const stepTotal = t.steps.passed + t.steps.failed + t.steps.skipped;
|
|
238
274
|
const part = (n, label, color) => n > 0 ? color(`${n} ${label}`) : null;
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
part(t.steps.skipped, "skipped", c.yellow)
|
|
248
|
-
].filter((x) => x !== null);
|
|
249
|
-
const seconds = (durationMs / 1e3).toFixed(2);
|
|
275
|
+
const line = (total, noun, counts) => {
|
|
276
|
+
const parts = [
|
|
277
|
+
part(counts.passed, "passed", c.green),
|
|
278
|
+
part(counts.failed, "failed", c.red),
|
|
279
|
+
part(counts.skipped, "skipped", c.yellow)
|
|
280
|
+
].filter((x) => x !== null);
|
|
281
|
+
return `${total} ${noun}${total === 1 ? "" : "s"} (${parts.join(", ")})`;
|
|
282
|
+
};
|
|
250
283
|
return [
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
c.dim(`${
|
|
284
|
+
line(scenarioTotal, "scenario", t.scenarios),
|
|
285
|
+
line(stepTotal, "step", t.steps),
|
|
286
|
+
c.dim(`${(durationMs / 1e3).toFixed(2)}s`)
|
|
254
287
|
].join("\n");
|
|
255
288
|
}
|
|
289
|
+
function write$1(text) {
|
|
290
|
+
process.stdout.write(text);
|
|
291
|
+
}
|
|
292
|
+
/** Feature-grouped tree of every scenario (used by `--verbose`). */
|
|
293
|
+
function renderFullTree(results, cwd) {
|
|
294
|
+
const groups = /* @__PURE__ */ new Map();
|
|
295
|
+
for (const r of results) {
|
|
296
|
+
const bucket = groups.get(r.scenario.file) ?? [];
|
|
297
|
+
bucket.push(r);
|
|
298
|
+
groups.set(r.scenario.file, bucket);
|
|
299
|
+
}
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const [file, group] of groups) {
|
|
302
|
+
out.push(c.bold(`Feature: ${(0, node_path.relative)(cwd, file)}`), "");
|
|
303
|
+
for (const r of group) out.push(indent(renderScenario(r, cwd, { stepSource: true }), 2), "");
|
|
304
|
+
}
|
|
305
|
+
return out.join("\n");
|
|
306
|
+
}
|
|
307
|
+
/** The failing scenarios only (used by the default, non-verbose output). */
|
|
308
|
+
function renderFailures(results, cwd) {
|
|
309
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
310
|
+
if (!failed.length) return "";
|
|
311
|
+
return failed.map((r) => renderScenario(r, cwd, { stepSource: false })).join("\n\n") + "\n\n";
|
|
312
|
+
}
|
|
256
313
|
/**
|
|
257
|
-
* Default reporter:
|
|
258
|
-
*
|
|
259
|
-
*
|
|
314
|
+
* Default reporter. Non-verbose: a dot per scenario as a heartbeat, then the
|
|
315
|
+
* failing scenarios in Cucumber-style detail, then the summary. Verbose: the
|
|
316
|
+
* full feature → scenario → step tree instead of just failures.
|
|
260
317
|
*/
|
|
261
|
-
function prettyReporter(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
318
|
+
function prettyReporter(opts = {}) {
|
|
319
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
320
|
+
const verbose = opts.verbose ?? false;
|
|
321
|
+
return {
|
|
322
|
+
onScenarioEnd(result) {
|
|
323
|
+
if (!verbose) write$1(scenarioDot(result));
|
|
324
|
+
},
|
|
325
|
+
onComplete(results, durationMs) {
|
|
326
|
+
write$1(`${verbose ? `\n${renderFullTree(results, cwd)}\n` : `\n\n${renderFailures(results, cwd)}`}${renderSummary(results, durationMs)}\n`);
|
|
269
327
|
}
|
|
270
|
-
|
|
271
|
-
process.stdout.write(out.join("\n") + "\n");
|
|
272
|
-
} };
|
|
328
|
+
};
|
|
273
329
|
}
|
|
274
330
|
/**
|
|
275
|
-
* Compact reporter:
|
|
276
|
-
*
|
|
331
|
+
* Compact reporter: a dot per scenario, then failures in detail and the
|
|
332
|
+
* summary. Always minimal — `verbose` is accepted for interface parity but does
|
|
333
|
+
* not expand the output (use `pretty --verbose` for the full tree).
|
|
277
334
|
*/
|
|
278
|
-
function progressReporter(
|
|
335
|
+
function progressReporter(opts = {}) {
|
|
336
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
279
337
|
return {
|
|
280
338
|
onScenarioEnd(result) {
|
|
281
|
-
|
|
282
|
-
process.stdout.write(mark);
|
|
339
|
+
write$1(scenarioDot(result));
|
|
283
340
|
},
|
|
284
341
|
onComplete(results, durationMs) {
|
|
285
|
-
|
|
286
|
-
const out = ["", ""];
|
|
287
|
-
if (failures.length) {
|
|
288
|
-
out.push(c.bold("Failures:"), "");
|
|
289
|
-
for (const result of failures) out.push(c.bold(`Feature: ${(0, node_path.relative)(cwd, result.scenario.file)}`), renderScenario(result), "");
|
|
290
|
-
}
|
|
291
|
-
out.push(renderSummary(results, durationMs));
|
|
292
|
-
process.stdout.write(out.join("\n") + "\n");
|
|
342
|
+
write$1(`\n\n${renderFailures(results, cwd)}${renderSummary(results, durationMs)}\n`);
|
|
293
343
|
}
|
|
294
344
|
};
|
|
295
345
|
}
|
|
296
|
-
function makeReporter(name,
|
|
297
|
-
return name === "progress" ? progressReporter(
|
|
346
|
+
function makeReporter(name, opts = {}) {
|
|
347
|
+
return name === "progress" ? progressReporter(opts) : prettyReporter(opts);
|
|
298
348
|
}
|
|
299
349
|
//#endregion
|
|
300
350
|
//#region src/runtime/runner.ts
|
|
@@ -349,7 +399,10 @@ async function runPool(items, limit, worker) {
|
|
|
349
399
|
* compiled-once step table with global/feature hooks around the batch. Never
|
|
350
400
|
* throws for test failures — inspect `RunResult.passed`.
|
|
351
401
|
*/
|
|
352
|
-
async function run(config, reporter = makeReporter(config.reporter,
|
|
402
|
+
async function run(config, reporter = makeReporter(config.reporter, {
|
|
403
|
+
cwd: config.cwd,
|
|
404
|
+
verbose: config.verbose
|
|
405
|
+
})) {
|
|
353
406
|
const start = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
354
407
|
const [featureFiles, stepFiles] = await Promise.all([require_gherkinParser.globFiles(config.features, config.cwd), require_gherkinParser.globFiles(config.steps, config.cwd)]);
|
|
355
408
|
await importSteps(stepFiles);
|
|
@@ -359,7 +412,7 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
|
|
|
359
412
|
name: config.name,
|
|
360
413
|
tags: config.tags
|
|
361
414
|
});
|
|
362
|
-
await require_hooks.
|
|
415
|
+
await require_hooks.runHooksParallel("global", "before", require_hooks.globalHookRegistry);
|
|
363
416
|
await require_hooks.runHooks("feature", "before", require_hooks.globalHookRegistry);
|
|
364
417
|
const results = await runPool(selected, config.concurrency, async (scenario) => {
|
|
365
418
|
const result = scenario.tags.includes("@skip") ? skippedResult(scenario) : await require_engine.runScenario(scenario, compiled, makeWorld, require_hooks.globalHookRegistry);
|
|
@@ -367,6 +420,7 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
|
|
|
367
420
|
return result;
|
|
368
421
|
});
|
|
369
422
|
await require_hooks.runHooks("feature", "after", require_hooks.globalHookRegistry);
|
|
423
|
+
await require_hooks.runHooksParallel("global", "after", require_hooks.globalHookRegistry);
|
|
370
424
|
const durationMs = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start;
|
|
371
425
|
reporter.onComplete(results, durationMs);
|
|
372
426
|
return {
|
|
@@ -376,6 +430,603 @@ async function run(config, reporter = makeReporter(config.reporter, config.cwd))
|
|
|
376
430
|
};
|
|
377
431
|
}
|
|
378
432
|
//#endregion
|
|
433
|
+
//#region src/runtime/prompt.ts
|
|
434
|
+
const useColor$1 = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
435
|
+
const wrap = (open, close) => (s) => useColor$1 ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
436
|
+
const color = {
|
|
437
|
+
green: wrap(32, 39),
|
|
438
|
+
cyan: wrap(36, 39),
|
|
439
|
+
dim: wrap(2, 22),
|
|
440
|
+
bold: wrap(1, 22),
|
|
441
|
+
inverse: wrap(7, 27)
|
|
442
|
+
};
|
|
443
|
+
/** Ranked match: lower rank sorts first; `-1` means "no match" (excluded). */
|
|
444
|
+
function rank(search, query) {
|
|
445
|
+
if (!query) return 0;
|
|
446
|
+
const idx = search.indexOf(query);
|
|
447
|
+
if (idx === -1) return -1;
|
|
448
|
+
if (idx === 0) return 0;
|
|
449
|
+
return /[\s:›@/]/.test(search[idx - 1]) ? 1 : 2;
|
|
450
|
+
}
|
|
451
|
+
var Prompt = class {
|
|
452
|
+
handlers;
|
|
453
|
+
prefix;
|
|
454
|
+
maxVisible;
|
|
455
|
+
all = [];
|
|
456
|
+
matches = [];
|
|
457
|
+
query;
|
|
458
|
+
cursor;
|
|
459
|
+
selected = 0;
|
|
460
|
+
window = 0;
|
|
461
|
+
/** Optional dim status line shown just above the input (set by the caller). */
|
|
462
|
+
status = "";
|
|
463
|
+
/** Lines the pinned block currently occupies, so a redraw can erase them. */
|
|
464
|
+
rendered = 0;
|
|
465
|
+
started = false;
|
|
466
|
+
/** While true the block is hidden so a run's output can own the screen. */
|
|
467
|
+
suspended = false;
|
|
468
|
+
keyListener;
|
|
469
|
+
constructor(options) {
|
|
470
|
+
this.handlers = options.handlers;
|
|
471
|
+
this.prefix = options.prefix ?? "› ";
|
|
472
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
473
|
+
this.query = options.initialQuery ?? "";
|
|
474
|
+
this.cursor = this.query.length;
|
|
475
|
+
}
|
|
476
|
+
/** Replace the suggestion pool (e.g. after the feature cache rebuilds). */
|
|
477
|
+
setSuggestions(suggestions) {
|
|
478
|
+
this.all = suggestions;
|
|
479
|
+
this.refilter();
|
|
480
|
+
if (this.started) this.render();
|
|
481
|
+
}
|
|
482
|
+
get value() {
|
|
483
|
+
return this.query;
|
|
484
|
+
}
|
|
485
|
+
/** Enter raw mode, attach the keypress listener, and draw the block. */
|
|
486
|
+
start() {
|
|
487
|
+
if (this.started) return;
|
|
488
|
+
this.started = true;
|
|
489
|
+
node_readline.emitKeypressEvents(process.stdin);
|
|
490
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
491
|
+
this.keyListener = (str, key) => this.onKey(str, key ?? {});
|
|
492
|
+
process.stdin.on("keypress", this.keyListener);
|
|
493
|
+
process.stdin.resume();
|
|
494
|
+
this.render();
|
|
495
|
+
}
|
|
496
|
+
/** Erase the block, restore cooked mode, and detach the listener. */
|
|
497
|
+
stop() {
|
|
498
|
+
if (!this.started) return;
|
|
499
|
+
this.started = false;
|
|
500
|
+
this.erase();
|
|
501
|
+
write("\x1B[?25h");
|
|
502
|
+
if (this.keyListener) process.stdin.off("keypress", this.keyListener);
|
|
503
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
504
|
+
process.stdin.pause();
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Erase the pinned block and hide it for the duration of an (async) run, so
|
|
508
|
+
* everything written to stdout in the meantime scrolls where the prompt was.
|
|
509
|
+
* Keypresses still update state but don't repaint until {@link endOutput}.
|
|
510
|
+
*/
|
|
511
|
+
beginOutput() {
|
|
512
|
+
if (!this.started) return;
|
|
513
|
+
this.erase();
|
|
514
|
+
this.suspended = true;
|
|
515
|
+
}
|
|
516
|
+
/** Redraw the pinned block below whatever output {@link beginOutput} let through. */
|
|
517
|
+
endOutput() {
|
|
518
|
+
if (!this.started) return;
|
|
519
|
+
this.suspended = false;
|
|
520
|
+
this.render();
|
|
521
|
+
}
|
|
522
|
+
/** Force a redraw (after mutating `status`, say). No-op while suspended. */
|
|
523
|
+
redraw() {
|
|
524
|
+
if (this.started && !this.suspended) this.render();
|
|
525
|
+
}
|
|
526
|
+
onKey(str, key) {
|
|
527
|
+
if (key.ctrl && (key.name === "c" || key.name === "d")) {
|
|
528
|
+
this.handlers.onQuit();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
switch (key.name) {
|
|
532
|
+
case "return":
|
|
533
|
+
case "enter": {
|
|
534
|
+
const value = this.matches[this.selected]?.value ?? null;
|
|
535
|
+
this.handlers.onSubmit(value, this.query);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
case "escape":
|
|
539
|
+
this.handlers.onEscape();
|
|
540
|
+
return;
|
|
541
|
+
case "up":
|
|
542
|
+
this.move(-1);
|
|
543
|
+
return;
|
|
544
|
+
case "down":
|
|
545
|
+
this.move(1);
|
|
546
|
+
return;
|
|
547
|
+
case "left":
|
|
548
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
549
|
+
this.render();
|
|
550
|
+
return;
|
|
551
|
+
case "right":
|
|
552
|
+
this.cursor = Math.min(this.query.length, this.cursor + 1);
|
|
553
|
+
this.render();
|
|
554
|
+
return;
|
|
555
|
+
case "home":
|
|
556
|
+
this.cursor = 0;
|
|
557
|
+
this.render();
|
|
558
|
+
return;
|
|
559
|
+
case "end":
|
|
560
|
+
this.cursor = this.query.length;
|
|
561
|
+
this.render();
|
|
562
|
+
return;
|
|
563
|
+
case "backspace":
|
|
564
|
+
this.deleteBefore();
|
|
565
|
+
return;
|
|
566
|
+
case "delete":
|
|
567
|
+
this.deleteAfter();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (str && str.length === 1 && str >= " " && !key.ctrl && !key.meta) this.insert(str);
|
|
571
|
+
}
|
|
572
|
+
insert(ch) {
|
|
573
|
+
this.query = this.query.slice(0, this.cursor) + ch + this.query.slice(this.cursor);
|
|
574
|
+
this.cursor += ch.length;
|
|
575
|
+
this.afterEdit();
|
|
576
|
+
}
|
|
577
|
+
deleteBefore() {
|
|
578
|
+
if (this.cursor === 0) return;
|
|
579
|
+
this.query = this.query.slice(0, this.cursor - 1) + this.query.slice(this.cursor);
|
|
580
|
+
this.cursor--;
|
|
581
|
+
this.afterEdit();
|
|
582
|
+
}
|
|
583
|
+
deleteAfter() {
|
|
584
|
+
if (this.cursor >= this.query.length) return;
|
|
585
|
+
this.query = this.query.slice(0, this.cursor) + this.query.slice(this.cursor + 1);
|
|
586
|
+
this.afterEdit();
|
|
587
|
+
}
|
|
588
|
+
afterEdit() {
|
|
589
|
+
this.refilter();
|
|
590
|
+
this.render();
|
|
591
|
+
this.handlers.onEdit(this.query);
|
|
592
|
+
}
|
|
593
|
+
move(delta) {
|
|
594
|
+
if (this.matches.length === 0) return;
|
|
595
|
+
this.selected = Math.min(this.matches.length - 1, Math.max(0, this.selected + delta));
|
|
596
|
+
this.reWindow();
|
|
597
|
+
this.render();
|
|
598
|
+
}
|
|
599
|
+
refilter() {
|
|
600
|
+
const q = this.query.trim().toLowerCase();
|
|
601
|
+
this.matches = this.all.map((s, index) => ({
|
|
602
|
+
s,
|
|
603
|
+
index,
|
|
604
|
+
r: rank(s.search, q)
|
|
605
|
+
})).filter((m) => m.r !== -1).sort((a, b) => a.r - b.r || a.index - b.index).map((m) => m.s);
|
|
606
|
+
this.selected = 0;
|
|
607
|
+
this.window = 0;
|
|
608
|
+
}
|
|
609
|
+
reWindow() {
|
|
610
|
+
if (this.selected < this.window) this.window = this.selected;
|
|
611
|
+
else if (this.selected >= this.window + this.maxVisible) this.window = this.selected - this.maxVisible + 1;
|
|
612
|
+
}
|
|
613
|
+
/** Build the block's lines, top (suggestions) to bottom (input). */
|
|
614
|
+
buildLines() {
|
|
615
|
+
const lines = [];
|
|
616
|
+
const total = this.matches.length;
|
|
617
|
+
if (this.query.trim() && total === 0) lines.push(color.dim(" no matching tag, feature, or scenario"));
|
|
618
|
+
const end = Math.min(this.window + this.maxVisible, total);
|
|
619
|
+
for (let i = this.window; i < end; i++) {
|
|
620
|
+
const s = this.matches[i];
|
|
621
|
+
const active = i === this.selected;
|
|
622
|
+
const row = `${color.cyan(s.badge.padEnd(9))} ${s.label}`;
|
|
623
|
+
lines.push(active ? color.inverse(`❯ ${row}`) : ` ${row}`);
|
|
624
|
+
}
|
|
625
|
+
if (total > end) lines.push(color.dim(` …and ${total - end} more`));
|
|
626
|
+
if (this.status) lines.push(color.dim(this.status));
|
|
627
|
+
lines.push(`${color.bold(this.prefix)}${this.query}`);
|
|
628
|
+
return lines;
|
|
629
|
+
}
|
|
630
|
+
render() {
|
|
631
|
+
if (this.suspended) return;
|
|
632
|
+
write("\x1B[?25l");
|
|
633
|
+
this.moveToBlockStart();
|
|
634
|
+
write("\x1B[0J");
|
|
635
|
+
const lines = this.buildLines();
|
|
636
|
+
write(lines.join("\r\n"));
|
|
637
|
+
this.rendered = lines.length;
|
|
638
|
+
write(`\r\x1b[${stringWidth(this.prefix) + this.cursor}C`);
|
|
639
|
+
write("\x1B[?25h");
|
|
640
|
+
}
|
|
641
|
+
/** Erase the block and leave the caret at the block's top-left. */
|
|
642
|
+
erase() {
|
|
643
|
+
this.moveToBlockStart();
|
|
644
|
+
write("\x1B[0J");
|
|
645
|
+
this.rendered = 0;
|
|
646
|
+
}
|
|
647
|
+
/** Move the caret to column 0 of the block's first line. */
|
|
648
|
+
moveToBlockStart() {
|
|
649
|
+
if (this.rendered > 1) write(`\x1b[${this.rendered - 1}A`);
|
|
650
|
+
write("\r");
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
/** Visible width, ignoring the ANSI escapes our color helpers may inject. */
|
|
654
|
+
function stringWidth(s) {
|
|
655
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
656
|
+
}
|
|
657
|
+
function write(s) {
|
|
658
|
+
process.stdout.write(s);
|
|
659
|
+
}
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/runtime/watcher.ts
|
|
662
|
+
/** Glob metacharacters — the same set {@link globFiles} treats as "magic". */
|
|
663
|
+
const MAGIC = /[*?[\]{}!()]/;
|
|
664
|
+
/** File extensions worth reacting to: features and step/world modules. */
|
|
665
|
+
const WATCHED_EXT = /\.(feature|ts|mts|js|mjs|cts|cjs)$/;
|
|
666
|
+
/**
|
|
667
|
+
* Watch the directories implied by feature/step globs and invoke `onChange`
|
|
668
|
+
* (debounced, coalesced) whenever a relevant file changes. Roots are the literal
|
|
669
|
+
* directory prefixes of each glob — e.g. `features/**\/*.feature` watches
|
|
670
|
+
* `features/` recursively — deduped so nested roots aren't watched twice.
|
|
671
|
+
*
|
|
672
|
+
* Recursive watching is supported on macOS and Windows and on modern Linux
|
|
673
|
+
* (Node ≥ 20 / Bun); on older Linux only the top level of each root is seen.
|
|
674
|
+
*/
|
|
675
|
+
function watchFeatures(globs, cwd, onChange, debounceMs = 120) {
|
|
676
|
+
const roots = watchRoots(globs, cwd);
|
|
677
|
+
const watchers = [];
|
|
678
|
+
let timer;
|
|
679
|
+
const schedule = (filename) => {
|
|
680
|
+
if (filename && !WATCHED_EXT.test(filename)) return;
|
|
681
|
+
if (timer) clearTimeout(timer);
|
|
682
|
+
timer = setTimeout(onChange, debounceMs);
|
|
683
|
+
};
|
|
684
|
+
for (const root of roots) try {
|
|
685
|
+
const w = node_fs.watch(root, { recursive: true }, (_event, filename) => schedule(filename));
|
|
686
|
+
w.on("error", () => {});
|
|
687
|
+
watchers.push(w);
|
|
688
|
+
} catch {}
|
|
689
|
+
return { close() {
|
|
690
|
+
if (timer) clearTimeout(timer);
|
|
691
|
+
for (const w of watchers) w.close();
|
|
692
|
+
} };
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* The set of directories to watch: each glob's literal prefix (the path up to
|
|
696
|
+
* its first magic character), resolved against `cwd`, deduped, with any root
|
|
697
|
+
* that nests inside another dropped. Falls back to `cwd` when nothing resolves.
|
|
698
|
+
*/
|
|
699
|
+
function watchRoots(globs, cwd) {
|
|
700
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
701
|
+
for (const glob of globs) dirs.add(node_path.resolve(cwd, literalPrefix(glob)));
|
|
702
|
+
const roots = [...dirs].sort();
|
|
703
|
+
const pruned = roots.filter((r, i) => !roots.some((other, j) => j < i && isInside(r, other)));
|
|
704
|
+
return pruned.length ? pruned : [cwd];
|
|
705
|
+
}
|
|
706
|
+
/** The directory portion of a glob before its first magic segment. */
|
|
707
|
+
function literalPrefix(glob) {
|
|
708
|
+
const segments = glob.split("/");
|
|
709
|
+
const literal = [];
|
|
710
|
+
for (const seg of segments) {
|
|
711
|
+
if (MAGIC.test(seg)) break;
|
|
712
|
+
literal.push(seg);
|
|
713
|
+
}
|
|
714
|
+
const joined = literal.join("/");
|
|
715
|
+
if (!joined) return ".";
|
|
716
|
+
return MAGIC.test(glob) ? joined : node_path.dirname(joined);
|
|
717
|
+
}
|
|
718
|
+
/** True when `child` is `parent` itself or nested beneath it. */
|
|
719
|
+
function isInside(child, parent) {
|
|
720
|
+
if (child === parent) return true;
|
|
721
|
+
const rel = node_path.relative(parent, child);
|
|
722
|
+
return !!rel && !rel.startsWith("..") && !node_path.isAbsolute(rel);
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/runtime/interactive.ts
|
|
726
|
+
const useColor = !process.env.NO_COLOR && (process.stdout.isTTY ?? false) === true;
|
|
727
|
+
const bold = (s) => useColor ? `\x1b[1m${s}\x1b[22m` : s;
|
|
728
|
+
const dim = (s) => useColor ? `\x1b[2m${s}\x1b[22m` : s;
|
|
729
|
+
const red = (s) => useColor ? `\x1b[31m${s}\x1b[39m` : s;
|
|
730
|
+
const yellow = (s) => useColor ? `\x1b[33m${s}\x1b[39m` : s;
|
|
731
|
+
/**
|
|
732
|
+
* Entry point for `step-forge -i`. Brings up the always-live typeahead prompt,
|
|
733
|
+
* watches the configured feature/step directories, and re-runs the armed
|
|
734
|
+
* population on every relevant file change. Requires a TTY.
|
|
735
|
+
*/
|
|
736
|
+
async function runInteractive(config) {
|
|
737
|
+
if (!process.stdin.isTTY) {
|
|
738
|
+
process.stderr.write("Interactive mode (-i) requires a TTY; stdin is not a terminal.\n");
|
|
739
|
+
process.exitCode = 1;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
await new InteractiveSession(config).start();
|
|
743
|
+
}
|
|
744
|
+
var InteractiveSession = class {
|
|
745
|
+
config;
|
|
746
|
+
prompt;
|
|
747
|
+
watcher;
|
|
748
|
+
catalog = [];
|
|
749
|
+
armed = null;
|
|
750
|
+
dirty = false;
|
|
751
|
+
running = false;
|
|
752
|
+
rerunQueued = false;
|
|
753
|
+
/** Increments per run so back-to-back identical output is still distinguishable. */
|
|
754
|
+
runCount = 0;
|
|
755
|
+
done;
|
|
756
|
+
constructor(config) {
|
|
757
|
+
this.config = config;
|
|
758
|
+
this.prompt = new Prompt({
|
|
759
|
+
initialQuery: config.tags ?? config.name ?? "",
|
|
760
|
+
handlers: {
|
|
761
|
+
onSubmit: (value) => this.onSubmit(value),
|
|
762
|
+
onEdit: () => this.onEdit(),
|
|
763
|
+
onEscape: () => this.onEscape(),
|
|
764
|
+
onQuit: () => this.onQuit()
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async start() {
|
|
769
|
+
await this.rebuildCatalog();
|
|
770
|
+
this.prompt.status = this.status();
|
|
771
|
+
this.prompt.start();
|
|
772
|
+
this.watcher = watchFeatures([...this.config.features, ...this.config.steps], this.config.cwd, () => void this.onFsChange());
|
|
773
|
+
await new Promise((resolve) => {
|
|
774
|
+
this.done = resolve;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
onSubmit(value) {
|
|
778
|
+
if (!value) return;
|
|
779
|
+
this.armed = value;
|
|
780
|
+
this.dirty = false;
|
|
781
|
+
this.prompt.status = this.status();
|
|
782
|
+
this.triggerRun();
|
|
783
|
+
}
|
|
784
|
+
onEdit() {
|
|
785
|
+
if (this.armed) this.dirty = true;
|
|
786
|
+
this.prompt.status = this.status();
|
|
787
|
+
this.prompt.redraw();
|
|
788
|
+
}
|
|
789
|
+
onEscape() {
|
|
790
|
+
if (!this.armed) return;
|
|
791
|
+
this.armed = null;
|
|
792
|
+
this.dirty = false;
|
|
793
|
+
this.prompt.status = this.status();
|
|
794
|
+
this.prompt.redraw();
|
|
795
|
+
}
|
|
796
|
+
onQuit() {
|
|
797
|
+
this.watcher?.close();
|
|
798
|
+
this.prompt.stop();
|
|
799
|
+
process.stdout.write("\n");
|
|
800
|
+
process.exitCode = 0;
|
|
801
|
+
this.done?.();
|
|
802
|
+
}
|
|
803
|
+
async onFsChange() {
|
|
804
|
+
await this.rebuildCatalog();
|
|
805
|
+
if (this.armed && !this.dirty) this.triggerRun();
|
|
806
|
+
}
|
|
807
|
+
async rebuildCatalog() {
|
|
808
|
+
try {
|
|
809
|
+
const featureFiles = await require_gherkinParser.globFiles(this.config.features, this.config.cwd);
|
|
810
|
+
this.catalog = require_gherkinParser.parseFeatureCatalog(featureFiles);
|
|
811
|
+
} catch {}
|
|
812
|
+
this.prompt.setSuggestions(buildSuggestions(this.catalog));
|
|
813
|
+
}
|
|
814
|
+
triggerRun() {
|
|
815
|
+
if (!this.armed) return;
|
|
816
|
+
if (this.running) {
|
|
817
|
+
this.rerunQueued = true;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.running = true;
|
|
821
|
+
this.prompt.beginOutput();
|
|
822
|
+
this.runLoop();
|
|
823
|
+
}
|
|
824
|
+
async runLoop() {
|
|
825
|
+
try {
|
|
826
|
+
do {
|
|
827
|
+
this.rerunQueued = false;
|
|
828
|
+
if (this.armed) await this.executeOnce(this.armed);
|
|
829
|
+
} while (this.rerunQueued && this.armed && !this.dirty);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
process.stdout.write(red(`\n run failed: ${err instanceof Error ? err.message : err}\n`));
|
|
832
|
+
} finally {
|
|
833
|
+
this.running = false;
|
|
834
|
+
this.prompt.status = this.status();
|
|
835
|
+
this.prompt.endOutput();
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* One run of `choice`: resolve its scenarios against the current parse, then
|
|
840
|
+
* shell out to a **fresh** `step-forge` process scoped to just that
|
|
841
|
+
* population. A child process is used deliberately — Bun caches ES modules for
|
|
842
|
+
* the life of a process and ignores query-string cache-busting, so re-running
|
|
843
|
+
* in-process would never pick up edited step code. A new process re-reads
|
|
844
|
+
* every step/feature file, which is exactly what a watch loop needs.
|
|
845
|
+
*/
|
|
846
|
+
async executeOnce(choice) {
|
|
847
|
+
const selected = resolveScenarios(choice, this.scenarios());
|
|
848
|
+
const count = `${selected.length} scenario${selected.length === 1 ? "" : "s"}`;
|
|
849
|
+
process.stdout.write(`\n${bold(`▶ ${choiceLabel(choice)}`)} ${dim(`(${count} · run #${++this.runCount} · ${clock()})`)}\n\n`);
|
|
850
|
+
if (selected.length === 0) {
|
|
851
|
+
process.stdout.write(yellow(" no scenarios match this selection\n"));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const single = selected.length === 1;
|
|
855
|
+
if (single) await this.analyzeScenario(selected[0]);
|
|
856
|
+
await this.spawnRun(runArgs(choice, selected[0], single, this.config));
|
|
857
|
+
}
|
|
858
|
+
/** Every scenario across the current catalog, flattened. */
|
|
859
|
+
scenarios() {
|
|
860
|
+
return this.catalog.flatMap((f) => f.scenarios);
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Run `step-forge` as a child process with `args`, streaming its output to the
|
|
864
|
+
* terminal (where the erased prompt was). Re-invokes the very CLI that's
|
|
865
|
+
* running us (`process.execPath` + `argv[1]`), so it works identically from
|
|
866
|
+
* the source tree and the built bin. Never rejects — a spawn error is reported
|
|
867
|
+
* and swallowed so the watch loop survives.
|
|
868
|
+
*/
|
|
869
|
+
spawnRun(args) {
|
|
870
|
+
return new Promise((resolve) => {
|
|
871
|
+
const child = (0, node_child_process.spawn)(process.execPath, [process.argv[1], ...args], {
|
|
872
|
+
cwd: this.config.cwd,
|
|
873
|
+
stdio: [
|
|
874
|
+
"ignore",
|
|
875
|
+
"inherit",
|
|
876
|
+
"inherit"
|
|
877
|
+
]
|
|
878
|
+
});
|
|
879
|
+
child.on("error", (err) => {
|
|
880
|
+
process.stdout.write(red(` could not start runner: ${err.message}\n`));
|
|
881
|
+
resolve();
|
|
882
|
+
});
|
|
883
|
+
child.on("close", () => resolve());
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Run the static analyzer over a single scenario and print any dependency /
|
|
888
|
+
* undefined / ambiguous diagnostics. The analyzer needs the optional
|
|
889
|
+
* `typescript` peer for AST extraction; if it's absent we note that and skip,
|
|
890
|
+
* never failing the run.
|
|
891
|
+
*/
|
|
892
|
+
async analyzeScenario(scenario) {
|
|
893
|
+
let analyzer;
|
|
894
|
+
try {
|
|
895
|
+
analyzer = await Promise.resolve().then(() => require("./analyzer-DIZPMxzN.cjs")).then((n) => n.analyzer_exports);
|
|
896
|
+
} catch {
|
|
897
|
+
process.stdout.write(dim(" analysis skipped: install `typescript` to enable it\n\n"));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
const stepFiles = await require_gherkinParser.globFiles(this.config.steps, this.config.cwd);
|
|
902
|
+
const defs = analyzer.extractStepDefinitions(stepFiles);
|
|
903
|
+
const matched = analyzer.matchScenarioSteps(scenario, defs);
|
|
904
|
+
printDiagnostics(analyzer.defaultRules.flatMap((rule) => rule.check(scenario, matched)), this.config.cwd);
|
|
905
|
+
} catch (err) {
|
|
906
|
+
process.stdout.write(dim(` analysis unavailable: ${err instanceof Error ? err.message : err}\n\n`));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
status() {
|
|
910
|
+
if (!this.armed) return " type to filter · ↑↓ move · enter run · ctrl-c quit";
|
|
911
|
+
if (this.dirty) return " ⏸ suspended (query edited) · enter runs the selection · esc cancel";
|
|
912
|
+
return ` ▶ watching ${choiceLabel(this.armed)} · save a file to re-run · enter forces · esc change · ctrl-c quit`;
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
/** Build the typeahead pool: every tag, feature, and scenario in the catalog. */
|
|
916
|
+
function buildSuggestions(catalog) {
|
|
917
|
+
const suggestions = [];
|
|
918
|
+
const tags = /* @__PURE__ */ new Set();
|
|
919
|
+
for (const feature of catalog) for (const scenario of feature.scenarios) for (const tag of scenario.tags) tags.add(tag);
|
|
920
|
+
for (const tag of [...tags].sort()) suggestions.push({
|
|
921
|
+
badge: "#tag",
|
|
922
|
+
label: tag,
|
|
923
|
+
search: tag.toLowerCase(),
|
|
924
|
+
value: {
|
|
925
|
+
kind: "tag",
|
|
926
|
+
tag
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
for (const feature of catalog) {
|
|
930
|
+
const featureLabel = feature.name || node_path.basename(feature.file);
|
|
931
|
+
suggestions.push({
|
|
932
|
+
badge: "feature",
|
|
933
|
+
label: featureLabel,
|
|
934
|
+
search: `${feature.name} ${feature.file}`.toLowerCase(),
|
|
935
|
+
value: {
|
|
936
|
+
kind: "feature",
|
|
937
|
+
file: feature.file,
|
|
938
|
+
name: feature.name
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
for (const scenario of feature.scenarios) {
|
|
942
|
+
const scenarioName = scenario.outline ? `${scenario.outline.name} › ${scenario.name}` : scenario.name;
|
|
943
|
+
suggestions.push({
|
|
944
|
+
badge: "scenario",
|
|
945
|
+
label: scenarioName,
|
|
946
|
+
search: `${featureLabel} ${scenarioName}`.toLowerCase(),
|
|
947
|
+
value: {
|
|
948
|
+
kind: "scenario",
|
|
949
|
+
file: scenario.file,
|
|
950
|
+
name: scenario.name,
|
|
951
|
+
line: scenario.line
|
|
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 && (choice.line === void 0 || s.line === choice.line));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function choiceLabel(choice) {
|
|
967
|
+
switch (choice.kind) {
|
|
968
|
+
case "tag": return choice.tag;
|
|
969
|
+
case "feature": return choice.name || node_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 "/^…$/"`
|
|
981
|
+
* A single scenario runs verbose; a population uses the configured reporter.
|
|
982
|
+
*/
|
|
983
|
+
function runArgs(choice, first, single, config) {
|
|
984
|
+
const args = [];
|
|
985
|
+
for (const glob of config.steps) args.push("-s", glob);
|
|
986
|
+
if (config.world) args.push("-w", config.world);
|
|
987
|
+
args.push("-c", String(config.concurrency));
|
|
988
|
+
switch (choice.kind) {
|
|
989
|
+
case "tag":
|
|
990
|
+
args.push(...config.features, "-t", choice.tag);
|
|
991
|
+
break;
|
|
992
|
+
case "feature":
|
|
993
|
+
args.push(choice.file);
|
|
994
|
+
break;
|
|
995
|
+
case "scenario":
|
|
996
|
+
args.push(choice.file, "-n", `/^${escapeRegExp(first.name)}$/`);
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
if (single) args.push("-v");
|
|
1000
|
+
else {
|
|
1001
|
+
args.push("-r", config.reporter);
|
|
1002
|
+
if (config.verbose) args.push("-v");
|
|
1003
|
+
}
|
|
1004
|
+
return args;
|
|
1005
|
+
}
|
|
1006
|
+
/** Escape a scenario name for use inside an anchored `-n` regex. */
|
|
1007
|
+
function escapeRegExp(text) {
|
|
1008
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1009
|
+
}
|
|
1010
|
+
/** Local wall-clock time as `HH:MM:SS`, so each run's header is dated. */
|
|
1011
|
+
function clock() {
|
|
1012
|
+
const d = /* @__PURE__ */ new Date();
|
|
1013
|
+
return [
|
|
1014
|
+
d.getHours(),
|
|
1015
|
+
d.getMinutes(),
|
|
1016
|
+
d.getSeconds()
|
|
1017
|
+
].map((n) => String(n).padStart(2, "0")).join(":");
|
|
1018
|
+
}
|
|
1019
|
+
function printDiagnostics(diagnostics, cwd) {
|
|
1020
|
+
if (diagnostics.length === 0) return;
|
|
1021
|
+
process.stdout.write(bold(" analyzer:\n"));
|
|
1022
|
+
for (const d of diagnostics) {
|
|
1023
|
+
const mark = d.severity === "error" ? red("✗") : d.severity === "warning" ? yellow("!") : dim("i");
|
|
1024
|
+
const loc = `${node_path.relative(cwd, d.file)}:${d.range.startLine}`;
|
|
1025
|
+
process.stdout.write(` ${mark} ${d.message} ${dim(`(${loc})`)}\n`);
|
|
1026
|
+
}
|
|
1027
|
+
process.stdout.write("\n");
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
379
1030
|
//#region src/runtime/cli.ts
|
|
380
1031
|
const HELP = `step-forge — native TypeScript runner for Gherkin step definitions
|
|
381
1032
|
|
|
@@ -389,6 +1040,9 @@ Options:
|
|
|
389
1040
|
-w, --world <module> World factory module (default export () => world)
|
|
390
1041
|
-c, --concurrency <n> Max scenarios in flight (default: 1, i.e. serial)
|
|
391
1042
|
-r, --reporter <name> "pretty" (default) or "progress"
|
|
1043
|
+
-v, --verbose Report every scenario, not just failures
|
|
1044
|
+
-i, --interactive Interactive watch mode: pick a tag/feature/scenario
|
|
1045
|
+
and re-run it on every file change
|
|
392
1046
|
--config <path> Config file directory (default: cwd)
|
|
393
1047
|
-h, --help Show this help
|
|
394
1048
|
|
|
@@ -425,6 +1079,14 @@ function parseCli(argv) {
|
|
|
425
1079
|
type: "string",
|
|
426
1080
|
short: "r"
|
|
427
1081
|
},
|
|
1082
|
+
verbose: {
|
|
1083
|
+
type: "boolean",
|
|
1084
|
+
short: "v"
|
|
1085
|
+
},
|
|
1086
|
+
interactive: {
|
|
1087
|
+
type: "boolean",
|
|
1088
|
+
short: "i"
|
|
1089
|
+
},
|
|
428
1090
|
config: { type: "string" },
|
|
429
1091
|
help: {
|
|
430
1092
|
type: "boolean",
|
|
@@ -442,6 +1104,7 @@ function parseCli(argv) {
|
|
|
442
1104
|
if (values.name) overrides.name = values.name;
|
|
443
1105
|
if (values.steps) overrides.steps = values.steps;
|
|
444
1106
|
if (values.world) overrides.world = values.world;
|
|
1107
|
+
if (values.verbose) overrides.verbose = true;
|
|
445
1108
|
if (values.reporter) {
|
|
446
1109
|
if (values.reporter !== "pretty" && values.reporter !== "progress") throw new Error(`Unknown reporter: ${values.reporter}`);
|
|
447
1110
|
overrides.reporter = values.reporter;
|
|
@@ -453,12 +1116,18 @@ function parseCli(argv) {
|
|
|
453
1116
|
}
|
|
454
1117
|
return {
|
|
455
1118
|
cwd: values.config ? node_path.resolve(process.cwd(), values.config) : process.cwd(),
|
|
456
|
-
overrides
|
|
1119
|
+
overrides,
|
|
1120
|
+
interactive: values.interactive ?? false
|
|
457
1121
|
};
|
|
458
1122
|
}
|
|
459
1123
|
async function main() {
|
|
460
|
-
const { cwd, overrides } = parseCli(process.argv.slice(2));
|
|
461
|
-
const
|
|
1124
|
+
const { cwd, overrides, interactive } = parseCli(process.argv.slice(2));
|
|
1125
|
+
const config = resolveConfig(cwd, await loadConfigFile(cwd), overrides);
|
|
1126
|
+
if (interactive) {
|
|
1127
|
+
await runInteractive(config);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const { passed } = await run(config);
|
|
462
1131
|
process.exitCode = passed ? 0 : 1;
|
|
463
1132
|
}
|
|
464
1133
|
main().catch((err) => {
|