pw-repl 0.11.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/README.md +174 -131
- package/bin/pw-repl.js +6 -6
- package/lib/background.js +2 -2
- package/lib/client.js +1 -1
- package/lib/help.js +19 -7
- package/lib/inspect.js +39 -9
- package/lib/send.js +2 -2
- package/lib/start.js +1 -1
- package/lib/tabs.js +10 -4
- package/lib/watch.js +43 -9
- package/package.json +2 -2
- package/skill/SKILL.md +11 -11
package/AGENTS.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
# Agent Instructions —
|
|
1
|
+
# Agent Instructions — pw-repl
|
|
2
2
|
|
|
3
3
|
How to use the REPL is in the skill `bin/pw-repl.js skill` prints (from `skill/SKILL.md`, stamped with
|
|
4
4
|
its version and hash). Read it first. In this clone, `pw-repl` there means `bin/pw-repl.js` (from the
|
|
5
5
|
folder this file is in) when it is not installed globally.
|
|
6
6
|
|
|
7
|
+
If there is a local agent file (e.g. `AGENTS.LOCAL.md`) next to this file, read it too.
|
|
8
|
+
They add to the rules in this file, and where the two differ, the local file wins.
|
|
9
|
+
|
|
7
10
|
## Contributing
|
|
8
11
|
|
|
9
12
|
If you hit a limitation or write a workaround, consider adding the capability to the REPL instead.
|
package/README.md
CHANGED
|
@@ -1,152 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pw-repl
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
and every command shows in the REPL, so each sees what the other does.
|
|
3
|
+
A Playwright REPL for driving Chromium from the command line, made to be shared by you and your AI
|
|
4
|
+
agents.
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
pw-repl connects to a Chromium over CDP (the Chrome DevTools Protocol), in the browser you already use or
|
|
7
|
+
in one it starts itself. You type commands at a `pw>` prompt. Agents send the same commands from a shell
|
|
8
|
+
with `pw-repl send`. Every command shows in the REPL, whoever sent it, so you always see what an agent
|
|
9
|
+
is doing in your browser, and the agent can see what you did.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```text
|
|
12
|
+
pw> tab new http://localhost:3000/cart
|
|
13
|
+
pw> snapshot # the page by role and name, with [ref=eN] labels
|
|
14
|
+
pw> click e7 # click by ref, or by any Playwright selector
|
|
15
|
+
pw> requests # what the page requested
|
|
16
|
+
pw> body 4 # what one request got back
|
|
17
|
+
pw> route **/api/cart 500 {"error":"boom"} # fake a failing backend
|
|
18
|
+
pw> reload
|
|
19
|
+
pw> console error # and see how the page copes
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Why use it
|
|
10
23
|
|
|
11
|
-
- **Your browser, as it is.** It connects to a running Chromium, with your tabs,
|
|
12
|
-
leaves it running when it stops
|
|
24
|
+
- **Your browser, as it is.** It connects to a running Chromium, with your tabs, logins and state, and
|
|
25
|
+
leaves it running when it stops. Nothing is reset to a fresh profile.
|
|
13
26
|
- **One session, seen by everyone.** You and any number of agents drive the same browser, each with a
|
|
14
|
-
selected tab of its own, and every command shows in the REPL
|
|
27
|
+
selected tab of its own, and every command shows in the REPL.
|
|
15
28
|
- **What you did, for an agent to read.** `watch` records each step you take in a tab, with the requests
|
|
16
|
-
it caused
|
|
17
|
-
|
|
29
|
+
it caused. Reproduce a bug by hand and the agent sees exactly what happened. `watch save` keeps the
|
|
30
|
+
steps as a file of commands that runs them again.
|
|
18
31
|
- **Breaking things on purpose.** Fake, patch, delay or fail responses, cut or slow the network, emulate a
|
|
19
|
-
phone, a locale or a timezone
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
phone, a locale or a timezone. All of it applies to one tab, so your other tabs are left alone.
|
|
33
|
+
- **Videos that explain themselves.** Record a tab with a pointer that glides to each click, captions on
|
|
34
|
+
screen, boxes around what matters, and pacing for a viewer.
|
|
35
|
+
|
|
36
|
+
## What it can do
|
|
37
|
+
|
|
38
|
+
- **Read the page:** accessibility snapshots with clickable refs, text, HTML, attributes, links, form
|
|
39
|
+
controls, event listeners, screenshots (viewport, full page or one element).
|
|
40
|
+
- **Act on it:** click, fill, type, press keys, select, check, upload files, move the mouse, scroll the
|
|
41
|
+
wheel, answer dialogs.
|
|
42
|
+
- **Watch the network:** every request and response body, console messages and page errors, all recorded
|
|
43
|
+
as they happen.
|
|
44
|
+
- **Change the network:** fake a response (`route`), patch its JSON, delay it, fail it, or cut or slow the
|
|
45
|
+
whole tab's network.
|
|
46
|
+
- **Emulate:** a phone, dark mode, a locale, a timezone, a viewport size.
|
|
47
|
+
- **Run code:** `eval` any JavaScript in the page, or send a raw CDP command with `cdp`.
|
|
48
|
+
- **Wait:** for an element, some text, a response or a page load.
|
|
49
|
+
- **Record:** what someone does in a tab (`watch`), a timeline of requests and console (`capture`), or a
|
|
50
|
+
video of the tab (`record`), with a virtual pointer (`cursor`), captions (`toast`) and highlights
|
|
51
|
+
(`highlight`).
|
|
52
|
+
- **Share:** many clients on one REPL, each with its own tab; a command server on a Unix socket or a local
|
|
53
|
+
port, with an HTTP+JSON protocol.
|
|
22
54
|
|
|
23
55
|
## Quick start
|
|
24
56
|
|
|
25
|
-
|
|
26
|
-
`npx playwright-core install chromium` downloads
|
|
57
|
+
You need Node.js 20 or newer. pw-repl can use a Chromium-based browser you already have (Chrome,
|
|
58
|
+
Chromium, ...), or Playwright's own: `npx playwright-core install chromium` downloads it.
|
|
27
59
|
|
|
28
60
|
```bash
|
|
29
61
|
npx pw-repl@latest run --launch https://example.com
|
|
30
62
|
```
|
|
31
63
|
|
|
32
|
-
`--launch` starts a private headless Chromium for the REPL
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
`--launch` starts a private headless Chromium for the REPL and stops it when the REPL quits. Add
|
|
65
|
+
`--headed` to see that browser and click in it yourself. The page opens in a new tab, and a `pw>`
|
|
66
|
+
prompt waits:
|
|
35
67
|
|
|
36
68
|
```text
|
|
37
69
|
pw> help # common tasks and topics; help <topic>, help <command>, help --all
|
|
38
|
-
pw> snapshot
|
|
39
|
-
pw> click e3
|
|
40
|
-
pw>
|
|
70
|
+
pw> snapshot
|
|
71
|
+
pw> click e3
|
|
72
|
+
pw> screenshot
|
|
41
73
|
pw> quit
|
|
42
74
|
```
|
|
43
75
|
|
|
44
|
-
Commands act on the selected tab
|
|
76
|
+
Commands act on the selected tab. `tab` lists the tabs, with `*` on the selected one.
|
|
77
|
+
|
|
78
|
+
### Install
|
|
79
|
+
|
|
80
|
+
| How | Setup | Then run |
|
|
81
|
+
| ------------ | -------------------------------------------- | ------------------------------ |
|
|
82
|
+
| Without | nothing | `npx pw-repl@latest <command>` |
|
|
83
|
+
| Global | `npm install -g pw-repl` | `pw-repl <command>` |
|
|
84
|
+
| From a clone | `npm install` (and `npm link` for `pw-repl`) | `bin/pw-repl.js <command>` |
|
|
45
85
|
|
|
46
|
-
|
|
47
|
-
the same version. Installed: `npm install -g pw-repl`, then `pw-repl <command>`. From a clone:
|
|
48
|
-
`npm install`, then `bin/pw-repl.js <command>` (or `npm link` to get `pw-repl`).
|
|
86
|
+
With `npx`, use `npx pw-repl@latest` the same way every time, so every command runs the same version.
|
|
49
87
|
|
|
50
88
|
## Use your own browser
|
|
51
89
|
|
|
52
|
-
Start
|
|
90
|
+
Start Chrome with remote debugging, then run the REPL without `--launch`:
|
|
53
91
|
|
|
54
92
|
```bash
|
|
55
93
|
chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
|
|
56
|
-
pw-repl run # connects to localhost:9222
|
|
94
|
+
pw-repl run # connects to localhost:9222
|
|
57
95
|
```
|
|
58
96
|
|
|
59
|
-
Use a separate `--user-data-dir`: recent Chrome
|
|
60
|
-
profile.
|
|
61
|
-
|
|
97
|
+
Use a separate `--user-data-dir`: recent versions of Chrome do not open the debugging port on the default
|
|
98
|
+
profile. To check that it is up: `curl http://localhost:9222/json/version`. To reach a browser on
|
|
99
|
+
another port or machine, set `PW_CDP_URL=http://host:9222`.
|
|
62
100
|
|
|
63
|
-
|
|
64
|
-
--user-data-dir=/tmp/chrome-headless`). Nobody
|
|
65
|
-
accept
|
|
101
|
+
A headless Chrome works the same way (`chrome --headless=new --remote-debugging-port=9222
|
|
102
|
+
--user-data-dir=/tmp/chrome-headless`). Nobody can click a headless browser's dialogs, so answer them
|
|
103
|
+
with `dialog accept` or `dialog dismiss`. Its pages start at about 800x600; `viewport 1280x800` changes
|
|
104
|
+
that.
|
|
66
105
|
|
|
67
106
|
**Browser on the host, REPL in a container:** with host networking, `localhost:9222` reaches the host's
|
|
68
|
-
browser directly. Otherwise set `PW_CDP_URL` to the host's address as
|
|
69
|
-
the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
107
|
+
browser directly. Otherwise, set `PW_CDP_URL` to the host's address as the container sees it (for
|
|
108
|
+
example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
70
109
|
|
|
71
110
|
## Ways to run it
|
|
72
111
|
|
|
73
|
-
| Command | What you get
|
|
74
|
-
| ---------------------------- |
|
|
75
|
-
| `pw-repl run` | a prompt in this terminal; `send` reaches it only through a tmux session
|
|
76
|
-
| `pw-repl serve` | a prompt (`pw[serve]>`), plus a command server on `/tmp/
|
|
77
|
-
| `pw-repl serve --background` | no terminal
|
|
112
|
+
| Command | What you get |
|
|
113
|
+
| ---------------------------- | ---------------------------------------------------------------------------------- |
|
|
114
|
+
| `pw-repl run` | a prompt in this terminal; `send` reaches it only through a tmux session `pw-repl` |
|
|
115
|
+
| `pw-repl serve` | a prompt (`pw[serve]>`), plus a command server on `/tmp/pw-repl.sock` |
|
|
116
|
+
| `pw-repl serve --background` | no terminal; output in `/tmp/pw-repl.log` |
|
|
78
117
|
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
- A
|
|
83
|
-
|
|
84
|
-
- A
|
|
85
|
-
|
|
118
|
+
- `pw-repl attach` shows a background REPL and takes commands, from any terminal. `pw-repl stop` stops
|
|
119
|
+
it. `pw-repl where` says what is running and how `send` will reach it.
|
|
120
|
+
- For `send` to reach `run`, start it in tmux: `tmux new -s pw-repl`, then `pw-repl run` there.
|
|
121
|
+
- A URL after the command opens in a new tab: `pw-repl serve http://localhost:3000`.
|
|
122
|
+
- `--launch` works with each of them. Flags after `--` go to that Chromium.
|
|
123
|
+
- A REPL of your own goes on a socket of its own: `-e /tmp/mine.sock` on every command, `serve`
|
|
124
|
+
included, or set `PW_SOCKET`. `serve <port>` listens on TCP, on 127.0.0.1 only.
|
|
86
125
|
|
|
87
126
|
## Common tasks
|
|
88
127
|
|
|
89
|
-
| I want to… | Commands
|
|
90
|
-
| ------------------------------------ |
|
|
91
|
-
| see where I am | `tab` (it marks the tab you are looking at `(visible)`), `info`
|
|
92
|
-
| see what is on the page | `snapshot`, `screenshot`
|
|
93
|
-
| do something on it | `click`, `fill`, `press`
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
| see
|
|
98
|
-
| see
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
| record
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
| I want to… | Commands |
|
|
129
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------ |
|
|
130
|
+
| see where I am | `tab` (it marks the tab you are looking at `(visible)`), `info` |
|
|
131
|
+
| see what is on the page | `snapshot`, `screenshot` |
|
|
132
|
+
| do something on it | `click`, `fill`, `type`, `press` |
|
|
133
|
+
| run some JavaScript in the page | `eval document.title` |
|
|
134
|
+
| wait for a page or element | `wait load`, `wait <selector>`, `wait <selector> --gone`, `wait text <text>` |
|
|
135
|
+
| choose a file in a file input | `upload <selector> <file>` |
|
|
136
|
+
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark` (also `emulate locale`, `emulate timezone`) |
|
|
137
|
+
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
138
|
+
| see console messages and errors | `console` |
|
|
139
|
+
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
140
|
+
| see each step as I click | `watch on --live` |
|
|
141
|
+
| replay what I clicked through | `watch on`, click around, `watch save flow.txt`, then `pw-repl send --file flow.txt` |
|
|
142
|
+
| record requests and console together | `capture on`, then `capture off` |
|
|
143
|
+
| fake a backend response | `route <glob> <status> <json>`, `route <glob> abort` |
|
|
144
|
+
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
145
|
+
| cut or slow the whole network | `network off`, `network slow`, then `network on` |
|
|
146
|
+
| record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
|
|
147
|
+
| show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
|
|
148
|
+
| say what is happening on screen | `toast <text>`, then `toast off` (`--duration=3s` hides it by itself) |
|
|
149
|
+
| point something out on the page | `highlight <ref>` draws a box; `highlight off` hides it |
|
|
150
|
+
| clean up | `modes off` |
|
|
151
|
+
|
|
152
|
+
`help <topic>` lists the rest, and `help <command>` has usage and caveats.
|
|
113
153
|
|
|
114
154
|
### Modes
|
|
115
155
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
`
|
|
120
|
-
|
|
121
|
-
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
122
|
-
lists them for every tab, and `modes off` turns them all off.
|
|
123
|
-
|
|
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.
|
|
126
|
-
|
|
127
|
-
## How it differs from playwright-cli
|
|
128
|
-
|
|
129
|
-
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
130
|
-
gives each agent a browser session of its own; pw-repl shares one browser between users and agents.
|
|
131
|
-
Its command names and most of their options work here too (`help playwright-cli` lists them), and where
|
|
132
|
-
pw-repl differs, it is mostly because someone else may be using the browser:
|
|
156
|
+
Some commands turn on a mode that stays on until you turn it off: `watch`, `capture`, `route`, `network
|
|
157
|
+
off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor` and `toast`. While any are on in
|
|
158
|
+
the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes` lists them for every
|
|
159
|
+
tab, and `modes off` turns them all off. Each of these commands on its own shows its state and what you
|
|
160
|
+
can run next.
|
|
133
161
|
|
|
134
|
-
|
|
135
|
-
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
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.
|
|
138
|
-
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
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.
|
|
141
|
-
- `highlight` draws its box without Playwright's locator label, which covers what is below it and puts
|
|
142
|
-
selectors in screenshots and videos; `highlight --labels on` shows it.
|
|
143
|
-
|
|
144
|
-
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
145
|
-
playwright-cli is the better fit. `record on` records a tab's page, and `cursor on` draws a pointer where the
|
|
146
|
-
agent acts; for the shared browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS
|
|
147
|
-
`screencapture -v`, OBS).
|
|
148
|
-
|
|
149
|
-
## From scripts and agents
|
|
162
|
+
## For agents and scripts
|
|
150
163
|
|
|
151
164
|
### A skill for agents
|
|
152
165
|
|
|
@@ -159,9 +172,9 @@ mkdir -p <skills folder>/pw-repl
|
|
|
159
172
|
npx pw-repl@latest skill > <skills folder>/pw-repl/SKILL.md
|
|
160
173
|
```
|
|
161
174
|
|
|
162
|
-
It is stamped with the version it came from and a hash of its text
|
|
163
|
-
|
|
164
|
-
(keeping its Custom rules section).
|
|
175
|
+
It is stamped with the version it came from and a hash of its text. `pw-repl where` names the skill of
|
|
176
|
+
the pw-repl being run, so an agent can tell when its saved copy is out of date and offer to update it
|
|
177
|
+
(keeping its Custom rules section).
|
|
165
178
|
|
|
166
179
|
### send, clients and where
|
|
167
180
|
|
|
@@ -171,44 +184,74 @@ pw-repl send help # the REPL's commands; works without a running REPL
|
|
|
171
184
|
pw-repl where # which REPL send would reach
|
|
172
185
|
```
|
|
173
186
|
|
|
174
|
-
`pw-repl send` sends one command and prints its result, through the server when
|
|
175
|
-
`
|
|
176
|
-
is not confirmed (exit 1 or 2), `;`
|
|
187
|
+
`pw-repl send` sends one command and prints its result, through the server when one is running, and
|
|
188
|
+
through the `pw-repl` tmux session otherwise. Chain several in the shell: `&&` stops at the first that
|
|
189
|
+
fails or is not confirmed (exit 1 or 2), and `;` carries on.
|
|
190
|
+
|
|
191
|
+
Several agents can share one REPL. `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client
|
|
192
|
+
with a selected tab of its own, shown in the REPL as `[server:<name>]`. `modes` says which client turned
|
|
193
|
+
each mode on, and `modes off --mine` turns off only the sender's own.
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
and `modes off --mine` turns off only the sender's own.
|
|
195
|
+
`send --file take.txt` runs a file of commands, one per line, and stops at the first that fails: a
|
|
196
|
+
repro to keep, or a video take.
|
|
181
197
|
|
|
182
|
-
|
|
183
|
-
or the same line in a file run with `send --file`, as `watch save` writes it. `send`
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
text that repeats it do not. Only `PW_` names are variables (`pw-repl --help` has the rest).
|
|
198
|
+
**Passwords:** type one as a variable: `PW_PASSWORD=... pw-repl send fill '#password' "{{ PW_PASSWORD
|
|
199
|
+
}}"`, or the same line in a file run with `send --file`, as `watch save` writes it. `send` passes the
|
|
200
|
+
value along with the command, and the REPL puts it in only as it runs the command, so the file, the
|
|
201
|
+
REPL's output, its log and its history never hold it. Output that reads it back can: `snapshot`, `html`
|
|
202
|
+
and `attrs` hide a password field's value, but `eval` on the field, a field that a Show password button
|
|
203
|
+
made plain text, or page text that repeats it do not. Only `PW_` names are variables.
|
|
189
204
|
|
|
190
205
|
### The HTTP protocol
|
|
191
206
|
|
|
192
207
|
With `pw-repl serve`, the server speaks HTTP with JSON, for clients of your own:
|
|
193
208
|
|
|
194
209
|
```bash
|
|
195
|
-
curl --unix-socket /tmp/
|
|
210
|
+
curl --unix-socket /tmp/pw-repl.sock -H 'Content-Type: application/json' \
|
|
196
211
|
-d '{"command": "info"}' http://localhost/run
|
|
197
212
|
```
|
|
198
213
|
|
|
199
214
|
The body may also have `"client": "<name>"`, and `"vars": {"PW_NAME": "<value>"}` with the values of the
|
|
200
|
-
`{{ PW_NAME }}` variables in the command
|
|
201
|
-
`"unconfirmed": true` when the command may or may not have done what it
|
|
202
|
-
the REPL quit while it ran and it was not read-only). While the REPL
|
|
203
|
-
`"starting": true`: retry. `GET /health` says `"status": "starting"` until
|
|
204
|
-
`pw-repl serve <port>` serves TCP on 127.0.0.1
|
|
215
|
+
`{{ PW_NAME }}` variables in the command (one line only, else 400). It returns `{"status": "ok" |
|
|
216
|
+
"error", "output": "..."}`, plus `"unconfirmed": true` when the command may or may not have done what it
|
|
217
|
+
was sent to do (it timed out, or the REPL quit while it ran and it was not read-only). While the REPL
|
|
218
|
+
starts, `/run` answers 503 with `"starting": true`: retry. `GET /health` says `"status": "starting"` until
|
|
219
|
+
it serves, then `"ok"`. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
|
|
220
|
+
|
|
221
|
+
## How it differs from playwright-cli
|
|
205
222
|
|
|
206
|
-
|
|
223
|
+
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
224
|
+
gives each agent a browser session of its own, while pw-repl shares one browser between users and agents.
|
|
225
|
+
playwright-cli's command names and most of their options work here too (`help playwright-cli` lists
|
|
226
|
+
them). Where pw-repl differs, it is mostly because someone else may be using the browser:
|
|
227
|
+
|
|
228
|
+
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
229
|
+
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
230
|
+
- File pickers are not caught, since they may be the user's: `upload` names the input instead.
|
|
231
|
+
- There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
|
|
232
|
+
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
233
|
+
- `watch` records what the user does as steps with their requests, and `watch save` writes them as
|
|
234
|
+
pw-repl commands to run again, not as Playwright code.
|
|
235
|
+
- `highlight` draws its box without Playwright's locator label, which would cover what is below it and
|
|
236
|
+
put selectors in screenshots and videos. `highlight --labels on` shows it.
|
|
237
|
+
|
|
238
|
+
For a browser of its own for each agent, isolated sessions, traces, PDFs or generated test code,
|
|
239
|
+
playwright-cli is the better fit. `record` records a tab's page; to record the shared browser as you see
|
|
240
|
+
it, with your real pointer, use a screen recorder (OBS, `ffmpeg -f x11grab`, macOS `screencapture -v`).
|
|
241
|
+
|
|
242
|
+
## Contributing
|
|
243
|
+
|
|
244
|
+
`AGENTS.md` describes how the code is laid out, how to add a command, and how releases are cut.
|
|
207
245
|
|
|
208
246
|
```bash
|
|
247
|
+
npm install
|
|
209
248
|
npm test
|
|
210
249
|
```
|
|
211
250
|
|
|
212
|
-
|
|
213
|
-
the one `--launch` would use: Playwright's,
|
|
214
|
-
|
|
251
|
+
The tests need Node.js 21 or newer. They run against a private headless Chromium they start themselves
|
|
252
|
+
(`PW_TEST_CHROME`, or the one `--launch` would use: Playwright's, then one on the `PATH`) and a local test
|
|
253
|
+
site. The browser tests are skipped when no Chromium is found.
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT. See `LICENSE.md`.
|
package/bin/pw-repl.js
CHANGED
|
@@ -8,7 +8,7 @@ const USAGE = `Usage:
|
|
|
8
8
|
connect to the browser and open the prompt
|
|
9
9
|
|
|
10
10
|
pw-repl serve [--background] [--launch [--headed]] [[-e] endpoint] [start-url] [-- <chromium flags>]
|
|
11
|
-
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/
|
|
11
|
+
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/pw-repl.sock);
|
|
12
12
|
--background runs it detached, with its output in a log next to the socket
|
|
13
13
|
|
|
14
14
|
pw-repl attach [-e endpoint]
|
|
@@ -29,10 +29,10 @@ const USAGE = `Usage:
|
|
|
29
29
|
pw-repl skill
|
|
30
30
|
print an agent skill (SKILL.md) that teaches an agent to use the REPL
|
|
31
31
|
|
|
32
|
-
send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/pw-repl.sock) is
|
|
33
|
+
there, and the tmux session (-s, $PW_TMUX_SESSION, default pw-repl) otherwise. If the socket exists but
|
|
34
|
+
nothing answers, send fails rather than fall back. serve without an endpoint serves on that same one:
|
|
35
|
+
$PW_ENDPOINT, then $PW_SOCKET, then the default.
|
|
36
36
|
|
|
37
37
|
send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
|
|
38
38
|
several agents can share one REPL; pw-repl help session has the rest.
|
|
@@ -64,7 +64,7 @@ and stops it with the REPL (Ctrl-C while it starts too); stop returns once that
|
|
|
64
64
|
Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
65
65
|
kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
66
66
|
|
|
67
|
-
In a tmux session named
|
|
67
|
+
In a tmux session named pw-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
68
68
|
|
|
69
69
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
70
70
|
|
package/lib/background.js
CHANGED
|
@@ -20,9 +20,9 @@ const FOLLOW_INTERVAL = 150;
|
|
|
20
20
|
const ATTACH_BACKLOG = 20;
|
|
21
21
|
const ATTACH_COMMAND_TIMEOUT = 3600000;
|
|
22
22
|
|
|
23
|
-
// /tmp/
|
|
23
|
+
// /tmp/pw-repl.sock keeps /tmp/pw-repl.pid and .log; a port, /tmp/pw-repl-<port>.*.
|
|
24
24
|
function filesFor(endpoint) {
|
|
25
|
-
const base = endpoint.socket ? endpoint.socket.replace(/\.sock$/, '') : path.join('/tmp', `
|
|
25
|
+
const base = endpoint.socket ? endpoint.socket.replace(/\.sock$/, '') : path.join('/tmp', `pw-repl-${endpoint.port}`);
|
|
26
26
|
return { pidFile: `${base}.pid`, logFile: `${base}.log` };
|
|
27
27
|
}
|
|
28
28
|
|
package/lib/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const http = require('http');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
-
const DEFAULT_SOCKET = '/tmp/
|
|
5
|
+
const DEFAULT_SOCKET = '/tmp/pw-repl.sock';
|
|
6
6
|
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
7
7
|
|
|
8
8
|
// A port or loopback host:port is TCP; anything else is a unix socket path.
|
package/lib/help.js
CHANGED
|
@@ -22,7 +22,7 @@ function wrap(text, width = WIDTH) {
|
|
|
22
22
|
}).join('\n\n');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const OVERVIEW = wrap(`
|
|
25
|
+
const OVERVIEW = wrap(`pw-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
|
|
26
26
|
The browser may be a shared session, with other users' tabs open in it.
|
|
27
27
|
|
|
28
28
|
Common tasks:
|
|
@@ -128,7 +128,7 @@ still show sensitive data (cdp Network.getCookies shows cookie values).`,
|
|
|
128
128
|
Unknown outcome: a command that timed out after it began acting on the page may or may not have done
|
|
129
129
|
it. It says so (pw-repl send exits 2), and the REPL carries on.
|
|
130
130
|
|
|
131
|
-
Server: pw-repl serve also takes commands on /tmp/
|
|
131
|
+
Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
|
|
132
132
|
|
|
133
133
|
Clients: pw-repl send -c <name> (or $PW_CLIENT) sends as a client of its own. It has a selected tab of
|
|
134
134
|
its own, so clients do not move each other's; its commands show as [server:<name>] lines. modes and tab
|
|
@@ -156,7 +156,7 @@ const COMMANDS = {
|
|
|
156
156
|
['<index>', 'selects by the number in your latest listing (each client has its own); numbers change when tabs open or close.'],
|
|
157
157
|
['<url-part>', 'selects the one tab whose URL contains it; refused if none or several do.'],
|
|
158
158
|
['new [url]', 'opens a tab behind the one in front, so it does not take the window from whoever is using it, and selects it. The URL is read as goto reads it.'],
|
|
159
|
-
['close [url-part]', 'closes the selected tab, or the one matching url-part.'],
|
|
159
|
+
['close [url-part]', 'closes the selected tab, or the one matching url-part; of several, the one you opened, if exactly one of them is, and refused otherwise.'],
|
|
160
160
|
],
|
|
161
161
|
detail: `Closing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is
|
|
162
162
|
selected, and commands that need one refuse until one is.
|
|
@@ -316,7 +316,8 @@ to the label.
|
|
|
316
316
|
Passwords: a password field's value is shown as (a password: not shown), --full too, as is that of a
|
|
317
317
|
field whose autocomplete is for a password, a one-time code or a card number; html and attrs show its
|
|
318
318
|
value attribute so too. A field a Show password button made plain text, with no such autocomplete,
|
|
319
|
-
shows its value.
|
|
319
|
+
shows its value. Every other field shows what it holds (textbox "Name": Ada), and a select its chosen
|
|
320
|
+
option: inputs lists the fields without what they hold.
|
|
320
321
|
|
|
321
322
|
Output over 60 lines ends with a line count.`,
|
|
322
323
|
},
|
|
@@ -341,7 +342,8 @@ Output over 60 lines ends with a line count.`,
|
|
|
341
342
|
navigation, described by role and name as in snapshot, with up to 5 of the requests it caused under it,
|
|
342
343
|
each with its status as in requests (body <#> for one), leaving out scripts and what requests hides. The
|
|
343
344
|
REPL's own fill shows as type. A double-click shows as two clicks; hovering and moving the mouse are not
|
|
344
|
-
recorded.
|
|
345
|
+
recorded. When other elements are described alike (cards that repeat their buttons), the step says which
|
|
346
|
+
one: (under "<its nearest heading>"), with n of N where the heading does not settle it.
|
|
345
347
|
|
|
346
348
|
Privacy: what is typed is recorded, and watch and watch save show it, except in a password field: one
|
|
347
349
|
that was ever type=password, or whose autocomplete is for a password, a one-time code or a card number.
|
|
@@ -380,7 +382,9 @@ off leaves the wait on.
|
|
|
380
382
|
|
|
381
383
|
watch save: a submit is left out, since the click or Enter that caused it is there. So is a URL the app
|
|
382
384
|
changed without loading a page (history.pushState or replaceState), as a comment: the step before it
|
|
383
|
-
changes it again. A click on a label
|
|
385
|
+
changes it again. A click on a label that holds its control is the control's click, as it passes it on;
|
|
386
|
+
one on a label that names its control with for= is the label's, since that can be a control anywhere
|
|
387
|
+
(the first of a page's repeated ids), and a for= label on a checkbox toggles it rather than checks it.
|
|
384
388
|
In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
|
|
385
389
|
},
|
|
386
390
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
@@ -400,7 +404,15 @@ Playwright adds to window as it acts are left out, and it says how many.`,
|
|
|
400
404
|
count: { usage: 'count <selector>', summary: 'number of matches' },
|
|
401
405
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
402
406
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
403
|
-
inputs: {
|
|
407
|
+
inputs: {
|
|
408
|
+
usage: 'inputs [--all]',
|
|
409
|
+
summary: 'form controls, native and ARIA; values are omitted',
|
|
410
|
+
detail: `Each control as JSON: tag, role, type, name, id, label, placeholder, disabled (by its own attribute,
|
|
411
|
+
a disabled fieldset around it, or aria-disabled), readonly, checked for a checkbox, radio or switch (true,
|
|
412
|
+
false or "mixed"), and autocomplete. An ARIA control is an element with role checkbox, radio, switch, combobox, listbox, textbox,
|
|
413
|
+
searchbox, slider or spinbutton, as Radix and the like draw them (a button that is a radio); its role
|
|
414
|
+
says what it is, and type is a native control's own. What a field holds is never shown.`,
|
|
415
|
+
},
|
|
404
416
|
highlight: {
|
|
405
417
|
usage: 'highlight [<selector> | off] [options]',
|
|
406
418
|
summary: 'draw a box over elements, for the user or a screenshot',
|
package/lib/inspect.js
CHANGED
|
@@ -15,6 +15,8 @@ const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
|
15
15
|
const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
|
|
16
16
|
|
|
17
17
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
18
|
+
// What inputs lists: native form controls, and elements that are one by their ARIA role.
|
|
19
|
+
const INPUTS = ['input', 'select', 'textarea', ...['checkbox', 'radio', 'switch', 'combobox', 'listbox', 'textbox', 'searchbox', 'slider', 'spinbutton'].map(role => `[role=${role}]`)].join(', ');
|
|
18
20
|
|
|
19
21
|
function nextScreenshotPath(name) {
|
|
20
22
|
const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
|
|
@@ -262,18 +264,46 @@ const commands = {
|
|
|
262
264
|
printOutput(links, all);
|
|
263
265
|
},
|
|
264
266
|
|
|
267
|
+
// Native controls and ARIA ones (a button with role=radio, as Radix draws them), each with what names
|
|
268
|
+
// it and, for a checkbox, radio or switch, whether it is checked: true, false or "mixed" for both.
|
|
265
269
|
async inputs(_args, all) {
|
|
266
|
-
const inputs = await state.page.$$eval(
|
|
267
|
-
|
|
270
|
+
const inputs = await state.page.$$eval(INPUTS, els => {
|
|
271
|
+
const plain = text => (text || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
272
|
+
// A label's own words, without what the fields inside it hold.
|
|
273
|
+
const labelText = label => {
|
|
274
|
+
const copy = label.cloneNode(true);
|
|
275
|
+
copy.querySelectorAll('select, textarea, input, button').forEach(n => n.remove());
|
|
276
|
+
return copy.textContent;
|
|
277
|
+
};
|
|
278
|
+
const labelOf = e => {
|
|
279
|
+
const byIds = (e.getAttribute('aria-labelledby') || '').split(/\s+/).map(id => id && document.getElementById(id)).filter(Boolean);
|
|
280
|
+
if (byIds.length) return plain(byIds.map(n => n.textContent).join(' '));
|
|
281
|
+
return plain(e.getAttribute('aria-label')) || plain([...(e.labels || [])].map(labelText).join(' ')) || plain(e.getAttribute('title'));
|
|
282
|
+
};
|
|
283
|
+
const native = e => e.matches('input, select, textarea');
|
|
284
|
+
const checkedOf = e => {
|
|
285
|
+
if (native(e) && /^(?:checkbox|radio)$/.test(e.type)) return e.indeterminate ? 'mixed' : e.checked;
|
|
286
|
+
if (!/^(?:checkbox|radio|switch)$/.test(e.getAttribute('role'))) return undefined;
|
|
287
|
+
const state = e.getAttribute('aria-checked');
|
|
288
|
+
return state === 'mixed' ? 'mixed' : state === 'true';
|
|
289
|
+
};
|
|
290
|
+
return els.map(e => ({
|
|
268
291
|
tag: e.tagName.toLowerCase(),
|
|
269
|
-
|
|
270
|
-
|
|
292
|
+
role: e.getAttribute('role') || '',
|
|
293
|
+
// A button's own type (submit) is not what kind of control it is: its role says that.
|
|
294
|
+
type: native(e) ? e.type : '',
|
|
295
|
+
name: e.getAttribute('name') || '',
|
|
271
296
|
id: e.id || '',
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
label: labelOf(e),
|
|
298
|
+
placeholder: e.getAttribute('placeholder') || '',
|
|
299
|
+
// By its own attribute (a button that is a radio, as Radix does it), a disabled fieldset around it,
|
|
300
|
+
// or aria-disabled.
|
|
301
|
+
disabled: e.matches(':disabled') || e.getAttribute('aria-disabled') === 'true',
|
|
302
|
+
readonly: e.matches('input, textarea') ? e.readOnly : e.getAttribute('aria-readonly') === 'true',
|
|
303
|
+
checked: checkedOf(e),
|
|
304
|
+
autocomplete: native(e) ? e.autocomplete || '' : ''
|
|
305
|
+
}));
|
|
306
|
+
});
|
|
277
307
|
if (!inputs.length) { out.log('No inputs found'); return; }
|
|
278
308
|
printOutput(inputs, all);
|
|
279
309
|
},
|
package/lib/send.js
CHANGED
|
@@ -25,7 +25,7 @@ function hasSession(session) {
|
|
|
25
25
|
function route(options) {
|
|
26
26
|
const socket = process.env.PW_SOCKET || client.DEFAULT_SOCKET;
|
|
27
27
|
const endpoint = options.endpoint || process.env.PW_ENDPOINT || '';
|
|
28
|
-
const session = options.session || process.env.PW_TMUX_SESSION || '
|
|
28
|
+
const session = options.session || process.env.PW_TMUX_SESSION || 'pw-repl';
|
|
29
29
|
if (options.session) return { kind: 'tmux', session };
|
|
30
30
|
if (endpoint) return { kind: 'server', endpoint: client.parseEndpoint(endpoint), explicit: true };
|
|
31
31
|
if (process.env.PW_SOCKET) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: true };
|
|
@@ -235,7 +235,7 @@ async function where(options) {
|
|
|
235
235
|
console.error(way.endpoint.socket && !fs.existsSync(way.endpoint.socket) ? `server: no REPL is serving on ${name} (there is no socket)` : `server: ${name} is not answering`);
|
|
236
236
|
if (way.explicit) return 64;
|
|
237
237
|
}
|
|
238
|
-
const session = options.session || process.env.PW_TMUX_SESSION || '
|
|
238
|
+
const session = options.session || process.env.PW_TMUX_SESSION || 'pw-repl';
|
|
239
239
|
if (!hasSession(session)) {
|
|
240
240
|
console.error(`tmux: no session '${session}'`);
|
|
241
241
|
console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
|
package/lib/start.js
CHANGED
|
@@ -100,7 +100,7 @@ async function start(options) {
|
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
if (!options.serve && !process.env.TMUX) {
|
|
103
|
-
out.log('Tip: run it in a tmux session named
|
|
103
|
+
out.log('Tip: run it in a tmux session named pw-repl so pw-repl send can reach it, or use pw-repl serve.');
|
|
104
104
|
}
|
|
105
105
|
out.log("Run 'help' to see the commands. They act on the selected tab (*).");
|
|
106
106
|
out.log('');
|
package/lib/tabs.js
CHANGED
|
@@ -164,11 +164,13 @@ const commands = {
|
|
|
164
164
|
ensureDialogHandler(p);
|
|
165
165
|
await keepDrawing(p);
|
|
166
166
|
};
|
|
167
|
-
|
|
167
|
+
// Opened by this client, with tab new: the prompt's and nameless senders' are theirs together.
|
|
168
|
+
const mine = p => 'opened' in tabState(p) && tabState(p).owner === state.client;
|
|
169
|
+
const byUrl = (part, closing = false) => {
|
|
168
170
|
const matches = all.filter(p => p.url().includes(part));
|
|
169
171
|
if (matches.length === 1) return matches[0];
|
|
170
172
|
if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
|
|
171
|
-
throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
|
|
173
|
+
throw new Error(`${matches.length} tabs match "${part}"; use a longer part, or tab <index> from tab's listing${closing ? ', then tab close' : ''}:\n${matches.map(p => ` ${p.url()}${mine(p) ? ' (opened by you)' : ''}`).join('\n')}`);
|
|
172
174
|
};
|
|
173
175
|
if (/^\d+$/.test(subcommand)) {
|
|
174
176
|
if (parts.length) throw new Error(usage);
|
|
@@ -196,7 +198,11 @@ const commands = {
|
|
|
196
198
|
return listTabs();
|
|
197
199
|
}
|
|
198
200
|
if (subcommand === 'close') {
|
|
199
|
-
const
|
|
201
|
+
const part = parts.join(' ');
|
|
202
|
+
const matches = part ? all.filter(p => p.url().includes(part)) : [];
|
|
203
|
+
// Of several, the one tab you opened is the one to close: the others may be someone else's.
|
|
204
|
+
const yours = matches.length > 1 && matches.filter(mine).length === 1 ? matches.find(mine) : null;
|
|
205
|
+
const target = yours || (part ? byUrl(part, true) : state.page);
|
|
200
206
|
if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
|
|
201
207
|
if (all.length <= 1) throw new Error('Refusing to close the last tab');
|
|
202
208
|
const url = target.url();
|
|
@@ -208,7 +214,7 @@ const commands = {
|
|
|
208
214
|
state.page = back && !back.isClosed() && 'opened' in tabState(back) ? back : null;
|
|
209
215
|
state.previousPage = null;
|
|
210
216
|
}
|
|
211
|
-
out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
217
|
+
out.log(`Closed ${url}${yours ? `, the one you opened; ${matches.length - 1} other ${matches.length === 2 ? 'tab matches' : 'tabs match'} "${part}"` : ''}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
212
218
|
if (recording) out.log(await endedByTabClose(recording));
|
|
213
219
|
return listTabs();
|
|
214
220
|
}
|
package/lib/watch.js
CHANGED
|
@@ -64,6 +64,30 @@ const WATCH_SCRIPT = `(() => {
|
|
|
64
64
|
|| el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
|
|
65
65
|
};
|
|
66
66
|
const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
|
|
67
|
+
// Which of the elements described alike this one is: under its nearest heading, and n of N where that
|
|
68
|
+
// does not settle it (cards that repeat a heading). Empty when nothing else is described as it is.
|
|
69
|
+
const HEADING = 'h1,h2,h3,h4,h5,h6,[role=heading]';
|
|
70
|
+
const headingOf = el => {
|
|
71
|
+
for (let at = el.parentElement; at; at = at.parentElement) {
|
|
72
|
+
const heading = at.querySelector(HEADING);
|
|
73
|
+
if (heading && !heading.contains(el)) return clip(heading.innerText);
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
};
|
|
77
|
+
// Run before the page's own handlers, so only elements of its kind are compared: a page can have
|
|
78
|
+
// thousands of divs. One with no name is alike every other of its role, and n of N says nothing.
|
|
79
|
+
const contextOf = el => {
|
|
80
|
+
if (el.getRootNode() !== document || !nameOf(el)) return '';
|
|
81
|
+
const role = roleOf(el);
|
|
82
|
+
const me = describe(el);
|
|
83
|
+
const kind = el.hasAttribute('role') ? '[role]' : el.localName + ',[role]';
|
|
84
|
+
const alike = [...document.querySelectorAll(kind)].filter(other => other === el || (roleOf(other) === role && describe(other) === me));
|
|
85
|
+
if (alike.length < 2) return '';
|
|
86
|
+
const heading = headingOf(el);
|
|
87
|
+
const peers = heading ? alike.filter(other => headingOf(other) === heading) : alike;
|
|
88
|
+
const which = peers.length > 1 ? (peers.indexOf(el) + 1) + ' of ' + peers.length : '';
|
|
89
|
+
return [heading && 'under ' + JSON.stringify(heading), which].filter(Boolean).join(', ');
|
|
90
|
+
};
|
|
67
91
|
// A field that was a password field while watched stays one when a "show password" button makes it
|
|
68
92
|
// text; and some take secrets under another type. Their values never leave the page.
|
|
69
93
|
const secrets = new WeakSet();
|
|
@@ -233,24 +257,34 @@ const WATCH_SCRIPT = `(() => {
|
|
|
233
257
|
if (typeof window.__pwReplWatch !== 'function') return;
|
|
234
258
|
let selector = null;
|
|
235
259
|
try { selector = el ? selectorOf(el) : null; } catch {}
|
|
260
|
+
let context = '';
|
|
261
|
+
try { context = el ? contextOf(el) : ''; } catch {}
|
|
236
262
|
const typed = (action === 'type' || action === 'fill') && el;
|
|
237
263
|
const secret = typed && isSecret(el);
|
|
238
264
|
const option = action === 'select' ? el.value : null;
|
|
239
|
-
window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0, selector, secret, value: typed && !secret ? valueOf(el) : null, option }));
|
|
265
|
+
window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', context, extra: extra || '', since: since || 0, selector, secret, value: typed && !secret ? valueOf(el) : null, option }));
|
|
240
266
|
};
|
|
241
267
|
// The element itself, not its component's host, when it is inside an open shadow root.
|
|
242
268
|
const targetOf = e => (e.composedPath && e.composedPath()[0]) || e.target;
|
|
243
269
|
// Enter in a form's field submits it with a click on its submit button, which is the Enter's doing:
|
|
244
270
|
// recorded too, it would submit the form again when the steps run again.
|
|
245
271
|
let enteredForm = null;
|
|
272
|
+
let labelled = null;
|
|
246
273
|
document.addEventListener('click', e => {
|
|
247
274
|
const t = targetOf(e);
|
|
248
275
|
const el = t.closest ? (t.closest(INTERACTIVE) || t) : t;
|
|
249
276
|
if (!el.matches || el.matches('input[type=checkbox],input[type=radio],select') || isPassword(el)) return;
|
|
250
277
|
if (e.detail === 0 && enteredForm && el.form === enteredForm) return;
|
|
251
|
-
// A click on a label is passed on to its control as a click of its own,
|
|
252
|
-
//
|
|
253
|
-
|
|
278
|
+
// A click on a label is passed on to its control as a click of its own, in the same task; one of them
|
|
279
|
+
// is recorded, or a replay would toggle a checkbox twice. A label that holds its control passes it on
|
|
280
|
+
// inside what was clicked: the control's click is recorded (or the check its change records), and
|
|
281
|
+
// found by its role. A label's for= can name a control anywhere, even another card's where ids
|
|
282
|
+
// repeat: the label is what was clicked, and the passed-on click and its change are left out.
|
|
283
|
+
if (el.localName === 'label' && el.control) {
|
|
284
|
+
if (el.contains(el.control)) return;
|
|
285
|
+
labelled = el.control;
|
|
286
|
+
setTimeout(() => { labelled = null; });
|
|
287
|
+
} else if (el === labelled) return;
|
|
254
288
|
send('click', el);
|
|
255
289
|
}, true);
|
|
256
290
|
// Typing is recorded once it pauses, not on change, so a typeahead's requests
|
|
@@ -295,7 +329,7 @@ const WATCH_SCRIPT = `(() => {
|
|
|
295
329
|
}, true);
|
|
296
330
|
document.addEventListener('change', e => {
|
|
297
331
|
const el = targetOf(e);
|
|
298
|
-
if (!el.matches) return;
|
|
332
|
+
if (!el.matches || el === labelled) return;
|
|
299
333
|
if (el.matches('input[type=checkbox],input[type=radio]')) return send(el.checked ? 'check' : 'uncheck', el);
|
|
300
334
|
if (el.matches('select')) return send('select', el, JSON.stringify(clip(el.selectedOptions[0]?.text)));
|
|
301
335
|
if (pending.has(el)) { flushTyping(el); typed.delete(el); return; }
|
|
@@ -460,7 +494,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
|
|
|
460
494
|
if (!created.on) return;
|
|
461
495
|
const previous = created.events[created.events.length - 1];
|
|
462
496
|
const t = Math.max(Date.now() - Math.min(Math.max(Number(since) || 0, 0), TYPING_BACKDATE_MAX), previous ? previous.t : 0);
|
|
463
|
-
const step = { seq: created.nextSeq++, t, action, target: clipText(target), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url, loaded: !!more.loaded };
|
|
497
|
+
const step = { seq: created.nextSeq++, t, action, target: clipText(target), context: clipText(more.context || ''), extra: clipText(extra), selector: more.selector || null, secret: !!more.secret, url: more.url, loaded: !!more.loaded };
|
|
464
498
|
if (typeof more.option === 'string') step.option = more.option;
|
|
465
499
|
if (typeof more.value === 'string' && created.values) step.value = more.value;
|
|
466
500
|
keep(created.events, step);
|
|
@@ -483,7 +517,7 @@ async function startWatching(p, changes = false, live = false, values = true) {
|
|
|
483
517
|
try { event = JSON.parse(payload); } catch { return; }
|
|
484
518
|
if (event && PAGE_ACTIONS.has(event.action)) {
|
|
485
519
|
binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since,
|
|
486
|
-
{ selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option });
|
|
520
|
+
{ selector: typeof event.selector === 'string' ? event.selector : null, secret: !!event.secret, value: event.value, option: event.option, context: String(event.context || '') });
|
|
487
521
|
}
|
|
488
522
|
});
|
|
489
523
|
// Calls are reported only to a session with Runtime on.
|
|
@@ -619,7 +653,7 @@ function requestsByStep(watch, p = state.page) {
|
|
|
619
653
|
|
|
620
654
|
function stepLines(e, caused, note = '', withChanges = true) {
|
|
621
655
|
const typed = e.secret ? ' (a password: not recorded)' : typeof e.value === 'string' ? ` ${JSON.stringify(clipText(e.value))}` : '';
|
|
622
|
-
const lines = [`${clock(e.t)} ${e.action} ${e.target}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
|
|
656
|
+
const lines = [`${clock(e.t)} ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${typed}${e.extra ? ` ${e.extra}` : ''}${note}`];
|
|
623
657
|
const indent = ' '.repeat(clock(e.t).length + 1);
|
|
624
658
|
for (const r of caused.slice(0, WATCH_REQUESTS_SHOWN)) lines.push(`${indent}#${r.id} ${r.method} ${r.status} ${r.url}`);
|
|
625
659
|
if (caused.length > WATCH_REQUESTS_SHOWN) lines.push(`${indent}… ${caused.length - WATCH_REQUESTS_SHOWN} more (requests)`);
|
|
@@ -700,7 +734,7 @@ function savedCommands(watch, p) {
|
|
|
700
734
|
if (e.action === 'submit' || changedAgain(i)) continue;
|
|
701
735
|
if (routed && e.selector) lines.push(`wait ${sel(e.selector)} ${ROUTE_WAIT}`);
|
|
702
736
|
routed = false;
|
|
703
|
-
lines.push(`# ${e.action} ${e.target}${e.extra ? ` ${e.extra}` : ''}`);
|
|
737
|
+
lines.push(`# ${e.action} ${e.target}${e.context ? ` (${e.context})` : ''}${e.extra ? ` ${e.extra}` : ''}`);
|
|
704
738
|
if (!e.selector && !(e.action === 'press' && e.target === 'page')) { comment(e, 'no selector finds it alone: it is in a frame or a closed shadow root, or its place matches others too'); continue; }
|
|
705
739
|
if (e.action === 'click' || e.action === 'check' || e.action === 'uncheck') lines.push(`${e.action} ${sel(e.selector)}`);
|
|
706
740
|
else if (e.action === 'select' && typeof e.option !== 'string') comment(e, 'the option was not recorded');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pw-repl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Text REPL for driving an existing Chromium through Playwright over CDP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/arcanemachine/
|
|
17
|
+
"url": "git+https://github.com/arcanemachine/pw-repl.git"
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"pw-repl": "bin/pw-repl.js"
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pw-repl
|
|
3
|
-
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl
|
|
3
|
+
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
4
4
|
allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,8 +8,8 @@ allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
|
|
|
8
8
|
|
|
9
9
|
<!-- pw-repl skill stamp -->
|
|
10
10
|
|
|
11
|
-
pw-repl
|
|
12
|
-
|
|
11
|
+
pw-repl is a REPL that drives a running Chromium over CDP. The browser may be a shared session: the
|
|
12
|
+
user can have their own tabs open in it and be using it while you work.
|
|
13
13
|
|
|
14
14
|
`pw-repl` below is the command the `pw-repl` npm package installs. Without a global install, use
|
|
15
15
|
`npx pw-repl@latest` in its place, in that same form for every command, so each one runs the same
|
|
@@ -45,10 +45,10 @@ when none is running). Right after you start one, for a moment before its proces
|
|
|
45
45
|
say none is there: retry for a few seconds before taking that as a failed start. There are three ways to
|
|
46
46
|
run one; each takes an optional start URL, which opens in a new tab.
|
|
47
47
|
|
|
48
|
-
- `pw-repl serve --background` runs it detached, with a command server on `/tmp/
|
|
49
|
-
(owner-only) and its output in `/tmp/
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
- `pw-repl serve --background` runs it detached, with a command server on `/tmp/pw-repl.sock`
|
|
49
|
+
(owner-only) and its output in `/tmp/pw-repl.log`. `pw-repl attach` shows everything it does and
|
|
50
|
+
takes commands; `pw-repl stop` stops it. It returns once the REPL serves; Ctrl-C before then gives up
|
|
51
|
+
on the start, stops the REPL, exits 130, and says so in the log.
|
|
52
52
|
- `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
|
|
53
53
|
`pw[serve]>`. It stops at that prompt (`quit`, or Ctrl-C); `pw-repl stop` is for a background one.
|
|
54
54
|
- Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
|
|
@@ -61,11 +61,11 @@ run one; each takes an optional start URL, which opens in a new tab.
|
|
|
61
61
|
attached, so `requests` and `console` have only what came after, often nothing: `reload` for a full
|
|
62
62
|
record.
|
|
63
63
|
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
64
|
-
the tmux session `
|
|
64
|
+
the tmux session `pw-repl`:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
|
-
tmux send-keys -t
|
|
68
|
-
tmux send-keys -t
|
|
67
|
+
tmux send-keys -t pw-repl -l 'pw-repl run'
|
|
68
|
+
tmux send-keys -t pw-repl Enter
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
A REPL of your own goes on a socket of its own, named with `-e` on every command, `serve` included:
|
|
@@ -257,7 +257,7 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
257
257
|
prints each file's path; other REPLs may save theirs in the same place. Remove only your own, and keep
|
|
258
258
|
those too if they are needed, e.g. as evidence or as something to hand over.
|
|
259
259
|
- `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `serve`
|
|
260
|
-
serves on and `send` looks for when neither is given (default `/tmp/
|
|
260
|
+
serves on and `send` looks for when neither is given (default `/tmp/pw-repl.sock`).
|
|
261
261
|
|
|
262
262
|
## Custom rules
|
|
263
263
|
|