pw-repl 0.12.7 → 0.12.8
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 -1
- package/README.md +76 -74
- package/bin/pw-repl.js +56 -33
- package/lib/cli-names.js +3 -3
- package/lib/elements.js +115 -26
- package/lib/emulation.js +2 -1
- package/lib/help.js +114 -47
- package/lib/inspect.js +22 -5
- package/lib/launch.js +3 -1
- package/lib/record.js +1 -1
- package/lib/runner.js +11 -0
- package/lib/send.js +16 -4
- package/lib/start.js +7 -2
- package/lib/syntax.js +11 -5
- package/package.json +1 -1
- package/skill/SKILL.md +27 -40
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,9 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
53
53
|
shown by `help <command> --all` and `help --all`. An error about something in `more` ends by naming it
|
|
54
54
|
(`help record --all`). Paragraphs are plain: `wrap()` lays each out at 104 columns, and keeps as written
|
|
55
55
|
a paragraph with a bullet, a table or an indented example. `test/fixtures` holds the rendered help;
|
|
56
|
-
after an intended change to it, save it again (the command is in `test/units.test.js`).
|
|
56
|
+
after an intended change to it, save it again (the command is in `test/units.test.js`). The common
|
|
57
|
+
tasks and modes on help's front page (`TASKS` and `MODES`) are the README's too: change both, as a
|
|
58
|
+
unit test compares them.
|
|
57
59
|
- Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`).
|
|
58
60
|
Compare with the last commit that has one (`git log --grep='npm test:'`): a run that grew by more than
|
|
59
61
|
a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
|
package/README.md
CHANGED
|
@@ -24,6 +24,39 @@ pw> reload
|
|
|
24
24
|
pw> console error # and see how the page copes
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
You need Node.js 20 or newer. pw-repl can use a Chromium-based browser you already have (Chrome,
|
|
30
|
+
Chromium, ...), or Playwright's own: `npx playwright-core install chromium` downloads it.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx pw-repl@latest run --launch https://example.com
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`--launch` starts a private headless Chromium for the REPL, in a 1280x800 window, and stops it when
|
|
37
|
+
the REPL quits. Add `--headed` to see that browser and click in it yourself. The page opens, and a
|
|
38
|
+
`pw>` prompt waits:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
pw> help # common tasks and topics; help <topic>, help <command>, help --all
|
|
42
|
+
pw> snapshot
|
|
43
|
+
pw> click e3
|
|
44
|
+
pw> screenshot
|
|
45
|
+
pw> quit
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Commands act on the selected tab. `tab` lists the tabs, with `*` on the selected one.
|
|
49
|
+
|
|
50
|
+
### Install
|
|
51
|
+
|
|
52
|
+
| How | Setup | Then run |
|
|
53
|
+
| ------------ | -------------------------------------------- | ------------------------------ |
|
|
54
|
+
| Without | nothing | `npx pw-repl@latest <command>` |
|
|
55
|
+
| Global | `npm install -g pw-repl` | `pw-repl <command>` |
|
|
56
|
+
| From a clone | `npm install` (and `npm link` for `pw-repl`) | `bin/pw-repl.js <command>` |
|
|
57
|
+
|
|
58
|
+
With `npx`, use `npx pw-repl@latest` the same way every time, so every command runs the same version.
|
|
59
|
+
|
|
27
60
|
## Why use it
|
|
28
61
|
|
|
29
62
|
- **Your browser, as it is.** It connects to a running Chromium, with your tabs, logins and state, and
|
|
@@ -42,14 +75,15 @@ pw> console error # and see how the page copes
|
|
|
42
75
|
|
|
43
76
|
- **Read the page:** accessibility snapshots with clickable refs, text, HTML, attributes, links, form
|
|
44
77
|
controls, event listeners, screenshots (viewport, full page or one element).
|
|
45
|
-
- **Act on it:** click, fill, type, press keys, select, check,
|
|
46
|
-
wheel, answer dialogs.
|
|
78
|
+
- **Act on it:** click, double-click, fill, type, press keys, select, check, drag and drop, upload files,
|
|
79
|
+
click or drag at a point, scroll the wheel, answer dialogs.
|
|
47
80
|
- **Watch the network:** every request and response body, console messages and page errors, all recorded
|
|
48
81
|
as they happen.
|
|
49
82
|
- **Change the network:** fake a response (`route`), patch its JSON, delay it, fail it, or cut or slow the
|
|
50
83
|
whole tab's network.
|
|
51
84
|
- **Emulate:** a phone, dark mode, a locale, a timezone, a viewport size.
|
|
52
|
-
- **Run code:** `eval` any JavaScript in the page, or send a raw CDP command with
|
|
85
|
+
- **Run code:** `eval` any JavaScript in the page, a line or a file of it, or send a raw CDP command with
|
|
86
|
+
`cdp`.
|
|
53
87
|
- **Wait:** for an element, some text, a response or a page load.
|
|
54
88
|
- **Record:** what someone does in a tab (`watch`), a timeline of requests and console (`capture`), or a
|
|
55
89
|
video of the tab (`record`), with a virtual pointer (`cursor`), captions (`toast`) and highlights
|
|
@@ -59,42 +93,10 @@ pw> console error # and see how the page copes
|
|
|
59
93
|
- **Share:** many clients on one REPL, each with its own tab; a command server on a Unix socket or a local
|
|
60
94
|
port, with an HTTP+JSON protocol.
|
|
61
95
|
|
|
62
|
-
## Quick start
|
|
63
|
-
|
|
64
|
-
You need Node.js 20 or newer. pw-repl can use a Chromium-based browser you already have (Chrome,
|
|
65
|
-
Chromium, ...), or Playwright's own: `npx playwright-core install chromium` downloads it.
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
npx pw-repl@latest run --launch https://example.com
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
`--launch` starts a private headless Chromium for the REPL and stops it when the REPL quits. Add
|
|
72
|
-
`--headed` to see that browser and click in it yourself. The page opens in a new tab, and a `pw>`
|
|
73
|
-
prompt waits:
|
|
74
|
-
|
|
75
|
-
```text
|
|
76
|
-
pw> help # common tasks and topics; help <topic>, help <command>, help --all
|
|
77
|
-
pw> snapshot
|
|
78
|
-
pw> click e3
|
|
79
|
-
pw> screenshot
|
|
80
|
-
pw> quit
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
Commands act on the selected tab. `tab` lists the tabs, with `*` on the selected one.
|
|
84
|
-
|
|
85
|
-
### Install
|
|
86
|
-
|
|
87
|
-
| How | Setup | Then run |
|
|
88
|
-
| ------------ | -------------------------------------------- | ------------------------------ |
|
|
89
|
-
| Without | nothing | `npx pw-repl@latest <command>` |
|
|
90
|
-
| Global | `npm install -g pw-repl` | `pw-repl <command>` |
|
|
91
|
-
| From a clone | `npm install` (and `npm link` for `pw-repl`) | `bin/pw-repl.js <command>` |
|
|
92
|
-
|
|
93
|
-
With `npx`, use `npx pw-repl@latest` the same way every time, so every command runs the same version.
|
|
94
|
-
|
|
95
96
|
## Use your own browser
|
|
96
97
|
|
|
97
|
-
Start Chrome with remote debugging, then run the REPL
|
|
98
|
+
Start Chrome with remote debugging (on Linux it may be `google-chrome` or `chromium`), then run the REPL
|
|
99
|
+
without `--launch`:
|
|
98
100
|
|
|
99
101
|
```bash
|
|
100
102
|
chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
|
|
@@ -126,47 +128,49 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
126
128
|
it. `pw-repl where` says what is running and how `send` will reach it.
|
|
127
129
|
- For `send` to reach `run`, start it in tmux: `tmux new -s pw-repl`, then `pw-repl run` there.
|
|
128
130
|
- A URL after the command opens in a new tab: `pw-repl serve http://localhost:3000`.
|
|
129
|
-
- `--launch` works with each of them. Flags after `--` go to that Chromium (flags only; a start URL goes
|
|
131
|
+
- `--launch` works with each of them. Flags after `--` go to that Chromium (flags only; a start URL goes
|
|
132
|
+
before `--`).
|
|
130
133
|
- A REPL of your own goes on a socket of its own: `-e /tmp/mine.sock` on every command, `serve`
|
|
131
134
|
included, or set `PW_SOCKET`. `serve <port>` listens on TCP, on 127.0.0.1 only.
|
|
132
135
|
|
|
133
136
|
## Common tasks
|
|
134
137
|
|
|
135
|
-
| I want to… | Commands
|
|
136
|
-
| ------------------------------------- |
|
|
137
|
-
| see where I am | `tab
|
|
138
|
-
| see what is on the page | `snapshot`, `screenshot`
|
|
139
|
-
| do something on it | `click`, `fill`, `type`, `press`
|
|
140
|
-
| click what has no selector (a canvas) | `mouseclick <x> <y>`
|
|
141
|
-
| run some JavaScript in the page | `eval document.title`
|
|
142
|
-
| wait for a page or element | `wait load`, `wait <selector
|
|
143
|
-
| choose a file in a file input | `upload <selector> <file>`
|
|
144
|
-
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark`
|
|
145
|
-
| see what the page requested | `requests`, then `body <#>` for what one got back
|
|
146
|
-
| see console messages and errors | `console`
|
|
147
|
-
| show an agent what I do | `watch on`, click around
|
|
148
|
-
| see each step as I click | `watch on --live`
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
138
|
+
| I want to… | Commands |
|
|
139
|
+
| ------------------------------------- | ------------------------------------------------------------- |
|
|
140
|
+
| see where I am | `tab`, `info` |
|
|
141
|
+
| see what is on the page | `snapshot`, `screenshot` |
|
|
142
|
+
| do something on it | `click`, `fill`, `type`, `press`, `drag` |
|
|
143
|
+
| click what has no selector (a canvas) | `mouseclick <x> <y>`, `mousedblclick <x> <y>` |
|
|
144
|
+
| run some JavaScript in the page | `eval document.title`; `eval --file=check.js` for a long one |
|
|
145
|
+
| wait for a page or element | `wait load`, `wait <selector> [--gone]`, `wait text <text>` |
|
|
146
|
+
| choose a file in a file input | `upload <selector> <file>` |
|
|
147
|
+
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark` |
|
|
148
|
+
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
149
|
+
| see console messages and errors | `console` |
|
|
150
|
+
| show an agent what I do | `watch on`, click around, then `watch` |
|
|
151
|
+
| see each step as I click | `watch on --live` |
|
|
152
|
+
| watch a tab someone will open | `watch on --next-tab [url-part]` |
|
|
153
|
+
| replay what I clicked through | `watch save flow.txt`, then `pw-repl send --file flow.txt` |
|
|
154
|
+
| record requests and console together | `capture on`, then `capture off` |
|
|
155
|
+
| find what makes the page slow | `profile on`, then `profile off`; `profile reload` for a load |
|
|
156
|
+
| fake a backend response | `route <glob> <status> <json>`, `route <glob> abort` |
|
|
157
|
+
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
158
|
+
| cut or slow the whole network | `network off`, `network slow`, then `network on` |
|
|
159
|
+
| record a video of the page | `record on`, then `record off` (`help video` for a take) |
|
|
160
|
+
| show a pointer where the agent acts | `cursor on`, then `cursor off` |
|
|
161
|
+
| say what is happening on screen | `toast <text>`, then `toast off` |
|
|
162
|
+
| point something out on the page | `highlight <ref>`, then `highlight off` |
|
|
163
|
+
| clean up | `modes off` |
|
|
160
164
|
|
|
161
165
|
`help <topic>` lists the rest, and `help <command>` has usage and caveats.
|
|
162
166
|
|
|
163
167
|
### Modes
|
|
164
168
|
|
|
165
|
-
Some commands turn on a mode that stays on until you turn it off: `watch`, `capture`, `profile`,
|
|
166
|
-
`network off` or `slow`, `emulate`, `viewport`, `
|
|
167
|
-
are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
168
|
-
for every tab, and `modes off` turns them all off. Each of these commands on its own shows
|
|
169
|
-
what you can run next.
|
|
169
|
+
Some commands turn on a mode that stays on until you turn it off: `watch`, `capture`, `profile`,
|
|
170
|
+
`record`, `route`, `network off` or `slow`, `emulate`, `viewport`, `highlight`, `cursor` and `toast`.
|
|
171
|
+
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
172
|
+
lists them for every tab, and `modes off` turns them all off. Each of these commands on its own shows
|
|
173
|
+
its state and what you can run next.
|
|
170
174
|
|
|
171
175
|
## For agents and scripts
|
|
172
176
|
|
|
@@ -204,12 +208,10 @@ each mode on, and `modes off --mine` turns off only the sender's own.
|
|
|
204
208
|
`send --file take.txt` runs a file of commands, one per line, and stops at the first that fails: a
|
|
205
209
|
repro to keep, or a video take.
|
|
206
210
|
|
|
207
|
-
**Passwords:** type one as a variable
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
and `attrs` hide a password field's value, but `eval` on the field, a field that a Show password button
|
|
212
|
-
made plain text, or page text that repeats it do not. Only `PW_` names are variables.
|
|
211
|
+
**Passwords:** type one as a variable, so it is never in the command, a file of commands or the REPL's
|
|
212
|
+
log: `PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD }}"` (`watch save` writes them that
|
|
213
|
+
way). What reads the page back can still show it: `snapshot`, `html` and `attrs` hide a password field's
|
|
214
|
+
value; `eval` on the field does not.
|
|
213
215
|
|
|
214
216
|
### The HTTP protocol
|
|
215
217
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -9,7 +9,8 @@ const USAGE = `Usage:
|
|
|
9
9
|
|
|
10
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/pw-repl.sock);
|
|
12
|
-
--background runs it detached, with its output in a log next to the socket
|
|
12
|
+
--background runs it detached, with its output in a log next to the socket; a socket's folder is
|
|
13
|
+
made if it is missing
|
|
13
14
|
|
|
14
15
|
pw-repl attach [-e endpoint]
|
|
15
16
|
see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
|
|
@@ -18,7 +19,8 @@ const USAGE = `Usage:
|
|
|
18
19
|
stop a background REPL, and say what became of each recording it ended; its socket is removed, its
|
|
19
20
|
log stays
|
|
20
21
|
|
|
21
|
-
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] [--no-vars]
|
|
22
|
+
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] [--no-vars]
|
|
23
|
+
<command...> | --file <file>
|
|
22
24
|
run one command in a running REPL and print its output; --file runs a file of them, one per line
|
|
23
25
|
|
|
24
26
|
pw-repl where [-e endpoint | -s session]
|
|
@@ -30,10 +32,26 @@ const USAGE = `Usage:
|
|
|
30
32
|
pw-repl skill
|
|
31
33
|
print an agent skill (SKILL.md) that teaches an agent to use the REPL
|
|
32
34
|
|
|
35
|
+
A browser: --launch starts a Chromium of the REPL's own, headless unless --headed, 1280x800, in a
|
|
36
|
+
temporary profile, on a free port of its own (pw-repl where names it). It prints the command it ran, and
|
|
37
|
+
stops with the REPL (Ctrl-C while it starts too); stop returns once it has exited. Flags after -- go to
|
|
38
|
+
it, and only flags (a start URL goes before --); a --user-data-dir=<dir> among them is used instead of
|
|
39
|
+
the temporary profile, and kept. PW_CHROME picks which Chromium.
|
|
40
|
+
|
|
41
|
+
Without --launch, the REPL connects to a browser running with --remote-debugging-port, at $PW_CDP_URL
|
|
42
|
+
(default http://localhost:9222).
|
|
43
|
+
|
|
33
44
|
send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/pw-repl.sock) is
|
|
34
|
-
there, and the tmux session (-s, $PW_TMUX_SESSION, default pw-repl)
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
there, and the tmux session where pw-repl run runs otherwise (-s, $PW_TMUX_SESSION, default pw-repl).
|
|
46
|
+
serve without an endpoint serves on that same socket. send exit status: 0 ok, 1 command error, 2
|
|
47
|
+
completion not confirmed, 64 usage or unreachable. pw-repl send --help has the rest: clients, -t,
|
|
48
|
+
--file and variables.`;
|
|
49
|
+
|
|
50
|
+
// pw-repl send --help: what send does beyond its usage line.
|
|
51
|
+
const SEND_HELP = `send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/pw-repl.sock) is
|
|
52
|
+
there, and the tmux session (-s, $PW_TMUX_SESSION, default pw-repl) otherwise, where pw-repl run runs.
|
|
53
|
+
If the socket exists but nothing answers, send fails rather than fall back. serve without an endpoint
|
|
54
|
+
serves on that same one: $PW_ENDPOINT, then $PW_SOCKET, then the default.
|
|
37
55
|
|
|
38
56
|
send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
|
|
39
57
|
several agents can share one REPL; pw-repl help session has the rest.
|
|
@@ -41,36 +59,24 @@ several agents can share one REPL; pw-repl help session has the rest.
|
|
|
41
59
|
send's options can also follow the command, as in pw-repl send open <url> -t 30; after a -- before the
|
|
42
60
|
command, every word is the command's, -t 30 included.
|
|
43
61
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
send --file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt:
|
|
49
|
-
quoted as the prompt reads it (no shell), with a file it records, uploads or saves relative to this
|
|
50
|
-
folder, as on send's command line, and # comments and blank lines skipped. It stops at the first
|
|
51
|
-
command that fails, saying its line, with its exit status. -t is each command's own limit. Other
|
|
52
|
-
clients' commands can run between its lines, on their own tabs.
|
|
62
|
+
Exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable. -t is how long
|
|
63
|
+
send waits for the answer (default 20s); it does not lengthen a command's own limits, such as the 5s an
|
|
64
|
+
action waits for its element.
|
|
53
65
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
page's template text, is sent as written. In a file, put it in double quotes: there its value is
|
|
60
|
-
escaped as they read it (in single quotes, which escape nothing, a value with a ' is refused). The REPL
|
|
61
|
-
puts it in only as it runs the command, and its pane and log show the command as written. A variable that
|
|
62
|
-
is not set stops the command, or the file at that line, before it is sent. --no-vars sends
|
|
63
|
-
{{ PW_NAME }} as it is. Variables need the command server (pw-repl serve), not the tmux pane.
|
|
66
|
+
--file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt: quoted
|
|
67
|
+
as the prompt reads it (no shell), with a file it records, uploads or saves relative to this folder, as
|
|
68
|
+
on send's command line, and # comments and blank lines skipped. It stops at the first command that
|
|
69
|
+
fails, saying its line, with its exit status. -t is each command's own limit. Other clients' commands
|
|
70
|
+
can run between its lines, on their own tabs.
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
In a tmux session named pw-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
72
|
+
Variables: a {{ PW_NAME }} in a command, or in a line of the file, takes its value from send's own
|
|
73
|
+
environment (PW_NAME), passed along with the command, not in it, so a password stays out of the file and
|
|
74
|
+
out of the REPL's log: PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD }}", or
|
|
75
|
+
PW_PASSWORD="$(cat <file>)" to give it from a file without typing it. Only PW_ names are variables; any
|
|
76
|
+
other {{ ... }} is sent as written. In a file, put one in double quotes (in single quotes, a value with
|
|
77
|
+
a ' is refused). One that is not set stops the command, or the file at that line, before it is sent;
|
|
78
|
+
--no-vars sends {{ PW_NAME }} as it is. Variables need the command server (pw-repl serve), not the tmux
|
|
79
|
+
pane.`;
|
|
74
80
|
|
|
75
81
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
76
82
|
|
|
@@ -229,12 +235,28 @@ function skill() {
|
|
|
229
235
|
|
|
230
236
|
async function main() {
|
|
231
237
|
let [subcommand, ...args] = process.argv.slice(2);
|
|
238
|
+
// -h after send, where, attach or stop is a request for help, never a command to send.
|
|
239
|
+
if (['send', 'where', 'attach', 'stop'].includes(subcommand) && /^(?:-h|--help)$/.test(args[0] || '')) {
|
|
240
|
+
if (subcommand !== 'send') return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
241
|
+
const block = USAGE.split('\n\n').find(b => b.startsWith(' pw-repl send '));
|
|
242
|
+
return console.log(`Usage:\n${block}\n\n${SEND_HELP}`);
|
|
243
|
+
}
|
|
232
244
|
// Bare pw-repl explains itself rather than connect to someone's browser.
|
|
233
245
|
if (subcommand === undefined) return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
234
246
|
switch (subcommand) {
|
|
235
247
|
case 'run':
|
|
236
248
|
case 'serve': {
|
|
237
249
|
const options = startOptions(args, subcommand === 'serve');
|
|
250
|
+
// A socket of one's own is often in a folder of its own (/tmp/<name>/repl.sock): made here, before
|
|
251
|
+
// the log, the pid file or the socket go in it, whichever way serve runs. Owner-only, as the socket is.
|
|
252
|
+
const socket = subcommand === 'serve' ? require('../lib/client').parseEndpoint(options.endpoint).socket : null;
|
|
253
|
+
if (socket) {
|
|
254
|
+
const folder = require('path').dirname(socket);
|
|
255
|
+
try { require('fs').mkdirSync(folder, { recursive: true, mode: 0o700 }); } catch (error) {
|
|
256
|
+
console.error(`pw-repl: cannot create ${folder} for the socket (${error.code === 'EEXIST' ? 'a file is there' : error.code || error.message})`);
|
|
257
|
+
process.exit(64);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
238
260
|
if (options.background) process.exit(await require('../lib/background').start(options));
|
|
239
261
|
return require('../lib/start').start(options);
|
|
240
262
|
}
|
|
@@ -249,6 +271,7 @@ async function main() {
|
|
|
249
271
|
// falls through never: process.exit above
|
|
250
272
|
case 'send': {
|
|
251
273
|
const options = parseSendArgs(args, 'send');
|
|
274
|
+
if (!options.command && !options.file) usage('no command given', 'send');
|
|
252
275
|
// help is fixed text, so it needs no browser: answer it here.
|
|
253
276
|
const asksHelp = /^help(?:\s+(.*))?$/.exec(options.command);
|
|
254
277
|
if (asksHelp) return help((asksHelp[1] || '').trim());
|
package/lib/cli-names.js
CHANGED
|
@@ -13,7 +13,7 @@ const NAMES = {
|
|
|
13
13
|
'tab-select': [a => `tab-select ${a}`, 'tab <index>, by index only'],
|
|
14
14
|
// By index there, by URL here: a number could match a port in another tab's URL.
|
|
15
15
|
'tab-close': [a => (a ? null : 'tab close'), 'tab close [url-part], or tab <index> then tab close'],
|
|
16
|
-
open: [a => `tab new ${a}`.trim(), 'tab new [url]; --mobile
|
|
16
|
+
open: [a => `tab new ${a}`.trim(), 'tab new [url] (goto <url> navigates); --mobile, --device=<name>: emulate mobile'],
|
|
17
17
|
// It ends playwright-cli's own browser; this one is shared, so tabs stay open.
|
|
18
18
|
close: [() => 'close', 'modes off --mine, and tabs stay open'],
|
|
19
19
|
'go-back': [() => 'back', 'back'],
|
|
@@ -69,7 +69,7 @@ const NEAREST = {
|
|
|
69
69
|
list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
|
|
70
70
|
attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
|
|
71
71
|
detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
|
|
72
|
-
|
|
72
|
+
drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
|
|
73
73
|
keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
|
|
74
74
|
pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
|
|
75
75
|
'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
|
|
@@ -244,7 +244,7 @@ function translate(text) {
|
|
|
244
244
|
if (name === 'open') {
|
|
245
245
|
const { found, rest: url } = takeOptions(args, OPTIONS.open);
|
|
246
246
|
const device = found.find(o => o.name === 'device');
|
|
247
|
-
if (!device && !found.length) return { text: `tab new ${url}`.trim(), as: 'tab new [url]' };
|
|
247
|
+
if (!device && !found.length) return { text: `tab new ${url}`.trim(), as: 'tab new [url], a new tab each time; goto <url> navigates the selected tab' };
|
|
248
248
|
if (device && !device.value) return { refuse: 'Usage: open [url] [--mobile | --device=<name>]' };
|
|
249
249
|
return {
|
|
250
250
|
steps: ['tab new', `emulate mobile${device ? ` ${device.value}` : ''}`, ...(url ? [`goto ${url}`] : [])],
|
package/lib/elements.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const { state, onShutdown } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { takeOptions } = require('./cli-names');
|
|
7
|
-
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
7
|
+
const { unquote, unescapeDouble, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
8
|
const { numbers, hints, BUSY_PAGE } = require('./util');
|
|
9
9
|
const { tabState } = require('./tabstate');
|
|
10
10
|
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
@@ -35,18 +35,37 @@ const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
|
|
|
35
35
|
|
|
36
36
|
// click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
|
|
37
37
|
// takes them. The button is read only after a ref or a quoted selector, so
|
|
38
|
-
// click text=Turn right is still one selector.
|
|
39
|
-
|
|
38
|
+
// click text=Turn right is still one selector; --button=right, as Playwright's option, after any.
|
|
39
|
+
// click takes --click-count too; dblclick is click's count of 2.
|
|
40
|
+
function clickArgs(args, usage, counts = false) {
|
|
40
41
|
// Found outside quotes only, so a quoted selector keeps whatever is in it.
|
|
41
|
-
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
|
|
42
|
-
|
|
42
|
+
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value' });
|
|
43
|
+
if (!counts && found.some(o => o.name === 'click-count')) throw new Error('dblclick is a click count of 2; for another count, click --click-count=<n>');
|
|
44
|
+
const clickCount = clickCountOf(found, usage);
|
|
45
|
+
const modifiers = found.filter(o => o.name === 'modifiers').flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
|
|
43
46
|
const unknown = modifiers.find(m => !MODIFIERS.includes(m));
|
|
44
47
|
if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
|
|
45
48
|
const parsed = splitSelector(text);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
49
|
+
const positional = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
|
|
50
|
+
const button = chooseButton(found, positional, usage);
|
|
51
|
+
const selector = positional ? parsed.selector : soleSelector(text, usage);
|
|
52
|
+
const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}) };
|
|
53
|
+
// The selector as written, then the kind of click, so an option never reads as part of the selector.
|
|
54
|
+
const kind = [
|
|
55
|
+
...(button && button !== 'left' ? [`${button} button`] : []),
|
|
56
|
+
...(clickCount > 1 ? [`${clickCount} times`] : []),
|
|
57
|
+
...(modifiers.length ? [`with ${modifiers.join('+')}`] : []),
|
|
58
|
+
];
|
|
59
|
+
return { selector, shown: [positional ? parsed.word : unquote(text), ...kind].join(', '), options };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --click-count=<n>, as Playwright's clickCount: 3 selects a line or a field's text. Past 3 a browser
|
|
63
|
+
// does nothing more.
|
|
64
|
+
function clickCountOf(found, usage) {
|
|
65
|
+
const given = found.filter(o => o.name === 'click-count');
|
|
66
|
+
if (!given.length) return 1;
|
|
67
|
+
if (given.length > 1 || !/^[1-3]$/.test(given[0].value || '')) throw new Error(`Usage: ${usage}; --click-count is 1, 2 or 3, once (2 is a double-click, 3 a triple-click)`);
|
|
68
|
+
return Number(given[0].value);
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
// A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
|
|
@@ -177,9 +196,30 @@ async function chooseMatch(selector, timeout) {
|
|
|
177
196
|
throw error;
|
|
178
197
|
}
|
|
179
198
|
|
|
199
|
+
// The button, from a word (click e5 right) or --button=right, as Playwright's option is named; not both.
|
|
200
|
+
function chooseButton(found, positional, usage) {
|
|
201
|
+
const named = found.filter(o => o.name === 'button');
|
|
202
|
+
if (named.length > 1 || (named.length && !/^(?:left|right|middle)$/.test(named[0].value || ''))) throw new Error(`Usage: ${usage}; --button is left, right or middle, once`);
|
|
203
|
+
if (positional && named.length) throw new Error(`Usage: ${usage}; the button goes once, as a word or as --button, not both`);
|
|
204
|
+
return positional || named[0]?.value || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A mouse command's words, its button (a last word, or --button) and its options. Any other --option is
|
|
208
|
+
// refused before a mouse event is sent.
|
|
209
|
+
function mouseArgs(line, usage, spec = {}) {
|
|
210
|
+
const { found, rest } = takeOptions((line || '').trim(), { button: 'value', ...spec });
|
|
211
|
+
const words = rest.split(/\s+/).filter(Boolean);
|
|
212
|
+
const unknown = words.find(w => w.startsWith('--'));
|
|
213
|
+
if (unknown) throw new Error(`Usage: ${usage}; ${unknown.split('=')[0]} is not one of its options`);
|
|
214
|
+
const last = /^[a-z]+$/.test(words[words.length - 1] || '') ? words.pop() : null;
|
|
215
|
+
if (last && !/^(?:left|right|middle)$/.test(last)) throw new Error(`Usage: ${usage}`);
|
|
216
|
+
return { words, found, button: chooseButton(found, last, usage) || 'left' };
|
|
217
|
+
}
|
|
218
|
+
|
|
180
219
|
function mouseButton(args, name) {
|
|
181
|
-
const
|
|
182
|
-
|
|
220
|
+
const usage = `${name} [left|right|middle]`;
|
|
221
|
+
const { words, button } = mouseArgs(args, usage);
|
|
222
|
+
if (words.length) throw new Error(`Usage: ${usage}`);
|
|
183
223
|
return button;
|
|
184
224
|
}
|
|
185
225
|
|
|
@@ -386,17 +426,48 @@ function dateInputHolds(kind, value, what, selector) {
|
|
|
386
426
|
|
|
387
427
|
const commands = {
|
|
388
428
|
async click(args) {
|
|
389
|
-
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
390
|
-
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, 1);
|
|
429
|
+
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]]', true);
|
|
430
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, options.clickCount || 1);
|
|
391
431
|
out.log(`Clicked: ${shown}${which}`);
|
|
392
432
|
},
|
|
393
433
|
|
|
394
434
|
async dblclick(args) {
|
|
395
|
-
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
435
|
+
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]]');
|
|
396
436
|
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
|
|
397
437
|
out.log(`Double-clicked: ${shown}${which}`);
|
|
398
438
|
},
|
|
399
439
|
|
|
440
|
+
// drag <from> <to>, as playwright-cli's drag: Playwright's locator.dragTo, in its steps (hover the
|
|
441
|
+
// source, press, hover the target, release), so the cursor can glide to each in turn. Chromium turns
|
|
442
|
+
// the moves into an HTML5 drag when the source is draggable.
|
|
443
|
+
async drag(args) {
|
|
444
|
+
const usage = 'Usage: drag <from> <to>, each a selector or a ref, quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#list li"';
|
|
445
|
+
const from = splitSelector(args);
|
|
446
|
+
const to = from?.rest !== undefined ? splitSelector(from.rest) : null;
|
|
447
|
+
if (!to || to.rest !== undefined) throw new Error(usage);
|
|
448
|
+
const TIMEOUT = 5000;
|
|
449
|
+
const deadline = Date.now() + TIMEOUT;
|
|
450
|
+
const left = () => Math.max(1000, deadline - Date.now());
|
|
451
|
+
const source = state.page.locator(from.selector).first();
|
|
452
|
+
const target = state.page.locator(to.selector).first();
|
|
453
|
+
await onElement(from.selector, () => aimAt(state.page, source, left()));
|
|
454
|
+
await onElement(from.selector, () => source.hover({ timeout: left() }));
|
|
455
|
+
await state.page.mouse.down();
|
|
456
|
+
// Pressed from here on: a failure would leave the button down, which the page would take as a drag still going.
|
|
457
|
+
try {
|
|
458
|
+
await showClick(state.page);
|
|
459
|
+
await onElement(to.selector, () => aimAt(state.page, target, left()));
|
|
460
|
+
await onElement(to.selector, () => target.hover({ timeout: left() }));
|
|
461
|
+
} catch (error) {
|
|
462
|
+
await state.page.mouse.up().catch(() => {});
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
await state.page.mouse.up();
|
|
466
|
+
const box = await target.boundingBox({ timeout: 1000 }).catch(() => null);
|
|
467
|
+
tabState(state.page).mouseAt = box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : null;
|
|
468
|
+
out.log(`Dragged ${from.word} to ${to.word}`);
|
|
469
|
+
},
|
|
470
|
+
|
|
400
471
|
async hover(args) {
|
|
401
472
|
const selector = soleSelector(args, 'hover <selector>');
|
|
402
473
|
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.hover(sel, o) : on.hover(o)));
|
|
@@ -472,23 +543,41 @@ const commands = {
|
|
|
472
543
|
|
|
473
544
|
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
474
545
|
async mouseclick(line) {
|
|
475
|
-
const usage = 'mouseclick <x> <y> [left|right|middle] [--delay=<ms>]';
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
546
|
+
const usage = 'mouseclick <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
547
|
+
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
548
|
+
const clickCount = clickCountOf(found, usage);
|
|
549
|
+
const delay = found.find(o => o.name === 'delay');
|
|
550
|
+
if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
|
|
551
|
+
const held = delay ? Number(delay.value) : 0;
|
|
480
552
|
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
481
553
|
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
482
554
|
stepTarget(state.page, null, { x, y });
|
|
483
555
|
await glideTo(state.page, x, y);
|
|
484
556
|
await state.page.mouse.move(x, y);
|
|
485
557
|
tabState(state.page).mouseAt = { x, y };
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
558
|
+
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
559
|
+
// mousedown's does, while a --delay holds the button.
|
|
560
|
+
for (let count = 1; count <= clickCount; count += 1) {
|
|
561
|
+
await state.page.mouse.down({ button, clickCount: count });
|
|
562
|
+
if (count === 1) await showClick(state.page, clickCount);
|
|
563
|
+
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
564
|
+
await state.page.mouse.up({ button, clickCount: count });
|
|
565
|
+
}
|
|
566
|
+
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Two mouseclicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
570
|
+
async mousedblclick(line) {
|
|
571
|
+
const usage = 'mousedblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
572
|
+
const { words, button } = mouseArgs(line, usage);
|
|
573
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
574
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
575
|
+
stepTarget(state.page, null, { x, y });
|
|
576
|
+
await glideTo(state.page, x, y);
|
|
577
|
+
await state.page.mouse.dblclick(x, y, { button });
|
|
578
|
+
tabState(state.page).mouseAt = { x, y };
|
|
579
|
+
await showClick(state.page, 2);
|
|
580
|
+
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
492
581
|
},
|
|
493
582
|
|
|
494
583
|
async mousedown(args) {
|
|
@@ -633,7 +722,7 @@ const commands = {
|
|
|
633
722
|
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
634
723
|
// A path, not a selector; // starts an XPath.
|
|
635
724
|
if (/^(?:\.{1,2}\/|\/(?!\/)|~)/.test(parsed.word) || (!REF.test(parsed.word) && fs.statSync(path.resolve(parsed.word), { throwIfNoEntry: false })?.isFile())) throw new Error(`${parsed.word} is a file, not the input: ${usage.slice(7)}`);
|
|
636
|
-
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1]
|
|
725
|
+
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? unescapeDouble(m[1]) : m[2] ?? m[3]));
|
|
637
726
|
const payloads = files.map(file => {
|
|
638
727
|
let buffer;
|
|
639
728
|
try { buffer = fs.readFileSync(file); } catch (error) { throw new Error(`Cannot read ${file}: ${error.code === 'ENOENT' ? 'no such file' : error.code === 'EISDIR' ? 'it is a folder' : error.message}`); }
|
package/lib/emulation.js
CHANGED
|
@@ -20,7 +20,8 @@ async function viewportText() {
|
|
|
20
20
|
const set = tabState(state.page).viewport;
|
|
21
21
|
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
22
22
|
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
23
|
-
|
|
23
|
+
// The window less its frame (headless Chromium counts one too: a 1280x800 window has a 1280x657 page).
|
|
24
|
+
return size ? `${size} (the window's page area)` : 'unknown';
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const DEFAULT_DEVICE = 'Pixel 7';
|