@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
package/src/index.js CHANGED
@@ -1,34 +1,114 @@
1
+ // The public surface: the step verbs, the hooks, the registry they write into, and Fusion.
2
+ //
3
+ // Everything Fusion does beyond holding the registry is delegated: the option merge to
4
+ // src/configuration.js, the outside world (the feature file, the parser, the caller stack) to
5
+ // src/feature-source.js, and the Jest runner to src/test-registration.js. Both ports are
6
+ // required by module path and are never exported, so they stay internal.
7
+ import { inspect } from "util";
8
+ import { mergeFusionOptions, setFusionConfiguration } from "./configuration.js";
9
+ import * as featureSource from "./feature-source.js";
10
+ import * as testRegistration from "./test-registration.js";
11
+ import { shared, replaceShared } from "./shared-state.js";
12
+
13
+ // Each bucket is keyed by step text or regex source, so it has no prototype: a step whose text
14
+ // is `constructor` or `toString` would otherwise find the inherited property and be refused as
15
+ // a duplicate on its first registration (fresh fuzz of PR #16, 2026-10-10, B2).
16
+ const emptyBucket = () => Object.create(null);
1
17
  const emptyStepsDefinition = () => ({
2
- given: {},
3
- when: {},
4
- then: {},
5
- and: {},
6
- but: {},
18
+ given: emptyBucket(),
19
+ when: emptyBucket(),
20
+ then: emptyBucket(),
21
+ and: emptyBucket(),
22
+ but: emptyBucket(),
7
23
  before: [],
8
24
  after: [],
9
25
  });
10
26
 
11
- // Module-level registry. Rebuilt from the empty shape above once a feature has been
12
- // loaded, so a second Fusion() in the same module starts from a clean slate rather
27
+ // The registry the verbs and hooks write into. It lives in src/shared-state.js, not in a module
28
+ // variable, so that a step file using one copy of the dual package (import) and a shared step
29
+ // library using the other (require) register into the same one. Rebuilt from the empty shape
30
+ // above once a feature has been loaded, so a second Fusion() starts from a clean slate rather
13
31
  // than inheriting the previous feature's step definitions and hooks.
14
- let stepsDefinition = emptyStepsDefinition();
32
+ const stepsDefinition = () => shared("steps", emptyStepsDefinition);
33
+
34
+ const isRegExp = (candidate) =>
35
+ Object.prototype.toString.call(candidate) === "[object RegExp]";
36
+
37
+ const isStepMatcher = (candidate) =>
38
+ typeof candidate === "string" || isRegExp(candidate);
39
+
40
+ const verbNamed = (definitionType) =>
41
+ definitionType.charAt(0).toUpperCase() + definitionType.slice(1);
42
+
43
+ const whatTheMatcherWas = (candidate) =>
44
+ candidate === undefined || candidate === null
45
+ ? String(candidate)
46
+ : `${typeof candidate} ${inspect(candidate)}`;
47
+
48
+ // Refused at the call, like every other misuse. Until 3.0.0 a matcher of any other type was
49
+ // silently ignored, so the scenario failed later as an unbound step, far from the mistake.
50
+ const refuseUnsupportedMatcher = (definitionType, candidate) =>
51
+ new Error(
52
+ `Unsupported step matcher: ${verbNamed(
53
+ definitionType,
54
+ )} was given ${whatTheMatcherWas(candidate)}.\n\n` +
55
+ `WHY: Fusion binds a step definition to the feature's steps by its matcher, and only\n` +
56
+ ` a string or a regular expression can match a step's text. Any other matcher\n` +
57
+ ` binds nothing, and the step would then fail as unbound, far from this call.\n` +
58
+ `HOW: pass the step's text, ${verbNamed(
59
+ definitionType,
60
+ )}("the shop is open", fn), or a regular\n` +
61
+ ` expression, ${verbNamed(
62
+ definitionType,
63
+ )}(/^(\\d+) items? in the basket$/, fn).`,
64
+ );
65
+
66
+ // Refused at the call too. Accepted, a definition with no function failed only when its step
67
+ // ran, as "step.bound.stepFn is not a function" (fresh fuzz of PR #16, 2026-10-10).
68
+ const refuseMissingStepFunction = (
69
+ definitionType,
70
+ matcher,
71
+ fnForDefinition,
72
+ ) => {
73
+ const call = `${verbNamed(definitionType)}(${
74
+ typeof matcher === "string" ? JSON.stringify(matcher) : String(matcher)
75
+ })`;
76
+ return new Error(
77
+ `Missing step function: ${call} was given ${whatTheMatcherWas(
78
+ fnForDefinition,
79
+ )}.\n\n` +
80
+ `WHY: the function is what runs when a step matches, so a definition without one\n` +
81
+ ` would fail only when its step ran, far from this call.\n` +
82
+ `HOW: pass the step's code as the second argument, for example\n` +
83
+ ` ${call.slice(0, -1)}, () => { ... }).`,
84
+ );
85
+ };
15
86
 
16
87
  const addDefinitionFunction = (
17
88
  definitionType,
18
89
  regexpSentence,
19
- fnForDefinition
90
+ fnForDefinition,
20
91
  ) => {
21
- if (stepsDefinition[definitionType]) {
22
- if (regexpSentence.constructor === RegExp) {
92
+ if (!isStepMatcher(regexpSentence))
93
+ throw refuseUnsupportedMatcher(definitionType, regexpSentence);
94
+ if (typeof fnForDefinition !== "function")
95
+ throw refuseMissingStepFunction(
96
+ definitionType,
97
+ regexpSentence,
98
+ fnForDefinition,
99
+ );
100
+
101
+ if (stepsDefinition()[definitionType]) {
102
+ if (isRegExp(regexpSentence)) {
23
103
  throwIfDuplicateMatcher(definitionType, regexpSentence.source);
24
- stepsDefinition[definitionType][regexpSentence.source] = {
104
+ stepsDefinition()[definitionType][regexpSentence.source] = {
25
105
  stepRegExp: regexpSentence,
26
106
  stepExpression: null,
27
107
  stepFn: fnForDefinition,
28
108
  };
29
- } else if (typeof regexpSentence === "string") {
109
+ } else {
30
110
  throwIfDuplicateMatcher(definitionType, regexpSentence);
31
- stepsDefinition[definitionType][regexpSentence] = {
111
+ stepsDefinition()[definitionType][regexpSentence] = {
32
112
  stepRegExp: null,
33
113
  stepExpression: regexpSentence,
34
114
  stepFn: fnForDefinition,
@@ -38,9 +118,9 @@ const addDefinitionFunction = (
38
118
  };
39
119
 
40
120
  const throwIfDuplicateMatcher = (definitionType, matcherKey) => {
41
- if (stepsDefinition[definitionType][matcherKey])
121
+ if (stepsDefinition()[definitionType][matcherKey])
42
122
  throw new Error(
43
- `Duplicate step definition: "${matcherKey}" is already registered for "${definitionType}"`
123
+ `Duplicate step definition: "${matcherKey}" is already registered for "${definitionType}"`,
44
124
  );
45
125
  };
46
126
 
@@ -48,7 +128,7 @@ const Given = (regexpSentenceOrChainedObject, fnForDefinition) => {
48
128
  return defineAndChain(
49
129
  "given",
50
130
  regexpSentenceOrChainedObject,
51
- fnForDefinition
131
+ fnForDefinition,
52
132
  );
53
133
  };
54
134
  const When = (regexpSentenceOrChainedObject, fnForDefinition) => {
@@ -65,18 +145,21 @@ const But = (regexpSentenceOrChainedObject, fnForDefinition) => {
65
145
  return defineAndChain("but", regexpSentenceOrChainedObject, fnForDefinition);
66
146
  };
67
147
 
148
+ // The chained form: a StepChain handed back by an earlier verb, re-registered under a second
149
+ // keyword. A single argument carrying a stepSentence is one of those; a matcher is not, and
150
+ // the RegExp clause keeps a regex out even though a regex carries no stepSentence either.
151
+ const isChainedStepObject = (candidate, fnForStep) =>
152
+ !fnForStep &&
153
+ candidate instanceof Object &&
154
+ Object.prototype.toString.call(candidate) !== "[object RegExp]" &&
155
+ candidate.stepSentence;
156
+
68
157
  const defineAndChain = (stepType, stepObjectOrSentence, fnForStep) => {
69
- if (
70
- !fnForStep &&
71
- stepObjectOrSentence instanceof Object &&
72
- Object.prototype.toString.call(stepObjectOrSentence) !==
73
- "[object RegExp]" &&
74
- stepObjectOrSentence.stepSentence
75
- ) {
158
+ if (isChainedStepObject(stepObjectOrSentence, fnForStep)) {
76
159
  addDefinitionFunction(
77
160
  stepType,
78
161
  stepObjectOrSentence.stepSentence,
79
- stepObjectOrSentence.stepFnDefinition
162
+ stepObjectOrSentence.stepFnDefinition,
80
163
  );
81
164
 
82
165
  return stepObjectOrSentence;
@@ -88,411 +171,61 @@ const defineAndChain = (stepType, stepObjectOrSentence, fnForStep) => {
88
171
  };
89
172
 
90
173
  const Before = (fnDefinition) => {
91
- stepsDefinition.before.push(fnDefinition);
174
+ stepsDefinition().before.push(fnDefinition);
92
175
  };
93
176
  const After = (fnDefinition) => {
94
- stepsDefinition.after.push(fnDefinition);
95
- };
177
+ stepsDefinition().after.push(fnDefinition);
178
+ };
179
+
180
+ // Fusion() with no path, or a non-string one, reached Node's path module and failed with its
181
+ // raw `The "paths[1]" argument must be of type string` (fresh fuzz of PR #16, 2026-10-10).
182
+ const refuseFeaturePathNotAString = (featureFileToLoad) =>
183
+ new Error(
184
+ `Fusion needs the feature file's path as a string, but was given ${whatTheMatcherWas(
185
+ featureFileToLoad,
186
+ )}.\n\n` +
187
+ `HOW: pass the feature file's path, relative to this steps file, for example\n` +
188
+ ` Fusion("rocket-launching.feature"). A file: URL needs url.fileURLToPath first.`,
189
+ );
96
190
 
97
- const Fusion = (featureFileToLoad, optionsToPassToJestCucumber) => {
191
+ const Fusion = (featureFileToLoad, optionsForThisFeature) => {
98
192
  try {
99
- const path = require("path");
100
- const callerSites = require("callsites");
101
- // Resolve the feature path from the FIRST stack frame outside this package, so
102
- // an in-package re-export/wrapper frame does not retarget it; guard a shallow
103
- // stack (no external frame) so we never call getFileName() on undefined.
104
- const externalFrame = callerSites
105
- .default()
106
- .find((currentFrame) => currentFrame.getFileName() !== __filename);
107
- const callerSiteCaller = externalFrame ? externalFrame.getFileName() : "";
108
- const dirOfCaller = path.dirname(callerSiteCaller || "");
109
- const absoluteFeatureFilePath = path.resolve(
110
- dirOfCaller,
111
- featureFileToLoad
112
- );
193
+ if (typeof featureFileToLoad !== "string")
194
+ throw refuseFeaturePathNotAString(featureFileToLoad);
195
+ const absoluteFeatureFilePath =
196
+ featureSource.resolveFeaturePath(featureFileToLoad);
197
+ const effectiveOptions = mergeFusionOptions(optionsForThisFeature);
113
198
 
114
- const jestCucumber = require("jest-cucumber");
115
- const feature = jestCucumber.loadFeature(
199
+ const loadedFeature = featureSource.loadFeature(
116
200
  absoluteFeatureFilePath,
117
- optionsToPassToJestCucumber
118
- );
119
-
120
- // When jest-cucumber's own step-count validation is disabled ({ errors: false }),
121
- // the wrapper must fail loudly on an unmatched step itself; otherwise stay silent
122
- // so jest-cucumber's native validation remains the (transparent) source of truth.
123
- const failOnUnmatchedStep = !!(
124
- optionsToPassToJestCucumber &&
125
- optionsToPassToJestCucumber.errors === false
126
- );
127
-
128
- // This feature binds the definitions and hooks registered for IT — captured before the
129
- // registry is reset below, so the binding does not depend on when jest-cucumber invokes
130
- // the callback.
131
- const registryForThisFeature = stepsDefinition;
132
-
133
- jestCucumber.defineFeature(feature, (testFn) => {
134
- // jest-cucumber wraps the whole feature in ONE describe, so a hook registered here
135
- // already runs around every test in it. Register each hook once per feature, never
136
- // per scenario: N registrations would run every hook N times per test.
137
- registerHooks(registryForThisFeature, beforeEach, afterEach);
138
-
139
- if (feature.scenarios.length > 0)
140
- matchJestTestSuiteWithCucumberFeature(
141
- registryForThisFeature,
142
- feature.scenarios,
143
- testFn,
144
- false,
145
- failOnUnmatchedStep
146
- );
147
-
148
- if (feature.scenarioOutlines.length > 0)
149
- matchJestTestSuiteWithCucumberFeature(
150
- registryForThisFeature,
151
- feature.scenarioOutlines,
152
- testFn,
153
- true,
154
- failOnUnmatchedStep
155
- );
156
- });
157
- } finally {
158
- // Unconditional: Fusion() always leaves a clean slate — normal return OR throw. Rebinding
159
- // the module-level registry (never mutating it in place) keeps the object captured above
160
- // intact for the callback, while the next Fusion() starts empty and must re-register.
161
- stepsDefinition = emptyStepsDefinition();
162
- }
163
- };
164
-
165
- const registerHooks = (featureRegistry, beforeEachFn, afterEachFn) => {
166
- featureRegistry.before.forEach((beforeHook) => beforeEachFn(beforeHook));
167
- featureRegistry.after.forEach((afterHook) => afterEachFn(afterHook));
168
- };
169
-
170
- const matchJestTestSuiteWithCucumberFeature = (
171
- featureRegistry,
172
- featureScenariosOrOutline,
173
- testFn,
174
- isOutline,
175
- failOnUnmatchedStep
176
- ) => {
177
- featureScenariosOrOutline.forEach((currentScenarioOrOutline) => {
178
- matchJestTestWithCucumberScenario(
179
- featureRegistry,
180
- currentScenarioOrOutline.title,
181
- currentScenarioOrOutline.steps,
182
- testFn,
183
- isOutline,
184
- failOnUnmatchedStep
185
- );
186
- });
187
- };
188
-
189
- const matchJestTestWithCucumberScenario = (
190
- featureRegistry,
191
- currentScenarioTitle,
192
- currentScenarioSteps,
193
- testFn,
194
- isOutline,
195
- failOnUnmatchedStep
196
- ) => {
197
- testFn(currentScenarioTitle, ({ given, when, then, and, but }) => {
198
- currentScenarioSteps.forEach((currentStep) => {
199
- matchJestDefinitionWithCucumberStep(
200
- featureRegistry,
201
- { given, when, then, and, but },
202
- currentStep,
203
- isOutline,
204
- failOnUnmatchedStep
205
- );
206
- });
207
- });
208
- };
209
-
210
- const matchJestDefinitionWithCucumberStep = (
211
- featureRegistry,
212
- verbFunction,
213
- currentStep,
214
- isOutline,
215
- failOnUnmatchedStep
216
- ) => {
217
- const foundMatchingStep = findMatchingStep(
218
- featureRegistry,
219
- currentStep,
220
- isOutline
221
- );
222
- if (!foundMatchingStep) {
223
- if (failOnUnmatchedStep)
224
- throw new Error(`No step definition matches: "${currentStep.stepText}"`);
225
- return;
226
- }
227
-
228
- // this will be the "given", "when", "then"...functions
229
- verbFunction[currentStep.keyword](
230
- foundMatchingStep.stepExpression,
231
- foundMatchingStep.stepFn
232
- );
233
- };
234
-
235
- const findMatchingStep = (featureRegistry, currentStep, isOutline) => {
236
- const scenarioType = currentStep.keyword;
237
- const scenarioSentence = currentStep.stepText;
238
- const matchingSteps = Object.keys(featureRegistry[scenarioType]).filter(
239
- (currentStepDefinitionFunction) => {
240
- return isFunctionForScenario(
241
- scenarioSentence,
242
- featureRegistry[scenarioType][currentStepDefinitionFunction],
243
- isOutline
244
- );
245
- }
246
- );
247
- if (matchingSteps.length === 0) return null;
248
-
249
- if (matchingSteps.length > 1) {
250
- const competingMatchers = matchingSteps
251
- .map((matcherSource) => `"${matcherSource}"`)
252
- .join(", ");
253
- throw new Error(
254
- `Ambiguous step definition: "${scenarioSentence}" matches ${matchingSteps.length} step definitions: ${competingMatchers}`
201
+ effectiveOptions,
255
202
  );
256
- }
257
-
258
- return injectVariable(
259
- featureRegistry,
260
- scenarioType,
261
- scenarioSentence,
262
- matchingSteps[0],
263
- currentStep.stepArgument
264
- );
265
- };
266
-
267
- const isFunctionForScenario = (
268
- scenarioSentence,
269
- stepDefinitionFunction,
270
- isOutline
271
- ) => {
272
- if (stepDefinitionFunction.stepRegExp) {
273
- if (isOutline && /<[\w]*>/.test(scenarioSentence)) {
274
- return isPotentialStepFunctionForScenario(
275
- scenarioSentence,
276
- stepDefinitionFunction.stepRegExp
277
- );
278
- } else return scenarioSentence.match(stepDefinitionFunction.stepRegExp);
279
- }
280
-
281
- return scenarioSentence === stepDefinitionFunction.stepExpression;
282
- };
283
203
 
284
- // An ESCAPED paren is literal text, never a group delimiter — the same knowledge holdsCapturingGroup
285
- // (below) already encodes. Masking each `\(` / `\)` with an ordinary two-character body sequence keeps
286
- // every index and length identical to the raw source, so the group locator can run over the mask and
287
- // its result still addresses the RAW string.
288
- const maskEscapedParens = (stepFunctionDef) =>
289
- stepFunctionDef.replace(/\\[()]/g, "\\-");
204
+ // This feature binds the definitions and hooks registered for IT, captured before the
205
+ // registry is reset below, so the binding never depends on registration that came after.
206
+ const registryForThisFeature = stepsDefinition();
290
207
 
291
- // The scenario sentence carries a matcher's parens/anchors as LITERAL characters, so a position in the
292
- // sentence is only comparable against the step function once its escapes are collapsed the same way.
293
- const asScenarioText = (stepFunctionDef) =>
294
- stepFunctionDef
295
- .replace(/\\\(/g, "(")
296
- .replace(/\\\)/g, ")")
297
- .replace(/\\\^/g, "^")
298
- .replace(/\\\$/g, "$");
299
-
300
- const isPotentialStepFunctionForScenario = (
301
- scenarioDefinition,
302
- regStepFunc
303
- ) => {
304
- //so this one is tricky, to ensure we only find the
305
- // step definition corresponding to actual steps function in the case of outlined gherkin
306
- // we have to "disable" the outlining (since it can replace regular expression
307
- // and then ensure that all "non-outlined" part do respect the regular expression of
308
- // of the step function
309
- // FIRST, we clean the string version of the step definition that has outline variable
310
- const cleanedStepFunc = regStepFunc.source
311
- .replace(/^\^/, "")
312
- // .replace( /\\\(/g, '(' )
313
- // .replace( /\\\)/g, ')')
314
- // .replace( /\\\^/g, '^')
315
- // .replace( /\\\$/g, '$')
316
- .replace(/\$$/, "");
317
- // .replace( /\([.\\]+[sSdDwWbB*][*?+]?\)|\(\[.*\](?:[+?*]{1}|\{\d\})\)/g, '' )
318
-
319
- let currentScenarioPart;
320
- let currentStepFuncLeft = cleanedStepFunc;
321
- let currentScenarioDefLeft = scenarioDefinition;
322
-
323
- //we step through each of the scenario outline variables
324
- // from there, we will try to detect any regexp present in the
325
- // step definition, so that we can ensure to find the right match
326
- while (
327
- (currentScenarioPart = /<[\w]*>/gi.exec(currentScenarioDefLeft)) != null
328
- ) {
329
- let fixedPart = currentScenarioPart.input.substring(
330
- 0,
331
- currentScenarioPart.index
332
- );
333
- let idxCutScenarioPart =
334
- currentScenarioPart.index + currentScenarioPart[0].length;
335
-
336
- // The character class spans the source of a capturing group. It must include the digits 0-9:
337
- // spelled `0` alone it cannot span a group whose source holds a digit 1-9, which makes EVERY
338
- // bounded quantifier — "(\d{4})" — invisible to this detector and therefore unbindable in an
339
- // outline, while "(\d+)" (no digit in its source) binds. Braces are not the cause; the digits are.
340
- //
341
- // Locate the group over the MASKED source, so an escaped paren can never be mistaken for a group
342
- // boundary: previously the locator ran twice — once on the raw source, once on a source whose
343
- // `\(`/`\)` had been unescaped into BARE parens — and the two disagreed about where (and whether)
344
- // a group was. For "(\w+\(\))" the raw pass matched "(\)" while the unescaped pass ("(\w+())")
345
- // matched nothing at all, and a guard that tested the first while dereferencing the second threw
346
- // on the null. One escape-aware pass gives one answer: the real group, in raw coordinates.
347
- const groupInStepFunc = /\([a-zA-Z0-9!|,:?*+.^=${}><\\\-]+\)/g.exec(
348
- maskEscapedParens(currentStepFuncLeft)
208
+ testRegistration.registerFeature(
209
+ loadedFeature,
210
+ registryForThisFeature,
211
+ effectiveOptions,
349
212
  );
350
- // The scenario sentence spells the step function's prefix as literal text, so the two are only
351
- // comparable in scenario-text coordinates — escapes collapsed, one character each.
352
- const stepFuncPrefixAsScenarioText = groupInStepFunc
353
- ? asScenarioText(currentStepFuncLeft.substring(0, groupInStepFunc.index))
354
- : "";
355
-
356
- if (
357
- groupInStepFunc &&
358
- stepFuncPrefixAsScenarioText.length == currentScenarioPart.index
359
- ) {
360
- //if we have a regex inside our step function definition
361
- // and that regex is at the same position than our Outlined variable
362
- // we just need to check that the sentence match,
363
- // so we can "evaluate" the step function and remove the regex in it
364
- currentStepFuncLeft =
365
- stepFuncPrefixAsScenarioText +
366
- currentStepFuncLeft.substring(
367
- groupInStepFunc.index + groupInStepFunc[0].length
368
- );
369
- } else if (
370
- groupInStepFunc &&
371
- groupInStepFunc.index < currentScenarioPart.index
372
- ) {
373
- //if we have a regex inside our step function definition
374
- // but that regex is not at the same position than our outlined variable
375
- // we need to evaluate the regex against the scenario part
376
- const strRegexToEvaluate = currentStepFuncLeft.substring(
377
- 0,
378
- groupInStepFunc.index + groupInStepFunc[0].length
379
- );
380
- const regexToEvaluate = new RegExp(strRegexToEvaluate);
381
- const regIntermediatePart = regexToEvaluate.exec(
382
- currentScenarioPart.input
383
- );
384
- if (regIntermediatePart) {
385
- fixedPart = strRegexToEvaluate;
386
- idxCutScenarioPart = regIntermediatePart[0].length;
387
- }
388
- }
389
-
390
- const partIndex = currentStepFuncLeft.indexOf(fixedPart);
391
- if (partIndex !== -1) {
392
- currentStepFuncLeft = currentStepFuncLeft.substring(
393
- partIndex + fixedPart.length
394
- );
395
- currentScenarioDefLeft =
396
- currentScenarioDefLeft.substring(idxCutScenarioPart);
397
- } else {
398
- return false;
399
- }
400
- }
401
-
402
- return (
403
- (currentScenarioDefLeft === "" && currentStepFuncLeft === "") ||
404
- evaluateStepFuncEndVsScenarioEnd(
405
- currentStepFuncLeft,
406
- currentScenarioDefLeft
407
- )
408
- );
409
- };
410
-
411
- // A leftover fragment holds a CAPTURING group — so it has to be evaluated as a regex, not compared
412
- // as a literal. Escaped parens are stripped first (`\(n\)` is literal text, not a group) and `(?...)`
413
- // is skipped (non-capturing), so this recognises exactly the real groups.
414
- const holdsCapturingGroup = (stepFunctionDef) =>
415
- /\((?!\?)[^()]*\)/.test(stepFunctionDef.replace(/\\[()]/g, ""));
416
-
417
- const evaluateStepFuncEndVsScenarioEnd = (
418
- stepFunctionDef,
419
- scenarioDefinition
420
- ) => {
421
- // The leftover is regex SOURCE, so a capturing group in it must be evaluated as a regex. The old
422
- // test only recognised a group holding one of [sSdDwWbB*], which made the group's SPELLING the
423
- // discriminator rather than its presence: " lamp is (on|off)" fell through to a literal endsWith
424
- // (always false — the scenario text reads " lamp is on") and never bound, while the identically
425
- // shaped " lamp is (on|down)" took the regex branch and bound. Widening is strict: every fragment
426
- // that already took the regex branch still does.
427
- if (
428
- /\(.*(\?\:)?[.\\]*[sSdDwWbB*][*?+]?.*\)|\(\[.*\](?:[+?*]{1}|\{\d\})\)/g.test(
429
- stepFunctionDef
430
- ) ||
431
- holdsCapturingGroup(stepFunctionDef)
432
- ) {
433
- return new RegExp(stepFunctionDef).test(scenarioDefinition);
434
- }
435
-
436
- return stepFunctionDef.endsWith(scenarioDefinition);
437
- };
438
-
439
- const injectVariable = (
440
- featureRegistry,
441
- scenarioType,
442
- scenarioSentence,
443
- stepFunctionDefinition,
444
- stepArgs
445
- ) => {
446
- const stepObject = featureRegistry[scenarioType][stepFunctionDefinition];
447
-
448
- if (!stepObject.stepRegExp)
449
- return {
450
- stepExpression: scenarioSentence,
451
- // Forward through a rest-param wrapper so the reported arity is 0.
452
- // jest-cucumber v4.4.0+ treats an arity mismatch (stepFn.length >
453
- // matched args) as a request for a done() callback and then blocks
454
- // on a Promise that never resolves. A rest param has length 0 while
455
- // still passing along whatever args jest-cucumber provides.
456
- stepFn: (...args) => stepObject.stepFn(...args),
457
- };
458
-
459
- const exprMatches = stepObject.stepRegExp.exec(scenarioSentence);
460
-
461
- if (!exprMatches || /<.*>/.test(scenarioSentence))
462
- return {
463
- stepExpression: stepObject.stepRegExp,
464
- // See note above: keep arity 0 to avoid jest-cucumber's done-callback
465
- // heuristic hanging the step (matters for plain scenarios whose text
466
- // contains "<...>" and for outline template steps).
467
- stepFn: (...args) => stepObject.stepFn(...args),
468
- };
469
-
470
- const dynamicMatchThatAreVariables = [];
471
-
472
- exprMatches.forEach((match, groupIndex) => {
473
- if (groupIndex > 0) dynamicMatchThatAreVariables.push(match);
474
- });
475
-
476
- // Forward the step's Gherkin argument on PRESENCE, never on its type — the same test jest-cucumber
477
- // itself applies before handing an argument to a step (feature-definition-creation.js:129-130). A
478
- // type test drops half the shapes: Gherkin parses a data table to an array but a docstring to a
479
- // string, and an empty docstring to "" — which a `.length` test would drop too. Absent, the argument
480
- // is null, so this is the whole distinction that matters.
481
- if (stepArgs != null) {
482
- dynamicMatchThatAreVariables.push(stepArgs);
213
+ } finally {
214
+ // Unconditional: Fusion() always leaves a clean slate (normal return OR throw). Replacing
215
+ // the shared registry (never mutating it in place) keeps the object captured above intact
216
+ // for whoever still holds it, while the next Fusion() starts empty and must re-register.
217
+ replaceShared("steps", emptyStepsDefinition());
483
218
  }
484
-
485
- return {
486
- stepExpression: stepObject.stepRegExp,
487
- stepFn: () => stepObject.stepFn(...dynamicMatchThatAreVariables),
488
- };
489
219
  };
490
220
 
491
- module.exports.Before = Before;
492
- module.exports.After = After;
493
- module.exports.Given = Given;
494
- module.exports.When = When;
495
- module.exports.Then = Then;
496
- module.exports.And = And;
497
- module.exports.But = But;
498
- module.exports.Fusion = Fusion;
221
+ export { Before };
222
+ export { After };
223
+ export { Given };
224
+ export { When };
225
+ export { Then };
226
+ export { And };
227
+ export { But };
228
+ export { Fusion };
229
+ // Re-exported, not re-implemented: the module that owns the merge owns the layer the setter
230
+ // writes, so the entry point keeps no state of its own.
231
+ export { setFusionConfiguration };