@specific.dev/spectest 0.54.1 → 0.55.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/index.js CHANGED
@@ -34,6 +34,7 @@ export { SQL } from "./sql.js";
34
34
  export { RedisClient } from "./redis.js";
35
35
  export { S3Client } from "./s3.js";
36
36
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
37
+ import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
37
38
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
39
  // Low-level ingress primitives + the framework lowering that the friendly
39
40
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
@@ -549,7 +550,8 @@ function buildLocatorMatchers(loc, negated, message) {
549
550
  // value for the timeline.
550
551
  const run = async (matcher, timeout, check, describe, expected) => {
551
552
  const started = Date.now();
552
- const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
553
+ const budget = timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
554
+ const deadline = started + budget;
553
555
  let actual;
554
556
  // Whether this matcher's subject is meant to be on screen, i.e. whether
555
557
  // the step should carry a reveal target for the replay (see
@@ -560,20 +562,59 @@ function buildLocatorMatchers(loc, negated, message) {
560
562
  const expectsGone = (visibility && (matcher === "toBeHidden") !== negated) ||
561
563
  (matcher === "toHaveCount" && expected === 0);
562
564
  const settleOpts = { reveal: !expectsGone };
565
+ // "That element is not there" said plainly, or `undefined` when the
566
+ // element IS there and the matcher's own description is the better
567
+ // sentence.
568
+ //
569
+ // Two ways to learn it. A matcher whose probe waits (textContent,
570
+ // inputValue, isEnabled …) has already been told, by the playwright
571
+ // timeout it caught. One whose probe answers instantly (isVisible) has
572
+ // not — `toBeVisible()` on a selector that matches nothing and one that
573
+ // matches a hidden element fail identically — so the count is read once,
574
+ // at failure time only. `toHaveCount` is left alone: "expected count 2,
575
+ // got 0" already says it, and better.
576
+ const elementFailure = async (err) => {
577
+ if (err !== undefined)
578
+ return locatorFailureMessage(probe.label, err);
579
+ if (expectsGone || matcher === "toHaveCount")
580
+ return undefined;
581
+ try {
582
+ if ((await probe.count()) === 0) {
583
+ return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
584
+ }
585
+ }
586
+ catch {
587
+ /* The page is gone or the chain is invalid — the matcher's own
588
+ description still says what was expected. */
589
+ }
590
+ return undefined;
591
+ };
592
+ // The last error a probe threw, kept for the failure message: a matcher
593
+ // whose read waits for the element (textContent, inputValue, isEnabled …)
594
+ // reports a missing element as a playwright timeout, and that — not
595
+ // "got <error: Timeout 5000ms exceeded …>" — is the sentence to fail with.
596
+ let probeError;
563
597
  for (;;) {
564
598
  let satisfied;
565
599
  try {
566
600
  const r = await check();
567
601
  satisfied = r.satisfied;
568
602
  actual = r.actual;
603
+ probeError = undefined;
569
604
  }
570
605
  catch (err) {
606
+ probeError = err;
571
607
  if (Date.now() < deadline) {
572
608
  await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
573
609
  continue;
574
610
  }
575
611
  satisfied = false;
576
- actual = `<error: ${err?.message ?? String(err)}>`;
612
+ // The recorded "actual" is what the timeline shows next to the
613
+ // matcher, so it gets the readable summary too — never the call-log
614
+ // wall a playwright timeout carries.
615
+ actual =
616
+ locatorFailureMessage(probe.label, err) ??
617
+ `<error: ${err?.message ?? String(err)}>`;
577
618
  }
578
619
  const passed = satisfied !== negated;
579
620
  if (passed) {
@@ -590,7 +631,11 @@ function buildLocatorMatchers(loc, negated, message) {
590
631
  return;
591
632
  }
592
633
  if (Date.now() >= deadline) {
593
- const msg = describe(actual);
634
+ // The deadline, not the stopwatch: every one of these polled until it
635
+ // ran out, and "(waited 5s)" is both what the author set and the same
636
+ // number twice in a row — a measured 4_987ms is neither.
637
+ const msg = (await elementFailure(probeError)) ??
638
+ `${describe(actual)} (waited ${formatWaited(budget)})`;
594
639
  const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
595
640
  recordAssertion({
596
641
  matcher,
@@ -0,0 +1,21 @@
1
+ /** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
2
+ export declare function formatWaited(ms: number): string;
3
+ /**
4
+ * The sentence to fail with, or `undefined` when `err` is not a locator
5
+ * timeout we understand (in which case the caller must leave it alone).
6
+ *
7
+ * `label` is the locator's human chain label, the same string the timeline
8
+ * step shows.
9
+ */
10
+ export declare function locatorFailureMessage(label: string, err: unknown): string | undefined;
11
+ /**
12
+ * Replace a locator timeout's message in place and hand the error back, so the
13
+ * caller can `throw` it unchanged in every other respect.
14
+ *
15
+ * The error is mutated rather than wrapped: its stack holds the frames of the
16
+ * author's own call, which a fresh Error would lose. The stack string embeds
17
+ * the old message (it is built at construction), so that copy is rewritten
18
+ * too — otherwise the CLI's failure block would print the friendly message and
19
+ * then the timeout wall right under it.
20
+ */
21
+ export declare function rewriteLocatorError(err: unknown, label: string): unknown;
@@ -0,0 +1,142 @@
1
+ // Readable failures for locator steps.
2
+ //
3
+ // Playwright reports every unmet actionability wait the same way: a
4
+ // `TimeoutError` whose message is "Timeout 5000ms exceeded." with the real
5
+ // story — did the element exist at all? was it disabled? did something cover
6
+ // it? — buried in a call log below it. A test that clicks a button that is not
7
+ // on the page therefore fails with a sentence about OUR deadline, which reads
8
+ // as an infrastructure problem and says nothing about the page.
9
+ //
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.
15
+ //
16
+ // The call log is read from the error's own `log` array (playwright attaches
17
+ // it) and falls back to parsing the message, since only the message survives
18
+ // a serialize/deserialize round trip.
19
+ /** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
20
+ export function formatWaited(ms) {
21
+ if (!Number.isFinite(ms) || ms < 0)
22
+ return "0ms";
23
+ if (ms < 1000)
24
+ return `${Math.round(ms)}ms`;
25
+ // Rounded off the millisecond count, not through toFixed — 1450ms is
26
+ // "1.5s", and binary floating point renders 1.45 as 1.4.
27
+ return `${Math.round(ms / 100) / 10}s`;
28
+ }
29
+ /** Recognise playwright's actionability timeout and pull it apart. Returns
30
+ * `undefined` for every other error. */
31
+ function parseTimeout(err) {
32
+ const e = err;
33
+ if (!e || e.name !== "TimeoutError" || typeof e.message !== "string")
34
+ return undefined;
35
+ const m = /Timeout (\d+)ms exceeded/.exec(e.message);
36
+ if (!m)
37
+ return undefined;
38
+ const raw = Array.isArray(e.log)
39
+ ? e.log.filter((l) => typeof l === "string")
40
+ : logFromMessage(e.message);
41
+ return { timeoutMs: Number(m[1]), log: raw.map(cleanLogLine).filter(Boolean) };
42
+ }
43
+ function logFromMessage(message) {
44
+ const at = message.indexOf("Call log:");
45
+ if (at === -1)
46
+ return [];
47
+ return message.slice(at + "Call log:".length).split("\n");
48
+ }
49
+ /** Strip a line's leading bullet and repeat count ("2 × waiting for …"). */
50
+ function cleanLogLine(line) {
51
+ return line.trim().replace(/^-\s*/, "").replace(/^\d+\s*×\s*/, "");
52
+ }
53
+ /** Did the locator ever match anything? Playwright logs "locator resolved to
54
+ * <html>" the moment it does, and nothing of the sort when it never did. */
55
+ function resolved(log) {
56
+ return log.some((l) => l.startsWith("locator resolved to"));
57
+ }
58
+ /** The state a `waitFor`-style wait was after, from its opening log line. */
59
+ function waitedForState(log) {
60
+ for (const line of log) {
61
+ const m = /^waiting for .* to be (attached|detached|visible|hidden)$/.exec(line);
62
+ if (m)
63
+ return m[1];
64
+ }
65
+ return undefined;
66
+ }
67
+ /** Why an element that DID match still could not be acted on. Read from the
68
+ * last attempt, since that is the state the deadline caught it in. */
69
+ function actionabilityReason(log) {
70
+ for (let i = log.length - 1; i >= 0; i--) {
71
+ const line = log[i];
72
+ // "element is not visible" / "not enabled" / "not stable" / "not editable"
73
+ const missing = /^element is not (.+)$/.exec(line);
74
+ if (missing)
75
+ return `is not ${missing[1]}`;
76
+ // "<div id="overlay"></div> intercepts pointer events"
77
+ const covered = /^(<.+>) intercepts pointer events$/.exec(line);
78
+ if (covered)
79
+ return `is covered by ${covered[1]}`;
80
+ }
81
+ return undefined;
82
+ }
83
+ /**
84
+ * The sentence to fail with, or `undefined` when `err` is not a locator
85
+ * timeout we understand (in which case the caller must leave it alone).
86
+ *
87
+ * `label` is the locator's human chain label, the same string the timeline
88
+ * step shows.
89
+ */
90
+ export function locatorFailureMessage(label, err) {
91
+ const detail = parseTimeout(err);
92
+ if (!detail)
93
+ return undefined;
94
+ const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
95
+ if (!resolved(detail.log))
96
+ return `No element matches ${label} ${waited}`;
97
+ // It matched, so the failure is about the element's state.
98
+ switch (waitedForState(detail.log)) {
99
+ case "hidden":
100
+ return `Element ${label} is still visible ${waited}`;
101
+ case "detached":
102
+ return `Element ${label} is still attached to the page ${waited}`;
103
+ case "visible":
104
+ return `Element ${label} is not visible ${waited}`;
105
+ default:
106
+ break;
107
+ }
108
+ const reason = actionabilityReason(detail.log);
109
+ return reason
110
+ ? `Element ${label} ${reason} ${waited}`
111
+ : `Element ${label} never became ready for this action ${waited}`;
112
+ }
113
+ /**
114
+ * Replace a locator timeout's message in place and hand the error back, so the
115
+ * caller can `throw` it unchanged in every other respect.
116
+ *
117
+ * The error is mutated rather than wrapped: its stack holds the frames of the
118
+ * author's own call, which a fresh Error would lose. The stack string embeds
119
+ * the old message (it is built at construction), so that copy is rewritten
120
+ * too — otherwise the CLI's failure block would print the friendly message and
121
+ * then the timeout wall right under it.
122
+ */
123
+ export function rewriteLocatorError(err, label) {
124
+ const message = locatorFailureMessage(label, err);
125
+ if (message === undefined)
126
+ return err;
127
+ const e = err;
128
+ const old = e.message;
129
+ if (typeof e.stack === "string") {
130
+ for (const [header, replacement] of [
131
+ [`${e.name}: ${old}`, `${e.name}: ${message}`],
132
+ [old, message],
133
+ ]) {
134
+ if (e.stack.startsWith(header)) {
135
+ e.stack = replacement + e.stack.slice(header.length);
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ e.message = message;
141
+ return e;
142
+ }
package/dist/locator.js CHANGED
@@ -22,6 +22,7 @@
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
26
  import { resolveExistingProjectPath } from "./project-files.js";
26
27
  import { truncateUtf8 } from "./recorder.js";
27
28
  /** Default deadline for a locator action/read's target to become actionable.
@@ -350,20 +351,31 @@ function lowerInputFiles(files) {
350
351
  export function makeLocator(backend, strategy, chain) {
351
352
  const label = chainLabel(chain);
352
353
  const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
354
+ // Every terminal op runs through this: playwright's actionability timeouts
355
+ // say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
356
+ // 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) => {
360
+ try {
361
+ return await fn();
362
+ }
363
+ catch (err) {
364
+ throw rewriteLocatorError(err, label);
365
+ }
366
+ };
353
367
  // One recorded event, result NOT wrapped (void/action).
354
- const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
368
+ const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain), page)));
355
369
  // One recorded event whose fields the action itself finishes filling in:
356
370
  // `rec` is the very object the recorder spreads once `fn` resolves (the
357
371
  // same mutate-in-flight seam `screenshot`'s artifact id rides), so the
358
372
  // click family can stamp the point it acted on. See stampActionPoint.
359
373
  const actAt = (action, fn) => {
360
374
  const rec = { selector: label };
361
- return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
375
+ return backend.pageOp(action, rec, (page) => readable(() => fn(lower(page, chain), rec)));
362
376
  };
363
377
  // One recorded event, result provenance-wrapped for expect().
364
- const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
365
- wrap: true,
366
- });
378
+ const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain))), { wrap: true });
367
379
  // Silent, non-recorded reads for expect(locator) matchers to poll.
368
380
  const probe = {
369
381
  label,
@@ -459,7 +471,7 @@ export function makeLocator(backend, strategy, chain) {
459
471
  out.push(extend({ m: "nth", args: [i] }));
460
472
  return out;
461
473
  },
462
- evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => lower(page, chain).evaluate(fn, arg), { wrap: true }),
474
+ evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => readable(() => lower(page, chain).evaluate(fn, arg)), { wrap: true }),
463
475
  waitFor: (opts) => act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
464
476
  };
465
477
  // Attach the silent-read probe under its symbol (kept off the typed literal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.54.1",
3
+ "version": "0.55.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ import {
87
87
  getBrowserProbe,
88
88
  DEFAULT_ACTION_TIMEOUT_MS,
89
89
  } from "./locator.js";
90
+ import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
90
91
 
91
92
  export type { UrlPattern } from "./url-match.js";
92
93
  import type { UrlPattern } from "./url-match.js";
@@ -2467,7 +2468,8 @@ function buildLocatorMatchers(
2467
2468
  expected?: unknown,
2468
2469
  ): Promise<void> => {
2469
2470
  const started = Date.now();
2470
- const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
2471
+ const budget = timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
2472
+ const deadline = started + budget;
2471
2473
  let actual: unknown;
2472
2474
  // Whether this matcher's subject is meant to be on screen, i.e. whether
2473
2475
  // the step should carry a reveal target for the replay (see
@@ -2479,19 +2481,55 @@ function buildLocatorMatchers(
2479
2481
  (visibility && (matcher === "toBeHidden") !== negated) ||
2480
2482
  (matcher === "toHaveCount" && expected === 0);
2481
2483
  const settleOpts = { reveal: !expectsGone };
2484
+ // "That element is not there" said plainly, or `undefined` when the
2485
+ // element IS there and the matcher's own description is the better
2486
+ // sentence.
2487
+ //
2488
+ // Two ways to learn it. A matcher whose probe waits (textContent,
2489
+ // inputValue, isEnabled …) has already been told, by the playwright
2490
+ // timeout it caught. One whose probe answers instantly (isVisible) has
2491
+ // not — `toBeVisible()` on a selector that matches nothing and one that
2492
+ // matches a hidden element fail identically — so the count is read once,
2493
+ // at failure time only. `toHaveCount` is left alone: "expected count 2,
2494
+ // got 0" already says it, and better.
2495
+ const elementFailure = async (err: unknown): Promise<string | undefined> => {
2496
+ if (err !== undefined) return locatorFailureMessage(probe.label, err);
2497
+ if (expectsGone || matcher === "toHaveCount") return undefined;
2498
+ try {
2499
+ if ((await probe.count()) === 0) {
2500
+ return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
2501
+ }
2502
+ } catch {
2503
+ /* The page is gone or the chain is invalid — the matcher's own
2504
+ description still says what was expected. */
2505
+ }
2506
+ return undefined;
2507
+ };
2508
+ // The last error a probe threw, kept for the failure message: a matcher
2509
+ // whose read waits for the element (textContent, inputValue, isEnabled …)
2510
+ // reports a missing element as a playwright timeout, and that — not
2511
+ // "got <error: Timeout 5000ms exceeded …>" — is the sentence to fail with.
2512
+ let probeError: unknown;
2482
2513
  for (;;) {
2483
2514
  let satisfied: boolean;
2484
2515
  try {
2485
2516
  const r = await check();
2486
2517
  satisfied = r.satisfied;
2487
2518
  actual = r.actual;
2519
+ probeError = undefined;
2488
2520
  } catch (err) {
2521
+ probeError = err;
2489
2522
  if (Date.now() < deadline) {
2490
2523
  await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
2491
2524
  continue;
2492
2525
  }
2493
2526
  satisfied = false;
2494
- actual = `<error: ${(err as Error)?.message ?? String(err)}>`;
2527
+ // The recorded "actual" is what the timeline shows next to the
2528
+ // matcher, so it gets the readable summary too — never the call-log
2529
+ // wall a playwright timeout carries.
2530
+ actual =
2531
+ locatorFailureMessage(probe.label, err) ??
2532
+ `<error: ${(err as Error)?.message ?? String(err)}>`;
2495
2533
  }
2496
2534
  const passed = satisfied !== negated;
2497
2535
  if (passed) {
@@ -2508,7 +2546,11 @@ function buildLocatorMatchers(
2508
2546
  return;
2509
2547
  }
2510
2548
  if (Date.now() >= deadline) {
2511
- const msg = describe(actual);
2549
+ // The deadline, not the stopwatch: every one of these polled until it
2550
+ // ran out, and "(waited 5s)" is both what the author set and the same
2551
+ // number twice in a row — a measured 4_987ms is neither.
2552
+ const msg = (await elementFailure(probeError)) ??
2553
+ `${describe(actual)} (waited ${formatWaited(budget)})`;
2512
2554
  const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
2513
2555
  recordAssertion({
2514
2556
  matcher,
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { formatWaited, locatorFailureMessage, rewriteLocatorError } from "./locator-errors.js";
4
+
5
+ /** A playwright actionability timeout, shaped exactly as playwright-core
6
+ * 1.61 raises one: `name`, the message with the call log appended, the same
7
+ * log as an array, and a stack whose first lines ARE the message. Captured
8
+ * from a real run against headless Chromium. */
9
+ function timeoutError(message: string, log: string[]): Error {
10
+ const full = `${message}\nCall log:\n${log.join("\n")}\n`;
11
+ const err = new Error(full) as Error & { log: string[] };
12
+ err.name = "TimeoutError";
13
+ err.log = log;
14
+ err.stack = `${full}\n at /project/spectest/tests/todo.ts:12:34`;
15
+ return err;
16
+ }
17
+
18
+ describe("locatorFailureMessage", () => {
19
+ test("names the element that was never found", () => {
20
+ const err = timeoutError("click: Timeout 5000ms exceeded.", [
21
+ " - waiting for getByTestId('add')",
22
+ ]);
23
+ expect(locatorFailureMessage('testid "add"', err)).toBe(
24
+ 'No element matches testid "add" (waited 5s)',
25
+ );
26
+ });
27
+
28
+ test("reports the state of an element that was found", () => {
29
+ const err = timeoutError("click: Timeout 5000ms exceeded.", [
30
+ " - waiting for locator('#dis')",
31
+ " - locator resolved to <button id=\"dis\" disabled>Nope</button>",
32
+ " - attempting click action",
33
+ " 2 × waiting for element to be visible, enabled and stable",
34
+ " - element is not enabled",
35
+ " - retrying click action",
36
+ " - waiting 100ms",
37
+ ]);
38
+ expect(locatorFailureMessage('css "#dis"', err)).toBe(
39
+ 'Element css "#dis" is not enabled (waited 5s)',
40
+ );
41
+ });
42
+
43
+ test("names what covered the element", () => {
44
+ const err = timeoutError("click: Timeout 1000ms exceeded.", [
45
+ " - waiting for locator('#ok')",
46
+ " - locator resolved to <button id=\"ok\">OK</button>",
47
+ " - attempting click action",
48
+ " - <div id=\"cover\"></div> intercepts pointer events",
49
+ ]);
50
+ expect(locatorFailureMessage('css "#ok"', err)).toBe(
51
+ 'Element css "#ok" is covered by <div id="cover"></div> (waited 1s)',
52
+ );
53
+ });
54
+
55
+ test("a wait for the element to go away says it is still there", () => {
56
+ const err = timeoutError("waitFor: Timeout 2500ms exceeded.", [
57
+ " - waiting for locator('#ok') to be hidden",
58
+ " 11 × locator resolved to visible <button id=\"ok\">OK</button>",
59
+ ]);
60
+ expect(locatorFailureMessage('css "#ok"', err)).toBe(
61
+ 'Element css "#ok" is still visible (waited 2.5s)',
62
+ );
63
+ });
64
+
65
+ test("falls back when the element matched for a reason we cannot read", () => {
66
+ const err = timeoutError("click: Timeout 5000ms exceeded.", [
67
+ " - waiting for locator('#ok')",
68
+ " - locator resolved to <button id=\"ok\">OK</button>",
69
+ " - something new playwright started logging",
70
+ ]);
71
+ expect(locatorFailureMessage('css "#ok"', err)).toBe(
72
+ 'Element css "#ok" never became ready for this action (waited 5s)',
73
+ );
74
+ });
75
+
76
+ test("reads the call log out of the message when the log array is gone", () => {
77
+ const err = timeoutError("click: Timeout 5000ms exceeded.", [
78
+ " - waiting for getByTestId('add')",
79
+ ]);
80
+ delete (err as { log?: unknown }).log;
81
+ expect(locatorFailureMessage('testid "add"', err)).toBe(
82
+ 'No element matches testid "add" (waited 5s)',
83
+ );
84
+ });
85
+
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
+ expect(locatorFailureMessage('css "p"', new Error("page closed"))).toBeUndefined();
92
+ expect(locatorFailureMessage('css "p"', "not an error at all")).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ describe("rewriteLocatorError", () => {
97
+ test("rewrites the message AND the copy embedded in the stack", () => {
98
+ const err = timeoutError("click: Timeout 5000ms exceeded.", [
99
+ " - waiting for getByTestId('add')",
100
+ ]);
101
+ const out = rewriteLocatorError(err, 'testid "add"') as Error;
102
+
103
+ expect(out).toBe(err); // same error: the author's own frames survive
104
+ expect(out.message).toBe('No element matches testid "add" (waited 5s)');
105
+ expect(out.stack).toStartWith('No element matches testid "add" (waited 5s)');
106
+ expect(out.stack).not.toContain("Timeout 5000ms exceeded");
107
+ expect(out.stack).toContain("todo.ts:12:34");
108
+ });
109
+
110
+ test("hands back anything it does not understand untouched", () => {
111
+ const err = new Error("page closed");
112
+ expect(rewriteLocatorError(err, 'css "p"')).toBe(err);
113
+ expect(err.message).toBe("page closed");
114
+ });
115
+ });
116
+
117
+ describe("formatWaited", () => {
118
+ test("reads in the units a reader thinks in", () => {
119
+ expect(formatWaited(250)).toBe("250ms");
120
+ expect(formatWaited(5000)).toBe("5s");
121
+ expect(formatWaited(5040)).toBe("5s");
122
+ expect(formatWaited(1450)).toBe("1.5s");
123
+ expect(formatWaited(30_000)).toBe("30s");
124
+ });
125
+ });
@@ -0,0 +1,153 @@
1
+ // Readable failures for locator steps.
2
+ //
3
+ // Playwright reports every unmet actionability wait the same way: a
4
+ // `TimeoutError` whose message is "Timeout 5000ms exceeded." with the real
5
+ // story — did the element exist at all? was it disabled? did something cover
6
+ // it? — buried in a call log below it. A test that clicks a button that is not
7
+ // on the page therefore fails with a sentence about OUR deadline, which reads
8
+ // as an infrastructure problem and says nothing about the page.
9
+ //
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.
15
+ //
16
+ // The call log is read from the error's own `log` array (playwright attaches
17
+ // it) and falls back to parsing the message, since only the message survives
18
+ // a serialize/deserialize round trip.
19
+
20
+ /** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
21
+ export function formatWaited(ms: number): string {
22
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
23
+ if (ms < 1000) return `${Math.round(ms)}ms`;
24
+ // Rounded off the millisecond count, not through toFixed — 1450ms is
25
+ // "1.5s", and binary floating point renders 1.45 as 1.4.
26
+ return `${Math.round(ms / 100) / 10}s`;
27
+ }
28
+
29
+ /** The states `waitFor` (and the mobile tap's own pre-wait) can ask for. Read
30
+ * off the call log rather than threaded through the callers, so a wait nested
31
+ * inside some other action is described just as well. */
32
+ type WaitState = "attached" | "detached" | "visible" | "hidden";
33
+
34
+ interface TimeoutDetail {
35
+ /** The deadline playwright reported — the exact number the author set. */
36
+ timeoutMs: number;
37
+ /** Call-log lines, leading "- " and "N × " repeat counts stripped. */
38
+ log: string[];
39
+ }
40
+
41
+ /** Recognise playwright's actionability timeout and pull it apart. Returns
42
+ * `undefined` for every other error. */
43
+ function parseTimeout(err: unknown): TimeoutDetail | undefined {
44
+ const e = err as { name?: string; message?: unknown; log?: unknown } | null;
45
+ if (!e || e.name !== "TimeoutError" || typeof e.message !== "string") return undefined;
46
+ const m = /Timeout (\d+)ms exceeded/.exec(e.message);
47
+ if (!m) return undefined;
48
+ const raw = Array.isArray(e.log)
49
+ ? (e.log as unknown[]).filter((l): l is string => typeof l === "string")
50
+ : logFromMessage(e.message);
51
+ return { timeoutMs: Number(m[1]), log: raw.map(cleanLogLine).filter(Boolean) };
52
+ }
53
+
54
+ function logFromMessage(message: string): string[] {
55
+ const at = message.indexOf("Call log:");
56
+ if (at === -1) return [];
57
+ return message.slice(at + "Call log:".length).split("\n");
58
+ }
59
+
60
+ /** Strip a line's leading bullet and repeat count ("2 × waiting for …"). */
61
+ function cleanLogLine(line: string): string {
62
+ return line.trim().replace(/^-\s*/, "").replace(/^\d+\s*×\s*/, "");
63
+ }
64
+
65
+ /** Did the locator ever match anything? Playwright logs "locator resolved to
66
+ * <html>" the moment it does, and nothing of the sort when it never did. */
67
+ function resolved(log: string[]): boolean {
68
+ return log.some((l) => l.startsWith("locator resolved to"));
69
+ }
70
+
71
+ /** The state a `waitFor`-style wait was after, from its opening log line. */
72
+ function waitedForState(log: string[]): WaitState | undefined {
73
+ for (const line of log) {
74
+ const m = /^waiting for .* to be (attached|detached|visible|hidden)$/.exec(line);
75
+ if (m) return m[1] as WaitState;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** Why an element that DID match still could not be acted on. Read from the
81
+ * last attempt, since that is the state the deadline caught it in. */
82
+ function actionabilityReason(log: string[]): string | undefined {
83
+ for (let i = log.length - 1; i >= 0; i--) {
84
+ const line = log[i]!;
85
+ // "element is not visible" / "not enabled" / "not stable" / "not editable"
86
+ const missing = /^element is not (.+)$/.exec(line);
87
+ if (missing) return `is not ${missing[1]}`;
88
+ // "<div id="overlay"></div> intercepts pointer events"
89
+ const covered = /^(<.+>) intercepts pointer events$/.exec(line);
90
+ if (covered) return `is covered by ${covered[1]}`;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
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).
98
+ *
99
+ * `label` is the locator's human chain label, the same string the timeline
100
+ * step shows.
101
+ */
102
+ export function locatorFailureMessage(label: string, err: unknown): string | undefined {
103
+ const detail = parseTimeout(err);
104
+ if (!detail) return undefined;
105
+ const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
106
+ if (!resolved(detail.log)) return `No element matches ${label} ${waited}`;
107
+
108
+ // It matched, so the failure is about the element's state.
109
+ switch (waitedForState(detail.log)) {
110
+ case "hidden":
111
+ return `Element ${label} is still visible ${waited}`;
112
+ case "detached":
113
+ return `Element ${label} is still attached to the page ${waited}`;
114
+ case "visible":
115
+ return `Element ${label} is not visible ${waited}`;
116
+ default:
117
+ break;
118
+ }
119
+ const reason = actionabilityReason(detail.log);
120
+ return reason
121
+ ? `Element ${label} ${reason} ${waited}`
122
+ : `Element ${label} never became ready for this action ${waited}`;
123
+ }
124
+
125
+ /**
126
+ * Replace a locator timeout's message in place and hand the error back, so the
127
+ * caller can `throw` it unchanged in every other respect.
128
+ *
129
+ * The error is mutated rather than wrapped: its stack holds the frames of the
130
+ * author's own call, which a fresh Error would lose. The stack string embeds
131
+ * the old message (it is built at construction), so that copy is rewritten
132
+ * too — otherwise the CLI's failure block would print the friendly message and
133
+ * then the timeout wall right under it.
134
+ */
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;
139
+ const old = e.message;
140
+ if (typeof e.stack === "string") {
141
+ for (const [header, replacement] of [
142
+ [`${e.name}: ${old}`, `${e.name}: ${message}`],
143
+ [old, message],
144
+ ] as const) {
145
+ if (e.stack.startsWith(header)) {
146
+ e.stack = replacement + e.stack.slice(header.length);
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ e.message = message;
152
+ return e;
153
+ }
package/src/locator.ts CHANGED
@@ -26,6 +26,7 @@ import { Buffer } from "node:buffer";
26
26
  import type { Page, Locator as PWLocator } from "playwright-core";
27
27
  import type { RecordableFields } from "./browser.js";
28
28
  import type { Wrapped } from "./inspect.js";
29
+ import { rewriteLocatorError } from "./locator-errors.js";
29
30
  import { resolveExistingProjectPath } from "./project-files.js";
30
31
  import { truncateUtf8 } from "./recorder.js";
31
32
 
@@ -701,6 +702,19 @@ export function makeLocator(
701
702
  const extend = (step: Step): Locator =>
702
703
  makeLocator(backend, strategy, { steps: [...chain.steps, step] });
703
704
 
705
+ // Every terminal op runs through this: playwright's actionability timeouts
706
+ // say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
707
+ // log, so they are rewritten into a sentence naming the element and its
708
+ // state (see locator-errors.ts). Applied INSIDE `pageOp`, so the recorded
709
+ // step carries the readable message too — not just the thrown error.
710
+ const readable = async <T>(fn: () => Promise<T>): Promise<T> => {
711
+ try {
712
+ return await fn();
713
+ } catch (err) {
714
+ throw rewriteLocatorError(err, label);
715
+ }
716
+ };
717
+
704
718
  // One recorded event, result NOT wrapped (void/action).
705
719
  const act = <T>(
706
720
  action: string,
@@ -708,7 +722,7 @@ export function makeLocator(
708
722
  fn: (loc: PWLocator, page: Page) => Promise<T>,
709
723
  ): Promise<T> =>
710
724
  backend.pageOp(action, { selector: label, ...fields }, (page) =>
711
- fn(lower(page, chain), page),
725
+ readable(() => fn(lower(page, chain), page)),
712
726
  );
713
727
 
714
728
  // One recorded event whose fields the action itself finishes filling in:
@@ -720,7 +734,9 @@ export function makeLocator(
720
734
  fn: (loc: PWLocator, rec: Partial<RecordableFields>) => Promise<T>,
721
735
  ): Promise<T> => {
722
736
  const rec: Partial<RecordableFields> = { selector: label };
723
- return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
737
+ return backend.pageOp(action, rec, (page) =>
738
+ readable(() => fn(lower(page, chain), rec)),
739
+ );
724
740
  };
725
741
 
726
742
  // One recorded event, result provenance-wrapped for expect().
@@ -729,9 +745,12 @@ export function makeLocator(
729
745
  fn: (loc: PWLocator) => Promise<T>,
730
746
  fields: Partial<RecordableFields> = {},
731
747
  ): Promise<Wrapped<T>> =>
732
- backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
733
- wrap: true,
734
- }) as Promise<Wrapped<T>>;
748
+ backend.pageOp(
749
+ action,
750
+ { selector: label, ...fields },
751
+ (page) => readable(() => fn(lower(page, chain))),
752
+ { wrap: true },
753
+ ) as Promise<Wrapped<T>>;
735
754
 
736
755
  // Silent, non-recorded reads for expect(locator) matchers to poll.
737
756
  const probe: LocatorProbe = {
@@ -848,7 +867,7 @@ export function makeLocator(
848
867
  backend.pageOp(
849
868
  "evaluate",
850
869
  { selector: label, description },
851
- (page) => lower(page, chain).evaluate(fn as never, arg),
870
+ (page) => readable(() => lower(page, chain).evaluate(fn as never, arg)),
852
871
  { wrap: true },
853
872
  ) as Promise<Wrapped<never>>,
854
873