playwright-mutation-gate 0.2.2 → 0.3.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 +87 -4
- package/dist/behavior.d.ts +34 -0
- package/dist/behavior.js +167 -0
- package/dist/cli.js +10 -2
- package/dist/extract.d.ts +21 -0
- package/dist/extract.js +103 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/mutate.d.ts +101 -0
- package/dist/mutate.js +156 -31
- package/dist/report.d.ts +29 -1
- package/dist/report.js +93 -3
- package/dist/run.d.ts +54 -1
- package/dist/run.js +149 -2
- package/package.json +1 -1
package/dist/run.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, dirname, join, relative } from 'node:path';
|
|
5
|
+
import { planBehaviorMutations } from './behavior.js';
|
|
5
6
|
import { planSpecMutations, writeMutationFile } from './mutate.js';
|
|
6
7
|
export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
8
|
const DEFAULT_COMMAND = ['npx', 'playwright', 'test'];
|
|
@@ -142,6 +143,107 @@ export async function runMutation(specPath, index, mutation, opts = {}) {
|
|
|
142
143
|
rmSync(reportDir, { recursive: true, force: true });
|
|
143
144
|
}
|
|
144
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Decide a behavior verdict from a Playwright JSON report and whether the
|
|
148
|
+
* injected handler recorded a hit (the named value was found and corrupted).
|
|
149
|
+
* Mirrors verdictFromReport's stats reading, then layers the behavior
|
|
150
|
+
* semantics: no hit means the value never reached a rewritable response
|
|
151
|
+
* (cannot-locate); a hit plus a red test is killed; a hit plus a green test is
|
|
152
|
+
* a hollow assertion (weak).
|
|
153
|
+
*/
|
|
154
|
+
export function behaviorVerdictFromReport(raw, hit) {
|
|
155
|
+
let report;
|
|
156
|
+
try {
|
|
157
|
+
report = JSON.parse(raw);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return { verdict: 'error', reason: 'Playwright JSON report is not valid JSON' };
|
|
161
|
+
}
|
|
162
|
+
const stats = report.stats;
|
|
163
|
+
const expected = typeof stats?.expected === 'number' ? stats.expected : null;
|
|
164
|
+
const unexpected = typeof stats?.unexpected === 'number' ? stats.unexpected : null;
|
|
165
|
+
const flaky = typeof stats?.flaky === 'number' ? stats.flaky : null;
|
|
166
|
+
const skipped = typeof stats?.skipped === 'number' ? stats.skipped : 0;
|
|
167
|
+
if (expected === null || unexpected === null || flaky === null) {
|
|
168
|
+
return { verdict: 'error', reason: 'Playwright JSON report has no stats block' };
|
|
169
|
+
}
|
|
170
|
+
if (expected + unexpected + flaky === 0) {
|
|
171
|
+
if (skipped > 0) {
|
|
172
|
+
return {
|
|
173
|
+
verdict: 'cannot-locate',
|
|
174
|
+
reason: 'the test was skipped at runtime, so its behavior could not be observed',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const message = report.errors?.map((e) => e.message).find((m) => typeof m === 'string');
|
|
178
|
+
const detail = message ? `: ${message.split('\n', 1)[0]}` : '';
|
|
179
|
+
return { verdict: 'error', reason: `no tests ran under the mutation filter${detail}` };
|
|
180
|
+
}
|
|
181
|
+
if (!hit) {
|
|
182
|
+
return {
|
|
183
|
+
verdict: 'cannot-locate',
|
|
184
|
+
reason: 'the named value never appeared in an intercepted response, so nothing was corrupted',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (unexpected + flaky > 0) {
|
|
188
|
+
return {
|
|
189
|
+
verdict: 'killed',
|
|
190
|
+
reason: 'the test failed once the named value was corrupted (a red here could also be flow breakage, not the assertion)',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { verdict: 'weak', reason: 'the test stayed green while the value it names was broken' };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Run one behavior mutation: write the injected copy (route interception on top
|
|
197
|
+
* of the intact assertion), run Playwright against exactly that test, and read
|
|
198
|
+
* the verdict from the JSON report plus the hit-file the handler writes when it
|
|
199
|
+
* corrupts a response. The copy, the report dir and the hit-file are always
|
|
200
|
+
* removed.
|
|
201
|
+
*/
|
|
202
|
+
export async function runBehaviorMutation(specPath, index, mutation, opts = {}) {
|
|
203
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
204
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
205
|
+
const command = opts.playwrightCommand ?? DEFAULT_COMMAND;
|
|
206
|
+
const copyPath = writeMutationFile(specPath, index, mutation.mutatedSource);
|
|
207
|
+
const reportDir = mkdtempSync(join(tmpdir(), 'pmg-'));
|
|
208
|
+
const reportPath = join(reportDir, 'report.json');
|
|
209
|
+
const hitPath = join(reportDir, 'hit');
|
|
210
|
+
try {
|
|
211
|
+
const target = `${relative(cwd, copyPath) || copyPath}:${mutation.testLine}`;
|
|
212
|
+
const outcome = await runChild([...command, target, '--reporter=json', ...(opts.playwrightArgs ?? [])], cwd, { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath, PMG_HIT_FILE: hitPath }, timeoutMs);
|
|
213
|
+
if (outcome.spawnError) {
|
|
214
|
+
return { verdict: 'error', reason: `could not launch ${command.join(' ')}: ${outcome.spawnError}` };
|
|
215
|
+
}
|
|
216
|
+
if (outcome.timedOut) {
|
|
217
|
+
return {
|
|
218
|
+
verdict: 'error',
|
|
219
|
+
reason: `Playwright run timed out after ${Math.round(timeoutMs / 1000)}s and was killed`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
let raw;
|
|
223
|
+
try {
|
|
224
|
+
raw = readFileSync(reportPath, 'utf8');
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
const tail = outcome.output.trim();
|
|
228
|
+
return {
|
|
229
|
+
verdict: 'error',
|
|
230
|
+
reason: `Playwright exited with code ${outcome.code ?? 'unknown'} without producing a report` +
|
|
231
|
+
(tail === '' ? '' : `; output tail: ${tail.slice(-500)}`),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const hit = existsSync(hitPath) && statSync(hitPath).size > 0;
|
|
235
|
+
return behaviorVerdictFromReport(raw, hit);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(copyPath);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Already gone.
|
|
243
|
+
}
|
|
244
|
+
rmSync(reportDir, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
145
247
|
function pidIsAlive(pid) {
|
|
146
248
|
try {
|
|
147
249
|
process.kill(pid, 0);
|
|
@@ -217,6 +319,7 @@ export async function runSpec(specPath, opts = {}) {
|
|
|
217
319
|
assertLine: m.assertLine,
|
|
218
320
|
verdict: 'cannot-mutate',
|
|
219
321
|
reason: m.cannotMutate,
|
|
322
|
+
...(m.gateSkip !== undefined ? { gateSkip: m.gateSkip } : {}),
|
|
220
323
|
});
|
|
221
324
|
continue;
|
|
222
325
|
}
|
|
@@ -230,11 +333,55 @@ export async function runSpec(specPath, opts = {}) {
|
|
|
230
333
|
});
|
|
231
334
|
}
|
|
232
335
|
}
|
|
233
|
-
|
|
336
|
+
const result = {
|
|
234
337
|
specPath,
|
|
235
338
|
results,
|
|
236
339
|
unmarkedTests: plan.unmarkedTests,
|
|
237
340
|
strayMarkerLines: plan.strayMarkerLines,
|
|
238
341
|
malformedMarkerLines: plan.malformedMarkerLines,
|
|
342
|
+
malformedGateSkipLines: plan.malformedGateSkipLines,
|
|
239
343
|
};
|
|
344
|
+
if (opts.behavior) {
|
|
345
|
+
result.behavior = await runBehaviorPass(specPath, source, plan.onlyTestLines, opts);
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Run the behavior mutation pass for one spec: plan the route-interception
|
|
351
|
+
* mutations and run each one that can be located. A spec containing test.only
|
|
352
|
+
* is refused (same reason as inversion: .only makes every verdict fiction).
|
|
353
|
+
*/
|
|
354
|
+
async function runBehaviorPass(specPath, source, onlyTestLines, opts) {
|
|
355
|
+
const plan = planBehaviorMutations(source, { testIds: opts.testIds });
|
|
356
|
+
const results = [];
|
|
357
|
+
if (onlyTestLines.length > 0) {
|
|
358
|
+
const reason = `test.only on line ${onlyTestLines.join(', ')} of ${basename(specPath)} ` +
|
|
359
|
+
'filters out every other test, so no behavior verdict would be honest; remove .only first';
|
|
360
|
+
for (const m of plan) {
|
|
361
|
+
results.push({ testTitle: m.testTitle, testLine: m.testLine, assertLine: m.assertLine, verdict: 'error', reason });
|
|
362
|
+
}
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
365
|
+
for (const [index, m] of plan.entries()) {
|
|
366
|
+
if ('cannotLocate' in m) {
|
|
367
|
+
results.push({
|
|
368
|
+
testTitle: m.testTitle,
|
|
369
|
+
testLine: m.testLine,
|
|
370
|
+
assertLine: m.assertLine,
|
|
371
|
+
verdict: 'cannot-locate',
|
|
372
|
+
reason: m.cannotLocate,
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const run = await runBehaviorMutation(specPath, index, m, opts);
|
|
377
|
+
results.push({
|
|
378
|
+
testTitle: m.testTitle,
|
|
379
|
+
testLine: m.testLine,
|
|
380
|
+
assertLine: m.assertLine,
|
|
381
|
+
verdict: run.verdict,
|
|
382
|
+
value: m.value,
|
|
383
|
+
...('reason' in run && run.reason !== undefined ? { reason: run.reason } : {}),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return results;
|
|
240
387
|
}
|
package/package.json
CHANGED