@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.
@@ -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 { rewriteLocatorError } from "./locator-errors.js";
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). Applied INSIDE `pageOp`, so the recorded
709
- // step carries the readable message too — not just the thrown error.
710
- const readable = async <T>(fn: () => Promise<T>): Promise<T> => {
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 rewriteLocatorError(err, label);
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