@piwitests/reporter 0.6.0 → 0.7.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.
@@ -1,4 +1,18 @@
1
- import type { Fixtures } from '@playwright/test';
1
+ import type { Fixtures, Locator } from '@playwright/test';
2
+ /**
3
+ * ARIA snapshot that tolerates every Playwright version the reporter supports,
4
+ * returning null instead of throwing so a capture can never fail the test. The
5
+ * options validator runs client-side in the user's installed Playwright, so an
6
+ * unsupported key surfaces as a rejected promise (not a synchronous throw):
7
+ * - ≥ 1.59: `mode: 'ai'` yields the ref-annotated AI snapshot (the ideal);
8
+ * `ref` is unknown and silently stripped.
9
+ * - 1.52: `mode: 'ai'` fails validation (only 'raw'/'regex' are accepted) and
10
+ * rejects; the `{ ref: true }` fallback then yields a ref-annotated snapshot.
11
+ * - 1.53–1.58: neither `ref` nor `mode` exists — unknown keys are stripped
12
+ * server-side, so the first call already returns a plain flat snapshot.
13
+ * - < 1.49: `locator.ariaSnapshot` does not exist — returns null up front.
14
+ */
15
+ export declare function ariaSnapshotBestEffort(target: Locator, timeout?: number): Promise<string | null>;
2
16
  /**
3
17
  * Playwright fixtures that collect network requests, console entries,
4
18
  * web vitals, ARIA snapshots, and locator interaction data during a test.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dashboardFixtures = void 0;
4
+ exports.ariaSnapshotBestEffort = ariaSnapshotBestEffort;
4
5
  exports.extendDashboardFixtures = extendDashboardFixtures;
5
6
  const node_zlib_1 = require("node:zlib");
6
7
  const locator_healing_js_1 = require("./locator-healing.js");
@@ -118,7 +119,6 @@ function wrapLocator(page, locator, originMethod, originArgs) {
118
119
  // Push a placeholder immediately — DOM capture runs async below
119
120
  sink.capturedLocators.push({
120
121
  location: callerLocation,
121
- stepIndex: seq,
122
122
  used,
123
123
  element: null,
124
124
  alternatives: [],
@@ -138,6 +138,7 @@ function wrapLocator(page, locator, originMethod, originArgs) {
138
138
  // evaluate() can hang when page navigates (element detaches), so
139
139
  // race it against a 500ms deadline and never throw.
140
140
  const resolveAttrs = (async () => {
141
+ let deadline;
141
142
  try {
142
143
  // `el` is browser-context (no DOM lib in this Node package), so it
143
144
  // stays `any`; the callback's return type pins `attrs` to CapturedAttrs.
@@ -149,17 +150,63 @@ function wrapLocator(page, locator, originMethod, originArgs) {
149
150
  attrMap[key] = typeof v === 'string' ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
150
151
  }
151
152
  const r = el.getBoundingClientRect();
153
+ // Uniqueness probe: how many elements each candidate selector
154
+ // matches. A count > 1 marks the alternative as ambiguous
155
+ // (strict-mode violation) so generateAlternatives drops it.
156
+ // All DOM/CSS access goes through `el` (no DOM lib here).
157
+ const selectorCounts = {};
158
+ try {
159
+ const doc = el.ownerDocument;
160
+ const cssEsc = (s) => doc.defaultView.CSS.escape(s);
161
+ const count = (sel) => {
162
+ try {
163
+ return doc.querySelectorAll(sel).length;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ };
169
+ if (attrMap['data-testid']) {
170
+ selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap['data-testid'])}]`);
171
+ }
172
+ if (attrMap['id'])
173
+ selectorCounts.id = count(`#${cssEsc(attrMap['id'])}`);
174
+ if (attrMap['name'])
175
+ selectorCounts.name = count(`[name=${JSON.stringify(attrMap['name'])}]`);
176
+ const classList = (attrMap['class'] || '')
177
+ .split(/\s+/)
178
+ .filter((c) => c.length > 1)
179
+ .slice(0, 10);
180
+ if (classList.length > 0) {
181
+ const classCounts = {};
182
+ for (const cls of classList) {
183
+ const n = count(`.${cssEsc(cls)}`);
184
+ if (n !== undefined)
185
+ classCounts[cls] = n;
186
+ }
187
+ selectorCounts.classes = classCounts;
188
+ }
189
+ }
190
+ catch {
191
+ // Uniqueness probing is best-effort — never fail the capture.
192
+ }
152
193
  return {
153
194
  tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
154
195
  attributes: attrMap,
155
- textContent: (el.textContent || '').trim().slice(0, 80),
196
+ // Collapse whitespace so multi-line text can't produce a
197
+ // getByText suggestion with literal newlines in it.
198
+ textContent: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
156
199
  center: {
157
200
  x: Math.round(r.x + r.width / 2),
158
201
  y: Math.round(r.y + r.height / 2),
159
202
  },
203
+ hasLabel: !!(el.labels && el.labels.length > 0),
204
+ selectorCounts,
160
205
  };
161
206
  }, CAPTURED_ATTRS_ARG),
162
- new Promise((_, reject) => setTimeout(() => reject(new Error('locator capture timeout')), 500)),
207
+ new Promise((_, reject) => {
208
+ deadline = setTimeout(() => reject(new Error('locator capture timeout')), 500);
209
+ }),
163
210
  ]);
164
211
  // The browser-computed accessible name only feeds role-based and
165
212
  // form-field alternatives, so only pay for the extra ARIA
@@ -175,15 +222,25 @@ function wrapLocator(page, locator, originMethod, originArgs) {
175
222
  const accessibleName = (0, locator_healing_js_1.extractAccessibleName)(aria) || (0, locator_healing_js_1.approximateAccessibleName)({ ...attrs, accessibleName: null });
176
223
  sink.capturedLocators[seq] = {
177
224
  location: callerLocation,
178
- stepIndex: seq,
179
225
  used,
180
- element: { ...attrs, accessibleName },
226
+ // hasLabel/selectorCounts inform alternative generation only —
227
+ // keep the stored element to the wire shape.
228
+ element: {
229
+ tagName: attrs.tagName,
230
+ attributes: attrs.attributes,
231
+ textContent: attrs.textContent,
232
+ accessibleName,
233
+ center: attrs.center,
234
+ },
181
235
  alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
182
236
  };
183
237
  }
184
238
  catch {
185
239
  // element detached or timeout — keep the placeholder
186
240
  }
241
+ finally {
242
+ clearTimeout(deadline);
243
+ }
187
244
  })();
188
245
  sink.capturePromises.push(resolveAttrs);
189
246
  return result;
@@ -339,11 +396,21 @@ async function flushSink(sink, testInfo) {
339
396
  // Cap the drain so a stuck capture (e.g. a navigation in flight) can never
340
397
  // hang teardown past the test timeout; per-action evaluate/ariaSnapshot are
341
398
  // already bounded, this is a backstop.
342
- await Promise.race([Promise.allSettled(sink.capturePromises), new Promise((resolve) => setTimeout(resolve, 2000))]);
399
+ let drainDeadline;
400
+ await Promise.race([
401
+ Promise.allSettled(sink.capturePromises),
402
+ new Promise((resolve) => {
403
+ drainDeadline = setTimeout(resolve, 2000);
404
+ }),
405
+ ]);
406
+ clearTimeout(drainDeadline);
343
407
  if (sink.capturedLocators.length > 0) {
344
408
  await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locators, {
345
409
  contentType: 'application/json',
346
- body: Buffer.from(JSON.stringify(sink.capturedLocators)),
410
+ // Repeated call sites (loops) keep only their latest capture — the
411
+ // server stores one row per location anyway, so shipping every
412
+ // iteration is pure payload bloat.
413
+ body: Buffer.from(JSON.stringify((0, locator_healing_js_1.dedupeSnapshotsByLocation)(sink.capturedLocators))),
347
414
  });
348
415
  }
349
416
  const page = sink.lastActivePage;
@@ -11,6 +11,18 @@ export interface RankedLocator {
11
11
  /** 0-100 stability score. data-testid=100, semantic CSS=35-40, hash-suffixed=10. */
12
12
  score: number;
13
13
  }
14
+ /**
15
+ * Match counts for candidate selectors, probed with `querySelectorAll` against
16
+ * the live page at capture time. A count > 1 means the selector is ambiguous
17
+ * (would be a strict-mode violation) and the alternative is suppressed; a
18
+ * missing count means the probe didn't run and the alternative is kept.
19
+ */
20
+ export interface SelectorCounts {
21
+ testId?: number;
22
+ id?: number;
23
+ name?: number;
24
+ classes?: Record<string, number>;
25
+ }
14
26
  export interface ElementAttributes {
15
27
  tagName: string;
16
28
  attributes: Record<string, string | null>;
@@ -20,10 +32,18 @@ export interface ElementAttributes {
20
32
  x: number;
21
33
  y: number;
22
34
  } | null;
35
+ /**
36
+ * True when the element has an associated `<label>` (`el.labels`). Gates the
37
+ * `getByLabel` alternative — an accessible name approximated from
38
+ * placeholder/title would produce a `getByLabel` that matches nothing.
39
+ * Undefined on payloads from older capture probes (legacy behavior applies).
40
+ */
41
+ hasLabel?: boolean;
42
+ /** Live-page uniqueness probe results for candidate selectors. */
43
+ selectorCounts?: SelectorCounts;
23
44
  }
24
45
  export interface LocatorSnapshot {
25
46
  location: string | null;
26
- stepIndex: number;
27
47
  used: {
28
48
  method: string;
29
49
  args: unknown[];
@@ -41,6 +61,15 @@ export interface LocatorSnapshot {
41
61
  } | null;
42
62
  alternatives: RankedLocator[];
43
63
  }
64
+ /**
65
+ * Drop repeated captures of the same call site before attaching: a loop (or a
66
+ * page-object method called repeatedly) produces one snapshot per action, but
67
+ * the server keeps only the latest per location anyway. Keeps the last
68
+ * element-bearing snapshot per location — falling back to the last placeholder
69
+ * when no capture resolved, so the location still counts as "seen this run"
70
+ * for the server's stale-location purge. Entries with no location pass through.
71
+ */
72
+ export declare function dedupeSnapshotsByLocation(snaps: LocatorSnapshot[]): LocatorSnapshot[];
44
73
  /**
45
74
  * Page-level locator-building methods wrapped by the capture proxy. Imported by
46
75
  * both `reporter/src/fixtures.ts` and the dogfooding `application/tests/fixtures.ts`
@@ -123,6 +152,23 @@ export interface LocatorSuggestion {
123
152
  /** Fresh locator suggestions for the element's current identity, best first. */
124
153
  suggestions: string[];
125
154
  }
155
+ /**
156
+ * Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side
157
+ * `parseAriaCandidates` in application/shared/locator-fingerprint.ts).
158
+ * Exported so the dashboard's drift-guard unit test can compare the two.
159
+ */
160
+ export declare function parseAriaRoleName(ariaSnapshot: string): Array<{
161
+ role: string;
162
+ name: string | null;
163
+ }>;
164
+ /**
165
+ * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
166
+ * Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
167
+ * this package publishes standalone to npm and can't import monorepo-relative
168
+ * shared/ code, so keep the two implementations in sync by hand. Exported so
169
+ * the dashboard's drift-guard unit test can compare the two.
170
+ */
171
+ export declare function nameSimilarity(a: string | null, b: string | null): number;
126
172
  /**
127
173
  * Best-effort runtime suggestion for a locator that matched nothing: find the
128
174
  * element on the *current* page that the failed locator most likely targeted
@@ -34,15 +34,42 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CAPTURED_ATTRIBUTES = exports.LOCATOR_CREATING_CHAINS = exports.ACTION_METHODS = exports.CHAIN_METHODS = exports.LOCATOR_METHODS = void 0;
37
+ exports.dedupeSnapshotsByLocation = dedupeSnapshotsByLocation;
37
38
  exports.resolveAriaRole = resolveAriaRole;
38
39
  exports.generateAlternatives = generateAlternatives;
39
40
  exports.classifyCssStability = classifyCssStability;
40
41
  exports.isAutoGenerated = isAutoGenerated;
41
42
  exports.extractAccessibleName = extractAccessibleName;
42
43
  exports.approximateAccessibleName = approximateAccessibleName;
44
+ exports.parseAriaRoleName = parseAriaRoleName;
45
+ exports.nameSimilarity = nameSimilarity;
43
46
  exports.suggestLocatorsFromAria = suggestLocatorsFromAria;
44
47
  exports.captureCallerLocation = captureCallerLocation;
45
48
  const path = __importStar(require("node:path"));
49
+ /**
50
+ * Drop repeated captures of the same call site before attaching: a loop (or a
51
+ * page-object method called repeatedly) produces one snapshot per action, but
52
+ * the server keeps only the latest per location anyway. Keeps the last
53
+ * element-bearing snapshot per location — falling back to the last placeholder
54
+ * when no capture resolved, so the location still counts as "seen this run"
55
+ * for the server's stale-location purge. Entries with no location pass through.
56
+ */
57
+ function dedupeSnapshotsByLocation(snaps) {
58
+ const lastWithElement = new Map();
59
+ const lastAny = new Map();
60
+ snaps.forEach((s, i) => {
61
+ if (!s.location)
62
+ return;
63
+ lastAny.set(s.location, i);
64
+ if (s.element)
65
+ lastWithElement.set(s.location, i);
66
+ });
67
+ return snaps.filter((s, i) => {
68
+ if (!s.location)
69
+ return true;
70
+ return (lastWithElement.get(s.location) ?? lastAny.get(s.location)) === i;
71
+ });
72
+ }
46
73
  // ── Playwright method surface (shared with the fixture proxy) ────────────────
47
74
  /**
48
75
  * Page-level locator-building methods wrapped by the capture proxy. Imported by
@@ -92,12 +119,18 @@ exports.ACTION_METHODS = [
92
119
  'hover',
93
120
  'press',
94
121
  'type',
122
+ 'pressSequentially',
95
123
  'clear',
96
124
  'setInputFiles',
97
125
  'dragTo',
98
126
  'focus',
99
127
  'blur',
100
128
  'scrollIntoViewIfNeeded',
129
+ 'dispatchEvent',
130
+ 'selectText',
131
+ // Not an action, but a successful waitFor proves the element resolved — the
132
+ // closest capture hook available for assertion-style usage of a locator.
133
+ 'waitFor',
101
134
  ];
102
135
  /** Chain methods that create a new locator scope (origin tracks the chain call). */
103
136
  exports.LOCATOR_CREATING_CHAINS = new Set(exports.LOCATOR_METHODS);
@@ -118,7 +151,11 @@ exports.CAPTURED_ATTRIBUTES = [
118
151
  'role',
119
152
  'type',
120
153
  'href',
121
- 'value',
154
+ // `multiple` distinguishes listbox vs combobox for <select>. `value` is
155
+ // deliberately NOT captured: no alternative generator uses it, and reading
156
+ // the live value after fill() would leak user-typed secrets (passwords,
157
+ // tokens) into the snapshot payload and server storage.
158
+ 'multiple',
122
159
  ];
123
160
  // ── ARIA role resolution ─────────────────────────────────────────────────────
124
161
  /** Implicit ARIA role for an HTML tag (when no explicit `role` is set). */
@@ -142,7 +179,6 @@ const TAG_TO_ROLE = {
142
179
  output: 'status',
143
180
  progress: 'progressbar',
144
181
  meter: 'meter',
145
- select: 'listbox',
146
182
  textarea: 'textbox',
147
183
  h1: 'heading',
148
184
  h2: 'heading',
@@ -189,6 +225,11 @@ function resolveAriaRole(attrs) {
189
225
  const type = (attrs.attributes['type'] ?? 'text').toLowerCase();
190
226
  return INPUT_TYPE_TO_ROLE[type] ?? 'textbox';
191
227
  }
228
+ // A plain <select> is a combobox; only with `multiple` (or size > 1, not
229
+ // captured) does it become a listbox. Matches the server's implicitRoleForTag.
230
+ if (tag === 'select') {
231
+ return attrs.attributes['multiple'] != null ? 'listbox' : 'combobox';
232
+ }
192
233
  if (tag === 'a') {
193
234
  return attrs.attributes['href'] != null ? 'link' : null;
194
235
  }
@@ -197,6 +238,10 @@ function resolveAriaRole(attrs) {
197
238
  // ── Alternative generation ───────────────────────────────────────────────────
198
239
  const attr = (a, key) => a.attributes[key] || null;
199
240
  const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
241
+ /** Escape a value for use inside a double-quoted CSS attribute selector. */
242
+ const escCssAttrValue = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
243
+ /** An id usable as a bare `#id` CSS selector without escaping (framework ids like `:r3:` are not). */
244
+ const isCssSafeId = (id) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
200
245
  /**
201
246
  * Build a ranked list of alternative locators from the captured element
202
247
  * attributes. The list is sorted descending by stability score.
@@ -216,9 +261,17 @@ function generateAlternatives(attrs) {
216
261
  const { accessibleName } = attrs;
217
262
  const tag = attrs.tagName;
218
263
  const role = resolveAriaRole(attrs);
264
+ // A probed count > 1 means the selector matches several elements on the
265
+ // captured page — a strict-mode violation, so the alternative is suppressed.
266
+ // Unknown counts (probe absent/failed) keep the alternative.
267
+ const counts = attrs.selectorCounts;
268
+ const isUnique = (n) => n == null || n <= 1;
269
+ // Collapse whitespace: multi-line/indented textContent would otherwise
270
+ // produce a getByText with literal newlines — invalid when pasted as code.
271
+ const text = attrs.textContent ? attrs.textContent.replace(/\s+/g, ' ').trim() : null;
219
272
  // 1. data-testid — highest stability (100)
220
273
  const testId = attr(attrs, 'data-testid');
221
- if (testId) {
274
+ if (testId && isUnique(counts?.testId)) {
222
275
  add({
223
276
  locator: `getByTestId('${esc(testId)}')`,
224
277
  method: 'getByTestId',
@@ -245,14 +298,22 @@ function generateAlternatives(attrs) {
245
298
  score: 85,
246
299
  });
247
300
  }
248
- // 4. getByLabel — for form fields with associated <label> (85)
301
+ // 4. getByLabel — for form fields (85). getByLabel only matches a real
302
+ // <label> association or aria-label — but the accessible name may have been
303
+ // computed (or approximated) from placeholder/title, where getByLabel would
304
+ // match nothing. Only emit when a label/aria-label actually backs the name;
305
+ // legacy payloads without the hasLabel probe keep the old permissive behavior.
249
306
  if (accessibleName && ['input', 'select', 'textarea'].includes(tag)) {
250
- add({
251
- locator: `getByLabel('${esc(accessibleName)}')`,
252
- method: 'getByLabel',
253
- args: { label: accessibleName },
254
- score: 85,
255
- });
307
+ const label = attr(attrs, 'aria-label');
308
+ const labelBacked = attrs.hasLabel === undefined ? true : attrs.hasLabel === true || label === accessibleName;
309
+ if (labelBacked) {
310
+ add({
311
+ locator: `getByLabel('${esc(accessibleName)}')`,
312
+ method: 'getByLabel',
313
+ args: { label: accessibleName },
314
+ score: 85,
315
+ });
316
+ }
256
317
  }
257
318
  // 5. getByPlaceholder — for inputs (80)
258
319
  const placeholder = attr(attrs, 'placeholder');
@@ -265,31 +326,35 @@ function generateAlternatives(attrs) {
265
326
  });
266
327
  }
267
328
  // 6. getByText — from visible text content (70-80)
268
- if (attrs.textContent && attrs.textContent.length < 80) {
329
+ if (text && text.length < 80) {
269
330
  add({
270
- locator: `getByText('${esc(attrs.textContent)}')`,
331
+ locator: `getByText('${esc(text)}')`,
271
332
  method: 'getByText',
272
- args: { text: attrs.textContent },
333
+ args: { text },
273
334
  score: 75,
274
335
  });
275
336
  }
276
- // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70)
337
+ // 7. locator('#id') — if id exists and doesn't look auto-generated (50-70).
338
+ // Framework ids that aren't valid bare CSS identifiers (React useId's
339
+ // `:r3:`, ids with dots) fall back to an attribute selector.
277
340
  const id = attr(attrs, 'id');
278
- if (id && !isAutoGenerated(id)) {
341
+ if (id && !isAutoGenerated(id) && isUnique(counts?.id)) {
342
+ const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
279
343
  add({
280
- locator: `locator('#${esc(id)}')`,
344
+ locator: `locator('${esc(selector)}')`,
281
345
  method: 'locator',
282
- args: { selector: `#${id}` },
346
+ args: { selector },
283
347
  score: 65,
284
348
  });
285
349
  }
286
350
  // 8. locator('[name="..."]') — for form elements (60)
287
351
  const name = attr(attrs, 'name');
288
- if (name) {
352
+ if (name && isUnique(counts?.name)) {
353
+ const selector = `[name="${escCssAttrValue(name)}"]`;
289
354
  add({
290
- locator: `locator('[name="${esc(name)}"]')`,
355
+ locator: `locator('${esc(selector)}')`,
291
356
  method: 'locator',
292
- args: { selector: `[name="${name}"]` },
357
+ args: { selector },
293
358
  score: 60,
294
359
  });
295
360
  }
@@ -313,12 +378,15 @@ function generateAlternatives(attrs) {
313
378
  score: 50,
314
379
  });
315
380
  }
316
- // 11. CSS class-based locators — capped at 3 most stable classes
381
+ // 11. CSS class-based locators — capped at 3 most stable classes; classes
382
+ // the uniqueness probe saw on more than one element are dropped outright.
317
383
  const clsStr = attr(attrs, 'class');
318
384
  if (clsStr) {
319
385
  const classes = clsStr
320
386
  .split(/\s+/)
321
- .filter((c) => c.length > 1)
387
+ // Only classes valid as a bare `.cls` selector — Tailwind variants
388
+ // (`hover:bg-red-500`) and arbitrary values (`w-[10px]`) are not.
389
+ .filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(counts?.classes?.[c]))
322
390
  .map((cls) => ({
323
391
  cls,
324
392
  score: classifyCssStability(cls),
@@ -395,6 +463,12 @@ function isAutoGenerated(value) {
395
463
  // Angular-style generated IDs (ng-xxx-N)
396
464
  if (value.startsWith('ng-'))
397
465
  return true;
466
+ // Component-library generated ids (Radix, Headless UI, MUI, Mantine, Chakra)
467
+ if (/^(radix-|headlessui-|mui-|mantine-|chakra-)/i.test(value))
468
+ return true;
469
+ // React useId format — `:r1:` (18) / `«r1»` (19)
470
+ if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value))
471
+ return true;
398
472
  return false;
399
473
  }
400
474
  // ── Accessible name extraction ───────────────────────────────────────────────
@@ -448,7 +522,11 @@ const NAME_BASED_METHODS = new Set([
448
522
  'getByAltText',
449
523
  ]);
450
524
  const escAttr = (s) => s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
451
- /** Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side matcher). */
525
+ /**
526
+ * Parse `ariaSnapshot()` lines into role/name pairs (mirrors the server-side
527
+ * `parseAriaCandidates` in application/shared/locator-fingerprint.ts).
528
+ * Exported so the dashboard's drift-guard unit test can compare the two.
529
+ */
452
530
  function parseAriaRoleName(ariaSnapshot) {
453
531
  const out = [];
454
532
  for (const line of ariaSnapshot.split('\n')) {
@@ -467,7 +545,8 @@ function parseAriaRoleName(ariaSnapshot) {
467
545
  * Token-set (Dice) similarity, 0-1, case- and punctuation-insensitive.
468
546
  * Duplicated from `textSimilarity` in application/shared/locator-fingerprint.ts —
469
547
  * this package publishes standalone to npm and can't import monorepo-relative
470
- * shared/ code, so keep the two implementations in sync by hand.
548
+ * shared/ code, so keep the two implementations in sync by hand. Exported so
549
+ * the dashboard's drift-guard unit test can compare the two.
471
550
  */
472
551
  function nameSimilarity(a, b) {
473
552
  const tok = (s) => new Set((s ?? '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Playwright reporter for sending test results to Piwi Dashboard",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.github.io",