pw-repl 0.10.0 → 0.11.1
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 +3 -2
- package/README.md +33 -19
- package/bin/pw-repl.js +13 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +21 -47
- package/lib/elements.js +45 -8
- package/lib/help.js +85 -19
- package/lib/inspect.js +35 -4
- package/lib/modes.js +6 -1
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +13 -2
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +61 -44
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
14
14
|
`READ_ONLY` or `INSPECTION`, and one that takes fixed words in `complete` (`lib/commands.js`).
|
|
15
15
|
- The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
|
|
16
16
|
`lib/elements.js` (choosing a match; the interact and mouse commands; highlight), `lib/cursor.js` (the pointer
|
|
17
|
-
drawn over the page, which the element and mouse commands glide first), `lib/
|
|
17
|
+
drawn over the page, which the element and mouse commands glide first), `lib/toast.js` (a line of text
|
|
18
|
+
over the page), `lib/overlay.js` (the element the cursor and toast are drawn in, one layer each), `lib/inspect.js` (reading the
|
|
18
19
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
19
20
|
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/record.js`
|
|
20
21
|
(video, through ffmpeg, and the steps file saved with it), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
|
@@ -24,7 +25,7 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
24
25
|
runs commands one at a time (`quit` and `dialog` skip its queue); `lib/server.js` is the command
|
|
25
26
|
server; `lib/send.js` and `lib/client.js` are `send` and `where`; `lib/background.js` is
|
|
26
27
|
`serve --background`, `attach` and `stop`; `lib/launch.js` is `--launch`; `lib/syntax.js` is how a
|
|
27
|
-
command line's words are read, shared with `send
|
|
28
|
+
command line's words are read, shared with `send`, and its `{{ PW_NAME }}` variables; `lib/cli-names.js` maps playwright-cli's command
|
|
28
29
|
names to these; `lib/state.js` holds the session state, with each client's selected tab;
|
|
29
30
|
`lib/output.js` routes all output so the server can return it; `lib/skill.js` stamps the skill
|
|
30
31
|
`pw-repl skill` prints, and gives `where` its hash.
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# playwright-repl
|
|
2
2
|
|
|
3
3
|
A command REPL for a Chromium you are already using, shared by you and your agents. It connects over CDP
|
|
4
|
-
through Playwright;
|
|
4
|
+
through Playwright; users click in the browser and type at the prompt, agents send the same commands,
|
|
5
5
|
and every command shows in the REPL, so each sees what the other does.
|
|
6
6
|
|
|
7
7
|
## Why use it
|
|
@@ -13,7 +13,8 @@ For working on a web page together with an agent, in the browser you already use
|
|
|
13
13
|
- **One session, seen by everyone.** You and any number of agents drive the same browser, each with a
|
|
14
14
|
selected tab of its own, and every command shows in the REPL, whoever sent it.
|
|
15
15
|
- **What you did, for an agent to read.** `watch` records each step you take in a tab, with the requests
|
|
16
|
-
it caused, so you can reproduce a bug by hand and the agent can see exactly what happened
|
|
16
|
+
it caused, so you can reproduce a bug by hand and the agent can see exactly what happened; `watch save`
|
|
17
|
+
keeps the steps as a file of commands that runs them again.
|
|
17
18
|
- **Breaking things on purpose.** Fake, patch, delay or fail responses, cut or slow the network, emulate a
|
|
18
19
|
phone, a locale or a timezone, all per tab, so your other tabs are left alone.
|
|
19
20
|
|
|
@@ -87,7 +88,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
87
88
|
|
|
88
89
|
| I want to… | Commands |
|
|
89
90
|
| ------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
90
|
-
| see where I am | `tab
|
|
91
|
+
| see where I am | `tab` (it marks the tab you are looking at `(in front)`), `info` |
|
|
91
92
|
| see what is on the page | `snapshot`, `screenshot` |
|
|
92
93
|
| do something on it | `click`, `fill`, `press` |
|
|
93
94
|
| wait for a page or element | `wait load`, `wait <selector>`, `wait <selector> --gone` |
|
|
@@ -97,9 +98,11 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
97
98
|
| see console messages and errors | `console` |
|
|
98
99
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
99
100
|
| see each step as I click | `watch on --live` |
|
|
101
|
+
| replay what I clicked through | `watch on`, click around, `watch save flow.txt`, then `pw-repl send --file flow.txt` |
|
|
100
102
|
| record requests and console together | `capture on`, then `capture off` |
|
|
101
103
|
| record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
|
|
102
104
|
| show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
|
|
105
|
+
| say what is happening on screen | `toast <text>`, then `toast off` (`--duration=3s` hides it by itself) |
|
|
103
106
|
| point something out on the page | `highlight <ref>`: a box, unlabelled unless `--labels on`; `highlight off` hides it |
|
|
104
107
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
105
108
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
@@ -110,29 +113,31 @@ Everything else is in `help <topic>`; `help <command>` has usage and caveats.
|
|
|
110
113
|
|
|
111
114
|
### Modes
|
|
112
115
|
|
|
113
|
-
`watch`, `capture`, `route`, `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight
|
|
114
|
-
stay on until you turn them off: `watch on|off`, `capture on|off`, `route ...|route off`,
|
|
115
|
-
`emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`,
|
|
116
|
-
(and `highlight --labels on|off`), `cursor on|off
|
|
116
|
+
`watch`, `capture`, `route`, `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor`
|
|
117
|
+
and `toast` stay on until you turn them off: `watch on|off`, `capture on|off`, `route ...|route off`,
|
|
118
|
+
`network off|slow|on`, `emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`,
|
|
119
|
+
`highlight <selector>|highlight off` (and `highlight --labels on|off`), `cursor on|off`,
|
|
120
|
+
`toast <text>|toast off`.
|
|
117
121
|
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
118
122
|
lists them for every tab, and `modes off` turns them all off.
|
|
119
123
|
|
|
120
|
-
`tab`, `watch`, `capture`, `route`, `network`, `emulate`, `viewport`, `record`, `highlight`, `cursor
|
|
121
|
-
their own show their state and what you can run next.
|
|
124
|
+
`tab`, `watch`, `capture`, `route`, `network`, `emulate`, `viewport`, `record`, `highlight`, `cursor`, `toast`
|
|
125
|
+
and `modes` on their own show their state and what you can run next.
|
|
122
126
|
|
|
123
127
|
## How it differs from playwright-cli
|
|
124
128
|
|
|
125
129
|
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
126
|
-
gives each agent a browser session of its own; pw-repl shares one browser between
|
|
130
|
+
gives each agent a browser session of its own; pw-repl shares one browser between users and agents.
|
|
127
131
|
Its command names and most of their options work here too (`help playwright-cli` lists them), and where
|
|
128
132
|
pw-repl differs, it is mostly because someone else may be using the browser:
|
|
129
133
|
|
|
130
134
|
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
131
135
|
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
132
|
-
- File pickers are not caught, since they may be the
|
|
133
|
-
- There is no snapshot after every command, since
|
|
136
|
+
- File pickers are not caught, since they may be the user's: `upload` names the input instead.
|
|
137
|
+
- There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
|
|
134
138
|
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
135
|
-
- `watch` records what
|
|
139
|
+
- `watch` records what the user does as steps with their requests, and `watch save` writes them as
|
|
140
|
+
pw-repl commands to run again, not as Playwright code.
|
|
136
141
|
- `highlight` draws its box without Playwright's locator label, which covers what is below it and puts
|
|
137
142
|
selectors in screenshots and videos; `highlight --labels on` shows it.
|
|
138
143
|
|
|
@@ -146,7 +151,7 @@ agent acts; for the shared browser as you see it, use a screen recorder (`ffmpeg
|
|
|
146
151
|
### A skill for agents
|
|
147
152
|
|
|
148
153
|
`pw-repl skill` prints an agent skill (`SKILL.md`) that teaches an agent to use the REPL: how to start
|
|
149
|
-
it, send commands, and share the browser with
|
|
154
|
+
it, send commands, and share the browser with the user. Save it as `pw-repl/SKILL.md` in the folder
|
|
150
155
|
your agent reads skills from:
|
|
151
156
|
|
|
152
157
|
```bash
|
|
@@ -174,6 +179,14 @@ Several agents can share one REPL: `pw-repl send -c <name>` (or `PW_CLIENT=<name
|
|
|
174
179
|
selected tab of its own, shown in the pane as `[server:<name>]`. `modes` says which client turned each mode on,
|
|
175
180
|
and `modes off --mine` turns off only the sender's own.
|
|
176
181
|
|
|
182
|
+
A password can be typed as a variable: `PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD }}"`,
|
|
183
|
+
or the same line in a file run with `send --file`, as `watch save` writes it. `send` takes the value of
|
|
184
|
+
`{{ PW_NAME }}` from its own environment and passes it along with the command; the REPL puts it in only as
|
|
185
|
+
it runs the command, so the file, and the command as the pane, the log and the prompt's history show it,
|
|
186
|
+
never hold it. Output that reads it back can: `snapshot`, `html` and `attrs` hide a password field's value,
|
|
187
|
+
but `eval` reading the field, a field a Show password button made plain text (`help snapshot`), or page
|
|
188
|
+
text that repeats it do not. Only `PW_` names are variables (`pw-repl --help` has the rest).
|
|
189
|
+
|
|
177
190
|
### The HTTP protocol
|
|
178
191
|
|
|
179
192
|
With `pw-repl serve`, the server speaks HTTP with JSON, for clients of your own:
|
|
@@ -183,11 +196,12 @@ curl --unix-socket /tmp/playwright-repl.sock -H 'Content-Type: application/json'
|
|
|
183
196
|
-d '{"command": "info"}' http://localhost/run
|
|
184
197
|
```
|
|
185
198
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
The body may also have `"client": "<name>"`, and `"vars": {"PW_NAME": "<value>"}` with the values of the
|
|
200
|
+
`{{ PW_NAME }}` variables in the command, one line each (else 400). It returns `{"status": "ok" | "error", "output": "..."}`, plus
|
|
201
|
+
`"unconfirmed": true` when the command may or may not have done what it was sent to do (it timed out, or
|
|
202
|
+
the REPL quit while it ran and it was not read-only). While the REPL starts, `/run` answers 503 with
|
|
203
|
+
`"starting": true`: retry. `GET /health` says `"status": "starting"` until it serves, then `"ok"`.
|
|
204
|
+
`pw-repl serve <port>` serves TCP on 127.0.0.1 instead; other addresses are refused.
|
|
191
205
|
|
|
192
206
|
## Tests
|
|
193
207
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -17,7 +17,7 @@ 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...> | --file <file>
|
|
20
|
+
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] [--no-vars] <command...> | --file <file>
|
|
21
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]
|
|
@@ -45,6 +45,17 @@ folder, as on send's command line, and # comments and blank lines skipped. It st
|
|
|
45
45
|
command that fails, saying its line, with its exit status. -t is each command's own limit. Other
|
|
46
46
|
clients' commands can run between its lines, on their own tabs.
|
|
47
47
|
|
|
48
|
+
A {{ PW_NAME }} in a command, or in a line of the file, is a variable: send takes its value from its own
|
|
49
|
+
environment (PW_NAME) and passes it along with the command, not in it, e.g. a password kept out of the
|
|
50
|
+
file and out of the command as the REPL logs it. In a file: fill '#password' "{{ PW_PASSWORD }}",
|
|
51
|
+
run with PW_PASSWORD=... pw-repl send --file login.txt; or one command: PW_PASSWORD=... pw-repl send
|
|
52
|
+
fill '#password' "{{ PW_PASSWORD }}". Only names starting PW_ are variables: any other {{ ... }}, as in a
|
|
53
|
+
page's template text, is sent as written. In a file, put it in double quotes: there its value is
|
|
54
|
+
escaped as they read it (in single quotes, which escape nothing, a value with a ' is refused). The REPL
|
|
55
|
+
puts it in only as it runs the command, and its pane and log show the command as written. A variable that
|
|
56
|
+
is not set stops the command, or the file at that line, before it is sent. --no-vars sends
|
|
57
|
+
{{ PW_NAME }} as it is. Variables need the command server (pw-repl serve), not the tmux pane.
|
|
58
|
+
|
|
48
59
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
49
60
|
|
|
50
61
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
@@ -87,6 +98,7 @@ function parseSendArgs(args, allowCommand) {
|
|
|
87
98
|
options.file = require('path').resolve(args[++i]);
|
|
88
99
|
continue;
|
|
89
100
|
}
|
|
101
|
+
if (arg === '--no-vars') { options.noVars = true; continue; }
|
|
90
102
|
if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
|
|
91
103
|
const value = args[++i];
|
|
92
104
|
if (value === undefined) usage();
|
package/lib/background.js
CHANGED
|
@@ -69,13 +69,23 @@ function claimPidFile(pidFile) {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Why a REPL cannot serve on an endpoint another one serves on, and what to do instead; null if none does.
|
|
73
|
+
async function alreadyServing(endpoint) {
|
|
74
|
+
const name = client.describe(endpoint);
|
|
75
|
+
const another = `; to start another, give it a socket of its own: -e /tmp/<name>.sock`;
|
|
76
|
+
const background = runningPid(filesFor(endpoint).pidFile);
|
|
77
|
+
if (background) return `a background REPL is already serving on ${name} (pid ${background}): pw-repl attach uses it, pw-repl stop stops it${another}`;
|
|
78
|
+
const info = await client.healthInfo(endpoint, 2000);
|
|
79
|
+
if (!info) return null;
|
|
80
|
+
return `a REPL is already serving on ${name}${info.pid ? ` (pid ${info.pid})` : ''}, in a terminal: pw-repl send uses it, and quit at its prompt stops it${another}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
async function start(options) {
|
|
73
84
|
const endpoint = client.parseEndpoint(options.endpoint);
|
|
74
85
|
const name = client.describe(endpoint);
|
|
75
86
|
const { pidFile, logFile } = filesFor(endpoint);
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
|
|
87
|
+
const serving = await alreadyServing(endpoint);
|
|
88
|
+
if (serving) return fail(serving);
|
|
79
89
|
// Appended to, so an earlier run's log stays; each run is marked where it starts and stops. One grown
|
|
80
90
|
// past LOG_LIMIT is moved to <log>.1 first, replacing the one there.
|
|
81
91
|
try { if (fs.statSync(logFile).size > LOG_LIMIT) fs.renameSync(logFile, `${logFile}.1`); } catch {}
|
|
@@ -234,4 +244,4 @@ function describeBackground(endpoint) {
|
|
|
234
244
|
return `background: pid ${pid}${since}; log ${logFile} (pw-repl attach${e}, pw-repl stop${e})`;
|
|
235
245
|
}
|
|
236
246
|
|
|
237
|
-
module.exports = { filesFor, endpointFlag, markStopInLog, claimPidFile, start, stop, attach, describeBackground };
|
|
247
|
+
module.exports = { alreadyServing, filesFor, endpointFlag, markStopInLog, claimPidFile, start, stop, attach, describeBackground };
|
package/lib/cdp.js
CHANGED
|
@@ -20,6 +20,12 @@ async function keepDrawing(p) {
|
|
|
20
20
|
tabState(p).drawing = true;
|
|
21
21
|
try {
|
|
22
22
|
const session = await keptSession(p);
|
|
23
|
+
// A tab behind another draws no frames, and Playwright's actions wait for frames: told it is in
|
|
24
|
+
// front and focused, it draws, and clicks in it work. Only in tabs the REPL uses (the browser is
|
|
25
|
+
// connected without Playwright's own, which would tell every tab, the user's too), so the user's
|
|
26
|
+
// tabs say truly whether they are in front.
|
|
27
|
+
await session.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
28
|
+
tabState(p).focusEmulated = true;
|
|
23
29
|
session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
|
|
24
30
|
await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
|
|
25
31
|
} catch {
|
package/lib/cli-names.js
CHANGED
|
@@ -91,7 +91,7 @@ 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
|
-
NEAREST['video-chapter'] = 'not here:
|
|
94
|
+
NEAREST['video-chapter'] = 'not here: toast <text> --duration=3s shows a title over the page instead, in the video';
|
|
95
95
|
// Theirs labels each action and pauses on it; the cursor is a pointer that moves to each action instead.
|
|
96
96
|
NEAREST['video-show-actions'] = 'not here; cursor on draws a pointer that moves to each action, without labels';
|
|
97
97
|
NEAREST['video-hide-actions'] = 'not here; cursor off hides the pointer cursor on draws';
|
package/lib/client.js
CHANGED
|
@@ -61,9 +61,10 @@ async function ready(endpoint, timeoutMs) {
|
|
|
61
61
|
// One command. Resolves to { result }, { timeout: true }, { dropped: reason }
|
|
62
62
|
// (the connection closed after the command was sent, so it may have run) or
|
|
63
63
|
// { unreachable: reason } (it was never sent).
|
|
64
|
-
|
|
64
|
+
// vars: the values of the {{ PW_NAME }} variables in command, which the REPL fills in as it runs it.
|
|
65
|
+
function request(endpoint, command, timeoutMs, client = null, vars = null) {
|
|
65
66
|
return new Promise(resolve => {
|
|
66
|
-
const body = JSON.stringify(client ? {
|
|
67
|
+
const body = JSON.stringify({ command, ...(client ? { client } : {}), ...(vars ? { vars } : {}) });
|
|
67
68
|
let connected = false;
|
|
68
69
|
const req = http.request({
|
|
69
70
|
...target(endpoint),
|
package/lib/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ const { tabState } = require('./tabstate');
|
|
|
7
7
|
const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
|
|
8
8
|
const { commands: elementsCommands } = require('./elements');
|
|
9
9
|
const { commands: cursorCommands } = require('./cursor');
|
|
10
|
+
const { commands: toastCommands } = require('./toast');
|
|
10
11
|
const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
|
|
11
12
|
const { commands: routesCommands, routesFor } = require('./routes');
|
|
12
13
|
const { commands: networkCommands } = require('./network');
|
|
@@ -23,6 +24,7 @@ const commands = {
|
|
|
23
24
|
...tabsCommands,
|
|
24
25
|
...elementsCommands,
|
|
25
26
|
...cursorCommands,
|
|
27
|
+
...toastCommands,
|
|
26
28
|
...inspectCommands,
|
|
27
29
|
...watchCommands,
|
|
28
30
|
...requestlogCommands,
|
|
@@ -116,6 +118,7 @@ function complete(line) {
|
|
|
116
118
|
if (command === 'capture') return match(['on', 'off']);
|
|
117
119
|
if (command === 'record') return match(['on', 'off']);
|
|
118
120
|
if (command === 'cursor') return match(['on', 'off']);
|
|
121
|
+
if (command === 'toast') return match(['off']);
|
|
119
122
|
if (command === 'viewport') return match(['off']);
|
|
120
123
|
if (command === 'route') return match(['off']);
|
|
121
124
|
}
|
package/lib/cursor.js
CHANGED
|
@@ -7,6 +7,7 @@ const out = require('./output');
|
|
|
7
7
|
const { tabState } = require('./tabstate');
|
|
8
8
|
const { unquote, toSelector } = require('./syntax');
|
|
9
9
|
const { recordingOf, stepTarget, cursorStep } = require('./record');
|
|
10
|
+
const { inOverlay, keepAcrossPages, forget } = require('./overlay');
|
|
10
11
|
|
|
11
12
|
// Scaled with the distance, so a short hop is quick and a long one does not drag. Slower while the tab
|
|
12
13
|
// records, so a viewer can follow it.
|
|
@@ -19,37 +20,21 @@ const glideTime = (distance, recording) => {
|
|
|
19
20
|
return Math.round(Math.min(max, Math.max(min, base + distance * perPixel)));
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
// Runs in the page
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// which a page's Trusted Types or style-src rules do not block. It is shown as a popover, in the top
|
|
26
|
-
// layer, above the page's own dialogs, as Playwright's highlight is. Kept on the document under a
|
|
27
|
-
// symbol, so each call finds it again, and one the page removed is drawn again.
|
|
28
|
-
function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
|
|
29
|
-
const KEY = Symbol.for('pw-repl-cursor');
|
|
30
|
-
// Important on the element in the page, whose own rules could reach it; inside, the page's cannot,
|
|
31
|
-
// and an important transform would win over the glide's animation.
|
|
32
|
-
const set = (el, styles, important = '') => { for (const [name, value] of Object.entries(styles)) el.style.setProperty(name, value, important); };
|
|
23
|
+
// Runs in the page, as the overlay's cursor layer (lib/overlay.js).
|
|
24
|
+
function pageCursor(overlay, { x, y, action, ms, count, fade, box, items, index }) {
|
|
25
|
+
const { set } = overlay;
|
|
33
26
|
const place = (el, px, py) => set(el, { transform: `translate(${px}px, ${py}px)` });
|
|
34
|
-
let cursor =
|
|
27
|
+
let cursor = overlay.cursor;
|
|
35
28
|
if (action === 'remove') {
|
|
36
29
|
if (!cursor) return;
|
|
37
|
-
delete
|
|
38
|
-
if (!fade
|
|
30
|
+
delete overlay.cursor;
|
|
31
|
+
if (!fade) { overlay.drop('cursor'); return; }
|
|
39
32
|
// Waited on, as a glide is, but not past its time in a tab that draws no frames.
|
|
40
33
|
const fading = cursor.arrow.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' });
|
|
41
|
-
return Promise.race([fading.finished, new Promise(resolve => setTimeout(resolve, 500))]).then(() =>
|
|
34
|
+
return Promise.race([fading.finished, new Promise(resolve => setTimeout(resolve, 500))]).then(() => overlay.drop('cursor'), () => overlay.drop('cursor'));
|
|
42
35
|
}
|
|
43
|
-
if (!cursor || !cursor
|
|
44
|
-
const
|
|
45
|
-
host.setAttribute('aria-hidden', 'true');
|
|
46
|
-
host.setAttribute('popover', 'manual');
|
|
47
|
-
set(host, {
|
|
48
|
-
display: 'block', position: 'fixed', inset: '0', width: '100%', height: '100%', 'max-width': 'none',
|
|
49
|
-
'max-height': 'none', margin: '0', padding: '0', border: 'none', background: 'transparent',
|
|
50
|
-
overflow: 'visible', 'pointer-events': 'none', 'z-index': '2147483647',
|
|
51
|
-
}, 'important');
|
|
52
|
-
const root = host.attachShadow({ mode: 'closed' });
|
|
36
|
+
if (!cursor || !overlay.has('cursor')) {
|
|
37
|
+
const root = overlay.layer('cursor');
|
|
53
38
|
const NS = 'http://www.w3.org/2000/svg';
|
|
54
39
|
const arrow = document.createElementNS(NS, 'svg');
|
|
55
40
|
arrow.setAttribute('width', '20');
|
|
@@ -65,14 +50,11 @@ function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
|
|
|
65
50
|
shape.setAttribute('stroke-linejoin', 'round');
|
|
66
51
|
arrow.append(shape);
|
|
67
52
|
root.append(arrow);
|
|
68
|
-
|
|
69
|
-
cursor = document[KEY] = { host, root, arrow, x, y };
|
|
53
|
+
cursor = overlay.cursor = { root, arrow, x, y };
|
|
70
54
|
place(arrow, x, y);
|
|
71
55
|
// Faded in when turned on; drawn again on a new page, it is there at once, as it was.
|
|
72
56
|
if (fade) arrow.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });
|
|
73
57
|
}
|
|
74
|
-
// Shown again, so it is above a dialog the page opened since.
|
|
75
|
-
try { cursor.host.hidePopover(); cursor.host.showPopover(); } catch {}
|
|
76
58
|
if (action === 'move') {
|
|
77
59
|
const from = `translate(${cursor.x}px, ${cursor.y}px)`;
|
|
78
60
|
const to = `translate(${x}px, ${y}px)`;
|
|
@@ -143,16 +125,6 @@ function cursorOf(p) {
|
|
|
143
125
|
return tabState(p).cursor || null;
|
|
144
126
|
}
|
|
145
127
|
|
|
146
|
-
// A new document drops the cursor: it is drawn again where it was.
|
|
147
|
-
function keepAcrossPages(p) {
|
|
148
|
-
if (tabState(p).cursorKept) return;
|
|
149
|
-
tabState(p).cursorKept = true;
|
|
150
|
-
p.on('domcontentloaded', () => {
|
|
151
|
-
const cursor = cursorOf(p);
|
|
152
|
-
if (cursor) p.evaluate(pageCursor, { x: cursor.x, y: cursor.y, action: 'draw' }).catch(() => {});
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
128
|
// Glides the tab's cursor to a point, if it is on, and waits for it to get there.
|
|
157
129
|
async function glideTo(p, x, y) {
|
|
158
130
|
const cursor = cursorOf(p);
|
|
@@ -162,7 +134,7 @@ async function glideTo(p, x, y) {
|
|
|
162
134
|
if (ms) cursorStep(p, `(cursor from ${cursor.x} ${cursor.y})`, { x, y }, ms);
|
|
163
135
|
cursor.x = x;
|
|
164
136
|
cursor.y = y;
|
|
165
|
-
await p
|
|
137
|
+
await inOverlay(p, pageCursor, { x, y, action: 'move', ms }).catch(() => {});
|
|
166
138
|
}
|
|
167
139
|
|
|
168
140
|
// Whether a command should find its element first: to glide the cursor there, or, while recording, to
|
|
@@ -235,7 +207,7 @@ async function aimAt(p, target, timeout) {
|
|
|
235
207
|
async function showClick(p, count = 1) {
|
|
236
208
|
const cursor = cursorOf(p);
|
|
237
209
|
if (!cursor) return;
|
|
238
|
-
await p
|
|
210
|
+
await inOverlay(p, pageCursor, { x: cursor.x, y: cursor.y, action: 'click', count }).catch(() => {});
|
|
239
211
|
}
|
|
240
212
|
|
|
241
213
|
// select with the cursor on: a native select's list opens outside the page, where no recording (or
|
|
@@ -257,17 +229,17 @@ async function showSelect(p, target, value, act) {
|
|
|
257
229
|
if (!before || before.chosen < 0 || before.chosen >= 10) return act();
|
|
258
230
|
const pace = recordingOf(p) ? 1 : 0.5;
|
|
259
231
|
await showClick(p);
|
|
260
|
-
const opened = await p
|
|
232
|
+
const opened = await inOverlay(p, pageCursor, { action: 'pickerOpen', box, items: before.items, index: before.index }).catch(() => null);
|
|
261
233
|
if (!opened) return act();
|
|
262
234
|
await sleep(350 * pace);
|
|
263
235
|
await glideTo(p, Math.round(cursor.x), Math.round(opened.rowY + before.chosen * opened.rowHeight));
|
|
264
|
-
await p
|
|
236
|
+
await inOverlay(p, pageCursor, { action: 'pickerChoose', index: before.chosen }).catch(() => {});
|
|
265
237
|
await showClick(p);
|
|
266
238
|
try {
|
|
267
239
|
return await act();
|
|
268
240
|
} finally {
|
|
269
241
|
await sleep(250 * pace);
|
|
270
|
-
await p
|
|
242
|
+
await inOverlay(p, pageCursor, { action: 'pickerClose' }).catch(() => {});
|
|
271
243
|
}
|
|
272
244
|
}
|
|
273
245
|
|
|
@@ -276,7 +248,8 @@ async function cursorOff(p) {
|
|
|
276
248
|
const { x, y } = cursorOf(p);
|
|
277
249
|
delete tabState(p).cursor;
|
|
278
250
|
cursorStep(p, '(cursor off)', { x, y }, 0);
|
|
279
|
-
|
|
251
|
+
forget(p, 'cursor');
|
|
252
|
+
await inOverlay(p, pageCursor, { action: 'remove', fade: true }, false).catch(() => {});
|
|
280
253
|
return true;
|
|
281
254
|
}
|
|
282
255
|
|
|
@@ -344,10 +317,11 @@ const commands = {
|
|
|
344
317
|
out.log(`The cursor moved to ${at.x}, ${at.y}`);
|
|
345
318
|
return;
|
|
346
319
|
}
|
|
347
|
-
await p
|
|
320
|
+
await inOverlay(p, pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
|
|
348
321
|
tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
|
|
349
322
|
cursorStep(p, '(cursor on)', at, 0);
|
|
350
|
-
|
|
323
|
+
// A new document drops it: it is drawn again where it was.
|
|
324
|
+
keepAcrossPages(p, 'cursor', async () => { const c = cursorOf(p); if (c) await inOverlay(p, pageCursor, { x: c.x, y: c.y, action: 'draw' }); });
|
|
351
325
|
out.log(`The cursor is on, at ${at.x}, ${at.y}: commands on elements and the mouse glide it there first, and clicks show where they land; cursor off hides it`);
|
|
352
326
|
},
|
|
353
327
|
};
|
package/lib/elements.js
CHANGED
|
@@ -197,10 +197,16 @@ async function pointerAction(selector, act, options = {}, clicks = 0) {
|
|
|
197
197
|
return '';
|
|
198
198
|
}
|
|
199
199
|
try {
|
|
200
|
+
// The first of several is named, as it may not be the one meant (a heading with a button's text);
|
|
201
|
+
// before acting, since a click may take the page away.
|
|
202
|
+
const what = chosen.position === 1
|
|
203
|
+
? await chosen.handle.evaluate(el => `<${el.tagName.toLowerCase()}> ${(el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)}`).catch(() => null)
|
|
204
|
+
: null;
|
|
200
205
|
const point = await aimAt(state.page, chosen.handle, left());
|
|
201
206
|
await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
|
|
202
207
|
await landed(point, clicks);
|
|
203
|
-
|
|
208
|
+
if (chosen.position !== 1) return ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
|
|
209
|
+
return ` (the first of ${chosen.count} matches${what ? `: ${what}` : ''})`;
|
|
204
210
|
} catch (error) {
|
|
205
211
|
if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
|
|
206
212
|
} finally {
|
|
@@ -355,6 +361,24 @@ async function setLabels(p, on) {
|
|
|
355
361
|
if (on) await applyLabels(p, false); else await keepLabels(p);
|
|
356
362
|
}
|
|
357
363
|
|
|
364
|
+
// A date or time input takes keys part by part, in the order it shows them (10/02/2026 in en-US), so
|
|
365
|
+
// its ISO form, typed, comes out empty or mangled; fill sets it from that.
|
|
366
|
+
const DATE_INPUTS = {
|
|
367
|
+
date: [/^\d{4,}-\d\d-\d\d$/, '2026-10-02'],
|
|
368
|
+
time: [/^\d\d:\d\d(:\d\d(\.\d+)?)?$/, '14:30'],
|
|
369
|
+
'datetime-local': [/^\d{4,}-\d\d-\d\d[T ]\d\d:\d\d(:\d\d(\.\d+)?)?$/, '2026-10-02T14:30'],
|
|
370
|
+
month: [/^\d{4,}-\d\d$/, '2026-10'],
|
|
371
|
+
week: [/^\d{4,}-W\d\d$/, '2026-W40'],
|
|
372
|
+
};
|
|
373
|
+
function refuseIsoDate(kind, text, what, selector) {
|
|
374
|
+
if (DATE_INPUTS[kind]?.[0].test(text)) throw new Error(`${what} is a ${kind} input: typed, ${text} comes out mangled, since it takes keys part by part in the order it shows them. fill ${selector} ${text} sets it`);
|
|
375
|
+
}
|
|
376
|
+
function dateInputHolds(kind, value, what, selector) {
|
|
377
|
+
if (!DATE_INPUTS[kind]) return '';
|
|
378
|
+
if (!value) throw new Error(`${what}, a ${kind} input, is still empty: it takes keys part by part, in the order it shows them. fill sets it from its ISO form: fill ${selector} ${DATE_INPUTS[kind][1]}`);
|
|
379
|
+
return `, a ${kind} input, which now holds ${value}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
358
382
|
const commands = {
|
|
359
383
|
async click(args) {
|
|
360
384
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
@@ -482,25 +506,34 @@ const commands = {
|
|
|
482
506
|
if (sole && sole.rest === undefined) {
|
|
483
507
|
// Named, since it may not be the element meant; one that takes no text is refused.
|
|
484
508
|
const focused = await state.page.evaluate(() => {
|
|
485
|
-
|
|
509
|
+
let el = document.activeElement;
|
|
510
|
+
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
|
486
511
|
if (!el || el === document.body || el === document.documentElement) return null;
|
|
487
512
|
const editable = el.isContentEditable || el.matches('textarea, input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color],[type=hidden])');
|
|
488
513
|
const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
|
|
489
|
-
return { editable, name: `${el.tagName.toLowerCase()}${id}
|
|
514
|
+
return { editable, name: `${el.tagName.toLowerCase()}${id}`, type: el.type };
|
|
490
515
|
});
|
|
491
516
|
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
492
517
|
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
493
518
|
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
519
|
+
refuseIsoDate(focused.type, sole.word, `The focused ${focused.name}`, '<selector>');
|
|
494
520
|
await state.page.keyboard.type(sole.word, { delay: delay() });
|
|
521
|
+
const held = DATE_INPUTS[focused.type] ? dateInputHolds(focused.type, await state.page.evaluate(() => {
|
|
522
|
+
let el = document.activeElement;
|
|
523
|
+
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
|
524
|
+
return el?.value;
|
|
525
|
+
}), `The focused ${focused.name}`, '<selector>') : '';
|
|
495
526
|
if (submit) await state.page.keyboard.press('Enter');
|
|
496
|
-
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
527
|
+
out.log(`Typed into the focused ${focused.name}${held}${submit ? ', and submitted' : ''}`);
|
|
497
528
|
return;
|
|
498
529
|
}
|
|
499
530
|
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
500
531
|
const timeout = await aimFor(selector);
|
|
501
532
|
// Typed after what the field already holds, as someone clicking into it
|
|
502
533
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
503
|
-
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
534
|
+
const kind = await onElement(selector, () => state.page.locator(selector).first().evaluate((el, dateKinds) => {
|
|
535
|
+
// Focused again, so a date input already focused takes keys from its first part, not where it was left.
|
|
536
|
+
if (dateKinds.includes(el.type) && el.matches(':focus')) el.blur();
|
|
504
537
|
el.focus();
|
|
505
538
|
if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
|
|
506
539
|
try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
|
|
@@ -511,10 +544,14 @@ const commands = {
|
|
|
511
544
|
getSelection().removeAllRanges();
|
|
512
545
|
getSelection().addRange(range);
|
|
513
546
|
}
|
|
514
|
-
|
|
547
|
+
return el.type;
|
|
548
|
+
}, Object.keys(DATE_INPUTS), { timeout }));
|
|
549
|
+
const quoted = /\s/.test(shown) ? `"${shown}"` : shown;
|
|
550
|
+
refuseIsoDate(kind, value, shown, quoted);
|
|
515
551
|
await state.page.keyboard.type(value, { delay: delay() });
|
|
552
|
+
const held = DATE_INPUTS[kind] ? dateInputHolds(kind, await onElement(selector, () => state.page.locator(selector).first().evaluate(el => el.value, null, { timeout: 5000 })), shown, quoted) : '';
|
|
516
553
|
if (submit) await state.page.keyboard.press('Enter');
|
|
517
|
-
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
554
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}${held}`);
|
|
518
555
|
},
|
|
519
556
|
|
|
520
557
|
async press(args) {
|
|
@@ -561,7 +598,7 @@ const commands = {
|
|
|
561
598
|
async upload(args) {
|
|
562
599
|
const parsed = splitSelector(args);
|
|
563
600
|
// playwright-cli's upload <file> answers a file picker a click opened. Here
|
|
564
|
-
// the input is named instead: catching pickers would catch the
|
|
601
|
+
// the input is named instead: catching pickers would catch the user's too.
|
|
565
602
|
const usage = 'Usage: upload <selector> <file>..., naming the file input (or the button that opens it) first, e.g. upload e12 ./doc.pdf; quote a path with spaces';
|
|
566
603
|
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
567
604
|
// A path, not a selector; // starts an XPath.
|