@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.
- 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 +155 -35
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/harness/wrapper-rules.d.ts +149 -0
- package/dist/harness/wrapper-rules.js +422 -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 +171 -34
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/harness/wrapper-rules.test.ts +170 -0
- package/src/harness/wrapper-rules.ts +547 -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,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 {
|
|
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)
|
|
358
|
-
//
|
|
359
|
-
|
|
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
|
|
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;
|