@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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 (
|
|
329
|
+
if (text && text.length < 80) {
|
|
269
330
|
add({
|
|
270
|
-
locator: `getByText('${esc(
|
|
331
|
+
locator: `getByText('${esc(text)}')`,
|
|
271
332
|
method: 'getByText',
|
|
272
|
-
args: { text
|
|
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('
|
|
344
|
+
locator: `locator('${esc(selector)}')`,
|
|
281
345
|
method: 'locator',
|
|
282
|
-
args: { selector
|
|
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('
|
|
355
|
+
locator: `locator('${esc(selector)}')`,
|
|
291
356
|
method: 'locator',
|
|
292
|
-
args: { selector
|
|
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
|
-
|
|
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
|
-
/**
|
|
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