@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.
- package/README.md +444 -38
- package/dist/THIRD_PARTY_LICENSES.txt +91 -0
- package/dist/index.cjs +9917 -0
- package/dist/index.d.cts +145 -0
- package/package.json +60 -10
- package/scripts/prepare-hooks.js +23 -0
- package/src/code-suggestion.js +240 -0
- package/src/configuration.js +147 -0
- package/src/feature-source.js +322 -0
- package/src/index.d.ts +123 -13
- package/src/index.js +153 -420
- package/src/keywords.js +56 -0
- package/src/scenario-name.js +128 -0
- package/src/shared-state.js +36 -0
- package/src/step-argument.js +56 -0
- package/src/step-matching.js +89 -0
- package/src/tag-filter.js +90 -0
- package/src/test-registration.js +178 -0
- package/src/value-description.js +26 -0
- package/.prettierignore +0 -16
- package/.prettierrc.json +0 -0
- package/codecov +0 -0
- package/codecov.SHA256SUM +0 -1
- package/codecov.SHA256SUM.sig +0 -16
- package/docs/AdditionalConfiguration.md +0 -155
- package/docs/GherkinTables.md +0 -61
- package/docs/Language.md +0 -76
- package/docs/ReusingStepDefinitions.md +0 -110
- package/docs/RunningTheExamples.md +0 -16
- package/docs/ScenarioOutlines.md +0 -43
- package/docs/StepDefinitionArguments.md +0 -37
- package/docs/product/expectations/fix-l2-outline-regex/outline-regex-binds.md +0 -148
- package/docs/product/expectations/fix-l4-escaped-parens-outline/escaped-parens-bind-in-outlines.md +0 -106
- package/docs/product/expectations/fix-m3-singleton-reset/clean-slate-per-feature.md +0 -133
- package/test/specs/features/basic-scenarios.feature +0 -28
- package/test/specs/features/l3-step-argument-delivery.feature +0 -44
- package/test/specs/features/language.feature +0 -41
- package/test/specs/features/m6-hooks-once-per-test.feature +0 -21
- package/test/specs/features/m6-hooks-outline-only.feature +0 -12
- package/test/specs/features/reuse-definition.feature +0 -13
- package/test/specs/features/scenario-outline2.feature +0 -73
- package/test/specs/features/scenario-outlines.feature +0 -88
- package/test/specs/features/step-definitions/ambiguous-step-shadowing.steps.js +0 -92
- package/test/specs/features/step-definitions/basic-scenarios.steps.js +0 -62
- package/test/specs/features/step-definitions/fuzz-properties.steps.js +0 -170
- package/test/specs/features/step-definitions/hook-error.steps.js +0 -59
- package/test/specs/features/step-definitions/l2-outline-edge-cases.steps.js +0 -327
- package/test/specs/features/step-definitions/l3-step-argument-delivery.steps.js +0 -191
- package/test/specs/features/step-definitions/l4-escaped-parens-outline.steps.js +0 -256
- package/test/specs/features/step-definitions/language.steps.js +0 -86
- package/test/specs/features/step-definitions/m1-before-hooks-clobber.steps.js +0 -64
- package/test/specs/features/step-definitions/m2-duplicate-matcher.steps.js +0 -60
- package/test/specs/features/step-definitions/m3-singleton-reset.steps.js +0 -275
- package/test/specs/features/step-definitions/m4-callsite-resolution.steps.js +0 -76
- package/test/specs/features/step-definitions/m5-errors-false-silent-skip.steps.js +0 -80
- package/test/specs/features/step-definitions/m6-hooks-once-per-test.steps.js +0 -90
- package/test/specs/features/step-definitions/missing-feature-file.steps.js +0 -23
- package/test/specs/features/step-definitions/reuse-code.js +0 -22
- package/test/specs/features/step-definitions/reuse-definition.steps.js +0 -30
- package/test/specs/features/step-definitions/scenario-outline2.steps.js +0 -57
- package/test/specs/features/step-definitions/scenario-outlines.steps.js +0 -110
- package/test/specs/features/step-definitions/undefined-step.steps.js +0 -39
- package/test/specs/features/step-definitions/using-dynamic-values.steps.js +0 -70
- package/test/specs/features/step-definitions/using-gherkin-tables.steps.js +0 -42
- package/test/specs/features/undefined-step.feature +0 -4
- package/test/specs/features/using-dynamic-values.feature +0 -34
- package/test/specs/features/using-gherkin-tables.feature +0 -16
- package/test/src/bank-account.js +0 -17
- package/test/src/online-sales.js +0 -33
- package/test/src/rocket.js +0 -13
- package/test/src/todo-list.js +0 -20
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Type definitions for @g_package/jest-cucumber-fusion
|
|
2
|
+
// Project: https://github.com/gotreasa/jest-cucumber-fusion#readme
|
|
3
|
+
// Originally written by Pelle Johnsen <https://github.com/pjoe> for DefinitelyTyped.
|
|
4
|
+
|
|
5
|
+
/** What Fusion passes a step: a capture or a docstring as a string, a data table as its rows. */
|
|
6
|
+
export type StepArgument = string | Array<Record<string, string>>;
|
|
7
|
+
|
|
8
|
+
// Declared through a method so its parameters are checked bivariantly: a step may declare
|
|
9
|
+
// the narrower type it receives, such as `(count: string) => ...`, which a plain function type
|
|
10
|
+
// refuses under strictFunctionTypes. A type Fusion never passes, such as `number`, is still
|
|
11
|
+
// refused, and an undeclared parameter is still a StepArgument.
|
|
12
|
+
export type CallBack = {
|
|
13
|
+
step(...args: ReadonlyArray<StepArgument>): void | Promise<void>;
|
|
14
|
+
}["step"];
|
|
15
|
+
|
|
16
|
+
export interface StepChain {
|
|
17
|
+
stepSentence: string | RegExp;
|
|
18
|
+
stepFnDefinition: CallBack;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Given(name: string | RegExp, callback: CallBack): StepChain;
|
|
22
|
+
export function Given(chain: StepChain): StepChain;
|
|
23
|
+
export function When(name: string | RegExp, callback: CallBack): StepChain;
|
|
24
|
+
export function When(chain: StepChain): StepChain;
|
|
25
|
+
export function Then(name: string | RegExp, callback: CallBack): StepChain;
|
|
26
|
+
export function Then(chain: StepChain): StepChain;
|
|
27
|
+
export function And(name: string | RegExp, callback: CallBack): StepChain;
|
|
28
|
+
export function And(chain: StepChain): StepChain;
|
|
29
|
+
export function But(name: string | RegExp, callback: CallBack): StepChain;
|
|
30
|
+
export function But(chain: StepChain): StepChain;
|
|
31
|
+
|
|
32
|
+
export function Before(callback: () => void | Promise<void>): void;
|
|
33
|
+
export function After(callback: () => void | Promise<void>): void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which of Fusion's validations are on.
|
|
37
|
+
*
|
|
38
|
+
* - `stepsMustMatchFeatureFile` (default on): a feature step that no registered definition
|
|
39
|
+
* matches is refused at collection, in one message naming every unbound step of the feature
|
|
40
|
+
* with starter code for each. Off, the scenarios holding those steps are registered through
|
|
41
|
+
* `test.skip`, so Jest reports them as skipped and never as passed.
|
|
42
|
+
* - `scenariosMustMatchFeatureFile` (default on): two scenarios of one feature file declared
|
|
43
|
+
* with the same title, ignoring case and counting a scenario inside a Rule, are refused at
|
|
44
|
+
* collection. A Scenario Outline counts once however many Examples rows it has, so repeated
|
|
45
|
+
* row names are never a duplicate. Off, the file is accepted as written.
|
|
46
|
+
* - `allowScenariosNotInFeatureFile`: accepted and vestigial. Fusion generates the scenario
|
|
47
|
+
* definitions from the feature file itself, so there is no scenario outside it to allow.
|
|
48
|
+
*
|
|
49
|
+
* As a boolean, `errors` is shorthand: `true` turns every key on, `false` turns every key off.
|
|
50
|
+
* As an object it merges KEY-WISE over the defaults, so naming one key says nothing about the
|
|
51
|
+
* others. Switching the step check off leaves the duplicate check exactly as it was.
|
|
52
|
+
*/
|
|
53
|
+
export interface FusionErrorOptions {
|
|
54
|
+
stepsMustMatchFeatureFile?: boolean;
|
|
55
|
+
scenariosMustMatchFeatureFile?: boolean;
|
|
56
|
+
allowScenariosNotInFeatureFile?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a scenarioNameTemplate is handed, once per test. Unchanged from the shape consumers
|
|
61
|
+
* already write: these four variables and no others.
|
|
62
|
+
*
|
|
63
|
+
* `scenarioTitle` is the title of the individual test being named. For a Scenario Outline row
|
|
64
|
+
* that is the row's OWN substituted title, so each row gets its own name.
|
|
65
|
+
*
|
|
66
|
+
* `featureTags` are the feature's declared tags. `scenarioTags` are the rest of the tags that
|
|
67
|
+
* reached the scenario, which for an Examples row includes that Examples set's tags. The two
|
|
68
|
+
* lists are disjoint, and every tag carries its leading `@` and is lowercased. A tag declared
|
|
69
|
+
* on both the feature and the scenario appears in `featureTags` only.
|
|
70
|
+
*/
|
|
71
|
+
export interface ScenarioNameTemplateVars {
|
|
72
|
+
featureTitle: string;
|
|
73
|
+
scenarioTitle: string;
|
|
74
|
+
scenarioTags: string[];
|
|
75
|
+
featureTags: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FusionOptions {
|
|
79
|
+
/**
|
|
80
|
+
* Accepted and ignored. Fusion always resolves a relative feature path against the
|
|
81
|
+
* directory of the file that called it, and hands an absolute path on from there, so there
|
|
82
|
+
* is nothing left for this to switch.
|
|
83
|
+
*/
|
|
84
|
+
loadRelativePath?: boolean;
|
|
85
|
+
errors?: boolean | FusionErrorOptions;
|
|
86
|
+
/**
|
|
87
|
+
* Which scenarios to run, as a tag expression: a tag name written `@name`, combined with
|
|
88
|
+
* the operators `and`, `or` and `not` and grouped with parentheses. For example
|
|
89
|
+
* `@smoke and not @slow`, or `@shop and (@included or @draft)`.
|
|
90
|
+
*
|
|
91
|
+
* Matching ignores case on both sides: the expression and every tag are lowercased before
|
|
92
|
+
* they are compared, so the operators may be written in any case too.
|
|
93
|
+
*
|
|
94
|
+
* A scenario is selected on the tags its compiled pickle carries, which is the union of the
|
|
95
|
+
* scenario's own tags, its feature's tags and, for a Scenario Outline row, that Examples
|
|
96
|
+
* set's tags. A scenario the expression excludes is registered through `test.skip` under its
|
|
97
|
+
* own unannotated name, so Jest reports it as skipped: never run, and never absent from the
|
|
98
|
+
* report. Its steps are also exempt from the unmatched-step check, because a scenario you
|
|
99
|
+
* excluded is not one you are asking to have wired. They are still read, so a step with an
|
|
100
|
+
* unsupported keyword (`*`) or one that matches two definitions is refused all the same.
|
|
101
|
+
*
|
|
102
|
+
* An expression that cannot be parsed is refused at collection, before anything is
|
|
103
|
+
* registered, and the refusal names the expression you wrote. So is one with an operand
|
|
104
|
+
* that is not a tag, such as `smoke` written for `@smoke`: it could never match.
|
|
105
|
+
*/
|
|
106
|
+
tagFilter?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Renames every test Fusion registers, Scenario Outline rows included. It is called once per
|
|
109
|
+
* test while Fusion registers, never again while the tests run, and it names a test whatever
|
|
110
|
+
* its status: a scenario skipped because a tag filter excluded it, or because its steps do
|
|
111
|
+
* not bind, carries the same templated name it would have carried had it run.
|
|
112
|
+
*
|
|
113
|
+
* It must return a non-empty string. A template that throws, or that answers with anything
|
|
114
|
+
* else, is refused at collection rather than naming a test something Jest cannot report.
|
|
115
|
+
*/
|
|
116
|
+
scenarioNameTemplate?: (vars: ScenarioNameTemplateVars) => string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function Fusion(feature: string, options?: FusionOptions): void;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sets options for every `Fusion()` call in the current test file, so they need not be repeated
|
|
123
|
+
* in each one. Meant for a script listed in Jest's `setupFiles`:
|
|
124
|
+
*
|
|
125
|
+
* ```js
|
|
126
|
+
* // jest.config.js -> setupFiles: ["<rootDir>/jest.setup.js"]
|
|
127
|
+
* // jest.setup.js
|
|
128
|
+
* const { setFusionConfiguration } = require("@g_package/jest-cucumber-fusion");
|
|
129
|
+
* setFusionConfiguration({ tagFilter: "@smoke and not @slow" });
|
|
130
|
+
* ```
|
|
131
|
+
*
|
|
132
|
+
* Options are merged lowest to highest: the defaults, then whatever this setter holds, then the
|
|
133
|
+
* options passed to one `Fusion()` call. A per-call option therefore still wins for its own
|
|
134
|
+
* file, and `errors` merges key-wise at every layer, so naming one validation never switches
|
|
135
|
+
* off another. An option set to `undefined`, such as an unset environment variable forwarded
|
|
136
|
+
* as `{ tagFilter: process.env.TAGS }`, counts as not set and leaves the layer below in place.
|
|
137
|
+
*
|
|
138
|
+
* A second call REPLACES what the first set rather than merging into it, which is what lets a
|
|
139
|
+
* global be cleared or redefined. Jest gives each test file its own module registry and runs
|
|
140
|
+
* `setupFiles` inside it, so what is set here cannot reach another file.
|
|
141
|
+
*
|
|
142
|
+
* An argument that is not an options object is refused where it is called, before any step
|
|
143
|
+
* definition file has loaded. An unknown key is accepted and ignored, exactly as it is per call.
|
|
144
|
+
*/
|
|
145
|
+
export function setFusionConfiguration(options: FusionOptions): void;
|
package/package.json
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g_package/jest-cucumber-fusion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Write cucumber test as part of a jest run (including coverage)",
|
|
5
|
-
"
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
6
7
|
"types": "src/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src/",
|
|
10
|
+
"dist/",
|
|
11
|
+
"scripts/prepare-hooks.js"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
|
15
|
+
},
|
|
7
16
|
"scripts": {
|
|
8
|
-
"
|
|
17
|
+
"pretest": "npm run build",
|
|
18
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --color",
|
|
9
19
|
"test-d": "tsd",
|
|
10
|
-
"
|
|
20
|
+
"test:baseline": "node test/specs/baseline/run-all.js",
|
|
21
|
+
"coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
22
|
+
"lint": "eslint . --max-warnings 0",
|
|
23
|
+
"lint:secrets": "secretlint \"**/*\"",
|
|
11
24
|
"commit": "git-cz",
|
|
12
25
|
"semantic-release": "semantic-release",
|
|
13
|
-
"fmt": "npx prettier --write ."
|
|
26
|
+
"fmt": "npx prettier --write .",
|
|
27
|
+
"prepare": "node scripts/prepare-hooks.js",
|
|
28
|
+
"build": "node scripts/build-cjs.js",
|
|
29
|
+
"prepack": "npm run build"
|
|
14
30
|
},
|
|
15
31
|
"repository": {
|
|
16
32
|
"type": "git",
|
|
@@ -23,14 +39,28 @@
|
|
|
23
39
|
"gherkin"
|
|
24
40
|
],
|
|
25
41
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
42
|
+
"@cucumber/gherkin": "42.0.1",
|
|
43
|
+
"@cucumber/messages": "34.2.1",
|
|
44
|
+
"@cucumber/tag-expressions": "11.0.1",
|
|
45
|
+
"callsites": "4.2.0"
|
|
29
46
|
},
|
|
30
47
|
"devDependencies": {
|
|
48
|
+
"@commitlint/cli": "^21.2.3",
|
|
49
|
+
"@commitlint/config-conventional": "^21.2.3",
|
|
50
|
+
"@eslint/js": "^9.39.5",
|
|
51
|
+
"@secretlint/secretlint-rule-pattern": "^13.0.7",
|
|
52
|
+
"@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
|
|
31
53
|
"cz-conventional-changelog": "^3.3.0",
|
|
54
|
+
"esbuild": "0.28.2",
|
|
55
|
+
"eslint": "^9.39.5",
|
|
56
|
+
"eslint-plugin-jest": "^29.16.6",
|
|
32
57
|
"fast-check": "^4.10.2",
|
|
33
|
-
"
|
|
58
|
+
"globals": "^17.13.0",
|
|
59
|
+
"husky": "^9.1.7",
|
|
60
|
+
"jest": "^30.4.2",
|
|
61
|
+
"lint-staged": "^16.4.0",
|
|
62
|
+
"prettier": "3.9.9",
|
|
63
|
+
"secretlint": "^13.0.7",
|
|
34
64
|
"semantic-release": "^25.0.5",
|
|
35
65
|
"tsd": "^0.33.0"
|
|
36
66
|
},
|
|
@@ -56,7 +86,8 @@
|
|
|
56
86
|
[
|
|
57
87
|
"@semantic-release/npm",
|
|
58
88
|
{
|
|
59
|
-
"npmPublish":
|
|
89
|
+
"npmPublish": false,
|
|
90
|
+
"tarballDir": "release"
|
|
60
91
|
}
|
|
61
92
|
]
|
|
62
93
|
],
|
|
@@ -78,11 +109,30 @@
|
|
|
78
109
|
"/node_modules/",
|
|
79
110
|
"<rootDir>/.claude/"
|
|
80
111
|
],
|
|
112
|
+
"collectCoverageFrom": [
|
|
113
|
+
"src/**/*.js"
|
|
114
|
+
],
|
|
81
115
|
"coveragePathIgnorePatterns": [
|
|
82
116
|
"/node_modules/",
|
|
83
117
|
"/test/"
|
|
84
118
|
],
|
|
85
119
|
"coverageDirectory": "./coverage/",
|
|
86
120
|
"collectCoverage": true
|
|
121
|
+
},
|
|
122
|
+
"exports": {
|
|
123
|
+
".": {
|
|
124
|
+
"import": {
|
|
125
|
+
"types": "./src/index.d.ts",
|
|
126
|
+
"default": "./src/index.js"
|
|
127
|
+
},
|
|
128
|
+
"require": {
|
|
129
|
+
"types": "./dist/index.d.cts",
|
|
130
|
+
"default": "./dist/index.cjs"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"./package.json": "./package.json"
|
|
134
|
+
},
|
|
135
|
+
"peerDependencies": {
|
|
136
|
+
"jest": ">=27"
|
|
87
137
|
}
|
|
88
138
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The prepare script: installs the git hooks, exactly as `husky` does, but writes husky's
|
|
2
|
+
// message to stderr instead of stdout.
|
|
3
|
+
//
|
|
4
|
+
// npm runs `prepare` during `npm pack`, and `npm pack --json` prints its JSON on stdout. husky's
|
|
5
|
+
// own command writes its message to stdout, so outside a git checkout (a source archive, for
|
|
6
|
+
// one) ".git can't be found" broke that JSON (finding F6 of PR #16 review round 4;
|
|
7
|
+
// test/specs/packaging/prepare-script.steps.js). husky returns the message from its function,
|
|
8
|
+
// so it is still shown, on stderr, and the exit code stays 0 as husky's own command keeps it.
|
|
9
|
+
//
|
|
10
|
+
// npm also runs `prepare` when a consumer installs this package from a directory or a `file:`
|
|
11
|
+
// dependency. That install has none of this package's devDependencies, husky included, and no
|
|
12
|
+
// hooks to install, so a missing husky is skipped quietly. Any other failure still fails. This
|
|
13
|
+
// file ships in the package for that reason (fresh fuzz of PR #16, 2026-10-10, finding B4).
|
|
14
|
+
let husky;
|
|
15
|
+
try {
|
|
16
|
+
({ default: husky } = await import("husky"));
|
|
17
|
+
} catch (failure) {
|
|
18
|
+
if (failure && failure.code === "ERR_MODULE_NOT_FOUND") process.exit(0);
|
|
19
|
+
throw failure;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const message = husky();
|
|
23
|
+
if (message) console.error(`husky: ${message}`);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// What to tell a consumer whose step binds nothing: the starter code for one step, and the
|
|
2
|
+
// one numbered refusal for every unbound step of a feature.
|
|
3
|
+
//
|
|
4
|
+
// The refusal is the whole value here. "No step definition matches" on its own is the message
|
|
5
|
+
// the consumer already had; code they can paste is the message they wanted. So each entry
|
|
6
|
+
// carries a verb call in FUSION's own idiom, keyed on that step's recovered keyword, with a
|
|
7
|
+
// matcher built from the step's own text and one parameter for every argument the step
|
|
8
|
+
// implies.
|
|
9
|
+
//
|
|
10
|
+
// Pure: no filesystem, no parser, no Jest global. The argument detection, the escaping and the
|
|
11
|
+
// parameter naming are carried over from the removed intermediary's code generator
|
|
12
|
+
// (code-generation/step-generation.js:19-61); only its output form is not, because it emitted
|
|
13
|
+
// a test-callback idiom Fusion has no verb for.
|
|
14
|
+
|
|
15
|
+
// A number, or a double-quoted substring. The intermediary's pattern had a third alternative
|
|
16
|
+
// for an angle-bracket placeholder; it is dead for Fusion, because the step text reaching a
|
|
17
|
+
// suggestion is already substituted for its Examples row and no angle brackets survive.
|
|
18
|
+
const ARGUMENT_IN_STEP_TEXT = /([-+]?[0-9]*\.?[0-9]+)|"([^"<]+)"/g;
|
|
19
|
+
|
|
20
|
+
// A plain unsigned integer keeps the familiar (\d+). Any other number the detection above
|
|
21
|
+
// accepts (a sign, a decimal point, a leading dot) gets a capture that matches that same shape,
|
|
22
|
+
// because (\d+) cannot match "3.14", "-5" or ".5" and the suggested matcher would then fail to
|
|
23
|
+
// bind the very step it was suggested for. Inherited from jest-cucumber's generator; found by
|
|
24
|
+
// fuzzing on 2026-10-08.
|
|
25
|
+
const INTEGER_CAPTURE = "(\\d+)";
|
|
26
|
+
const NUMBER_CAPTURE = "([-+]?\\d*\\.?\\d+)";
|
|
27
|
+
// A quoted value never holds a double quote (the detection above stops at one), so the capture
|
|
28
|
+
// stops at one too. jest-cucumber's "(.*)" is greedy: it spans several quoted arguments and the
|
|
29
|
+
// text between them, so the matcher for `"a" "b"` also matched `"a" "b" 3 "c"` and two pasted
|
|
30
|
+
// definitions were refused as ambiguous. And "." matches no line terminator, which a quoted
|
|
31
|
+
// value may hold (a lone \r, U+2028 and U+2029 survive Gherkin's \r?\n line split); a negated
|
|
32
|
+
// class matches every character but the quote. Found by the review of the PR #16 fixes.
|
|
33
|
+
const QUOTED_CAPTURE = '"([^"]*)"';
|
|
34
|
+
|
|
35
|
+
// One capture for every value an argument position takes across the steps it must bind, so
|
|
36
|
+
// one definition binds them all: the integer capture only if every value is an unsigned
|
|
37
|
+
// integer.
|
|
38
|
+
const captureForNumbers = (numberTexts) =>
|
|
39
|
+
numberTexts.every((numberText) => /^\d+$/.test(numberText))
|
|
40
|
+
? INTEGER_CAPTURE
|
|
41
|
+
: NUMBER_CAPTURE;
|
|
42
|
+
|
|
43
|
+
const captureFor = (kind, valueTexts) =>
|
|
44
|
+
kind === "number" ? captureForNumbers(valueTexts) : QUOTED_CAPTURE;
|
|
45
|
+
|
|
46
|
+
const VERB_FOR_BUCKET = {
|
|
47
|
+
given: "Given",
|
|
48
|
+
when: "When",
|
|
49
|
+
then: "Then",
|
|
50
|
+
and: "And",
|
|
51
|
+
but: "But",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// The matcher is emitted as a regex LITERAL, so "/" must be escaped too: unescaped it ends the
|
|
55
|
+
// literal early and the suggested code is not valid JavaScript. The same holds for the two
|
|
56
|
+
// Unicode line terminators, U+2028 and U+2029: Gherkin splits lines on \r?\n only, so they can
|
|
57
|
+
// reach step text, and JavaScript forbids a line terminator inside a regex literal. They are
|
|
58
|
+
// written as \u escapes, which match the same character. A lone \r reaches step text the same
|
|
59
|
+
// way and ends a regex or a string literal just as early, so it is written \r in both.
|
|
60
|
+
const escapedLineTerminators = (text) =>
|
|
61
|
+
text
|
|
62
|
+
.replace(/\r/g, "\\r")
|
|
63
|
+
.replace(/\u2028/g, "\\u2028")
|
|
64
|
+
.replace(/\u2029/g, "\\u2029");
|
|
65
|
+
|
|
66
|
+
const escapedForRegex = (text) =>
|
|
67
|
+
escapedLineTerminators(text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&"));
|
|
68
|
+
|
|
69
|
+
const escapedForDoubleQuotes = (text) =>
|
|
70
|
+
escapedLineTerminators(text.replace(/[\\"]/g, "\\$&"));
|
|
71
|
+
|
|
72
|
+
// Every argument the step text carries, as {start, end, kind, value} in text order. One pass
|
|
73
|
+
// with indices rather than a sequence of string replacements: a replacement would rewrite the
|
|
74
|
+
// first occurrence of the matched text wherever it sat, which is the wrong one as soon as a
|
|
75
|
+
// step says the same number twice.
|
|
76
|
+
//
|
|
77
|
+
// matchAll clones the pattern internally, so a shared lastIndex cannot leak between calls and
|
|
78
|
+
// the module-level regex needs no defensive copy of its own.
|
|
79
|
+
const argumentsInStepText = (stepText) =>
|
|
80
|
+
[...stepText.matchAll(ARGUMENT_IN_STEP_TEXT)].map((match) => ({
|
|
81
|
+
start: match.index,
|
|
82
|
+
end: match.index + match[0].length,
|
|
83
|
+
kind: match[1] === undefined ? "quoted" : "number",
|
|
84
|
+
value: match[1] === undefined ? match[2] : match[1],
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// A step's SHAPE: its verb and its literal text, with each argument reduced to its kind. Steps
|
|
88
|
+
// of one shape are bound by one definition, whatever their values.
|
|
89
|
+
const shapeOf = (step) => {
|
|
90
|
+
const argumentsFound = argumentsInStepText(step.stepText);
|
|
91
|
+
const shape = argumentsFound.reduce(
|
|
92
|
+
(built, argument) => ({
|
|
93
|
+
text:
|
|
94
|
+
built.text +
|
|
95
|
+
step.stepText.slice(built.consumedTo, argument.start) +
|
|
96
|
+
`\u0000${argument.kind}\u0000`,
|
|
97
|
+
consumedTo: argument.end,
|
|
98
|
+
}),
|
|
99
|
+
{ text: "", consumedTo: 0 },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return `${step.keyword}\u0000${
|
|
103
|
+
shape.text + step.stepText.slice(shape.consumedTo)
|
|
104
|
+
}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// An ANCHORED regex literal over the step's own text, with each detected argument replaced by
|
|
108
|
+
// a capture group and everything around it escaped so it matches literally.
|
|
109
|
+
const matcherRegexFor = (stepText, argumentsFound) => {
|
|
110
|
+
const pattern = argumentsFound.reduce(
|
|
111
|
+
(built, argument) => ({
|
|
112
|
+
source:
|
|
113
|
+
built.source +
|
|
114
|
+
escapedForRegex(stepText.slice(built.consumedTo, argument.start)) +
|
|
115
|
+
argument.capture,
|
|
116
|
+
consumedTo: argument.end,
|
|
117
|
+
}),
|
|
118
|
+
{ source: "", consumedTo: 0 },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return `/^${
|
|
122
|
+
pattern.source + escapedForRegex(stepText.slice(pattern.consumedTo))
|
|
123
|
+
}$/`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// One parameter per detected argument, then the Gherkin argument LAST if the step carries one,
|
|
127
|
+
// named for the shape it actually is. Fusion hands a step its captures and then its Gherkin
|
|
128
|
+
// argument, so this order is the order the values arrive in.
|
|
129
|
+
//
|
|
130
|
+
// The Gherkin argument is read by PRESENCE: an empty docstring is "" and still earns a
|
|
131
|
+
// parameter, because the step really does receive it.
|
|
132
|
+
const parametersFor = (argumentsFound, stepArgument) => {
|
|
133
|
+
const captures = argumentsFound.map((each, index) => `arg${index}`);
|
|
134
|
+
|
|
135
|
+
if (stepArgument == null) return captures;
|
|
136
|
+
|
|
137
|
+
return captures.concat([
|
|
138
|
+
typeof stepArgument === "string" ? "docString" : "table",
|
|
139
|
+
]);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const matcherFor = (stepText, argumentsFound) =>
|
|
143
|
+
argumentsFound.length > 0
|
|
144
|
+
? matcherRegexFor(stepText, argumentsFound)
|
|
145
|
+
: `"${escapedForDoubleQuotes(stepText)}"`;
|
|
146
|
+
|
|
147
|
+
// The starter code for steps of ONE shape: the verb for their keyword, a matcher built from the
|
|
148
|
+
// first step's text with each capture wide enough for every step's value at that position, and
|
|
149
|
+
// a step function with the parameters they imply and an empty body to fill in. The Gherkin
|
|
150
|
+
// argument parameter is named for the first step that carries one.
|
|
151
|
+
const starterCodeForShape = (steps) => {
|
|
152
|
+
const valuesByStep = steps.map((step) => argumentsInStepText(step.stepText));
|
|
153
|
+
const argumentsFound = valuesByStep[0].map((argument, position) => ({
|
|
154
|
+
start: argument.start,
|
|
155
|
+
end: argument.end,
|
|
156
|
+
capture: captureFor(
|
|
157
|
+
argument.kind,
|
|
158
|
+
valuesByStep.map((values) => values[position].value),
|
|
159
|
+
),
|
|
160
|
+
}));
|
|
161
|
+
const stepWithArgument = steps.find((step) => step.stepArgument != null);
|
|
162
|
+
const parameters = parametersFor(
|
|
163
|
+
argumentsFound,
|
|
164
|
+
stepWithArgument ? stepWithArgument.stepArgument : null,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return `${VERB_FOR_BUCKET[steps[0].keyword]}(${matcherFor(
|
|
168
|
+
steps[0].stepText,
|
|
169
|
+
argumentsFound,
|
|
170
|
+
)}, (${parameters.join(", ")}) => {});`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// The unbound steps of one feature, each named once (a Background step is unbound in every
|
|
174
|
+
// scenario), grouped by shape in order of first appearance. Every row of an outline, and any
|
|
175
|
+
// two steps that differ only in their values, share a shape. The consumer writes ONE definition
|
|
176
|
+
// for them: two definitions would be refused when pasted, as a duplicate when their matchers
|
|
177
|
+
// are equal, and as ambiguous when one is wider ("(\d+)" beside a decimal capture both match
|
|
178
|
+
// "1"). A step with a table and the same step without one share a shape too; parameters do not
|
|
179
|
+
// make two definitions distinct.
|
|
180
|
+
const stepsByShape = (unboundSteps) => {
|
|
181
|
+
const groups = new Map();
|
|
182
|
+
const named = new Set();
|
|
183
|
+
unboundSteps.forEach((step) => {
|
|
184
|
+
const identity = `${step.keyword}\u0000${step.stepText}`;
|
|
185
|
+
if (named.has(identity)) return;
|
|
186
|
+
named.add(identity);
|
|
187
|
+
const shape = shapeOf(step);
|
|
188
|
+
if (!groups.has(shape)) groups.set(shape, []);
|
|
189
|
+
groups.get(shape).push(step);
|
|
190
|
+
});
|
|
191
|
+
return [...groups.values()];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// The refusal for every unbound step of one feature, numbered, in feature order.
|
|
195
|
+
//
|
|
196
|
+
// Refusing on the first unbound step instead bills the consumer one full re-run per missing
|
|
197
|
+
// definition, and the message would be telling the truth about a fraction of the state.
|
|
198
|
+
//
|
|
199
|
+
// The phrase "No step definition matches:" followed by the step text in double quotes opens
|
|
200
|
+
// every entry and appears nowhere else in the message. That is a designed constraint, not a
|
|
201
|
+
// turn of phrase: it is what the existing undefined-step guard and two seam regressions match
|
|
202
|
+
// on, and what lets a reader count the entries. One entry is one definition to write; the
|
|
203
|
+
// other steps it binds follow on lines of their own, so every unbound step is still named, and
|
|
204
|
+
// the header counts steps, not entries.
|
|
205
|
+
const unmatchedStepRefusal = (featureTitle, unboundSteps) => {
|
|
206
|
+
const shapes = stepsByShape(unboundSteps);
|
|
207
|
+
const stepCount = shapes.reduce((count, steps) => count + steps.length, 0);
|
|
208
|
+
const entries = shapes.map(
|
|
209
|
+
(steps, index) =>
|
|
210
|
+
` ${index + 1}. No step definition matches: "${steps[0].stepText}"\n` +
|
|
211
|
+
steps
|
|
212
|
+
.slice(1)
|
|
213
|
+
.map((step) => ` nor: "${step.stepText}"\n`)
|
|
214
|
+
.join("") +
|
|
215
|
+
` ${starterCodeForShape(steps)}`,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return new Error(
|
|
219
|
+
`Fusion found ${stepCount} step${
|
|
220
|
+
stepCount === 1 ? "" : "s"
|
|
221
|
+
} in the feature "${featureTitle}" that no registered step definition matches.\n\n` +
|
|
222
|
+
`WHY: Fusion runs each step through the definition registered for that step's own\n` +
|
|
223
|
+
` Gherkin keyword, so a step with no definition has nothing to run and the\n` +
|
|
224
|
+
` scenario holding it cannot be reported honestly.\n` +
|
|
225
|
+
`HOW: register a definition for each entry below. The starter code under each one is\n` +
|
|
226
|
+
` the verb, the matcher and the parameters its steps need, and one definition\n` +
|
|
227
|
+
` binds every step its entry names. Or pass\n` +
|
|
228
|
+
` errors: { stepsMustMatchFeatureFile: false } to have the scenarios holding them\n` +
|
|
229
|
+
` reported as skipped tests instead.\n\n` +
|
|
230
|
+
`${entries.join("\n\n")}\n`,
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// The starter code for ONE step, as the refusal would suggest it on its own. Kept as an export
|
|
235
|
+
// for probes that drive the generator directly (plan, 2026-10-08 refactor: "keep the
|
|
236
|
+
// starterCodeFor export").
|
|
237
|
+
const starterCodeFor = (step) => starterCodeForShape([step]);
|
|
238
|
+
|
|
239
|
+
export { starterCodeFor };
|
|
240
|
+
export { unmatchedStepRefusal };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The option defaults, the global layer and the merge, owned here rather than by the removed
|
|
2
|
+
// intermediary, whose configuration module did exactly this (configuration.js:26-36: the
|
|
3
|
+
// defaults, the global object, then the per-call options, with `errors: true` expanded).
|
|
4
|
+
//
|
|
5
|
+
// Three layers, lowest first: the defaults, the global a consumer sets once through
|
|
6
|
+
// setFusionConfiguration, and the options passed to one Fusion() call. The per-call layer wins,
|
|
7
|
+
// which is the precedence the documentation has always stated.
|
|
8
|
+
//
|
|
9
|
+
// WHERE THE GLOBAL LAYER LIVES, AND WHY IT IS PER FILE. In src/shared-state.js, on globalThis,
|
|
10
|
+
// so that every copy of the dual package in a test file sees it: a setup script may require()
|
|
11
|
+
// the package while the steps import it. Jest gives each test file its own global and runs
|
|
12
|
+
// setupFiles inside it, so the global a setup script sets is visible to that file's Fusion
|
|
13
|
+
// calls and cannot reach another file. It is per-file configuration, which is the whole reason
|
|
14
|
+
// this option can be set in one place.
|
|
15
|
+
|
|
16
|
+
import { describeValue } from "./value-description.js";
|
|
17
|
+
import { shared, replaceShared } from "./shared-state.js";
|
|
18
|
+
|
|
19
|
+
// Fusion's three validation keys. stepsMustMatchFeatureFile decides between the
|
|
20
|
+
// unmatched-step refusal and a visible skipped test; scenariosMustMatchFeatureFile gates the
|
|
21
|
+
// duplicate declared-title check; allowScenariosNotInFeatureFile is accepted and vestigial,
|
|
22
|
+
// because Fusion generates the scenario definitions from the feature file itself and so has
|
|
23
|
+
// no scenario outside it to allow.
|
|
24
|
+
const everyValidation = (on) => ({
|
|
25
|
+
stepsMustMatchFeatureFile: on,
|
|
26
|
+
scenariosMustMatchFeatureFile: on,
|
|
27
|
+
allowScenariosNotInFeatureFile: on,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Validation is ON by default: an unmatched step fails loudly, and a duplicated declared
|
|
31
|
+
// title is refused, unless the consumer asks otherwise.
|
|
32
|
+
const defaultOptions = () => ({
|
|
33
|
+
errors: everyValidation(true),
|
|
34
|
+
tagFilter: undefined,
|
|
35
|
+
scenarioNameTemplate: undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// An option whose value is `undefined` is NOT SET, at every layer and inside `errors`, so it
|
|
39
|
+
// never overrides the layer below. Forwarding an unset environment variable,
|
|
40
|
+
// `{ tagFilter: process.env.TAGS }`, is the ordinary way to write one, and a key-wise
|
|
41
|
+
// Object.assign would otherwise copy that `undefined` over a global the consumer set on purpose
|
|
42
|
+
// (finding F4 of the PR #16 review). `errors: undefined` already meant "no change"; this makes
|
|
43
|
+
// every key agree with it.
|
|
44
|
+
const keysThatAreSet = (options) =>
|
|
45
|
+
Object.fromEntries(
|
|
46
|
+
Object.entries(options).filter(([, value]) => value !== undefined),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// What ONE layer's `errors` contributes, in the three forms a consumer may write it:
|
|
50
|
+
//
|
|
51
|
+
// true -> every key on. An explicit reset of all three.
|
|
52
|
+
// false -> every key off. The same shorthand in the other direction.
|
|
53
|
+
// { one key } -> ONLY that key. Naming one validation says nothing about the others, so the
|
|
54
|
+
// keys it did not mention keep whatever the layer below set.
|
|
55
|
+
//
|
|
56
|
+
// Returning only the named keys is what makes the layers compose. Expanding a partial object
|
|
57
|
+
// over all-true here instead would let a per-call object naming one key silently switch a
|
|
58
|
+
// DIFFERENT key back on, undoing a global the consumer set deliberately.
|
|
59
|
+
const errorsNamedBy = (errors) => {
|
|
60
|
+
if (errors === true) return everyValidation(true);
|
|
61
|
+
if (errors === false) return everyValidation(false);
|
|
62
|
+
// Anything else but an object, null or undefined is refused: `errors: 0` was read as "no
|
|
63
|
+
// change", leaving every validation on while the consumer meant off (fresh fuzz of PR #16).
|
|
64
|
+
if (errors !== undefined && errors !== null && !isAnOptionObject(errors))
|
|
65
|
+
throw new Error(
|
|
66
|
+
`The errors option must be true, false or an object, but was given ${describeValue(
|
|
67
|
+
errors,
|
|
68
|
+
)}.\n\n` +
|
|
69
|
+
`HOW: pass errors: false to switch every validation off, or an object naming the ones\n` +
|
|
70
|
+
` to change, for example errors: { stepsMustMatchFeatureFile: false }.`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return keysThatAreSet(errors || {});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The middle layer. Replaced wholesale by each setFusionConfiguration call, never merged into:
|
|
77
|
+
// replace is what the previous setter did, and it is the only semantics under which a consumer
|
|
78
|
+
// can CLEAR a global they set earlier.
|
|
79
|
+
//
|
|
80
|
+
// Kept in the shared store (src/shared-state.js), not in a module variable: a global set
|
|
81
|
+
// through one copy of the dual package was otherwise lost to the other (measured: the mixed
|
|
82
|
+
// consumer in test/specs/baseline/assert-packaged-consumer.js).
|
|
83
|
+
const globalOptions = () => shared("globalOptions", () => ({}));
|
|
84
|
+
|
|
85
|
+
const isAnOptionObject = (candidate) =>
|
|
86
|
+
typeof candidate === "object" &&
|
|
87
|
+
candidate !== null &&
|
|
88
|
+
!Array.isArray(candidate);
|
|
89
|
+
|
|
90
|
+
const refuseNonOptionObject = (whatItWasGiven) =>
|
|
91
|
+
new Error(
|
|
92
|
+
`setFusionConfiguration needs an options object.\n\n` +
|
|
93
|
+
`WHAT: it was given ${describeValue(whatItWasGiven)}.\n` +
|
|
94
|
+
`WHY: the argument is merged under every Fusion() call of this test file, so anything\n` +
|
|
95
|
+
` that is not an options object leaves the whole file silently unconfigured, and\n` +
|
|
96
|
+
` the mistake then looks like a bug in the feature files.\n` +
|
|
97
|
+
`HOW: pass the same object a Fusion() call accepts, for example\n` +
|
|
98
|
+
` setFusionConfiguration({ tagFilter: "@smoke and not @slow" }). The accepted keys\n` +
|
|
99
|
+
` are errors, tagFilter, scenarioNameTemplate and loadRelativePath; an unknown key\n` +
|
|
100
|
+
` is ignored, exactly as it is per call.`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Refused here, at the call, because that is the one place and time the consumer can act: a
|
|
104
|
+
// setup script runs before any step definition file loads.
|
|
105
|
+
const setFusionConfiguration = (optionsForEveryFusionCall) => {
|
|
106
|
+
if (!isAnOptionObject(optionsForEveryFusionCall))
|
|
107
|
+
throw refuseNonOptionObject(optionsForEveryFusionCall);
|
|
108
|
+
|
|
109
|
+
// Copied, so the stored global is ours: a consumer who later mutates the object they passed
|
|
110
|
+
// does not silently reconfigure the rest of their file.
|
|
111
|
+
replaceShared("globalOptions", Object.assign({}, optionsForEveryFusionCall));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// `errors` is merged key-wise ACROSS the layers, not layer-over-layer as a whole value, which
|
|
115
|
+
// is the same promise value 2 made within one layer: naming one validation says nothing about
|
|
116
|
+
// the others, whichever layer named it. A top-level Object.assign would instead replace a
|
|
117
|
+
// global errors object wholesale with a per-call one, losing a key the consumer only mentioned
|
|
118
|
+
// once.
|
|
119
|
+
//
|
|
120
|
+
// The result always carries all three keys, because the fold starts from them. So no reader
|
|
121
|
+
// downstream has to know that `errors` has three spellings or three layers.
|
|
122
|
+
const errorsAcross = (layers) =>
|
|
123
|
+
layers.reduce(
|
|
124
|
+
(merged, layer) =>
|
|
125
|
+
Object.prototype.hasOwnProperty.call(layer, "errors")
|
|
126
|
+
? Object.assign(merged, errorsNamedBy(layer.errors))
|
|
127
|
+
: merged,
|
|
128
|
+
everyValidation(true),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const mergeFusionOptions = (perCallOptions) => {
|
|
132
|
+
const perCall = perCallOptions || {};
|
|
133
|
+
// Lowest to highest, and a fresh object every call: the per-call options are never written
|
|
134
|
+
// into the global, so one file's option cannot configure another file of the same run.
|
|
135
|
+
const merged = Object.assign(
|
|
136
|
+
defaultOptions(),
|
|
137
|
+
keysThatAreSet(globalOptions()),
|
|
138
|
+
keysThatAreSet(perCall),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return Object.assign(merged, {
|
|
142
|
+
errors: errorsAcross([globalOptions(), perCall]),
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export { setFusionConfiguration };
|
|
147
|
+
export { mergeFusionOptions };
|