playwright-mutation-gate 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -17
- package/dist/cli.js +64 -0
- package/dist/invert.js +116 -0
- package/dist/lex.js +185 -0
- package/dist/mutate.js +266 -0
- package/dist/report.js +99 -0
- package/dist/run.js +240 -0
- package/package.json +28 -6
- package/index.js +0 -6
package/README.md
CHANGED
|
@@ -1,39 +1,239 @@
|
|
|
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>
|
|
16
|
+
|
|
17
|
+
<details><summary>Text version (if the SVG above does not render)</summary>
|
|
18
|
+
|
|
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>
|
|
6
30
|
|
|
7
|
-
##
|
|
31
|
+
## Quick start
|
|
8
32
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
17
99
|
});
|
|
18
100
|
```
|
|
19
101
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
22
113
|
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
114
|
+
```yaml
|
|
115
|
+
# .github/workflows/mutation-gate.yml
|
|
116
|
+
name: mutation-gate
|
|
26
117
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
The package is CLI-only for now. Programmatic API is planned for v0.2.
|
|
170
|
+
|
|
171
|
+
## Limitations
|
|
172
|
+
|
|
173
|
+
**The gate proves an assertion can fail, not that it checks the right thing.**
|
|
174
|
+
A weak but falsifiable assertion passes: a test that asserts a site-wide header
|
|
175
|
+
(one that also renders on the error page) is killed by inversion and clears the
|
|
176
|
+
gate, even though it would not catch the feature breaking. What the gate
|
|
177
|
+
guarantees is narrower and mechanical: every marked assertion actually runs and
|
|
178
|
+
is load-bearing, so the test cannot stay green regardless of what the app does.
|
|
179
|
+
Judging whether the assertion is the *right* one still takes review, or
|
|
180
|
+
application-code mutation testing (Stryker-style), which is a different tool.
|
|
181
|
+
|
|
182
|
+
**A kill is slightly generous by construction.** `killed` means the mutated
|
|
183
|
+
run failed, not that the flipped assertion is what failed. Playwright actions
|
|
184
|
+
carry implicit assertions (`click()` fails on its own if the element never
|
|
185
|
+
appears), and an ordinarily flaky step also counts, so a test can die before
|
|
186
|
+
it ever reaches the inverted line. The asymmetry works in your favor:
|
|
187
|
+
`survived` has no such excuse. A test that stays green with its own primary
|
|
188
|
+
assertion inverted is hollow, full stop.
|
|
189
|
+
|
|
190
|
+
String-based inversion, not AST. This keeps the tool small and dependency-free
|
|
191
|
+
but means some shapes report `cannot-mutate`:
|
|
192
|
+
|
|
193
|
+
- **Single line only.** `expect(...)` and its matcher must start on the marked
|
|
194
|
+
line. Multi-line chains report cannot-mutate.
|
|
195
|
+
- **One assertion per line.** Two `expect` calls on the same line (including
|
|
196
|
+
asymmetric matchers in arguments, nested expects inside `toPass`) report
|
|
197
|
+
cannot-mutate.
|
|
198
|
+
- **No aliases.** Assertions using a re-exported or aliased `expect` are not
|
|
199
|
+
recognized.
|
|
200
|
+
- **Custom matchers** must follow the `toXxx` convention. Names that shadow
|
|
201
|
+
built-ins (`toString`, `toFixed`, ...) are denylisted.
|
|
202
|
+
- **Supported chain:**
|
|
203
|
+
`[await] expect[.soft|.poll](...)[.resolves|.rejects][.not].toXxx(`
|
|
204
|
+
|
|
205
|
+
`cannot-mutate` is always safe: the gate tells you it could not verify the
|
|
206
|
+
assertion, never silently skips it.
|
|
207
|
+
|
|
208
|
+
Skipped tests get the same honesty. A test disabled with `test.skip` never
|
|
209
|
+
executes, so there is nothing to observe when its assertion is inverted.
|
|
210
|
+
Rather than guess, the gate reports `cannot-mutate` with the reason ("the
|
|
211
|
+
test was skipped at runtime (conditional test.skip), so the inverted
|
|
212
|
+
assertion never ran"). In
|
|
213
|
+
practice this is a feature: a `test.skip(true, ...)` from months ago looks
|
|
214
|
+
identical to a healthy passing spec in normal CI output, and the gate is
|
|
215
|
+
often what surfaces it.
|
|
29
216
|
|
|
30
217
|
## Why
|
|
31
218
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
219
|
+
AI code generators write tests fast. The tests look right: good names, proper
|
|
220
|
+
selectors, green checkmarks. But the assertions are often hollow: a forgotten
|
|
221
|
+
`await` whose result nobody checks, an assertion guarded behind a branch that
|
|
222
|
+
never executes, a `not.toBeVisible` against a selector that never existed, a
|
|
223
|
+
test silently disabled with `skip`.
|
|
224
|
+
|
|
225
|
+
Stryker and friends mutate your *application code* to score unit tests. E2E
|
|
226
|
+
suites have the opposite blind spot: the *assertion itself* is often the weak
|
|
227
|
+
link. It passes against a broken app. `playwright-mutation-gate` mutates the
|
|
228
|
+
assertions, not the app.
|
|
229
|
+
|
|
230
|
+
If a green test cannot turn red when its assertion is inverted, it was never
|
|
231
|
+
testing anything. The gate catches that before it reaches production.
|
|
232
|
+
|
|
233
|
+
## Contributing
|
|
35
234
|
|
|
36
|
-
|
|
235
|
+
Issues and PRs are welcome. Run `npm test` before submitting -- it covers
|
|
236
|
+
lint, typecheck, unit, e2e and integration tests in one pass.
|
|
37
237
|
|
|
38
238
|
## License
|
|
39
239
|
|
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/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.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
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 const NON_CODE = 0;
|
|
9
|
+
/** Executable source. */
|
|
10
|
+
export const CODE = 1;
|
|
11
|
+
/** Char of a `//` comment that starts in code context. */
|
|
12
|
+
export const LINE_COMMENT = 2;
|
|
13
|
+
// Keywords after which a `/` starts a regex literal, not division.
|
|
14
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
15
|
+
'return',
|
|
16
|
+
'typeof',
|
|
17
|
+
'instanceof',
|
|
18
|
+
'in',
|
|
19
|
+
'of',
|
|
20
|
+
'new',
|
|
21
|
+
'delete',
|
|
22
|
+
'void',
|
|
23
|
+
'do',
|
|
24
|
+
'else',
|
|
25
|
+
'case',
|
|
26
|
+
'yield',
|
|
27
|
+
'await',
|
|
28
|
+
]);
|
|
29
|
+
const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
|
|
30
|
+
/**
|
|
31
|
+
* A `/` in code context is a regex literal when the last significant code
|
|
32
|
+
* token cannot end an expression: after a value (identifier, `)`, `]`,
|
|
33
|
+
* string) it is division.
|
|
34
|
+
*/
|
|
35
|
+
function startsRegex(lastSig, lastWord) {
|
|
36
|
+
if (lastSig === '')
|
|
37
|
+
return true;
|
|
38
|
+
if (isWordChar(lastSig))
|
|
39
|
+
return REGEX_PRECEDING_KEYWORDS.has(lastWord);
|
|
40
|
+
return lastSig !== ')' && lastSig !== ']' && lastSig !== '"' && lastSig !== "'" && lastSig !== '`';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Classify every char of `src` as CODE, LINE_COMMENT or NON_CODE. Handles
|
|
44
|
+
* strings with escapes, template literals with nested `${}` expressions,
|
|
45
|
+
* `//` and block comments, and regex literals (heuristic: see startsRegex).
|
|
46
|
+
*/
|
|
47
|
+
export function lex(src) {
|
|
48
|
+
const kind = new Uint8Array(src.length).fill(CODE);
|
|
49
|
+
// '`' = inside a template literal, '{' = inside a `${}` expression.
|
|
50
|
+
const stack = [];
|
|
51
|
+
// Last significant code char, its index, and the word it ends, for regex detection.
|
|
52
|
+
let lastSig = '';
|
|
53
|
+
let lastSigIndex = -2;
|
|
54
|
+
let lastWord = '';
|
|
55
|
+
let i = 0;
|
|
56
|
+
while (i < src.length) {
|
|
57
|
+
const c = src[i];
|
|
58
|
+
if (stack[stack.length - 1] === '`') {
|
|
59
|
+
kind[i] = NON_CODE;
|
|
60
|
+
if (c === '\\') {
|
|
61
|
+
if (i + 1 < src.length)
|
|
62
|
+
kind[i + 1] = NON_CODE;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
else if (c === '`') {
|
|
66
|
+
stack.pop();
|
|
67
|
+
lastSig = '`';
|
|
68
|
+
lastWord = '';
|
|
69
|
+
}
|
|
70
|
+
else if (c === '$' && src[i + 1] === '{') {
|
|
71
|
+
kind[i + 1] = NON_CODE;
|
|
72
|
+
stack.push('{');
|
|
73
|
+
lastSig = '';
|
|
74
|
+
lastWord = '';
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
i++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === "'" || c === '"') {
|
|
81
|
+
kind[i] = NON_CODE;
|
|
82
|
+
i++;
|
|
83
|
+
while (i < src.length && src[i] !== '\n') {
|
|
84
|
+
kind[i] = NON_CODE;
|
|
85
|
+
if (src[i] === '\\' && i + 1 < src.length) {
|
|
86
|
+
kind[i + 1] = NON_CODE;
|
|
87
|
+
i += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
if (src[i - 1] === c)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
lastSig = c;
|
|
95
|
+
lastWord = '';
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === '`') {
|
|
99
|
+
kind[i] = NON_CODE;
|
|
100
|
+
stack.push('`');
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
105
|
+
while (i < src.length && src[i] !== '\n')
|
|
106
|
+
kind[i++] = LINE_COMMENT;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
110
|
+
kind[i] = NON_CODE;
|
|
111
|
+
kind[i + 1] = NON_CODE;
|
|
112
|
+
i += 2;
|
|
113
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/'))
|
|
114
|
+
kind[i++] = NON_CODE;
|
|
115
|
+
if (i < src.length) {
|
|
116
|
+
kind[i] = NON_CODE;
|
|
117
|
+
kind[i + 1] = NON_CODE;
|
|
118
|
+
i += 2;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === '/' && startsRegex(lastSig, lastWord)) {
|
|
123
|
+
kind[i] = NON_CODE;
|
|
124
|
+
i++;
|
|
125
|
+
let inClass = false;
|
|
126
|
+
while (i < src.length && src[i] !== '\n') {
|
|
127
|
+
kind[i] = NON_CODE;
|
|
128
|
+
if (src[i] === '\\' && i + 1 < src.length) {
|
|
129
|
+
kind[i + 1] = NON_CODE;
|
|
130
|
+
i += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (src[i] === '[')
|
|
134
|
+
inClass = true;
|
|
135
|
+
else if (src[i] === ']')
|
|
136
|
+
inClass = false;
|
|
137
|
+
else if (src[i] === '/' && !inClass) {
|
|
138
|
+
i++;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
while (i < src.length && /[a-z]/.test(src[i]))
|
|
144
|
+
kind[i++] = NON_CODE; // flags
|
|
145
|
+
lastSig = '/';
|
|
146
|
+
lastWord = '';
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!/\s/.test(c)) {
|
|
150
|
+
if (isWordChar(c)) {
|
|
151
|
+
lastWord = isWordChar(lastSig) && lastSigIndex === i - 1 ? lastWord + c : c;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
lastWord = '';
|
|
155
|
+
}
|
|
156
|
+
lastSig = c;
|
|
157
|
+
lastSigIndex = i;
|
|
158
|
+
if (stack.length > 0) {
|
|
159
|
+
if (c === '{')
|
|
160
|
+
stack.push('{');
|
|
161
|
+
else if (c === '}')
|
|
162
|
+
stack.pop();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
return kind;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Find the `)` matching the `(` at `open`, counting only CODE chars.
|
|
172
|
+
* Returns -1 when it does not close within `src`.
|
|
173
|
+
*/
|
|
174
|
+
export function findCloseMasked(src, kind, open) {
|
|
175
|
+
let depth = 0;
|
|
176
|
+
for (let i = open; i < src.length; i++) {
|
|
177
|
+
if (kind[i] !== CODE)
|
|
178
|
+
continue;
|
|
179
|
+
if (src[i] === '(')
|
|
180
|
+
depth++;
|
|
181
|
+
else if (src[i] === ')' && --depth === 0)
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
package/dist/mutate.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { invertAssertLine } from './invert.js';
|
|
4
|
+
import { CODE, LINE_COMMENT, findCloseMasked, lex } from './lex.js';
|
|
5
|
+
export const MARKER = '// @primary-assert';
|
|
6
|
+
/** Read the test title: the first argument when it is a literal without interpolation. */
|
|
7
|
+
function readTitle(src, open) {
|
|
8
|
+
let i = open + 1;
|
|
9
|
+
while (i < src.length && /\s/.test(src[i]))
|
|
10
|
+
i++;
|
|
11
|
+
const quote = src[i];
|
|
12
|
+
if (quote !== "'" && quote !== '"' && quote !== '`')
|
|
13
|
+
return '(dynamic title)';
|
|
14
|
+
let title = '';
|
|
15
|
+
for (i++; i < src.length; i++) {
|
|
16
|
+
const c = src[i];
|
|
17
|
+
if (c === '\\' && i + 1 < src.length) {
|
|
18
|
+
title += src[i + 1];
|
|
19
|
+
i++;
|
|
20
|
+
}
|
|
21
|
+
else if (quote === '`' && c === '$' && src[i + 1] === '{') {
|
|
22
|
+
return '(dynamic title)';
|
|
23
|
+
}
|
|
24
|
+
else if (c === quote) {
|
|
25
|
+
return title;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
title += c;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return title;
|
|
32
|
+
}
|
|
33
|
+
const IDENT = '[A-Za-z_$][\\w$]*';
|
|
34
|
+
/** True when the first argument at `open` (the call's `(`) is a string literal. */
|
|
35
|
+
function firstArgIsStringLiteral(src, open) {
|
|
36
|
+
let i = open + 1;
|
|
37
|
+
while (i < src.length && /\s/.test(src[i]))
|
|
38
|
+
i++;
|
|
39
|
+
const c = src[i];
|
|
40
|
+
return c === "'" || c === '"' || c === '`';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The set of identifiers that declare a test in this file. Always `test`, plus
|
|
44
|
+
* any passed in explicitly (`--test-id`), plus auto-detected custom fixtures:
|
|
45
|
+
* a local `const X = <known>.extend(...)` chain, or a renamed import
|
|
46
|
+
* (`import { test as X }`). Resolved to a fixpoint so chained extends and
|
|
47
|
+
* renames of just-discovered ids are picked up too.
|
|
48
|
+
*/
|
|
49
|
+
function collectTestIds(src, kind, extra) {
|
|
50
|
+
const ids = new Set(['test', ...extra]);
|
|
51
|
+
const renameRe = new RegExp(`\\b(${IDENT})\\s+as\\s+(${IDENT})`, 'g');
|
|
52
|
+
const extendRe = new RegExp(`\\b(?:const|let|var)\\s+(${IDENT})\\s*=\\s*(${IDENT})\\s*\\.\\s*extend\\s*\\(`, 'g');
|
|
53
|
+
let changed = true;
|
|
54
|
+
while (changed) {
|
|
55
|
+
changed = false;
|
|
56
|
+
for (const m of src.matchAll(renameRe)) {
|
|
57
|
+
if (kind[m.index] !== CODE)
|
|
58
|
+
continue;
|
|
59
|
+
if (ids.has(m[1]) && !ids.has(m[2])) {
|
|
60
|
+
ids.add(m[2]);
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const m of src.matchAll(extendRe)) {
|
|
65
|
+
if (kind[m.index] !== CODE)
|
|
66
|
+
continue;
|
|
67
|
+
if (ids.has(m[2]) && !ids.has(m[1])) {
|
|
68
|
+
ids.add(m[1]);
|
|
69
|
+
changed = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return ids;
|
|
74
|
+
}
|
|
75
|
+
function findTestBlocks(src, kind, ids) {
|
|
76
|
+
const alt = [...ids].sort((a, b) => b.length - a.length).join('|');
|
|
77
|
+
const anchor = new RegExp(`\\b(?:${alt})\\s*(?:\\.\\s*(only|skip|fixme|fail)\\s*)?\\(`, 'g');
|
|
78
|
+
const blocks = [];
|
|
79
|
+
for (const m of src.matchAll(anchor)) {
|
|
80
|
+
if (kind[m.index] !== CODE)
|
|
81
|
+
continue;
|
|
82
|
+
// Excludes member calls like `t.test(...)`; `test.describe(` and
|
|
83
|
+
// `test.step(` never match the regex in the first place.
|
|
84
|
+
if (m.index > 0 && src[m.index - 1] === '.')
|
|
85
|
+
continue;
|
|
86
|
+
const mode = (m[1] ?? '');
|
|
87
|
+
const open = m.index + m[0].length - 1;
|
|
88
|
+
// `test.skip('title', fn)` declares a skipped test; `test.skip(!cond)` is a
|
|
89
|
+
// runtime modifier statement inside a test body. Only the declaration form
|
|
90
|
+
// (string title) is a block; the modifier is left to sit inside its test.
|
|
91
|
+
if (UNGATEABLE_MODES.has(mode) && !firstArgIsStringLiteral(src, open))
|
|
92
|
+
continue;
|
|
93
|
+
const close = findCloseMasked(src, kind, open);
|
|
94
|
+
blocks.push({
|
|
95
|
+
title: readTitle(src, open),
|
|
96
|
+
mode,
|
|
97
|
+
anchor: m.index,
|
|
98
|
+
open,
|
|
99
|
+
close: close === -1 ? src.length : close,
|
|
100
|
+
markers: [],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return blocks;
|
|
104
|
+
}
|
|
105
|
+
// test.skip/fixme never run and test.fail inverts pass/fail semantics, so a
|
|
106
|
+
// mutation verdict on them would be a lie; they are also exempt from sentinel
|
|
107
|
+
// accounting. test.only runs and is treated like a plain test.
|
|
108
|
+
const UNGATEABLE_MODES = new Set(['skip', 'fixme', 'fail']);
|
|
109
|
+
/**
|
|
110
|
+
* Parse a spec source and plan its mutations: one inverted copy per marked
|
|
111
|
+
* test. Anything the gate cannot honestly mutate is reported with a reason,
|
|
112
|
+
* never skipped silently.
|
|
113
|
+
*/
|
|
114
|
+
export function planSpecMutations(source, opts = {}) {
|
|
115
|
+
const kind = lex(source);
|
|
116
|
+
const ids = collectTestIds(source, kind, opts.testIds ?? []);
|
|
117
|
+
const blocks = findTestBlocks(source, kind, ids);
|
|
118
|
+
const lines = source.split('\n');
|
|
119
|
+
// Char offset of each line start, to map offsets to 1-based line numbers.
|
|
120
|
+
const starts = [0];
|
|
121
|
+
for (let i = 0; i < source.length; i++)
|
|
122
|
+
if (source[i] === '\n')
|
|
123
|
+
starts.push(i + 1);
|
|
124
|
+
const lineOf = (offset) => {
|
|
125
|
+
let n = 0;
|
|
126
|
+
while (n + 1 < starts.length && starts[n + 1] <= offset)
|
|
127
|
+
n++;
|
|
128
|
+
return n + 1;
|
|
129
|
+
};
|
|
130
|
+
// A line "has code" when any of its chars is CODE and not whitespace;
|
|
131
|
+
// blank, comment-only and string-interior lines do not.
|
|
132
|
+
const lineHasCode = (n) => {
|
|
133
|
+
for (let j = starts[n], end = starts[n] + lines[n].length; j < end; j++) {
|
|
134
|
+
if (kind[j] === CODE && !/\s/.test(source[j]))
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
};
|
|
139
|
+
// The `//` line comment on a line (in code context), or null when the line
|
|
140
|
+
// has none. `codeBefore` distinguishes a trailing marker (assertion code
|
|
141
|
+
// precedes the comment) from a whole-line one.
|
|
142
|
+
const lineComment = (n) => {
|
|
143
|
+
const start = starts[n];
|
|
144
|
+
const end = start + lines[n].length;
|
|
145
|
+
let c = -1;
|
|
146
|
+
for (let j = start; j < end; j++) {
|
|
147
|
+
if (kind[j] === LINE_COMMENT) {
|
|
148
|
+
c = j;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (c === -1)
|
|
153
|
+
return null;
|
|
154
|
+
let codeBefore = false;
|
|
155
|
+
for (let j = start; j < c; j++) {
|
|
156
|
+
if (kind[j] === CODE && !/\s/.test(source[j])) {
|
|
157
|
+
codeBefore = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { codeBefore, text: source.slice(c, end).trim() };
|
|
162
|
+
};
|
|
163
|
+
const strayMarkerLines = [];
|
|
164
|
+
const malformedMarkerLines = [];
|
|
165
|
+
for (let n = 0; n < lines.length; n++) {
|
|
166
|
+
const line = lines[n];
|
|
167
|
+
if (!line.includes('@primary-assert'))
|
|
168
|
+
continue;
|
|
169
|
+
// A valid marker is a `// @primary-assert` line comment in code context,
|
|
170
|
+
// either on its own line (assertion is the next code line) or trailing the
|
|
171
|
+
// assertion. The same text inside a block comment, string, template, or as
|
|
172
|
+
// a near-miss (`// @primary-assert: foo`) is not a marker.
|
|
173
|
+
const lc = lineComment(n);
|
|
174
|
+
if (!lc || lc.text !== MARKER) {
|
|
175
|
+
malformedMarkerLines.push(n + 1);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const firstNonWs = starts[n] + (line.length - line.trimStart().length);
|
|
179
|
+
const block = blocks.find((b) => firstNonWs > b.open && firstNonWs < b.close);
|
|
180
|
+
if (block)
|
|
181
|
+
block.markers.push({ line: n + 1, sameLine: lc.codeBefore });
|
|
182
|
+
else
|
|
183
|
+
strayMarkerLines.push(n + 1);
|
|
184
|
+
}
|
|
185
|
+
const mutations = [];
|
|
186
|
+
const unmarkedTests = [];
|
|
187
|
+
const onlyTestLines = [];
|
|
188
|
+
for (const block of blocks) {
|
|
189
|
+
const testLine = lineOf(block.anchor);
|
|
190
|
+
if (block.mode === 'only')
|
|
191
|
+
onlyTestLines.push(testLine);
|
|
192
|
+
if (block.markers.length === 0) {
|
|
193
|
+
if (!UNGATEABLE_MODES.has(block.mode)) {
|
|
194
|
+
unmarkedTests.push({ title: block.title, line: testLine });
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const marker = block.markers[0];
|
|
199
|
+
const base = { testTitle: block.title, testLine, markerLine: marker.line };
|
|
200
|
+
if (UNGATEABLE_MODES.has(block.mode)) {
|
|
201
|
+
const why = block.mode === 'fail'
|
|
202
|
+
? 'test.fail() expects failure, so a mutation verdict would be meaningless'
|
|
203
|
+
: `test.${block.mode}() never runs, so a mutation cannot be observed`;
|
|
204
|
+
mutations.push({ ...base, assertLine: null, cannotMutate: why });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (block.markers.length > 1) {
|
|
208
|
+
mutations.push({
|
|
209
|
+
...base,
|
|
210
|
+
assertLine: null,
|
|
211
|
+
cannotMutate: `found ${block.markers.length} @primary-assert markers in one test, expected exactly one`,
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// A trailing marker sits on the assertion itself; a whole-line marker
|
|
216
|
+
// points at the next line that contains code (blank and comment-only lines
|
|
217
|
+
// are skipped, not mutated).
|
|
218
|
+
let target; // 0-based index of the assertion line
|
|
219
|
+
if (marker.sameLine) {
|
|
220
|
+
target = marker.line - 1;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
target = marker.line; // 0-based index of the line after the marker
|
|
224
|
+
while (target < lines.length && !lineHasCode(target))
|
|
225
|
+
target++;
|
|
226
|
+
if (target >= lines.length) {
|
|
227
|
+
mutations.push({ ...base, assertLine: null, cannotMutate: 'no code line follows the marker' });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const inverted = invertAssertLine(lines[target]);
|
|
232
|
+
if ('cannotMutate' in inverted) {
|
|
233
|
+
mutations.push({ ...base, assertLine: target + 1, cannotMutate: inverted.cannotMutate });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const mutatedLines = lines.slice();
|
|
237
|
+
mutatedLines[target] = inverted.ok;
|
|
238
|
+
mutations.push({ ...base, assertLine: target + 1, mutatedSource: mutatedLines.join('\n') });
|
|
239
|
+
}
|
|
240
|
+
return { mutations, unmarkedTests, strayMarkerLines, malformedMarkerLines, onlyTestLines };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Name for a mutated copy: the marker segment goes right after the first
|
|
244
|
+
* dot-segment of the basename, so the ENTIRE original tail is preserved and
|
|
245
|
+
* the copy matches whatever testMatch convention picked up the original
|
|
246
|
+
* (`checkout.e2e.ts` -> `checkout.pmg-mutation-0.e2e.ts`).
|
|
247
|
+
*/
|
|
248
|
+
export function mutationFilePath(specPath, index, tag = '') {
|
|
249
|
+
const base = basename(specPath);
|
|
250
|
+
const dot = base.indexOf('.', 1); // a leading dot (dotfile) stays with the stem
|
|
251
|
+
const stem = dot === -1 ? base : base.slice(0, dot);
|
|
252
|
+
const tail = dot === -1 ? '' : base.slice(dot);
|
|
253
|
+
const marker = tag === '' ? `pmg-mutation-${index}` : `pmg-mutation-${index}-${tag}`;
|
|
254
|
+
return join(dirname(specPath), `${stem}.${marker}${tail}`);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Write the mutated copy next to the original spec (same dir keeps relative
|
|
258
|
+
* imports and the Playwright testDir working) and return its path. The name
|
|
259
|
+
* carries the process id so overlapping gate runs in one checkout cannot
|
|
260
|
+
* collide. The caller owns cleanup.
|
|
261
|
+
*/
|
|
262
|
+
export function writeMutationFile(specPath, index, mutatedSource) {
|
|
263
|
+
const path = mutationFilePath(specPath, index, process.pid.toString(36));
|
|
264
|
+
writeFileSync(path, mutatedSource);
|
|
265
|
+
return path;
|
|
266
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function summarize(specs, opts) {
|
|
2
|
+
const s = {
|
|
3
|
+
killed: 0,
|
|
4
|
+
survived: 0,
|
|
5
|
+
cannotMutate: 0,
|
|
6
|
+
errors: 0,
|
|
7
|
+
unmarkedTests: 0,
|
|
8
|
+
strayMarkers: 0,
|
|
9
|
+
malformedMarkers: 0,
|
|
10
|
+
gatePassed: true,
|
|
11
|
+
};
|
|
12
|
+
for (const spec of specs) {
|
|
13
|
+
for (const m of spec.results) {
|
|
14
|
+
if (m.verdict === 'killed')
|
|
15
|
+
s.killed++;
|
|
16
|
+
else if (m.verdict === 'survived')
|
|
17
|
+
s.survived++;
|
|
18
|
+
else if (m.verdict === 'cannot-mutate')
|
|
19
|
+
s.cannotMutate++;
|
|
20
|
+
else
|
|
21
|
+
s.errors++;
|
|
22
|
+
}
|
|
23
|
+
s.unmarkedTests += spec.unmarkedTests.length;
|
|
24
|
+
s.strayMarkers += spec.strayMarkerLines.length;
|
|
25
|
+
s.malformedMarkers += spec.malformedMarkerLines.length;
|
|
26
|
+
}
|
|
27
|
+
s.gatePassed =
|
|
28
|
+
s.survived === 0 &&
|
|
29
|
+
s.cannotMutate === 0 &&
|
|
30
|
+
s.errors === 0 &&
|
|
31
|
+
s.strayMarkers === 0 &&
|
|
32
|
+
s.malformedMarkers === 0 &&
|
|
33
|
+
(!opts.requireSentinel || s.unmarkedTests === 0);
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
export function jsonReport(specs, opts) {
|
|
37
|
+
return JSON.stringify({ specs, summary: summarize(specs, opts) }, null, 2);
|
|
38
|
+
}
|
|
39
|
+
const RESET = '\x1b[0m';
|
|
40
|
+
const VERDICT_COLOR = {
|
|
41
|
+
killed: '\x1b[32m',
|
|
42
|
+
survived: '\x1b[31m',
|
|
43
|
+
'cannot-mutate': '\x1b[33m',
|
|
44
|
+
error: '\x1b[33m',
|
|
45
|
+
};
|
|
46
|
+
const TITLE_WIDTH = 48;
|
|
47
|
+
function clip(title) {
|
|
48
|
+
return title.length <= TITLE_WIDTH ? title : title.slice(0, TITLE_WIDTH - 3) + '...';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Human-readable report: per spec, one row per verdict plus sentinel findings,
|
|
52
|
+
* then a one-line summary and the gate outcome.
|
|
53
|
+
*/
|
|
54
|
+
export function renderTable(specs, opts) {
|
|
55
|
+
const paint = (color, text) => opts.color ? color + text + RESET : text;
|
|
56
|
+
const labelWidth = 'cannot-mutate'.length;
|
|
57
|
+
const titleWidth = Math.min(TITLE_WIDTH, Math.max(4, ...specs.flatMap((spec) => [
|
|
58
|
+
...spec.results.map((m) => clip(m.testTitle).length),
|
|
59
|
+
...spec.unmarkedTests.map((t) => clip(t.title).length),
|
|
60
|
+
])));
|
|
61
|
+
const row = (label, color, title, line) => ' ' + paint(color, label.padEnd(labelWidth)) + ' ' + clip(title).padEnd(titleWidth) + ' ' + line;
|
|
62
|
+
const lines = [];
|
|
63
|
+
for (const spec of specs) {
|
|
64
|
+
lines.push(spec.specPath);
|
|
65
|
+
for (const m of spec.results) {
|
|
66
|
+
const at = m.assertLine === null ? ':?' : `:${m.assertLine}`;
|
|
67
|
+
lines.push(row(m.verdict, VERDICT_COLOR[m.verdict], m.testTitle, at));
|
|
68
|
+
if (m.reason !== undefined) {
|
|
69
|
+
lines.push(' ' + ' '.repeat(labelWidth) + ' ' + m.reason);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const t of spec.unmarkedTests) {
|
|
73
|
+
lines.push(row('unmarked', VERDICT_COLOR['cannot-mutate'], t.title, `:${t.line}`));
|
|
74
|
+
}
|
|
75
|
+
for (const line of spec.strayMarkerLines) {
|
|
76
|
+
lines.push(` marker outside any test() block at line ${line}`);
|
|
77
|
+
}
|
|
78
|
+
for (const line of spec.malformedMarkerLines) {
|
|
79
|
+
lines.push(` malformed @primary-assert marker at line ${line}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
const s = summarize(specs, opts);
|
|
84
|
+
const counts = [
|
|
85
|
+
`${s.killed} killed`,
|
|
86
|
+
`${s.survived} survived`,
|
|
87
|
+
`${s.cannotMutate} cannot-mutate`,
|
|
88
|
+
`${s.errors} errors`,
|
|
89
|
+
];
|
|
90
|
+
if (s.unmarkedTests > 0) {
|
|
91
|
+
counts.push(`${s.unmarkedTests} unmarked ${s.unmarkedTests === 1 ? 'test' : 'tests'}` +
|
|
92
|
+
(opts.requireSentinel ? '' : ' (not enforced)'));
|
|
93
|
+
}
|
|
94
|
+
lines.push(counts.join(', '));
|
|
95
|
+
lines.push(s.gatePassed
|
|
96
|
+
? paint('\x1b[1;32m', 'Gate passed: every marked assertion can fail.')
|
|
97
|
+
: paint('\x1b[1;31m', 'Gate FAILED: hollow, unverified or broken assertions above.'));
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
5
|
+
import { planSpecMutations, writeMutationFile } from './mutate.js';
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
|
+
const DEFAULT_COMMAND = ['npx', 'playwright', 'test'];
|
|
8
|
+
/** Matches the copy names produced by mutationFilePath, pid token included. */
|
|
9
|
+
const MUTATION_FILE_RE = /\.pmg-mutation-\d+-([a-z0-9]+)\./;
|
|
10
|
+
const OUTPUT_TAIL_LIMIT = 4000;
|
|
11
|
+
function runChild(command, cwd, env, timeoutMs) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
// A detached child gets its own process group, so on timeout the kill
|
|
14
|
+
// reaches Playwright's browser and web-server descendants too.
|
|
15
|
+
const detached = process.platform !== 'win32';
|
|
16
|
+
const child = spawn(command[0], command.slice(1), {
|
|
17
|
+
cwd,
|
|
18
|
+
env,
|
|
19
|
+
detached,
|
|
20
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
let output = '';
|
|
23
|
+
let timedOut = false;
|
|
24
|
+
const append = (chunk) => {
|
|
25
|
+
output = (output + chunk.toString()).slice(-OUTPUT_TAIL_LIMIT);
|
|
26
|
+
};
|
|
27
|
+
child.stdout.on('data', append);
|
|
28
|
+
child.stderr.on('data', append);
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
timedOut = true;
|
|
31
|
+
try {
|
|
32
|
+
if (detached && child.pid !== undefined)
|
|
33
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
34
|
+
else
|
|
35
|
+
child.kill('SIGKILL');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Already gone.
|
|
39
|
+
}
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
child.on('error', (err) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve({ code: null, timedOut, spawnError: err.message, output });
|
|
44
|
+
});
|
|
45
|
+
child.on('close', (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve({ code, timedOut, output });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decide a verdict from a Playwright JSON report. The exit code alone cannot
|
|
53
|
+
* be trusted: Playwright exits 1 for a failed test, for "no tests found" and
|
|
54
|
+
* for many crashes alike, and only the report tells those apart.
|
|
55
|
+
*/
|
|
56
|
+
export function verdictFromReport(raw) {
|
|
57
|
+
let report;
|
|
58
|
+
try {
|
|
59
|
+
report = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { verdict: 'error', reason: 'Playwright JSON report is not valid JSON' };
|
|
63
|
+
}
|
|
64
|
+
const stats = report.stats;
|
|
65
|
+
const expected = typeof stats?.expected === 'number' ? stats.expected : null;
|
|
66
|
+
const unexpected = typeof stats?.unexpected === 'number' ? stats.unexpected : null;
|
|
67
|
+
const flaky = typeof stats?.flaky === 'number' ? stats.flaky : null;
|
|
68
|
+
const skipped = typeof stats?.skipped === 'number' ? stats.skipped : 0;
|
|
69
|
+
if (expected === null || unexpected === null || flaky === null) {
|
|
70
|
+
return { verdict: 'error', reason: 'Playwright JSON report has no stats block' };
|
|
71
|
+
}
|
|
72
|
+
if (expected + unexpected + flaky === 0) {
|
|
73
|
+
// A test that matched the filter but was skipped at runtime (a conditional
|
|
74
|
+
// test.skip, e.g. a fixture with no captured session) never executed the
|
|
75
|
+
// inverted assertion. Reporting survived would be a lie; it is cannot-mutate.
|
|
76
|
+
if (skipped > 0) {
|
|
77
|
+
return {
|
|
78
|
+
verdict: 'cannot-mutate',
|
|
79
|
+
reason: 'the test was skipped at runtime (conditional test.skip), so the inverted assertion never ran',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const message = report.errors?.map((e) => e.message).find((m) => typeof m === 'string');
|
|
83
|
+
const detail = message ? `: ${message.split('\n', 1)[0]}` : '';
|
|
84
|
+
return { verdict: 'error', reason: `no tests ran under the mutation filter${detail}` };
|
|
85
|
+
}
|
|
86
|
+
// A flaky test failed at least once under the inversion, so the assertion
|
|
87
|
+
// demonstrably can fail; retries just masked it. That counts as killed.
|
|
88
|
+
if (unexpected + flaky > 0)
|
|
89
|
+
return { verdict: 'killed' };
|
|
90
|
+
return { verdict: 'survived' };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Run one planned mutation: write the inverted copy next to the spec, run
|
|
94
|
+
* Playwright against exactly that copy and test line, and read the verdict
|
|
95
|
+
* from the JSON report. The copy and the report directory are always removed.
|
|
96
|
+
*/
|
|
97
|
+
export async function runMutation(specPath, index, mutation, opts = {}) {
|
|
98
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
99
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
100
|
+
const command = opts.playwrightCommand ?? DEFAULT_COMMAND;
|
|
101
|
+
const copyPath = writeMutationFile(specPath, index, mutation.mutatedSource);
|
|
102
|
+
const reportDir = mkdtempSync(join(tmpdir(), 'pmg-'));
|
|
103
|
+
const reportPath = join(reportDir, 'report.json');
|
|
104
|
+
try {
|
|
105
|
+
// The copy replaces a single line, so line numbers match the original
|
|
106
|
+
// and the test declaration line targets exactly the mutated test.
|
|
107
|
+
const target = `${relative(cwd, copyPath) || copyPath}:${mutation.testLine}`;
|
|
108
|
+
const outcome = await runChild([...command, target, '--reporter=json', ...(opts.playwrightArgs ?? [])], cwd, { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath }, timeoutMs);
|
|
109
|
+
if (outcome.spawnError) {
|
|
110
|
+
return {
|
|
111
|
+
verdict: 'error',
|
|
112
|
+
reason: `could not launch ${command.join(' ')}: ${outcome.spawnError}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (outcome.timedOut) {
|
|
116
|
+
return {
|
|
117
|
+
verdict: 'error',
|
|
118
|
+
reason: `Playwright run timed out after ${Math.round(timeoutMs / 1000)}s and was killed`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(reportPath, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
const tail = outcome.output.trim();
|
|
127
|
+
return {
|
|
128
|
+
verdict: 'error',
|
|
129
|
+
reason: `Playwright exited with code ${outcome.code ?? 'unknown'} without producing a report` +
|
|
130
|
+
(tail === '' ? '' : `; output tail: ${tail.slice(-500)}`),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return verdictFromReport(raw);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
try {
|
|
137
|
+
unlinkSync(copyPath);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Already gone.
|
|
141
|
+
}
|
|
142
|
+
rmSync(reportDir, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function pidIsAlive(pid) {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(pid, 0);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// EPERM means the pid exists but belongs to someone else: alive.
|
|
152
|
+
return err.code === 'EPERM';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Delete leftover mutation copies in a directory: files whose pid token
|
|
157
|
+
* points at a process that no longer exists. Copies of live concurrent gate
|
|
158
|
+
* runs are left alone. Returns the paths that were removed.
|
|
159
|
+
*/
|
|
160
|
+
export function sweepStaleMutationFiles(dir) {
|
|
161
|
+
const removed = [];
|
|
162
|
+
let entries;
|
|
163
|
+
try {
|
|
164
|
+
entries = readdirSync(dir);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return removed;
|
|
168
|
+
}
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
const token = MUTATION_FILE_RE.exec(entry)?.[1];
|
|
171
|
+
if (token === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
const pid = parseInt(token, 36);
|
|
174
|
+
if (Number.isNaN(pid) || pidIsAlive(pid))
|
|
175
|
+
continue;
|
|
176
|
+
const path = join(dir, entry);
|
|
177
|
+
try {
|
|
178
|
+
unlinkSync(path);
|
|
179
|
+
removed.push(path);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Raced with another sweep.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return removed;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Gate one spec file end to end: plan its mutations, sweep stale copies from
|
|
189
|
+
* earlier crashed runs, run every mutable mutation and collect verdicts.
|
|
190
|
+
* A spec containing test.only is refused outright: .only filters out every
|
|
191
|
+
* other test, so verdicts from such a run would be fiction.
|
|
192
|
+
*/
|
|
193
|
+
export async function runSpec(specPath, opts = {}) {
|
|
194
|
+
const source = readFileSync(specPath, 'utf8');
|
|
195
|
+
const plan = planSpecMutations(source, { testIds: opts.testIds });
|
|
196
|
+
const results = [];
|
|
197
|
+
if (plan.onlyTestLines.length > 0) {
|
|
198
|
+
const reason = `test.only on line ${plan.onlyTestLines.join(', ')} of ${basename(specPath)} ` +
|
|
199
|
+
'filters out every other test, so no mutation verdict would be honest; remove .only first';
|
|
200
|
+
for (const m of plan.mutations) {
|
|
201
|
+
results.push({
|
|
202
|
+
testTitle: m.testTitle,
|
|
203
|
+
testLine: m.testLine,
|
|
204
|
+
assertLine: m.assertLine,
|
|
205
|
+
verdict: 'error',
|
|
206
|
+
reason,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
sweepStaleMutationFiles(dirname(specPath));
|
|
212
|
+
for (const [index, m] of plan.mutations.entries()) {
|
|
213
|
+
if ('cannotMutate' in m) {
|
|
214
|
+
results.push({
|
|
215
|
+
testTitle: m.testTitle,
|
|
216
|
+
testLine: m.testLine,
|
|
217
|
+
assertLine: m.assertLine,
|
|
218
|
+
verdict: 'cannot-mutate',
|
|
219
|
+
reason: m.cannotMutate,
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const run = await runMutation(specPath, index, m, opts);
|
|
224
|
+
results.push({
|
|
225
|
+
testTitle: m.testTitle,
|
|
226
|
+
testLine: m.testLine,
|
|
227
|
+
assertLine: m.assertLine,
|
|
228
|
+
verdict: run.verdict,
|
|
229
|
+
...('reason' in run ? { reason: run.reason } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
specPath,
|
|
235
|
+
results,
|
|
236
|
+
unmarkedTests: plan.unmarkedTests,
|
|
237
|
+
strayMarkerLines: plan.strayMarkerLines,
|
|
238
|
+
malformedMarkerLines: plan.malformedMarkerLines,
|
|
239
|
+
};
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playwright-mutation-gate",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Mutation testing for Playwright assertions: invert each test's primary assertion and fail the run if the test stays green.
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mutation testing for Playwright assertions: invert each test's primary assertion and fail the run if the test stays green.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
7
7
|
"mutation-testing",
|
|
@@ -15,14 +15,36 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": "Vladyslav Dmitriiev <vladyslav.dmitriiev@pm.me>",
|
|
18
|
-
"
|
|
18
|
+
"type": "module",
|
|
19
19
|
"bin": {
|
|
20
|
-
"playwright-mutation-gate": "
|
|
20
|
+
"playwright-mutation-gate": "dist/cli.js"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"
|
|
23
|
+
"dist"
|
|
24
24
|
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"test:unit": "vitest run",
|
|
30
|
+
"test:e2e": "playwright test",
|
|
31
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
32
|
+
"test": "npm run lint && npm run typecheck && npm run build && npm run test:unit && npm run test:e2e && npm run test:integration"
|
|
33
|
+
},
|
|
25
34
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@eslint/js": "^10.0.1",
|
|
39
|
+
"@playwright/test": "^1.61.1",
|
|
40
|
+
"@types/node": "^26.1.1",
|
|
41
|
+
"eslint": "^10.7.0",
|
|
42
|
+
"globals": "^17.7.0",
|
|
43
|
+
"typescript": "^6.0.3",
|
|
44
|
+
"typescript-eslint": "^8.63.0",
|
|
45
|
+
"vitest": "^4.1.10"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"commander": "^15.0.0"
|
|
27
49
|
}
|
|
28
50
|
}
|