@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
package/src/index.ts CHANGED
@@ -116,7 +116,20 @@ import {
116
116
  getBrowserProbe,
117
117
  DEFAULT_ACTION_TIMEOUT_MS,
118
118
  } from "./locator.js";
119
- import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
119
+ import {
120
+ classifyLocatorFailure,
121
+ formatWaited,
122
+ locatorFailureMessage,
123
+ } from "./locator-errors.js";
124
+ import {
125
+ containsText,
126
+ containsTextArray,
127
+ escapeInvisible,
128
+ matchesText,
129
+ matchesTextArray,
130
+ textDifferenceNote,
131
+ type TextMatchOptions,
132
+ } from "./text-match.js";
120
133
 
121
134
  export type { UrlPattern } from "./url-match.js";
122
135
  import type { UrlPattern } from "./url-match.js";
@@ -182,7 +195,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
182
195
  */
183
196
  export interface Interception {
184
197
  hostname: string;
185
- /** The normalised mount path (`"/"` when none was given). */
198
+ /** The normalised mount path parsed off the target (`"/"` when the
199
+ * target was a bare hostname). */
186
200
  path: string;
187
201
  /** How many requests the interceptor has seen. Provenance-wrapped, so an
188
202
  * `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
@@ -545,12 +559,23 @@ export interface SpectestContext<
545
559
  * make your **own** backend misbehave for one test: force a 500 from an
546
560
  * API route, add latency, fail twice then pass, or just count calls.
547
561
  *
562
+ * `target` is the hostname, optionally with a mount path:
563
+ * `"app.test"` claims every request to that host, `"app.test/api/sync"`
564
+ * claims that path and everything below it (like `app.use(path, fn)`).
565
+ *
566
+ * `description` is what the environment now **does**, in the present
567
+ * tense — it is the step's whole title in the timeline and in the CLI's
568
+ * failure detail, so it is what tells a reader why the page under test
569
+ * went to its error state. Write the effect, not the act of intercepting
570
+ * (the step is already labelled INTERCEPT) and not the test's goal:
571
+ * `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
572
+ * API"` or `"test error handling"`. The handler's own source is shown
573
+ * under it, so the description carries the intent, never the mechanism.
574
+ *
548
575
  * Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
549
576
  * response (a proxied service, or a fake). Return a `Response` to answer
550
577
  * yourself, `next()` to pass through, or change what `next()` returned.
551
- * An optional mount `path` limits it to that path and everything below
552
- * (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
553
- * registration order.
578
+ * Interceptors run in registration order.
554
579
  *
555
580
  * Only traffic that reaches the daemon can be intercepted: the browser,
556
581
  * `ctx.fetch`, and any container that calls the hostname — so the service
@@ -564,16 +589,18 @@ export interface SpectestContext<
564
589
  * `remove()`. Every request it sees is recorded under the intercept step.
565
590
  *
566
591
  * ```ts
567
- * const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
568
- * new Response("boom", { status: 500 }));
592
+ * const outage = ctx.intercept(
593
+ * "api.test/functions/v1/sync",
594
+ * "the sync function returns 500",
595
+ * () => new Response("boom", { status: 500 }),
596
+ * );
569
597
  * await page.getByRole("button", { name: "Sync" }).click();
570
598
  * await expect(page.getByText("Retry")).toBeVisible();
571
599
  * expect(outage.calls).toBe(1);
572
600
  * outage.remove(); // the retry now reaches the real function
573
601
  * ```
574
602
  */
575
- intercept(hostname: string, handler: InterceptHandler): Interception;
576
- intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
603
+ intercept(target: string, description: string, handler: InterceptHandler): Interception;
577
604
  /**
578
605
  * Mint a leaf certificate from the in-VM root CA and return the PEMs.
579
606
  *
@@ -2657,6 +2684,15 @@ export interface Expectation extends Matchers {
2657
2684
  not: Matchers;
2658
2685
  }
2659
2686
 
2687
+ /** Options for the text matchers — playwright's set, same names, same
2688
+ * meanings. */
2689
+ export interface TextMatcherOptions extends TextMatchOptions {
2690
+ timeout?: number;
2691
+ /** Read `innerText` (what the page renders — hidden elements dropped,
2692
+ * `text-transform` applied) instead of `textContent`. */
2693
+ useInnerText?: boolean;
2694
+ }
2695
+
2660
2696
  /**
2661
2697
  * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
2662
2698
  * `expect(locator)` matchers. Each polls the element until it passes or a
@@ -2668,10 +2704,33 @@ export interface LocatorMatchers {
2668
2704
  toBeVisible(opts?: { timeout?: number }): Promise<void>;
2669
2705
  /** The element is absent or hidden. */
2670
2706
  toBeHidden(opts?: { timeout?: number }): Promise<void>;
2671
- /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
2672
- toHaveText(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
2673
- /** The element's text contains `expected`. */
2674
- toContainText(expected: string, opts?: { timeout?: number }): Promise<void>;
2707
+ /**
2708
+ * The element's text equals `expected`, or matches it when it is a RegExp.
2709
+ *
2710
+ * **Whitespace is normalized on both sides**, exactly as playwright does
2711
+ * it: the text is trimmed and every run of whitespace becomes one space.
2712
+ * So a typed space matches the no-break space (U+00A0) that
2713
+ * `Intl.NumberFormat` puts between thousands, and a value wrapped across
2714
+ * two lines in the markup matches the one-line string you wrote.
2715
+ *
2716
+ * Pass an **array** to assert over every element the locator matches, in
2717
+ * order; the counts must then agree.
2718
+ */
2719
+ toHaveText(
2720
+ expected: string | RegExp | Array<string | RegExp>,
2721
+ opts?: TextMatcherOptions,
2722
+ ): Promise<void>;
2723
+ /**
2724
+ * The element's text contains `expected` — a substring, or a RegExp tested
2725
+ * against the text. Whitespace is normalized as in {@link toHaveText}.
2726
+ *
2727
+ * An **array** asserts that the matched elements contain these texts in
2728
+ * order; unlike `toHaveText` extra elements between them are allowed.
2729
+ */
2730
+ toContainText(
2731
+ expected: string | RegExp | Array<string | RegExp>,
2732
+ opts?: TextMatcherOptions,
2733
+ ): Promise<void>;
2675
2734
  /** The input's value equals `expected` (or matches a RegExp). */
2676
2735
  toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
2677
2736
  /** The locator resolves to exactly `expected` elements. */
@@ -2805,12 +2864,32 @@ function buildLocatorMatchers(
2805
2864
  // matches a hidden element fail identically — so the count is read once,
2806
2865
  // at failure time only. `toHaveCount` is left alone: "expected count 2,
2807
2866
  // got 0" already says it, and better.
2867
+ //
2868
+ // Either way the message ends with the near-miss block — what the page
2869
+ // holds that is close to what was asked for (see locator-hints.ts) — so
2870
+ // the author does not have to run again to find out.
2871
+ const nearMiss = async (): Promise<string> => {
2872
+ try {
2873
+ return await probe.nearMiss();
2874
+ } catch {
2875
+ return "";
2876
+ }
2877
+ };
2808
2878
  const elementFailure = async (err: unknown): Promise<string | undefined> => {
2809
- if (err !== undefined) return locatorFailureMessage(probe.label, err);
2879
+ if (err !== undefined) {
2880
+ const failure = classifyLocatorFailure(probe.label, err);
2881
+ if (!failure) return undefined;
2882
+ return failure.kind === "no-match"
2883
+ ? failure.message + (await nearMiss())
2884
+ : failure.message;
2885
+ }
2810
2886
  if (expectsGone || matcher === "toHaveCount") return undefined;
2811
2887
  try {
2812
2888
  if ((await probe.count()) === 0) {
2813
- return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
2889
+ return (
2890
+ `No element matches ${probe.label} (waited ${formatWaited(budget)})` +
2891
+ (await nearMiss())
2892
+ );
2814
2893
  }
2815
2894
  } catch {
2816
2895
  /* The page is gone or the chain is invalid — the matcher's own
@@ -2882,8 +2961,29 @@ function buildLocatorMatchers(
2882
2961
  };
2883
2962
 
2884
2963
  const not = negated ? " not" : "";
2885
- const matchesText = (v: string, expected: string | RegExp): boolean =>
2886
- expected instanceof RegExp ? expected.test(v) : v === expected;
2964
+ // The element's text, read the way the caller asked for it. `useInnerText`
2965
+ // is playwright's option and means the same here: what the page renders,
2966
+ // rather than every character in the subtree.
2967
+ const readText = async (opts?: TextMatcherOptions): Promise<string> =>
2968
+ opts?.useInnerText
2969
+ ? await probe.innerText(opts.timeout)
2970
+ : ((await probe.textContent(opts?.timeout)) ?? "");
2971
+ const readTexts = async (opts?: TextMatcherOptions): Promise<string[]> =>
2972
+ opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
2973
+ const isArrayExpectation = (
2974
+ v: string | RegExp | Array<string | RegExp>,
2975
+ ): v is Array<string | RegExp> => Array.isArray(v);
2976
+ /** Expected values render as themselves; a RegExp renders as its source. */
2977
+ const fmtExpected = (v: string | RegExp | Array<string | RegExp>): string =>
2978
+ v instanceof RegExp
2979
+ ? String(v)
2980
+ : Array.isArray(v)
2981
+ ? `[${v.map(fmtExpected).join(", ")}]`
2982
+ : fmt(v);
2983
+ /** What goes on the assertion event: a RegExp cannot be serialized. */
2984
+ const expectedValue = (
2985
+ v: string | RegExp | Array<string | RegExp>,
2986
+ ): unknown => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
2887
2987
 
2888
2988
  return {
2889
2989
  toBeVisible: (opts) =>
@@ -2911,33 +3011,53 @@ function buildLocatorMatchers(
2911
3011
  "toHaveText",
2912
3012
  opts?.timeout,
2913
3013
  async () => {
2914
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2915
- return { satisfied: matchesText(t.trim(), expected), actual: t };
3014
+ if (isArrayExpectation(expected)) {
3015
+ const texts = await readTexts(opts);
3016
+ return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
3017
+ }
3018
+ const t = await readText(opts);
3019
+ return { satisfied: matchesText(t, expected, opts), actual: t };
2916
3020
  },
2917
- (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`,
2918
- expected instanceof RegExp ? String(expected) : expected,
3021
+ (a) =>
3022
+ `expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
3023
+ textDifferenceNote(a, expected, "equal"),
3024
+ expectedValue(expected),
2919
3025
  ),
2920
3026
  toContainText: (expected, opts) =>
2921
3027
  run(
2922
3028
  "toContainText",
2923
3029
  opts?.timeout,
2924
3030
  async () => {
2925
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2926
- return { satisfied: t.includes(expected), actual: t };
3031
+ if (isArrayExpectation(expected)) {
3032
+ const texts = await readTexts(opts);
3033
+ return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
3034
+ }
3035
+ const t = await readText(opts);
3036
+ return { satisfied: containsText(t, expected, opts), actual: t };
2927
3037
  },
2928
- (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`,
2929
- expected,
3038
+ (a) =>
3039
+ `expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
3040
+ textDifferenceNote(a, expected, "contains"),
3041
+ expectedValue(expected),
2930
3042
  ),
3043
+ // Deliberately NOT whitespace-normalized: an input's value is data the
3044
+ // user typed or the app set, not rendered text, and playwright compares
3045
+ // it exactly for the same reason. When that exactness is what failed,
3046
+ // `textDifferenceNote` says so instead of leaving two identical-looking
3047
+ // strings on screen.
2931
3048
  toHaveValue: (expected, opts) =>
2932
3049
  run(
2933
3050
  "toHaveValue",
2934
3051
  opts?.timeout,
2935
3052
  async () => {
2936
3053
  const v = await probe.inputValue(opts?.timeout);
2937
- return { satisfied: matchesText(v, expected), actual: v };
3054
+ const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
3055
+ return { satisfied, actual: v };
2938
3056
  },
2939
- (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`,
2940
- expected instanceof RegExp ? String(expected) : expected,
3057
+ (a) =>
3058
+ `expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
3059
+ textDifferenceNote(a, expected, "equal"),
3060
+ expectedValue(expected),
2941
3061
  ),
2942
3062
  toHaveCount: (expected, opts) =>
2943
3063
  run(
@@ -3162,7 +3282,8 @@ function buildCore(
3162
3282
  run(
3163
3283
  "toBe",
3164
3284
  Object.is(actual, exp),
3165
- `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`,
3285
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}` +
3286
+ textDifferenceNote(actual, exp, "equal"),
3166
3287
  exp,
3167
3288
  );
3168
3289
  },
@@ -3177,7 +3298,8 @@ function buildCore(
3177
3298
  run(
3178
3299
  "toEqual",
3179
3300
  equal,
3180
- `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`,
3301
+ `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}` +
3302
+ textDifferenceNote(actual, exp, "equal"),
3181
3303
  exp,
3182
3304
  );
3183
3305
  },
@@ -3245,7 +3367,8 @@ function buildCore(
3245
3367
  run(
3246
3368
  "toContain",
3247
3369
  contained,
3248
- `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`,
3370
+ `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}` +
3371
+ textDifferenceNote(actual, exp, "contains"),
3249
3372
  exp,
3250
3373
  );
3251
3374
  },
@@ -3295,7 +3418,11 @@ function buildCore(
3295
3418
  }
3296
3419
 
3297
3420
  function fmt(v: unknown): string {
3298
- if (typeof v === "string") return JSON.stringify(v);
3421
+ // A string is escaped down to its invisible characters, so two values that
3422
+ // print the same on screen do not print the same in a failure. Without it a
3423
+ // no-break space and a space are the same three characters wide, and the
3424
+ // message reads "expected "15 000 kr", got "15 000 kr"".
3425
+ if (typeof v === "string") return escapeInvisible(JSON.stringify(v));
3299
3426
  if (typeof v === "bigint") return `${v}n`;
3300
3427
  if (v === undefined) return "undefined";
3301
3428
  try {
@@ -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
+ }