@piwitests/reporter 0.19.0 → 0.21.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.
@@ -146,6 +146,12 @@ var TAG_TO_ROLE = {
146
146
  figcaption: "caption",
147
147
  blockquote: "blockquote",
148
148
  table: "table",
149
+ caption: "caption",
150
+ thead: "rowgroup",
151
+ tbody: "rowgroup",
152
+ tfoot: "rowgroup",
153
+ tr: "row",
154
+ td: "cell",
149
155
  ul: "list",
150
156
  ol: "list",
151
157
  li: "listitem",
@@ -241,13 +247,19 @@ function classifyCssStability(className) {
241
247
  function isAutoGenerated(value) {
242
248
  if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
243
249
  if (/^[a-f0-9]{8,}$/i.test(value)) return true;
244
- if (/^[a-z]+-\d+$/.test(value)) return true;
250
+ if (/^[a-z]+-\d{4,}$/.test(value)) return true;
251
+ if (/^(tab|panel|input|select|option|dialog|modal|popup|tooltip|menu|listbox|combobox|checkbox|radio|textarea|button|field|label|accordion|collapse|step|slider|toggle|switch|dropdown|overlay|portal|layer)-\d+$/i.test(
252
+ value
253
+ )) {
254
+ return true;
255
+ }
245
256
  if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
246
257
  if (value.startsWith("ng-")) return true;
247
258
  if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value)) return true;
248
259
  if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
249
260
  return false;
250
261
  }
262
+ var TEST_DATA_ATTRS = /* @__PURE__ */ new Set(["data-test", "data-test-id", "data-qa", "data-qa-id", "data-cy", "data-e2e"]);
251
263
  function generateAlternatives(attrs) {
252
264
  const alts = [];
253
265
  const seen = /* @__PURE__ */ new Set();
@@ -262,6 +274,7 @@ function generateAlternatives(attrs) {
262
274
  const role = resolveAriaRole(attrs);
263
275
  const counts = attrs.selectorCounts;
264
276
  const isUnique = (n) => n == null || n <= 1;
277
+ const ambiguityPenalty = (n) => n != null && n > 1 ? 45 : 0;
265
278
  const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, " ").trim() : null;
266
279
  const testId = attr(attrs, "data-testid");
267
280
  if (testId && isUnique(counts?.testId)) {
@@ -280,7 +293,7 @@ function generateAlternatives(attrs) {
280
293
  locator: `getByRole('${role}', { name: '${esc(accessibleName)}'${levelPart} })`,
281
294
  method: "getByRole",
282
295
  args: withLevel({ role, name: accessibleName }),
283
- score: 90
296
+ score: 90 - ambiguityPenalty(counts?.roleName)
284
297
  });
285
298
  }
286
299
  const ariaLabel = attr(attrs, "aria-label");
@@ -289,7 +302,7 @@ function generateAlternatives(attrs) {
289
302
  locator: `getByRole('${role}', { name: '${esc(ariaLabel)}'${levelPart} })`,
290
303
  method: "getByRole",
291
304
  args: withLevel({ role, name: ariaLabel }),
292
- score: 85
305
+ score: 85 - ambiguityPenalty(counts?.roleName)
293
306
  });
294
307
  }
295
308
  if (accessibleName && ["input", "select", "textarea"].includes(tag)) {
@@ -310,7 +323,7 @@ function generateAlternatives(attrs) {
310
323
  locator: `getByPlaceholder('${esc(placeholder)}')`,
311
324
  method: "getByPlaceholder",
312
325
  args: { placeholder },
313
- score: 80
326
+ score: 80 - ambiguityPenalty(counts?.placeholder)
314
327
  });
315
328
  }
316
329
  if (text && text.length < 80) {
@@ -318,7 +331,7 @@ function generateAlternatives(attrs) {
318
331
  locator: `getByText('${esc(text)}')`,
319
332
  method: "getByText",
320
333
  args: { text },
321
- score: 75
334
+ score: 75 - ambiguityPenalty(counts?.text)
322
335
  });
323
336
  }
324
337
  const id = attr(attrs, "id");
@@ -347,7 +360,7 @@ function generateAlternatives(attrs) {
347
360
  locator: `getByAltText('${esc(alt)}')`,
348
361
  method: "getByAltText",
349
362
  args: { text: alt },
350
- score: 60
363
+ score: 60 - ambiguityPenalty(counts?.alt)
351
364
  });
352
365
  }
353
366
  const title = attr(attrs, "title");
@@ -356,48 +369,77 @@ function generateAlternatives(attrs) {
356
369
  locator: `getByTitle('${esc(title)}')`,
357
370
  method: "getByTitle",
358
371
  args: { title },
359
- score: 50
372
+ score: 50 - ambiguityPenalty(counts?.title)
360
373
  });
361
374
  }
362
375
  const hasOwnTestId = !!(testId && isUnique(counts?.testId));
363
- if (role && !hasOwnTestId) {
364
- const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
365
- const leafArgs = withLevel({ role });
376
+ const addAnchoredChains = (leaf, scopedCount, scores) => {
366
377
  let testIdAnchorDone = false;
367
378
  let idAnchorDone = false;
368
379
  let roleAnchorDone = false;
380
+ let dataAnchorDone = false;
381
+ let filterAnchorDone = false;
369
382
  for (const anc of attrs.ancestors ?? []) {
370
- if (anc.scopedRoleCount !== 1) continue;
383
+ if (scopedCount(anc) !== 1) continue;
371
384
  if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
372
385
  testIdAnchorDone = true;
373
386
  add({
374
- locator: `getByTestId('${esc(anc.testId)}').getByRole(${rolePart})`,
375
- method: "getByRole",
376
- args: { ...leafArgs, anchorTestId: anc.testId },
377
- score: 72
387
+ locator: `getByTestId('${esc(anc.testId)}').${leaf.expr}`,
388
+ method: leaf.method,
389
+ args: { ...leaf.args, anchorTestId: anc.testId },
390
+ score: scores.testId
378
391
  });
379
392
  }
380
393
  if (!idAnchorDone && anc.id && !isAutoGenerated(anc.id) && anc.idCount === 1) {
381
394
  idAnchorDone = true;
382
395
  const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
383
396
  add({
384
- locator: `locator('${esc(anchorSelector)}').getByRole(${rolePart})`,
385
- method: "getByRole",
386
- args: { ...leafArgs, anchorSelector },
387
- score: 64
397
+ locator: `locator('${esc(anchorSelector)}').${leaf.expr}`,
398
+ method: leaf.method,
399
+ args: { ...leaf.args, anchorSelector },
400
+ score: scores.id
401
+ });
402
+ }
403
+ if (!dataAnchorDone && anc.dataAttr && anc.dataAttrCount === 1) {
404
+ dataAnchorDone = true;
405
+ const { name: name2, value } = anc.dataAttr;
406
+ const anchorSelector = `[${name2}="${escCssAttrValue(value)}"]`;
407
+ add({
408
+ locator: `locator('${esc(anchorSelector)}').${leaf.expr}`,
409
+ method: leaf.method,
410
+ args: { ...leaf.args, anchorSelector },
411
+ score: TEST_DATA_ATTRS.has(name2) ? scores.testData : scores.data
388
412
  });
389
413
  }
390
414
  const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
391
- if (!roleAnchorDone && ancestorRole && ancestorRole !== role && anc.roleCount === 1) {
415
+ if (!roleAnchorDone && ancestorRole && ancestorRole !== leaf.role && anc.roleCount === 1) {
392
416
  roleAnchorDone = true;
393
417
  add({
394
- locator: `getByRole('${esc(ancestorRole)}').getByRole(${rolePart})`,
395
- method: "getByRole",
396
- args: { ...leafArgs, anchorRole: ancestorRole },
397
- score: 55
418
+ locator: `getByRole('${esc(ancestorRole)}').${leaf.expr}`,
419
+ method: leaf.method,
420
+ args: { ...leaf.args, anchorRole: ancestorRole },
421
+ score: scores.role
422
+ });
423
+ }
424
+ if (!filterAnchorDone && ancestorRole && anc.filterText && anc.filterRoleCount === 1) {
425
+ filterAnchorDone = true;
426
+ add({
427
+ locator: `getByRole('${esc(ancestorRole)}').filter({ hasText: '${esc(anc.filterText)}' }).${leaf.expr}`,
428
+ method: leaf.method,
429
+ args: { ...leaf.args, anchorRole: ancestorRole, anchorHasText: anc.filterText },
430
+ score: scores.filter
398
431
  });
399
432
  }
400
433
  }
434
+ };
435
+ if (role && !hasOwnTestId) {
436
+ const rolePart = level != null ? `'${role}', { level: ${level} }` : `'${role}'`;
437
+ const leafArgs = withLevel({ role });
438
+ addAnchoredChains(
439
+ { expr: `getByRole(${rolePart})`, method: "getByRole", args: leafArgs, role },
440
+ (anc) => anc.scopedRoleCount,
441
+ { testId: 72, testData: 70, id: 64, data: 60, role: 55, filter: 53 }
442
+ );
401
443
  const pos = attrs.rolePosition;
402
444
  if (pos && pos.role === role && (pos.count === 1 || level != null && pos.levelCount === 1)) {
403
445
  add({
@@ -407,6 +449,12 @@ function generateAlternatives(attrs) {
407
449
  score: 58
408
450
  });
409
451
  }
452
+ } else if (!role && text && text.length < 80 && !hasOwnTestId) {
453
+ addAnchoredChains(
454
+ { expr: `getByText('${esc(text)}')`, method: "getByText", args: { text }, role: null },
455
+ (anc) => anc.scopedTextCount,
456
+ { testId: 68, testData: 66, id: 60, data: 56, role: 51, filter: 49 }
457
+ );
410
458
  }
411
459
  const clsStr = attr(attrs, "class");
412
460
  if (clsStr) {
@@ -1,80 +1,18 @@
1
1
  import { TestInfo, Page } from '@playwright/test';
2
+ import { ProbeArg, ProbedAttrs, PickedAnchorInfo } from '@piwitests/picker-dom';
3
+ export { PickedAnchorInfo, PickedLeafInfo, generateAnchoredAlternatives, installPickerOverlay, mergeCandidates, showAnchorPicker, showPickerChoices } from '@piwitests/picker-dom';
2
4
  import { FailedLocatorInfo } from './locator-healing.js';
3
- import { SelectorCounts, RolePosition, AncestorAnchor, RankedLocator, LocatorSnapshot } from '@piwitests/core/locator-healing-types';
5
+ import { RankedLocator, LocatorSnapshot } from '@piwitests/core/locator-healing-types';
4
6
  import '@piwitests/core/locator-generation';
5
7
 
6
- /**
7
- * Failure-time locator picker: when enabled (opt-in via the
8
- * `pickLocatorOnFailure` reporter option / `PIWI_PICK_LOCATOR_ON_FAIL`), a
9
- * test whose locator action failed gets a picker overlay injected into the
10
- * still-open page. The flow is guided:
11
- *
12
- * 1. **Element** — hover highlights, a click picks. The pick snaps to the
13
- * nearest actionable ancestor (a click on the `<span>` inside a button
14
- * picks the button), and ↑/↓ walk the DOM tree before the click commits.
15
- * 2. **Anchors** — the element's ancestors are listed; the human can bless
16
- * one or more *stable parents* to scope the locator to, with a live
17
- * "matches N" count for the current selection against the failing page.
18
- * 3. **Confirm** — ranked replacement locators (standard generation merged
19
- * with the anchor-scoped candidates) are listed; the human confirms one.
20
- *
21
- * The confirmed pick is folded into the failing call site's locator snapshot
22
- * (so it rides the normal `piwi-locators` wire into the dashboard's healing
23
- * panel) and attached as `piwi-user-pick` plus a report annotation.
24
- *
25
- * Gated exactly like failure-time inspection (`inspect-on-failure.ts`):
26
- * headed browser, never CI, final attempt only.
27
- */
28
- /** Element shape the in-page probe returns — structural view of what the picker needs. */
29
- interface ProbedAttrs {
30
- tagName: string;
31
- attributes: Record<string, string | null>;
32
- textContent: string | null;
33
- center: {
34
- x: number;
35
- y: number;
36
- } | null;
37
- hasLabel?: boolean;
38
- selectorCounts?: SelectorCounts;
39
- rolePosition?: RolePosition | null;
40
- ancestors?: AncestorAnchor[];
41
- }
42
8
  /**
43
9
  * The probe dependency, passed in by the capture fixture to avoid an import
44
10
  * cycle. `fn` is the fixture's in-page element probe (`probeElementAttrs`);
45
11
  * `el` is a browser-side element, hence `any` (no DOM lib in this package).
46
- * `arg` is the fixture's `CAPTURED_ATTRS_ARG` — its role maps also drive the
47
- * anchor step's in-page role resolution.
48
12
  */
49
13
  interface PickerProbe {
50
- fn: (el: any, arg: any) => ProbedAttrs;
51
- arg: unknown;
52
- }
53
- /** An ancestor the human blessed as an anchor, with its in-page uniqueness counts. */
54
- interface PickedAnchorInfo {
55
- tag: string;
56
- /** Hops from the picked element (1 = direct parent). */
57
- depth: number;
58
- testId: string | null;
59
- id: string | null;
60
- ariaLabel: string | null;
61
- /** Resolved anchor role (explicit attribute or tag-implied). */
62
- role: string | null;
63
- /** Document-wide match count for the anchor's own data-testid. */
64
- testIdCount?: number;
65
- /** Document-wide match count for the anchor's own id. */
66
- idCount?: number;
67
- /** Document-wide count of same-role elements carrying the same aria-label. */
68
- labeledRoleCount?: number;
69
- /** Document-wide count of elements resolving to the anchor's role. */
70
- roleCount?: number;
71
- /** Leaf matches (picked element's identity) within this anchor's subtree. */
72
- scopedLeafCount?: number;
73
- }
74
- /** The picked element's identity used for anchor-scoped candidates. */
75
- interface PickedLeafInfo {
76
- role: string | null;
77
- level: number | null;
14
+ fn: (el: any, arg: ProbeArg) => ProbedAttrs;
15
+ arg: ProbeArg;
78
16
  }
79
17
  /** A confirmed pick — everything the fixture records about the human's choice. */
80
18
  interface UserPickResult {
@@ -113,63 +51,6 @@ declare function parseLeafLocatorExpression(rawExpr: string): {
113
51
  * when no locator can be identified. Exported for tests.
114
52
  */
115
53
  declare function deriveFailedLocator(testInfo: TestInfo): FailedLocatorInfo | null;
116
- /**
117
- * Build anchor-scoped candidates from the human-blessed parents. Shapes stay
118
- * consistent with the heuristic chains in `generateAlternatives` — leaf method
119
- * `getByRole` with flat anchor args, no leaf `name` key. Single-anchor
120
- * candidates require the leaf to be unique inside that anchor; with two or
121
- * more anchors a combined chain is added when the whole chain resolves to
122
- * exactly one element (`chainLeafCount`). Pure — exported for tests.
123
- */
124
- declare function generateAnchoredAlternatives(leaf: PickedLeafInfo, anchors: PickedAnchorInfo[], chainLeafCount?: number): RankedLocator[];
125
- /** Merge candidate lists, first list winning duplicates, sorted by score. Exported for tests. */
126
- declare function mergeCandidates(base: RankedLocator[], extra: RankedLocator[]): RankedLocator[];
127
- /**
128
- * Runs inside the browser via `evaluate()` — installs the element-picking
129
- * overlay: a hover highlight, an instruction banner, and capture-phase
130
- * listeners that suppress the app's own handlers while picking. The hover
131
- * target snaps to the nearest actionable ancestor (button/link/field/role/
132
- * testid) and ↑/↓ walk the DOM chain before the click commits. Resolves
133
- * through `__piwiPickState` ('picked' | 'skipped') polled from Node; the
134
- * picked element is parked in `__piwiPickedElement` for an `evaluateHandle`
135
- * read. Must stay fully self-contained (no module-closure references).
136
- */
137
- declare function installPickerOverlay(arg: {
138
- failing: string | null;
139
- }): void;
140
- /**
141
- * Runs inside the browser via `evaluate()` — the anchor step: lists the picked
142
- * element's ancestors so the human can bless one or more stable parents to
143
- * scope the locator to. Each row shows the ancestor's strongest hook and how
144
- * many leaf matches it contains; the footer shows a live "matches N" count for
145
- * the combined selection, recomputed against the real failing page on every
146
- * toggle (exactly 1 = green). Hovering a row outlines that ancestor in the
147
- * page. Resolves through `__piwiAnchorState` ('done' | 'skipped'); selected
148
- * anchors land in `__piwiPickAnchors` (+ `__piwiPickChainCount`). Role
149
- * resolution reuses the maps passed in `arg` (single source of truth in
150
- * locator-healing.ts). Must stay fully self-contained.
151
- */
152
- declare function showAnchorPicker(arg: {
153
- tagRoles: Record<string, string>;
154
- inputRoles: Record<string, string>;
155
- roleSources: string;
156
- leafRole: string;
157
- leafLevel: number | null;
158
- leafTestId: string | null;
159
- }): void;
160
- /**
161
- * Runs inside the browser via `evaluate()` — replaces the pick overlay with a
162
- * confirmation panel listing the ranked replacement locators. The chosen index
163
- * lands in `__piwiPickChoice` (-1 = skipped), polled from Node. Must stay
164
- * fully self-contained.
165
- */
166
- declare function showPickerChoices(arg: {
167
- failing: string | null;
168
- choices: Array<{
169
- locator: string;
170
- score: number;
171
- }>;
172
- }): void;
173
54
  /**
174
55
  * Drive the full pick flow on the failing page: element pick (snap +
175
56
  * tree-walk), optional stable-parent anchoring with live match counts, ranked
@@ -195,4 +76,4 @@ declare function runLocatorPicker(page: Page, testInfo: TestInfo, failed: Failed
195
76
  */
196
77
  declare function applyPickToSnapshots(snapshots: LocatorSnapshot[], pick: UserPickResult): boolean;
197
78
 
198
- export { type PickedAnchorInfo, type PickedLeafInfo, type PickerProbe, type UserPickResult, applyPickToSnapshots, deriveFailedLocator, generateAnchoredAlternatives, installPickerOverlay, mergeCandidates, parseLeafLocatorExpression, runLocatorPicker, showAnchorPicker, showPickerChoices };
79
+ export { type PickerProbe, type UserPickResult, applyPickToSnapshots, deriveFailedLocator, parseLeafLocatorExpression, runLocatorPicker };