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 CHANGED
@@ -1,39 +1,239 @@
1
1
  # playwright-mutation-gate
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/playwright-mutation-gate)](https://www.npmjs.com/package/playwright-mutation-gate)
4
+ [![CI](https://github.com/VladyslavDmitriiev/playwright-mutation-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/VladyslavDmitriiev/playwright-mutation-gate/actions)
5
+ [![license](https://img.shields.io/npm/l/playwright-mutation-gate)](./LICENSE)
6
+
3
7
  > A passing test only means something if it can fail.
4
8
 
5
- **Status: v0.0.1 is a name reservation.** The actual CLI is in development and ships as v0.1.0.
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
- ## What it will do
31
+ ## Quick start
8
32
 
9
- `playwright-mutation-gate` checks that your green Playwright tests are green for a reason.
10
- You mark the primary assertion in each test:
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 order confirmation', async ({ page }) => {
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.getByText('Order confirmed')).toBeVisible();
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
- The gate then inverts that assertion (`expect(x).toBeVisible()` `expect(x).not.toBeVisible()`)
21
- in a temporary copy of the spec and re-runs the test:
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
- - test fails → the assertion is alive (**killed** mutation, good)
24
- - test still passes → the assertion checks nothing (**survived**, a hollow test), so the
25
- gate exits 1 and fails your CI run
114
+ ```yaml
115
+ # .github/workflows/mutation-gate.yml
116
+ name: mutation-gate
26
117
 
27
- Tests without a `@primary-assert` marker also fail the gate by default, so nothing slips
28
- through unmarked.
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
- Stryker and friends mutate your application code to score unit tests. E2E suites have the
33
- opposite blind spot: the assertion itself is often hollow. It passes against a broken app.
34
- This tool mutates the assertions, not the app.
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
- v0.1.0 coming soon.
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.1",
4
- "description": "Mutation testing for Playwright assertions: invert each test's primary assertion and fail the run if the test stays green. Name reservation; v0.1.0 in development.",
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
- "main": "index.js",
18
+ "type": "module",
19
19
  "bin": {
20
- "playwright-mutation-gate": "index.js"
20
+ "playwright-mutation-gate": "dist/cli.js"
21
21
  },
22
22
  "files": [
23
- "index.js"
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": ">=18"
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
  }
package/index.js DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env node
2
- console.error(
3
- 'playwright-mutation-gate 0.0.1 is a name reservation.\n' +
4
- 'The actual CLI ships as v0.1.0. Watch the npm page for updates.'
5
- );
6
- process.exit(1);