pw-repl 0.9.0 → 0.11.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 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
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,10 +98,12 @@ 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) |
103
- | point something out on the page | `highlight <ref>` (`--style=<css>` to restyle it), then `highlight off` |
105
+ | say what is happening on screen | `toast <text>`, then `toast off` (`--duration=3s` hides it by itself) |
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>` |
106
109
  | slow the whole network | `network slow` |
@@ -110,29 +113,33 @@ 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
- `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
- pw-repl differs, it is because someone else may be using the browser:
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.
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.
136
143
 
137
144
  For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
138
145
  playwright-cli is the better fit. `record on` records a tab's page, and `cursor on` draws a pointer where the
@@ -144,7 +151,7 @@ agent acts; for the shared browser as you see it, use a screen recorder (`ffmpeg
144
151
  ### A skill for agents
145
152
 
146
153
  `pw-repl skill` prints an agent skill (`SKILL.md`) that teaches an agent to use the REPL: how to start
147
- 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
148
155
  your agent reads skills from:
149
156
 
150
157
  ```bash
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,16 @@ 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 the variable PW_NAME from send's environment,
49
+ e.g. a password kept out of the file and the REPL's log. In a file: fill '#password' "{{ PW_PASSWORD }}",
50
+ run with PW_PASSWORD=... pw-repl send --file login.txt; or one command: PW_PASSWORD=... pw-repl send
51
+ fill '#password' "{{ PW_PASSWORD }}". Only names starting PW_ are variables: any other {{ ... }}, as in a
52
+ page's template text, is sent as written. In a file, put it in double quotes: there its value is
53
+ escaped as they read it (in single quotes, which escape nothing, a value with a ' is refused). The REPL
54
+ fills it in as it runs the command, and its pane and log show the command as written. A variable that
55
+ is not set stops the command, or the file at that line, before it is sent. --no-vars sends
56
+ {{ PW_NAME }} as it is. Variables need the command server (pw-repl serve), not the tmux pane.
57
+
48
58
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
49
59
 
50
60
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
@@ -87,6 +97,7 @@ function parseSendArgs(args, allowCommand) {
87
97
  options.file = require('path').resolve(args[++i]);
88
98
  continue;
89
99
  }
100
+ if (arg === '--no-vars') { options.noVars = true; continue; }
90
101
  if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
91
102
  const value = args[++i];
92
103
  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
 
@@ -316,13 +289,25 @@ const commands = {
316
289
  } else if (where) {
317
290
  const target = p.locator(toSelector(unquote(where))).first();
318
291
  try {
319
- await target.scrollIntoViewIfNeeded({ timeout: 5000 });
292
+ await target.waitFor({ state: 'visible', timeout: 5000 });
320
293
  } catch {
321
294
  throw new Error(`No element matches ${where} that can be shown (waited 5s); ${still}`);
322
295
  }
323
- const box = await target.boundingBox();
296
+ // Only pointing: an element partly in view is pointed at where it shows, not scrolled, so that
297
+ // one the page cuts off still looks cut off (a bug in a video). One wholly out of view is scrolled to.
298
+ const view = await p.evaluate(() => ({ width: innerWidth, height: innerHeight }));
299
+ const inView = b => b && b.x < view.width && b.y < view.height && b.x + b.width > 0 && b.y + b.height > 0;
300
+ let box = await target.boundingBox();
301
+ if (!inView(box)) {
302
+ await target.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
303
+ box = await target.boundingBox();
304
+ }
324
305
  if (!box) throw new Error(`${where} is not on the page to show the cursor at; ${still}`);
325
- at = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
306
+ const left = Math.max(box.x, 0);
307
+ const top = Math.max(box.y, 0);
308
+ const right = Math.min(box.x + box.width, view.width);
309
+ const bottom = Math.min(box.y + box.height, view.height);
310
+ at = { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
326
311
  } else {
327
312
  at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
328
313
  }
@@ -332,10 +317,11 @@ const commands = {
332
317
  out.log(`The cursor moved to ${at.x}, ${at.y}`);
333
318
  return;
334
319
  }
335
- 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 });
336
321
  tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
337
322
  cursorStep(p, '(cursor on)', at, 0);
338
- 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' }); });
339
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`);
340
326
  },
341
327
  };