@specific.dev/spectest 0.66.0 → 0.67.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.
- package/dist/browser.js +42 -1
- package/dist/components/supabase.d.ts +0 -14
- package/dist/components/supabase.js +2 -8
- package/dist/daemon.js +83 -31
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/index.d.ts +52 -16
- package/dist/index.js +76 -17
- package/dist/locator-errors.d.ts +19 -10
- package/dist/locator-errors.js +80 -20
- package/dist/locator-hints.d.ts +96 -0
- package/dist/locator-hints.js +403 -0
- package/dist/locator.d.ts +22 -0
- package/dist/locator.js +63 -9
- package/dist/page-snapshot.d.ts +42 -0
- package/dist/page-snapshot.js +149 -0
- package/dist/recorder.d.ts +16 -0
- package/dist/text-match.d.ts +39 -0
- package/dist/text-match.js +239 -0
- package/package.json +1 -1
- package/src/browser.ts +43 -1
- package/src/components/supabase.ts +2 -20
- package/src/daemon.ts +85 -29
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/index.ts +159 -32
- package/src/locator-errors.test.ts +99 -11
- package/src/locator-errors.ts +98 -19
- package/src/locator-hints.test.ts +188 -0
- package/src/locator-hints.ts +514 -0
- package/src/locator.ts +72 -9
- package/src/page-snapshot.test.ts +100 -0
- package/src/page-snapshot.ts +180 -0
- package/src/recorder.ts +16 -0
- package/src/text-match.test.ts +132 -0
- package/src/text-match.ts +285 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// "Did you mean…" for a locator that matched nothing.
|
|
2
|
+
//
|
|
3
|
+
// `locator-errors.ts` turns a playwright timeout into one sentence about the
|
|
4
|
+
// element. This module answers the next question, which that sentence cannot:
|
|
5
|
+
// *what IS on the page?* A locator that matches nothing usually misses by a
|
|
6
|
+
// little — the accessible name carries a suffix, an `aria-label` overrides the
|
|
7
|
+
// visible text, the control is a `combobox` and not the `button` it looks
|
|
8
|
+
// like — and every one of those costs a full edit/run round trip to discover.
|
|
9
|
+
//
|
|
10
|
+
// Two sources, both authoritative, neither guessed at:
|
|
11
|
+
//
|
|
12
|
+
// * **The ARIA snapshot** (`page.ariaSnapshot()`) — playwright's own
|
|
13
|
+
// accessibility tree, so the accessible names here are exactly the ones
|
|
14
|
+
// `getByRole(role, { name })` matches against. We never compute an
|
|
15
|
+
// accessible name ourselves.
|
|
16
|
+
// * **One DOM pass** for the facts the snapshot drops: visible text of leaf
|
|
17
|
+
// elements, test ids, placeholders, labels, and — the case that prompted
|
|
18
|
+
// this — every interactive element whose visible text and name-giving
|
|
19
|
+
// attribute disagree.
|
|
20
|
+
//
|
|
21
|
+
// Nothing is printed on trust. Each candidate carries the locator that would
|
|
22
|
+
// select it, and `nearMissHints` runs that locator before the line reaches the
|
|
23
|
+
// author: a suggestion that resolves to nothing is dropped rather than sent
|
|
24
|
+
// out to be tried. The whole pass runs only on the failure path, inside a
|
|
25
|
+
// budget, and any error in it is swallowed — a diagnostic must never replace
|
|
26
|
+
// the failure it is explaining.
|
|
27
|
+
|
|
28
|
+
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
29
|
+
|
|
30
|
+
import { escapeInvisible, normalizeWhiteSpace } from "./text-match.js";
|
|
31
|
+
|
|
32
|
+
/** What the author asked for — the last selecting step of a failed chain. */
|
|
33
|
+
export interface HintTarget {
|
|
34
|
+
kind:
|
|
35
|
+
| "role"
|
|
36
|
+
| "text"
|
|
37
|
+
| "label"
|
|
38
|
+
| "placeholder"
|
|
39
|
+
| "altText"
|
|
40
|
+
| "title"
|
|
41
|
+
| "testId"
|
|
42
|
+
| "other";
|
|
43
|
+
/** The ARIA role, for `kind: "role"`. */
|
|
44
|
+
role?: string;
|
|
45
|
+
/** The string the author asked for. Absent for a RegExp query, which we
|
|
46
|
+
* make no suggestions from — a near-miss on a pattern is not a near-miss. */
|
|
47
|
+
query?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One role/name pair from the page's accessibility tree. */
|
|
51
|
+
export interface AriaCandidate {
|
|
52
|
+
role: string;
|
|
53
|
+
name: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An interactive element whose visible text is not its accessible name. */
|
|
57
|
+
export interface NameMismatch {
|
|
58
|
+
tag: string;
|
|
59
|
+
text: string;
|
|
60
|
+
name: string;
|
|
61
|
+
/** The attribute the name came from: `aria-label`, `aria-labelledby`, … */
|
|
62
|
+
from: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the DOM pass brings back. Every list is deduplicated and capped. */
|
|
66
|
+
export interface DomFacts {
|
|
67
|
+
texts: string[];
|
|
68
|
+
testIds: string[];
|
|
69
|
+
placeholders: string[];
|
|
70
|
+
labels: string[];
|
|
71
|
+
titles: string[];
|
|
72
|
+
alts: string[];
|
|
73
|
+
mismatches: NameMismatch[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const EMPTY_DOM_FACTS: DomFacts = {
|
|
77
|
+
texts: [],
|
|
78
|
+
testIds: [],
|
|
79
|
+
placeholders: [],
|
|
80
|
+
labels: [],
|
|
81
|
+
titles: [],
|
|
82
|
+
alts: [],
|
|
83
|
+
mismatches: [],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The locator a suggestion proposes, as data — so the ranking stays pure and
|
|
87
|
+
* testable and only `nearMissHints` touches a page. */
|
|
88
|
+
export type SuggestedLocator =
|
|
89
|
+
| { m: "getByRole"; role: string; name: string }
|
|
90
|
+
| { m: "getByText"; text: string }
|
|
91
|
+
| { m: "getByLabel"; text: string }
|
|
92
|
+
| { m: "getByPlaceholder"; text: string }
|
|
93
|
+
| { m: "getByAltText"; text: string }
|
|
94
|
+
| { m: "getByTitle"; text: string }
|
|
95
|
+
| { m: "getByTestId"; id: string };
|
|
96
|
+
|
|
97
|
+
export interface Suggestion {
|
|
98
|
+
/** What is on the page, in the reader's terms. */
|
|
99
|
+
fact: string;
|
|
100
|
+
/** The locator that selects it. */
|
|
101
|
+
locator: SuggestedLocator;
|
|
102
|
+
/** Higher is closer. Ranking only; never shown. */
|
|
103
|
+
score: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Similarity ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** Levenshtein distance, with an early exit once `max` is passed. */
|
|
109
|
+
function distance(a: string, b: string, max: number): number {
|
|
110
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
111
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
112
|
+
for (let i = 1; i <= a.length; i++) {
|
|
113
|
+
const cur = [i];
|
|
114
|
+
let best = i;
|
|
115
|
+
for (let j = 1; j <= b.length; j++) {
|
|
116
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
117
|
+
const v = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + cost);
|
|
118
|
+
cur.push(v);
|
|
119
|
+
if (v < best) best = v;
|
|
120
|
+
}
|
|
121
|
+
if (best > max) return max + 1;
|
|
122
|
+
prev = cur;
|
|
123
|
+
}
|
|
124
|
+
return prev[b.length]!;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function key(s: string): string {
|
|
128
|
+
return normalizeWhiteSpace(s).toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How close `candidate` is to what the author asked for, from 0 (unrelated)
|
|
133
|
+
* to 4 (the same string). Everything at or above `NEAR` is worth showing.
|
|
134
|
+
*/
|
|
135
|
+
export function closeness(query: string, candidate: string): number {
|
|
136
|
+
const q = key(query);
|
|
137
|
+
const c = key(candidate);
|
|
138
|
+
if (!q || !c) return 0;
|
|
139
|
+
if (q === c) return 4;
|
|
140
|
+
// A name that carries a suffix or a prefix ("Spara" vs "Spara ändringar")
|
|
141
|
+
// is the single most common near miss, and `{ exact: false }` or a
|
|
142
|
+
// substring query would already have matched it.
|
|
143
|
+
if (c.includes(q) || q.includes(c)) return 3;
|
|
144
|
+
const max = Math.max(2, Math.floor(Math.max(q.length, c.length) * 0.34));
|
|
145
|
+
const d = distance(q, c, max);
|
|
146
|
+
if (d > max) return 0;
|
|
147
|
+
return 2 - d / (max + 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The score at which a candidate is close enough to show. */
|
|
151
|
+
const NEAR = 1;
|
|
152
|
+
|
|
153
|
+
/** How many lines an author can absorb below a failure. */
|
|
154
|
+
const MAX_SUGGESTIONS = 3;
|
|
155
|
+
|
|
156
|
+
// ── Ranking ───────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** A page string, quoted for reading — with its invisible characters spelled
|
|
159
|
+
* out. A suggestion whose only difference from what the author typed is a
|
|
160
|
+
* no-break space would otherwise print as the very string that just failed. */
|
|
161
|
+
function quoted(s: string): string {
|
|
162
|
+
return escapeInvisible(JSON.stringify(s));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function code(loc: SuggestedLocator): string {
|
|
166
|
+
switch (loc.m) {
|
|
167
|
+
case "getByRole":
|
|
168
|
+
return `getByRole(${quoted(loc.role)}, { name: ${quoted(loc.name)} })`;
|
|
169
|
+
case "getByTestId":
|
|
170
|
+
return `getByTestId(${quoted(loc.id)})`;
|
|
171
|
+
default:
|
|
172
|
+
return `${loc.m}(${quoted(loc.text)})`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Render one verified suggestion as the line the author reads. */
|
|
177
|
+
export function suggestionLine(s: Suggestion, matches: number): string {
|
|
178
|
+
const extra = matches > 1 ? ` (matches ${matches} elements)` : "";
|
|
179
|
+
return ` - ${s.fact} → ${code(s.locator)}${extra}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function byScore(a: Suggestion, b: Suggestion): number {
|
|
183
|
+
return b.score - a.score;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Drop repeats of the same proposed locator, keeping the best-scoring one. */
|
|
187
|
+
function dedupe(items: Suggestion[]): Suggestion[] {
|
|
188
|
+
const seen = new Map<string, Suggestion>();
|
|
189
|
+
for (const s of items.slice().sort(byScore)) {
|
|
190
|
+
const k = code(s.locator);
|
|
191
|
+
if (!seen.has(k)) seen.set(k, s);
|
|
192
|
+
}
|
|
193
|
+
return [...seen.values()];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fromValues(
|
|
197
|
+
query: string,
|
|
198
|
+
values: string[],
|
|
199
|
+
make: (v: string) => SuggestedLocator,
|
|
200
|
+
fact: (v: string) => string,
|
|
201
|
+
): Suggestion[] {
|
|
202
|
+
const out: Suggestion[] = [];
|
|
203
|
+
for (const v of values) {
|
|
204
|
+
const score = closeness(query, v);
|
|
205
|
+
if (score >= NEAR) out.push({ fact: fact(v), locator: make(v), score });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Rank what the page holds against what the author asked for. Pure: the
|
|
212
|
+
* caller collects `aria`/`dom` and verifies the winners.
|
|
213
|
+
*/
|
|
214
|
+
export function buildSuggestions(
|
|
215
|
+
target: HintTarget,
|
|
216
|
+
aria: AriaCandidate[],
|
|
217
|
+
dom: DomFacts,
|
|
218
|
+
): Suggestion[] {
|
|
219
|
+
const q = target.query;
|
|
220
|
+
if (!q) return [];
|
|
221
|
+
const out: Suggestion[] = [];
|
|
222
|
+
|
|
223
|
+
if (target.kind === "role") {
|
|
224
|
+
const role = target.role ?? "";
|
|
225
|
+
for (const c of aria) {
|
|
226
|
+
const score = closeness(q, c.name);
|
|
227
|
+
if (score < NEAR) continue;
|
|
228
|
+
if (c.role === role) {
|
|
229
|
+
// Same role, near name: the everyday miss.
|
|
230
|
+
out.push({
|
|
231
|
+
fact: `${c.role} ${quoted(c.name)}`,
|
|
232
|
+
locator: { m: "getByRole", role: c.role, name: c.name },
|
|
233
|
+
score: score + 1,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
// The name is right and the role is not — a `combobox` styled as a
|
|
237
|
+
// button, a `link` that looks like one. Worth saying out loud,
|
|
238
|
+
// because the author's own reading of the page is what is wrong.
|
|
239
|
+
out.push({
|
|
240
|
+
fact: `${c.role} ${quoted(c.name)} — this is a ${c.role}, not a ${role}`,
|
|
241
|
+
locator: { m: "getByRole", role: c.role, name: c.name },
|
|
242
|
+
score,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// An element whose visible text is what the author typed, but whose
|
|
247
|
+
// accessible name — the thing `getByRole` matches — is something else.
|
|
248
|
+
for (const m of dom.mismatches) {
|
|
249
|
+
const score = closeness(q, m.text);
|
|
250
|
+
if (score < NEAR) continue;
|
|
251
|
+
out.push({
|
|
252
|
+
fact: `<${m.tag}> shows ${quoted(m.text)} but its accessible name is ${quoted(m.name)} (from ${m.from})`,
|
|
253
|
+
locator: { m: "getByRole", role: role || "button", name: m.name },
|
|
254
|
+
score: score + 0.5,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (target.kind === "text") {
|
|
260
|
+
out.push(
|
|
261
|
+
...fromValues(q, dom.texts, (v) => ({ m: "getByText", text: v }), (v) => `text ${quoted(v)}`),
|
|
262
|
+
);
|
|
263
|
+
for (const c of aria) {
|
|
264
|
+
const score = closeness(q, c.name);
|
|
265
|
+
if (score >= NEAR) {
|
|
266
|
+
out.push({
|
|
267
|
+
fact: `${c.role} ${quoted(c.name)}`,
|
|
268
|
+
locator: { m: "getByRole", role: c.role, name: c.name },
|
|
269
|
+
score,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (target.kind === "testId") {
|
|
276
|
+
out.push(
|
|
277
|
+
...fromValues(
|
|
278
|
+
q,
|
|
279
|
+
dom.testIds,
|
|
280
|
+
(v) => ({ m: "getByTestId", id: v }),
|
|
281
|
+
(v) => `testid ${quoted(v)}`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (target.kind === "label") {
|
|
287
|
+
out.push(
|
|
288
|
+
...fromValues(q, dom.labels, (v) => ({ m: "getByLabel", text: v }), (v) => `label ${quoted(v)}`),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (target.kind === "placeholder") {
|
|
293
|
+
out.push(
|
|
294
|
+
...fromValues(
|
|
295
|
+
q,
|
|
296
|
+
dom.placeholders,
|
|
297
|
+
(v) => ({ m: "getByPlaceholder", text: v }),
|
|
298
|
+
(v) => `placeholder ${quoted(v)}`,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (target.kind === "title") {
|
|
304
|
+
out.push(
|
|
305
|
+
...fromValues(q, dom.titles, (v) => ({ m: "getByTitle", text: v }), (v) => `title ${quoted(v)}`),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (target.kind === "altText") {
|
|
310
|
+
out.push(
|
|
311
|
+
...fromValues(q, dom.alts, (v) => ({ m: "getByAltText", text: v }), (v) => `alt ${quoted(v)}`),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return dedupe(out).sort(byScore).slice(0, MAX_SUGGESTIONS);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Page-side collection ──────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Pull `- button "Save"` lines out of an ARIA snapshot.
|
|
322
|
+
*
|
|
323
|
+
* The snapshot is YAML, but only its leading token carries what we need, so
|
|
324
|
+
* this reads it line by line rather than pulling in a parser. A line we do not
|
|
325
|
+
* recognise is skipped — the worst case is one fewer suggestion.
|
|
326
|
+
*/
|
|
327
|
+
export function parseAriaSnapshot(yaml: string): AriaCandidate[] {
|
|
328
|
+
const out: AriaCandidate[] = [];
|
|
329
|
+
const seen = new Set<string>();
|
|
330
|
+
for (const raw of yaml.split("\n")) {
|
|
331
|
+
// `- role "name" [level=1]:` — the role is a bare word, the name a
|
|
332
|
+
// double-quoted string, and both the name and the trailing parts are
|
|
333
|
+
// optional. A quoted name may contain escaped quotes.
|
|
334
|
+
const m = /^\s*-\s+([a-zA-Z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/.exec(raw);
|
|
335
|
+
if (!m) continue;
|
|
336
|
+
const role = m[1]!;
|
|
337
|
+
if (m[2] === undefined) continue; // No accessible name: nothing to match.
|
|
338
|
+
const name = m[2].replace(/\\(.)/g, "$1");
|
|
339
|
+
const k = `${role} "${name}"`;
|
|
340
|
+
if (seen.has(k)) continue;
|
|
341
|
+
seen.add(k);
|
|
342
|
+
out.push({ role, name });
|
|
343
|
+
if (out.length >= 400) break;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Read the page facts the ARIA snapshot does not carry.
|
|
350
|
+
*
|
|
351
|
+
* Runs in the browser, so it must stay self-contained (no closure over
|
|
352
|
+
* anything here) and cheap: it walks a bounded number of elements and caps
|
|
353
|
+
* every list it fills.
|
|
354
|
+
*/
|
|
355
|
+
/* c8 ignore start — executes in the browser, not under bun test */
|
|
356
|
+
function domFactsScript(): DomFacts {
|
|
357
|
+
const MAX_ELEMENTS = 4000;
|
|
358
|
+
const MAX_ITEMS = 200;
|
|
359
|
+
const norm = (s: string | null | undefined): string =>
|
|
360
|
+
(s ?? "").replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ").slice(0, 120);
|
|
361
|
+
const push = (arr: string[], v: string): void => {
|
|
362
|
+
if (v && arr.length < MAX_ITEMS && !arr.includes(v)) arr.push(v);
|
|
363
|
+
};
|
|
364
|
+
const out: DomFacts = {
|
|
365
|
+
texts: [],
|
|
366
|
+
testIds: [],
|
|
367
|
+
placeholders: [],
|
|
368
|
+
labels: [],
|
|
369
|
+
titles: [],
|
|
370
|
+
alts: [],
|
|
371
|
+
mismatches: [],
|
|
372
|
+
};
|
|
373
|
+
const visible = (el: Element): boolean => {
|
|
374
|
+
const r = el.getBoundingClientRect();
|
|
375
|
+
if (r.width === 0 && r.height === 0) return false;
|
|
376
|
+
const st = getComputedStyle(el);
|
|
377
|
+
return st.visibility !== "hidden" && st.display !== "none";
|
|
378
|
+
};
|
|
379
|
+
const INTERACTIVE =
|
|
380
|
+
"a,button,summary,select,textarea,input,[role],[onclick],[tabindex]";
|
|
381
|
+
let n = 0;
|
|
382
|
+
for (const el of Array.from(document.querySelectorAll("*"))) {
|
|
383
|
+
if (++n > MAX_ELEMENTS) break;
|
|
384
|
+
const tag = el.tagName.toLowerCase();
|
|
385
|
+
if (tag === "script" || tag === "style" || tag === "head") continue;
|
|
386
|
+
push(out.testIds, norm(el.getAttribute("data-testid")));
|
|
387
|
+
push(out.placeholders, norm(el.getAttribute("placeholder")));
|
|
388
|
+
push(out.titles, norm(el.getAttribute("title")));
|
|
389
|
+
push(out.alts, norm(el.getAttribute("alt")));
|
|
390
|
+
const ariaLabel = norm(el.getAttribute("aria-label"));
|
|
391
|
+
push(out.labels, ariaLabel);
|
|
392
|
+
if (tag === "label") push(out.labels, norm((el as HTMLElement).innerText));
|
|
393
|
+
if (el.children.length === 0 && visible(el)) {
|
|
394
|
+
push(out.texts, norm((el as HTMLElement).innerText || el.textContent));
|
|
395
|
+
}
|
|
396
|
+
if (!el.matches(INTERACTIVE) || !visible(el)) continue;
|
|
397
|
+
const text = norm((el as HTMLElement).innerText || el.textContent);
|
|
398
|
+
let name = ariaLabel;
|
|
399
|
+
let from = "aria-label";
|
|
400
|
+
if (!name) {
|
|
401
|
+
const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).filter(Boolean);
|
|
402
|
+
const parts: string[] = [];
|
|
403
|
+
for (const id of ids) {
|
|
404
|
+
const ref = document.getElementById(id);
|
|
405
|
+
if (ref) parts.push(norm((ref as HTMLElement).innerText || ref.textContent));
|
|
406
|
+
}
|
|
407
|
+
if (parts.length) {
|
|
408
|
+
name = norm(parts.join(" "));
|
|
409
|
+
from = "aria-labelledby";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!name && el.getAttribute("title")) {
|
|
413
|
+
name = norm(el.getAttribute("title"));
|
|
414
|
+
from = "title";
|
|
415
|
+
}
|
|
416
|
+
if (!name && tag === "input" && el.getAttribute("value")) {
|
|
417
|
+
name = norm(el.getAttribute("value"));
|
|
418
|
+
from = "value";
|
|
419
|
+
}
|
|
420
|
+
if (name && text && name !== text && out.mismatches.length < 50) {
|
|
421
|
+
out.mismatches.push({ tag, text, name, from });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
/* c8 ignore stop */
|
|
427
|
+
|
|
428
|
+
/** Build the playwright locator a suggestion proposes, for verification. */
|
|
429
|
+
function resolve(page: Page, loc: SuggestedLocator): PWLocator {
|
|
430
|
+
switch (loc.m) {
|
|
431
|
+
case "getByRole":
|
|
432
|
+
return page.getByRole(loc.role as Parameters<Page["getByRole"]>[0], {
|
|
433
|
+
name: loc.name,
|
|
434
|
+
exact: true,
|
|
435
|
+
});
|
|
436
|
+
case "getByTestId":
|
|
437
|
+
return page.getByTestId(loc.id);
|
|
438
|
+
case "getByText":
|
|
439
|
+
return page.getByText(loc.text, { exact: true });
|
|
440
|
+
case "getByLabel":
|
|
441
|
+
return page.getByLabel(loc.text, { exact: true });
|
|
442
|
+
case "getByPlaceholder":
|
|
443
|
+
return page.getByPlaceholder(loc.text, { exact: true });
|
|
444
|
+
case "getByAltText":
|
|
445
|
+
return page.getByAltText(loc.text, { exact: true });
|
|
446
|
+
case "getByTitle":
|
|
447
|
+
return page.getByTitle(loc.text, { exact: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** How long the whole diagnostic may take. It runs after a failure that has
|
|
452
|
+
* already waited seconds, so a short budget costs nothing and a hung page
|
|
453
|
+
* cannot make a failing test hang. */
|
|
454
|
+
const HINT_BUDGET_MS = 2_000;
|
|
455
|
+
|
|
456
|
+
async function withBudget<T>(work: Promise<T>, fallback: T): Promise<T> {
|
|
457
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
458
|
+
try {
|
|
459
|
+
return await Promise.race([
|
|
460
|
+
work,
|
|
461
|
+
new Promise<T>((resolve) => {
|
|
462
|
+
timer = setTimeout(() => resolve(fallback), HINT_BUDGET_MS);
|
|
463
|
+
}),
|
|
464
|
+
]);
|
|
465
|
+
} catch {
|
|
466
|
+
return fallback;
|
|
467
|
+
} finally {
|
|
468
|
+
if (timer) clearTimeout(timer);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The lines to append to a "no element matches" failure — at most
|
|
474
|
+
* {@link MAX_SUGGESTIONS}, each verified against the live page, and empty
|
|
475
|
+
* whenever the page holds nothing close.
|
|
476
|
+
*/
|
|
477
|
+
export async function nearMissHints(page: Page, target: HintTarget): Promise<string[]> {
|
|
478
|
+
if (!target.query) return [];
|
|
479
|
+
const collected = await withBudget(
|
|
480
|
+
(async () => {
|
|
481
|
+
const [snapshot, dom] = await Promise.all([
|
|
482
|
+
page.ariaSnapshot().catch(() => ""),
|
|
483
|
+
page.evaluate(domFactsScript).catch(() => EMPTY_DOM_FACTS),
|
|
484
|
+
]);
|
|
485
|
+
return { aria: parseAriaSnapshot(snapshot), dom };
|
|
486
|
+
})(),
|
|
487
|
+
{ aria: [] as AriaCandidate[], dom: EMPTY_DOM_FACTS },
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const suggestions = buildSuggestions(target, collected.aria, collected.dom);
|
|
491
|
+
if (!suggestions.length) return [];
|
|
492
|
+
|
|
493
|
+
// Verify before printing. A suggestion nobody can use is worse than none:
|
|
494
|
+
// it sends the author to try a locator we invented.
|
|
495
|
+
const lines = await withBudget(
|
|
496
|
+
Promise.all(
|
|
497
|
+
suggestions.map(async (s) => {
|
|
498
|
+
const matches = await resolve(page, s.locator)
|
|
499
|
+
.count()
|
|
500
|
+
.catch(() => 0);
|
|
501
|
+
return matches > 0 ? suggestionLine(s, matches) : undefined;
|
|
502
|
+
}),
|
|
503
|
+
),
|
|
504
|
+
[],
|
|
505
|
+
);
|
|
506
|
+
return lines.filter((l): l is string => l !== undefined);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The block appended under a failure sentence, or `""` when there is
|
|
510
|
+
* nothing to add. */
|
|
511
|
+
export function formatHints(lines: string[]): string {
|
|
512
|
+
if (!lines.length) return "";
|
|
513
|
+
return `\nClose matches on the page:\n${lines.join("\n")}`;
|
|
514
|
+
}
|
package/src/locator.ts
CHANGED
|
@@ -26,7 +26,8 @@ import { Buffer } from "node:buffer";
|
|
|
26
26
|
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
27
27
|
import type { RecordableFields } from "./browser.js";
|
|
28
28
|
import type { Wrapped } from "./inspect.js";
|
|
29
|
-
import {
|
|
29
|
+
import { rewriteLocatorErrorWithHints } from "./locator-errors.js";
|
|
30
|
+
import { formatHints, nearMissHints, type HintTarget } from "./locator-hints.js";
|
|
30
31
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
31
32
|
import { truncateUtf8 } from "./recorder.js";
|
|
32
33
|
|
|
@@ -261,6 +262,45 @@ function stepLabel(step: Step): string {
|
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
/**
|
|
266
|
+
* What a chain asked for, in the terms the near-miss diagnostic reasons in
|
|
267
|
+
* (see locator-hints.ts). `undefined` when this chain must not produce
|
|
268
|
+
* suggestions.
|
|
269
|
+
*
|
|
270
|
+
* **Single-step chains only, deliberately.** A suggestion is verified against
|
|
271
|
+
* the whole page, so for a scoped chain (`locator("#form").getByRole(…)`) a
|
|
272
|
+
* page-wide match could resolve outside the scope — a suggestion that does not
|
|
273
|
+
* work where the author is looking is worse than no suggestion. A RegExp query
|
|
274
|
+
* is skipped for the same reason: a pattern has no near miss to speak of.
|
|
275
|
+
*/
|
|
276
|
+
export function hintTargetFromChain(chain: Chain): HintTarget | undefined {
|
|
277
|
+
if (chain.steps.length !== 1) return undefined;
|
|
278
|
+
const step = chain.steps[0]!;
|
|
279
|
+
const textQuery = (v: string | RegExp): string | undefined =>
|
|
280
|
+
typeof v === "string" ? v : undefined;
|
|
281
|
+
switch (step.m) {
|
|
282
|
+
case "getByRole": {
|
|
283
|
+
const name = step.args[1]?.name;
|
|
284
|
+
if (name === undefined) return undefined; // Only a role: nothing to be near.
|
|
285
|
+
return { kind: "role", role: step.args[0], query: textQuery(name) };
|
|
286
|
+
}
|
|
287
|
+
case "getByText":
|
|
288
|
+
return { kind: "text", query: textQuery(step.args[0]) };
|
|
289
|
+
case "getByLabel":
|
|
290
|
+
return { kind: "label", query: textQuery(step.args[0]) };
|
|
291
|
+
case "getByPlaceholder":
|
|
292
|
+
return { kind: "placeholder", query: textQuery(step.args[0]) };
|
|
293
|
+
case "getByAltText":
|
|
294
|
+
return { kind: "altText", query: textQuery(step.args[0]) };
|
|
295
|
+
case "getByTitle":
|
|
296
|
+
return { kind: "title", query: textQuery(step.args[0]) };
|
|
297
|
+
case "getByTestId":
|
|
298
|
+
return { kind: "testId", query: step.args[0] };
|
|
299
|
+
default:
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
264
304
|
/** Short human label for a chain, e.g.
|
|
265
305
|
* `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
|
|
266
306
|
* Root first; narrowing steps appended in reading order. */
|
|
@@ -315,8 +355,17 @@ export interface LocatorProbe {
|
|
|
315
355
|
readonly label: string;
|
|
316
356
|
isVisible(): Promise<boolean>;
|
|
317
357
|
textContent(timeout?: number): Promise<string | null>;
|
|
358
|
+
innerText(timeout?: number): Promise<string>;
|
|
359
|
+
/** Every match's text — the array forms of `toHaveText`/`toContainText`,
|
|
360
|
+
* which are about a list of elements and so must not go through the
|
|
361
|
+
* single-element (strict) reads above. */
|
|
362
|
+
allTextContents(): Promise<string[]>;
|
|
363
|
+
allInnerTexts(): Promise<string[]>;
|
|
318
364
|
inputValue(timeout?: number): Promise<string>;
|
|
319
365
|
count(): Promise<number>;
|
|
366
|
+
/** The "did you mean…" block for a locator that matched nothing, or `""`.
|
|
367
|
+
* See locator-hints.ts. */
|
|
368
|
+
nearMiss(): Promise<string>;
|
|
320
369
|
isEnabled(timeout?: number): Promise<boolean>;
|
|
321
370
|
isChecked(timeout?: number): Promise<boolean>;
|
|
322
371
|
/** After the silent poll settles, emit the single settled browser step this
|
|
@@ -702,16 +751,26 @@ export function makeLocator(
|
|
|
702
751
|
const extend = (step: Step): Locator =>
|
|
703
752
|
makeLocator(backend, strategy, { steps: [...chain.steps, step] });
|
|
704
753
|
|
|
754
|
+
/** The "did you mean…" block for a locator that matched nothing. Empty
|
|
755
|
+
* for a chain that must not suggest (see {@link hintTargetFromChain}) and
|
|
756
|
+
* for a page that holds nothing close. */
|
|
757
|
+
const hintBlock = async (page: Page): Promise<string> => {
|
|
758
|
+
const target = hintTargetFromChain(chain);
|
|
759
|
+
if (!target) return "";
|
|
760
|
+
return formatHints(await nearMissHints(page, target));
|
|
761
|
+
};
|
|
762
|
+
|
|
705
763
|
// Every terminal op runs through this: playwright's actionability timeouts
|
|
706
764
|
// say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
|
|
707
765
|
// log, so they are rewritten into a sentence naming the element and its
|
|
708
|
-
// state (see locator-errors.ts)
|
|
709
|
-
//
|
|
710
|
-
|
|
766
|
+
// state (see locator-errors.ts), and a failure that found NO element asks
|
|
767
|
+
// the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
|
|
768
|
+
// carries the readable message too — not just the thrown error.
|
|
769
|
+
const readable = async <T>(page: Page, fn: () => Promise<T>): Promise<T> => {
|
|
711
770
|
try {
|
|
712
771
|
return await fn();
|
|
713
772
|
} catch (err) {
|
|
714
|
-
throw
|
|
773
|
+
throw await rewriteLocatorErrorWithHints(err, label, () => hintBlock(page));
|
|
715
774
|
}
|
|
716
775
|
};
|
|
717
776
|
|
|
@@ -722,7 +781,7 @@ export function makeLocator(
|
|
|
722
781
|
fn: (loc: PWLocator, page: Page) => Promise<T>,
|
|
723
782
|
): Promise<T> =>
|
|
724
783
|
backend.pageOp(action, { selector: label, ...fields }, (page) =>
|
|
725
|
-
readable(() => fn(lower(page, chain), page)),
|
|
784
|
+
readable(page, () => fn(lower(page, chain), page)),
|
|
726
785
|
);
|
|
727
786
|
|
|
728
787
|
// One recorded event whose fields the action itself finishes filling in:
|
|
@@ -735,7 +794,7 @@ export function makeLocator(
|
|
|
735
794
|
): Promise<T> => {
|
|
736
795
|
const rec: Partial<RecordableFields> = { selector: label };
|
|
737
796
|
return backend.pageOp(action, rec, (page) =>
|
|
738
|
-
readable(() => fn(lower(page, chain), rec)),
|
|
797
|
+
readable(page, () => fn(lower(page, chain), rec)),
|
|
739
798
|
);
|
|
740
799
|
};
|
|
741
800
|
|
|
@@ -748,7 +807,7 @@ export function makeLocator(
|
|
|
748
807
|
backend.pageOp(
|
|
749
808
|
action,
|
|
750
809
|
{ selector: label, ...fields },
|
|
751
|
-
(page) => readable(() => fn(lower(page, chain))),
|
|
810
|
+
(page) => readable(page, () => fn(lower(page, chain))),
|
|
752
811
|
{ wrap: true },
|
|
753
812
|
) as Promise<Wrapped<T>>;
|
|
754
813
|
|
|
@@ -757,8 +816,12 @@ export function makeLocator(
|
|
|
757
816
|
label,
|
|
758
817
|
isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
|
|
759
818
|
textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
|
|
819
|
+
innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
|
|
820
|
+
allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
|
|
821
|
+
allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
|
|
760
822
|
inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
|
|
761
823
|
count: () => backend.silentRead((page) => lower(page, chain).count()),
|
|
824
|
+
nearMiss: () => backend.silentRead((page) => hintBlock(page)),
|
|
762
825
|
isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
|
|
763
826
|
isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
|
|
764
827
|
settle: async (action, waitedMs, error, opts) => {
|
|
@@ -867,7 +930,7 @@ export function makeLocator(
|
|
|
867
930
|
backend.pageOp(
|
|
868
931
|
"evaluate",
|
|
869
932
|
{ selector: label, description },
|
|
870
|
-
(page) => readable(() => lower(page, chain).evaluate(fn as never, arg)),
|
|
933
|
+
(page) => readable(page, () => lower(page, chain).evaluate(fn as never, arg)),
|
|
871
934
|
{ wrap: true },
|
|
872
935
|
) as Promise<Wrapped<never>>,
|
|
873
936
|
|