@specific.dev/spectest 0.54.0 → 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/daemon.js CHANGED
@@ -3928,6 +3928,7 @@ async function runOne(testCase) {
3928
3928
  rows: term.rows,
3929
3929
  outputPreview: preview.value,
3930
3930
  outputTruncated: preview.truncated,
3931
+ interactive: true,
3931
3932
  });
3932
3933
  return term;
3933
3934
  };
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
@@ -481,6 +481,14 @@ export interface TerminalEvent extends BaseEvent {
481
481
  outputTruncated: boolean;
482
482
  /** Set if spawning the PTY itself failed. */
483
483
  error?: string;
484
+ /**
485
+ * Set on the open marker of an interactive session (`ctx.terminal.open`).
486
+ * A one-shot `ctx.terminal(...)` event covers a whole command run, so the
487
+ * UI seeks its replay to the end; the open marker is the state at open,
488
+ * so the UI seeks to 0 instead. Older recordings lack this flag — the UI
489
+ * falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
490
+ */
491
+ interactive?: boolean;
484
492
  }
485
493
  /** Action taken on an open interactive terminal session. */
486
494
  export type TerminalStepAction = "send" | "sendLine" | "press" | "waitFor" | "exit" | "close";
package/dist/terminal.js CHANGED
@@ -128,8 +128,23 @@ export async function openTerminal(args) {
128
128
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
129
129
  // frame instead of microseconds before it.
130
130
  const frameWaiters = [];
131
+ // Time of the most recently ingested frame. Two jobs: (1) frame
132
+ // timestamps are forced strictly increasing (Date.now() has ms
133
+ // resolution, so a fast PTY can land two frames — or two *ops* —
134
+ // in the same millisecond), and (2) it is what `recordOpEvent`
135
+ // stamps as the step's `castTimeSec`. Together they make the
136
+ // player's `seek(castTimeSec)` (which applies frames `<= target`)
137
+ // land on exactly the frames this op had seen: the step's own last
138
+ // frame is included, and the next op's first frame can never share
139
+ // the stamp. Before this, a `waitFor` that matched in the same
140
+ // millisecond as the following `sendLine`'s echo showed that echo
141
+ // — the replay ran one step ahead of the step list.
142
+ let lastFrameT = 0;
131
143
  const ingest = (chunk) => {
132
- const t = (Date.now() - start) / 1000;
144
+ let t = (Date.now() - start) / 1000;
145
+ if (t <= lastFrameT)
146
+ t = lastFrameT + 0.001;
147
+ lastFrameT = t;
133
148
  sink.pushFrame(t, chunk);
134
149
  emu.write(chunk);
135
150
  if (output.length < OUTPUT_CAP_BYTES) {
@@ -223,8 +238,8 @@ export async function openTerminal(args) {
223
238
  }
224
239
  // Record cast time AFTER drain, so the `exit` step we record below
225
240
  // points the player at the very last frame (the program's final
226
- // output before EOF).
227
- exitCastTimeSec = (Date.now() - start) / 1000;
241
+ // output before EOF). Same between-frames stamp as `recordOpEvent`.
242
+ exitCastTimeSec = lastFrameT + 0.0005;
228
243
  sink.markClosed();
229
244
  resolveExit(code);
230
245
  })();
@@ -265,10 +280,17 @@ export async function openTerminal(args) {
265
280
  if (!args.recordEvents)
266
281
  return undefined;
267
282
  const preview = truncateUtf8(renderScreen());
268
- // Use the SAME clock the asciicast frames use (offset from `start`,
269
- // which is captured at Bun.spawn). That way the UI seeks the player
270
- // directly to this value with no frame arithmetic.
271
- const castTimeSec = (Date.now() - start) / 1000;
283
+ // Stamp the step half a millisecond past its last ingested frame:
284
+ // frame times are strictly increasing in 1ms steps (see `ingest`),
285
+ // so the stamp sits strictly BETWEEN the op's own last frame and
286
+ // any later op's first frame. That makes the boundary unambiguous
287
+ // in both directions — an inclusive seek (`<= stamp`) shows the
288
+ // op's own frame and can never pull in the next op's, and the
289
+ // viewer's exclusive `waitFor` seek (stamp − 0.0004, which
290
+ // disambiguates legacy wall-clock stamps that tied with the next
291
+ // op's echo) still lands past the op's own frame. Same clock as
292
+ // the frames (offset from `start`), so the UI seeks directly.
293
+ const castTimeSec = lastFrameT + 0.0005;
272
294
  // Return the event seq so callers can `wrap()` their result value
273
295
  // against it — e.g. `expect(await term.waitFor(...))` then links its
274
296
  // assertion to this step (sourceSeq) and the UI nests it here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.54.0",
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/daemon.ts CHANGED
@@ -4815,6 +4815,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4815
4815
  rows: term.rows,
4816
4816
  outputPreview: preview.value,
4817
4817
  outputTruncated: preview.truncated,
4818
+ interactive: true,
4818
4819
  });
4819
4820
  return term;
4820
4821
  };
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
 
package/src/recorder.ts CHANGED
@@ -537,6 +537,14 @@ export interface TerminalEvent extends BaseEvent {
537
537
  outputTruncated: boolean;
538
538
  /** Set if spawning the PTY itself failed. */
539
539
  error?: string;
540
+ /**
541
+ * Set on the open marker of an interactive session (`ctx.terminal.open`).
542
+ * A one-shot `ctx.terminal(...)` event covers a whole command run, so the
543
+ * UI seeks its replay to the end; the open marker is the state at open,
544
+ * so the UI seeks to 0 instead. Older recordings lack this flag — the UI
545
+ * falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
546
+ */
547
+ interactive?: boolean;
540
548
  }
541
549
 
542
550
  /** Action taken on an open interactive terminal session. */
package/src/terminal.ts CHANGED
@@ -305,8 +305,22 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
305
305
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
306
306
  // frame instead of microseconds before it.
307
307
  const frameWaiters: Array<() => void> = [];
308
+ // Time of the most recently ingested frame. Two jobs: (1) frame
309
+ // timestamps are forced strictly increasing (Date.now() has ms
310
+ // resolution, so a fast PTY can land two frames — or two *ops* —
311
+ // in the same millisecond), and (2) it is what `recordOpEvent`
312
+ // stamps as the step's `castTimeSec`. Together they make the
313
+ // player's `seek(castTimeSec)` (which applies frames `<= target`)
314
+ // land on exactly the frames this op had seen: the step's own last
315
+ // frame is included, and the next op's first frame can never share
316
+ // the stamp. Before this, a `waitFor` that matched in the same
317
+ // millisecond as the following `sendLine`'s echo showed that echo
318
+ // — the replay ran one step ahead of the step list.
319
+ let lastFrameT = 0;
308
320
  const ingest = (chunk: string): void => {
309
- const t = (Date.now() - start) / 1000;
321
+ let t = (Date.now() - start) / 1000;
322
+ if (t <= lastFrameT) t = lastFrameT + 0.001;
323
+ lastFrameT = t;
310
324
  sink.pushFrame(t, chunk);
311
325
  emu.write(chunk);
312
326
  if (output.length < OUTPUT_CAP_BYTES) {
@@ -397,8 +411,8 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
397
411
  }
398
412
  // Record cast time AFTER drain, so the `exit` step we record below
399
413
  // points the player at the very last frame (the program's final
400
- // output before EOF).
401
- exitCastTimeSec = (Date.now() - start) / 1000;
414
+ // output before EOF). Same between-frames stamp as `recordOpEvent`.
415
+ exitCastTimeSec = lastFrameT + 0.0005;
402
416
  sink.markClosed();
403
417
  resolveExit(code);
404
418
  })();
@@ -444,10 +458,17 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
444
458
  ): number | undefined => {
445
459
  if (!args.recordEvents) return undefined;
446
460
  const preview = truncateUtf8(renderScreen());
447
- // Use the SAME clock the asciicast frames use (offset from `start`,
448
- // which is captured at Bun.spawn). That way the UI seeks the player
449
- // directly to this value with no frame arithmetic.
450
- const castTimeSec = (Date.now() - start) / 1000;
461
+ // Stamp the step half a millisecond past its last ingested frame:
462
+ // frame times are strictly increasing in 1ms steps (see `ingest`),
463
+ // so the stamp sits strictly BETWEEN the op's own last frame and
464
+ // any later op's first frame. That makes the boundary unambiguous
465
+ // in both directions — an inclusive seek (`<= stamp`) shows the
466
+ // op's own frame and can never pull in the next op's, and the
467
+ // viewer's exclusive `waitFor` seek (stamp − 0.0004, which
468
+ // disambiguates legacy wall-clock stamps that tied with the next
469
+ // op's echo) still lands past the op's own frame. Same clock as
470
+ // the frames (offset from `start`), so the UI seeks directly.
471
+ const castTimeSec = lastFrameT + 0.0005;
451
472
  // Return the event seq so callers can `wrap()` their result value
452
473
  // against it — e.g. `expect(await term.waitFor(...))` then links its
453
474
  // assertion to this step (sourceSeq) and the UI nests it here.