playwright-mutation-gate 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -17
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -0
- package/dist/invert.d.ts +16 -0
- package/dist/invert.js +116 -0
- package/dist/lex.d.ts +23 -0
- package/dist/lex.js +185 -0
- package/dist/mutate.d.ts +66 -0
- package/dist/mutate.js +266 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +99 -0
- package/dist/run.d.ts +68 -0
- package/dist/run.js +240 -0
- package/package.json +37 -6
- package/index.js +0 -6
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type Mutation, type SpecPlan } from './mutate.js';
|
|
2
|
+
export type Verdict = 'killed' | 'survived' | 'cannot-mutate' | 'error';
|
|
3
|
+
export interface MutationResult {
|
|
4
|
+
testTitle: string;
|
|
5
|
+
testLine: number;
|
|
6
|
+
/** 1-based line of the mutated assertion; null when none was located. */
|
|
7
|
+
assertLine: number | null;
|
|
8
|
+
verdict: Verdict;
|
|
9
|
+
/** Present for cannot-mutate and error verdicts. */
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SpecResult {
|
|
13
|
+
specPath: string;
|
|
14
|
+
results: MutationResult[];
|
|
15
|
+
unmarkedTests: SpecPlan['unmarkedTests'];
|
|
16
|
+
strayMarkerLines: number[];
|
|
17
|
+
malformedMarkerLines: number[];
|
|
18
|
+
}
|
|
19
|
+
export interface RunOptions {
|
|
20
|
+
/** Directory Playwright is spawned from; defaults to process.cwd(). */
|
|
21
|
+
cwd?: string;
|
|
22
|
+
/** Hard cap for one mutation run; the child is killed past it. Default 5 min. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Extra args appended to every `playwright test` invocation. */
|
|
25
|
+
playwrightArgs?: string[];
|
|
26
|
+
/** Command that launches Playwright; default ['npx', 'playwright', 'test']. */
|
|
27
|
+
playwrightCommand?: string[];
|
|
28
|
+
/** Extra identifiers to treat as test builders (custom fixtures). */
|
|
29
|
+
testIds?: string[];
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
32
|
+
type RunVerdict = {
|
|
33
|
+
verdict: 'killed' | 'survived';
|
|
34
|
+
} | {
|
|
35
|
+
verdict: 'cannot-mutate';
|
|
36
|
+
reason: string;
|
|
37
|
+
} | {
|
|
38
|
+
verdict: 'error';
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Decide a verdict from a Playwright JSON report. The exit code alone cannot
|
|
43
|
+
* be trusted: Playwright exits 1 for a failed test, for "no tests found" and
|
|
44
|
+
* for many crashes alike, and only the report tells those apart.
|
|
45
|
+
*/
|
|
46
|
+
export declare function verdictFromReport(raw: string): RunVerdict;
|
|
47
|
+
/**
|
|
48
|
+
* Run one planned mutation: write the inverted copy next to the spec, run
|
|
49
|
+
* Playwright against exactly that copy and test line, and read the verdict
|
|
50
|
+
* from the JSON report. The copy and the report directory are always removed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function runMutation(specPath: string, index: number, mutation: Extract<Mutation, {
|
|
53
|
+
mutatedSource: string;
|
|
54
|
+
}>, opts?: RunOptions): Promise<RunVerdict>;
|
|
55
|
+
/**
|
|
56
|
+
* Delete leftover mutation copies in a directory: files whose pid token
|
|
57
|
+
* points at a process that no longer exists. Copies of live concurrent gate
|
|
58
|
+
* runs are left alone. Returns the paths that were removed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sweepStaleMutationFiles(dir: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* Gate one spec file end to end: plan its mutations, sweep stale copies from
|
|
63
|
+
* earlier crashed runs, run every mutable mutation and collect verdicts.
|
|
64
|
+
* A spec containing test.only is refused outright: .only filters out every
|
|
65
|
+
* other test, so verdicts from such a run would be fiction.
|
|
66
|
+
*/
|
|
67
|
+
export declare function runSpec(specPath: string, opts?: RunOptions): Promise<SpecResult>;
|
|
68
|
+
export {};
|
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.2.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,45 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": "Vladyslav Dmitriiev <vladyslav.dmitriiev@pm.me>",
|
|
18
|
-
"
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "dist/index.js",
|
|
20
|
+
"types": "dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
19
28
|
"bin": {
|
|
20
|
-
"playwright-mutation-gate": "
|
|
29
|
+
"playwright-mutation-gate": "dist/cli.js"
|
|
21
30
|
},
|
|
22
31
|
"files": [
|
|
23
|
-
"
|
|
32
|
+
"dist"
|
|
24
33
|
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"lint": "eslint .",
|
|
38
|
+
"test:unit": "vitest run",
|
|
39
|
+
"test:e2e": "playwright test",
|
|
40
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
41
|
+
"test": "npm run lint && npm run typecheck && npm run build && npm run test:unit && npm run test:e2e && npm run test:integration"
|
|
42
|
+
},
|
|
25
43
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@eslint/js": "^10.0.1",
|
|
48
|
+
"@playwright/test": "^1.61.1",
|
|
49
|
+
"@types/node": "^26.1.1",
|
|
50
|
+
"eslint": "^10.7.0",
|
|
51
|
+
"globals": "^17.7.0",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"typescript-eslint": "^8.63.0",
|
|
54
|
+
"vitest": "^4.1.10"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"commander": "^15.0.0"
|
|
27
58
|
}
|
|
28
59
|
}
|