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/mutate.d.ts CHANGED
@@ -16,6 +16,8 @@ export type Mutation = {
16
16
  /** 1-based line of the assertion candidate; null when none was located. */
17
17
  assertLine: number | null;
18
18
  cannotMutate: string;
19
+ /** A valid @gate-skip acknowledgement decorating this test, if any. */
20
+ gateSkip?: GateSkip;
19
21
  };
20
22
  export interface PlanOptions {
21
23
  /**
@@ -43,7 +45,105 @@ export interface SpecPlan {
43
45
  onlyTestLines: number[];
44
46
  /** 1-based lines mentioning @primary-assert that do not form a valid marker. */
45
47
  malformedMarkerLines: number[];
48
+ /** 1-based lines mentioning @gate-skip that do not form a valid, placed marker. */
49
+ malformedGateSkipLines: number[];
46
50
  }
51
+ /**
52
+ * One test() block that carries at least one valid `@primary-assert` marker,
53
+ * with its assertion line already resolved. Shared between inversion planning
54
+ * (planSpecMutations) and behavior planning (planBehaviorMutations) so the
55
+ * marker/block parser lives in one place.
56
+ */
57
+ /**
58
+ * A parsed, valid `// @gate-skip owner=.. expires=YYYY-MM-DD reason=".."`
59
+ * acknowledgement decorating a test. It turns a skip's cannot-mutate verdict
60
+ * from a gate failure into an accepted, time-boxed one - until `expires`.
61
+ */
62
+ export interface GateSkip {
63
+ owner: string;
64
+ /** ISO date (YYYY-MM-DD) after which the acknowledgement expires. */
65
+ expires: string;
66
+ reason?: string;
67
+ }
68
+ export interface MarkedTest {
69
+ title: string;
70
+ /** 1-based line of the test() declaration. */
71
+ testLine: number;
72
+ mode: TestBlock['mode'];
73
+ /** How many valid markers sit inside this block (>1 is a mistake). */
74
+ markerCount: number;
75
+ /** 1-based line of the first marker. */
76
+ markerLine: number;
77
+ /** Char offsets of the test call's `(` and its matching `)`. */
78
+ open: number;
79
+ close: number;
80
+ /** 1-based line of the assertion the first marker points at; null when unresolved. */
81
+ assertLine: number | null;
82
+ /** Set when assertLine is null: why the assertion line could not be located. */
83
+ resolveError: string | null;
84
+ /** A valid @gate-skip acknowledgement decorating this test, if any. */
85
+ gateSkip?: GateSkip;
86
+ }
87
+ /**
88
+ * The parsed view of a spec that both planners build on: the lexed source, the
89
+ * test() blocks that carry a marker (assertion line resolved), and the
90
+ * spec-level marker findings (unmarked tests, stray/malformed markers, .only).
91
+ */
92
+ export interface SpecAnalysis {
93
+ source: string;
94
+ lines: string[];
95
+ kind: Uint8Array;
96
+ markedTests: MarkedTest[];
97
+ unmarkedTests: {
98
+ title: string;
99
+ line: number;
100
+ }[];
101
+ strayMarkerLines: number[];
102
+ onlyTestLines: number[];
103
+ malformedMarkerLines: number[];
104
+ /** 1-based lines mentioning @gate-skip that do not form a valid, placed marker. */
105
+ malformedGateSkipLines: number[];
106
+ }
107
+ /**
108
+ * A valid `// @primary-assert` marker. `sameLine` is true when the marker
109
+ * trails the assertion on its own line (`expect(x).toY(); // @primary-assert`),
110
+ * false when it sits on its own line above the assertion.
111
+ */
112
+ interface Marker {
113
+ line: number;
114
+ sameLine: boolean;
115
+ }
116
+ interface TestBlock {
117
+ title: string;
118
+ mode: '' | 'only' | 'skip' | 'fixme' | 'fail';
119
+ /** Char offset of the `test` token. */
120
+ anchor: number;
121
+ /** Char offsets of the call's `(` and its matching `)` (source end when unclosed). */
122
+ open: number;
123
+ close: number;
124
+ /** Valid markers inside this block. */
125
+ markers: Marker[];
126
+ /** A valid @gate-skip acknowledgement decorating this test, if any. */
127
+ gateSkip?: GateSkip;
128
+ }
129
+ /** True when `s` is a real calendar date in strict YYYY-MM-DD form. */
130
+ export declare function isValidYmd(s: string): boolean;
131
+ /**
132
+ * Parse a `@gate-skip owner=.. expires=YYYY-MM-DD reason=".."` line comment.
133
+ * Returns the acknowledgement, or null when it is malformed (missing owner or
134
+ * expires, or an unparseable date) - the same fail-closed policy as a malformed
135
+ * @primary-assert marker.
136
+ */
137
+ export declare function parseGateSkip(commentText: string): GateSkip | null;
138
+ export declare const UNGATEABLE_MODES: Set<string>;
139
+ /**
140
+ * Parse a spec source into the shared view both planners build on: which
141
+ * test() blocks carry a valid `@primary-assert` marker (with the assertion
142
+ * line already resolved), plus the spec-level marker findings. The parser
143
+ * (lexer, test-id collection, block finding, marker association, assertion-line
144
+ * resolution) lives here so inversion and behavior planning cannot diverge.
145
+ */
146
+ export declare function analyzeSpec(source: string, opts?: PlanOptions): SpecAnalysis;
47
147
  /**
48
148
  * Parse a spec source and plan its mutations: one inverted copy per marked
49
149
  * test. Anything the gate cannot honestly mutate is reported with a reason,
@@ -64,3 +164,4 @@ export declare function mutationFilePath(specPath: string, index: number, tag?:
64
164
  * collide. The caller owns cleanup.
65
165
  */
66
166
  export declare function writeMutationFile(specPath: string, index: number, mutatedSource: string): string;
167
+ export {};
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 and plan its mutations: one inverted copy per marked
111
- * test. Anything the gate cannot honestly mutate is reported with a reason,
112
- * never skipped silently.
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 planSpecMutations(source, opts = {}) {
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
- const mutations = [];
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
- const base = { testTitle: block.title, testLine, markerLine: marker.line };
200
- if (UNGATEABLE_MODES.has(block.mode)) {
201
- const why = block.mode === 'fail'
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.${block.mode}() never runs, so a mutation cannot be observed`;
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 (block.markers.length > 1) {
335
+ if (t.markerCount > 1) {
208
336
  mutations.push({
209
337
  ...base,
338
+ ...gs,
210
339
  assertLine: null,
211
- cannotMutate: `found ${block.markers.length} @primary-assert markers in one test, expected exactly one`,
340
+ cannotMutate: `found ${t.markerCount} @primary-assert markers in one test, expected exactly one`,
212
341
  });
213
342
  continue;
214
343
  }
215
- // A trailing marker sits on the assertion itself; a whole-line marker
216
- // points at the next line that contains code (blank and comment-only lines
217
- // are skipped, not mutated).
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: target + 1, cannotMutate: inverted.cannotMutate });
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: target + 1, mutatedSource: mutatedLines.join('\n') });
356
+ mutations.push({ ...base, assertLine: t.assertLine, mutatedSource: mutatedLines.join('\n') });
239
357
  }
240
- return { mutations, unmarkedTests, strayMarkerLines, malformedMarkerLines, onlyTestLines };
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
- s.cannotMutate++;
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
- (!opts.requireSentinel || s.unmarkedTests === 0);
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 Mutation, type SpecPlan } from './mutate.js';
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