playwright-mutation-gate 0.2.1 → 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 +9 -1
package/dist/mutate.js
CHANGED
|
@@ -3,6 +3,44 @@ import { basename, dirname, join } from 'node:path';
|
|
|
3
3
|
import { invertAssertLine } from './invert.js';
|
|
4
4
|
import { CODE, LINE_COMMENT, findCloseMasked, lex } from './lex.js';
|
|
5
5
|
export const MARKER = '// @primary-assert';
|
|
6
|
+
const GATE_SKIP = '@gate-skip';
|
|
7
|
+
const YMD_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
8
|
+
/** True when `s` is a real calendar date in strict YYYY-MM-DD form. */
|
|
9
|
+
export function isValidYmd(s) {
|
|
10
|
+
const m = YMD_RE.exec(s);
|
|
11
|
+
if (!m)
|
|
12
|
+
return false;
|
|
13
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
14
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31)
|
|
15
|
+
return false;
|
|
16
|
+
const date = new Date(Date.UTC(y, mo - 1, d));
|
|
17
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d;
|
|
18
|
+
}
|
|
19
|
+
const attr = (text, name) => {
|
|
20
|
+
const m = new RegExp(`(?:^|\\s)${name}=("[^"]*"|\\S+)`).exec(text);
|
|
21
|
+
if (!m)
|
|
22
|
+
return null;
|
|
23
|
+
const raw = m[1];
|
|
24
|
+
return raw.startsWith('"') ? raw.slice(1, -1) : raw;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Parse a `@gate-skip owner=.. expires=YYYY-MM-DD reason=".."` line comment.
|
|
28
|
+
* Returns the acknowledgement, or null when it is malformed (missing owner or
|
|
29
|
+
* expires, or an unparseable date) - the same fail-closed policy as a malformed
|
|
30
|
+
* @primary-assert marker.
|
|
31
|
+
*/
|
|
32
|
+
export function parseGateSkip(commentText) {
|
|
33
|
+
const body = commentText.replace(/^\/\/\s*/, '');
|
|
34
|
+
if (!(body === GATE_SKIP || body.startsWith(GATE_SKIP + ' ')))
|
|
35
|
+
return null;
|
|
36
|
+
const rest = body.slice(GATE_SKIP.length);
|
|
37
|
+
const owner = attr(rest, 'owner');
|
|
38
|
+
const expires = attr(rest, 'expires');
|
|
39
|
+
if (owner === null || owner === '' || expires === null || !isValidYmd(expires))
|
|
40
|
+
return null;
|
|
41
|
+
const reason = attr(rest, 'reason');
|
|
42
|
+
return reason !== null && reason !== '' ? { owner, expires, reason } : { owner, expires };
|
|
43
|
+
}
|
|
6
44
|
/** Read the test title: the first argument when it is a literal without interpolation. */
|
|
7
45
|
function readTitle(src, open) {
|
|
8
46
|
let i = open + 1;
|
|
@@ -105,13 +143,15 @@ function findTestBlocks(src, kind, ids) {
|
|
|
105
143
|
// test.skip/fixme never run and test.fail inverts pass/fail semantics, so a
|
|
106
144
|
// mutation verdict on them would be a lie; they are also exempt from sentinel
|
|
107
145
|
// accounting. test.only runs and is treated like a plain test.
|
|
108
|
-
const UNGATEABLE_MODES = new Set(['skip', 'fixme', 'fail']);
|
|
146
|
+
export const UNGATEABLE_MODES = new Set(['skip', 'fixme', 'fail']);
|
|
109
147
|
/**
|
|
110
|
-
* Parse a spec source
|
|
111
|
-
* test
|
|
112
|
-
*
|
|
148
|
+
* Parse a spec source into the shared view both planners build on: which
|
|
149
|
+
* test() blocks carry a valid `@primary-assert` marker (with the assertion
|
|
150
|
+
* line already resolved), plus the spec-level marker findings. The parser
|
|
151
|
+
* (lexer, test-id collection, block finding, marker association, assertion-line
|
|
152
|
+
* resolution) lives here so inversion and behavior planning cannot diverge.
|
|
113
153
|
*/
|
|
114
|
-
export function
|
|
154
|
+
export function analyzeSpec(source, opts = {}) {
|
|
115
155
|
const kind = lex(source);
|
|
116
156
|
const ids = collectTestIds(source, kind, opts.testIds ?? []);
|
|
117
157
|
const blocks = findTestBlocks(source, kind, ids);
|
|
@@ -182,7 +222,38 @@ export function planSpecMutations(source, opts = {}) {
|
|
|
182
222
|
else
|
|
183
223
|
strayMarkerLines.push(n + 1);
|
|
184
224
|
}
|
|
185
|
-
|
|
225
|
+
// A @gate-skip decorates the test whose declaration it sits on (trailing) or
|
|
226
|
+
// immediately above (its own line, only blank/comment lines between). It must
|
|
227
|
+
// parse (owner + a real expires date) and land on a test() block; anything
|
|
228
|
+
// else is malformed and fails closed, like a malformed @primary-assert.
|
|
229
|
+
const malformedGateSkipLines = [];
|
|
230
|
+
for (let n = 0; n < lines.length; n++) {
|
|
231
|
+
if (!lines[n].includes(GATE_SKIP))
|
|
232
|
+
continue;
|
|
233
|
+
const lc = lineComment(n);
|
|
234
|
+
const parsed = lc ? parseGateSkip(lc.text) : null;
|
|
235
|
+
if (!lc || parsed === null) {
|
|
236
|
+
malformedGateSkipLines.push(n + 1);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
let declLine;
|
|
240
|
+
if (lc.codeBefore) {
|
|
241
|
+
declLine = n + 1;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
let next = n + 1; // 0-based index of the line after the marker
|
|
245
|
+
while (next < lines.length && !lineHasCode(next))
|
|
246
|
+
next++;
|
|
247
|
+
declLine = next + 1;
|
|
248
|
+
}
|
|
249
|
+
const block = blocks.find((b) => lineOf(b.anchor) === declLine);
|
|
250
|
+
if (!block || block.gateSkip !== undefined) {
|
|
251
|
+
malformedGateSkipLines.push(n + 1);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
block.gateSkip = parsed;
|
|
255
|
+
}
|
|
256
|
+
const markedTests = [];
|
|
186
257
|
const unmarkedTests = [];
|
|
187
258
|
const onlyTestLines = [];
|
|
188
259
|
for (const block of blocks) {
|
|
@@ -196,48 +267,102 @@ export function planSpecMutations(source, opts = {}) {
|
|
|
196
267
|
continue;
|
|
197
268
|
}
|
|
198
269
|
const marker = block.markers[0];
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
270
|
+
// Resolve the assertion line for the first marker: a trailing marker sits
|
|
271
|
+
// on the assertion itself; a whole-line marker points at the next line that
|
|
272
|
+
// contains code (blank and comment-only lines are skipped, not mutated).
|
|
273
|
+
let assertLine = null;
|
|
274
|
+
let resolveError = null;
|
|
275
|
+
let target; // 0-based index of the assertion line
|
|
276
|
+
if (marker.sameLine) {
|
|
277
|
+
target = marker.line - 1;
|
|
278
|
+
assertLine = target + 1;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
target = marker.line; // 0-based index of the line after the marker
|
|
282
|
+
while (target < lines.length && !lineHasCode(target))
|
|
283
|
+
target++;
|
|
284
|
+
if (target >= lines.length)
|
|
285
|
+
resolveError = 'no code line follows the marker';
|
|
286
|
+
else
|
|
287
|
+
assertLine = target + 1;
|
|
288
|
+
}
|
|
289
|
+
markedTests.push({
|
|
290
|
+
title: block.title,
|
|
291
|
+
testLine,
|
|
292
|
+
mode: block.mode,
|
|
293
|
+
markerCount: block.markers.length,
|
|
294
|
+
markerLine: marker.line,
|
|
295
|
+
open: block.open,
|
|
296
|
+
close: block.close,
|
|
297
|
+
assertLine,
|
|
298
|
+
resolveError,
|
|
299
|
+
...(block.gateSkip !== undefined ? { gateSkip: block.gateSkip } : {}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
source,
|
|
304
|
+
lines,
|
|
305
|
+
kind,
|
|
306
|
+
markedTests,
|
|
307
|
+
unmarkedTests,
|
|
308
|
+
strayMarkerLines,
|
|
309
|
+
onlyTestLines,
|
|
310
|
+
malformedMarkerLines,
|
|
311
|
+
malformedGateSkipLines,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Parse a spec source and plan its mutations: one inverted copy per marked
|
|
316
|
+
* test. Anything the gate cannot honestly mutate is reported with a reason,
|
|
317
|
+
* never skipped silently.
|
|
318
|
+
*/
|
|
319
|
+
export function planSpecMutations(source, opts = {}) {
|
|
320
|
+
const analysis = analyzeSpec(source, opts);
|
|
321
|
+
const { lines, markedTests } = analysis;
|
|
322
|
+
const mutations = [];
|
|
323
|
+
for (const t of markedTests) {
|
|
324
|
+
const base = { testTitle: t.title, testLine: t.testLine, markerLine: t.markerLine };
|
|
325
|
+
// A @gate-skip rides along on every cannot-mutate verdict for this test, so
|
|
326
|
+
// the gate can treat an acknowledged skip as accepted rather than failing.
|
|
327
|
+
const gs = t.gateSkip !== undefined ? { gateSkip: t.gateSkip } : {};
|
|
328
|
+
if (UNGATEABLE_MODES.has(t.mode)) {
|
|
329
|
+
const why = t.mode === 'fail'
|
|
202
330
|
? 'test.fail() expects failure, so a mutation verdict would be meaningless'
|
|
203
|
-
: `test.${
|
|
204
|
-
mutations.push({ ...base, assertLine: null, cannotMutate: why });
|
|
331
|
+
: `test.${t.mode}() never runs, so a mutation cannot be observed`;
|
|
332
|
+
mutations.push({ ...base, ...gs, assertLine: null, cannotMutate: why });
|
|
205
333
|
continue;
|
|
206
334
|
}
|
|
207
|
-
if (
|
|
335
|
+
if (t.markerCount > 1) {
|
|
208
336
|
mutations.push({
|
|
209
337
|
...base,
|
|
338
|
+
...gs,
|
|
210
339
|
assertLine: null,
|
|
211
|
-
cannotMutate: `found ${
|
|
340
|
+
cannotMutate: `found ${t.markerCount} @primary-assert markers in one test, expected exactly one`,
|
|
212
341
|
});
|
|
213
342
|
continue;
|
|
214
343
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
}
|
|
344
|
+
if (t.assertLine === null) {
|
|
345
|
+
mutations.push({ ...base, ...gs, assertLine: null, cannotMutate: t.resolveError ?? 'assertion line not found' });
|
|
346
|
+
continue;
|
|
230
347
|
}
|
|
348
|
+
const target = t.assertLine - 1;
|
|
231
349
|
const inverted = invertAssertLine(lines[target]);
|
|
232
350
|
if ('cannotMutate' in inverted) {
|
|
233
|
-
mutations.push({ ...base, assertLine:
|
|
351
|
+
mutations.push({ ...base, ...gs, assertLine: t.assertLine, cannotMutate: inverted.cannotMutate });
|
|
234
352
|
continue;
|
|
235
353
|
}
|
|
236
354
|
const mutatedLines = lines.slice();
|
|
237
355
|
mutatedLines[target] = inverted.ok;
|
|
238
|
-
mutations.push({ ...base, assertLine:
|
|
356
|
+
mutations.push({ ...base, assertLine: t.assertLine, mutatedSource: mutatedLines.join('\n') });
|
|
239
357
|
}
|
|
240
|
-
return {
|
|
358
|
+
return {
|
|
359
|
+
mutations,
|
|
360
|
+
unmarkedTests: analysis.unmarkedTests,
|
|
361
|
+
strayMarkerLines: analysis.strayMarkerLines,
|
|
362
|
+
malformedMarkerLines: analysis.malformedMarkerLines,
|
|
363
|
+
malformedGateSkipLines: analysis.malformedGateSkipLines,
|
|
364
|
+
onlyTestLines: analysis.onlyTestLines,
|
|
365
|
+
};
|
|
241
366
|
}
|
|
242
367
|
/**
|
|
243
368
|
* Name for a mutated copy: the marker segment goes right after the first
|
package/dist/report.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { SpecResult } from './run.js';
|
|
|
2
2
|
export interface GateOptions {
|
|
3
3
|
/** When true (default), tests without a @primary-assert marker fail the gate. */
|
|
4
4
|
requireSentinel: boolean;
|
|
5
|
+
/** "Today" for @gate-skip expiry checks; defaults to the current date. */
|
|
6
|
+
now?: Date;
|
|
5
7
|
}
|
|
6
8
|
export interface Summary {
|
|
7
9
|
killed: number;
|
|
@@ -11,11 +13,37 @@ export interface Summary {
|
|
|
11
13
|
unmarkedTests: number;
|
|
12
14
|
strayMarkers: number;
|
|
13
15
|
malformedMarkers: number;
|
|
16
|
+
/**
|
|
17
|
+
* @gate-skip tallies. `acknowledgedSkips`: cannot-mutate verdicts covered by
|
|
18
|
+
* a valid, unexpired acknowledgement - accepted, does NOT fail the gate.
|
|
19
|
+
* `expiredSkips`: the acknowledgement's expires date has passed, so the skip
|
|
20
|
+
* fails closed again. `malformedGateSkip`: a @gate-skip that does not parse or
|
|
21
|
+
* is misplaced - fails the gate, like a malformed @primary-assert.
|
|
22
|
+
*/
|
|
23
|
+
acknowledgedSkips: number;
|
|
24
|
+
expiredSkips: number;
|
|
25
|
+
malformedGateSkip: number;
|
|
26
|
+
/**
|
|
27
|
+
* Behavior mutation tallies, present only when the behavior pass ran.
|
|
28
|
+
* `behaviorKilled`: the test caught the corrupted value. `weak`: the test
|
|
29
|
+
* stayed green while the value it names was broken (a hollow assertion) -
|
|
30
|
+
* this fails the gate. `behaviorCannotLocate`: the value could not be found
|
|
31
|
+
* in a rewritable response - reported honestly and, unlike inversion's
|
|
32
|
+
* cannot-mutate, does NOT fail the gate (a value not living in an
|
|
33
|
+
* interceptable response is expected, not a defect). `behaviorErrors`: the
|
|
34
|
+
* behavior run itself failed - fails the gate.
|
|
35
|
+
*/
|
|
36
|
+
behaviorRan: boolean;
|
|
37
|
+
behaviorKilled: number;
|
|
38
|
+
weak: number;
|
|
39
|
+
behaviorCannotLocate: number;
|
|
40
|
+
behaviorErrors: number;
|
|
14
41
|
/**
|
|
15
42
|
* Green means proven: every marked assertion was killed under inversion and
|
|
16
43
|
* nothing was left unverified. Any survived, cannot-mutate or error verdict
|
|
17
44
|
* fails the gate, as do marker mistakes (stray or malformed markers) and,
|
|
18
|
-
* under require-sentinel, tests with no marker at all.
|
|
45
|
+
* under require-sentinel, tests with no marker at all. When the behavior pass
|
|
46
|
+
* ran, any weak or behavior-error verdict also fails the gate.
|
|
19
47
|
*/
|
|
20
48
|
gatePassed: boolean;
|
|
21
49
|
}
|
package/dist/report.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** Format a date as YYYY-MM-DD in local time, so expiry compares lexically. */
|
|
2
|
+
function ymd(date) {
|
|
3
|
+
const y = date.getFullYear();
|
|
4
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
5
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
6
|
+
return `${y}-${m}-${d}`;
|
|
7
|
+
}
|
|
1
8
|
export function summarize(specs, opts) {
|
|
2
9
|
const s = {
|
|
3
10
|
killed: 0,
|
|
@@ -7,22 +14,53 @@ export function summarize(specs, opts) {
|
|
|
7
14
|
unmarkedTests: 0,
|
|
8
15
|
strayMarkers: 0,
|
|
9
16
|
malformedMarkers: 0,
|
|
17
|
+
acknowledgedSkips: 0,
|
|
18
|
+
expiredSkips: 0,
|
|
19
|
+
malformedGateSkip: 0,
|
|
20
|
+
behaviorRan: false,
|
|
21
|
+
behaviorKilled: 0,
|
|
22
|
+
weak: 0,
|
|
23
|
+
behaviorCannotLocate: 0,
|
|
24
|
+
behaviorErrors: 0,
|
|
10
25
|
gatePassed: true,
|
|
11
26
|
};
|
|
27
|
+
const today = ymd(opts.now ?? new Date());
|
|
12
28
|
for (const spec of specs) {
|
|
13
29
|
for (const m of spec.results) {
|
|
14
30
|
if (m.verdict === 'killed')
|
|
15
31
|
s.killed++;
|
|
16
32
|
else if (m.verdict === 'survived')
|
|
17
33
|
s.survived++;
|
|
18
|
-
else if (m.verdict === 'cannot-mutate')
|
|
19
|
-
|
|
34
|
+
else if (m.verdict === 'cannot-mutate') {
|
|
35
|
+
// A cannot-mutate covered by a valid @gate-skip is accepted until it
|
|
36
|
+
// expires; expired ones fail closed again. Uncovered ones fail as before.
|
|
37
|
+
if (m.gateSkip === undefined)
|
|
38
|
+
s.cannotMutate++;
|
|
39
|
+
else if (today > m.gateSkip.expires)
|
|
40
|
+
s.expiredSkips++;
|
|
41
|
+
else
|
|
42
|
+
s.acknowledgedSkips++;
|
|
43
|
+
}
|
|
20
44
|
else
|
|
21
45
|
s.errors++;
|
|
22
46
|
}
|
|
23
47
|
s.unmarkedTests += spec.unmarkedTests.length;
|
|
24
48
|
s.strayMarkers += spec.strayMarkerLines.length;
|
|
25
49
|
s.malformedMarkers += spec.malformedMarkerLines.length;
|
|
50
|
+
s.malformedGateSkip += spec.malformedGateSkipLines.length;
|
|
51
|
+
if (spec.behavior !== undefined) {
|
|
52
|
+
s.behaviorRan = true;
|
|
53
|
+
for (const b of spec.behavior) {
|
|
54
|
+
if (b.verdict === 'killed')
|
|
55
|
+
s.behaviorKilled++;
|
|
56
|
+
else if (b.verdict === 'weak')
|
|
57
|
+
s.weak++;
|
|
58
|
+
else if (b.verdict === 'cannot-locate')
|
|
59
|
+
s.behaviorCannotLocate++;
|
|
60
|
+
else
|
|
61
|
+
s.behaviorErrors++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
26
64
|
}
|
|
27
65
|
s.gatePassed =
|
|
28
66
|
s.survived === 0 &&
|
|
@@ -30,9 +68,19 @@ export function summarize(specs, opts) {
|
|
|
30
68
|
s.errors === 0 &&
|
|
31
69
|
s.strayMarkers === 0 &&
|
|
32
70
|
s.malformedMarkers === 0 &&
|
|
33
|
-
|
|
71
|
+
s.expiredSkips === 0 &&
|
|
72
|
+
s.malformedGateSkip === 0 &&
|
|
73
|
+
(!opts.requireSentinel || s.unmarkedTests === 0) &&
|
|
74
|
+
s.weak === 0 &&
|
|
75
|
+
s.behaviorErrors === 0;
|
|
34
76
|
return s;
|
|
35
77
|
}
|
|
78
|
+
const BEHAVIOR_VERDICT_COLOR = {
|
|
79
|
+
killed: '\x1b[32m',
|
|
80
|
+
weak: '\x1b[31m',
|
|
81
|
+
'cannot-locate': '\x1b[33m',
|
|
82
|
+
error: '\x1b[33m',
|
|
83
|
+
};
|
|
36
84
|
export function jsonReport(specs, opts) {
|
|
37
85
|
return JSON.stringify({ specs, summary: summarize(specs, opts) }, null, 2);
|
|
38
86
|
}
|
|
@@ -57,13 +105,29 @@ export function renderTable(specs, opts) {
|
|
|
57
105
|
const titleWidth = Math.min(TITLE_WIDTH, Math.max(4, ...specs.flatMap((spec) => [
|
|
58
106
|
...spec.results.map((m) => clip(m.testTitle).length),
|
|
59
107
|
...spec.unmarkedTests.map((t) => clip(t.title).length),
|
|
108
|
+
...(spec.behavior ?? []).map((b) => clip(b.testTitle).length),
|
|
60
109
|
])));
|
|
61
110
|
const row = (label, color, title, line) => ' ' + paint(color, label.padEnd(labelWidth)) + ' ' + clip(title).padEnd(titleWidth) + ' ' + line;
|
|
111
|
+
const today = ymd(opts.now ?? new Date());
|
|
62
112
|
const lines = [];
|
|
63
113
|
for (const spec of specs) {
|
|
64
114
|
lines.push(spec.specPath);
|
|
65
115
|
for (const m of spec.results) {
|
|
66
116
|
const at = m.assertLine === null ? ':?' : `:${m.assertLine}`;
|
|
117
|
+
// An acknowledged (unexpired) @gate-skip reads as accepted, not failing;
|
|
118
|
+
// an expired one reads as a failure that fell back closed.
|
|
119
|
+
if (m.verdict === 'cannot-mutate' && m.gateSkip !== undefined) {
|
|
120
|
+
const expired = today > m.gateSkip.expires;
|
|
121
|
+
const label = expired ? 'expired-skip' : 'acknowledged';
|
|
122
|
+
const color = expired ? '\x1b[31m' : '\x1b[32m';
|
|
123
|
+
lines.push(row(label, color, m.testTitle, at));
|
|
124
|
+
const gs = m.gateSkip;
|
|
125
|
+
const detail = `owner=${gs.owner} expires=${gs.expires}` +
|
|
126
|
+
(gs.reason !== undefined ? ` reason="${gs.reason}"` : '') +
|
|
127
|
+
(expired ? ' (EXPIRED)' : '');
|
|
128
|
+
lines.push(' ' + ' '.repeat(labelWidth) + ' ' + detail);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
67
131
|
lines.push(row(m.verdict, VERDICT_COLOR[m.verdict], m.testTitle, at));
|
|
68
132
|
if (m.reason !== undefined) {
|
|
69
133
|
lines.push(' ' + ' '.repeat(labelWidth) + ' ' + m.reason);
|
|
@@ -78,6 +142,19 @@ export function renderTable(specs, opts) {
|
|
|
78
142
|
for (const line of spec.malformedMarkerLines) {
|
|
79
143
|
lines.push(` malformed @primary-assert marker at line ${line}`);
|
|
80
144
|
}
|
|
145
|
+
for (const line of spec.malformedGateSkipLines) {
|
|
146
|
+
lines.push(` malformed @gate-skip marker at line ${line} (needs owner= and a valid expires=YYYY-MM-DD on a test)`);
|
|
147
|
+
}
|
|
148
|
+
if (spec.behavior !== undefined && spec.behavior.length > 0) {
|
|
149
|
+
lines.push(' behavior:');
|
|
150
|
+
for (const b of spec.behavior) {
|
|
151
|
+
const at = b.assertLine === null ? ':?' : `:${b.assertLine}`;
|
|
152
|
+
lines.push(row(b.verdict, BEHAVIOR_VERDICT_COLOR[b.verdict], b.testTitle, at));
|
|
153
|
+
if (b.reason !== undefined) {
|
|
154
|
+
lines.push(' ' + ' '.repeat(labelWidth) + ' ' + b.reason);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
81
158
|
lines.push('');
|
|
82
159
|
}
|
|
83
160
|
const s = summarize(specs, opts);
|
|
@@ -91,7 +168,20 @@ export function renderTable(specs, opts) {
|
|
|
91
168
|
counts.push(`${s.unmarkedTests} unmarked ${s.unmarkedTests === 1 ? 'test' : 'tests'}` +
|
|
92
169
|
(opts.requireSentinel ? '' : ' (not enforced)'));
|
|
93
170
|
}
|
|
171
|
+
if (s.acknowledgedSkips > 0)
|
|
172
|
+
counts.push(`${s.acknowledgedSkips} acknowledged`);
|
|
173
|
+
if (s.expiredSkips > 0)
|
|
174
|
+
counts.push(`${s.expiredSkips} expired-skip`);
|
|
94
175
|
lines.push(counts.join(', '));
|
|
176
|
+
if (s.behaviorRan) {
|
|
177
|
+
lines.push('behavior: ' +
|
|
178
|
+
[
|
|
179
|
+
`${s.behaviorKilled} killed`,
|
|
180
|
+
`${s.weak} weak`,
|
|
181
|
+
`${s.behaviorCannotLocate} cannot-locate`,
|
|
182
|
+
`${s.behaviorErrors} errors`,
|
|
183
|
+
].join(', '));
|
|
184
|
+
}
|
|
95
185
|
lines.push(s.gatePassed
|
|
96
186
|
? paint('\x1b[1;32m', 'Gate passed: every marked assertion can fail.')
|
|
97
187
|
: paint('\x1b[1;31m', 'Gate FAILED: hollow, unverified or broken assertions above.'));
|
package/dist/run.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type BehaviorMutation } from './behavior.js';
|
|
2
|
+
import { type GateSkip, type Mutation, type SpecPlan } from './mutate.js';
|
|
2
3
|
export type Verdict = 'killed' | 'survived' | 'cannot-mutate' | 'error';
|
|
3
4
|
export interface MutationResult {
|
|
4
5
|
testTitle: string;
|
|
@@ -8,6 +9,27 @@ export interface MutationResult {
|
|
|
8
9
|
verdict: Verdict;
|
|
9
10
|
/** Present for cannot-mutate and error verdicts. */
|
|
10
11
|
reason?: string;
|
|
12
|
+
/** A valid @gate-skip acknowledgement on this test, for cannot-mutate verdicts. */
|
|
13
|
+
gateSkip?: GateSkip;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Behavior mutation verdicts. `killed`: the test caught the corrupted value
|
|
17
|
+
* (or the flow broke around it). `weak`: the test stayed green while the value
|
|
18
|
+
* it names was broken - a hollow assertion. `cannot-locate`: the value never
|
|
19
|
+
* appeared in a rewritable response, reported honestly and NOT a gate failure.
|
|
20
|
+
* `error`: the run itself failed (spawn, timeout, no report).
|
|
21
|
+
*/
|
|
22
|
+
export type BehaviorVerdict = 'killed' | 'weak' | 'cannot-locate' | 'error';
|
|
23
|
+
export interface BehaviorResult {
|
|
24
|
+
testTitle: string;
|
|
25
|
+
testLine: number;
|
|
26
|
+
/** 1-based line of the assertion whose value was corrupted; null when none. */
|
|
27
|
+
assertLine: number | null;
|
|
28
|
+
verdict: BehaviorVerdict;
|
|
29
|
+
/** The corrupted value, when one was located. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Explanation for weak / cannot-locate / error, and the flow-breakage caveat on killed. */
|
|
32
|
+
reason?: string;
|
|
11
33
|
}
|
|
12
34
|
export interface SpecResult {
|
|
13
35
|
specPath: string;
|
|
@@ -15,6 +37,9 @@ export interface SpecResult {
|
|
|
15
37
|
unmarkedTests: SpecPlan['unmarkedTests'];
|
|
16
38
|
strayMarkerLines: number[];
|
|
17
39
|
malformedMarkerLines: number[];
|
|
40
|
+
malformedGateSkipLines: number[];
|
|
41
|
+
/** Behavior mutation results, present only when the behavior pass ran. */
|
|
42
|
+
behavior?: BehaviorResult[];
|
|
18
43
|
}
|
|
19
44
|
export interface RunOptions {
|
|
20
45
|
/** Directory Playwright is spawned from; defaults to process.cwd(). */
|
|
@@ -27,6 +52,8 @@ export interface RunOptions {
|
|
|
27
52
|
playwrightCommand?: string[];
|
|
28
53
|
/** Extra identifiers to treat as test builders (custom fixtures). */
|
|
29
54
|
testIds?: string[];
|
|
55
|
+
/** When true, also run the behavior mutation pass (page.route value corruption). */
|
|
56
|
+
behavior?: boolean;
|
|
30
57
|
}
|
|
31
58
|
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
32
59
|
type RunVerdict = {
|
|
@@ -52,6 +79,32 @@ export declare function verdictFromReport(raw: string): RunVerdict;
|
|
|
52
79
|
export declare function runMutation(specPath: string, index: number, mutation: Extract<Mutation, {
|
|
53
80
|
mutatedSource: string;
|
|
54
81
|
}>, opts?: RunOptions): Promise<RunVerdict>;
|
|
82
|
+
type BehaviorRunVerdict = {
|
|
83
|
+
verdict: 'killed' | 'weak' | 'cannot-locate';
|
|
84
|
+
reason?: string;
|
|
85
|
+
} | {
|
|
86
|
+
verdict: 'error';
|
|
87
|
+
reason: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Decide a behavior verdict from a Playwright JSON report and whether the
|
|
91
|
+
* injected handler recorded a hit (the named value was found and corrupted).
|
|
92
|
+
* Mirrors verdictFromReport's stats reading, then layers the behavior
|
|
93
|
+
* semantics: no hit means the value never reached a rewritable response
|
|
94
|
+
* (cannot-locate); a hit plus a red test is killed; a hit plus a green test is
|
|
95
|
+
* a hollow assertion (weak).
|
|
96
|
+
*/
|
|
97
|
+
export declare function behaviorVerdictFromReport(raw: string, hit: boolean): BehaviorRunVerdict;
|
|
98
|
+
/**
|
|
99
|
+
* Run one behavior mutation: write the injected copy (route interception on top
|
|
100
|
+
* of the intact assertion), run Playwright against exactly that test, and read
|
|
101
|
+
* the verdict from the JSON report plus the hit-file the handler writes when it
|
|
102
|
+
* corrupts a response. The copy, the report dir and the hit-file are always
|
|
103
|
+
* removed.
|
|
104
|
+
*/
|
|
105
|
+
export declare function runBehaviorMutation(specPath: string, index: number, mutation: Extract<BehaviorMutation, {
|
|
106
|
+
mutatedSource: string;
|
|
107
|
+
}>, opts?: RunOptions): Promise<BehaviorRunVerdict>;
|
|
55
108
|
/**
|
|
56
109
|
* Delete leftover mutation copies in a directory: files whose pid token
|
|
57
110
|
* points at a process that no longer exists. Copies of live concurrent gate
|