gherkin-node-test 0.1.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/LICENSE +21 -0
- package/README.md +251 -0
- package/index.js +534 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bing Ho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# gherkin-node-test
|
|
2
|
+
|
|
3
|
+
**The smallest honest Gherkin runner.** Zero dependencies, no build step, no
|
|
4
|
+
CLI — it turns `.feature` files into real [`node:test`](https://nodejs.org/api/test.html)
|
|
5
|
+
tests, and it treats every silence as a bug. One file, ~520 lines, small enough
|
|
6
|
+
to read in one sitting or to vendor outright.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i -D gherkin-node-test # or just copy index.js into your repo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why another BDD tool
|
|
13
|
+
|
|
14
|
+
There are excellent Gherkin runners already — [cucumber-js](https://github.com/cucumber/cucumber-js)
|
|
15
|
+
if you want the full standard and its platform, [vitest-cucumber](https://github.com/amiceli/vitest-cucumber)
|
|
16
|
+
if you live in Vitest. This one exists for a different reason, and if the reason
|
|
17
|
+
doesn't resonate, use those instead.
|
|
18
|
+
|
|
19
|
+
This runner came out of an experiment in **agent-driven development with strict
|
|
20
|
+
BDD**: a workflow where a human writes and owns the Gherkin feature files, and
|
|
21
|
+
coding agents write essentially all of the implementation. In that workflow the
|
|
22
|
+
feature files aren't documentation — they're the **control layer**. They're the
|
|
23
|
+
one artifact the human actually reads, audits, and carries between
|
|
24
|
+
implementations. Everything underneath is regenerable.
|
|
25
|
+
|
|
26
|
+
That inverts what matters in a test harness. When no human reads every line of
|
|
27
|
+
the code, the harness is the only witness — and the failure mode that kills you
|
|
28
|
+
is not a crash. It's a **false green**: a suite that says "all your acceptance
|
|
29
|
+
criteria hold" when some of them were never checked. Crashes get fixed;
|
|
30
|
+
silences compound.
|
|
31
|
+
|
|
32
|
+
False greens have specific, boring causes. Each one is a design decision here:
|
|
33
|
+
|
|
34
|
+
| How suites lie | What this runner does about it |
|
|
35
|
+
|---|---|
|
|
36
|
+
| The parser half-understands a construct and silently drops steps or table cells | Unsupported syntax is a **hard error with `file:line`** — doc strings, `Rule:`, ragged tables, a table row missing its closing `\|`, all of it. Never a best-effort parse. |
|
|
37
|
+
| A scenario with zero bound steps "passes" | Unbound scenarios register as `node:test` TODO — and TODO is *reported as passing*, so the high-level runner **fails the suite** on any unbound step unless the feature is explicitly listed as work-in-progress. Rewording one step can't silently un-test a feature. |
|
|
38
|
+
| A step matches two definitions and one silently wins | Ambiguity is **asserted against per feature**, at suite start, for every step. |
|
|
39
|
+
| Step definitions collide across the suite's global namespace | There is no global namespace: **each feature gets its own registry**. An agent editing one feature structurally cannot break another's bindings. |
|
|
40
|
+
| A scaffolded step stub passes vacuously | Missing-step errors include a **paste-ready definition whose body throws** `pending`. You cannot paste your way to a false green. |
|
|
41
|
+
| A failing assertion leaks the temp dir / process it was about to clean up | `world.defer(fn)` runs cleanup LIFO **even when a step fails**. |
|
|
42
|
+
| A typo'd `@skip` tag is silently inert | Misplaced tags, dangling tags, and near-miss tags (`@Skip`, `@ONLY`) are **loud errors** — worst case is `@only`, where the typo would silently *deselect* the scenario under `--test-only`. |
|
|
43
|
+
|
|
44
|
+
The same properties turn out to be exactly what a coding agent needs, because
|
|
45
|
+
agents act on error output. A located `file:line` error, a failure message
|
|
46
|
+
containing the snippet that fixes it, a ratchet that converts silent decay into
|
|
47
|
+
a red test — these close the agent's write→run→fix loop through the test runner
|
|
48
|
+
itself. None of this was designed "for AI"; it was designed for a human who
|
|
49
|
+
couldn't personally re-read the implementation, which is rapidly becoming
|
|
50
|
+
everyone's situation.
|
|
51
|
+
|
|
52
|
+
And because the runner compiles scenarios into `node:test`, there is no second
|
|
53
|
+
toolchain: one command (`node --test`) runs unit tests and acceptance criteria
|
|
54
|
+
together, with watch mode, coverage, and CI reporters inherited from Node
|
|
55
|
+
itself.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
features/
|
|
61
|
+
counter.feature
|
|
62
|
+
test/
|
|
63
|
+
features.test.js
|
|
64
|
+
steps/counter.steps.js
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```gherkin
|
|
68
|
+
# features/counter.feature
|
|
69
|
+
Feature: Counter
|
|
70
|
+
Scenario: increment once
|
|
71
|
+
Given a counter at 0
|
|
72
|
+
When I add 5
|
|
73
|
+
Then the counter is 5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// test/features.test.js
|
|
78
|
+
const path = require('node:path');
|
|
79
|
+
const { runFeatures } = require('gherkin-node-test');
|
|
80
|
+
|
|
81
|
+
runFeatures(path.join(__dirname, '..', 'features'), {
|
|
82
|
+
// feature basename → its step definer
|
|
83
|
+
'counter': require('./steps/counter.steps'),
|
|
84
|
+
}, { wip: [] }); // basenames still bootstrapping (unbound steps allowed as TODO)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
// test/steps/counter.steps.js
|
|
89
|
+
const assert = require('node:assert');
|
|
90
|
+
|
|
91
|
+
module.exports = (reg) => {
|
|
92
|
+
reg.define(/^a counter at (\d+)$/, (w, n) => { w.count = Number(n); });
|
|
93
|
+
reg.define(/^I add (\d+)$/, (w, n) => { w.count += Number(n); });
|
|
94
|
+
reg.define(/^the counter is (\d+)$/, (w, n) => assert.strictEqual(w.count, Number(n)));
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
node --test
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each scenario becomes one `node:test` test named `Feature :: Scenario`. A fresh
|
|
103
|
+
`world` object is created per scenario; `Background` steps run before each one.
|
|
104
|
+
Alongside the scenarios, `runFeatures` registers the guard tests described
|
|
105
|
+
above (ambiguity, unbound steps, orphaned definer keys).
|
|
106
|
+
|
|
107
|
+
If a step is missing, the guard failure hands you the definition:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
✖ counter :: step definitions are complete and unambiguous
|
|
111
|
+
unbound steps would register as TODO (passing); bind them or add 'counter' to wip:
|
|
112
|
+
|
|
113
|
+
// I add 5
|
|
114
|
+
reg.define(/^I add (\d+)$/, (w, p1) => {
|
|
115
|
+
throw new Error('pending: implement this step');
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Supported grammar
|
|
120
|
+
|
|
121
|
+
| Construct | Notes |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `Feature:` | exactly one per file, required |
|
|
124
|
+
| `Background:` | optional, at most one, must precede every `Scenario` |
|
|
125
|
+
| `Scenario:` | free-text title |
|
|
126
|
+
| `Scenario Outline:` | requires exactly one `Examples:` table |
|
|
127
|
+
| `Examples:` | a header row then ≥1 data row, `\|`-delimited |
|
|
128
|
+
| `<placeholder>` | substituted from the Examples columns — in step text **and** step data tables; every `<name>` must match a column |
|
|
129
|
+
| Steps | `Given` `When` `Then` `And` `But` `*`, followed by step text |
|
|
130
|
+
| Step data tables | `\|` rows after a step attach to it; the step function receives a **`DataTable`** as its last argument |
|
|
131
|
+
| Tags | `@skip` / `@todo` / `@only` map to the `node:test` options of the same name; tags on `Feature:` apply to all its scenarios; any other tag is carried on `scenario.tags` with no runtime effect |
|
|
132
|
+
| `# comment` | ignored anywhere |
|
|
133
|
+
| Feature narrative | the `As a… / I want… / So that…` prose block is ignored |
|
|
134
|
+
|
|
135
|
+
Table cells honor the Gherkin escapes `\|` (literal pipe), `\\` (literal
|
|
136
|
+
backslash) and `\n` (newline); a backslash before any other character is
|
|
137
|
+
literal, so cells like `C:\Temp` or `Cmd+\` need no escaping.
|
|
138
|
+
|
|
139
|
+
Tag semantics: `@skip` never executes the scenario (but its steps must still be
|
|
140
|
+
*bound* — skip means "don't run", never "don't bind"); `@todo` executes it but
|
|
141
|
+
its failures don't fail the run; `@only` is honored under `node --test
|
|
142
|
+
--test-only`.
|
|
143
|
+
|
|
144
|
+
### Step matching and `DataTable`
|
|
145
|
+
|
|
146
|
+
Steps are matched by **`RegExp` or exact string** — capture groups become step
|
|
147
|
+
arguments. There are no Cucumber Expressions (`{int}`, `{string}`); write a
|
|
148
|
+
regex.
|
|
149
|
+
|
|
150
|
+
A step with a data table receives a `DataTable` as its **last** argument,
|
|
151
|
+
API-compatible with cucumber-js so step code (and muscle memory) ports both
|
|
152
|
+
ways:
|
|
153
|
+
|
|
154
|
+
```gherkin
|
|
155
|
+
Given these users
|
|
156
|
+
| name | role |
|
|
157
|
+
| ada | admin |
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
reg.define(/^these users$/, (w, table) => {
|
|
162
|
+
table.raw(); // [['name','role'],['ada','admin']] (defensive copy)
|
|
163
|
+
table.rows(); // rows minus the header
|
|
164
|
+
table.hashes(); // [{ name: 'ada', role: 'admin' }]
|
|
165
|
+
table.rowsHash(); // two-column table → { key: value } map
|
|
166
|
+
table.transpose(); // columns become rows → new DataTable
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Scenario-scoped cleanup: `world.defer(fn)`
|
|
171
|
+
|
|
172
|
+
Cleanup runs after the scenario in reverse (LIFO) order — **including when a
|
|
173
|
+
step failed**. The step failure, if any, outranks cleanup errors; if the steps
|
|
174
|
+
passed, the first cleanup error fails the scenario. (`defer` is a reserved key
|
|
175
|
+
on the world object.)
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
reg.define(/^a scratch dir$/, (w) => {
|
|
179
|
+
w.dir = fs.mkdtempSync(prefix);
|
|
180
|
+
w.defer(() => fs.rmSync(w.dir, { recursive: true, force: true }));
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Deliberately unsupported — and rejected loudly
|
|
185
|
+
|
|
186
|
+
The design rule: **parse the supported subset correctly; reject everything else
|
|
187
|
+
with a `file:line` error; never parse a feature file vacuously.** Each of these
|
|
188
|
+
throws `GherkinSyntaxError` with the offending line number:
|
|
189
|
+
|
|
190
|
+
| Rejected | Why it's rejected, not ignored |
|
|
191
|
+
|---|---|
|
|
192
|
+
| Doc strings (`"""` / ` ``` `) | would be mis-read line-by-line as steps |
|
|
193
|
+
| Multiple `Examples:` per Outline | the 2nd header row would corrupt the expansion |
|
|
194
|
+
| `Examples:` with no data rows / no header | would expand to zero (vacuous) scenarios |
|
|
195
|
+
| Ragged table rows (Examples **or** step tables) | column misalignment would pass silently |
|
|
196
|
+
| A table row missing its closing `\|` | the trailing cell would be silently dropped |
|
|
197
|
+
| A table row with no preceding step | the data would silently belong to nothing |
|
|
198
|
+
| Unknown `<placeholder>` | almost always a typo; would leak `<name>` into a step |
|
|
199
|
+
| A `Scenario`/`Scenario Outline` with no steps | would run zero assertions and pass vacuously |
|
|
200
|
+
| A step *after* its `Examples:` table | malformed ordering; the step would mis-attach |
|
|
201
|
+
| Tags anywhere but immediately before `Feature:` / `Scenario:` / `Scenario Outline:` | a mis-placed `@skip` would silently not skip |
|
|
202
|
+
| A near-miss semantic tag (`@Skip`, `@SKIP`, `@Only`, …) | would be silently inert — worst for `@only`, which would silently *deselect* under `--test-only` |
|
|
203
|
+
| `Rule:` (Gherkin 6) | grouping would be silently flattened |
|
|
204
|
+
| A step before any `Scenario`/`Background` | would be silently discarded |
|
|
205
|
+
| A 2nd `Feature:` / `Background:`, or `Background:` after a `Scenario` | ambiguous scope |
|
|
206
|
+
|
|
207
|
+
Two non-features by design, with no dedicated error: **Cucumber Expressions**
|
|
208
|
+
(write a regex) and **i18n** (English keywords only — a non-English keyword
|
|
209
|
+
reads as narrative; if that empties a scenario, the no-steps guard fires, so it
|
|
210
|
+
still can't pass vacuously).
|
|
211
|
+
|
|
212
|
+
## When *not* to use this
|
|
213
|
+
|
|
214
|
+
- You want tag-expression filtering, parallel workers, retries, HTML
|
|
215
|
+
living-documentation reports, or attachments → **cucumber-js**. That's a
|
|
216
|
+
platform; this is a file.
|
|
217
|
+
- You're on Vitest/Jest → **vitest-cucumber** / **jest-cucumber** integrate
|
|
218
|
+
with the runner you already have.
|
|
219
|
+
- You need the full Gherkin grammar (doc strings, `Rule:`, i18n) →
|
|
220
|
+
**@cucumber/gherkin** is the real parser.
|
|
221
|
+
|
|
222
|
+
The niche here is exactly: Gherkin on `node:test`, zero dependencies, loud by
|
|
223
|
+
construction.
|
|
224
|
+
|
|
225
|
+
## API
|
|
226
|
+
|
|
227
|
+
| Export | Purpose |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `runFeatures(dir, definers, { wip }?)` | **high-level runner**: discover every `.feature`, scoped registries, guard tests |
|
|
230
|
+
| `parseFeature(text, filename?)` | parse → `{ feature, background, scenarios }`; throws `GherkinSyntaxError` |
|
|
231
|
+
| `StepRegistry` | `.define(pattern, fn)` / `.find(text)` |
|
|
232
|
+
| `executeSteps(steps, registry, world?)` | run a flat step list against a shared world (installs `world.defer`) |
|
|
233
|
+
| `runFeature(parsed, registry)` | register a `node:test` per scenario (tags mapped, unbound → TODO) |
|
|
234
|
+
| `runFeatureFile(file, registry)` | read + parse + run a single `.feature` file |
|
|
235
|
+
| `DataTable` | cucumber-compatible step table: `raw` / `rows` / `hashes` / `rowsHash` / `transpose` |
|
|
236
|
+
| `buildSnippet(text)` | paste-ready step definition for an unbound step (body throws) |
|
|
237
|
+
| `GherkinSyntaxError` | thrown on unsupported/malformed syntax; carries `.line` |
|
|
238
|
+
|
|
239
|
+
## Provenance
|
|
240
|
+
|
|
241
|
+
Extracted from [ccr](https://github.com/bingh0/ccr), where it runs ~15 feature
|
|
242
|
+
files / ~180 scenarios as the acceptance layer of a shipping CLI — written and
|
|
243
|
+
hardened *by* the agent-driven BDD workflow it advocates, including adversarial
|
|
244
|
+
review of its own guards (the closing-pipe check exists because that review
|
|
245
|
+
found the naive parser silently dropping a cell). The self-test suite
|
|
246
|
+
(`test/harness.test.js`) includes a rejection test for every guard above, a
|
|
247
|
+
self-proving `@skip` scenario whose only step throws, and an eval of a
|
|
248
|
+
generated snippet proving it's valid JS that matches its own step and fails
|
|
249
|
+
until implemented.
|
|
250
|
+
|
|
251
|
+
MIT © Bing Ho
|
package/index.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// gherkin-node-test
|
|
3
|
+
// A tiny, zero-dependency Gherkin runner on top of Node's built-in test runner.
|
|
4
|
+
//
|
|
5
|
+
// It parses the practical core of Gherkin — Feature / Background / Scenario /
|
|
6
|
+
// Scenario Outline + Examples, with Given·When·Then·And·But·* steps, step-level
|
|
7
|
+
// data tables, and @skip/@todo/@only tags — and turns each scenario into a
|
|
8
|
+
// node:test test(). Scenario Outlines are expanded once per Examples row.
|
|
9
|
+
//
|
|
10
|
+
// The high-level entry point is runFeatures(dir, definers, { wip }): it
|
|
11
|
+
// discovers every *.feature in dir, runs each against its OWN scoped registry
|
|
12
|
+
// (step patterns never leak between features), and registers guard tests that
|
|
13
|
+
// fail on ambiguous steps, on unbound steps (which would otherwise register as
|
|
14
|
+
// TODO — reported as PASSING by node:test), and on definer keys that match no
|
|
15
|
+
// feature file. A feature still being bootstrapped opts out of the unbound-step
|
|
16
|
+
// ratchet by name via `wip`.
|
|
17
|
+
//
|
|
18
|
+
// SUPPORTED grammar (the practical core, guarded loudly):
|
|
19
|
+
// Feature: one per file, required
|
|
20
|
+
// Background: optional, at most one, before any Scenario
|
|
21
|
+
// Scenario: free text title
|
|
22
|
+
// Scenario Outline: + exactly one Examples: table; <placeholder> substitution
|
|
23
|
+
// Examples: a leading header row then >=1 data row, pipe-delimited
|
|
24
|
+
// Steps: Given | When | Then | And | But | * followed by text
|
|
25
|
+
// Step data tables: | rows after a step attach to it; the step function
|
|
26
|
+
// receives a cucumber-compatible DataTable as its last
|
|
27
|
+
// argument (raw/rows/hashes/rowsHash/transpose). Cells
|
|
28
|
+
// honor \| \\ \n escapes; other backslashes are literal.
|
|
29
|
+
// Tags: @skip / @todo / @only map to the node:test options of
|
|
30
|
+
// the same name (@only needs `node --test --test-only`);
|
|
31
|
+
// tags on Feature: apply to all its scenarios; all other
|
|
32
|
+
// tags (e.g. @AC3) are carried but have no effect.
|
|
33
|
+
// Comments (# ...) and the Feature narrative are ignored.
|
|
34
|
+
//
|
|
35
|
+
// DELIBERATELY NOT SUPPORTED. Structural misuse is REJECTED LOUDLY — each throws
|
|
36
|
+
// a GherkinSyntaxError with a file:line, so a feature file can't pass *vacuously*
|
|
37
|
+
// by being silently mis-parsed:
|
|
38
|
+
// - doc strings (""" or ```) - the Rule: keyword (Gherkin 6)
|
|
39
|
+
// - multiple Examples per Outline - a step after its Examples table
|
|
40
|
+
// - a Scenario/Outline with no steps - a table row with no preceding step
|
|
41
|
+
// - ragged table rows - a table row missing its closing |
|
|
42
|
+
// - tags anywhere but immediately before Feature:/Scenario:/Scenario Outline:
|
|
43
|
+
// Two non-features are NOT special-cased, by design (no dedicated error):
|
|
44
|
+
// - Cucumber Expressions ({int}, …): step text is matched by RegExp/string via
|
|
45
|
+
// StepRegistry — write a regex; there is no {int} expansion.
|
|
46
|
+
// - i18n: English keywords only. A non-English keyword line is treated as
|
|
47
|
+
// narrative and ignored; if that leaves a scenario empty the no-steps guard
|
|
48
|
+
// fires, so it still can't pass vacuously.
|
|
49
|
+
// If you need the real thing, reach for @cucumber/gherkin.
|
|
50
|
+
// See README.md for the full grammar and rationale.
|
|
51
|
+
//
|
|
52
|
+
// No npm deps — Node ≥18 stdlib only. Run with `node --test`.
|
|
53
|
+
|
|
54
|
+
const fs = require('node:fs');
|
|
55
|
+
const path = require('node:path');
|
|
56
|
+
const assert = require('node:assert');
|
|
57
|
+
const { test } = require('node:test');
|
|
58
|
+
|
|
59
|
+
/** @typedef {{ keyword: string, text: string, table?: string[][] }} Step */
|
|
60
|
+
/** @typedef {{ name: string, steps: Step[], line: number, tags: string[] }} Scenario */
|
|
61
|
+
/** @typedef {{ feature: string, background: Step[], scenarios: Scenario[] }} ParsedFeature */
|
|
62
|
+
/** @typedef {(world: Record<string, any>, ...args: any[]) => (void | Promise<void>)} StepFn */
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Thrown when a feature file uses syntax this parser does not support, or a
|
|
66
|
+
* malformed construct it would otherwise mis-read. The message is prefixed with
|
|
67
|
+
* `file:line:` and `.line` carries the 1-based line number.
|
|
68
|
+
*/
|
|
69
|
+
class GherkinSyntaxError extends Error {
|
|
70
|
+
/** @param {string} message @param {number} line */
|
|
71
|
+
constructor(message, line) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'GherkinSyntaxError';
|
|
74
|
+
this.line = line;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} s
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function escapeRegExp(s) {
|
|
83
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Data tables --------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A step's data table, API-compatible with cucumber-js's DataTable so step code
|
|
90
|
+
* (and muscle memory) ports both ways.
|
|
91
|
+
*/
|
|
92
|
+
class DataTable {
|
|
93
|
+
/** @param {string[][]} raw */
|
|
94
|
+
constructor(raw) {
|
|
95
|
+
/** @type {string[][]} */
|
|
96
|
+
this.rawTable = raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {string[][]} a defensive copy of every row */
|
|
100
|
+
raw() { return this.rawTable.map((r) => [...r]); }
|
|
101
|
+
|
|
102
|
+
/** @returns {string[][]} all rows except the first (header) row */
|
|
103
|
+
rows() { return this.raw().slice(1); }
|
|
104
|
+
|
|
105
|
+
/** @returns {Record<string, string>[]} one object per non-header row, keyed by the header row */
|
|
106
|
+
hashes() {
|
|
107
|
+
const [header, ...rest] = this.rawTable;
|
|
108
|
+
return rest.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @returns {Record<string, string>} a two-column table as a key → value map */
|
|
112
|
+
rowsHash() {
|
|
113
|
+
if (this.rawTable.some((r) => r.length !== 2)) {
|
|
114
|
+
throw new Error('rowsHash() requires a table with exactly two columns');
|
|
115
|
+
}
|
|
116
|
+
return Object.fromEntries(this.rawTable.map(([k, v]) => [k, v]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @returns {DataTable} columns become rows */
|
|
120
|
+
transpose() {
|
|
121
|
+
return new DataTable(this.rawTable[0].map((_, i) => this.rawTable.map((r) => r[i])));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Parser -----------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} text raw .feature file contents
|
|
129
|
+
* @param {string} [filename] used only to prefix error messages
|
|
130
|
+
* @returns {ParsedFeature}
|
|
131
|
+
*/
|
|
132
|
+
function parseFeature(text, filename = '<feature>') {
|
|
133
|
+
const lines = text.split(/\r?\n/);
|
|
134
|
+
let feature = '';
|
|
135
|
+
let featureSeen = false;
|
|
136
|
+
let backgroundSeen = false;
|
|
137
|
+
/** @type {Step[]} */
|
|
138
|
+
const background = [];
|
|
139
|
+
/** @type {Scenario[]} */
|
|
140
|
+
const scenarios = [];
|
|
141
|
+
/** @type {Step[] | null} */
|
|
142
|
+
let cur = null; // array currently collecting steps
|
|
143
|
+
/** @type {{ name: string, steps: Step[], header: string[] | null, rows: string[][], examplesSeen: boolean, line: number, tags: string[] } | null} */
|
|
144
|
+
let outline = null; // set while inside a Scenario Outline
|
|
145
|
+
let inExamples = false;
|
|
146
|
+
/** @type {string[]} */
|
|
147
|
+
let featureTags = [];
|
|
148
|
+
/** @type {string[]} */
|
|
149
|
+
let pendingTags = []; // collected @tags awaiting a Feature:/Scenario:/Outline:
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {number} line
|
|
153
|
+
* @param {string} msg
|
|
154
|
+
* @returns {never}
|
|
155
|
+
*/
|
|
156
|
+
const fail = (line, msg) => {
|
|
157
|
+
throw new GherkinSyntaxError(`${filename}:${line}: ${msg}`, line);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Consume pending tags (for a construct that accepts them). */
|
|
161
|
+
const takeTags = () => { const t = pendingTags; pendingTags = []; return t; };
|
|
162
|
+
/** Reject pending tags (for a line that must not carry them). @param {number} lineNo */
|
|
163
|
+
const noTags = (lineNo) => {
|
|
164
|
+
if (pendingTags.length) {
|
|
165
|
+
fail(lineNo, `tags (${pendingTags.join(' ')}) must immediately precede Feature:, Scenario:, or Scenario Outline:`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Split one `| a | b |` row into trimmed cells. Honors Gherkin cell escapes
|
|
171
|
+
* (\| → |, \\ → \, \n → newline); a backslash before any other character is
|
|
172
|
+
* literal. A row that does not end with a closing | is a loud error — the
|
|
173
|
+
* naive split would silently drop the trailing cell.
|
|
174
|
+
* @param {string} line already trimmed, starts with '|'
|
|
175
|
+
* @param {number} lineNo
|
|
176
|
+
* @returns {string[]}
|
|
177
|
+
*/
|
|
178
|
+
const splitRow = (line, lineNo) => {
|
|
179
|
+
/** @type {string[]} */
|
|
180
|
+
const cells = [];
|
|
181
|
+
let buf = '';
|
|
182
|
+
let i = 1;
|
|
183
|
+
while (i < line.length) {
|
|
184
|
+
const c = line[i];
|
|
185
|
+
if (c === '\\' && i + 1 < line.length) {
|
|
186
|
+
const n = line[i + 1];
|
|
187
|
+
if (n === '|' || n === '\\') { buf += n; i += 2; continue; }
|
|
188
|
+
if (n === 'n') { buf += '\n'; i += 2; continue; }
|
|
189
|
+
}
|
|
190
|
+
if (c === '|') { cells.push(buf.trim()); buf = ''; i += 1; continue; }
|
|
191
|
+
buf += c;
|
|
192
|
+
i += 1;
|
|
193
|
+
}
|
|
194
|
+
if (buf.trim() !== '') fail(lineNo, 'table row must end with a closing |');
|
|
195
|
+
if (cells.length === 0) fail(lineNo, 'empty table row');
|
|
196
|
+
return cells;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const flushOutline = () => {
|
|
200
|
+
if (!outline) return;
|
|
201
|
+
const { name, steps, header, rows, examplesSeen, line, tags } = outline;
|
|
202
|
+
if (steps.length === 0) fail(line, `Scenario Outline "${name}" has no steps`);
|
|
203
|
+
if (!examplesSeen) fail(line, 'Scenario Outline has no Examples: block');
|
|
204
|
+
if (!header) fail(line, 'Scenario Outline Examples: has no header row');
|
|
205
|
+
if (rows.length === 0) fail(line, 'Scenario Outline Examples: has a header but no data rows');
|
|
206
|
+
rows.forEach((row, i) => {
|
|
207
|
+
/** @type {Record<string, string>} */
|
|
208
|
+
const map = {};
|
|
209
|
+
header.forEach((h, j) => { map[h] = row[j]; });
|
|
210
|
+
/** @param {string} s */
|
|
211
|
+
const subst = (s) => s.replace(/<([^>]+)>/g, (m, k) => {
|
|
212
|
+
if (!(k in map)) fail(line, `unknown placeholder <${k}> (no matching Examples column)`);
|
|
213
|
+
return map[k];
|
|
214
|
+
});
|
|
215
|
+
scenarios.push({
|
|
216
|
+
name: `${subst(name)} [${i + 1}]`,
|
|
217
|
+
steps: steps.map((st) => ({
|
|
218
|
+
keyword: st.keyword,
|
|
219
|
+
text: subst(st.text),
|
|
220
|
+
...(st.table ? { table: st.table.map((r) => r.map(subst)) } : {}),
|
|
221
|
+
})),
|
|
222
|
+
line,
|
|
223
|
+
tags,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
outline = null;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let lineNo = 0;
|
|
230
|
+
for (const raw of lines) {
|
|
231
|
+
lineNo += 1;
|
|
232
|
+
const line = raw.trim();
|
|
233
|
+
if (!line || line.startsWith('#')) continue;
|
|
234
|
+
if (line.startsWith('@')) {
|
|
235
|
+
const tags = line.split(/\s+/);
|
|
236
|
+
for (const t of tags) {
|
|
237
|
+
// A near-miss of a semantic tag (@Skip, @SKIP, @Only…) would be
|
|
238
|
+
// silently inert — worst for @only, where the typo silently
|
|
239
|
+
// DESELECTS the scenario under --test-only. Reject it loudly.
|
|
240
|
+
if (/^@(skip|todo|only)$/i.test(t) && t !== '@skip' && t !== '@todo' && t !== '@only') {
|
|
241
|
+
fail(lineNo, `tag ${t} looks like ${t.toLowerCase()} but isn't exact — a near-miss tag is silently inert; use lowercase`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
pendingTags.push(...tags);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Reject constructs that would otherwise be silently mis-parsed.
|
|
249
|
+
if (line.startsWith('"""') || line.startsWith('```')) {
|
|
250
|
+
fail(lineNo, 'doc strings (""" / ```) are not supported');
|
|
251
|
+
}
|
|
252
|
+
if (line.startsWith('Rule:')) fail(lineNo, 'the Rule: keyword is not supported');
|
|
253
|
+
|
|
254
|
+
let m;
|
|
255
|
+
if ((m = line.match(/^Feature:\s*(.*)$/))) {
|
|
256
|
+
if (featureSeen) fail(lineNo, 'multiple Feature: blocks in one file');
|
|
257
|
+
flushOutline(); feature = m[1]; featureSeen = true; featureTags = takeTags(); cur = null; inExamples = false; continue;
|
|
258
|
+
}
|
|
259
|
+
if (line.startsWith('Background:')) {
|
|
260
|
+
noTags(lineNo);
|
|
261
|
+
if (backgroundSeen) fail(lineNo, 'multiple Background: blocks');
|
|
262
|
+
flushOutline(); // expand any pending outline first, so the check below sees it
|
|
263
|
+
if (scenarios.length) fail(lineNo, 'Background: must appear before any Scenario');
|
|
264
|
+
cur = background; backgroundSeen = true; inExamples = false; continue;
|
|
265
|
+
}
|
|
266
|
+
if ((m = line.match(/^Scenario Outline:\s*(.*)$/))) {
|
|
267
|
+
flushOutline();
|
|
268
|
+
outline = { name: m[1], steps: [], header: null, rows: [], examplesSeen: false, line: lineNo, tags: [...featureTags, ...takeTags()] };
|
|
269
|
+
cur = outline.steps; inExamples = false; continue;
|
|
270
|
+
}
|
|
271
|
+
if ((m = line.match(/^Scenario:\s*(.*)$/))) {
|
|
272
|
+
flushOutline();
|
|
273
|
+
const sc = { name: m[1], steps: [], line: lineNo, tags: [...featureTags, ...takeTags()] };
|
|
274
|
+
scenarios.push(sc); cur = sc.steps; inExamples = false; continue;
|
|
275
|
+
}
|
|
276
|
+
if (line.startsWith('Examples:')) {
|
|
277
|
+
noTags(lineNo);
|
|
278
|
+
if (!outline) fail(lineNo, 'Examples: outside a Scenario Outline');
|
|
279
|
+
if (outline.examplesSeen) fail(lineNo, 'multiple Examples: blocks per Scenario Outline are not supported');
|
|
280
|
+
outline.examplesSeen = true; inExamples = true; continue;
|
|
281
|
+
}
|
|
282
|
+
if ((m = line.match(/^(Given|When|Then|And|But|\*)\s+(.*)$/))) {
|
|
283
|
+
noTags(lineNo);
|
|
284
|
+
if (!cur) fail(lineNo, 'step before any Scenario or Background');
|
|
285
|
+
if (inExamples) fail(lineNo, 'step after an Examples: table (steps must precede Examples)');
|
|
286
|
+
cur.push({ keyword: m[1], text: m[2] });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (line.startsWith('|')) {
|
|
290
|
+
noTags(lineNo);
|
|
291
|
+
const cells = splitRow(line, lineNo);
|
|
292
|
+
if (outline && inExamples) {
|
|
293
|
+
if (!outline.header) {
|
|
294
|
+
outline.header = cells;
|
|
295
|
+
} else if (cells.length !== outline.header.length) {
|
|
296
|
+
fail(lineNo, `Examples row has ${cells.length} cell(s); header has ${outline.header.length}`);
|
|
297
|
+
} else {
|
|
298
|
+
outline.rows.push(cells);
|
|
299
|
+
}
|
|
300
|
+
} else if (cur && cur.length) {
|
|
301
|
+
// A table row after a step is that step's data table.
|
|
302
|
+
const last = cur[cur.length - 1];
|
|
303
|
+
if (!last.table) {
|
|
304
|
+
last.table = [cells];
|
|
305
|
+
} else if (cells.length !== last.table[0].length) {
|
|
306
|
+
fail(lineNo, `table row has ${cells.length} cell(s); this step's table has ${last.table[0].length}`);
|
|
307
|
+
} else {
|
|
308
|
+
last.table.push(cells);
|
|
309
|
+
}
|
|
310
|
+
} else if (cur) {
|
|
311
|
+
fail(lineNo, 'table row without a preceding step');
|
|
312
|
+
} else {
|
|
313
|
+
fail(lineNo, 'table row before any Scenario or Background');
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// Anything else (Feature narrative: "As a…/I want…/So that…") is ignored.
|
|
318
|
+
}
|
|
319
|
+
flushOutline();
|
|
320
|
+
if (pendingTags.length) fail(lineNo, `dangling tags (${pendingTags.join(' ')}) at end of file`);
|
|
321
|
+
if (!featureSeen) fail(lineNo, 'no Feature: line found');
|
|
322
|
+
// A scenario with no steps would run zero assertions and pass vacuously. This
|
|
323
|
+
// also catches step lines silently dropped as narrative (e.g. a misspelled or
|
|
324
|
+
// non-English keyword) when they were a scenario's only steps.
|
|
325
|
+
for (const sc of scenarios) {
|
|
326
|
+
if (sc.steps.length === 0) fail(sc.line, `Scenario "${sc.name}" has no steps`);
|
|
327
|
+
}
|
|
328
|
+
return { feature, background, scenarios };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- Step registry ----------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
class StepRegistry {
|
|
334
|
+
constructor() {
|
|
335
|
+
/** @type {{ re: RegExp, fn: StepFn }[]} */
|
|
336
|
+
this.steps = [];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {RegExp | string} pattern RegExp (capture groups become step args) or exact string
|
|
341
|
+
* @param {StepFn} fn
|
|
342
|
+
* @returns {this}
|
|
343
|
+
*/
|
|
344
|
+
define(pattern, fn) {
|
|
345
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(`^${escapeRegExp(pattern)}$`);
|
|
346
|
+
this.steps.push({ re, fn });
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} text
|
|
352
|
+
* @returns {{ fn: StepFn, args: string[] } | null}
|
|
353
|
+
*/
|
|
354
|
+
find(text) {
|
|
355
|
+
for (const s of this.steps) {
|
|
356
|
+
const m = text.match(s.re);
|
|
357
|
+
if (m) return { fn: s.fn, args: m.slice(1) };
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// --- Snippets ----------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Build a paste-ready step definition for an unbound step: numbers become
|
|
367
|
+
* (\d+) / ([\d.]+) captures, "quoted strings" become "([^"]*)", everything
|
|
368
|
+
* else is regex-escaped. The generated body THROWS — an empty body would turn
|
|
369
|
+
* the pasted definition into an instant vacuous pass, the exact failure mode
|
|
370
|
+
* this harness exists to prevent.
|
|
371
|
+
* @param {string} text step text as written in the feature file
|
|
372
|
+
* @returns {string}
|
|
373
|
+
*/
|
|
374
|
+
function buildSnippet(text) {
|
|
375
|
+
let src = '';
|
|
376
|
+
let params = 0;
|
|
377
|
+
let last = 0;
|
|
378
|
+
const token = /"[^"]*"|\d+(?:\.\d+)?/g;
|
|
379
|
+
let m;
|
|
380
|
+
while ((m = token.exec(text))) {
|
|
381
|
+
src += escapeRegExp(text.slice(last, m.index));
|
|
382
|
+
params += 1;
|
|
383
|
+
src += m[0].startsWith('"') ? '"([^"]*)"'
|
|
384
|
+
: m[0].includes('.') ? '([\\d.]+)'
|
|
385
|
+
: '(\\d+)';
|
|
386
|
+
last = m.index + m[0].length;
|
|
387
|
+
}
|
|
388
|
+
src += escapeRegExp(text.slice(last));
|
|
389
|
+
src = src.replace(/\//g, '\\/'); // keep the emitted /.../ literal valid JS
|
|
390
|
+
const args = ['w'];
|
|
391
|
+
for (let i = 1; i <= params; i++) args.push(`p${i}`);
|
|
392
|
+
return `reg.define(/^${src}$/, (${args.join(', ')}) => {\n throw new Error('pending: implement this step');\n});`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- Execution --------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Run a flat list of steps against a shared world. Throws on an undefined step
|
|
399
|
+
* or a failing assertion. Exposed so the harness self-test can drive it without
|
|
400
|
+
* going through node:test.
|
|
401
|
+
*
|
|
402
|
+
* `world.defer(fn)` registers scenario-scoped cleanup: deferred functions run
|
|
403
|
+
* in reverse (LIFO) order after the steps, INCLUDING when a step failed — so a
|
|
404
|
+
* failing assertion can't leak temp dirs/processes. The step failure, if any,
|
|
405
|
+
* outranks cleanup errors; with no step failure the first cleanup error throws.
|
|
406
|
+
* (`defer` is a reserved key on the world.)
|
|
407
|
+
* @param {Step[]} steps
|
|
408
|
+
* @param {StepRegistry} registry
|
|
409
|
+
* @param {Record<string, any>} [world]
|
|
410
|
+
* @returns {Promise<Record<string, any>>}
|
|
411
|
+
*/
|
|
412
|
+
async function executeSteps(steps, registry, world = {}) {
|
|
413
|
+
/** @type {Array<(w: Record<string, any>) => any>} */
|
|
414
|
+
const deferred = [];
|
|
415
|
+
world.defer = (/** @type {(w: Record<string, any>) => any} */ fn) => { deferred.push(fn); };
|
|
416
|
+
let failure = null;
|
|
417
|
+
try {
|
|
418
|
+
for (const step of steps) {
|
|
419
|
+
const found = registry.find(step.text);
|
|
420
|
+
if (!found) {
|
|
421
|
+
throw new Error(`Undefined step: ${step.text}\nDefine it with:\n${buildSnippet(step.text)}`);
|
|
422
|
+
}
|
|
423
|
+
const args = step.table ? [...found.args, new DataTable(step.table)] : found.args;
|
|
424
|
+
await found.fn(world, ...args);
|
|
425
|
+
}
|
|
426
|
+
} catch (e) {
|
|
427
|
+
failure = e;
|
|
428
|
+
}
|
|
429
|
+
for (let i = deferred.length - 1; i >= 0; i--) {
|
|
430
|
+
try { await deferred[i](world); } catch (e) { failure = failure ?? e; }
|
|
431
|
+
}
|
|
432
|
+
if (failure) throw failure;
|
|
433
|
+
return world;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Register one node:test per scenario. Scenarios whose steps aren't all
|
|
438
|
+
* defined register as TODO (see runFeatures for the guard that keeps TODO from
|
|
439
|
+
* silently swallowing a bound feature). Tag mapping: @skip → skipped, @todo →
|
|
440
|
+
* runs but doesn't gate the suite, @only → honored under `--test-only`.
|
|
441
|
+
* @param {ParsedFeature} parsed
|
|
442
|
+
* @param {StepRegistry} registry
|
|
443
|
+
*/
|
|
444
|
+
function runFeature(parsed, registry) {
|
|
445
|
+
for (const sc of parsed.scenarios) {
|
|
446
|
+
const steps = [...parsed.background, ...sc.steps];
|
|
447
|
+
const title = `${parsed.feature} :: ${sc.name}`;
|
|
448
|
+
const missing = steps.filter((s) => !registry.find(s.text));
|
|
449
|
+
if (missing.length) {
|
|
450
|
+
test(title, { todo: `${missing.length} undefined step(s); first: "${missing[0].text}"` }, () => {});
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const tags = new Set(sc.tags);
|
|
454
|
+
/** @type {{ skip?: boolean, todo?: boolean, only?: boolean }} */
|
|
455
|
+
const opts = {};
|
|
456
|
+
if (tags.has('@skip')) opts.skip = true;
|
|
457
|
+
if (tags.has('@todo')) opts.todo = true;
|
|
458
|
+
if (tags.has('@only')) opts.only = true;
|
|
459
|
+
test(title, opts, async () => { await executeSteps(steps, registry); });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {string} file
|
|
465
|
+
* @param {StepRegistry} registry
|
|
466
|
+
*/
|
|
467
|
+
function runFeatureFile(file, registry) {
|
|
468
|
+
runFeature(parseFeature(fs.readFileSync(file, 'utf8'), file), registry);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --- High-level runner --------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Discover and run every *.feature in `dir`, each against its OWN scoped
|
|
475
|
+
* registry — one feature's step patterns can never match another feature's
|
|
476
|
+
* steps, so there is no global step namespace to collide in.
|
|
477
|
+
*
|
|
478
|
+
* Guards registered alongside the scenarios:
|
|
479
|
+
* - every key in `definers` must name an existing feature file (a renamed
|
|
480
|
+
* feature can't silently strand its steps);
|
|
481
|
+
* - within each feature, every step must match exactly one definition — no
|
|
482
|
+
* ambiguity, and (unless the feature is listed in `wip`) no unbound steps,
|
|
483
|
+
* because unbound scenarios register as TODO, which node:test reports as
|
|
484
|
+
* PASSING. The failure message includes a paste-ready snippet per missing
|
|
485
|
+
* step. @skip'd scenarios are ratcheted too: skip means "don't run",
|
|
486
|
+
* never "don't bind".
|
|
487
|
+
*
|
|
488
|
+
* @param {string} dir directory containing .feature files
|
|
489
|
+
* @param {Record<string, (reg: StepRegistry) => any>} definers feature basename → step definer
|
|
490
|
+
* @param {{ wip?: Iterable<string> }} [opts] feature basenames still bootstrapping (TODO allowed)
|
|
491
|
+
*/
|
|
492
|
+
function runFeatures(dir, definers, opts = {}) {
|
|
493
|
+
const wip = new Set(opts.wip || []);
|
|
494
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.feature')).sort();
|
|
495
|
+
const bases = files.map((f) => f.replace(/\.feature$/, ''));
|
|
496
|
+
|
|
497
|
+
test('step definers map only to existing feature files', () => {
|
|
498
|
+
const orphaned = Object.keys(definers).filter((k) => !bases.includes(k));
|
|
499
|
+
assert.deepStrictEqual(orphaned, [], `definers with no matching .feature in ${dir}: ${orphaned.join(', ')}`);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
for (const file of files) {
|
|
503
|
+
const base = file.replace(/\.feature$/, '');
|
|
504
|
+
const featureFile = path.join(dir, file);
|
|
505
|
+
const definer = definers[base];
|
|
506
|
+
if (definer !== undefined && typeof definer !== 'function') {
|
|
507
|
+
throw new TypeError(`definer for "${base}" must be a function, got ${typeof definer}`);
|
|
508
|
+
}
|
|
509
|
+
const registry = new StepRegistry();
|
|
510
|
+
if (definer) definer(registry);
|
|
511
|
+
const parsed = parseFeature(fs.readFileSync(featureFile, 'utf8'), featureFile);
|
|
512
|
+
|
|
513
|
+
test(`${base} :: step definitions are ${wip.has(base) ? 'unambiguous' : 'complete and unambiguous'}`, () => {
|
|
514
|
+
const steps = [...parsed.background, ...parsed.scenarios.flatMap((s) => s.steps)];
|
|
515
|
+
const ambiguous = steps
|
|
516
|
+
.filter((s) => registry.steps.filter((d) => s.text.match(d.re)).length > 1)
|
|
517
|
+
.map((s) => `"${s.text}"`);
|
|
518
|
+
assert.strictEqual(ambiguous.length, 0, `steps matching >1 definition: ${ambiguous.join('; ')}`);
|
|
519
|
+
if (!wip.has(base)) {
|
|
520
|
+
const unresolved = [...new Set(steps.filter((s) => !registry.find(s.text)).map((s) => s.text))];
|
|
521
|
+
assert.strictEqual(unresolved.length, 0,
|
|
522
|
+
`unbound steps would register as TODO (passing); bind them or add '${base}' to wip:\n\n`
|
|
523
|
+
+ unresolved.map((t) => `// ${t}\n${buildSnippet(t)}`).join('\n\n'));
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
runFeature(parsed, registry);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
module.exports = {
|
|
532
|
+
parseFeature, StepRegistry, executeSteps, runFeature, runFeatureFile, runFeatures,
|
|
533
|
+
DataTable, buildSnippet, GherkinSyntaxError,
|
|
534
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gherkin-node-test",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The smallest honest Gherkin runner — zero dependencies, on Node's built-in test runner, built for agent-driven BDD.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bing Ho <reps-attic-riot@duck.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/bingh0/gherkin-node-test.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "commonjs",
|
|
12
|
+
"main": "index.js",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.3"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"gherkin",
|
|
26
|
+
"bdd",
|
|
27
|
+
"cucumber",
|
|
28
|
+
"node-test",
|
|
29
|
+
"zero-dependency",
|
|
30
|
+
"acceptance-testing",
|
|
31
|
+
"agent",
|
|
32
|
+
"vibe-coding"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {},
|
|
35
|
+
"devDependencies": {}
|
|
36
|
+
}
|