playwright-mutation-gate 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -17
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -0
- package/dist/invert.d.ts +16 -0
- package/dist/invert.js +116 -0
- package/dist/lex.d.ts +23 -0
- package/dist/lex.js +185 -0
- package/dist/mutate.d.ts +66 -0
- package/dist/mutate.js +266 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +99 -0
- package/dist/run.d.ts +68 -0
- package/dist/run.js +240 -0
- package/package.json +37 -6
- package/index.js +0 -6
package/README.md
CHANGED
|
@@ -1,39 +1,267 @@
|
|
|
1
1
|
# playwright-mutation-gate
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/playwright-mutation-gate)
|
|
4
|
+
[](https://github.com/VladyslavDmitriiev/playwright-mutation-gate/actions)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
3
7
|
> A passing test only means something if it can fail.
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
`playwright-mutation-gate` inverts the assertion you marked as primary and
|
|
10
|
+
re-runs the test. If it stays green, the assertion is hollow and the gate
|
|
11
|
+
fails your CI run instead of reporting a lie.
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="media/demo.svg" alt="Terminal recording: 3 killed, 1 survived, gate fails" width="840">
|
|
15
|
+
</p>
|
|
6
16
|
|
|
7
|
-
|
|
17
|
+
<details><summary>Text version (if the SVG above does not render)</summary>
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
19
|
+
```
|
|
20
|
+
fixtures/demo.spec.ts
|
|
21
|
+
killed shows error for invalid email :8
|
|
22
|
+
killed hides error for valid email :16
|
|
23
|
+
killed shows success for valid email :24
|
|
24
|
+
survived hides success initially :31
|
|
25
|
+
|
|
26
|
+
3 killed, 1 survived, 0 cannot-mutate, 0 errors
|
|
27
|
+
Gate FAILED: hollow, unverified or broken assertions above.
|
|
28
|
+
```
|
|
29
|
+
</details>
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
Requires **Node >= 20** and a working **Playwright** setup (any version that
|
|
34
|
+
supports `playwright test`).
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -D playwright-mutation-gate
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Mark the primary assertion in each test with `// @primary-assert`. Put it at
|
|
41
|
+
the end of the assertion line, or on its own line right above it. The assertion
|
|
42
|
+
itself must be a single-line `expect(...)` call.
|
|
11
43
|
|
|
12
44
|
```ts
|
|
13
|
-
test('checkout shows
|
|
45
|
+
test('checkout shows confirmation', async ({ page }) => {
|
|
14
46
|
// ...
|
|
47
|
+
await expect(page.getByText('Order confirmed')).toBeVisible(); // @primary-assert
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Also valid: the marker on its own line above the assertion.
|
|
51
|
+
test('cart badge updates', async ({ page }) => {
|
|
15
52
|
// @primary-assert
|
|
16
|
-
await expect(page.
|
|
53
|
+
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run the gate:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx playwright-mutation-gate "tests/**/*.spec.ts"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How marking works
|
|
64
|
+
|
|
65
|
+
The gate inverts the marked assertion
|
|
66
|
+
(`expect(x).toBeVisible()` becomes `expect(x).not.toBeVisible()`) in a
|
|
67
|
+
temporary copy of the spec and re-runs the test in isolation.
|
|
68
|
+
|
|
69
|
+
- **killed** -- the test failed under inversion. The assertion is alive.
|
|
70
|
+
- **survived** -- the test still passed. The assertion checks nothing useful.
|
|
71
|
+
- **cannot-mutate** -- the assertion shape could not be inverted safely. The
|
|
72
|
+
gate reports it instead of guessing.
|
|
73
|
+
|
|
74
|
+
One marker per `test()` block. Markers inside `test.describe` or `test.use`
|
|
75
|
+
callbacks work the same way -- the gate scans each `test()` individually,
|
|
76
|
+
nesting does not matter.
|
|
77
|
+
|
|
78
|
+
By default, tests without a marker fail the gate (sentinel mode). This means
|
|
79
|
+
a newly added test without `// @primary-assert` shows up as:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
unmarked adds item to cart :42
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pass `--no-require-sentinel` to opt out while you're marking an existing suite.
|
|
86
|
+
|
|
87
|
+
## Custom test fixtures
|
|
88
|
+
|
|
89
|
+
If your tests are declared through a custom fixture instead of the bare `test`,
|
|
90
|
+
the gate finds it automatically in two cases: a local `.extend` chain and a
|
|
91
|
+
renamed import.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { test as base } from '@playwright/test';
|
|
95
|
+
const authedTest = base.extend({ /* ... */ }); // auto-detected
|
|
96
|
+
|
|
97
|
+
authedTest('sees dashboard', async ({ page }) => {
|
|
98
|
+
await expect(page.getByText('Welcome')).toBeVisible(); // @primary-assert
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
When the fixture is imported by its own name from another module, name it with
|
|
103
|
+
`--test-id` (repeatable):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx playwright-mutation-gate tests/dashboard.spec.ts --test-id authedTest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A runtime `authedTest.skip(condition)` inside a test is respected: if the test
|
|
110
|
+
skips, the mutation reports `cannot-mutate` rather than a false `survived`.
|
|
111
|
+
|
|
112
|
+
## CI recipe
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
# .github/workflows/mutation-gate.yml
|
|
116
|
+
name: mutation-gate
|
|
117
|
+
|
|
118
|
+
on: [push, pull_request]
|
|
119
|
+
|
|
120
|
+
jobs:
|
|
121
|
+
gate:
|
|
122
|
+
runs-on: ubuntu-latest
|
|
123
|
+
steps:
|
|
124
|
+
- uses: actions/checkout@v4
|
|
125
|
+
- uses: actions/setup-node@v4
|
|
126
|
+
with:
|
|
127
|
+
node-version: 22
|
|
128
|
+
cache: npm
|
|
129
|
+
- run: npm ci
|
|
130
|
+
- run: npx playwright install --with-deps chromium
|
|
131
|
+
- run: npx playwright-mutation-gate "tests/**/*.spec.ts"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The gate exits 0 only when every marked assertion is killed and sentinel
|
|
135
|
+
mode is satisfied. Any other outcome exits 1.
|
|
136
|
+
|
|
137
|
+
Cost scales linearly: every marked assertion is one extra `playwright test`
|
|
138
|
+
run. That is fine for a handful of specs, but on a large suite you probably
|
|
139
|
+
do not want the full sweep on every push. Two patterns that work well:
|
|
140
|
+
|
|
141
|
+
- **Gate only what changed.** The CLI takes plain file paths, so feed it the
|
|
142
|
+
spec files the PR touched:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
- run: |
|
|
146
|
+
SPECS=$(git diff --name-only origin/${{ github.base_ref }}... -- 'tests/**/*.spec.ts')
|
|
147
|
+
[ -n "$SPECS" ] && npx playwright-mutation-gate $SPECS || echo "no specs changed"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **Full sweep on a schedule.** Run the whole glob nightly instead of per
|
|
151
|
+
commit. A hollow assertion does not get less hollow between merges;
|
|
152
|
+
catching it a day later is fine. `--playwright-args` is available if you
|
|
153
|
+
need to shard or point at a specific config.
|
|
154
|
+
|
|
155
|
+
## CLI reference
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
npx playwright-mutation-gate [options] <specs...>
|
|
159
|
+
|
|
160
|
+
Options:
|
|
161
|
+
--json machine-readable JSON report
|
|
162
|
+
--no-require-sentinel do not fail on tests without a marker
|
|
163
|
+
--playwright-args <args> extra arguments for playwright test
|
|
164
|
+
--test-id <name> treat <name> as a test builder (repeatable)
|
|
165
|
+
-V, --version output the version number
|
|
166
|
+
-h, --help display help for command
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Programmatic API
|
|
170
|
+
|
|
171
|
+
Since v0.2 the package also exposes a library entry, so a tool can run the gate
|
|
172
|
+
in-process and inspect the structured report instead of shelling out to the CLI
|
|
173
|
+
and parsing `--json`.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { runSpec, summarize } from 'playwright-mutation-gate';
|
|
177
|
+
|
|
178
|
+
const spec = await runSpec('tests/checkout.spec.ts', {
|
|
179
|
+
testIds: ['authedTest'], // extra test builders, same as --test-id
|
|
180
|
+
playwrightArgs: ['--project=chromium', '--retries=0'],
|
|
17
181
|
});
|
|
182
|
+
const summary = summarize([spec], { requireSentinel: true });
|
|
183
|
+
|
|
184
|
+
if (!summary.gatePassed) {
|
|
185
|
+
const hollow = spec.results.filter((r) => r.verdict === 'survived');
|
|
186
|
+
console.error(`${hollow.length} assertion(s) do not gate the result`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
18
189
|
```
|
|
19
190
|
|
|
20
|
-
|
|
21
|
-
|
|
191
|
+
`runSpec(specPath, opts?)` returns a `SpecResult` (per-assertion `verdict`:
|
|
192
|
+
`killed` / `survived` / `cannot-mutate` / `error`, plus unmarked/stray/malformed
|
|
193
|
+
markers). `summarize(specs, opts)` folds one or more results into a `Summary`
|
|
194
|
+
with the `gatePassed` verdict. `jsonReport` and `renderTable` produce the CLI's
|
|
195
|
+
two output formats. Advanced: `planSpecMutations` and `invertAssertLine` expose
|
|
196
|
+
the mutation planner and the single-line inverter directly. Full types ship with
|
|
197
|
+
the package.
|
|
22
198
|
|
|
23
|
-
|
|
24
|
-
- test still passes → the assertion checks nothing (**survived**, a hollow test), so the
|
|
25
|
-
gate exits 1 and fails your CI run
|
|
199
|
+
## Limitations
|
|
26
200
|
|
|
27
|
-
|
|
28
|
-
|
|
201
|
+
**The gate proves an assertion can fail, not that it checks the right thing.**
|
|
202
|
+
A weak but falsifiable assertion passes: a test that asserts a site-wide header
|
|
203
|
+
(one that also renders on the error page) is killed by inversion and clears the
|
|
204
|
+
gate, even though it would not catch the feature breaking. What the gate
|
|
205
|
+
guarantees is narrower and mechanical: every marked assertion actually runs and
|
|
206
|
+
is load-bearing, so the test cannot stay green regardless of what the app does.
|
|
207
|
+
Judging whether the assertion is the *right* one still takes review, or
|
|
208
|
+
application-code mutation testing (Stryker-style), which is a different tool.
|
|
209
|
+
|
|
210
|
+
**A kill is slightly generous by construction.** `killed` means the mutated
|
|
211
|
+
run failed, not that the flipped assertion is what failed. Playwright actions
|
|
212
|
+
carry implicit assertions (`click()` fails on its own if the element never
|
|
213
|
+
appears), and an ordinarily flaky step also counts, so a test can die before
|
|
214
|
+
it ever reaches the inverted line. The asymmetry works in your favor:
|
|
215
|
+
`survived` has no such excuse. A test that stays green with its own primary
|
|
216
|
+
assertion inverted is hollow, full stop.
|
|
217
|
+
|
|
218
|
+
String-based inversion, not AST. This keeps the tool small and dependency-free
|
|
219
|
+
but means some shapes report `cannot-mutate`:
|
|
220
|
+
|
|
221
|
+
- **Single line only.** `expect(...)` and its matcher must start on the marked
|
|
222
|
+
line. Multi-line chains report cannot-mutate.
|
|
223
|
+
- **One assertion per line.** Two `expect` calls on the same line (including
|
|
224
|
+
asymmetric matchers in arguments, nested expects inside `toPass`) report
|
|
225
|
+
cannot-mutate.
|
|
226
|
+
- **No aliases.** Assertions using a re-exported or aliased `expect` are not
|
|
227
|
+
recognized.
|
|
228
|
+
- **Custom matchers** must follow the `toXxx` convention. Names that shadow
|
|
229
|
+
built-ins (`toString`, `toFixed`, ...) are denylisted.
|
|
230
|
+
- **Supported chain:**
|
|
231
|
+
`[await] expect[.soft|.poll](...)[.resolves|.rejects][.not].toXxx(`
|
|
232
|
+
|
|
233
|
+
`cannot-mutate` is always safe: the gate tells you it could not verify the
|
|
234
|
+
assertion, never silently skips it.
|
|
235
|
+
|
|
236
|
+
Skipped tests get the same honesty. A test disabled with `test.skip` never
|
|
237
|
+
executes, so there is nothing to observe when its assertion is inverted.
|
|
238
|
+
Rather than guess, the gate reports `cannot-mutate` with the reason ("the
|
|
239
|
+
test was skipped at runtime (conditional test.skip), so the inverted
|
|
240
|
+
assertion never ran"). In
|
|
241
|
+
practice this is a feature: a `test.skip(true, ...)` from months ago looks
|
|
242
|
+
identical to a healthy passing spec in normal CI output, and the gate is
|
|
243
|
+
often what surfaces it.
|
|
29
244
|
|
|
30
245
|
## Why
|
|
31
246
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
247
|
+
AI code generators write tests fast. The tests look right: good names, proper
|
|
248
|
+
selectors, green checkmarks. But the assertions are often hollow: a forgotten
|
|
249
|
+
`await` whose result nobody checks, an assertion guarded behind a branch that
|
|
250
|
+
never executes, a `not.toBeVisible` against a selector that never existed, a
|
|
251
|
+
test silently disabled with `skip`.
|
|
252
|
+
|
|
253
|
+
Stryker and friends mutate your *application code* to score unit tests. E2E
|
|
254
|
+
suites have the opposite blind spot: the *assertion itself* is often the weak
|
|
255
|
+
link. It passes against a broken app. `playwright-mutation-gate` mutates the
|
|
256
|
+
assertions, not the app.
|
|
257
|
+
|
|
258
|
+
If a green test cannot turn red when its assertion is inverted, it was never
|
|
259
|
+
testing anything. The gate catches that before it reaches production.
|
|
260
|
+
|
|
261
|
+
## Contributing
|
|
35
262
|
|
|
36
|
-
|
|
263
|
+
Issues and PRs are welcome. Run `npm test` before submitting -- it covers
|
|
264
|
+
lint, typecheck, unit, e2e and integration tests in one pass.
|
|
37
265
|
|
|
38
266
|
## License
|
|
39
267
|
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { Command, CommanderError } from 'commander';
|
|
4
|
+
import { jsonReport, renderTable, summarize } from './report.js';
|
|
5
|
+
import { runSpec } from './run.js';
|
|
6
|
+
// Exit codes: 0 = gate passed, 1 = gate failed (survived / unverified /
|
|
7
|
+
// marker mistakes), 2 = usage or environment error (bad flags, missing file).
|
|
8
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
const program = new Command()
|
|
10
|
+
.name('playwright-mutation-gate')
|
|
11
|
+
.description('Invert each @primary-assert assertion and re-run the test.\n' +
|
|
12
|
+
'A test that stays green under inversion is hollow and fails the gate.')
|
|
13
|
+
.version(pkg.version)
|
|
14
|
+
.argument('<specs...>', 'Playwright spec files to gate')
|
|
15
|
+
.option('--json', 'print a machine-readable JSON report instead of the table', false)
|
|
16
|
+
.option('--no-require-sentinel', 'do not fail the gate when a test() has no @primary-assert marker')
|
|
17
|
+
.option('--playwright-args <args>', 'extra arguments passed to `npx playwright test`, split on spaces\n' +
|
|
18
|
+
'(values containing spaces are not supported)')
|
|
19
|
+
.option('--test-id <name>', 'treat <name> as a test builder in addition to `test` (repeatable);\n' +
|
|
20
|
+
'for custom fixtures imported by name (import { authedTest } from ...).\n' +
|
|
21
|
+
'Local `const x = test.extend(...)` and `test as x` renames are auto-detected', (value, acc) => acc.concat(value), [])
|
|
22
|
+
.showHelpAfterError()
|
|
23
|
+
.addHelpText('after', '\nMarking:\n' +
|
|
24
|
+
' Put `// @primary-assert` at the end of the primary assertion line,\n' +
|
|
25
|
+
' or on its own line right above it. One marker per test().\n' +
|
|
26
|
+
' await expect(page.getByText(\'Saved\')).toBeVisible(); // @primary-assert')
|
|
27
|
+
.action(async (specPaths, opts) => {
|
|
28
|
+
const missing = specPaths.filter((p) => !existsSync(p));
|
|
29
|
+
if (missing.length > 0) {
|
|
30
|
+
console.error(`spec file not found: ${missing.join(', ')}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
const playwrightArgs = (opts.playwrightArgs ?? '').split(/\s+/).filter(Boolean);
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const spec of specPaths) {
|
|
36
|
+
process.stderr.write(`gating ${spec}\n`);
|
|
37
|
+
try {
|
|
38
|
+
results.push(await runSpec(spec, { playwrightArgs, testIds: opts.testId }));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(`failed to gate ${spec}: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const gate = { requireSentinel: opts.requireSentinel };
|
|
46
|
+
if (opts.json) {
|
|
47
|
+
console.log(jsonReport(results, gate));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const color = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
51
|
+
console.log(renderTable(results, { color, ...gate }));
|
|
52
|
+
}
|
|
53
|
+
process.exit(summarize(results, gate).gatePassed ? 0 : 1);
|
|
54
|
+
});
|
|
55
|
+
program.exitOverride();
|
|
56
|
+
try {
|
|
57
|
+
await program.parseAsync();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// commander has already printed its message; map usage errors to exit 2,
|
|
61
|
+
// keep 0 for --help / --version.
|
|
62
|
+
const code = err instanceof CommanderError && err.exitCode === 0 ? 0 : 2;
|
|
63
|
+
process.exit(code);
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { runSpec, sweepStaleMutationFiles, DEFAULT_TIMEOUT_MS, type SpecResult, type MutationResult, type Verdict, type RunOptions, } from './run.js';
|
|
2
|
+
export { summarize, jsonReport, renderTable, type Summary, type GateOptions, type RenderOptions, } from './report.js';
|
|
3
|
+
export { planSpecMutations, MARKER, type SpecPlan, type Mutation, type PlanOptions, } from './mutate.js';
|
|
4
|
+
export { invertAssertLine, type InvertResult } from './invert.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Programmatic entry point.
|
|
2
|
+
//
|
|
3
|
+
// The CLI (`dist/cli.js`) is one consumer of this library; another is any tool
|
|
4
|
+
// that wants to run the gate in-process and inspect the report itself — e.g. a
|
|
5
|
+
// custom test-pipeline that already knows which specs changed and wants the
|
|
6
|
+
// structured `SpecResult`/`Summary` without shelling out and parsing `--json`.
|
|
7
|
+
//
|
|
8
|
+
// Curated surface: run a spec, summarize/render the results, and (advanced) plan
|
|
9
|
+
// or invert individual mutations. The tokenizer (`lex.ts`) and the on-disk
|
|
10
|
+
// mutant-file mechanics stay internal — `runSpec` orchestrates them for you.
|
|
11
|
+
export { runSpec, sweepStaleMutationFiles, DEFAULT_TIMEOUT_MS, } from './run.js';
|
|
12
|
+
export { summarize, jsonReport, renderTable, } from './report.js';
|
|
13
|
+
// Advanced: build or inspect the mutation plan directly.
|
|
14
|
+
export { planSpecMutations, MARKER, } from './mutate.js';
|
|
15
|
+
export { invertAssertLine } from './invert.js';
|
package/dist/invert.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type InvertResult = {
|
|
2
|
+
ok: string;
|
|
3
|
+
} | {
|
|
4
|
+
cannotMutate: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Invert the assertion on a single line of a Playwright spec:
|
|
8
|
+
* `expect(x).toY()` <-> `expect(x).not.toY()`.
|
|
9
|
+
*
|
|
10
|
+
* Purely string-based. Supported chain shape:
|
|
11
|
+
* `[await] expect[.soft|.poll](...) [.resolves|.rejects] [.not] .toXxx(`
|
|
12
|
+
* The matcher call must start on this line; its arguments may continue on
|
|
13
|
+
* following lines. Everything else returns `cannotMutate` with a reason
|
|
14
|
+
* rather than guessing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function invertAssertLine(line: string): InvertResult;
|
package/dist/invert.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { CODE, findCloseMasked, lex } from './lex.js';
|
|
2
|
+
// Built-in toXxx methods that look like matchers but are not assertions.
|
|
3
|
+
const NOT_MATCHERS = new Set([
|
|
4
|
+
'toString',
|
|
5
|
+
'toLocaleString',
|
|
6
|
+
'toJSON',
|
|
7
|
+
'toISOString',
|
|
8
|
+
'toFixed',
|
|
9
|
+
'toExponential',
|
|
10
|
+
'toPrecision',
|
|
11
|
+
'toDateString',
|
|
12
|
+
'toTimeString',
|
|
13
|
+
'toUTCString',
|
|
14
|
+
'toLocaleDateString',
|
|
15
|
+
'toLocaleTimeString',
|
|
16
|
+
'toLocaleLowerCase',
|
|
17
|
+
'toLocaleUpperCase',
|
|
18
|
+
'toLowerCase',
|
|
19
|
+
'toUpperCase',
|
|
20
|
+
'toReversed',
|
|
21
|
+
'toSorted',
|
|
22
|
+
'toSpliced',
|
|
23
|
+
]);
|
|
24
|
+
const isWord = (c) => c !== undefined && /[\w$]/.test(c);
|
|
25
|
+
/**
|
|
26
|
+
* Invert the assertion on a single line of a Playwright spec:
|
|
27
|
+
* `expect(x).toY()` <-> `expect(x).not.toY()`.
|
|
28
|
+
*
|
|
29
|
+
* Purely string-based. Supported chain shape:
|
|
30
|
+
* `[await] expect[.soft|.poll](...) [.resolves|.rejects] [.not] .toXxx(`
|
|
31
|
+
* The matcher call must start on this line; its arguments may continue on
|
|
32
|
+
* following lines. Everything else returns `cannotMutate` with a reason
|
|
33
|
+
* rather than guessing.
|
|
34
|
+
*/
|
|
35
|
+
export function invertAssertLine(line) {
|
|
36
|
+
const kind = lex(line);
|
|
37
|
+
// First CODE index at or after j: skips whitespace, comments and other
|
|
38
|
+
// non-code chars (so a comment inside the chain does not break the walk).
|
|
39
|
+
const skip = (j) => {
|
|
40
|
+
while (j < line.length && (kind[j] !== CODE || /\s/.test(line[j])))
|
|
41
|
+
j++;
|
|
42
|
+
return j;
|
|
43
|
+
};
|
|
44
|
+
const anchors = [...line.matchAll(/\bexpect\s*[.(]/g)].filter((m) => kind[m.index] === CODE);
|
|
45
|
+
if (anchors.length === 0) {
|
|
46
|
+
return { cannotMutate: 'no expect() call found on this line' };
|
|
47
|
+
}
|
|
48
|
+
if (anchors.length > 1) {
|
|
49
|
+
return { cannotMutate: 'multiple expect calls on one line' };
|
|
50
|
+
}
|
|
51
|
+
// Position of the `.` or `(` right after the `expect` token.
|
|
52
|
+
let i = anchors[0].index + anchors[0][0].length - 1;
|
|
53
|
+
if (line[i] === '.') {
|
|
54
|
+
i = skip(i + 1);
|
|
55
|
+
const start = i;
|
|
56
|
+
while (isWord(line[i]))
|
|
57
|
+
i++;
|
|
58
|
+
const form = line.slice(start, i);
|
|
59
|
+
if (form !== 'soft' && form !== 'poll') {
|
|
60
|
+
return { cannotMutate: `unsupported expect form "expect.${form}"` };
|
|
61
|
+
}
|
|
62
|
+
i = skip(i);
|
|
63
|
+
if (line[i] !== '(') {
|
|
64
|
+
return { cannotMutate: `expect.${form} is not called on this line` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const close = findCloseMasked(line, kind, i);
|
|
68
|
+
if (close === -1) {
|
|
69
|
+
return { cannotMutate: 'expect(...) does not close on this line (multi-line expect)' };
|
|
70
|
+
}
|
|
71
|
+
// Walk the chain after expect(...): modifiers, optional .not, then the matcher.
|
|
72
|
+
i = skip(close + 1);
|
|
73
|
+
let notStart = -1;
|
|
74
|
+
let notEnd = -1;
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (i >= line.length) {
|
|
77
|
+
return { cannotMutate: 'no matcher call after expect(...) on this line' };
|
|
78
|
+
}
|
|
79
|
+
if (line[i] !== '.') {
|
|
80
|
+
return { cannotMutate: `unexpected "${line[i]}" after expect(...) chain` };
|
|
81
|
+
}
|
|
82
|
+
const dot = i;
|
|
83
|
+
i = skip(i + 1);
|
|
84
|
+
const start = i;
|
|
85
|
+
while (isWord(line[i]))
|
|
86
|
+
i++;
|
|
87
|
+
const id = line.slice(start, i);
|
|
88
|
+
if (id === '') {
|
|
89
|
+
return { cannotMutate: 'unexpected character in expect(...) chain' };
|
|
90
|
+
}
|
|
91
|
+
const lookahead = skip(i);
|
|
92
|
+
if (line[lookahead] === '(') {
|
|
93
|
+
if (!/^to[A-Z]/.test(id) || NOT_MATCHERS.has(id)) {
|
|
94
|
+
return { cannotMutate: `".${id}" does not look like an assertion matcher` };
|
|
95
|
+
}
|
|
96
|
+
const inverted = notStart !== -1
|
|
97
|
+
? line.slice(0, notStart) + line.slice(notEnd)
|
|
98
|
+
: line.slice(0, dot) + '.not' + line.slice(dot);
|
|
99
|
+
return { ok: inverted };
|
|
100
|
+
}
|
|
101
|
+
if (id === 'not') {
|
|
102
|
+
if (notStart !== -1) {
|
|
103
|
+
return { cannotMutate: 'double .not in expect(...) chain' };
|
|
104
|
+
}
|
|
105
|
+
notStart = dot;
|
|
106
|
+
notEnd = i;
|
|
107
|
+
i = skip(i);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (id === 'resolves' || id === 'rejects') {
|
|
111
|
+
i = skip(i);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
return { cannotMutate: `unsupported chain segment ".${id}"` };
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/lex.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared single-pass lexer for the string-based spec analysis. Classifies
|
|
3
|
+
* every char of a source (a whole file or a single line) so the scanners in
|
|
4
|
+
* invert.ts and mutate.ts agree about what is code. One implementation on
|
|
5
|
+
* purpose: two hand-rolled lexers had already diverged once.
|
|
6
|
+
*/
|
|
7
|
+
/** Char inside a string, template literal, block comment or regex literal. */
|
|
8
|
+
export declare const NON_CODE = 0;
|
|
9
|
+
/** Executable source. */
|
|
10
|
+
export declare const CODE = 1;
|
|
11
|
+
/** Char of a `//` comment that starts in code context. */
|
|
12
|
+
export declare const LINE_COMMENT = 2;
|
|
13
|
+
/**
|
|
14
|
+
* Classify every char of `src` as CODE, LINE_COMMENT or NON_CODE. Handles
|
|
15
|
+
* strings with escapes, template literals with nested `${}` expressions,
|
|
16
|
+
* `//` and block comments, and regex literals (heuristic: see startsRegex).
|
|
17
|
+
*/
|
|
18
|
+
export declare function lex(src: string): Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* Find the `)` matching the `(` at `open`, counting only CODE chars.
|
|
21
|
+
* Returns -1 when it does not close within `src`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findCloseMasked(src: string, kind: Uint8Array, open: number): number;
|