pw-repl 0.10.0 → 0.11.1

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