@piwitests/reporter 0.36.0 → 0.39.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.
@@ -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
- /** Parse a Playwright locator expression into `{ method, args }` (leaf of any chain). Exported for tests. */
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 expr = leafExpression(rawExpr.trim());
1198
- const m = /^([A-Za-z]+)\((.*)\)$/s.exec(expr);
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.36.0",
3
+ "version": "0.39.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.