playwright-mutation-gate 0.2.2 → 0.3.1

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.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
- return {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-mutation-gate",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
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",