@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.
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { formatWaited, locatorFailureMessage, rewriteLocatorError } from "./locator-errors.js";
3
+ import {
4
+ classifyLocatorFailure,
5
+ formatWaited,
6
+ locatorFailureMessage,
7
+ rewriteLocatorErrorWithHints,
8
+ } from "./locator-errors.js";
4
9
 
5
10
  /** A playwright actionability timeout, shaped exactly as playwright-core
6
11
  * 1.61 raises one: `name`, the message with the call log appended, the same
@@ -83,22 +88,105 @@ describe("locatorFailureMessage", () => {
83
88
  );
84
89
  });
85
90
 
86
- test("leaves errors that are not locator timeouts alone", () => {
87
- const strict = new Error(
88
- "click: Error: strict mode violation: locator('p') resolved to 2 elements",
89
- );
90
- expect(locatorFailureMessage('css "p"', strict)).toBeUndefined();
91
+ test("leaves errors that are neither a timeout nor a strict violation alone", () => {
91
92
  expect(locatorFailureMessage('css "p"', new Error("page closed"))).toBeUndefined();
92
93
  expect(locatorFailureMessage('css "p"', "not an error at all")).toBeUndefined();
93
94
  });
95
+
96
+ test("an ambiguous locator says how to narrow it, and lists the matches", () => {
97
+ // Playwright's own shape: the count, then one numbered line per match.
98
+ const strict = new Error(
99
+ "click: Error: strict mode violation: getByRole('button') resolved to 2 elements:\n" +
100
+ " 1) <button>Save</button> aka getByRole('button', { name: 'Save' })\n" +
101
+ " 2) <button>Save all</button> aka getByRole('button', { name: 'Save all' })\n",
102
+ );
103
+ expect(locatorFailureMessage("role button", strict)).toBe(
104
+ "role button matches 2 elements, so it is ambiguous — narrow it with .first(), " +
105
+ ".nth(i), .filter({ hasText }) or a more specific query:\n" +
106
+ " - <button>Save</button> aka getByRole('button', { name: 'Save' })\n" +
107
+ " - <button>Save all</button> aka getByRole('button', { name: 'Save all' })",
108
+ );
109
+ });
110
+
111
+ test("an ambiguous locator with no listed matches still says what to do", () => {
112
+ const strict = new Error("strict mode violation: locator('p') resolved to 7 elements");
113
+ expect(locatorFailureMessage('css "p"', strict)).toBe(
114
+ 'css "p" matches 7 elements, so it is ambiguous — narrow it with .first(), ' +
115
+ ".nth(i), .filter({ hasText }) or a more specific query",
116
+ );
117
+ });
118
+ });
119
+
120
+ describe("classifyLocatorFailure", () => {
121
+ test("only a locator that matched nothing is worth asking the page about", () => {
122
+ const missing = timeoutError("click: Timeout 5000ms exceeded.", [
123
+ " - waiting for getByTestId('add')",
124
+ ]);
125
+ expect(classifyLocatorFailure('testid "add"', missing)?.kind).toBe("no-match");
126
+
127
+ const disabled = timeoutError("click: Timeout 5000ms exceeded.", [
128
+ " - waiting for locator('#dis')",
129
+ ' - locator resolved to <button id="dis" disabled>Nope</button>',
130
+ " - element is not enabled",
131
+ ]);
132
+ expect(classifyLocatorFailure('css "#dis"', disabled)?.kind).toBe("state");
133
+
134
+ const strict = new Error("strict mode violation: locator('p') resolved to 2 elements");
135
+ expect(classifyLocatorFailure('css "p"', strict)?.kind).toBe("strict");
136
+ });
137
+ });
138
+
139
+ describe("rewriteLocatorErrorWithHints", () => {
140
+ const missing = (): Error =>
141
+ timeoutError("click: Timeout 5000ms exceeded.", [" - waiting for getByTestId('add')"]);
142
+
143
+ test("appends the near-miss block to a locator that matched nothing", async () => {
144
+ const err = (await rewriteLocatorErrorWithHints(missing(), 'testid "add"', async () =>
145
+ "\nClose matches on the page:\n - testid \"add-todo\"",
146
+ )) as Error;
147
+ expect(err.message).toBe(
148
+ 'No element matches testid "add" (waited 5s)\n' +
149
+ 'Close matches on the page:\n - testid "add-todo"',
150
+ );
151
+ // The stack's copy of the message is rewritten too, or the CLI would
152
+ // print the friendly sentence and the raw timeout under it.
153
+ expect(err.stack).toContain('Close matches on the page');
154
+ expect(err.stack).not.toContain("Timeout 5000ms exceeded");
155
+ });
156
+
157
+ test("never asks the page about a failure it could not explain", async () => {
158
+ const disabled = timeoutError("click: Timeout 5000ms exceeded.", [
159
+ " - waiting for locator('#dis')",
160
+ ' - locator resolved to <button id="dis" disabled>Nope</button>',
161
+ " - element is not enabled",
162
+ ]);
163
+ let asked = false;
164
+ const err = (await rewriteLocatorErrorWithHints(disabled, 'css "#dis"', async () => {
165
+ asked = true;
166
+ return "should not appear";
167
+ })) as Error;
168
+ expect(asked).toBe(false);
169
+ expect(err.message).toBe('Element css "#dis" is not enabled (waited 5s)');
170
+ });
171
+
172
+ test("a diagnostic that throws leaves the failure standing", async () => {
173
+ const err = (await rewriteLocatorErrorWithHints(missing(), 'testid "add"', async () => {
174
+ throw new Error("page closed");
175
+ })) as Error;
176
+ expect(err.message).toBe('No element matches testid "add" (waited 5s)');
177
+ });
94
178
  });
95
179
 
96
- describe("rewriteLocatorError", () => {
97
- test("rewrites the message AND the copy embedded in the stack", () => {
180
+ describe("rewriteLocatorErrorWithHints (stack)", () => {
181
+ test("rewrites the message AND the copy embedded in the stack", async () => {
98
182
  const err = timeoutError("click: Timeout 5000ms exceeded.", [
99
183
  " - waiting for getByTestId('add')",
100
184
  ]);
101
- const out = rewriteLocatorError(err, 'testid "add"') as Error;
185
+ const out = (await rewriteLocatorErrorWithHints(
186
+ err,
187
+ 'testid "add"',
188
+ async () => "",
189
+ )) as Error;
102
190
 
103
191
  expect(out).toBe(err); // same error: the author's own frames survive
104
192
  expect(out.message).toBe('No element matches testid "add" (waited 5s)');
@@ -107,9 +195,9 @@ describe("rewriteLocatorError", () => {
107
195
  expect(out.stack).toContain("todo.ts:12:34");
108
196
  });
109
197
 
110
- test("hands back anything it does not understand untouched", () => {
198
+ test("hands back anything it does not understand untouched", async () => {
111
199
  const err = new Error("page closed");
112
- expect(rewriteLocatorError(err, 'css "p"')).toBe(err);
200
+ expect(await rewriteLocatorErrorWithHints(err, 'css "p"', async () => "")).toBe(err);
113
201
  expect(err.message).toBe("page closed");
114
202
  });
115
203
  });
@@ -8,10 +8,15 @@
8
8
  // as an infrastructure problem and says nothing about the page.
9
9
  //
10
10
  // This module turns those into one sentence that names the element and what
11
- // was wrong with it. Anything it does not recognise (a strict-mode violation,
12
- // a closed page, an assertion of ours) is passed through untouched — the rule
13
- // is that a message is only ever replaced when we have something better to
14
- // say.
11
+ // was wrong with it. A strict-mode violation — the opposite failure, where the
12
+ // locator matched too much — is rewritten too, keeping playwright's own list
13
+ // of matches, which is the useful half of it. Anything else (a closed page, an
14
+ // assertion of ours) is passed through untouched: the rule is that a message is
15
+ // only ever replaced when we have something better to say.
16
+ //
17
+ // `classifyLocatorFailure` also says WHICH failure it was, because only one of
18
+ // them — a locator that matched nothing — can be explained further by asking
19
+ // the page what it holds (see locator-hints.ts).
15
20
  //
16
21
  // The call log is read from the error's own `log` array (playwright attaches
17
22
  // it) and falls back to parsing the message, since only the message survives
@@ -92,38 +97,87 @@ function actionabilityReason(log: string[]): string | undefined {
92
97
  return undefined;
93
98
  }
94
99
 
100
+ /** How a locator failed. `no-match` is the only kind worth asking the page
101
+ * about (see locator-hints.ts) — for every other kind the element was found
102
+ * and near misses would be noise. */
103
+ export type LocatorFailureKind = "no-match" | "state" | "strict";
104
+
105
+ export interface LocatorFailure {
106
+ message: string;
107
+ kind: LocatorFailureKind;
108
+ }
109
+
110
+ /** Playwright's strict-mode violation: the locator matched more than one
111
+ * element and refused to act. It is an ordinary `Error`, not a
112
+ * `TimeoutError`, and its message already lists the matches — which is the
113
+ * useful half, so it is kept and only the wall of text around it is cut. */
114
+ function parseStrictViolation(err: unknown, label: string): LocatorFailure | undefined {
115
+ const e = err as { message?: unknown } | null;
116
+ if (!e || typeof e.message !== "string") return undefined;
117
+ if (!e.message.includes("strict mode violation")) return undefined;
118
+ const m = /resolved to (\d+) elements/.exec(e.message);
119
+ if (!m) return undefined;
120
+ const matches = e.message
121
+ .split("\n")
122
+ .filter((l) => /^\s*\d+\)\s/.test(l))
123
+ .slice(0, 3)
124
+ .map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
125
+ const head =
126
+ `${label} matches ${m[1]} elements, so it is ambiguous` +
127
+ " — narrow it with .first(), .nth(i), .filter({ hasText }) or a more specific query";
128
+ return {
129
+ kind: "strict",
130
+ message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
131
+ };
132
+ }
133
+
95
134
  /**
96
- * The sentence to fail with, or `undefined` when `err` is not a locator
97
- * timeout we understand (in which case the caller must leave it alone).
135
+ * What to fail with, or `undefined` when `err` is not a locator failure we
136
+ * understand (in which case the caller must leave it alone).
98
137
  *
99
138
  * `label` is the locator's human chain label, the same string the timeline
100
139
  * step shows.
101
140
  */
102
- export function locatorFailureMessage(label: string, err: unknown): string | undefined {
141
+ export function classifyLocatorFailure(
142
+ label: string,
143
+ err: unknown,
144
+ ): LocatorFailure | undefined {
145
+ const strict = parseStrictViolation(err, label);
146
+ if (strict) return strict;
103
147
  const detail = parseTimeout(err);
104
148
  if (!detail) return undefined;
105
149
  const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
106
- if (!resolved(detail.log)) return `No element matches ${label} ${waited}`;
150
+ if (!resolved(detail.log)) {
151
+ return { kind: "no-match", message: `No element matches ${label} ${waited}` };
152
+ }
107
153
 
108
154
  // It matched, so the failure is about the element's state.
155
+ const state = (message: string): LocatorFailure => ({ kind: "state", message });
109
156
  switch (waitedForState(detail.log)) {
110
157
  case "hidden":
111
- return `Element ${label} is still visible ${waited}`;
158
+ return state(`Element ${label} is still visible ${waited}`);
112
159
  case "detached":
113
- return `Element ${label} is still attached to the page ${waited}`;
160
+ return state(`Element ${label} is still attached to the page ${waited}`);
114
161
  case "visible":
115
- return `Element ${label} is not visible ${waited}`;
162
+ return state(`Element ${label} is not visible ${waited}`);
116
163
  default:
117
164
  break;
118
165
  }
119
166
  const reason = actionabilityReason(detail.log);
120
- return reason
121
- ? `Element ${label} ${reason} ${waited}`
122
- : `Element ${label} never became ready for this action ${waited}`;
167
+ return state(
168
+ reason
169
+ ? `Element ${label} ${reason} ${waited}`
170
+ : `Element ${label} never became ready for this action ${waited}`,
171
+ );
172
+ }
173
+
174
+ /** The sentence alone. See {@link classifyLocatorFailure}. */
175
+ export function locatorFailureMessage(label: string, err: unknown): string | undefined {
176
+ return classifyLocatorFailure(label, err)?.message;
123
177
  }
124
178
 
125
179
  /**
126
- * Replace a locator timeout's message in place and hand the error back, so the
180
+ * Replace a locator failure's message in place and hand the error back, so the
127
181
  * caller can `throw` it unchanged in every other respect.
128
182
  *
129
183
  * The error is mutated rather than wrapped: its stack holds the frames of the
@@ -132,10 +186,8 @@ export function locatorFailureMessage(label: string, err: unknown): string | und
132
186
  * too — otherwise the CLI's failure block would print the friendly message and
133
187
  * then the timeout wall right under it.
134
188
  */
135
- export function rewriteLocatorError(err: unknown, label: string): unknown {
136
- const message = locatorFailureMessage(label, err);
137
- if (message === undefined) return err;
138
- const e = err as Error;
189
+ /** Put `message` on `err`, in the message and in the stack's copy of it. */
190
+ function setErrorMessage(e: Error, message: string): Error {
139
191
  const old = e.message;
140
192
  if (typeof e.stack === "string") {
141
193
  for (const [header, replacement] of [
@@ -151,3 +203,30 @@ export function rewriteLocatorError(err: unknown, label: string): unknown {
151
203
  e.message = message;
152
204
  return e;
153
205
  }
206
+
207
+ /**
208
+ * The rewrite above, plus the near-miss block for the one failure that can
209
+ * carry one — a locator that matched nothing.
210
+ *
211
+ * `hints` is only called in that case, so a page query is never made for a
212
+ * failure it could not explain (a disabled element, a covered one, an
213
+ * ambiguous match). It is expected to be best-effort itself; anything it
214
+ * throws leaves the plain sentence standing.
215
+ */
216
+ export async function rewriteLocatorErrorWithHints(
217
+ err: unknown,
218
+ label: string,
219
+ hints: () => Promise<string>,
220
+ ): Promise<unknown> {
221
+ const failure = classifyLocatorFailure(label, err);
222
+ if (!failure) return err;
223
+ let message = failure.message;
224
+ if (failure.kind === "no-match") {
225
+ try {
226
+ message += await hints();
227
+ } catch {
228
+ /* A diagnostic must never replace the failure it explains. */
229
+ }
230
+ }
231
+ return setErrorMessage(err as Error, message);
232
+ }
@@ -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
+ });