@step-forge/step-forge 0.0.20 → 0.0.22
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 +2 -0
- package/RUNTIME.md +242 -0
- package/dist/{analyzer-DJyJbU_V.js → analyzer-byS8yRrY.js} +202 -34
- package/dist/analyzer-byS8yRrY.js.map +1 -0
- package/dist/analyzer-cli.js +1 -1
- package/dist/analyzer.d.ts +2 -0
- package/dist/analyzer.js +1 -2
- package/dist/cli.cjs +525 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +526 -0
- package/dist/cli.js.map +1 -0
- package/dist/{hooks-Dar49TtT.d.ts → config-C7PCYgYy.d.cts} +65 -16
- package/dist/{hooks-Dar49TtT.d.cts → config-C7PCYgYy.d.ts} +65 -16
- package/dist/engine-DPVLEHBi.js +163 -0
- package/dist/engine-DPVLEHBi.js.map +1 -0
- package/dist/engine-vqA-eL_T.cjs +186 -0
- package/dist/engine-vqA-eL_T.cjs.map +1 -0
- package/dist/gherkinParser-BT40q_i3.cjs +338 -0
- package/dist/gherkinParser-BT40q_i3.cjs.map +1 -0
- package/dist/gherkinParser-NcttZgN4.js +259 -0
- package/dist/gherkinParser-NcttZgN4.js.map +1 -0
- package/dist/hooks-BDCMKeNq.js +71 -0
- package/dist/{hooks-CywugMQQ.js.map → hooks-BDCMKeNq.js.map} +1 -1
- package/dist/{hooks-CGYzwDOv.cjs → hooks-Be0cjULN.cjs} +20 -31
- package/dist/{hooks-CGYzwDOv.cjs.map → hooks-Be0cjULN.cjs.map} +1 -1
- package/dist/runtime.cjs +7 -162
- package/dist/runtime.d.cts +44 -8
- package/dist/runtime.d.ts +44 -8
- package/dist/runtime.js +3 -159
- package/dist/step-forge.cjs +73 -216
- package/dist/step-forge.cjs.map +1 -1
- package/dist/step-forge.d.cts +19 -10
- package/dist/step-forge.d.ts +19 -10
- package/dist/step-forge.js +67 -185
- package/dist/step-forge.js.map +1 -1
- package/package.json +12 -18
- package/dist/analyzer-DJyJbU_V.js.map +0 -1
- package/dist/gherkinParser-Dp2d7JNr.js +0 -116
- package/dist/gherkinParser-Dp2d7JNr.js.map +0 -1
- package/dist/hooks-CywugMQQ.js +0 -82
- package/dist/runtime.cjs.map +0 -1
- package/dist/runtime.js.map +0 -1
- package/dist/vitest.d.ts +0 -74
- package/dist/vitest.js +0 -136
- package/dist/vitest.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Step Forge is a typed wrapper around the Cucumber library. It provides an opinio
|
|
|
4
4
|
|
|
5
5
|
This is just a primer, see the [official documentation site](https://step-forge.com) for more information.
|
|
6
6
|
|
|
7
|
+
To run your `.feature` files against these steps, see the **[Runtime guide](./RUNTIME.md)** — configuration, the `step-forge` CLI, hooks, filtering, and concurrency.
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```bash
|
package/RUNTIME.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Step Forge Runtime
|
|
2
|
+
|
|
3
|
+
The Step Forge runtime executes your `.feature` files against your step
|
|
4
|
+
definitions. It is a native TypeScript runner built for speed and Cucumber-like
|
|
5
|
+
semantics — it does **not** run the Cucumber.js runtime, and it does not require
|
|
6
|
+
a bundler or a separate test framework.
|
|
7
|
+
|
|
8
|
+
If you only want to run features, this document is all you need. For how to
|
|
9
|
+
_write_ steps, the world, parsers, and dependencies, see the main
|
|
10
|
+
[README](./README.md).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
The runner executes under **[Bun](https://bun.sh)** so your TypeScript step
|
|
17
|
+
files run directly, with no transpile/build step and no loader configuration.
|
|
18
|
+
Bun is the one hard requirement:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/bun
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Everything the runner touches beyond that uses standard `node:` APIs, so your
|
|
25
|
+
step code stays ordinary TypeScript.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install --save-dev @step-forge/step-forge
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
1. **Write step definitions.** A step self-registers the moment you call
|
|
36
|
+
`.step(...)` — no separate registration call. Put them in files matched by
|
|
37
|
+
your `steps` glob (default `**/*.steps.ts`).
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// features/steps/user.steps.ts
|
|
41
|
+
import { givenBuilder, thenBuilder } from "@step-forge/step-forge";
|
|
42
|
+
|
|
43
|
+
givenBuilder<{ user: string }>()
|
|
44
|
+
.statement((name: string) => `a user named ${name}`)
|
|
45
|
+
.step(({ variables: [name] }) => ({ user: name }));
|
|
46
|
+
|
|
47
|
+
thenBuilder<{ user: string }, {}, {}>()
|
|
48
|
+
.statement((name: string) => `the user is ${name}`)
|
|
49
|
+
.dependencies({ given: { user: "required" } })
|
|
50
|
+
.step(({ variables: [name], given: { user } }) => {
|
|
51
|
+
if (user !== name) throw new Error(`expected ${name}, got ${user}`);
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
2. **Provide a world factory** (optional). One fresh world is created per
|
|
56
|
+
scenario, so state never leaks between scenarios. Omit this to get a default
|
|
57
|
+
`BasicWorld`.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// features/support/world.ts
|
|
61
|
+
import { BasicWorld } from "@step-forge/step-forge";
|
|
62
|
+
export default () => new BasicWorld();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
3. **Add a config file** (optional but recommended).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// step-forge.config.ts
|
|
69
|
+
import type { RunnerOptions } from "@step-forge/step-forge";
|
|
70
|
+
|
|
71
|
+
const config: RunnerOptions = {
|
|
72
|
+
features: "features/**/*.feature",
|
|
73
|
+
steps: "features/**/*.steps.ts",
|
|
74
|
+
world: "features/support/world.ts",
|
|
75
|
+
};
|
|
76
|
+
export default config;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
4. **Run.**
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
step-forge
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A typical `package.json` wires it as the test script:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "scripts": { "test": "step-forge" } }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
Config is resolved from three sources, later ones overriding earlier ones:
|
|
94
|
+
**defaults → `step-forge.config.ts` → CLI flags**. Every field is optional.
|
|
95
|
+
|
|
96
|
+
| Field | Type | Default | Meaning |
|
|
97
|
+
| ------------- | ------------------------- | ------------------ | ----------------------------------------------------------------------- |
|
|
98
|
+
| `features` | `string \| string[]` | `**/*.feature` | Feature-file glob(s), relative to the config directory. |
|
|
99
|
+
| `steps` | `string \| string[]` | `**/*.steps.ts` | Step-module glob(s). Importing them is what registers your steps. |
|
|
100
|
+
| `world` | `string` | `BasicWorld` | Module that default-exports a world factory `() => world`. |
|
|
101
|
+
| `concurrency` | `number` | `1` (serial) | Max scenarios in flight at once. See [Concurrency](#concurrency). |
|
|
102
|
+
| `reporter` | `"pretty" \| "progress"` | `pretty` | Output style. See [Reporters & output](#reporters--output). |
|
|
103
|
+
| `verbose` | `boolean` | `false` | Report every scenario, not just failures. |
|
|
104
|
+
| `name` | `string` | — | Only scenarios whose name matches (substring, or `/regex/flags`). |
|
|
105
|
+
| `tags` | `string` | — | Cucumber tag expression, e.g. `@smoke and not @wip`. |
|
|
106
|
+
|
|
107
|
+
The config file is loaded by Bun, so it may be TypeScript and import the
|
|
108
|
+
`RunnerOptions` type for editor help.
|
|
109
|
+
|
|
110
|
+
## CLI
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
step-forge [options] [feature globs...]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Positional arguments are feature globs and **override** the configured
|
|
117
|
+
`features`, so you can run a subset ad hoc.
|
|
118
|
+
|
|
119
|
+
| Flag | Short | Description |
|
|
120
|
+
| ----------------------- | ----- | ------------------------------------------------------- |
|
|
121
|
+
| `--tags <expr>` | `-t` | Tag expression, e.g. `"@smoke and not @wip"`. |
|
|
122
|
+
| `--name <pattern>` | `-n` | Only scenarios whose name matches (substring/`/regex/`).|
|
|
123
|
+
| `--steps <glob>` | `-s` | Step-module glob (repeatable). |
|
|
124
|
+
| `--world <module>` | `-w` | World factory module. |
|
|
125
|
+
| `--concurrency <n>` | `-c` | Max scenarios in flight (default `1`). |
|
|
126
|
+
| `--reporter <name>` | `-r` | `pretty` (default) or `progress`. |
|
|
127
|
+
| `--verbose` | `-v` | Report every scenario, not just failures. |
|
|
128
|
+
| `--config <path>` | | Directory to resolve the config file and globs from. |
|
|
129
|
+
| `--help` | `-h` | Show usage. |
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
step-forge features/checkout.feature # one file
|
|
135
|
+
step-forge --tags "@smoke and not @wip" # by tag
|
|
136
|
+
step-forge --name "logs in" # by scenario name
|
|
137
|
+
step-forge --concurrency 8 --reporter progress # parallel, compact output
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The process exits `0` when every scenario passes and `1` when any scenario
|
|
141
|
+
fails, so it drops straight into CI.
|
|
142
|
+
|
|
143
|
+
## Hooks
|
|
144
|
+
|
|
145
|
+
Hooks are side-effect callbacks for setup/teardown (start a server, reset a
|
|
146
|
+
mock). They **never seed scenario state** — that flows exclusively through
|
|
147
|
+
`given`/`when`/`then` so the typed dependency graph stays the single source of
|
|
148
|
+
truth. Import them from the main entry:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import {
|
|
152
|
+
beforeAll,
|
|
153
|
+
afterAll,
|
|
154
|
+
beforeFeature,
|
|
155
|
+
afterFeature,
|
|
156
|
+
beforeScenario,
|
|
157
|
+
afterScenario,
|
|
158
|
+
} from "@step-forge/step-forge";
|
|
159
|
+
|
|
160
|
+
beforeScenario(({ world, scenario }) => {
|
|
161
|
+
/* fresh world available; reset external state here */
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Hook | Runs |
|
|
166
|
+
| --------------------- | ------------------------------------------------ |
|
|
167
|
+
| `beforeAll`/`afterAll` | Once around the entire run — `beforeAll` before concurrency starts, `afterAll` after every scenario is done. |
|
|
168
|
+
| `beforeFeature`/`afterFeature` | Around each feature file. |
|
|
169
|
+
| `beforeScenario`/`afterScenario` | Around each scenario (gets its world).|
|
|
170
|
+
|
|
171
|
+
Multiple `beforeAll` (and multiple `afterAll`) hooks run **in parallel** with no
|
|
172
|
+
ordering between them — if one setup step must precede another, sequence both
|
|
173
|
+
inside a single hook. Feature and scenario `after*` hooks instead run in reverse
|
|
174
|
+
registration order so teardown unwinds setup, and `afterScenario` runs even when
|
|
175
|
+
a step failed.
|
|
176
|
+
|
|
177
|
+
## Filtering
|
|
178
|
+
|
|
179
|
+
- **Tags** — `--tags` / `tags` accepts a Cucumber tag expression with `and`,
|
|
180
|
+
`or`, `not`, parentheses, and `@tag` atoms:
|
|
181
|
+
`@smoke and (@fast or not @flaky)`.
|
|
182
|
+
- **Name** — `--name` / `name` is a substring by default, or a regex if written
|
|
183
|
+
as `/pattern/flags`.
|
|
184
|
+
- **`@skip`** — a scenario tagged `@skip` is never executed and is reported as
|
|
185
|
+
skipped (not dropped silently).
|
|
186
|
+
- **`@only`** — if any selected scenario is tagged `@only`, the run is focused to
|
|
187
|
+
just those. `@skip` wins over `@only`.
|
|
188
|
+
|
|
189
|
+
## Concurrency
|
|
190
|
+
|
|
191
|
+
Scenarios run **serially by default** (`concurrency: 1`), matching Cucumber.
|
|
192
|
+
Raise `--concurrency` (or the `concurrency` config field) to run scenarios
|
|
193
|
+
concurrently in a single process — ideal for I/O-bound suites.
|
|
194
|
+
|
|
195
|
+
This is safe because of one opinionated invariant Step Forge holds:
|
|
196
|
+
|
|
197
|
+
> **Module state is immutable and scenario-isolated.** All mutable scenario
|
|
198
|
+
> state lives in the per-scenario world (already isolated); anything at module
|
|
199
|
+
> scope is write-once configuration or pure lookups.
|
|
200
|
+
|
|
201
|
+
Because mutable state can't be shared across scenarios, concurrent scenarios
|
|
202
|
+
cannot corrupt one another — no worker threads or process isolation required.
|
|
203
|
+
The corollary is a rule to follow in your own code: **do not have a hook or step
|
|
204
|
+
write to a mutable module-level variable that another step reads.** That is a
|
|
205
|
+
cross-scenario data race and the only way to break parallel runs. Keep
|
|
206
|
+
per-scenario state in the world and you can turn concurrency up freely.
|
|
207
|
+
|
|
208
|
+
## Reporters & output
|
|
209
|
+
|
|
210
|
+
By default the runner is quiet: it prints a dot per scenario as a heartbeat
|
|
211
|
+
(`.` pass, `F` fail, `-` skip), then **only the failing scenarios** in detail,
|
|
212
|
+
then a summary. Pass `--verbose` (`-v`) to report every scenario instead.
|
|
213
|
+
|
|
214
|
+
Each failure is shown Cucumber-style so it's easy to locate:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
✗ a user can log in
|
|
218
|
+
features/auth.feature:12
|
|
219
|
+
|
|
220
|
+
✓ Given a registered user
|
|
221
|
+
✗ Then they reach the dashboard
|
|
222
|
+
feature: features/auth.feature:15
|
|
223
|
+
defined: features/steps/auth.steps.ts:40
|
|
224
|
+
AssertionError: expected "/login" to equal "/dashboard"
|
|
225
|
+
at features/steps/auth.steps.ts:42:18
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- **`feature:`** — the `.feature` file and line of the failing step.
|
|
229
|
+
- **`defined:`** — where that step is defined (its `.step(...)` call site).
|
|
230
|
+
- The stack is trimmed to your own code (library, engine, and `node_modules`
|
|
231
|
+
frames removed) and source-mapped by Bun to the original TypeScript, so the
|
|
232
|
+
top frame is the line in your step that actually threw.
|
|
233
|
+
|
|
234
|
+
Two styles are available via `--reporter`:
|
|
235
|
+
|
|
236
|
+
- **`pretty`** (default) — honours `--verbose`: failures-only by default, or the
|
|
237
|
+
full feature → scenario → step tree (each step annotated with its definition
|
|
238
|
+
location) under `--verbose`.
|
|
239
|
+
- **`progress`** — always compact (dots + failures + summary); ignores
|
|
240
|
+
`--verbose`.
|
|
241
|
+
|
|
242
|
+
Colour is emitted only to a TTY and is disabled when `NO_COLOR` is set.
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { glob } from "node:fs/promises";
|
|
1
|
+
import { glob, stat } from "node:fs/promises";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import ts from "typescript";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
|
|
6
|
+
import * as messages from "@cucumber/messages";
|
|
7
|
+
//#region src/globFiles.ts
|
|
8
|
+
/** Glob metacharacters. A pattern with none of these is a literal path. */
|
|
9
|
+
const MAGIC = /[*?[\]{}!()]/;
|
|
10
|
+
async function isFile(p) {
|
|
11
|
+
try {
|
|
12
|
+
return (await stat(p)).isFile();
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve glob patterns to a de-duplicated list of absolute file paths, with one
|
|
19
|
+
* important portability guarantee: a **literal absolute path** (no glob magic)
|
|
20
|
+
* is returned directly if it exists, without being handed to `glob()`.
|
|
21
|
+
*
|
|
22
|
+
* This exists because `node:fs`'s `glob` diverges between runtimes — Node
|
|
23
|
+
* matches an absolute-path pattern, Bun returns nothing for one. Rather than
|
|
24
|
+
* depend on that behaviour, we only ever glob relative patterns (against `cwd`)
|
|
25
|
+
* and short-circuit concrete absolute paths ourselves, so callers get identical
|
|
26
|
+
* results under Node and Bun.
|
|
27
|
+
*/
|
|
28
|
+
async function globFiles(patterns, cwd = process.cwd()) {
|
|
29
|
+
const files = /* @__PURE__ */ new Set();
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
if (path.isAbsolute(pattern) && !MAGIC.test(pattern)) {
|
|
32
|
+
if (await isFile(pattern)) files.add(pattern);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for await (const match of glob(pattern, { cwd })) files.add(path.resolve(cwd, match));
|
|
36
|
+
}
|
|
37
|
+
return [...files];
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
5
40
|
//#region src/analyzer/stepExtractor.ts
|
|
6
41
|
const BUILDER_NAMES = {
|
|
7
42
|
givenBuilder: "given",
|
|
@@ -9,6 +44,21 @@ const BUILDER_NAMES = {
|
|
|
9
44
|
thenBuilder: "then"
|
|
10
45
|
};
|
|
11
46
|
function extractStepDefinitions(filePaths, tsConfigPath) {
|
|
47
|
+
const fast = extractWithSources(filePaths.map(parseSourceFile).filter((s) => s !== null), null);
|
|
48
|
+
if (!fast.needsChecker) return fast.results;
|
|
49
|
+
const program = ts.createProgram(filePaths, resolveCompilerOptions(tsConfigPath));
|
|
50
|
+
const checker = program.getTypeChecker();
|
|
51
|
+
return extractWithSources(filePaths.map((fp) => program.getSourceFile(fp)).filter((s) => s !== void 0), checker).results;
|
|
52
|
+
}
|
|
53
|
+
/** Parse a single file into an AST with no type resolution. */
|
|
54
|
+
function parseSourceFile(filePath) {
|
|
55
|
+
const text = ts.sys.readFile(filePath);
|
|
56
|
+
if (text === void 0) return null;
|
|
57
|
+
const scriptKind = filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
58
|
+
return ts.createSourceFile(filePath, text, ts.ScriptTarget.ESNext, true, scriptKind);
|
|
59
|
+
}
|
|
60
|
+
/** Resolve compiler options from tsconfig (fallback to sane defaults). */
|
|
61
|
+
function resolveCompilerOptions(tsConfigPath) {
|
|
12
62
|
const configPath = tsConfigPath ?? ts.findConfigFile(process.cwd(), ts.sys.fileExists);
|
|
13
63
|
let compilerOptions = {
|
|
14
64
|
target: ts.ScriptTarget.ESNext,
|
|
@@ -22,22 +72,25 @@ function extractStepDefinitions(filePaths, tsConfigPath) {
|
|
|
22
72
|
if (!configFile.error) compilerOptions = ts.parseJsonConfigFileContent(configFile.config, ts.sys, configPath.replace(/[/\\][^/\\]+$/, "")).options;
|
|
23
73
|
}
|
|
24
74
|
compilerOptions.noEmit = true;
|
|
25
|
-
|
|
26
|
-
|
|
75
|
+
return compilerOptions;
|
|
76
|
+
}
|
|
77
|
+
function extractWithSources(sources, checker) {
|
|
78
|
+
const ctx = {
|
|
79
|
+
checker,
|
|
80
|
+
needsChecker: false
|
|
81
|
+
};
|
|
27
82
|
const results = [];
|
|
28
|
-
for (const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
return results;
|
|
83
|
+
for (const sourceFile of sources) results.push(...extractFromSourceFile(sourceFile, ctx));
|
|
84
|
+
return {
|
|
85
|
+
results,
|
|
86
|
+
needsChecker: ctx.needsChecker
|
|
87
|
+
};
|
|
35
88
|
}
|
|
36
|
-
function extractFromSourceFile(sourceFile,
|
|
89
|
+
function extractFromSourceFile(sourceFile, ctx) {
|
|
37
90
|
const results = [];
|
|
38
91
|
function visit(node) {
|
|
39
92
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "step") {
|
|
40
|
-
const meta = extractFromRegisterCall(node, sourceFile,
|
|
93
|
+
const meta = extractFromRegisterCall(node, sourceFile, ctx);
|
|
41
94
|
if (meta) results.push(meta);
|
|
42
95
|
}
|
|
43
96
|
ts.forEachChild(node, visit);
|
|
@@ -45,7 +98,7 @@ function extractFromSourceFile(sourceFile, checker) {
|
|
|
45
98
|
visit(sourceFile);
|
|
46
99
|
return results;
|
|
47
100
|
}
|
|
48
|
-
function extractFromRegisterCall(registerCall, sourceFile,
|
|
101
|
+
function extractFromRegisterCall(registerCall, sourceFile, ctx) {
|
|
49
102
|
const chain = collectCallChain(registerCall);
|
|
50
103
|
let stepType = null;
|
|
51
104
|
let expression = null;
|
|
@@ -60,7 +113,7 @@ function extractFromRegisterCall(registerCall, sourceFile, checker) {
|
|
|
60
113
|
if (!name) continue;
|
|
61
114
|
if (name === "register") continue;
|
|
62
115
|
if (name === "step") {
|
|
63
|
-
produces = extractProducedKeys(link,
|
|
116
|
+
produces = extractProducedKeys(link, ctx);
|
|
64
117
|
continue;
|
|
65
118
|
}
|
|
66
119
|
if (name === "dependencies") {
|
|
@@ -77,7 +130,7 @@ function extractFromRegisterCall(registerCall, sourceFile, checker) {
|
|
|
77
130
|
}
|
|
78
131
|
}
|
|
79
132
|
if (!stepType || !expression) {
|
|
80
|
-
const reExport = resolveReExportedCall(chain,
|
|
133
|
+
const reExport = resolveReExportedCall(chain, ctx);
|
|
81
134
|
if (reExport) {
|
|
82
135
|
if (!stepType) stepType = reExport.stepType;
|
|
83
136
|
if (!expression) expression = reExport.expression;
|
|
@@ -164,33 +217,37 @@ function extractDependencies(depsCall) {
|
|
|
164
217
|
}
|
|
165
218
|
return deps;
|
|
166
219
|
}
|
|
167
|
-
function extractProducedKeys(stepCall,
|
|
220
|
+
function extractProducedKeys(stepCall, ctx) {
|
|
168
221
|
const callback = stepCall.arguments[0];
|
|
169
222
|
if (!callback) return [];
|
|
170
|
-
if (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) return extractProducedKeysFromCallback(callback,
|
|
223
|
+
if (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) return extractProducedKeysFromCallback(callback, ctx);
|
|
171
224
|
return [];
|
|
172
225
|
}
|
|
173
|
-
function extractProducedKeysFromCallback(callback,
|
|
226
|
+
function extractProducedKeysFromCallback(callback, ctx) {
|
|
174
227
|
const body = callback.body;
|
|
175
|
-
if (!ts.isBlock(body)) return extractKeysFromExpression(body,
|
|
228
|
+
if (!ts.isBlock(body)) return extractKeysFromExpression(body, ctx);
|
|
176
229
|
const keys = /* @__PURE__ */ new Set();
|
|
177
230
|
function visitReturn(node) {
|
|
178
|
-
if (ts.isReturnStatement(node) && node.expression) for (const key of extractKeysFromExpression(node.expression,
|
|
231
|
+
if (ts.isReturnStatement(node) && node.expression) for (const key of extractKeysFromExpression(node.expression, ctx)) keys.add(key);
|
|
179
232
|
ts.forEachChild(node, visitReturn);
|
|
180
233
|
}
|
|
181
234
|
visitReturn(body);
|
|
182
235
|
return [...keys];
|
|
183
236
|
}
|
|
184
|
-
function extractKeysFromExpression(expr,
|
|
237
|
+
function extractKeysFromExpression(expr, ctx) {
|
|
185
238
|
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
|
|
186
239
|
if (ts.isObjectLiteralExpression(expr)) return expr.properties.filter((p) => ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)).map((p) => p.name.getText()).filter(Boolean);
|
|
240
|
+
if (!ctx.checker) {
|
|
241
|
+
ctx.needsChecker = true;
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
187
244
|
try {
|
|
188
|
-
return checker.getTypeAtLocation(expr).getProperties().map((p) => p.name).filter((n) => n !== "merge");
|
|
245
|
+
return ctx.checker.getTypeAtLocation(expr).getProperties().map((p) => p.name).filter((n) => n !== "merge");
|
|
189
246
|
} catch {
|
|
190
247
|
return [];
|
|
191
248
|
}
|
|
192
249
|
}
|
|
193
|
-
function resolveReExportedCall(chain,
|
|
250
|
+
function resolveReExportedCall(chain, ctx) {
|
|
194
251
|
const lastCall = chain[chain.length - 1];
|
|
195
252
|
if (!lastCall) return null;
|
|
196
253
|
const expr = lastCall.expression;
|
|
@@ -198,7 +255,11 @@ function resolveReExportedCall(chain, checker) {
|
|
|
198
255
|
if (ts.isIdentifier(expr)) identifier = expr;
|
|
199
256
|
else if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) identifier = expr.expression;
|
|
200
257
|
if (!identifier) return null;
|
|
201
|
-
|
|
258
|
+
if (!ctx.checker) {
|
|
259
|
+
ctx.needsChecker = true;
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const symbol = ctx.checker.getSymbolAtLocation(identifier);
|
|
202
263
|
if (!symbol) return null;
|
|
203
264
|
const decl = symbol.valueDeclaration;
|
|
204
265
|
if (!decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return null;
|
|
@@ -219,6 +280,118 @@ function resolveReExportedCall(chain, checker) {
|
|
|
219
280
|
return null;
|
|
220
281
|
}
|
|
221
282
|
//#endregion
|
|
283
|
+
//#region src/analyzer/gherkinParser.ts
|
|
284
|
+
function parseFeatureFiles(filePaths) {
|
|
285
|
+
const scenarios = [];
|
|
286
|
+
for (const filePath of filePaths) {
|
|
287
|
+
const parsed = parseFeatureContent(fs.readFileSync(filePath, "utf-8"), filePath);
|
|
288
|
+
scenarios.push(...parsed);
|
|
289
|
+
}
|
|
290
|
+
return scenarios;
|
|
291
|
+
}
|
|
292
|
+
function parseFeatureContent(content, filePath) {
|
|
293
|
+
const feature = new Parser(new AstBuilder(messages.IdGenerator.uuid()), new GherkinClassicTokenMatcher()).parse(content).feature;
|
|
294
|
+
if (!feature) return [];
|
|
295
|
+
const featureBackground = [];
|
|
296
|
+
const scenarios = [];
|
|
297
|
+
const featureTags = tagNames(feature.tags);
|
|
298
|
+
for (const child of feature.children) {
|
|
299
|
+
if (child.background) featureBackground.push(...child.background.steps);
|
|
300
|
+
if (child.scenario) scenarios.push(...expandScenario(child.scenario, featureBackground, filePath, featureTags));
|
|
301
|
+
if (child.rule) {
|
|
302
|
+
const ruleBackground = [...featureBackground];
|
|
303
|
+
const ruleTags = [...featureTags, ...tagNames(child.rule.tags)];
|
|
304
|
+
for (const ruleChild of child.rule.children) {
|
|
305
|
+
if (ruleChild.background) ruleBackground.push(...ruleChild.background.steps);
|
|
306
|
+
if (ruleChild.scenario) scenarios.push(...expandScenario(ruleChild.scenario, ruleBackground, filePath, ruleTags));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return scenarios;
|
|
311
|
+
}
|
|
312
|
+
/** Extract tag names (each keeping its leading `@`), deduped in order. */
|
|
313
|
+
function tagNames(tags) {
|
|
314
|
+
return [...new Set((tags ?? []).map((t) => t.name))];
|
|
315
|
+
}
|
|
316
|
+
function expandScenario(scenario, backgroundSteps, filePath, inheritedTags) {
|
|
317
|
+
const scenarioTags = [...inheritedTags, ...tagNames(scenario.tags)];
|
|
318
|
+
if (!(scenario.examples.length > 0 && scenario.examples.some((e) => e.tableBody.length > 0))) {
|
|
319
|
+
const bgParsed = convertSteps(backgroundSteps);
|
|
320
|
+
const scenarioParsed = convertSteps(scenario.steps);
|
|
321
|
+
const allSteps = resolveEffectiveKeywords([...bgParsed, ...scenarioParsed]);
|
|
322
|
+
return [{
|
|
323
|
+
name: scenario.name,
|
|
324
|
+
file: filePath,
|
|
325
|
+
line: scenario.location.line,
|
|
326
|
+
steps: allSteps,
|
|
327
|
+
tags: scenarioTags
|
|
328
|
+
}];
|
|
329
|
+
}
|
|
330
|
+
const results = [];
|
|
331
|
+
for (const example of scenario.examples) {
|
|
332
|
+
if (!example.tableHeader || example.tableBody.length === 0) continue;
|
|
333
|
+
const headers = example.tableHeader.cells.map((c) => c.value);
|
|
334
|
+
const exampleTags = [...scenarioTags, ...tagNames(example.tags)];
|
|
335
|
+
for (const row of example.tableBody) {
|
|
336
|
+
const values = row.cells.map((c) => c.value);
|
|
337
|
+
const substitution = {};
|
|
338
|
+
headers.forEach((h, i) => {
|
|
339
|
+
substitution[h] = values[i];
|
|
340
|
+
});
|
|
341
|
+
const bgParsed = convertSteps(backgroundSteps);
|
|
342
|
+
const scenarioSteps = convertSteps(scenario.steps).map((step) => ({
|
|
343
|
+
...step,
|
|
344
|
+
text: substituteExampleValues(step.text, substitution)
|
|
345
|
+
}));
|
|
346
|
+
const allSteps = resolveEffectiveKeywords([...bgParsed, ...scenarioSteps]);
|
|
347
|
+
results.push({
|
|
348
|
+
name: headers.map((h, i) => `${h}=${values[i]}`).join(", "),
|
|
349
|
+
file: filePath,
|
|
350
|
+
line: row.location.line,
|
|
351
|
+
steps: allSteps,
|
|
352
|
+
tags: exampleTags,
|
|
353
|
+
outline: { name: scenario.name }
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
function convertSteps(steps) {
|
|
360
|
+
return steps.map((step) => ({
|
|
361
|
+
keyword: normalizeKeyword(step.keyword),
|
|
362
|
+
text: step.text,
|
|
363
|
+
line: step.location.line,
|
|
364
|
+
column: (step.location.column ?? 1) + step.keyword.length
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
function normalizeKeyword(keyword) {
|
|
368
|
+
const trimmed = keyword.trim();
|
|
369
|
+
if (trimmed === "Given") return "Given";
|
|
370
|
+
if (trimmed === "When") return "When";
|
|
371
|
+
if (trimmed === "Then") return "Then";
|
|
372
|
+
if (trimmed === "And") return "And";
|
|
373
|
+
if (trimmed === "But") return "But";
|
|
374
|
+
return "Given";
|
|
375
|
+
}
|
|
376
|
+
function resolveEffectiveKeywords(steps) {
|
|
377
|
+
let lastEffective = "Given";
|
|
378
|
+
return steps.map((step) => {
|
|
379
|
+
let effectiveKeyword;
|
|
380
|
+
if (step.keyword === "And" || step.keyword === "But") effectiveKeyword = lastEffective;
|
|
381
|
+
else effectiveKeyword = step.keyword;
|
|
382
|
+
lastEffective = effectiveKeyword;
|
|
383
|
+
return {
|
|
384
|
+
...step,
|
|
385
|
+
effectiveKeyword
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function substituteExampleValues(text, substitution) {
|
|
390
|
+
let result = text;
|
|
391
|
+
for (const [key, value] of Object.entries(substitution)) result = result.replace(new RegExp(`<${key}>`, "g"), value);
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
222
395
|
//#region src/analyzer/stepMatcher.ts
|
|
223
396
|
function compileDefinitions(definitions) {
|
|
224
397
|
const compiled = [];
|
|
@@ -353,8 +526,8 @@ function runRules(rules, scenario, matchedSteps) {
|
|
|
353
526
|
//#region src/analyzer/index.ts
|
|
354
527
|
async function analyze(config, options) {
|
|
355
528
|
const rules = options?.rules ?? defaultRules;
|
|
356
|
-
const stepFilePaths = await
|
|
357
|
-
const featureFilePaths = await
|
|
529
|
+
const stepFilePaths = await globFiles(config.stepFiles);
|
|
530
|
+
const featureFilePaths = await globFiles(config.featureFiles);
|
|
358
531
|
if (stepFilePaths.length === 0) return [];
|
|
359
532
|
if (featureFilePaths.length === 0) return [];
|
|
360
533
|
const stepDefinitions = extractStepDefinitions(stepFilePaths, config.tsConfigPath);
|
|
@@ -366,12 +539,7 @@ async function analyze(config, options) {
|
|
|
366
539
|
}
|
|
367
540
|
return diagnostics;
|
|
368
541
|
}
|
|
369
|
-
async function resolveGlobs(patterns) {
|
|
370
|
-
const files = [];
|
|
371
|
-
for (const pattern of patterns) for await (const file of glob(pattern)) files.push(path.resolve(file));
|
|
372
|
-
return [...new Set(files)];
|
|
373
|
-
}
|
|
374
542
|
//#endregion
|
|
375
|
-
export {
|
|
543
|
+
export { parseFeatureContent as a, matchScenarioSteps as i, defaultRules as n, parseFeatureFiles as o, findMatchingDefinitions as r, extractStepDefinitions as s, analyze as t };
|
|
376
544
|
|
|
377
|
-
//# sourceMappingURL=analyzer-
|
|
545
|
+
//# sourceMappingURL=analyzer-byS8yRrY.js.map
|