@piwitests/reporter 0.36.0 → 0.38.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/cli/index.js +281 -8
- package/dist/global-setup-module.js +168 -15
- package/dist/index.d.ts +33 -4
- package/dist/index.js +1085 -151
- package/dist/internal/capture/attachments.d.ts +1 -0
- package/dist/internal/capture/attachments.js +1 -0
- package/dist/internal/capture/capture-fixtures.js +911 -85
- package/dist/internal/capture/pick-on-failure.d.ts +5 -1
- package/dist/internal/capture/pick-on-failure.js +284 -79
- package/package.json +2 -2
- package/templates/skills/write-the-missing-test/SKILL.md +69 -0
|
@@ -38,7 +38,11 @@ interface UserPickResult {
|
|
|
38
38
|
/** Stable parents the human blessed in the anchor step, when any. */
|
|
39
39
|
anchors?: PickedAnchorInfo[];
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Parse a Playwright locator expression into `{ method, args }` for its leaf —
|
|
43
|
+
* the last locating call, which identifies the resolved element, mirroring the
|
|
44
|
+
* server's `extractLeafSelector`. Exported for tests.
|
|
45
|
+
*/
|
|
42
46
|
declare function parseLeafLocatorExpression(rawExpr: string): {
|
|
43
47
|
method: string;
|
|
44
48
|
args: unknown[];
|
|
@@ -43,6 +43,288 @@ __export(pick_on_failure_exports, {
|
|
|
43
43
|
module.exports = __toCommonJS(pick_on_failure_exports);
|
|
44
44
|
var path2 = __toESM(require("path"));
|
|
45
45
|
|
|
46
|
+
// ../core/src/locator-chain.ts
|
|
47
|
+
var LOCATING_METHODS = /* @__PURE__ */ new Set([
|
|
48
|
+
"getByRole",
|
|
49
|
+
"getByTestId",
|
|
50
|
+
"getByText",
|
|
51
|
+
"getByLabel",
|
|
52
|
+
"getByPlaceholder",
|
|
53
|
+
"getByAltText",
|
|
54
|
+
"getByTitle",
|
|
55
|
+
"locator",
|
|
56
|
+
"frameLocator"
|
|
57
|
+
]);
|
|
58
|
+
var NARROWING_METHODS = /* @__PURE__ */ new Set([
|
|
59
|
+
"filter",
|
|
60
|
+
"first",
|
|
61
|
+
"last",
|
|
62
|
+
"nth",
|
|
63
|
+
"and",
|
|
64
|
+
"or",
|
|
65
|
+
"visible",
|
|
66
|
+
"contentFrame",
|
|
67
|
+
"owner"
|
|
68
|
+
]);
|
|
69
|
+
var LOCATOR_CHAIN_METHODS = /* @__PURE__ */ new Set([...LOCATING_METHODS, ...NARROWING_METHODS]);
|
|
70
|
+
var LocatorParseError = class extends Error {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "LocatorParseError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var SIMPLE_ESCAPES = {
|
|
77
|
+
n: "\n",
|
|
78
|
+
t: " ",
|
|
79
|
+
r: "\r",
|
|
80
|
+
b: "\b",
|
|
81
|
+
f: "\f",
|
|
82
|
+
v: "\v",
|
|
83
|
+
"0": "\0"
|
|
84
|
+
};
|
|
85
|
+
var Parser = class {
|
|
86
|
+
constructor(src) {
|
|
87
|
+
this.src = src;
|
|
88
|
+
this.i = 0;
|
|
89
|
+
}
|
|
90
|
+
parseTopLevel() {
|
|
91
|
+
const chain = this.parseChain();
|
|
92
|
+
this.skipSpace();
|
|
93
|
+
if (this.i < this.src.length) this.fail("unexpected text");
|
|
94
|
+
return chain;
|
|
95
|
+
}
|
|
96
|
+
fail(message) {
|
|
97
|
+
throw new LocatorParseError(`${message} at "${this.src.slice(this.i, this.i + 20)}"`);
|
|
98
|
+
}
|
|
99
|
+
skipSpace() {
|
|
100
|
+
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
|
|
101
|
+
}
|
|
102
|
+
peek() {
|
|
103
|
+
return this.src[this.i];
|
|
104
|
+
}
|
|
105
|
+
expect(ch) {
|
|
106
|
+
this.skipSpace();
|
|
107
|
+
if (this.src[this.i] !== ch) this.fail(`expected "${ch}"`);
|
|
108
|
+
this.i++;
|
|
109
|
+
}
|
|
110
|
+
identifier() {
|
|
111
|
+
const m = /^[A-Za-z_$][\w$]*/.exec(this.src.slice(this.i));
|
|
112
|
+
if (!m) this.fail("expected a name");
|
|
113
|
+
this.i += m[0].length;
|
|
114
|
+
return m[0];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A chain of calls joined by `.`. With `lenient`, parsing stops before the
|
|
118
|
+
* first link that doesn't parse instead of failing, so a chain followed by
|
|
119
|
+
* other text (an error message) is read up to where it ends. `spans` receives
|
|
120
|
+
* where each top-level call starts and ends.
|
|
121
|
+
*/
|
|
122
|
+
parseChain(opts = {}) {
|
|
123
|
+
this.skipSpace();
|
|
124
|
+
const calls = [];
|
|
125
|
+
for (; ; ) {
|
|
126
|
+
const before = this.i;
|
|
127
|
+
if (calls.length > 0) this.i++;
|
|
128
|
+
this.skipSpace();
|
|
129
|
+
const start = this.i;
|
|
130
|
+
try {
|
|
131
|
+
calls.push(this.parseCall(calls.length === 0));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!opts.lenient || calls.length === 0) throw error;
|
|
134
|
+
this.i = before;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
opts.spans?.push({ start, end: this.i });
|
|
138
|
+
const end = this.i;
|
|
139
|
+
this.skipSpace();
|
|
140
|
+
if (this.peek() !== ".") {
|
|
141
|
+
this.i = end;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { calls };
|
|
146
|
+
}
|
|
147
|
+
/** Where the parser stopped. */
|
|
148
|
+
get position() {
|
|
149
|
+
return this.i;
|
|
150
|
+
}
|
|
151
|
+
parseCall(first) {
|
|
152
|
+
this.skipSpace();
|
|
153
|
+
const method = this.identifier();
|
|
154
|
+
if (!LOCATOR_CHAIN_METHODS.has(method)) this.fail(`unsupported method ${method}()`);
|
|
155
|
+
if (first && !LOCATING_METHODS.has(method)) this.fail(`${method}() needs a locator before it`);
|
|
156
|
+
this.expect("(");
|
|
157
|
+
const args = [];
|
|
158
|
+
this.skipSpace();
|
|
159
|
+
if (this.peek() === ")") {
|
|
160
|
+
this.i++;
|
|
161
|
+
return { method, args };
|
|
162
|
+
}
|
|
163
|
+
for (; ; ) {
|
|
164
|
+
args.push(this.parseValue());
|
|
165
|
+
this.skipSpace();
|
|
166
|
+
if (this.peek() === ",") {
|
|
167
|
+
this.i++;
|
|
168
|
+
this.skipSpace();
|
|
169
|
+
if (this.peek() === ")") {
|
|
170
|
+
this.i++;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
this.expect(")");
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
return { method, args };
|
|
179
|
+
}
|
|
180
|
+
parseValue() {
|
|
181
|
+
this.skipSpace();
|
|
182
|
+
const ch = this.peek();
|
|
183
|
+
if (ch === "'" || ch === '"' || ch === "`") return { type: "string", value: this.parseString() };
|
|
184
|
+
if (ch === "/") return this.parseRegex();
|
|
185
|
+
if (ch === "{") return this.parseObject();
|
|
186
|
+
if (ch !== void 0 && /[-\d]/.test(ch)) {
|
|
187
|
+
const m = /^-?\d+(?:\.\d+)?/.exec(this.src.slice(this.i));
|
|
188
|
+
if (!m) this.fail("expected a number");
|
|
189
|
+
this.i += m[0].length;
|
|
190
|
+
return { type: "number", value: Number(m[0]) };
|
|
191
|
+
}
|
|
192
|
+
const rest = this.src.slice(this.i);
|
|
193
|
+
if (/^true\b/.test(rest)) {
|
|
194
|
+
this.i += 4;
|
|
195
|
+
return { type: "boolean", value: true };
|
|
196
|
+
}
|
|
197
|
+
if (/^false\b/.test(rest)) {
|
|
198
|
+
this.i += 5;
|
|
199
|
+
return { type: "boolean", value: false };
|
|
200
|
+
}
|
|
201
|
+
if (/^[A-Za-z_$]/.test(rest)) return { type: "chain", chain: this.parseChain() };
|
|
202
|
+
return this.fail("expected a value");
|
|
203
|
+
}
|
|
204
|
+
/** A quoted string, decoding the escapes `JSON.stringify` and JavaScript produce. */
|
|
205
|
+
parseString() {
|
|
206
|
+
const quote = this.src[this.i];
|
|
207
|
+
let out = "";
|
|
208
|
+
this.i++;
|
|
209
|
+
while (this.i < this.src.length) {
|
|
210
|
+
const c = this.src[this.i];
|
|
211
|
+
if (c === quote) {
|
|
212
|
+
this.i++;
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
if (c !== "\\") {
|
|
216
|
+
out += c;
|
|
217
|
+
this.i++;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const next = this.src[this.i + 1];
|
|
221
|
+
if (next === void 0) break;
|
|
222
|
+
const simple = SIMPLE_ESCAPES[next];
|
|
223
|
+
if (simple !== void 0) {
|
|
224
|
+
out += simple;
|
|
225
|
+
this.i += 2;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const hex = next === "u" && this.src[this.i + 2] === "{" ? /^u\{([0-9a-fA-F]{1,6})\}/.exec(this.src.slice(this.i + 1)) : next === "u" ? /^u([0-9a-fA-F]{4})/.exec(this.src.slice(this.i + 1)) : next === "x" ? /^x([0-9a-fA-F]{2})/.exec(this.src.slice(this.i + 1)) : null;
|
|
229
|
+
if (hex) {
|
|
230
|
+
out += String.fromCodePoint(parseInt(hex[1], 16));
|
|
231
|
+
this.i += 1 + hex[0].length;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (next === "u" || next === "x") this.fail("malformed escape");
|
|
235
|
+
out += next;
|
|
236
|
+
this.i += 2;
|
|
237
|
+
}
|
|
238
|
+
return this.fail("unterminated string");
|
|
239
|
+
}
|
|
240
|
+
parseRegex() {
|
|
241
|
+
let j = this.i + 1;
|
|
242
|
+
let inClass = false;
|
|
243
|
+
while (j < this.src.length) {
|
|
244
|
+
const c = this.src[j];
|
|
245
|
+
if (c === "\\") {
|
|
246
|
+
j += 2;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (c === "[") inClass = true;
|
|
250
|
+
else if (c === "]") inClass = false;
|
|
251
|
+
else if (c === "/" && !inClass) break;
|
|
252
|
+
j++;
|
|
253
|
+
}
|
|
254
|
+
if (j >= this.src.length) this.fail("unterminated regex");
|
|
255
|
+
const source = this.src.slice(this.i + 1, j);
|
|
256
|
+
const flags = /^[a-z]*/.exec(this.src.slice(j + 1))[0];
|
|
257
|
+
this.i = j + 1 + flags.length;
|
|
258
|
+
return { type: "regex", source, flags };
|
|
259
|
+
}
|
|
260
|
+
parseObject() {
|
|
261
|
+
this.expect("{");
|
|
262
|
+
const entries = [];
|
|
263
|
+
for (; ; ) {
|
|
264
|
+
this.skipSpace();
|
|
265
|
+
if (this.peek() === "}") {
|
|
266
|
+
this.i++;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
const ch = this.peek();
|
|
270
|
+
const key = ch === "'" || ch === '"' ? this.parseString() : this.identifier();
|
|
271
|
+
this.expect(":");
|
|
272
|
+
entries.push([key, this.parseValue()]);
|
|
273
|
+
this.skipSpace();
|
|
274
|
+
if (this.peek() === ",") {
|
|
275
|
+
this.i++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
this.expect("}");
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
return { type: "object", entries };
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
function parseLocatorChain(expr) {
|
|
285
|
+
const trimmed = expr.trim().replace(/^(?:await\s+)?(?:this\.)?page\./, "");
|
|
286
|
+
if (!trimmed) throw new LocatorParseError("empty expression");
|
|
287
|
+
return new Parser(trimmed).parseTopLevel();
|
|
288
|
+
}
|
|
289
|
+
function tryParseLocatorChain(expr) {
|
|
290
|
+
try {
|
|
291
|
+
return parseLocatorChain(expr);
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function parseLeafLocatorCall(expr) {
|
|
297
|
+
const chain = tryParseLocatorChain(expr);
|
|
298
|
+
if (!chain) return null;
|
|
299
|
+
for (let i = chain.calls.length - 1; i >= 0; i--) {
|
|
300
|
+
if (LOCATING_METHODS.has(chain.calls[i].method)) return chain.calls[i];
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
function locatorCallValues(call, opts = {}) {
|
|
305
|
+
const value = (arg) => {
|
|
306
|
+
switch (arg.type) {
|
|
307
|
+
case "string":
|
|
308
|
+
case "number":
|
|
309
|
+
case "boolean":
|
|
310
|
+
return arg.value;
|
|
311
|
+
case "regex":
|
|
312
|
+
return opts.regexAsText ? `/${arg.source}/${arg.flags}` : void 0;
|
|
313
|
+
case "object": {
|
|
314
|
+
const out = {};
|
|
315
|
+
for (const [key, v] of arg.entries) {
|
|
316
|
+
const plain = value(v);
|
|
317
|
+
if (plain !== void 0) out[key] = plain;
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
case "chain":
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
return call.args.map(value).filter((v) => v !== void 0);
|
|
326
|
+
}
|
|
327
|
+
|
|
46
328
|
// ../picker-dom/src/overlay-element.ts
|
|
47
329
|
function installPickerOverlay(arg) {
|
|
48
330
|
const g = globalThis;
|
|
@@ -1118,86 +1400,9 @@ function renderFailing(failed) {
|
|
|
1118
1400
|
|
|
1119
1401
|
// src/internal/capture/pick-on-failure.ts
|
|
1120
1402
|
var ANSI_RE = /\[[0-9;]*m/g;
|
|
1121
|
-
function endOfString(s, start) {
|
|
1122
|
-
const q = s[start];
|
|
1123
|
-
for (let i = start + 1; i < s.length; i++) {
|
|
1124
|
-
if (s[i] === "\\") {
|
|
1125
|
-
i++;
|
|
1126
|
-
continue;
|
|
1127
|
-
}
|
|
1128
|
-
if (s[i] === q) return i;
|
|
1129
|
-
}
|
|
1130
|
-
return s.length - 1;
|
|
1131
|
-
}
|
|
1132
|
-
function matchBrace(s, start) {
|
|
1133
|
-
let depth = 0;
|
|
1134
|
-
for (let i = start; i < s.length; i++) {
|
|
1135
|
-
if (s[i] === "{") depth++;
|
|
1136
|
-
else if (s[i] === "}" && --depth === 0) return i;
|
|
1137
|
-
}
|
|
1138
|
-
return s.length - 1;
|
|
1139
|
-
}
|
|
1140
|
-
function parseOptions(src) {
|
|
1141
|
-
const obj = {};
|
|
1142
|
-
const re = /(\w+)\s*:\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*"|true|false|-?\d+)/g;
|
|
1143
|
-
let m;
|
|
1144
|
-
while ((m = re.exec(src)) !== null) {
|
|
1145
|
-
const key = m[1];
|
|
1146
|
-
const raw = m[2];
|
|
1147
|
-
if (raw === "true") obj[key] = true;
|
|
1148
|
-
else if (raw === "false") obj[key] = false;
|
|
1149
|
-
else if (/^-?\d+$/.test(raw)) obj[key] = Number(raw);
|
|
1150
|
-
else obj[key] = raw.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
1151
|
-
}
|
|
1152
|
-
return obj;
|
|
1153
|
-
}
|
|
1154
|
-
function parseArgs(inner) {
|
|
1155
|
-
const args = [];
|
|
1156
|
-
let i = 0;
|
|
1157
|
-
while (i < inner.length) {
|
|
1158
|
-
const c = inner[i];
|
|
1159
|
-
if (c === " " || c === ",") {
|
|
1160
|
-
i++;
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
if (c === "'" || c === '"') {
|
|
1164
|
-
const end = endOfString(inner, i);
|
|
1165
|
-
args.push(inner.slice(i + 1, end).replace(/\\(.)/g, "$1"));
|
|
1166
|
-
i = end + 1;
|
|
1167
|
-
continue;
|
|
1168
|
-
}
|
|
1169
|
-
if (c === "{") {
|
|
1170
|
-
const end = matchBrace(inner, i);
|
|
1171
|
-
args.push(parseOptions(inner.slice(i, end + 1)));
|
|
1172
|
-
i = end + 1;
|
|
1173
|
-
continue;
|
|
1174
|
-
}
|
|
1175
|
-
i++;
|
|
1176
|
-
}
|
|
1177
|
-
return args;
|
|
1178
|
-
}
|
|
1179
|
-
function leafExpression(expr) {
|
|
1180
|
-
let depth = 0;
|
|
1181
|
-
let leafStart = 0;
|
|
1182
|
-
for (let i = 0; i < expr.length - 1; i++) {
|
|
1183
|
-
const c = expr[i];
|
|
1184
|
-
if (c === "'" || c === '"') {
|
|
1185
|
-
i = endOfString(expr, i);
|
|
1186
|
-
continue;
|
|
1187
|
-
}
|
|
1188
|
-
if (c === "(") depth++;
|
|
1189
|
-
else if (c === ")") {
|
|
1190
|
-
depth--;
|
|
1191
|
-
if (depth === 0 && expr[i + 1] === ".") leafStart = i + 2;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
return expr.slice(leafStart);
|
|
1195
|
-
}
|
|
1196
1403
|
function parseLeafLocatorExpression(rawExpr) {
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
if (!m) return null;
|
|
1200
|
-
return { method: m[1], args: parseArgs(m[2].trim()) };
|
|
1404
|
+
const leaf = parseLeafLocatorCall(rawExpr);
|
|
1405
|
+
return leaf ? { method: leaf.method, args: locatorCallValues(leaf) } : null;
|
|
1201
1406
|
}
|
|
1202
1407
|
function deriveFailedLocator(testInfo) {
|
|
1203
1408
|
const info = testInfo;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
|
|
5
5
|
"url": "https://github.com/PiwiTests/platform",
|
|
6
6
|
"homepage": "https://piwitests.dev",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"reporter:build": "tsup",
|
|
44
44
|
"reporter:dev": "tsup --watch",
|
|
45
|
-
"reporter:typecheck": "tsc --noEmit",
|
|
45
|
+
"reporter:typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tests/tsconfig.json",
|
|
46
46
|
"reporter:format": "oxfmt --config oxfmt.config.mts --write \"src/\"",
|
|
47
47
|
"reporter:format:check": "oxfmt --config oxfmt.config.mts --check \"src/\"",
|
|
48
48
|
"reporter:lint": "oxlint --config oxlint.config.mts .",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: write-the-missing-test
|
|
3
|
+
description: Write a test that does not exist yet, chosen from Piwi's scenario gaps — the routes, pages, controls and error paths the suite never exercises, or passes over without noticing. Use when the user asks to "close a test gap", "cover what's missing", "write the test we're missing", "add coverage for this change", or wants the next most valuable test rather than more of the same.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Write the missing test with Piwi scenario gaps
|
|
7
|
+
|
|
8
|
+
A passing suite hides what it never tries. Piwi builds one graph of what the
|
|
9
|
+
application exposes, what the suite reaches, and what a probe showed it would not
|
|
10
|
+
notice, and proposes the tests that do not exist yet — each a **scenario gap**
|
|
11
|
+
with its evidence and an exposure score. This skill takes the top gap in scope,
|
|
12
|
+
drafts it from the graph, and opens it in the same change.
|
|
13
|
+
|
|
14
|
+
## How you reach Piwi
|
|
15
|
+
|
|
16
|
+
Prefer the **Piwi MCP server** if it is connected (`list_scenario_gaps`,
|
|
17
|
+
`draft_scenario`, `get_change_coverage`). Otherwise use the dashboard's **Gaps**
|
|
18
|
+
view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
|
|
19
|
+
`PIWI_PROJECT_NAME` set.
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
1. **Find the gaps.** On a change, call `get_change_coverage` for the current
|
|
24
|
+
branch first — the uncovered changed files are the gaps that matter now.
|
|
25
|
+
Otherwise call `list_scenario_gaps` for the project. Filter to what you are
|
|
26
|
+
working on with `class`, `feature`, `minScore` or `pr`. Read the classes:
|
|
27
|
+
- **blind-spot** — no trusted test reaches it (a route, a page, a control, a
|
|
28
|
+
new error path).
|
|
29
|
+
- **false-comfort** — tests reach it and a probe showed they would not notice
|
|
30
|
+
it breaking. The most valuable to fix: the catalog says it is covered.
|
|
31
|
+
- **fragile** — reached by a single, flaky, quarantined or long-skipped test.
|
|
32
|
+
|
|
33
|
+
2. **Pick one.** Take the highest-scored gap in scope. Every gap is *observed
|
|
34
|
+
reach*, never instrumented coverage, and every evidence line pairs "no test"
|
|
35
|
+
with the count from recent history — trust the evidence, not a percentage.
|
|
36
|
+
|
|
37
|
+
3. **Draft it.** Call `draft_scenario` with the gap id. You get a deterministic
|
|
38
|
+
skeleton: a title, `piwi:` annotations from the nearest test, the graph path
|
|
39
|
+
from a reached page to the gap as the step list, the catalog page-object
|
|
40
|
+
methods that match the page, and a `TODO` assertion naming what to check.
|
|
41
|
+
Nothing is committed — the skeleton is yours to finish.
|
|
42
|
+
|
|
43
|
+
4. **Finish the assertion.** The draft reaches the subject; you write the check.
|
|
44
|
+
For a false-comfort gap, assert the effect the probe fault suppressed (the row
|
|
45
|
+
reflects the saved value, an error toast appears, the total is right) — a
|
|
46
|
+
visibility assertion is what let the gap through. Prefer the catalog methods
|
|
47
|
+
the draft named over hand-rolled locators.
|
|
48
|
+
|
|
49
|
+
5. **Run just this test, then verify.** Run the new spec with a file filter
|
|
50
|
+
(`npx @piwitests/reporter run` or `playwright test <file>`). Confirm it passes
|
|
51
|
+
for the right reason: temporarily break the behavior it asserts and see it
|
|
52
|
+
fail. Open it in the same pull request as the change that motivated it.
|
|
53
|
+
|
|
54
|
+
6. **Report.** State which gap you closed (class, subject, score) and how the new
|
|
55
|
+
test asserts the missing behavior. If the gap had no reachable path, say so —
|
|
56
|
+
the draft cannot invent an entry point, and the gap may need a fixture.
|
|
57
|
+
|
|
58
|
+
## Guardrails
|
|
59
|
+
|
|
60
|
+
- A gap is a suggestion with evidence, never a verdict. If the evidence does not
|
|
61
|
+
hold — the route is headless by design, the control is decorative — dismiss it
|
|
62
|
+
in the dashboard with a reason rather than writing a test that asserts nothing.
|
|
63
|
+
- Do not weaken an assertion to make the draft pass. A test that passes without
|
|
64
|
+
asserting the behavior is the false-comfort gap you were sent to close.
|
|
65
|
+
- The draft's assertion is a `TODO` on purpose: fill it from the source and the
|
|
66
|
+
intent, and validate the test the way you would any other — nothing is
|
|
67
|
+
committed except through the reviewed pull request.
|
|
68
|
+
- One gap at a time. Close the highest-value gap, verify it, then re-check the
|
|
69
|
+
list — closing one often changes the ranking of the rest.
|