pw-repl 0.8.2 → 0.10.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 +9 -7
- package/bin/pw-repl.js +33 -41
- package/lib/cli-names.js +4 -3
- package/lib/client.js +3 -0
- package/lib/cursor.js +65 -12
- package/lib/elements.js +93 -14
- package/lib/emulation.js +13 -1
- package/lib/help.js +511 -353
- package/lib/inspect.js +31 -3
- package/lib/modes.js +5 -1
- package/lib/record.js +68 -31
- package/lib/runner.js +2 -1
- package/lib/send.js +68 -1
- package/package.json +1 -1
- package/skill/SKILL.md +37 -25
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,9 +98,9 @@ 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
|
-
| point something out on the page | `highlight <ref
|
|
103
|
+
| point something out on the page | `highlight <ref>`: a box, unlabelled unless `--labels on`; `highlight off` hides it |
|
|
104
104
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
105
105
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
106
106
|
| slow the whole network | `network slow` |
|
|
@@ -112,8 +112,8 @@ Everything else is in `help <topic>`; `help <command>` has usage and caveats.
|
|
|
112
112
|
|
|
113
113
|
`watch`, `capture`, `route`, `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight` and `cursor`
|
|
114
114
|
stay on until you turn them off: `watch on|off`, `capture on|off`, `route ...|route off`, `network off|slow|on`,
|
|
115
|
-
`emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`, `highlight <selector>|highlight off
|
|
116
|
-
`cursor on|off`.
|
|
115
|
+
`emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`, `highlight <selector>|highlight off`
|
|
116
|
+
(and `highlight --labels on|off`), `cursor on|off`.
|
|
117
117
|
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
118
118
|
lists them for every tab, and `modes off` turns them all off.
|
|
119
119
|
|
|
@@ -125,7 +125,7 @@ their own show their state and what you can run next.
|
|
|
125
125
|
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
126
126
|
gives each agent a browser session of its own; pw-repl shares one browser between a person and agents.
|
|
127
127
|
Its command names and most of their options work here too (`help playwright-cli` lists them), and where
|
|
128
|
-
pw-repl differs, it is because someone else may be using the browser:
|
|
128
|
+
pw-repl differs, it is mostly because someone else may be using the browser:
|
|
129
129
|
|
|
130
130
|
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
131
131
|
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
@@ -133,6 +133,8 @@ pw-repl differs, it is because someone else may be using the browser:
|
|
|
133
133
|
- There is no snapshot after every command, since a person reads the REPL too; `snapshot` shows one.
|
|
134
134
|
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
135
135
|
- `watch` records what a person does as steps with their requests, not as generated code.
|
|
136
|
+
- `highlight` draws its box without Playwright's locator label, which covers what is below it and puts
|
|
137
|
+
selectors in screenshots and videos; `highlight --labels on` shows it.
|
|
136
138
|
|
|
137
139
|
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
138
140
|
playwright-cli is the better fit. `record on` records a tab's page, and `cursor on` draws a pointer where the
|
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)));
|
|
@@ -281,16 +316,34 @@ const commands = {
|
|
|
281
316
|
} else if (where) {
|
|
282
317
|
const target = p.locator(toSelector(unquote(where))).first();
|
|
283
318
|
try {
|
|
284
|
-
await target.
|
|
319
|
+
await target.waitFor({ state: 'visible', 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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
323
|
+
// Only pointing: an element partly in view is pointed at where it shows, not scrolled, so that
|
|
324
|
+
// one the page cuts off still looks cut off (a bug in a video). One wholly out of view is scrolled to.
|
|
325
|
+
const view = await p.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
326
|
+
const inView = b => b && b.x < view.width && b.y < view.height && b.x + b.width > 0 && b.y + b.height > 0;
|
|
327
|
+
let box = await target.boundingBox();
|
|
328
|
+
if (!inView(box)) {
|
|
329
|
+
await target.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
|
|
330
|
+
box = await target.boundingBox();
|
|
331
|
+
}
|
|
332
|
+
if (!box) throw new Error(`${where} is not on the page to show the cursor at; ${still}`);
|
|
333
|
+
const left = Math.max(box.x, 0);
|
|
334
|
+
const top = Math.max(box.y, 0);
|
|
335
|
+
const right = Math.min(box.x + box.width, view.width);
|
|
336
|
+
const bottom = Math.min(box.y + box.height, view.height);
|
|
337
|
+
at = { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
|
|
291
338
|
} else {
|
|
292
339
|
at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
|
|
293
340
|
}
|
|
341
|
+
// Already on: the drawing glides there, and the real mouse stays put, as with fill.
|
|
342
|
+
if (cursor) {
|
|
343
|
+
await glideTo(p, at.x, at.y);
|
|
344
|
+
out.log(`The cursor moved to ${at.x}, ${at.y}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
294
347
|
await p.evaluate(pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
|
|
295
348
|
tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
|
|
296
349
|
cursorStep(p, '(cursor on)', at, 0);
|
package/lib/elements.js
CHANGED
|
@@ -9,6 +9,7 @@ const { numbers, hints } = require('./util');
|
|
|
9
9
|
const { tabState } = require('./tabstate');
|
|
10
10
|
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
11
11
|
const { recordingOf, stepTarget } = require('./record');
|
|
12
|
+
const { settled } = require('./emulation');
|
|
12
13
|
|
|
13
14
|
// The type a page sees for a file it is given, from its extension.
|
|
14
15
|
const MIME_TYPES = {
|
|
@@ -66,8 +67,9 @@ function selectorAndValue(args, usage, example) {
|
|
|
66
67
|
// An element that never appeared is said plainly, not as Playwright's call
|
|
67
68
|
// log, and so is one that timed out: its call log (dozens of lines of retries) goes
|
|
68
69
|
// 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
|
-
|
|
70
|
+
// changed since the snapshot it came from. It says the command's whole budget: Playwright's own
|
|
71
|
+
// timeout is what was left of it for the last call, after a glide say, and can read 4999ms for 5000.
|
|
72
|
+
async function onElement(selector, action, budget = 5000) {
|
|
71
73
|
try {
|
|
72
74
|
return await action();
|
|
73
75
|
} catch (error) {
|
|
@@ -77,12 +79,12 @@ async function onElement(selector, action) {
|
|
|
77
79
|
const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
|
|
78
80
|
const timeout = /Timeout (\d+)ms exceeded/.exec(message);
|
|
79
81
|
if (timeout && /waiting for locator/.test(message) && !/resolved to/.test(message)) {
|
|
80
|
-
throw new Error(`No element matches ${ref || selector} (waited ${
|
|
82
|
+
throw new Error(`No element matches ${ref || selector} (waited ${budget / 1000}s)${staleRef}`);
|
|
81
83
|
}
|
|
82
84
|
if (!timeout) { error.message += staleRef; throw error; }
|
|
83
85
|
console.error(`Playwright's call log:\n${message}`);
|
|
84
86
|
// Found, so the ref is not stale: no hint.
|
|
85
|
-
const summary = new Error(`Timed out after ${
|
|
87
|
+
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
88
|
// Worked out from the whole log: the summary no longer says whether the element was found.
|
|
87
89
|
summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
|
|
88
90
|
throw summary;
|
|
@@ -281,7 +283,7 @@ async function hideHighlights(p, mine = false) {
|
|
|
281
283
|
if (!highlights?.size) return 0;
|
|
282
284
|
const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
|
|
283
285
|
if (hidden.length === highlights.size) await p.hideHighlight();
|
|
284
|
-
else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
|
|
286
|
+
else for (const [selector, h] of hidden) await (h.drawn || p.locator(selector)).hideHighlight();
|
|
285
287
|
for (const [selector] of hidden) highlights.delete(selector);
|
|
286
288
|
return hidden.length;
|
|
287
289
|
}
|
|
@@ -292,6 +294,67 @@ onShutdown(async () => {
|
|
|
292
294
|
await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
|
|
293
295
|
});
|
|
294
296
|
|
|
297
|
+
// Playwright's highlight labels each box with its locator, which covers what is below it, and has
|
|
298
|
+
// no option not to; highlights are drawn without it unless highlight --labels on. Its overlay is an
|
|
299
|
+
// x-pw-glass element with a closed shadow root, which only CDP reaches: a stylesheet adopted there
|
|
300
|
+
// hides the labels, under any CSP, and stays as Playwright redraws them. Playwright removes the
|
|
301
|
+
// overlay with its last highlight, and the sheet with it, so it is applied again after each one.
|
|
302
|
+
const LABELS_OFF = 'x-pw-tooltip:not([data-pw-repl])';
|
|
303
|
+
// The clients told that the labels could not be hidden, each once.
|
|
304
|
+
const labelsWarned = new Set();
|
|
305
|
+
|
|
306
|
+
// Adds or removes the sheet in the main document's overlay, and says whether it worked: false when
|
|
307
|
+
// there is an overlay but its labels are not hidden. Null when there is no overlay.
|
|
308
|
+
async function applyLabels(p, hide) {
|
|
309
|
+
const session = await p.context().newCDPSession(p);
|
|
310
|
+
try {
|
|
311
|
+
const { root } = await session.send('DOM.getDocument', { depth: 0 });
|
|
312
|
+
const { nodeId } = await session.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'x-pw-glass' });
|
|
313
|
+
if (!nodeId) return null;
|
|
314
|
+
const { node } = await session.send('DOM.describeNode', { nodeId, pierce: true });
|
|
315
|
+
const shadow = node.shadowRoots?.[0];
|
|
316
|
+
if (!shadow) return false;
|
|
317
|
+
const { object } = await session.send('DOM.resolveNode', { backendNodeId: shadow.backendNodeId });
|
|
318
|
+
const call = (fn, args) => session.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: fn.toString(), arguments: args.map(value => ({ value })), returnByValue: true });
|
|
319
|
+
await call(function (rule, hidden) {
|
|
320
|
+
const sheets = [...this.adoptedStyleSheets].filter(s => ![...s.cssRules].some(r => r.selectorText === rule));
|
|
321
|
+
if (hidden) {
|
|
322
|
+
const sheet = new CSSStyleSheet();
|
|
323
|
+
sheet.replaceSync(`${rule} { display: none !important; }`);
|
|
324
|
+
sheets.push(sheet);
|
|
325
|
+
}
|
|
326
|
+
this.adoptedStyleSheets = sheets;
|
|
327
|
+
}, [LABELS_OFF, hide]);
|
|
328
|
+
if (!hide) return true;
|
|
329
|
+
// Playwright draws its labels on a later frame.
|
|
330
|
+
await settled(p);
|
|
331
|
+
const { result } = await call(function (rule) {
|
|
332
|
+
const ours = [...this.adoptedStyleSheets].some(s => [...s.cssRules].some(r => r.selectorText === rule));
|
|
333
|
+
return ours && [...this.querySelectorAll('x-pw-tooltip')].every(t => getComputedStyle(t).display === 'none');
|
|
334
|
+
}, [LABELS_OFF]);
|
|
335
|
+
return result.value === true;
|
|
336
|
+
} finally {
|
|
337
|
+
await session.detach().catch(() => {});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Hides the tab's labels unless they are on; says so once if that failed, and the highlight is drawn
|
|
342
|
+
// with its label as Playwright draws it. A CDP call that fails, as one does when the tab navigates or
|
|
343
|
+
// closes, says nothing of Playwright's overlay: only the check finding the labels shown does.
|
|
344
|
+
async function keepLabels(p) {
|
|
345
|
+
if (tabState(p).labelsOn) return;
|
|
346
|
+
const worked = await applyLabels(p, true).catch(() => null);
|
|
347
|
+
const client = state.client ?? null;
|
|
348
|
+
if (worked !== false || labelsWarned.has(client)) return;
|
|
349
|
+
labelsWarned.add(client);
|
|
350
|
+
out.log(`highlight: could not hide the labels; Playwright's highlight has changed (playwright-core ${require('playwright-core/package.json').version}). The boxes are drawn with their labels.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function setLabels(p, on) {
|
|
354
|
+
if (on) tabState(p).labelsOn = { owner: state.client }; else delete tabState(p).labelsOn;
|
|
355
|
+
if (on) await applyLabels(p, false); else await keepLabels(p);
|
|
356
|
+
}
|
|
357
|
+
|
|
295
358
|
const commands = {
|
|
296
359
|
async click(args) {
|
|
297
360
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
@@ -312,15 +375,27 @@ const commands = {
|
|
|
312
375
|
},
|
|
313
376
|
|
|
314
377
|
async highlight(args) {
|
|
315
|
-
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
|
|
316
|
-
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
|
|
378
|
+
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | --labels off|on | off]';
|
|
379
|
+
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag', labels: 'value' });
|
|
317
380
|
const style = found.find(o => o.name === 'style');
|
|
318
381
|
const hide = found.some(o => o.name === 'hide');
|
|
382
|
+
const labels = found.find(o => o.name === 'labels');
|
|
319
383
|
const highlights = highlightsOf(state.page);
|
|
384
|
+
// For the whole tab, with a highlight to draw or on its own.
|
|
385
|
+
if (labels) {
|
|
386
|
+
if (hide || rest === 'off' || !['off', 'on'].includes(labels.value)) throw new Error(usage);
|
|
387
|
+
await setLabels(state.page, labels.value === 'on');
|
|
388
|
+
out.log(labels.value === 'on'
|
|
389
|
+
? 'Labels on in the selected tab: each highlight is labelled with its locator; highlight --labels off hides them'
|
|
390
|
+
: 'Labels off in the selected tab');
|
|
391
|
+
if (!rest) return;
|
|
392
|
+
}
|
|
320
393
|
if (!rest && !found.length) {
|
|
321
394
|
if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
|
|
322
|
-
for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
323
|
-
|
|
395
|
+
for (const [selector, h] of highlights) out.log(`${selector}${h.drawn?.toString() !== state.page.locator(selector).toString() ? ` as ${h.drawn}` : ''}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
396
|
+
const on = tabState(state.page).labelsOn;
|
|
397
|
+
if (on) out.log(`Labels are on${on.owner ? ` (by ${on.owner})` : ''}.`);
|
|
398
|
+
out.log(hints([['highlight --hide [<selector>]', 'hide one, or all'], on ? ['highlight --labels off', 'hide their labels'] : ['highlight --labels on', 'label each with its locator']]));
|
|
324
399
|
return;
|
|
325
400
|
}
|
|
326
401
|
if (rest === 'off' && !found.length || hide && !rest) {
|
|
@@ -333,7 +408,7 @@ const commands = {
|
|
|
333
408
|
const locator = state.page.locator(selector);
|
|
334
409
|
if (hide) {
|
|
335
410
|
if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
|
|
336
|
-
await locator.hideHighlight();
|
|
411
|
+
await (highlights.get(selector).drawn || locator).hideHighlight();
|
|
337
412
|
highlights.delete(selector);
|
|
338
413
|
out.log(`Hid the highlight on ${rest}`);
|
|
339
414
|
return;
|
|
@@ -341,9 +416,13 @@ const commands = {
|
|
|
341
416
|
const count = await locator.count();
|
|
342
417
|
if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
|
|
343
418
|
// Drawn again, with its new style, rather than twice.
|
|
344
|
-
if (highlights.has(selector)) await locator.hideHighlight();
|
|
345
|
-
|
|
346
|
-
|
|
419
|
+
if (highlights.has(selector)) await (highlights.get(selector).drawn || locator).hideHighlight();
|
|
420
|
+
// Playwright labels a box with the locator it is given, and a snapshot ref's (aria-ref=e5) finds
|
|
421
|
+
// nothing in the page's code: one is drawn with the locator Playwright makes for its element.
|
|
422
|
+
const drawn = REF.test(rest) ? await locator.normalize().catch(() => locator) : locator;
|
|
423
|
+
await drawn.highlight(style ? { style: style.value } : {});
|
|
424
|
+
await keepLabels(state.page);
|
|
425
|
+
highlights.set(selector, { owner: state.client, style: style?.value || null, drawn });
|
|
347
426
|
out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
|
|
348
427
|
},
|
|
349
428
|
|
|
@@ -510,4 +589,4 @@ const commands = {
|
|
|
510
589
|
},
|
|
511
590
|
};
|
|
512
591
|
|
|
513
|
-
module.exports = { commands, soleSelector, onElement, hideHighlights };
|
|
592
|
+
module.exports = { commands, soleSelector, onElement, hideHighlights, setLabels };
|
package/lib/emulation.js
CHANGED
|
@@ -66,6 +66,18 @@ function settled(p) {
|
|
|
66
66
|
]);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Bringing a tab to the front can leave Chrome capturing it at the window's size after its next
|
|
70
|
+
// navigation, although the page is still laid out at its viewport's or phone's: sending the same
|
|
71
|
+
// metrics again, on the kept session, has it draw at them.
|
|
72
|
+
async function reapplyMetrics(p) {
|
|
73
|
+
const tab = tabState(p);
|
|
74
|
+
if (!tab.viewport && !tab.emulation?.device) return;
|
|
75
|
+
const session = await keptSession(p);
|
|
76
|
+
if (tab.viewport) await viewportMetrics(session, tab.viewport);
|
|
77
|
+
else if (tab.emulation?.device) await deviceMetrics(session, tab.emulation.device);
|
|
78
|
+
await settled(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
async function setViewport(p, size) {
|
|
70
82
|
const session = await keptSession(p);
|
|
71
83
|
if (size) {
|
|
@@ -220,4 +232,4 @@ const commands = {
|
|
|
220
232
|
},
|
|
221
233
|
};
|
|
222
234
|
|
|
223
|
-
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled };
|
|
235
|
+
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled, reapplyMetrics };
|