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 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, upload files, move the mouse, scroll the
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 `cdp`.
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 without `--launch`:
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 before `--`).
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` (it marks the tab you are looking at `(visible)`), `info` |
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>`, `wait <selector> --gone`, `wait text <text>` |
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` (also `emulate locale`, `emulate timezone`) |
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 in the browser, then `watch` |
148
- | see each step as I click | `watch on --live` |
149
- | replay what I clicked through | `watch on`, click around, `watch save flow.txt`, then `pw-repl send --file flow.txt` |
150
- | record requests and console together | `capture on`, then `capture off` |
151
- | find what makes the page slow | `profile on`, then `profile off`; `profile reload` for a page load |
152
- | fake a backend response | `route <glob> <status> <json>`, `route <glob> abort` |
153
- | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
154
- | cut or slow the whole network | `network off`, `network slow`, then `network on` |
155
- | record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
156
- | show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
157
- | say what is happening on screen | `toast <text>`, then `toast off` (`--duration=3s` hides it by itself) |
158
- | point something out on the page | `highlight <ref>` draws a box; `highlight off` hides it |
159
- | clean up | `modes off` |
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`, `route`,
166
- `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor` and `toast`. While any
167
- are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes` lists them
168
- for every tab, and `modes off` turns them all off. Each of these commands on its own shows its state and
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: `PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD
208
- }}"`, or the same line in a file run with `send --file`, as `watch save` writes it. `send` passes the
209
- value along with the command, and the REPL puts it in only as it runs the command, so the file, the
210
- REPL's output, its log and its history never hold it. Output that reads it back can: `snapshot`, `html`
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] <command...> | --file <file>
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) otherwise. If the socket exists but
35
- nothing answers, send fails rather than fall back. serve without an endpoint serves on that same one:
36
- $PW_ENDPOINT, then $PW_SOCKET, then the default.
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
- send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable. send -t is
45
- how long send waits for the answer (default 20s); it does not lengthen a command's own limits, such as
46
- the 5s an action waits for its element.
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
- A {{ PW_NAME }} in a command, or in a line of the file, is a variable: send takes its value from its own
55
- environment (PW_NAME) and passes it along with the command, not in it, e.g. a password kept out of the
56
- file and out of the command as the REPL logs it. In a file: fill '#password' "{{ PW_PASSWORD }}",
57
- run with PW_PASSWORD=... pw-repl send --file login.txt; or one command: PW_PASSWORD=... pw-repl send
58
- fill '#password' "{{ PW_PASSWORD }}". Only names starting PW_ are variables: any other {{ ... }}, as in a
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
- The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
66
-
67
- --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
68
- a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
69
- and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
70
- Flags after -- go to it, and only flags; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
71
- kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
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 or --device=<name> is emulate mobile [name]'],
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
- drag: 'not here yet', drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
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
- function clickArgs(args, usage) {
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
- const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
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 button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
47
- const selector = button ? parsed.selector : soleSelector(text, usage);
48
- const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
49
- return { selector, shown: args.trim(), options };
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 button = (args || '').trim() || 'left';
182
- if (!/^(?:left|right|middle)$/.test(button)) throw new Error(`Usage: ${name} [left|right|middle]`);
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 held = /(?:^|\s)--delay(?:=|\s+)(\S*)(?=\s|$)/.exec(line || '');
477
- if (held && !/^\d+$/.test(held[1])) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
478
- const words = (held ? line.replace(held[0], ' ') : line || '').trim().split(/\s+/).filter(Boolean);
479
- const button = /^[a-z]+$/.test(words[words.length - 1] || '') ? mouseButton(words.pop(), 'mouseclick <x> <y>') : 'left';
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
- // The ring shows at the press, as mousedown's does, while a --delay holds the button.
487
- await state.page.mouse.down({ button });
488
- await showClick(state.page);
489
- if (held) await new Promise(resolve => setTimeout(resolve, Number(held[1])));
490
- await state.page.mouse.up({ button });
491
- out.log(`Clicked the ${button} button at ${x}, ${y}`);
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].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
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
- return size ? `${size} (the window's size)` : 'unknown';
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';