@specific.dev/spectest 0.66.0 → 0.68.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.
Files changed (40) hide show
  1. package/dist/browser.js +42 -1
  2. package/dist/components/supabase.d.ts +0 -14
  3. package/dist/components/supabase.js +2 -8
  4. package/dist/daemon.js +155 -35
  5. package/dist/harness/intercept.d.ts +22 -0
  6. package/dist/harness/intercept.js +29 -0
  7. package/dist/harness/wrapper-rules.d.ts +149 -0
  8. package/dist/harness/wrapper-rules.js +422 -0
  9. package/dist/index.d.ts +52 -16
  10. package/dist/index.js +76 -17
  11. package/dist/locator-errors.d.ts +19 -10
  12. package/dist/locator-errors.js +80 -20
  13. package/dist/locator-hints.d.ts +96 -0
  14. package/dist/locator-hints.js +403 -0
  15. package/dist/locator.d.ts +22 -0
  16. package/dist/locator.js +63 -9
  17. package/dist/page-snapshot.d.ts +42 -0
  18. package/dist/page-snapshot.js +149 -0
  19. package/dist/recorder.d.ts +16 -0
  20. package/dist/text-match.d.ts +39 -0
  21. package/dist/text-match.js +239 -0
  22. package/package.json +1 -1
  23. package/src/browser.ts +43 -1
  24. package/src/components/supabase.ts +2 -20
  25. package/src/daemon.ts +171 -34
  26. package/src/harness/intercept.test.ts +36 -0
  27. package/src/harness/intercept.ts +40 -0
  28. package/src/harness/wrapper-rules.test.ts +170 -0
  29. package/src/harness/wrapper-rules.ts +547 -0
  30. package/src/index.ts +159 -32
  31. package/src/locator-errors.test.ts +99 -11
  32. package/src/locator-errors.ts +98 -19
  33. package/src/locator-hints.test.ts +188 -0
  34. package/src/locator-hints.ts +514 -0
  35. package/src/locator.ts +72 -9
  36. package/src/page-snapshot.test.ts +100 -0
  37. package/src/page-snapshot.ts +180 -0
  38. package/src/recorder.ts +16 -0
  39. package/src/text-match.test.ts +132 -0
  40. package/src/text-match.ts +285 -0
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ // The ranking and the snapshot parsing are pure, so they are tested here. The
4
+ // collection half needs a real browser, and was verified against one — every
5
+ // fixture below is captured from that run rather than imagined. To repeat it,
6
+ // write a script under `sdk/` (so it resolves this SDK's own playwright-core,
7
+ // not a newer one from bun's cache), `page.setContent(...)` a page with the
8
+ // cases you care about, and call `nearMissHints(page, target)` directly:
9
+ //
10
+ // cd sdk && LD_LIBRARY_PATH=/tmp/chromium-scratch/root/usr/lib/aarch64-linux-gnu \
11
+ // FONTCONFIG_FILE=/tmp/chromium-scratch/fonts.conf bun my-harness.ts
12
+ //
13
+ // (that library path is the no-sudo Chromium recipe this box already carries;
14
+ // `chromium.launch({ args: ["--no-sandbox"] })` then works.) Measured there:
15
+ // 3–20 ms per diagnostic, which is why it can run on every failed locator.
16
+
17
+ import {
18
+ buildSuggestions,
19
+ closeness,
20
+ EMPTY_DOM_FACTS,
21
+ parseAriaSnapshot,
22
+ suggestionLine,
23
+ type DomFacts,
24
+ } from "./locator-hints.js";
25
+
26
+ /** A page's accessibility tree exactly as `page.ariaSnapshot()` rendered it —
27
+ * captured from playwright-core 1.61.1 driving headless Chromium against the
28
+ * fixture page in this module's verification harness. Note the shapes a
29
+ * hand-written fixture would have missed: a node's own text after the name
30
+ * (`: Spara`), property lines (`/placeholder:`), state suffixes
31
+ * (`[selected]`) and unnamed nodes (`- text:`). */
32
+ const SNAPSHOT = `- banner:
33
+ - heading "Fakturor" [level=1]
34
+ - button "Spara ändringar": Spara
35
+ - main:
36
+ - combobox "Sortera":
37
+ - option "Datum" [selected]
38
+ - button "Ny faktura"
39
+ - button "Ta bort"
40
+ - button "Ta bort allt"
41
+ - textbox "Sök":
42
+ - /placeholder: Sök fakturor
43
+ - paragraph: "Summa: 15 000 kr"
44
+ - button "Skicka till kund": Skicka
45
+ - text: Skicka till kund`;
46
+
47
+ describe("parseAriaSnapshot", () => {
48
+ test("reads the role and accessible name off every named node", () => {
49
+ expect(parseAriaSnapshot(SNAPSHOT)).toEqual([
50
+ { role: "heading", name: "Fakturor" },
51
+ { role: "button", name: "Spara ändringar" },
52
+ { role: "combobox", name: "Sortera" },
53
+ { role: "option", name: "Datum" },
54
+ { role: "button", name: "Ny faktura" },
55
+ { role: "button", name: "Ta bort" },
56
+ { role: "button", name: "Ta bort allt" },
57
+ { role: "textbox", name: "Sök" },
58
+ { role: "button", name: "Skicka till kund" },
59
+ ]);
60
+ });
61
+
62
+ test("skips nodes with no name, and lines it does not recognise", () => {
63
+ // A container with no accessible name cannot be suggested by name, and a
64
+ // property line ("/url: …") is not a node at all.
65
+ expect(parseAriaSnapshot("- generic:\n - /url: /x\nnot yaml at all")).toEqual([]);
66
+ });
67
+
68
+ test("unescapes a quoted name", () => {
69
+ expect(parseAriaSnapshot('- button "Say \\"hi\\""')).toEqual([
70
+ { role: "button", name: 'Say "hi"' },
71
+ ]);
72
+ });
73
+ });
74
+
75
+ describe("closeness", () => {
76
+ test("the same string, whitespace and case aside, is the closest", () => {
77
+ expect(closeness("Spara", "spara")).toBe(4);
78
+ expect(closeness("15 000 kr", "15 000 kr")).toBe(4);
79
+ });
80
+
81
+ test("a name that carries a suffix scores above a typo", () => {
82
+ expect(closeness("Spara", "Spara ändringar")).toBeGreaterThan(
83
+ closeness("Spara", "Spraa"),
84
+ );
85
+ });
86
+
87
+ test("an unrelated name scores nothing", () => {
88
+ expect(closeness("Spara", "Avbryt")).toBe(0);
89
+ expect(closeness("Spara", "")).toBe(0);
90
+ });
91
+ });
92
+
93
+ describe("buildSuggestions", () => {
94
+ const aria = parseAriaSnapshot(SNAPSHOT);
95
+
96
+ test("a role query finds the same role under a longer name", () => {
97
+ const [first] = buildSuggestions(
98
+ { kind: "role", role: "button", query: "Spara" },
99
+ aria,
100
+ EMPTY_DOM_FACTS,
101
+ );
102
+ expect(first?.fact).toBe('button "Spara ändringar"');
103
+ expect(suggestionLine(first!, 1)).toBe(
104
+ ' - button "Spara ändringar" → getByRole("button", { name: "Spara ändringar" })',
105
+ );
106
+ });
107
+
108
+ test("a name under the wrong role says which role it really is", () => {
109
+ const lines = buildSuggestions(
110
+ { kind: "role", role: "button", query: "Sortera" },
111
+ aria,
112
+ EMPTY_DOM_FACTS,
113
+ );
114
+ expect(lines[0]?.fact).toBe('combobox "Sortera" — this is a combobox, not a button');
115
+ expect(lines[0]?.locator).toEqual({
116
+ m: "getByRole",
117
+ role: "combobox",
118
+ name: "Sortera",
119
+ });
120
+ });
121
+
122
+ test("an aria-label that hides the visible text is called out by name", () => {
123
+ const dom: DomFacts = {
124
+ ...EMPTY_DOM_FACTS,
125
+ mismatches: [
126
+ { tag: "button", text: "Spara", name: "Spara ändringar", from: "aria-label" },
127
+ ],
128
+ };
129
+ const lines = buildSuggestions(
130
+ { kind: "role", role: "button", query: "Spara" },
131
+ [],
132
+ dom,
133
+ );
134
+ expect(lines[0]?.fact).toBe(
135
+ '<button> shows "Spara" but its accessible name is "Spara ändringar" (from aria-label)',
136
+ );
137
+ expect(lines[0]?.locator).toEqual({
138
+ m: "getByRole",
139
+ role: "button",
140
+ name: "Spara ändringar",
141
+ });
142
+ });
143
+
144
+ test("a test id suggests the test ids the page really carries", () => {
145
+ const dom: DomFacts = { ...EMPTY_DOM_FACTS, testIds: ["add-todo", "remove-todo"] };
146
+ const lines = buildSuggestions({ kind: "testId", query: "add" }, [], dom);
147
+ expect(lines).toHaveLength(1);
148
+ expect(lines[0]?.locator).toEqual({ m: "getByTestId", id: "add-todo" });
149
+ });
150
+
151
+ test("a RegExp query suggests nothing — a pattern has no near miss", () => {
152
+ expect(buildSuggestions({ kind: "role", role: "button" }, aria, EMPTY_DOM_FACTS)).toEqual(
153
+ [],
154
+ );
155
+ });
156
+
157
+ test("nothing close means nothing is said", () => {
158
+ expect(
159
+ buildSuggestions(
160
+ { kind: "role", role: "button", query: "Ladda upp kvitto" },
161
+ aria,
162
+ EMPTY_DOM_FACTS,
163
+ ),
164
+ ).toEqual([]);
165
+ });
166
+
167
+ test("at most three lines, best first, one per proposed locator", () => {
168
+ const dom: DomFacts = {
169
+ ...EMPTY_DOM_FACTS,
170
+ texts: ["Faktura 1", "Faktura 2", "Faktura 3", "Faktura 4", "Faktura 5"],
171
+ };
172
+ const lines = buildSuggestions({ kind: "text", query: "Faktura 1" }, aria, dom);
173
+ expect(lines).toHaveLength(3);
174
+ expect(lines[0]?.locator).toEqual({ m: "getByText", text: "Faktura 1" });
175
+ expect(new Set(lines.map((l) => JSON.stringify(l.locator))).size).toBe(3);
176
+ });
177
+
178
+ test("the count rides the line when a suggestion is itself ambiguous", () => {
179
+ const [first] = buildSuggestions(
180
+ { kind: "role", role: "button", query: "Ta bort" },
181
+ aria,
182
+ EMPTY_DOM_FACTS,
183
+ );
184
+ expect(suggestionLine(first!, 4)).toBe(
185
+ ' - button "Ta bort" → getByRole("button", { name: "Ta bort" }) (matches 4 elements)',
186
+ );
187
+ });
188
+ });
@@ -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
+ }