@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 +1 -0
- package/dist/index.js +48 -3
- package/dist/locator-errors.d.ts +21 -0
- package/dist/locator-errors.js +142 -0
- package/dist/locator.js +18 -6
- package/dist/recorder.d.ts +8 -0
- package/dist/terminal.js +29 -7
- package/package.json +1 -1
- package/src/daemon.ts +1 -0
- package/src/index.ts +45 -3
- package/src/locator-errors.test.ts +125 -0
- package/src/locator-errors.ts +153 -0
- package/src/locator.ts +25 -6
- package/src/recorder.ts +8 -0
- package/src/terminal.ts +28 -7
package/dist/daemon.js
CHANGED
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
|
|
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
|
|
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
|
-
|
|
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/dist/recorder.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
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
package/src/daemon.ts
CHANGED
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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(
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
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.
|