@g_package/jest-cucumber-fusion 2.0.0 → 3.0.0

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 (71) hide show
  1. package/README.md +444 -38
  2. package/dist/THIRD_PARTY_LICENSES.txt +91 -0
  3. package/dist/index.cjs +9917 -0
  4. package/dist/index.d.cts +145 -0
  5. package/package.json +60 -10
  6. package/scripts/prepare-hooks.js +23 -0
  7. package/src/code-suggestion.js +240 -0
  8. package/src/configuration.js +147 -0
  9. package/src/feature-source.js +322 -0
  10. package/src/index.d.ts +123 -13
  11. package/src/index.js +153 -420
  12. package/src/keywords.js +56 -0
  13. package/src/scenario-name.js +128 -0
  14. package/src/shared-state.js +36 -0
  15. package/src/step-argument.js +56 -0
  16. package/src/step-matching.js +89 -0
  17. package/src/tag-filter.js +90 -0
  18. package/src/test-registration.js +178 -0
  19. package/src/value-description.js +26 -0
  20. package/.prettierignore +0 -16
  21. package/.prettierrc.json +0 -0
  22. package/codecov +0 -0
  23. package/codecov.SHA256SUM +0 -1
  24. package/codecov.SHA256SUM.sig +0 -16
  25. package/docs/AdditionalConfiguration.md +0 -155
  26. package/docs/GherkinTables.md +0 -61
  27. package/docs/Language.md +0 -76
  28. package/docs/ReusingStepDefinitions.md +0 -110
  29. package/docs/RunningTheExamples.md +0 -16
  30. package/docs/ScenarioOutlines.md +0 -43
  31. package/docs/StepDefinitionArguments.md +0 -37
  32. package/docs/product/expectations/fix-l2-outline-regex/outline-regex-binds.md +0 -148
  33. package/docs/product/expectations/fix-l4-escaped-parens-outline/escaped-parens-bind-in-outlines.md +0 -106
  34. package/docs/product/expectations/fix-m3-singleton-reset/clean-slate-per-feature.md +0 -133
  35. package/test/specs/features/basic-scenarios.feature +0 -28
  36. package/test/specs/features/l3-step-argument-delivery.feature +0 -44
  37. package/test/specs/features/language.feature +0 -41
  38. package/test/specs/features/m6-hooks-once-per-test.feature +0 -21
  39. package/test/specs/features/m6-hooks-outline-only.feature +0 -12
  40. package/test/specs/features/reuse-definition.feature +0 -13
  41. package/test/specs/features/scenario-outline2.feature +0 -73
  42. package/test/specs/features/scenario-outlines.feature +0 -88
  43. package/test/specs/features/step-definitions/ambiguous-step-shadowing.steps.js +0 -92
  44. package/test/specs/features/step-definitions/basic-scenarios.steps.js +0 -62
  45. package/test/specs/features/step-definitions/fuzz-properties.steps.js +0 -170
  46. package/test/specs/features/step-definitions/hook-error.steps.js +0 -59
  47. package/test/specs/features/step-definitions/l2-outline-edge-cases.steps.js +0 -327
  48. package/test/specs/features/step-definitions/l3-step-argument-delivery.steps.js +0 -191
  49. package/test/specs/features/step-definitions/l4-escaped-parens-outline.steps.js +0 -256
  50. package/test/specs/features/step-definitions/language.steps.js +0 -86
  51. package/test/specs/features/step-definitions/m1-before-hooks-clobber.steps.js +0 -64
  52. package/test/specs/features/step-definitions/m2-duplicate-matcher.steps.js +0 -60
  53. package/test/specs/features/step-definitions/m3-singleton-reset.steps.js +0 -275
  54. package/test/specs/features/step-definitions/m4-callsite-resolution.steps.js +0 -76
  55. package/test/specs/features/step-definitions/m5-errors-false-silent-skip.steps.js +0 -80
  56. package/test/specs/features/step-definitions/m6-hooks-once-per-test.steps.js +0 -90
  57. package/test/specs/features/step-definitions/missing-feature-file.steps.js +0 -23
  58. package/test/specs/features/step-definitions/reuse-code.js +0 -22
  59. package/test/specs/features/step-definitions/reuse-definition.steps.js +0 -30
  60. package/test/specs/features/step-definitions/scenario-outline2.steps.js +0 -57
  61. package/test/specs/features/step-definitions/scenario-outlines.steps.js +0 -110
  62. package/test/specs/features/step-definitions/undefined-step.steps.js +0 -39
  63. package/test/specs/features/step-definitions/using-dynamic-values.steps.js +0 -70
  64. package/test/specs/features/step-definitions/using-gherkin-tables.steps.js +0 -42
  65. package/test/specs/features/undefined-step.feature +0 -4
  66. package/test/specs/features/using-dynamic-values.feature +0 -34
  67. package/test/specs/features/using-gherkin-tables.feature +0 -16
  68. package/test/src/bank-account.js +0 -17
  69. package/test/src/online-sales.js +0 -33
  70. package/test/src/rocket.js +0 -13
  71. package/test/src/todo-list.js +0 -20
@@ -0,0 +1,56 @@
1
+ // A Gherkin step keyword, in whatever dialect the feature file declared, resolved onto the
2
+ // one registry bucket that owns it.
3
+ //
4
+ // WHY THIS MODULE EXISTS. Compiled pickles throw the keyword away: a PickleStep carries only
5
+ // `type: "Context" | "Action" | "Outcome"`, and an `And` step arrives as Context. The registry
6
+ // is keyed by keyword, so an `And` definition must bind only `And` steps, which means the
7
+ // keyword has to come back from the parsed AST and then be mapped through the dialect that
8
+ // spelled it. This module owns that map and nothing else; it is pure, and it never sees a
9
+ // pickle, a file or a parser.
10
+
11
+ // The bucket order IS the resolution order. A dialect that spells one word in two lists
12
+ // (several do) therefore still yields exactly one bucket, deterministically.
13
+ const BUCKETS = ["given", "when", "then", "and", "but"];
14
+
15
+ // Legal in every dialect and in none of the five buckets. It is listed in all five dialect
16
+ // arrays, so a plain lookup would silently answer "given"; refusing by name is the only
17
+ // honest answer, and it is a refusal rather than a guess.
18
+ const UNBUCKETED_KEYWORD = "*";
19
+
20
+ const asComparable = (keyword) => String(keyword).trim();
21
+
22
+ const refuseUnsupportedKeyword = (keyword, language) => {
23
+ throw new Error(
24
+ `Unsupported step keyword: "${asComparable(keyword)}"` +
25
+ (language ? ` in the "${language}" Gherkin dialect` : "") +
26
+ `. Fusion binds step definitions by keyword, and only ${BUCKETS.join(
27
+ ", ",
28
+ )} have a registry to bind in. Rewrite the step with one of those keywords.`,
29
+ );
30
+ };
31
+
32
+ // Returns one of the five buckets, or throws. There is no third answer, which is what keeps
33
+ // an invalid keyword from ever reaching the registry lookup: the bare TypeError an asterisk
34
+ // step used to raise there came from exactly that gap.
35
+ const bucketForKeyword = (keyword, dialect, language) => {
36
+ const comparable = asComparable(keyword);
37
+
38
+ if (comparable === UNBUCKETED_KEYWORD || comparable === "")
39
+ refuseUnsupportedKeyword(keyword, language);
40
+
41
+ const bucket = BUCKETS.find((candidate) =>
42
+ (dialect[candidate] || [])
43
+ .map(asComparable)
44
+ .some(
45
+ (dialectKeyword) =>
46
+ dialectKeyword !== UNBUCKETED_KEYWORD &&
47
+ dialectKeyword === comparable,
48
+ ),
49
+ );
50
+
51
+ if (!bucket) refuseUnsupportedKeyword(keyword, language);
52
+
53
+ return bucket;
54
+ };
55
+
56
+ export { bucketForKeyword };
@@ -0,0 +1,128 @@
1
+ // What one test is called.
2
+ //
3
+ // With no scenarioNameTemplate the answer is the scenario's own title and nothing here is
4
+ // entered. With a template, this module assembles the four variables the template is declared
5
+ // to receive, calls it, and checks that what came back can actually name a test.
6
+ //
7
+ // The consumer's template is UNTRUSTED: it may throw, and it may answer with anything at all.
8
+ // Both are refusals rather than something to paper over, because there is no honest name to
9
+ // fall back on: naming one test from the template and the next from the raw title would leave
10
+ // a report that looks complete and cannot be compared with the next run.
11
+ //
12
+ // Pure: no filesystem, no parser, no Jest global. The port calls it; it calls the template.
13
+
14
+ import { describeValue } from "./value-description.js";
15
+
16
+ // Kept as the first line of the refusal because the previous engine used it, so anything a
17
+ // consumer greps for still matches.
18
+ const TEMPLATE_FAILED =
19
+ "An error occurred while executing a scenario name template";
20
+
21
+ // Tags reach a template lowercased and with their leading at sign, which is what the previous
22
+ // engine passed: it built its lists from AST tag names, and a Gherkin AST tag name carries the
23
+ // at sign. The value 3 carrier deliberately keeps the original case, so the lowercasing
24
+ // happens here, where the variables are assembled.
25
+ const asTemplateTags = (tagNames) =>
26
+ (tagNames || []).map((tagName) => String(tagName).toLowerCase());
27
+
28
+ // The four variables, and only those four: a consumer's existing template was written against
29
+ // this shape.
30
+ //
31
+ // scenarioTitle is the title of THIS test. For an Examples row that is the row's own
32
+ // substituted title, which is the whole point of this value: the previous engine called the
33
+ // template with the outline's un-substituted title and then registered the row under its own
34
+ // name anyway, so the option was silently inert on every row.
35
+ //
36
+ // scenarioTags is the tag set that reached this scenario with the feature's declared tags
37
+ // removed, which reproduces both of the previous engine's lists from the one source this
38
+ // package has. Named residue: a tag written on BOTH the feature and a scenario appears only in
39
+ // featureTags, where the previous engine would have had it in both.
40
+ const templateVariablesFor = (loadedFeature, scenario) => {
41
+ const featureTags = asTemplateTags(loadedFeature.featureTags);
42
+ const tagsThatReachedTheScenario = asTemplateTags(scenario.tags);
43
+
44
+ return {
45
+ featureTitle: loadedFeature.title,
46
+ featureTags,
47
+ scenarioTitle: scenario.title,
48
+ scenarioTags: tagsThatReachedTheScenario.filter(
49
+ (tagName) => !featureTags.includes(tagName),
50
+ ),
51
+ };
52
+ };
53
+
54
+ const describeWhatCameBack = (answer) =>
55
+ typeof answer === "string" ? `the empty string` : describeValue(answer);
56
+
57
+ const refuseUnusableName = (scenarioTitle, what, how) =>
58
+ new Error(
59
+ `${TEMPLATE_FAILED}.\n\n` +
60
+ `WHAT: ${what}\n` +
61
+ ` It was naming the scenario "${scenarioTitle}".\n` +
62
+ `WHY: every test needs a name before it can be registered, and a template that cannot\n` +
63
+ ` produce one leaves no honest name to fall back on. Naming this test from its raw\n` +
64
+ ` title while its siblings keep their templated names would give a report that\n` +
65
+ ` looks complete and cannot be compared with the next run.\n` +
66
+ `HOW: ${how}`,
67
+ );
68
+
69
+ // Calling the untrusted template, and nothing else. Whatever it answers comes back as it is;
70
+ // only a throw is turned into the refusal here, because that is the one outcome this function
71
+ // cannot hand on.
72
+ const answerFromTemplate = (loadedFeature, scenario, scenarioNameTemplate) => {
73
+ try {
74
+ return scenarioNameTemplate(templateVariablesFor(loadedFeature, scenario));
75
+ } catch (templateFailure) {
76
+ throw refuseUnusableName(
77
+ scenario.title,
78
+ `the scenarioNameTemplate threw: ${
79
+ templateFailure && templateFailure.message
80
+ ? templateFailure.message
81
+ : templateFailure
82
+ }`,
83
+ "make the template total over the four variables it is handed: featureTitle, " +
84
+ "featureTags, scenarioTitle and scenarioTags. A tag list may be empty, and a title " +
85
+ "may hold any character a feature file allows.",
86
+ );
87
+ }
88
+ };
89
+
90
+ const nameForScenario = (loadedFeature, scenario, scenarioNameTemplate) => {
91
+ if (!scenarioNameTemplate) return scenario.title;
92
+
93
+ // Named for what it is. Called anyway, a non-function was reported as a template that threw
94
+ // "scenarioNameTemplate is not a function" (fresh fuzz of PR #16, 2026-10-10).
95
+ if (typeof scenarioNameTemplate !== "function")
96
+ throw refuseUnusableName(
97
+ scenario.title,
98
+ `the scenarioNameTemplate is ${describeValue(scenarioNameTemplate)}, not a function.`,
99
+ "pass a function that takes the template variables and returns the test's name.",
100
+ );
101
+
102
+ const answer = answerFromTemplate(
103
+ loadedFeature,
104
+ scenario,
105
+ scenarioNameTemplate,
106
+ );
107
+
108
+ // An async template's Promise was described as "object {}", its JSON (fresh fuzz of PR #16).
109
+ if (answer && typeof answer.then === "function")
110
+ throw refuseUnusableName(
111
+ scenario.title,
112
+ "the scenarioNameTemplate returned a Promise.",
113
+ "return the name directly. The template runs while Fusion registers the tests, before " +
114
+ "any of them runs, so it cannot wait for anything.",
115
+ );
116
+
117
+ if (typeof answer !== "string" || answer.length === 0)
118
+ throw refuseUnusableName(
119
+ scenario.title,
120
+ `the scenarioNameTemplate returned ${describeWhatCameBack(answer)}.`,
121
+ "return a non-empty string. A missing return answers undefined, which produces a test " +
122
+ "that cannot be reported or selected by name.",
123
+ );
124
+
125
+ return answer;
126
+ };
127
+
128
+ export { nameForScenario };
@@ -0,0 +1,36 @@
1
+ // The state every copy of Fusion in one test file must share: the step and hook registry, and
2
+ // the global configuration.
3
+ //
4
+ // The package is dual. A file that require()s it loads dist/index.cjs; a file that imports it
5
+ // loads src/. Those are two copies, each with its own module variables, so state kept in a
6
+ // module variable splits between them: a shared step library written as CommonJS registered
7
+ // steps an ES module step file's Fusion() call could not see, and a global set by a require()ing
8
+ // setup script never reached importing steps (both measured with packed builds, and held by
9
+ // test/specs/baseline/assert-packaged-consumer.js).
10
+ //
11
+ // So the state lives on globalThis, under one key. Jest gives each test file its own global, so
12
+ // it stays per file exactly as a module variable did. The key names the major version: every
13
+ // 3.x copy shares it, and a future major whose state has another shape cannot read this one.
14
+ //
15
+ // A value is created only when absent. A copy that loads second must not reset what the first
16
+ // copy has already registered.
17
+ const STORE = Symbol.for("@g_package/jest-cucumber-fusion@3");
18
+
19
+ const store = () => {
20
+ if (!globalThis[STORE]) globalThis[STORE] = {};
21
+ return globalThis[STORE];
22
+ };
23
+
24
+ // The shared value under `name`, created by `initialValue()` the first time it is asked for.
25
+ const shared = (name, initialValue) => {
26
+ const state = store();
27
+ if (!Object.prototype.hasOwnProperty.call(state, name))
28
+ state[name] = initialValue();
29
+ return state[name];
30
+ };
31
+
32
+ const replaceShared = (name, value) => {
33
+ store()[name] = value;
34
+ };
35
+
36
+ export { shared, replaceShared };
@@ -0,0 +1,56 @@
1
+ // A pickle step's Gherkin argument, turned into the shape a consumer's step function receives.
2
+ //
3
+ // A pickle hands back a dataTable of rows of cells, or a docString with content. A step
4
+ // function receives an array of header-keyed row objects, or a plain string. That transform
5
+ // belonged to the removed intermediary (parsed-feature-loading.js:33-63) and is owned here.
6
+ //
7
+ // Two values have to survive, because a consumer wrote them down on purpose: the EMPTY string
8
+ // from an empty docstring, and the EMPTY array from a header-only table. Both are falsy, so
9
+ // every test here is for PRESENCE, never truthiness, never `.length`.
10
+
11
+ // `null` means "this step carries no argument", which is a different fact from an empty one.
12
+ const NO_STEP_ARGUMENT = null;
13
+
14
+ const cellValues = (row) => (row.cells || []).map((cell) => cell.value);
15
+
16
+ // Header row names the keys; every later row becomes one object under those keys. A
17
+ // header-only table is an empty array of rows, not an absent argument.
18
+ const asRowObjects = (dataTable) => {
19
+ const [header, ...bodyRows] = dataTable.rows || [];
20
+ if (!header) return [];
21
+
22
+ const columnNames = cellValues(header);
23
+
24
+ // defineProperty, not assignment: a column headed `__proto__` would otherwise call the
25
+ // prototype setter and vanish from the row (fresh fuzz of PR #16, 2026-10-10, B1). The row
26
+ // stays a plain object, so a consumer's toStrictEqual against a literal still holds.
27
+ return bodyRows.map((row) =>
28
+ cellValues(row).reduce(
29
+ (rowObject, value, columnIndex) =>
30
+ Object.defineProperty(rowObject, columnNames[columnIndex], {
31
+ value,
32
+ enumerable: true,
33
+ writable: true,
34
+ configurable: true,
35
+ }),
36
+ {},
37
+ ),
38
+ );
39
+ };
40
+
41
+ const stepArgumentFrom = (pickleStepArgument) => {
42
+ if (pickleStepArgument == null) return NO_STEP_ARGUMENT;
43
+
44
+ if (pickleStepArgument.dataTable)
45
+ return asRowObjects(pickleStepArgument.dataTable);
46
+
47
+ // `content` is read by presence: an empty docstring is "" and must reach the step.
48
+ if (pickleStepArgument.docString)
49
+ return pickleStepArgument.docString.content == null
50
+ ? NO_STEP_ARGUMENT
51
+ : pickleStepArgument.docString.content;
52
+
53
+ return NO_STEP_ARGUMENT;
54
+ };
55
+
56
+ export { stepArgumentFrom };
@@ -0,0 +1,89 @@
1
+ // Which registered definition owns a step, and what that definition is called with.
2
+ //
3
+ // The step text arriving here is CONCRETE: a Scenario Outline row reaches this module with
4
+ // its Examples values already substituted, so an outline step is bound by ordinary matching.
5
+ // The heuristic that used to guess whether a definition could own a step whose text still
6
+ // held `<placeholders>` is gone with the need for it, and with it went the two defects that
7
+ // lived in it.
8
+ //
9
+ // Every answer is a bound step, an UNBOUND step, or a refusal.
10
+ //
11
+ // Unbound is a returned result rather than a throw, and that carrier is the whole of what this
12
+ // module decides. A throw cannot express "unbound, and tolerated", which is precisely what a
13
+ // consumer asks for by switching the step check off, and a throw on the first unbound step
14
+ // can never reach the caller that holds the option, nor let it see the other unbound steps.
15
+ // So the lookup reports, and the caller holding the options decides.
16
+ //
17
+ // Ambiguity stays a refusal from here: it is not switchable, because there is no reading of
18
+ // two matching definitions under which Fusion could pick one.
19
+ const UNBOUND = Object.freeze({ isBound: false });
20
+
21
+ const findMatchingStep = (featureRegistry, currentStep) => {
22
+ const bucket = currentStep.keyword;
23
+ const stepText = currentStep.stepText;
24
+ const matchingSteps = Object.keys(featureRegistry[bucket]).filter(
25
+ (matcherKey) => {
26
+ return isFunctionForScenario(
27
+ stepText,
28
+ featureRegistry[bucket][matcherKey],
29
+ );
30
+ },
31
+ );
32
+ if (matchingSteps.length === 0) return UNBOUND;
33
+
34
+ if (matchingSteps.length > 1) {
35
+ const competingMatchers = matchingSteps
36
+ .map((matcherSource) => `"${matcherSource}"`)
37
+ .join(", ");
38
+ throw new Error(
39
+ `Ambiguous step definition: "${stepText}" matches ${matchingSteps.length} step definitions: ${competingMatchers}`,
40
+ );
41
+ }
42
+
43
+ // The winning definition is resolved here, where its key was just filtered, and handed over
44
+ // resolved. Passing the registry, the bucket and the key instead would be asking the next
45
+ // function to repeat a lookup this one has already done.
46
+ return boundStepFor(
47
+ featureRegistry[bucket][matchingSteps[0]],
48
+ stepText,
49
+ currentStep.stepArgument,
50
+ );
51
+ };
52
+
53
+ // A matcher with the global (g) or sticky (y) flag carries a `lastIndex` from one use to the
54
+ // next, and a sticky one starts matching there. Testing a step and then reading its captures
55
+ // each moved it, so a sticky matcher bound its step with no captures (finding F5 of the PR #16
56
+ // review). Every use starts from the beginning of the step text instead, and leaves lastIndex
57
+ // at 0 again: the consumer owns the RegExp and may use it elsewhere.
58
+ const execFromStart = (stepRegExp, stepText) => {
59
+ stepRegExp.lastIndex = 0;
60
+ const match = stepRegExp.exec(stepText);
61
+ stepRegExp.lastIndex = 0;
62
+ return match;
63
+ };
64
+
65
+ const isFunctionForScenario = (stepText, stepDefinition) => {
66
+ if (stepDefinition.stepRegExp)
67
+ return execFromStart(stepDefinition.stepRegExp, stepText) !== null;
68
+
69
+ return stepText === stepDefinition.stepExpression;
70
+ };
71
+
72
+ const boundStepFor = (stepDefinition, stepText, stepArgs) => {
73
+ // A string matcher captures nothing; a regex matcher captures its groups out of the
74
+ // concrete step text. Both may still carry a Gherkin argument.
75
+ const captures = stepDefinition.stepRegExp
76
+ ? (execFromStart(stepDefinition.stepRegExp, stepText) || []).slice(1)
77
+ : [];
78
+
79
+ // Forward the step's Gherkin argument on PRESENCE, never on its type. A type test drops
80
+ // half the shapes: Gherkin parses a data table to an array but a docstring to a string,
81
+ // and an empty docstring to "", which a `.length` test would drop too. Absent, the
82
+ // argument is null, so this is the whole distinction that matters.
83
+ const stepArguments =
84
+ stepArgs == null ? captures : captures.concat([stepArgs]);
85
+
86
+ return { isBound: true, stepArguments, stepFn: stepDefinition.stepFn };
87
+ };
88
+
89
+ export { findMatchingStep };
@@ -0,0 +1,90 @@
1
+ // Whether a scenario's tags satisfy a tagFilter expression.
2
+ //
3
+ // This module owns the NORMALISATION and the predicate. It does not own the expression
4
+ // language: the parse function arrives as an argument from src/feature-source.js, which is the
5
+ // one module the architectural law lets reach the cucumber scope
6
+ // (test/specs/arch/dependency-direction.steps.js). That injection is why this module requires
7
+ // nothing at all, and so can be read and reasoned about without a parser, a file or a runner.
8
+
9
+ // Both sides are lowercased before they meet, which is the whole of the normalisation and the
10
+ // reason matching ignores case. Lowercasing the expression lowercases its OPERATORS too, so an
11
+ // expression written `@a AND @b` parses here where the previous engine's case-sensitive
12
+ // rewriter rejected it. That widening is strict: every expression that worked before still
13
+ // works.
14
+ const lowercased = (text) => String(text).toLowerCase();
15
+
16
+ const refusal = (expression, why) =>
17
+ new Error(
18
+ `Could not parse tag filter "${expression}".\n\n` +
19
+ `WHY: ${why}\n` +
20
+ `HOW: write a tag name as @name, and combine names with the operators "and", "or" and\n` +
21
+ ` "not", grouping with parentheses "(" and ")". For example:\n` +
22
+ ` @smoke and not @slow\n` +
23
+ ` @shop and (@included or @draft)`,
24
+ );
25
+
26
+ const refuseUnparseableExpression = (expression, parseFailure) =>
27
+ refusal(
28
+ expression,
29
+ `the tag expression parser could not read it: ${
30
+ parseFailure && parseFailure.message ? parseFailure.message : parseFailure
31
+ }\n` +
32
+ ` The expression is lowercased before it is parsed, because tag matching ignores\n` +
33
+ ` case, so the text quoted above may differ in case from what you wrote.`,
34
+ );
35
+
36
+ const refuseUntaggedOperand = (expression, operand) =>
37
+ refusal(
38
+ expression,
39
+ `"${operand}" is not a tag. Every tag Fusion reads from a feature file starts with "@",\n` +
40
+ ` so "${operand}" could never match, and the filter would quietly select no\n` +
41
+ ` scenario. Did you mean "@${operand}"?`,
42
+ );
43
+
44
+ // Every operand of a parsed expression, read off its tree: an operand node carries its text as
45
+ // `value`, and every other node holds its operands in its own properties.
46
+ const operandsOf = (node) =>
47
+ typeof node.value === "string"
48
+ ? [node.value]
49
+ : Object.values(node)
50
+ .filter((child) => child !== null && typeof child === "object")
51
+ .flatMap(operandsOf);
52
+
53
+ // The parser saw the lowercased expression; name the operand as the consumer wrote it, matched
54
+ // as a WHOLE token of their text, and as parsed when no token matches (an escaped character,
55
+ // for one). A substring search would find `smoke` inside `@Smoke`, and an offset taken from the
56
+ // lowercased text drifts wherever lowercasing changes a length ("İ" becomes two code units).
57
+ const asWritten = (expression, operand) =>
58
+ String(expression)
59
+ .split(/[\s()]+/)
60
+ .find((token) => lowercased(token) === operand) || operand;
61
+
62
+ // Returns a predicate over a list of tag names, or raises the refusal if the expression cannot
63
+ // be read. Raising rather than answering false for everything is deliberate: an unparseable
64
+ // expression that quietly selected no scenario would exit 0 having run nothing, which is
65
+ // exactly what a correct filter selecting no scenario looks like.
66
+ //
67
+ // The parser accepts a bare word such as `smoke` as an operand, but no tag Gherkin hands Fusion
68
+ // lacks its "@", so such an operand can never match and leads to the same silent outcome. It is
69
+ // refused with the same first line (finding F1 of the PR #16 review). 2.0.0 refused a lone bare
70
+ // word the same way, but let a bare operand inside a longer expression through, or failed on it
71
+ // with a ReferenceError.
72
+ const tagFilterFor = (expression, parseExpression) => {
73
+ let parsed;
74
+
75
+ try {
76
+ parsed = parseExpression(lowercased(expression));
77
+ } catch (parseFailure) {
78
+ throw refuseUnparseableExpression(expression, parseFailure);
79
+ }
80
+
81
+ const untagged = operandsOf(parsed).find(
82
+ (operand) => !operand.startsWith("@"),
83
+ );
84
+ if (untagged !== undefined)
85
+ throw refuseUntaggedOperand(expression, asWritten(expression, untagged));
86
+
87
+ return (tagNames) => parsed.evaluate((tagNames || []).map(lowercased));
88
+ };
89
+
90
+ export { tagFilterFor };
@@ -0,0 +1,178 @@
1
+ // Driven port: the Jest runner.
2
+ //
3
+ // This is the ONLY module under src/ allowed to name describe, test, beforeEach or afterEach,
4
+ // and that is an architectural law rather than a convention (see
5
+ // test/specs/arch/dependency-direction.steps.js). It takes a LoadedFeature and the registry
6
+ // that feature's definitions were registered in, and from them it owns four things the
7
+ // removed intermediary used to own: one describe per feature, the hook wiring, one test per
8
+ // scenario, and running the steps of a test in order.
9
+
10
+ import { findMatchingStep } from "./step-matching.js";
11
+ import { unmatchedStepRefusal } from "./code-suggestion.js";
12
+ import { nameForScenario } from "./scenario-name.js";
13
+
14
+ // BIND BEFORE REGISTERING. Every step of every scenario is matched up front, so a refusal
15
+ // (an unmatched step, an ambiguous one, an unsupported keyword) leaves Fusion() on the way
16
+ // out, synchronously, at collection time. Inside a describe body it would be reported as a
17
+ // broken suite instead, and a consumer who wrapped Fusion() in a try/catch would never see
18
+ // it (test/specs/features/step-definitions/undefined-step.steps.js drives exactly that).
19
+ //
20
+ // Binding up front is also what lets ONE refusal name every unbound step of the feature: the
21
+ // lookup reports an unbound step rather than throwing on it, so this pass sees all of them.
22
+ const boundScenarios = (loadedFeature, featureRegistry) =>
23
+ loadedFeature.scenarios.map((scenario) => {
24
+ const steps = scenario.steps.map((step) => ({
25
+ step,
26
+ bound: findMatchingStep(featureRegistry, step),
27
+ }));
28
+
29
+ return {
30
+ scenario,
31
+ // Read by truthiness and carried through untouched: a LoadedFeature staged without it,
32
+ // as a test double's fabricated one is, reads as "not excluded".
33
+ excludedByTagFilter: !!scenario.excludedByTagFilter,
34
+ steps,
35
+ unboundSteps: steps
36
+ .filter((each) => !each.bound.isBound)
37
+ .map((each) => each.step),
38
+ };
39
+ });
40
+
41
+ // NAME BEFORE REGISTERING, for the same reason as binding. A template may throw on the third
42
+ // scenario after naming the first two, and a refusal that arrived mid-describe would leave
43
+ // those two registered: a report that looks complete and cannot be compared with the next run.
44
+ // Asking for every name first makes it one refusal with nothing registered.
45
+ //
46
+ // One call per test, and the name is kept. Both registration routes below take it from here,
47
+ // so a scenario's name never depends on whether it ran, and nothing re-derives it in a test
48
+ // body: an expensive template is paid for once, and an impure one cannot disagree with the
49
+ // name its test was registered under.
50
+ const namedScenarios = (loadedFeature, scenarios, scenarioNameTemplate) =>
51
+ scenarios.map((scenario) =>
52
+ Object.assign({}, scenario, {
53
+ name: nameForScenario(
54
+ loadedFeature,
55
+ scenario.scenario,
56
+ scenarioNameTemplate,
57
+ ),
58
+ }),
59
+ );
60
+
61
+ // The failing-step decoration, byte for byte as it has always read: the step's text in double
62
+ // quotes, the JSON of the arguments it was called with, then the original message, each on
63
+ // its own line with a blank line between. Every consumer failure output, and anything that
64
+ // greps it, is this shape.
65
+ //
66
+ // An Error is decorated IN PLACE and rethrown, as jest-cucumber did: its class, its stack
67
+ // frames in the consumer's steps file (Jest's code frame) and properties such as expect's
68
+ // matcherResult all survive. A new Error lost every one of them (fresh review of PR #16,
69
+ // 2026-10-10, finding B1). The stack's first line repeats the message, so it is rewritten to
70
+ // match. Recognised by its tag rather than instanceof, which fails for an error made in another
71
+ // realm (Node's own, from inside Jest's test context). Anything that cannot be rewritten (a
72
+ // frozen error) is wrapped with the original as its cause; anything else thrown is wrapped.
73
+ const isError = (value) =>
74
+ Object.prototype.toString.call(value) === "[object Error]";
75
+
76
+ const decorationOf = (stepText, stepArguments, originalMessage) =>
77
+ `Failing step: "${stepText}"\n\n` +
78
+ `Step arguments: ${JSON.stringify(stepArguments)}\n\n` +
79
+ `Error: ${originalMessage}`;
80
+
81
+ const decorateInPlace = (failure, decorated) => {
82
+ const oldHeader = `${failure.name}: ${failure.message}`;
83
+ const { stack } = failure;
84
+ failure.message = decorated;
85
+ if (typeof stack === "string" && stack.startsWith(oldHeader))
86
+ failure.stack = `${failure.name}: ${decorated}${stack.slice(oldHeader.length)}`;
87
+ return failure;
88
+ };
89
+
90
+ const decorate = (stepText, stepArguments, failure) => {
91
+ if (!isError(failure))
92
+ return new Error(
93
+ decorationOf(
94
+ stepText,
95
+ stepArguments,
96
+ failure && failure.message ? failure.message : failure,
97
+ ),
98
+ );
99
+ const decorated = decorationOf(stepText, stepArguments, failure.message);
100
+ try {
101
+ return decorateInPlace(failure, decorated);
102
+ } catch {
103
+ return new Error(decorated, { cause: failure });
104
+ }
105
+ };
106
+
107
+ // One test body: the scenario's steps, in order, each awaited before the next begins. A step
108
+ // that throws or whose promise rejects ends the scenario there: running on would report a
109
+ // second, invented failure and hide the first.
110
+ const runScenario = (scenario) => async () => {
111
+ for (const step of scenario.steps) {
112
+ try {
113
+ await step.bound.stepFn(...step.bound.stepArguments);
114
+ } catch (failure) {
115
+ throw decorate(step.step.stepText, step.bound.stepArguments, failure);
116
+ }
117
+ }
118
+ };
119
+
120
+ // Each hook registered exactly ONCE per feature. The describe below already wraps every test
121
+ // of the feature, so one registration runs the hook around each of them; N registrations
122
+ // would run every hook N times per test.
123
+ const registerHooks = (featureRegistry, beforeEachFn, afterEachFn) => {
124
+ featureRegistry.before.forEach((beforeHook) => beforeEachFn(beforeHook));
125
+ featureRegistry.after.forEach((afterHook) => afterEachFn(afterHook));
126
+ };
127
+
128
+ const registerFeature = (loadedFeature, featureRegistry, options) => {
129
+ const scenarios = boundScenarios(loadedFeature, featureRegistry);
130
+
131
+ // THE errors DECISION, taken here because this is the only module from which either outcome
132
+ // is reachable. With the step check on, one refusal names every unbound step of the feature
133
+ // and nothing is registered. With it off, the scenarios holding them become skipped tests,
134
+ // never passing ones, and never absent ones. Validation can be switched off; it is never
135
+ // switched into silence.
136
+ //
137
+ // Only the scenarios the tag filter KEPT are counted. A consumer who excluded a scenario is
138
+ // not asking for its steps to be bound, and excluding a half-written scenario is one of the
139
+ // main reasons to reach for a filter at all.
140
+ const unboundSteps = scenarios
141
+ .filter((scenario) => !scenario.excludedByTagFilter)
142
+ .flatMap((scenario) => scenario.unboundSteps);
143
+
144
+ if (unboundSteps.length > 0 && options.errors.stepsMustMatchFeatureFile)
145
+ throw unmatchedStepRefusal(loadedFeature.title, unboundSteps);
146
+
147
+ // Every name, before anything is registered. With no template this is each scenario's own
148
+ // title; with one it is what the template answered, and a template that cannot answer
149
+ // refuses here.
150
+ const named = namedScenarios(
151
+ loadedFeature,
152
+ scenarios,
153
+ options.scenarioNameTemplate,
154
+ );
155
+
156
+ // A feature with no scenarios registers no describe at all, and therefore no hooks: an
157
+ // empty describe is a reported suite that observes nothing, and hooks inside it would wrap
158
+ // nothing.
159
+ if (named.length === 0) return;
160
+
161
+ describe(loadedFeature.title, () => {
162
+ registerHooks(featureRegistry, beforeEach, afterEach);
163
+
164
+ named.forEach((scenario) => {
165
+ // ONE skip route, for either reason, and ONE name whichever route is taken. A scenario
166
+ // the filter excluded and a scenario left unwired are both registered as a skipped test
167
+ // under the name they would have carried had they run, and a scenario that is both is
168
+ // registered ONCE: two registrations would put two tests of one name in the report.
169
+ // Everything else runs: switching a check off, or filtering, costs the consumer only the
170
+ // scenarios it actually names.
171
+ if (scenario.excludedByTagFilter || scenario.unboundSteps.length > 0)
172
+ test.skip(scenario.name, () => {});
173
+ else test(scenario.name, runScenario(scenario));
174
+ });
175
+ });
176
+ };
177
+
178
+ export { registerFeature };
@@ -0,0 +1,26 @@
1
+ // How a refusal names a value the consumer handed Fusion: `<type> <value>`.
2
+ //
3
+ // JSON first, because that is the form the refusals have always printed (`string "x"`,
4
+ // `object ["@smoke"]`). JSON.stringify throws on a BigInt and on a circular object, and answers
5
+ // undefined for a function or a symbol, so those fall back to util.inspect: a refusal must never
6
+ // crash while naming what it refuses (finding F8 of the PR #16 review).
7
+ import { inspect } from "util";
8
+
9
+ const asText = (value) => {
10
+ try {
11
+ const json = JSON.stringify(value);
12
+ if (json !== undefined) return json;
13
+ } catch {
14
+ // Not serialisable as JSON; described below.
15
+ }
16
+ try {
17
+ return inspect(value);
18
+ } catch {
19
+ // A custom inspect hook that throws: say so rather than let the refusal crash.
20
+ return "(a value that cannot be printed)";
21
+ }
22
+ };
23
+
24
+ const describeValue = (value) => `${typeof value} ${asText(value)}`;
25
+
26
+ export { describeValue };