@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,403 @@
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
+ import { escapeInvisible, normalizeWhiteSpace } from "./text-match.js";
28
+ export const EMPTY_DOM_FACTS = {
29
+ texts: [],
30
+ testIds: [],
31
+ placeholders: [],
32
+ labels: [],
33
+ titles: [],
34
+ alts: [],
35
+ mismatches: [],
36
+ };
37
+ // ── Similarity ────────────────────────────────────────────────────────────
38
+ /** Levenshtein distance, with an early exit once `max` is passed. */
39
+ function distance(a, b, max) {
40
+ if (Math.abs(a.length - b.length) > max)
41
+ return max + 1;
42
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
43
+ for (let i = 1; i <= a.length; i++) {
44
+ const cur = [i];
45
+ let best = i;
46
+ for (let j = 1; j <= b.length; j++) {
47
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
48
+ const v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
49
+ cur.push(v);
50
+ if (v < best)
51
+ best = v;
52
+ }
53
+ if (best > max)
54
+ return max + 1;
55
+ prev = cur;
56
+ }
57
+ return prev[b.length];
58
+ }
59
+ function key(s) {
60
+ return normalizeWhiteSpace(s).toLowerCase();
61
+ }
62
+ /**
63
+ * How close `candidate` is to what the author asked for, from 0 (unrelated)
64
+ * to 4 (the same string). Everything at or above `NEAR` is worth showing.
65
+ */
66
+ export function closeness(query, candidate) {
67
+ const q = key(query);
68
+ const c = key(candidate);
69
+ if (!q || !c)
70
+ return 0;
71
+ if (q === c)
72
+ return 4;
73
+ // A name that carries a suffix or a prefix ("Spara" vs "Spara ändringar")
74
+ // is the single most common near miss, and `{ exact: false }` or a
75
+ // substring query would already have matched it.
76
+ if (c.includes(q) || q.includes(c))
77
+ return 3;
78
+ const max = Math.max(2, Math.floor(Math.max(q.length, c.length) * 0.34));
79
+ const d = distance(q, c, max);
80
+ if (d > max)
81
+ return 0;
82
+ return 2 - d / (max + 1);
83
+ }
84
+ /** The score at which a candidate is close enough to show. */
85
+ const NEAR = 1;
86
+ /** How many lines an author can absorb below a failure. */
87
+ const MAX_SUGGESTIONS = 3;
88
+ // ── Ranking ───────────────────────────────────────────────────────────────
89
+ /** A page string, quoted for reading — with its invisible characters spelled
90
+ * out. A suggestion whose only difference from what the author typed is a
91
+ * no-break space would otherwise print as the very string that just failed. */
92
+ function quoted(s) {
93
+ return escapeInvisible(JSON.stringify(s));
94
+ }
95
+ function code(loc) {
96
+ switch (loc.m) {
97
+ case "getByRole":
98
+ return `getByRole(${quoted(loc.role)}, { name: ${quoted(loc.name)} })`;
99
+ case "getByTestId":
100
+ return `getByTestId(${quoted(loc.id)})`;
101
+ default:
102
+ return `${loc.m}(${quoted(loc.text)})`;
103
+ }
104
+ }
105
+ /** Render one verified suggestion as the line the author reads. */
106
+ export function suggestionLine(s, matches) {
107
+ const extra = matches > 1 ? ` (matches ${matches} elements)` : "";
108
+ return ` - ${s.fact} → ${code(s.locator)}${extra}`;
109
+ }
110
+ function byScore(a, b) {
111
+ return b.score - a.score;
112
+ }
113
+ /** Drop repeats of the same proposed locator, keeping the best-scoring one. */
114
+ function dedupe(items) {
115
+ const seen = new Map();
116
+ for (const s of items.slice().sort(byScore)) {
117
+ const k = code(s.locator);
118
+ if (!seen.has(k))
119
+ seen.set(k, s);
120
+ }
121
+ return [...seen.values()];
122
+ }
123
+ function fromValues(query, values, make, fact) {
124
+ const out = [];
125
+ for (const v of values) {
126
+ const score = closeness(query, v);
127
+ if (score >= NEAR)
128
+ out.push({ fact: fact(v), locator: make(v), score });
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Rank what the page holds against what the author asked for. Pure: the
134
+ * caller collects `aria`/`dom` and verifies the winners.
135
+ */
136
+ export function buildSuggestions(target, aria, dom) {
137
+ const q = target.query;
138
+ if (!q)
139
+ return [];
140
+ const out = [];
141
+ if (target.kind === "role") {
142
+ const role = target.role ?? "";
143
+ for (const c of aria) {
144
+ const score = closeness(q, c.name);
145
+ if (score < NEAR)
146
+ continue;
147
+ if (c.role === role) {
148
+ // Same role, near name: the everyday miss.
149
+ out.push({
150
+ fact: `${c.role} ${quoted(c.name)}`,
151
+ locator: { m: "getByRole", role: c.role, name: c.name },
152
+ score: score + 1,
153
+ });
154
+ }
155
+ else {
156
+ // The name is right and the role is not — a `combobox` styled as a
157
+ // button, a `link` that looks like one. Worth saying out loud,
158
+ // because the author's own reading of the page is what is wrong.
159
+ out.push({
160
+ fact: `${c.role} ${quoted(c.name)} — this is a ${c.role}, not a ${role}`,
161
+ locator: { m: "getByRole", role: c.role, name: c.name },
162
+ score,
163
+ });
164
+ }
165
+ }
166
+ // An element whose visible text is what the author typed, but whose
167
+ // accessible name — the thing `getByRole` matches — is something else.
168
+ for (const m of dom.mismatches) {
169
+ const score = closeness(q, m.text);
170
+ if (score < NEAR)
171
+ continue;
172
+ out.push({
173
+ fact: `<${m.tag}> shows ${quoted(m.text)} but its accessible name is ${quoted(m.name)} (from ${m.from})`,
174
+ locator: { m: "getByRole", role: role || "button", name: m.name },
175
+ score: score + 0.5,
176
+ });
177
+ }
178
+ }
179
+ if (target.kind === "text") {
180
+ out.push(...fromValues(q, dom.texts, (v) => ({ m: "getByText", text: v }), (v) => `text ${quoted(v)}`));
181
+ for (const c of aria) {
182
+ const score = closeness(q, c.name);
183
+ if (score >= NEAR) {
184
+ out.push({
185
+ fact: `${c.role} ${quoted(c.name)}`,
186
+ locator: { m: "getByRole", role: c.role, name: c.name },
187
+ score,
188
+ });
189
+ }
190
+ }
191
+ }
192
+ if (target.kind === "testId") {
193
+ out.push(...fromValues(q, dom.testIds, (v) => ({ m: "getByTestId", id: v }), (v) => `testid ${quoted(v)}`));
194
+ }
195
+ if (target.kind === "label") {
196
+ out.push(...fromValues(q, dom.labels, (v) => ({ m: "getByLabel", text: v }), (v) => `label ${quoted(v)}`));
197
+ }
198
+ if (target.kind === "placeholder") {
199
+ out.push(...fromValues(q, dom.placeholders, (v) => ({ m: "getByPlaceholder", text: v }), (v) => `placeholder ${quoted(v)}`));
200
+ }
201
+ if (target.kind === "title") {
202
+ out.push(...fromValues(q, dom.titles, (v) => ({ m: "getByTitle", text: v }), (v) => `title ${quoted(v)}`));
203
+ }
204
+ if (target.kind === "altText") {
205
+ out.push(...fromValues(q, dom.alts, (v) => ({ m: "getByAltText", text: v }), (v) => `alt ${quoted(v)}`));
206
+ }
207
+ return dedupe(out).sort(byScore).slice(0, MAX_SUGGESTIONS);
208
+ }
209
+ // ── Page-side collection ──────────────────────────────────────────────────
210
+ /**
211
+ * Pull `- button "Save"` lines out of an ARIA snapshot.
212
+ *
213
+ * The snapshot is YAML, but only its leading token carries what we need, so
214
+ * this reads it line by line rather than pulling in a parser. A line we do not
215
+ * recognise is skipped — the worst case is one fewer suggestion.
216
+ */
217
+ export function parseAriaSnapshot(yaml) {
218
+ const out = [];
219
+ const seen = new Set();
220
+ for (const raw of yaml.split("\n")) {
221
+ // `- role "name" [level=1]:` — the role is a bare word, the name a
222
+ // double-quoted string, and both the name and the trailing parts are
223
+ // optional. A quoted name may contain escaped quotes.
224
+ const m = /^\s*-\s+([a-zA-Z]+)(?:\s+"((?:[^"\\]|\\.)*)")?/.exec(raw);
225
+ if (!m)
226
+ continue;
227
+ const role = m[1];
228
+ if (m[2] === undefined)
229
+ continue; // No accessible name: nothing to match.
230
+ const name = m[2].replace(/\\(.)/g, "$1");
231
+ const k = `${role} "${name}"`;
232
+ if (seen.has(k))
233
+ continue;
234
+ seen.add(k);
235
+ out.push({ role, name });
236
+ if (out.length >= 400)
237
+ break;
238
+ }
239
+ return out;
240
+ }
241
+ /**
242
+ * Read the page facts the ARIA snapshot does not carry.
243
+ *
244
+ * Runs in the browser, so it must stay self-contained (no closure over
245
+ * anything here) and cheap: it walks a bounded number of elements and caps
246
+ * every list it fills.
247
+ */
248
+ /* c8 ignore start — executes in the browser, not under bun test */
249
+ function domFactsScript() {
250
+ const MAX_ELEMENTS = 4000;
251
+ const MAX_ITEMS = 200;
252
+ const norm = (s) => (s ?? "").replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ").slice(0, 120);
253
+ const push = (arr, v) => {
254
+ if (v && arr.length < MAX_ITEMS && !arr.includes(v))
255
+ arr.push(v);
256
+ };
257
+ const out = {
258
+ texts: [],
259
+ testIds: [],
260
+ placeholders: [],
261
+ labels: [],
262
+ titles: [],
263
+ alts: [],
264
+ mismatches: [],
265
+ };
266
+ const visible = (el) => {
267
+ const r = el.getBoundingClientRect();
268
+ if (r.width === 0 && r.height === 0)
269
+ return false;
270
+ const st = getComputedStyle(el);
271
+ return st.visibility !== "hidden" && st.display !== "none";
272
+ };
273
+ const INTERACTIVE = "a,button,summary,select,textarea,input,[role],[onclick],[tabindex]";
274
+ let n = 0;
275
+ for (const el of Array.from(document.querySelectorAll("*"))) {
276
+ if (++n > MAX_ELEMENTS)
277
+ break;
278
+ const tag = el.tagName.toLowerCase();
279
+ if (tag === "script" || tag === "style" || tag === "head")
280
+ continue;
281
+ push(out.testIds, norm(el.getAttribute("data-testid")));
282
+ push(out.placeholders, norm(el.getAttribute("placeholder")));
283
+ push(out.titles, norm(el.getAttribute("title")));
284
+ push(out.alts, norm(el.getAttribute("alt")));
285
+ const ariaLabel = norm(el.getAttribute("aria-label"));
286
+ push(out.labels, ariaLabel);
287
+ if (tag === "label")
288
+ push(out.labels, norm(el.innerText));
289
+ if (el.children.length === 0 && visible(el)) {
290
+ push(out.texts, norm(el.innerText || el.textContent));
291
+ }
292
+ if (!el.matches(INTERACTIVE) || !visible(el))
293
+ continue;
294
+ const text = norm(el.innerText || el.textContent);
295
+ let name = ariaLabel;
296
+ let from = "aria-label";
297
+ if (!name) {
298
+ const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).filter(Boolean);
299
+ const parts = [];
300
+ for (const id of ids) {
301
+ const ref = document.getElementById(id);
302
+ if (ref)
303
+ parts.push(norm(ref.innerText || ref.textContent));
304
+ }
305
+ if (parts.length) {
306
+ name = norm(parts.join(" "));
307
+ from = "aria-labelledby";
308
+ }
309
+ }
310
+ if (!name && el.getAttribute("title")) {
311
+ name = norm(el.getAttribute("title"));
312
+ from = "title";
313
+ }
314
+ if (!name && tag === "input" && el.getAttribute("value")) {
315
+ name = norm(el.getAttribute("value"));
316
+ from = "value";
317
+ }
318
+ if (name && text && name !== text && out.mismatches.length < 50) {
319
+ out.mismatches.push({ tag, text, name, from });
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+ /* c8 ignore stop */
325
+ /** Build the playwright locator a suggestion proposes, for verification. */
326
+ function resolve(page, loc) {
327
+ switch (loc.m) {
328
+ case "getByRole":
329
+ return page.getByRole(loc.role, {
330
+ name: loc.name,
331
+ exact: true,
332
+ });
333
+ case "getByTestId":
334
+ return page.getByTestId(loc.id);
335
+ case "getByText":
336
+ return page.getByText(loc.text, { exact: true });
337
+ case "getByLabel":
338
+ return page.getByLabel(loc.text, { exact: true });
339
+ case "getByPlaceholder":
340
+ return page.getByPlaceholder(loc.text, { exact: true });
341
+ case "getByAltText":
342
+ return page.getByAltText(loc.text, { exact: true });
343
+ case "getByTitle":
344
+ return page.getByTitle(loc.text, { exact: true });
345
+ }
346
+ }
347
+ /** How long the whole diagnostic may take. It runs after a failure that has
348
+ * already waited seconds, so a short budget costs nothing and a hung page
349
+ * cannot make a failing test hang. */
350
+ const HINT_BUDGET_MS = 2_000;
351
+ async function withBudget(work, fallback) {
352
+ let timer;
353
+ try {
354
+ return await Promise.race([
355
+ work,
356
+ new Promise((resolve) => {
357
+ timer = setTimeout(() => resolve(fallback), HINT_BUDGET_MS);
358
+ }),
359
+ ]);
360
+ }
361
+ catch {
362
+ return fallback;
363
+ }
364
+ finally {
365
+ if (timer)
366
+ clearTimeout(timer);
367
+ }
368
+ }
369
+ /**
370
+ * The lines to append to a "no element matches" failure — at most
371
+ * {@link MAX_SUGGESTIONS}, each verified against the live page, and empty
372
+ * whenever the page holds nothing close.
373
+ */
374
+ export async function nearMissHints(page, target) {
375
+ if (!target.query)
376
+ return [];
377
+ const collected = await withBudget((async () => {
378
+ const [snapshot, dom] = await Promise.all([
379
+ page.ariaSnapshot().catch(() => ""),
380
+ page.evaluate(domFactsScript).catch(() => EMPTY_DOM_FACTS),
381
+ ]);
382
+ return { aria: parseAriaSnapshot(snapshot), dom };
383
+ })(), { aria: [], dom: EMPTY_DOM_FACTS });
384
+ const suggestions = buildSuggestions(target, collected.aria, collected.dom);
385
+ if (!suggestions.length)
386
+ return [];
387
+ // Verify before printing. A suggestion nobody can use is worse than none:
388
+ // it sends the author to try a locator we invented.
389
+ const lines = await withBudget(Promise.all(suggestions.map(async (s) => {
390
+ const matches = await resolve(page, s.locator)
391
+ .count()
392
+ .catch(() => 0);
393
+ return matches > 0 ? suggestionLine(s, matches) : undefined;
394
+ })), []);
395
+ return lines.filter((l) => l !== undefined);
396
+ }
397
+ /** The block appended under a failure sentence, or `""` when there is
398
+ * nothing to add. */
399
+ export function formatHints(lines) {
400
+ if (!lines.length)
401
+ return "";
402
+ return `\nClose matches on the page:\n${lines.join("\n")}`;
403
+ }
package/dist/locator.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Page, Locator as PWLocator } from "playwright-core";
2
2
  import type { RecordableFields } from "./browser.js";
3
3
  import type { Wrapped } from "./inspect.js";
4
+ import { type HintTarget } from "./locator-hints.js";
4
5
  /** Default deadline for a locator action/read's target to become actionable.
5
6
  * Playwright's own default is 30s — far too slow-failing for tests; 5s
6
7
  * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
@@ -115,6 +116,18 @@ export declare function isLocator(x: unknown): x is Locator;
115
116
  * Nested spectest locators (`filter({has})`, `and`, `or`) are lowered
116
117
  * recursively against the same page. */
117
118
  export declare function lower(page: Page, chain: Chain): PWLocator;
119
+ /**
120
+ * What a chain asked for, in the terms the near-miss diagnostic reasons in
121
+ * (see locator-hints.ts). `undefined` when this chain must not produce
122
+ * suggestions.
123
+ *
124
+ * **Single-step chains only, deliberately.** A suggestion is verified against
125
+ * the whole page, so for a scoped chain (`locator("#form").getByRole(…)`) a
126
+ * page-wide match could resolve outside the scope — a suggestion that does not
127
+ * work where the author is looking is worse than no suggestion. A RegExp query
128
+ * is skipped for the same reason: a pattern has no near miss to speak of.
129
+ */
130
+ export declare function hintTargetFromChain(chain: Chain): HintTarget | undefined;
118
131
  /** Short human label for a chain, e.g.
119
132
  * `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
120
133
  * Root first; narrowing steps appended in reading order. */
@@ -143,8 +156,17 @@ export interface LocatorProbe {
143
156
  readonly label: string;
144
157
  isVisible(): Promise<boolean>;
145
158
  textContent(timeout?: number): Promise<string | null>;
159
+ innerText(timeout?: number): Promise<string>;
160
+ /** Every match's text — the array forms of `toHaveText`/`toContainText`,
161
+ * which are about a list of elements and so must not go through the
162
+ * single-element (strict) reads above. */
163
+ allTextContents(): Promise<string[]>;
164
+ allInnerTexts(): Promise<string[]>;
146
165
  inputValue(timeout?: number): Promise<string>;
147
166
  count(): Promise<number>;
167
+ /** The "did you mean…" block for a locator that matched nothing, or `""`.
168
+ * See locator-hints.ts. */
169
+ nearMiss(): Promise<string>;
148
170
  isEnabled(timeout?: number): Promise<boolean>;
149
171
  isChecked(timeout?: number): Promise<boolean>;
150
172
  /** After the silent poll settles, emit the single settled browser step this
package/dist/locator.js CHANGED
@@ -22,7 +22,8 @@
22
22
  // author-facing call is exactly one recorded browser event (with its rrweb
23
23
  // drain), whatever playwright work it composes underneath.
24
24
  import { Buffer } from "node:buffer";
25
- import { rewriteLocatorError } from "./locator-errors.js";
25
+ import { rewriteLocatorErrorWithHints } from "./locator-errors.js";
26
+ import { formatHints, nearMissHints } from "./locator-hints.js";
26
27
  import { resolveExistingProjectPath } from "./project-files.js";
27
28
  import { truncateUtf8 } from "./recorder.js";
28
29
  /** Default deadline for a locator action/read's target to become actionable.
@@ -149,6 +150,45 @@ function stepLabel(step) {
149
150
  return `.nth(${step.args[0]})`;
150
151
  }
151
152
  }
153
+ /**
154
+ * What a chain asked for, in the terms the near-miss diagnostic reasons in
155
+ * (see locator-hints.ts). `undefined` when this chain must not produce
156
+ * suggestions.
157
+ *
158
+ * **Single-step chains only, deliberately.** A suggestion is verified against
159
+ * the whole page, so for a scoped chain (`locator("#form").getByRole(…)`) a
160
+ * page-wide match could resolve outside the scope — a suggestion that does not
161
+ * work where the author is looking is worse than no suggestion. A RegExp query
162
+ * is skipped for the same reason: a pattern has no near miss to speak of.
163
+ */
164
+ export function hintTargetFromChain(chain) {
165
+ if (chain.steps.length !== 1)
166
+ return undefined;
167
+ const step = chain.steps[0];
168
+ const textQuery = (v) => typeof v === "string" ? v : undefined;
169
+ switch (step.m) {
170
+ case "getByRole": {
171
+ const name = step.args[1]?.name;
172
+ if (name === undefined)
173
+ return undefined; // Only a role: nothing to be near.
174
+ return { kind: "role", role: step.args[0], query: textQuery(name) };
175
+ }
176
+ case "getByText":
177
+ return { kind: "text", query: textQuery(step.args[0]) };
178
+ case "getByLabel":
179
+ return { kind: "label", query: textQuery(step.args[0]) };
180
+ case "getByPlaceholder":
181
+ return { kind: "placeholder", query: textQuery(step.args[0]) };
182
+ case "getByAltText":
183
+ return { kind: "altText", query: textQuery(step.args[0]) };
184
+ case "getByTitle":
185
+ return { kind: "title", query: textQuery(step.args[0]) };
186
+ case "getByTestId":
187
+ return { kind: "testId", query: step.args[0] };
188
+ default:
189
+ return undefined;
190
+ }
191
+ }
152
192
  /** Short human label for a chain, e.g.
153
193
  * `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
154
194
  * Root first; narrowing steps appended in reading order. */
@@ -351,38 +391,52 @@ function lowerInputFiles(files) {
351
391
  export function makeLocator(backend, strategy, chain) {
352
392
  const label = chainLabel(chain);
353
393
  const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
394
+ /** The "did you mean…" block for a locator that matched nothing. Empty
395
+ * for a chain that must not suggest (see {@link hintTargetFromChain}) and
396
+ * for a page that holds nothing close. */
397
+ const hintBlock = async (page) => {
398
+ const target = hintTargetFromChain(chain);
399
+ if (!target)
400
+ return "";
401
+ return formatHints(await nearMissHints(page, target));
402
+ };
354
403
  // Every terminal op runs through this: playwright's actionability timeouts
355
404
  // say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
356
405
  // log, so they are rewritten into a sentence naming the element and its
357
- // state (see locator-errors.ts). Applied INSIDE `pageOp`, so the recorded
358
- // step carries the readable message too — not just the thrown error.
359
- const readable = async (fn) => {
406
+ // state (see locator-errors.ts), and a failure that found NO element asks
407
+ // the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
408
+ // carries the readable message too — not just the thrown error.
409
+ const readable = async (page, fn) => {
360
410
  try {
361
411
  return await fn();
362
412
  }
363
413
  catch (err) {
364
- throw rewriteLocatorError(err, label);
414
+ throw await rewriteLocatorErrorWithHints(err, label, () => hintBlock(page));
365
415
  }
366
416
  };
367
417
  // One recorded event, result NOT wrapped (void/action).
368
- const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain), page)));
418
+ const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(page, () => fn(lower(page, chain), page)));
369
419
  // One recorded event whose fields the action itself finishes filling in:
370
420
  // `rec` is the very object the recorder spreads once `fn` resolves (the
371
421
  // same mutate-in-flight seam `screenshot`'s artifact id rides), so the
372
422
  // click family can stamp the point it acted on. See stampActionPoint.
373
423
  const actAt = (action, fn) => {
374
424
  const rec = { selector: label };
375
- return backend.pageOp(action, rec, (page) => readable(() => fn(lower(page, chain), rec)));
425
+ return backend.pageOp(action, rec, (page) => readable(page, () => fn(lower(page, chain), rec)));
376
426
  };
377
427
  // One recorded event, result provenance-wrapped for expect().
378
- const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain))), { wrap: true });
428
+ const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(page, () => fn(lower(page, chain))), { wrap: true });
379
429
  // Silent, non-recorded reads for expect(locator) matchers to poll.
380
430
  const probe = {
381
431
  label,
382
432
  isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
383
433
  textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
434
+ innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
435
+ allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
436
+ allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
384
437
  inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
385
438
  count: () => backend.silentRead((page) => lower(page, chain).count()),
439
+ nearMiss: () => backend.silentRead((page) => hintBlock(page)),
386
440
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
387
441
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
388
442
  settle: async (action, waitedMs, error, opts) => {
@@ -471,7 +525,7 @@ export function makeLocator(backend, strategy, chain) {
471
525
  out.push(extend({ m: "nth", args: [i] }));
472
526
  return out;
473
527
  },
474
- evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => readable(() => lower(page, chain).evaluate(fn, arg)), { wrap: true }),
528
+ evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => readable(page, () => lower(page, chain).evaluate(fn, arg)), { wrap: true }),
475
529
  waitFor: (opts) => act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
476
530
  };
477
531
  // Attach the silent-read probe under its symbol (kept off the typed literal
@@ -0,0 +1,42 @@
1
+ import type { Page } from "playwright-core";
2
+ /** One child frame's tree. The main frame's tree leads the document. */
3
+ export interface FrameStructure {
4
+ /** The frame's `name` attribute, or `""` when it has none. */
5
+ name: string;
6
+ url: string;
7
+ tree: string;
8
+ }
9
+ /** Everything the document is rendered from. Split out from the capture so the
10
+ * formatting is testable without a browser. */
11
+ export interface PageStructure {
12
+ /** The session's name — `ctx.browser("alice")` — or `""` for the default. */
13
+ session: string;
14
+ kind: "browser" | "mobile";
15
+ /** The step that failed (`click`, `goto`, `toBeVisible`, …). */
16
+ action: string;
17
+ /** Its failure message; only the first line is kept. */
18
+ error: string;
19
+ url: string;
20
+ title: string;
21
+ tree: string;
22
+ frames: FrameStructure[];
23
+ }
24
+ /** The document is a debugging aid, and a pathological page must not grow the
25
+ * `/run` reply (the vm-agent caps a proxied daemon response at 16 MB). Real
26
+ * pages are far below this: the biggest thing measured while building it was
27
+ * 38 KB. */
28
+ export declare const MAX_DOCUMENT_BYTES: number;
29
+ /**
30
+ * Capture the page behind a failed step, or `undefined` when there is nothing
31
+ * to show.
32
+ *
33
+ * Everything here is best-effort: a page that is closed, navigating or wedged
34
+ * yields a partial record rather than an error, because the caller is already
35
+ * reporting a failure and must not report this one instead.
36
+ */
37
+ export declare function capturePageStructure(page: Page, meta: Pick<PageStructure, "session" | "kind" | "action" | "error">): Promise<string | undefined>;
38
+ /** Render the document. Pure, so its shape is testable without a browser. */
39
+ export declare function formatPageStructure(s: PageStructure): string;
40
+ /** Cut the document to {@link MAX_DOCUMENT_BYTES}, on a line boundary, and say
41
+ * so — a silently short tree reads as a page that ends there. */
42
+ export declare function truncate(doc: string): string;