pw-repl 0.8.2 → 0.9.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/AGENTS.md +8 -3
- package/README.md +3 -3
- package/bin/pw-repl.js +33 -41
- package/lib/cli-names.js +4 -3
- package/lib/client.js +3 -0
- package/lib/cursor.js +50 -9
- package/lib/elements.js +5 -4
- package/lib/help.js +496 -351
- package/lib/inspect.js +1 -1
- package/lib/record.js +66 -22
- package/lib/runner.js +2 -1
- package/lib/send.js +68 -1
- package/package.json +1 -1
- package/skill/SKILL.md +19 -10
package/AGENTS.md
CHANGED
|
@@ -41,9 +41,14 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
41
41
|
command or behaviour. In `test/repl.test.js` each test starts in a fresh tab of its own, and every mode
|
|
42
42
|
is turned off after it; a test whose REPL quits, times out or is killed goes in
|
|
43
43
|
`test/repl-alone.test.js`, with a Chromium of its own.
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
- A command's help (`lib/help.js`) follows playwright-cli's layout: `usage` and `summary`; `args` and
|
|
45
|
+
`options`, one `[name, text]` each, with the default in its text (`Default: 750; 0 none.`); `detail`,
|
|
46
|
+
short paragraphs on what most callers need, a paragraph on one theme starting with a label (`Steps
|
|
47
|
+
(--steps):`); and `more`, with `moreAbout` naming what is in it, for what only goes wrong now and then,
|
|
48
|
+
shown by `help <command> --all` and `help --all`. An error about something in `more` ends by naming it
|
|
49
|
+
(`help record --all`). Paragraphs are plain: `wrap()` lays each out at 104 columns, and keeps as written
|
|
50
|
+
a paragraph with a bullet, a table or an indented example. `test/fixtures` holds the rendered help;
|
|
51
|
+
after an intended change to it, save it again (the command is in `test/units.test.js`).
|
|
47
52
|
- Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`).
|
|
48
53
|
Compare with the last commit that has one (`git log --grep='npm test:'`): a run that grew by more than
|
|
49
54
|
a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
|
package/README.md
CHANGED
|
@@ -78,8 +78,8 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
78
78
|
- For `send` to reach `run`, start it in tmux: `tmux new -s playwright-repl`, then `pw-repl run` there.
|
|
79
79
|
- A URL after the command opens in a new tab: `pw-repl run http://localhost:3000`.
|
|
80
80
|
- `--launch` works with each of them; flags after `--` go to that Chromium.
|
|
81
|
-
-
|
|
82
|
-
|
|
81
|
+
- A socket of its own: `-e /tmp/mine.sock` on every command, `serve` included (`pw-repl serve --background
|
|
82
|
+
-e /tmp/mine.sock`), or `PW_SOCKET`; `serve <port>` listens on TCP on 127.0.0.1.
|
|
83
83
|
- A background REPL cannot be brought back like a Ctrl-Z job; `attach` is how you get back to it, from
|
|
84
84
|
any terminal.
|
|
85
85
|
|
|
@@ -98,7 +98,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
98
98
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
99
99
|
| see each step as I click | `watch on --live` |
|
|
100
100
|
| record requests and console together | `capture on`, then `capture off` |
|
|
101
|
-
| record a video of the page | `record on`, then `record off` (`help video` for
|
|
101
|
+
| record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
|
|
102
102
|
| show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
|
|
103
103
|
| point something out on the page | `highlight <ref>` (`--style=<css>` to restyle it), then `highlight off` |
|
|
104
104
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
package/bin/pw-repl.js
CHANGED
|
@@ -7,7 +7,7 @@ const USAGE = `Usage:
|
|
|
7
7
|
pw-repl run [--launch [--headed]] [start-url] [-- <chromium flags>]
|
|
8
8
|
connect to the browser and open the prompt
|
|
9
9
|
|
|
10
|
-
pw-repl serve [--background] [--launch [--headed]] [endpoint] [start-url] [-- <chromium flags>]
|
|
10
|
+
pw-repl serve [--background] [--launch [--headed]] [[-e] endpoint] [start-url] [-- <chromium flags>]
|
|
11
11
|
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock);
|
|
12
12
|
--background runs it detached, with its output in a log next to the socket
|
|
13
13
|
|
|
@@ -17,8 +17,8 @@ const USAGE = `Usage:
|
|
|
17
17
|
pw-repl stop [-e endpoint]
|
|
18
18
|
stop a background REPL, and say what became of each recording it ended
|
|
19
19
|
|
|
20
|
-
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
|
|
21
|
-
run one command in a running REPL and print its output
|
|
20
|
+
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...> | --file <file>
|
|
21
|
+
run one command in a running REPL and print its output; --file runs a file of them, one per line
|
|
22
22
|
|
|
23
23
|
pw-repl where [-e endpoint | -s session]
|
|
24
24
|
say which REPL send would reach
|
|
@@ -31,13 +31,20 @@ const USAGE = `Usage:
|
|
|
31
31
|
|
|
32
32
|
send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/playwright-repl.sock)
|
|
33
33
|
is there, and the tmux session (-s, $PW_TMUX_SESSION, default playwright-repl) otherwise. If the socket
|
|
34
|
-
exists but nothing answers, send fails rather than fall back.
|
|
34
|
+
exists but nothing answers, send fails rather than fall back. serve without an endpoint serves on that
|
|
35
|
+
same one: $PW_ENDPOINT, then $PW_SOCKET, then the default.
|
|
35
36
|
|
|
36
37
|
send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
|
|
37
38
|
several agents can share one REPL; pw-repl help session has the rest.
|
|
38
39
|
|
|
39
40
|
send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
|
|
40
41
|
|
|
42
|
+
send --file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt:
|
|
43
|
+
quoted as the prompt reads it (no shell), with a file it records, uploads or saves relative to this
|
|
44
|
+
folder, as on send's command line, and # comments and blank lines skipped. It stops at the first
|
|
45
|
+
command that fails, saying its line, with its exit status. -t is each command's own limit. Other
|
|
46
|
+
clients' commands can run between its lines, on their own tabs.
|
|
47
|
+
|
|
41
48
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
42
49
|
|
|
43
50
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
@@ -50,8 +57,8 @@ In a tmux session named playwright-repl, pw-repl send reaches pw-repl run withou
|
|
|
50
57
|
|
|
51
58
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
52
59
|
|
|
53
|
-
pw-repl help <topic | command | --all> shows one part of it; pw-repl help video and pw-repl
|
|
54
|
-
playwright-cli are guides.`;
|
|
60
|
+
pw-repl help <topic | command [--all] | --all> shows one part of it; pw-repl help video and pw-repl
|
|
61
|
+
help playwright-cli are guides.`;
|
|
55
62
|
|
|
56
63
|
function usage() {
|
|
57
64
|
console.error(USAGE);
|
|
@@ -60,7 +67,7 @@ function usage() {
|
|
|
60
67
|
|
|
61
68
|
// -e, -s, -c and -t, then the command words (unquoted words are one command).
|
|
62
69
|
function parseSendArgs(args, allowCommand) {
|
|
63
|
-
const options = { endpoint: null, session: null, client: null, timeout: 20, command: '' };
|
|
70
|
+
const options = { endpoint: null, session: null, client: null, timeout: 20, command: '', file: null };
|
|
64
71
|
let i = 0;
|
|
65
72
|
for (; i < args.length; i++) {
|
|
66
73
|
const arg = args[i];
|
|
@@ -73,6 +80,13 @@ function parseSendArgs(args, allowCommand) {
|
|
|
73
80
|
console.error('browser it shares, cookies, storage and modes and all.');
|
|
74
81
|
process.exit(64);
|
|
75
82
|
}
|
|
83
|
+
// A file of commands, one per line, as typed at the prompt.
|
|
84
|
+
if (arg.startsWith('--file=') && arg.length > 7) { options.file = require('path').resolve(arg.slice(7)); continue; }
|
|
85
|
+
if (arg === '--file') {
|
|
86
|
+
if (args[i + 1] === undefined) usage();
|
|
87
|
+
options.file = require('path').resolve(args[++i]);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
76
90
|
if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
|
|
77
91
|
const value = args[++i];
|
|
78
92
|
if (value === undefined) usage();
|
|
@@ -96,39 +110,9 @@ function parseSendArgs(args, allowCommand) {
|
|
|
96
110
|
}
|
|
97
111
|
}
|
|
98
112
|
const words = args.slice(i);
|
|
99
|
-
if (!allowCommand && words.length) usage();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// command takes the rest of its line as it is (eval, route's JSON), so its
|
|
103
|
-
// words are joined as they are.
|
|
104
|
-
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
105
|
-
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
106
|
-
// And record's file, which may have spaces in it.
|
|
107
|
-
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
|
|
108
|
-
// An empty word (fill #name "") is the empty value.
|
|
109
|
-
// upload's files are read by the REPL, whose folder may not be this one.
|
|
110
|
-
// So are the files upload reads and screenshot --filename writes.
|
|
111
|
-
const resolve = require('path').resolve;
|
|
112
|
-
let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
|
|
113
|
-
// record on <file> writes it too.
|
|
114
|
-
if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
|
|
115
|
-
resolved = words.map((w, n) => {
|
|
116
|
-
if (n < (words[0] === 'record' ? 2 : 1)) return w;
|
|
117
|
-
if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
|
|
118
|
-
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
if (words[0] === 'screenshot') {
|
|
122
|
-
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
123
|
-
}
|
|
124
|
-
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
|
|
125
|
-
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
|
126
|
-
// Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
|
|
127
|
-
for (const [n, w] of quoted.entries()) {
|
|
128
|
-
const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
|
|
129
|
-
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
|
|
130
|
-
}
|
|
131
|
-
options.command = quoted.join(' ').trim();
|
|
113
|
+
if (!allowCommand && (words.length || options.file)) usage();
|
|
114
|
+
if (options.file && words.length) usage();
|
|
115
|
+
options.command = require('../lib/send').commandLine(words);
|
|
132
116
|
return options;
|
|
133
117
|
}
|
|
134
118
|
|
|
@@ -143,7 +127,15 @@ function startOptions(args, serve) {
|
|
|
143
127
|
options.launch = flag('--launch');
|
|
144
128
|
options.headed = flag('--headed');
|
|
145
129
|
if ((options.headed || options.chromeArgs.length) && !options.launch) usage();
|
|
146
|
-
|
|
130
|
+
// -e <endpoint>, as send, attach, stop and where take it, or the endpoint as the first word.
|
|
131
|
+
const e = rest.indexOf('-e');
|
|
132
|
+
if (serve && e !== -1 && rest[e + 1] && looksLikeEndpoint(rest[e + 1])) {
|
|
133
|
+
options.endpoint = rest.splice(e, 2)[1];
|
|
134
|
+
// Two endpoints, one with -e: neither silently wins.
|
|
135
|
+
if (rest[0] && looksLikeEndpoint(rest[0])) usage();
|
|
136
|
+
} else if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
|
|
137
|
+
// Otherwise the socket send would look for: PW_ENDPOINT, then PW_SOCKET, then the default.
|
|
138
|
+
if (serve && !options.endpoint) options.endpoint = process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
|
|
147
139
|
if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
|
|
148
140
|
options.startUrl = rest[0] || null;
|
|
149
141
|
return options;
|
package/lib/cli-names.js
CHANGED
|
@@ -91,9 +91,10 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
|
|
|
91
91
|
NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
|
|
92
92
|
}
|
|
93
93
|
for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
NEAREST['video-chapter'] = 'not here: record on|off records a video, without chapters';
|
|
95
|
+
// Theirs labels each action and pauses on it; the cursor is a pointer that moves to each action instead.
|
|
96
|
+
NEAREST['video-show-actions'] = 'not here; cursor on draws a pointer that moves to each action, without labels';
|
|
97
|
+
NEAREST['video-hide-actions'] = 'not here; cursor off hides the pointer cursor on draws';
|
|
97
98
|
for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
|
|
98
99
|
|
|
99
100
|
const NAME_LIST = Object.keys(NAMES);
|
package/lib/client.js
CHANGED
|
@@ -69,6 +69,9 @@ function request(endpoint, command, timeoutMs, client = null) {
|
|
|
69
69
|
...target(endpoint),
|
|
70
70
|
path: '/run',
|
|
71
71
|
method: 'POST',
|
|
72
|
+
// A connection of its own each time: on one kept alive from an earlier command (send --file),
|
|
73
|
+
// 'connect' never fires, and a drop would be taken for a command never sent.
|
|
74
|
+
agent: false,
|
|
72
75
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
73
76
|
}, res => {
|
|
74
77
|
let text = '';
|
package/lib/cursor.js
CHANGED
|
@@ -9,7 +9,7 @@ const { unquote, toSelector } = require('./syntax');
|
|
|
9
9
|
const { recordingOf, stepTarget, cursorStep } = require('./record');
|
|
10
10
|
|
|
11
11
|
// Scaled with the distance, so a short hop is quick and a long one does not drag. Slower while the tab
|
|
12
|
-
// records, so a
|
|
12
|
+
// records, so a viewer can follow it.
|
|
13
13
|
const DWELL_MS = 300;
|
|
14
14
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
15
15
|
|
|
@@ -80,8 +80,8 @@ function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
|
|
|
80
80
|
cursor.y = y;
|
|
81
81
|
place(cursor.arrow, x, y);
|
|
82
82
|
if (!ms) return;
|
|
83
|
-
// Smoothstep, exactly (x = t with these control points, and y = 3t² - 2t³)
|
|
84
|
-
//
|
|
83
|
+
// Smoothstep, exactly (x = t with these control points, and y = 3t² - 2t³), so a tool editing the
|
|
84
|
+
// video can work out from the steps file where the cursor is on every frame.
|
|
85
85
|
const glide = cursor.arrow.animate([{ transform: from }, { transform: to }], { duration: ms, easing: 'cubic-bezier(0.3333, 0, 0.6667, 1)' });
|
|
86
86
|
// A hidden tab draws no frames, and its animation may never finish: not waited on past its time.
|
|
87
87
|
return Promise.race([glide.finished, new Promise(resolve => setTimeout(resolve, ms + 300))]).then(() => {}, () => {});
|
|
@@ -158,19 +158,52 @@ async function glideTo(p, x, y) {
|
|
|
158
158
|
const cursor = cursorOf(p);
|
|
159
159
|
if (!cursor) return;
|
|
160
160
|
const ms = glideTime(Math.hypot(x - cursor.x, y - cursor.y), !!recordingOf(p));
|
|
161
|
-
// A recording notes where the pointer went and when, for
|
|
161
|
+
// A recording with --steps notes where the pointer went and when, for whoever edits the video.
|
|
162
162
|
if (ms) cursorStep(p, `(cursor from ${cursor.x} ${cursor.y})`, { x, y }, ms);
|
|
163
163
|
cursor.x = x;
|
|
164
164
|
cursor.y = y;
|
|
165
165
|
await p.evaluate(pageCursor, { x, y, action: 'move', ms }).catch(() => {});
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
// Whether a command should find its element first: to glide the cursor there, or
|
|
169
|
-
//
|
|
168
|
+
// Whether a command should find its element first: to glide the cursor there, or, while recording, to
|
|
169
|
+
// scroll to it smoothly and note it in the steps (record on --steps).
|
|
170
170
|
function aiming(p) {
|
|
171
171
|
return !!(cursorOf(p) || recordingOf(p));
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// While recording, an element out of view is scrolled to smoothly, to the middle, rather than jumped to
|
|
175
|
+
// as Playwright's own scroll would. The page scrolls itself, so no input is sent and a scrollable box
|
|
176
|
+
// it is in scrolls too. One larger than the view is scrolled to only if its centre, where Playwright
|
|
177
|
+
// acts, is out of view. Done when it has held still for a few frames: a nested box ends its scroll on
|
|
178
|
+
// its own, and nothing ends one that never started.
|
|
179
|
+
async function scrollSmoothly(target) {
|
|
180
|
+
// Chrome slows a background tab's timers too, so the REPL bounds the wait as well.
|
|
181
|
+
await Promise.race([sleep(2000), target.evaluate(async (el, cap) => {
|
|
182
|
+
const r = el.getBoundingClientRect();
|
|
183
|
+
const cx = r.left + r.width / 2;
|
|
184
|
+
const cy = r.top + r.height / 2;
|
|
185
|
+
const inView = r.width > innerWidth || r.height > innerHeight
|
|
186
|
+
? cx >= 0 && cy >= 0 && cx <= innerWidth && cy <= innerHeight
|
|
187
|
+
: r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight;
|
|
188
|
+
if (inView) return;
|
|
189
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
190
|
+
// A tab that is not drawing (behind another, minimised) has no frames: the timer keeps the cap.
|
|
191
|
+
const frame = () => new Promise(resolve => { requestAnimationFrame(resolve); setTimeout(resolve, 100); });
|
|
192
|
+
const started = performance.now();
|
|
193
|
+
const from = `${r.left},${r.top}`;
|
|
194
|
+
let last = from;
|
|
195
|
+
let still = 0;
|
|
196
|
+
// A smooth scroll can take a frame or two to start: longer to wait while nothing has moved yet.
|
|
197
|
+
while (performance.now() - started < cap && still < (last === from ? 10 : 3)) {
|
|
198
|
+
await frame();
|
|
199
|
+
const b = el.getBoundingClientRect();
|
|
200
|
+
const at = `${b.left},${b.top}`;
|
|
201
|
+
still = at === last ? still + 1 : 0;
|
|
202
|
+
last = at;
|
|
203
|
+
}
|
|
204
|
+
}, 1500).catch(() => {})]);
|
|
205
|
+
}
|
|
206
|
+
|
|
174
207
|
// Finds the centre of an element (a locator or a handle), where Playwright clicks, after scrolling it
|
|
175
208
|
// into view as Playwright would, and glides the cursor there if it is on: the pointer is there before
|
|
176
209
|
// the click, not after. A recording notes the element in its steps.
|
|
@@ -182,6 +215,7 @@ async function aimAt(p, target, timeout) {
|
|
|
182
215
|
if (target.waitFor) await target.waitFor({ state: 'attached', timeout });
|
|
183
216
|
try {
|
|
184
217
|
if (!await target.isVisible()) return null;
|
|
218
|
+
if (recordingOf(p)) await scrollSmoothly(target);
|
|
185
219
|
await target.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
186
220
|
const box = await target.boundingBox({ timeout: 1000 });
|
|
187
221
|
if (!box) return null;
|
|
@@ -270,10 +304,11 @@ const commands = {
|
|
|
270
304
|
const usage = 'Usage: cursor [on [<selector> | <x> <y>] | off]';
|
|
271
305
|
const on = /^on(?:\s+([\s\S]+))?$/.exec(arg);
|
|
272
306
|
if (!on) throw new Error(usage);
|
|
273
|
-
if (cursor) { out.log(`The cursor is already on in the selected tab, at ${cursor.x}, ${cursor.y}; mousemove or a command on an element moves it`); return; }
|
|
274
307
|
// Where it is asked to appear (a video's first element, say), or where the mouse last went, if the
|
|
275
308
|
// REPL knows; otherwise the middle of the viewport.
|
|
276
309
|
const where = (on[1] || '').trim();
|
|
310
|
+
if (cursor && !where) { out.log(`The cursor is already on in the selected tab, at ${cursor.x}, ${cursor.y}; cursor on <selector> or <x> <y> moves it`); return; }
|
|
311
|
+
const still = cursor ? 'the cursor stays where it was' : 'the cursor is still off';
|
|
277
312
|
let at;
|
|
278
313
|
if (/^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?$/.test(where)) {
|
|
279
314
|
const [x, y] = where.split(/\s+/).map(n => Math.round(Number(n)));
|
|
@@ -283,14 +318,20 @@ const commands = {
|
|
|
283
318
|
try {
|
|
284
319
|
await target.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
285
320
|
} catch {
|
|
286
|
-
throw new Error(`No element matches ${where} that can be shown (waited 5s);
|
|
321
|
+
throw new Error(`No element matches ${where} that can be shown (waited 5s); ${still}`);
|
|
287
322
|
}
|
|
288
323
|
const box = await target.boundingBox();
|
|
289
|
-
if (!box) throw new Error(`${where} is not on the page to show the cursor at;
|
|
324
|
+
if (!box) throw new Error(`${where} is not on the page to show the cursor at; ${still}`);
|
|
290
325
|
at = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
|
|
291
326
|
} else {
|
|
292
327
|
at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
|
|
293
328
|
}
|
|
329
|
+
// Already on: the drawing glides there, and the real mouse stays put, as with fill.
|
|
330
|
+
if (cursor) {
|
|
331
|
+
await glideTo(p, at.x, at.y);
|
|
332
|
+
out.log(`The cursor moved to ${at.x}, ${at.y}`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
294
335
|
await p.evaluate(pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
|
|
295
336
|
tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
|
|
296
337
|
cursorStep(p, '(cursor on)', at, 0);
|
package/lib/elements.js
CHANGED
|
@@ -66,8 +66,9 @@ function selectorAndValue(args, usage, example) {
|
|
|
66
66
|
// An element that never appeared is said plainly, not as Playwright's call
|
|
67
67
|
// log, and so is one that timed out: its call log (dozens of lines of retries) goes
|
|
68
68
|
// to the REPL's pane and log only. A ref that no longer matches usually means the page
|
|
69
|
-
// changed since the snapshot it came from.
|
|
70
|
-
|
|
69
|
+
// changed since the snapshot it came from. It says the command's whole budget: Playwright's own
|
|
70
|
+
// timeout is what was left of it for the last call, after a glide say, and can read 4999ms for 5000.
|
|
71
|
+
async function onElement(selector, action, budget = 5000) {
|
|
71
72
|
try {
|
|
72
73
|
return await action();
|
|
73
74
|
} catch (error) {
|
|
@@ -77,12 +78,12 @@ async function onElement(selector, action) {
|
|
|
77
78
|
const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
|
|
78
79
|
const timeout = /Timeout (\d+)ms exceeded/.exec(message);
|
|
79
80
|
if (timeout && /waiting for locator/.test(message) && !/resolved to/.test(message)) {
|
|
80
|
-
throw new Error(`No element matches ${ref || selector} (waited ${
|
|
81
|
+
throw new Error(`No element matches ${ref || selector} (waited ${budget / 1000}s)${staleRef}`);
|
|
81
82
|
}
|
|
82
83
|
if (!timeout) { error.message += staleRef; throw error; }
|
|
83
84
|
console.error(`Playwright's call log:\n${message}`);
|
|
84
85
|
// Found, so the ref is not stale: no hint.
|
|
85
|
-
const summary = new Error(`Timed out after ${
|
|
86
|
+
const summary = new Error(`Timed out after ${budget / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
|
|
86
87
|
// Worked out from the whole log: the summary no longer says whether the element was found.
|
|
87
88
|
summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
|
|
88
89
|
throw summary;
|