@piwitests/reporter 0.6.0 → 0.9.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,46 @@
1
- import type { Fixtures } from '@playwright/test';
1
+ import type { Fixtures, Locator } from '@playwright/test';
2
+ /** Shape returned by the in-page element probe (see `wrapLocator`). */
3
+ interface CapturedAttrs {
4
+ tagName: string;
5
+ attributes: Record<string, string | null>;
6
+ textContent: string;
7
+ center: {
8
+ x: number;
9
+ y: number;
10
+ };
11
+ /** True when the element has an associated <label> — gates getByLabel. */
12
+ hasLabel: boolean;
13
+ /** querySelectorAll match counts for candidate selectors (uniqueness probe). */
14
+ selectorCounts: {
15
+ testId?: number;
16
+ id?: number;
17
+ name?: number;
18
+ classes?: Record<string, number>;
19
+ };
20
+ }
21
+ /**
22
+ * ARIA snapshot that tolerates every Playwright version the reporter supports,
23
+ * returning null instead of throwing so a capture can never fail the test. The
24
+ * options validator runs client-side in the user's installed Playwright, so an
25
+ * unsupported key surfaces as a rejected promise (not a synchronous throw):
26
+ * - ≥ 1.59: `mode: 'ai'` yields the ref-annotated AI snapshot (the ideal);
27
+ * `ref` is unknown and silently stripped.
28
+ * - 1.52: `mode: 'ai'` fails validation (only 'raw'/'regex' are accepted) and
29
+ * rejects; the `{ ref: true }` fallback then yields a ref-annotated snapshot.
30
+ * - 1.53–1.58: neither `ref` nor `mode` exists — unknown keys are stripped
31
+ * server-side, so the first call already returns a plain flat snapshot.
32
+ * - < 1.49: `locator.ariaSnapshot` does not exist — returns null up front.
33
+ */
34
+ export declare function ariaSnapshotBestEffort(target: Locator, timeout?: number): Promise<string | null>;
35
+ /**
36
+ * Runs inside the browser via `evaluate()` — probes a captured element for its
37
+ * attributes, geometry, label association, and selector-uniqueness counts.
38
+ * Must stay a fully self-contained function (no references to this module's
39
+ * closure): Playwright serializes it and executes it in the page, browser-side.
40
+ * `el` is browser-context (no DOM lib in this Node package), hence `any`.
41
+ * Exported for unit testing; still passed directly to `evaluate()` below.
42
+ */
43
+ export declare function probeElementAttrs(el: any, keep: string[]): CapturedAttrs;
2
44
  /**
3
45
  * Playwright fixtures that collect network requests, console entries,
4
46
  * web vitals, ARIA snapshots, and locator interaction data during a test.
@@ -25,3 +67,4 @@ export declare const dashboardFixtures: Fixtures;
25
67
  * ```
26
68
  */
27
69
  export declare function extendDashboardFixtures<T>(test: T): T;
70
+ export {};
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dashboardFixtures = void 0;
4
+ exports.ariaSnapshotBestEffort = ariaSnapshotBestEffort;
5
+ exports.probeElementAttrs = probeElementAttrs;
4
6
  exports.extendDashboardFixtures = extendDashboardFixtures;
5
7
  const node_zlib_1 = require("node:zlib");
6
8
  const locator_healing_js_1 = require("./locator-healing.js");
@@ -74,6 +76,75 @@ async function ariaSnapshotBestEffort(target, timeout) {
74
76
  }
75
77
  }
76
78
  }
79
+ /**
80
+ * Runs inside the browser via `evaluate()` — probes a captured element for its
81
+ * attributes, geometry, label association, and selector-uniqueness counts.
82
+ * Must stay a fully self-contained function (no references to this module's
83
+ * closure): Playwright serializes it and executes it in the page, browser-side.
84
+ * `el` is browser-context (no DOM lib in this Node package), hence `any`.
85
+ * Exported for unit testing; still passed directly to `evaluate()` below.
86
+ */
87
+ function probeElementAttrs(el, keep) {
88
+ const attrMap = {};
89
+ for (const key of keep) {
90
+ const v = el.getAttribute(key) ?? el[key];
91
+ attrMap[key] = typeof v === 'string' ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
92
+ }
93
+ const r = el.getBoundingClientRect();
94
+ // Uniqueness probe: how many elements each candidate selector matches. A
95
+ // count > 1 marks the alternative as ambiguous (strict-mode violation) so
96
+ // generateAlternatives drops it. All DOM/CSS access goes through `el` (no
97
+ // DOM lib here).
98
+ const selectorCounts = {};
99
+ try {
100
+ const doc = el.ownerDocument;
101
+ const cssEsc = (s) => doc.defaultView.CSS.escape(s);
102
+ const count = (sel) => {
103
+ try {
104
+ return doc.querySelectorAll(sel).length;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ };
110
+ if (attrMap['data-testid']) {
111
+ selectorCounts.testId = count(`[data-testid=${JSON.stringify(attrMap['data-testid'])}]`);
112
+ }
113
+ if (attrMap['id'])
114
+ selectorCounts.id = count(`#${cssEsc(attrMap['id'])}`);
115
+ if (attrMap['name'])
116
+ selectorCounts.name = count(`[name=${JSON.stringify(attrMap['name'])}]`);
117
+ const classList = (attrMap['class'] || '')
118
+ .split(/\s+/)
119
+ .filter((c) => c.length > 1)
120
+ .slice(0, 10);
121
+ if (classList.length > 0) {
122
+ const classCounts = {};
123
+ for (const cls of classList) {
124
+ const n = count(`.${cssEsc(cls)}`);
125
+ if (n !== undefined)
126
+ classCounts[cls] = n;
127
+ }
128
+ selectorCounts.classes = classCounts;
129
+ }
130
+ }
131
+ catch {
132
+ // Uniqueness probing is best-effort — never fail the capture.
133
+ }
134
+ return {
135
+ tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
136
+ attributes: attrMap,
137
+ // Collapse whitespace so multi-line text can't produce a getByText
138
+ // suggestion with literal newlines in it.
139
+ textContent: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
140
+ center: {
141
+ x: Math.round(r.x + r.width / 2),
142
+ y: Math.round(r.y + r.height / 2),
143
+ },
144
+ hasLabel: !!(el.labels && el.labels.length > 0),
145
+ selectorCounts,
146
+ };
147
+ }
77
148
  // Chain methods that take args and define a new locator scope (not just narrow).
78
149
  // Origin method/args update to the chain call, e.g. .locator('.item') → locator('.item').
79
150
  // Positional/filter chains that narrow but don't change locator identity.
@@ -118,7 +189,6 @@ function wrapLocator(page, locator, originMethod, originArgs) {
118
189
  // Push a placeholder immediately — DOM capture runs async below
119
190
  sink.capturedLocators.push({
120
191
  location: callerLocation,
121
- stepIndex: seq,
122
192
  used,
123
193
  element: null,
124
194
  alternatives: [],
@@ -138,28 +208,13 @@ function wrapLocator(page, locator, originMethod, originArgs) {
138
208
  // evaluate() can hang when page navigates (element detaches), so
139
209
  // race it against a 500ms deadline and never throw.
140
210
  const resolveAttrs = (async () => {
211
+ let deadline;
141
212
  try {
142
- // `el` is browser-context (no DOM lib in this Node package), so it
143
- // stays `any`; the callback's return type pins `attrs` to CapturedAttrs.
144
213
  const attrs = await Promise.race([
145
- target.evaluate((el, keep) => {
146
- const attrMap = {};
147
- for (const key of keep) {
148
- const v = el.getAttribute(key) ?? el[key];
149
- attrMap[key] = typeof v === 'string' ? v.slice(0, 200) : v ? String(v).slice(0, 200) : null;
150
- }
151
- const r = el.getBoundingClientRect();
152
- return {
153
- tagName: el.tagName?.toLowerCase?.() ?? 'unknown',
154
- attributes: attrMap,
155
- textContent: (el.textContent || '').trim().slice(0, 80),
156
- center: {
157
- x: Math.round(r.x + r.width / 2),
158
- y: Math.round(r.y + r.height / 2),
159
- },
160
- };
161
- }, CAPTURED_ATTRS_ARG),
162
- new Promise((_, reject) => setTimeout(() => reject(new Error('locator capture timeout')), 500)),
214
+ target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG),
215
+ new Promise((_, reject) => {
216
+ deadline = setTimeout(() => reject(new Error('locator capture timeout')), 500);
217
+ }),
163
218
  ]);
164
219
  // The browser-computed accessible name only feeds role-based and
165
220
  // form-field alternatives, so only pay for the extra ARIA
@@ -175,15 +230,25 @@ function wrapLocator(page, locator, originMethod, originArgs) {
175
230
  const accessibleName = (0, locator_healing_js_1.extractAccessibleName)(aria) || (0, locator_healing_js_1.approximateAccessibleName)({ ...attrs, accessibleName: null });
176
231
  sink.capturedLocators[seq] = {
177
232
  location: callerLocation,
178
- stepIndex: seq,
179
233
  used,
180
- element: { ...attrs, accessibleName },
234
+ // hasLabel/selectorCounts inform alternative generation only —
235
+ // keep the stored element to the wire shape.
236
+ element: {
237
+ tagName: attrs.tagName,
238
+ attributes: attrs.attributes,
239
+ textContent: attrs.textContent,
240
+ accessibleName,
241
+ center: attrs.center,
242
+ },
181
243
  alternatives: (0, locator_healing_js_1.generateAlternatives)({ ...attrs, accessibleName }),
182
244
  };
183
245
  }
184
246
  catch {
185
247
  // element detached or timeout — keep the placeholder
186
248
  }
249
+ finally {
250
+ clearTimeout(deadline);
251
+ }
187
252
  })();
188
253
  sink.capturePromises.push(resolveAttrs);
189
254
  return result;
@@ -339,11 +404,21 @@ async function flushSink(sink, testInfo) {
339
404
  // Cap the drain so a stuck capture (e.g. a navigation in flight) can never
340
405
  // hang teardown past the test timeout; per-action evaluate/ariaSnapshot are
341
406
  // already bounded, this is a backstop.
342
- await Promise.race([Promise.allSettled(sink.capturePromises), new Promise((resolve) => setTimeout(resolve, 2000))]);
407
+ let drainDeadline;
408
+ await Promise.race([
409
+ Promise.allSettled(sink.capturePromises),
410
+ new Promise((resolve) => {
411
+ drainDeadline = setTimeout(resolve, 2000);
412
+ }),
413
+ ]);
414
+ clearTimeout(drainDeadline);
343
415
  if (sink.capturedLocators.length > 0) {
344
416
  await testInfo.attach(attachments_js_1.ATTACHMENT_NAMES.locators, {
345
417
  contentType: 'application/json',
346
- body: Buffer.from(JSON.stringify(sink.capturedLocators)),
418
+ // Repeated call sites (loops) keep only their latest capture — the
419
+ // server stores one row per location anyway, so shipping every
420
+ // iteration is pure payload bloat.
421
+ body: Buffer.from(JSON.stringify((0, locator_healing_js_1.dedupeSnapshotsByLocation)(sink.capturedLocators))),
347
422
  });
348
423
  }
349
424
  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 ?? '')
@@ -66,7 +66,7 @@ export declare class StreamManager {
66
66
  */
67
67
  constructor(httpClient: HttpClient, streamBuffer: StreamBuffer, recovery: CrashRecovery, uploader: Uploader, fileHandler: FileHandler, options: PiwiDashboardOptions, logger?: Logger);
68
68
  /** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
69
- start(startTime: string, metadata: Record<string, any>, instanceId: string, playwrightVersion?: string | null, shardInfo?: ShardInfo | null, isFullRun?: boolean, filterDetails?: FilterDetails | null): void;
69
+ start(startTime: string, metadata: Record<string, any>, instanceId: string, playwrightVersion?: string | null, reporterVersion?: string | null, shardInfo?: ShardInfo | null, isFullRun?: boolean, filterDetails?: FilterDetails | null): void;
70
70
  private _doStart;
71
71
  /** Queue a test-case `begin` event. Held in a pre-start buffer if the stream is not yet open, then prepended so it arrives before the matching `complete` event. */
72
72
  queueBeginEvent(event: StreamEvent): void;
@@ -111,10 +111,10 @@ class StreamManager {
111
111
  this._startPromise = null;
112
112
  }
113
113
  /** Begin the streaming session after `onBegin` fires. Non-blocking — the actual handshake runs asynchronously. */
114
- start(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails) {
115
- this._startPromise = this._doStart(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails);
114
+ start(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
115
+ this._startPromise = this._doStart(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails);
116
116
  }
117
- async _doStart(startTime, metadata, instanceId, playwrightVersion, shardInfo, isFullRun, filterDetails) {
117
+ async _doStart(startTime, metadata, instanceId, playwrightVersion, reporterVersion, shardInfo, isFullRun, filterDetails) {
118
118
  const setupInfo = (0, setup_file_js_1.readSetupInfo)(this.options.projectName);
119
119
  try {
120
120
  this._auth = await this.httpClient.resolveAuth(this.options);
@@ -129,6 +129,7 @@ class StreamManager {
129
129
  totalTests: 0,
130
130
  metadata,
131
131
  playwrightVersion,
132
+ reporterVersion,
132
133
  shardIndex,
133
134
  shardTotal,
134
135
  isFullRun,
@@ -150,6 +151,7 @@ class StreamManager {
150
151
  metadata,
151
152
  instanceId,
152
153
  playwrightVersion,
154
+ reporterVersion,
153
155
  shardIndex,
154
156
  shardTotal,
155
157
  isFullRun,
@@ -167,6 +169,7 @@ class StreamManager {
167
169
  metadata,
168
170
  instanceId,
169
171
  playwrightVersion,
172
+ reporterVersion,
170
173
  shardIndex,
171
174
  shardTotal,
172
175
  isFullRun,
@@ -16,6 +16,7 @@ export interface CollectedRun {
16
16
  testCases: CollectedTestCase[];
17
17
  startTime: string | null;
18
18
  playwrightVersion: string | null;
19
+ reporterVersion: string | null;
19
20
  totalTests: number;
20
21
  passedTests: number;
21
22
  failedTests: number;
@@ -97,6 +97,7 @@ class RunSubmitter {
97
97
  metadata: run.metadata,
98
98
  instanceId: run.instanceId,
99
99
  playwrightVersion: run.playwrightVersion ?? undefined,
100
+ reporterVersion: run.reporterVersion ?? undefined,
100
101
  testCases: run.testCases,
101
102
  shardIndex: run.shardInfo?.current,
102
103
  shardTotal: run.shardInfo?.total,
@@ -126,6 +127,7 @@ class RunSubmitter {
126
127
  metadata: run.metadata,
127
128
  hasPendingUploads: this.hasReports(run),
128
129
  playwrightVersion: run.playwrightVersion ?? undefined,
130
+ reporterVersion: run.reporterVersion ?? undefined,
129
131
  setupSteps: run.setupSteps.length > 0 ? run.setupSteps : undefined,
130
132
  isFullRun: run.isFullRun,
131
133
  filterDetails: run.filterDetails ?? null,
@@ -94,6 +94,7 @@ function serializeRun(payload, opts) {
94
94
  metadata: payload.metadata,
95
95
  instanceId: payload.instanceId,
96
96
  playwrightVersion: payload.playwrightVersion,
97
+ reporterVersion: payload.reporterVersion,
97
98
  shardIndex: payload.shardIndex,
98
99
  shardTotal: payload.shardTotal,
99
100
  isFullRun: payload.isFullRun ?? true,
@@ -33,6 +33,8 @@ export interface RunPayload {
33
33
  testCases: CollectedTestCase[];
34
34
  /** Playwright framework version used for this run */
35
35
  playwrightVersion?: string;
36
+ /** Piwi reporter package version that produced this run */
37
+ reporterVersion?: string;
36
38
  /** 1-based shard index (e.g. 1, 2, 3) */
37
39
  shardIndex?: number;
38
40
  /** Total number of shards (e.g. 3) */
@@ -0,0 +1,2 @@
1
+ /** Read the reporter package's own version from `package.json`, memoized. Falls back to `'unknown'` if it can't be read (e.g. an unusual install layout). */
2
+ export declare function getReporterVersion(): string;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getReporterVersion = getReporterVersion;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ let cachedVersion = null;
40
+ /** Read the reporter package's own version from `package.json`, memoized. Falls back to `'unknown'` if it can't be read (e.g. an unusual install layout). */
41
+ function getReporterVersion() {
42
+ if (cachedVersion)
43
+ return cachedVersion;
44
+ try {
45
+ const pkgPath = path.resolve(__dirname, '../../../package.json');
46
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
47
+ const version = pkg.version;
48
+ cachedVersion = typeof version === 'string' ? version : 'unknown';
49
+ }
50
+ catch {
51
+ cachedVersion = 'unknown';
52
+ }
53
+ return cachedVersion;
54
+ }
@@ -14,6 +14,7 @@ export declare class PiwiDashboardReporter {
14
14
  private testCases;
15
15
  private startTime;
16
16
  private playwrightVersion;
17
+ private readonly reporterVersion;
17
18
  private totalTests;
18
19
  private passedTests;
19
20
  private failedTests;
@@ -46,6 +46,7 @@ const metadata_collector_js_1 = require("../internal/collect/metadata-collector.
46
46
  const stream_manager_js_1 = require("../internal/streaming/stream-manager.js");
47
47
  const step_analyzer_js_1 = require("../internal/collect/step-analyzer.js");
48
48
  const instance_id_js_1 = require("../internal/support/instance-id.js");
49
+ const reporter_version_js_1 = require("../internal/support/reporter-version.js");
49
50
  const source_snippet_js_1 = require("../internal/support/source-snippet.js");
50
51
  const ci_js_1 = require("../internal/support/ci.js");
51
52
  const worker_index_js_1 = require("../internal/support/worker-index.js");
@@ -79,6 +80,7 @@ class PiwiDashboardReporter {
79
80
  this.testCases = [];
80
81
  this.startTime = null;
81
82
  this.playwrightVersion = null;
83
+ this.reporterVersion = (0, reporter_version_js_1.getReporterVersion)();
82
84
  this.totalTests = 0;
83
85
  this.passedTests = 0;
84
86
  this.failedTests = 0;
@@ -154,7 +156,7 @@ class PiwiDashboardReporter {
154
156
  this.shardInfo = { current: pwShard.current, total: pwShard.total };
155
157
  this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
156
158
  }
157
- this.streamManager?.start(this.startTime, this.metadata, this.instanceId, this.playwrightVersion, this.shardInfo, this.isFullRun, this.filterDetails);
159
+ this.streamManager?.start(this.startTime, this.metadata, this.instanceId, this.playwrightVersion, this.reporterVersion, this.shardInfo, this.isFullRun, this.filterDetails);
158
160
  }
159
161
  /** Playwright reporter hook: called when an individual test begins */
160
162
  onTestBegin(test, result) {
@@ -353,6 +355,7 @@ class PiwiDashboardReporter {
353
355
  testCases: this.testCases,
354
356
  startTime: this.startTime,
355
357
  playwrightVersion: this.playwrightVersion,
358
+ reporterVersion: this.reporterVersion,
356
359
  totalTests: this.totalTests,
357
360
  passedTests: this.passedTests,
358
361
  failedTests: this.failedTests,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.6.0",
3
+ "version": "0.9.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",
@@ -41,7 +41,9 @@
41
41
  "reporter:lint": "oxlint --config oxlint.config.mts .",
42
42
  "reporter:lint:fix": "oxlint --config oxlint.config.mts . --fix",
43
43
  "reporter:test": "vitest run",
44
+ "reporter:test:coverage": "vitest run --coverage",
44
45
  "reporter:test:watch": "vitest",
46
+ "reporter:test:integration": "npm run reporter:build && playwright test --config=tests/integration/playwright.config.ts",
45
47
  "test": "npm run reporter:test",
46
48
  "prepublishOnly": "npm run reporter:build"
47
49
  },