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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/index.js +534 -0
  4. 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
+ }