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/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.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.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
- "main": "index.js",
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": "index.js"
29
+ "playwright-mutation-gate": "dist/cli.js"
21
30
  },
22
31
  "files": [
23
- "index.js"
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": ">=18"
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
  }
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);