pw-repl 0.7.2 → 0.8.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
@@ -13,10 +13,11 @@ If you hit a limitation or write a workaround, consider adding the capability to
13
13
  needs no tab, only reads, or takes `--all` is also listed in `lib/runner.js`'s `NO_TAB_NEEDED`,
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
- `lib/elements.js` (choosing a match; the interact and mouse commands), `lib/inspect.js` (reading the
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
18
  page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
18
19
  `lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/record.js`
19
- (video, through ffmpeg), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
20
+ (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
20
21
  session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
21
22
  `lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
22
23
  - `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
@@ -27,10 +28,11 @@ If you hit a limitation or write a workaround, consider adding the capability to
27
28
  names to these; `lib/state.js` holds the session state, with each client's selected tab;
28
29
  `lib/output.js` routes all output so the server can return it; `lib/skill.js` stamps the skill
29
30
  `pw-repl skill` prints, and gives `where` its hash.
30
- - Match playwright-cli (Microsoft's CLI for agents) where it is reasonable: agents are likelier to be
31
- trained on it. A new command, option or output that does what one of its does takes its name and form,
32
- or accepts them too (`lib/cli-names.js`, `help playwright-cli`). Differ where sharing a browser with a
33
- person needs it, and say why in the help.
31
+ - Match Playwright and playwright-cli (Microsoft's CLI for agents) as closely as is reasonable: agents are
32
+ likelier to be trained on them. A new command, option or output that does what one of theirs does takes
33
+ its name, form and API style (an option named as Playwright's API names it, e.g. type's `delay`), or
34
+ accepts theirs too (`lib/cli-names.js`, `help playwright-cli`). Deviate only where it is necessary, as
35
+ where sharing a browser with a user needs it, and say why in the help.
34
36
  - Comment the *why* when it isn't obvious from the code.
35
37
  - `npm test` runs the suite (under two minutes): a private headless Chromium, a local test site
36
38
  (`test/harness.js`), and the real REPL with its server. Nothing is mocked, and the shared browser is
@@ -60,7 +62,10 @@ If you hit a limitation or write a workaround, consider adding the capability to
60
62
 
61
63
  Work goes in waves, and each one ends with a release that is ready to push.
62
64
 
63
- 1. Build what was asked, with tests, and try it in a running REPL.
65
+ 1. Build what was asked, with tests, and try it in a running REPL. For anything the user will look at
66
+ (a cursor, an overlay, a video), show them a demo while it is still a prototype (a recording or
67
+ screenshots they can open) and get their go-ahead on how it looks before writing tests or
68
+ building on it.
64
69
  2. Try it on a fresh agent that has not seen the code or the change (a guinea pig), on something real: a
65
70
  small local page whose planted bugs need the change to find.
66
71
  - Brief it with goals only: what to find out or do in the browser, never the commands.
package/README.md CHANGED
@@ -99,6 +99,8 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
99
99
  | see each step as I click | `watch on --live` |
100
100
  | record requests and console together | `capture on`, then `capture off` |
101
101
  | record a video of the page | `record on`, then `record off` (`help video` for the other ways) |
102
+ | 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` |
102
104
  | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
103
105
  | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
104
106
  | slow the whole network | `network slow` |
@@ -108,13 +110,15 @@ Everything else is in `help <topic>`; `help <command>` has usage and caveats.
108
110
 
109
111
  ### Modes
110
112
 
111
- `watch`, `capture`, `route`, `network off` or `slow`, and `emulate` stay on until you turn them off:
112
- `watch on|off`, `capture on|off`, `route ...|route off`, `network off|slow|on`, `emulate ...|emulate off`.
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`.
113
117
  While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
114
118
  lists them for every tab, and `modes off` turns them all off.
115
119
 
116
- `tab`, `watch`, `capture`, `route`, `network`, `emulate` and `modes` on their own show their state and what
117
- you can run next.
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.
118
122
 
119
123
  ## How it differs from playwright-cli
120
124
 
@@ -131,8 +135,9 @@ pw-repl differs, it is because someone else may be using the browser:
131
135
  - `watch` records what a person does as steps with their requests, not as generated code.
132
136
 
133
137
  For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
134
- playwright-cli is the better fit. `record on` records a tab's page, without the pointer; for the shared
135
- browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
138
+ playwright-cli is the better fit. `record on` records a tab's page, and `cursor on` draws a pointer where the
139
+ agent acts; for the shared browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS
140
+ `screencapture -v`, OBS).
136
141
 
137
142
  ## From scripts and agents
138
143
 
package/lib/commands.js CHANGED
@@ -6,6 +6,7 @@ const HELP = require('./help');
6
6
  const { tabState } = require('./tabstate');
7
7
  const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
8
8
  const { commands: elementsCommands } = require('./elements');
9
+ const { commands: cursorCommands } = require('./cursor');
9
10
  const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
10
11
  const { commands: routesCommands, routesFor } = require('./routes');
11
12
  const { commands: networkCommands } = require('./network');
@@ -21,6 +22,7 @@ const { commands: modesCommands, allModesOff } = require('./modes');
21
22
  const commands = {
22
23
  ...tabsCommands,
23
24
  ...elementsCommands,
25
+ ...cursorCommands,
24
26
  ...inspectCommands,
25
27
  ...watchCommands,
26
28
  ...requestlogCommands,
@@ -113,6 +115,7 @@ function complete(line) {
113
115
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
114
116
  if (command === 'capture') return match(['on', 'off']);
115
117
  if (command === 'record') return match(['on', 'off']);
118
+ if (command === 'cursor') return match(['on', 'off']);
116
119
  if (command === 'viewport') return match(['off']);
117
120
  if (command === 'route') return match(['off']);
118
121
  }
package/lib/cursor.js ADDED
@@ -0,0 +1,302 @@
1
+ // A pointer drawn over the page, for videos: the REPL's input moves no pointer on screen. With it on,
2
+ // the commands that act on an element or at a point glide it there first and then act, and a
3
+ // click shows where it landed. It shows the REPL's own input only: the user's pointer is theirs.
4
+ // The REPL moves it, not the page's own mouse events, which the user's mouse fires as well.
5
+ const { state, onShutdown } = require('./state');
6
+ const out = require('./output');
7
+ const { tabState } = require('./tabstate');
8
+ const { unquote, toSelector } = require('./syntax');
9
+ const { recordingOf, stepTarget, cursorStep } = require('./record');
10
+
11
+ // Scaled with the distance, so a short hop is quick and a long one does not drag. Slower while the tab
12
+ // records, so a video (and a zoom that follows the cursor) can be followed.
13
+ const DWELL_MS = 300;
14
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
+
16
+ const glideTime = (distance, recording) => {
17
+ if (distance < 2) return 0;
18
+ const [min, max, base, perPixel] = recording ? [300, 1000, 200, 0.8] : [150, 600, 100, 0.5];
19
+ return Math.round(Math.min(max, Math.max(min, base + distance * perPixel)));
20
+ };
21
+
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); };
33
+ const place = (el, px, py) => set(el, { transform: `translate(${px}px, ${py}px)` });
34
+ let cursor = document[KEY];
35
+ if (action === 'remove') {
36
+ if (!cursor) return;
37
+ delete document[KEY];
38
+ if (!fade || !cursor.host.isConnected) { cursor.host.remove(); return; }
39
+ // Waited on, as a glide is, but not past its time in a tab that draws no frames.
40
+ 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());
42
+ }
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' });
53
+ const NS = 'http://www.w3.org/2000/svg';
54
+ const arrow = document.createElementNS(NS, 'svg');
55
+ arrow.setAttribute('width', '20');
56
+ arrow.setAttribute('height', '28');
57
+ arrow.setAttribute('viewBox', '0 0 15 21');
58
+ set(arrow, { position: 'absolute', left: '0', top: '0', overflow: 'visible', filter: 'drop-shadow(0 1px 1.5px rgba(0,0,0,.4))' });
59
+ const shape = document.createElementNS(NS, 'path');
60
+ // The tip at 0,0 is the point.
61
+ shape.setAttribute('d', 'M0 0 L0 17 L4.2 13.2 L7 19.6 L9.8 18.4 L7.1 12.1 L12.6 12.1 Z');
62
+ shape.setAttribute('fill', '#111');
63
+ shape.setAttribute('stroke', '#fff');
64
+ shape.setAttribute('stroke-width', '1.4');
65
+ shape.setAttribute('stroke-linejoin', 'round');
66
+ arrow.append(shape);
67
+ root.append(arrow);
68
+ document.documentElement.append(host);
69
+ cursor = document[KEY] = { host, root, arrow, x, y };
70
+ place(arrow, x, y);
71
+ // Faded in when turned on; drawn again on a new page, it is there at once, as it was.
72
+ if (fade) arrow.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });
73
+ }
74
+ // Shown again, so it is above a dialog the page opened since.
75
+ try { cursor.host.hidePopover(); cursor.host.showPopover(); } catch {}
76
+ if (action === 'move') {
77
+ const from = `translate(${cursor.x}px, ${cursor.y}px)`;
78
+ const to = `translate(${x}px, ${y}px)`;
79
+ cursor.x = x;
80
+ cursor.y = y;
81
+ place(cursor.arrow, x, y);
82
+ if (!ms) return;
83
+ // Smoothstep, exactly (x = t with these control points, and y = 3t² - 2t³): the easing a zoom that
84
+ // follows the cursor uses, so it knows where the cursor is on every frame.
85
+ const glide = cursor.arrow.animate([{ transform: from }, { transform: to }], { duration: ms, easing: 'cubic-bezier(0.3333, 0, 0.6667, 1)' });
86
+ // A hidden tab draws no frames, and its animation may never finish: not waited on past its time.
87
+ return Promise.race([glide.finished, new Promise(resolve => setTimeout(resolve, ms + 300))]).then(() => {}, () => {});
88
+ }
89
+ // A select's list, drawn under it: a native one opens outside the page, where no recording sees it.
90
+ if (action === 'pickerOpen') {
91
+ cursor.picker?.remove();
92
+ const list = document.createElement('div');
93
+ const rowHeight = 28;
94
+ set(list, {
95
+ position: 'absolute', left: `${box.x}px`, top: `${box.y + box.height + 2}px`, 'min-width': `${Math.max(box.width, 120)}px`,
96
+ background: '#fff', border: '1px solid #9aa0a6', 'border-radius': '4px', 'box-shadow': '0 4px 14px rgba(0,0,0,.25)',
97
+ font: '15px system-ui, sans-serif', color: '#202124', padding: '4px 0', 'box-sizing': 'border-box', overflow: 'hidden',
98
+ });
99
+ const rows = items.slice(0, 10).map(text => {
100
+ const row = document.createElement('div');
101
+ row.textContent = text;
102
+ set(row, { height: `${rowHeight}px`, 'line-height': `${rowHeight}px`, padding: '0 12px', 'white-space': 'nowrap' });
103
+ list.append(row);
104
+ return row;
105
+ });
106
+ cursor.picker = list;
107
+ cursor.pickerRows = rows;
108
+ cursor.root.insertBefore(list, cursor.arrow);
109
+ const mark = i => rows.forEach((row, j) => set(row, { background: j === i ? '#1a73e8' : 'transparent', color: j === i ? '#fff' : '#202124' }));
110
+ cursor.pickerMark = mark;
111
+ mark(index);
112
+ list.animate([{ opacity: 0, transform: 'translateY(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 150 });
113
+ // Where the chosen row will be, for the cursor to go to.
114
+ return { rowY: box.y + box.height + 2 + 4 + rowHeight / 2, rowHeight };
115
+ }
116
+ if (action === 'pickerChoose') {
117
+ cursor.pickerMark?.(index);
118
+ return;
119
+ }
120
+ if (action === 'pickerClose') {
121
+ const list = cursor.picker;
122
+ if (!list) return;
123
+ cursor.picker = null;
124
+ return Promise.race([list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150, fill: 'forwards' }).finished, new Promise(resolve => setTimeout(resolve, 400))])
125
+ .then(() => list.remove(), () => list.remove());
126
+ }
127
+ if (action === 'click') {
128
+ // A ring that grows and fades where the click landed, one for each click of a double-click.
129
+ for (let i = 0; i < count; i += 1) {
130
+ const ring = document.createElement('div');
131
+ set(ring, {
132
+ position: 'absolute', left: `${x - 14}px`, top: `${y - 14}px`, width: '28px', height: '28px', 'box-sizing': 'border-box',
133
+ 'border-radius': '50%', border: '2px solid rgba(230, 40, 40, .9)', background: 'rgba(230, 40, 40, .3)', opacity: '0',
134
+ });
135
+ cursor.root.insertBefore(ring, cursor.arrow);
136
+ ring.animate([{ transform: 'scale(.3)', opacity: 1 }, { transform: 'scale(1.5)', opacity: 0 }], { duration: 450, delay: i * 140, easing: 'ease-out' })
137
+ .finished.then(() => ring.remove(), () => ring.remove());
138
+ }
139
+ }
140
+ }
141
+
142
+ function cursorOf(p) {
143
+ return tabState(p).cursor || null;
144
+ }
145
+
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
+ // Glides the tab's cursor to a point, if it is on, and waits for it to get there.
157
+ async function glideTo(p, x, y) {
158
+ const cursor = cursorOf(p);
159
+ if (!cursor) return;
160
+ const ms = glideTime(Math.hypot(x - cursor.x, y - cursor.y), !!recordingOf(p));
161
+ // A recording notes where the pointer went and when, for a zoom to follow it.
162
+ if (ms) cursorStep(p, `(cursor from ${cursor.x} ${cursor.y})`, { x, y }, ms);
163
+ cursor.x = x;
164
+ cursor.y = y;
165
+ await p.evaluate(pageCursor, { x, y, action: 'move', ms }).catch(() => {});
166
+ }
167
+
168
+ // Whether a command should find its element first: to glide the cursor there, or to note it in a
169
+ // recording's steps.
170
+ function aiming(p) {
171
+ return !!(cursorOf(p) || recordingOf(p));
172
+ }
173
+
174
+ // Finds the centre of an element (a locator or a handle), where Playwright clicks, after scrolling it
175
+ // into view as Playwright would, and glides the cursor there if it is on: the pointer is there before
176
+ // the click, not after. A recording notes the element in its steps.
177
+ // It waits for a locator's element to be there, as the command would, and throws Playwright's timeout
178
+ // if none comes, for the command to report as its own: the command does not wait all over again. One
179
+ // there but hidden (a file input under its button) is not glided to, and the command says why.
180
+ async function aimAt(p, target, timeout) {
181
+ if (!aiming(p)) return null;
182
+ if (target.waitFor) await target.waitFor({ state: 'attached', timeout });
183
+ try {
184
+ if (!await target.isVisible()) return null;
185
+ await target.scrollIntoViewIfNeeded({ timeout: 2000 });
186
+ const box = await target.boundingBox({ timeout: 1000 });
187
+ if (!box) return null;
188
+ const point = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
189
+ stepTarget(p, box, point);
190
+ await glideTo(p, point.x, point.y);
191
+ // While recording, a moment on the element before acting on it, for the viewer to see where it is.
192
+ if (cursorOf(p) && recordingOf(p)) await sleep(DWELL_MS);
193
+ return point;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // Shows a click where the cursor is; the input has landed by then. A click that loads a new page
200
+ // may take the ring with it.
201
+ async function showClick(p, count = 1) {
202
+ const cursor = cursorOf(p);
203
+ if (!cursor) return;
204
+ await p.evaluate(pageCursor, { x: cursor.x, y: cursor.y, action: 'click', count }).catch(() => {});
205
+ }
206
+
207
+ // select with the cursor on: a native select's list opens outside the page, where no recording (or
208
+ // headless Chrome) shows it, so its list is drawn over the page instead. It opens under the select with
209
+ // the current option marked, the mark moves to the option to be chosen (by value or label, as
210
+ // Playwright's selectOption matches it), the cursor clicks it and the choice is made (act), and the list
211
+ // closes. Drawn only: the page gets the same input as without the cursor.
212
+ async function showSelect(p, target, value, act) {
213
+ const cursor = cursorOf(p);
214
+ const box = cursor && await target.boundingBox().catch(() => null);
215
+ // Not drawn for a disabled select or option, which selectOption refuses: the video would show a choice
216
+ // that was never made.
217
+ const before = box && await target.evaluate((el, v) => {
218
+ if (el.tagName !== 'SELECT' || el.multiple || el.size > 1 || el.disabled) return null;
219
+ const chosen = [...el.options].findIndex(o => o.value === v || o.label === v);
220
+ if (chosen >= 0 && (el.options[chosen].disabled || el.options[chosen].parentElement.disabled)) return null;
221
+ return { items: [...el.options].map(o => o.label || o.text), index: el.selectedIndex, chosen };
222
+ }, value).catch(() => null);
223
+ if (!before || before.chosen < 0 || before.chosen >= 10) return act();
224
+ const pace = recordingOf(p) ? 1 : 0.5;
225
+ await showClick(p);
226
+ const opened = await p.evaluate(pageCursor, { action: 'pickerOpen', box, items: before.items, index: before.index }).catch(() => null);
227
+ if (!opened) return act();
228
+ await sleep(350 * pace);
229
+ await glideTo(p, Math.round(cursor.x), Math.round(opened.rowY + before.chosen * opened.rowHeight));
230
+ await p.evaluate(pageCursor, { action: 'pickerChoose', index: before.chosen }).catch(() => {});
231
+ await showClick(p);
232
+ try {
233
+ return await act();
234
+ } finally {
235
+ await sleep(250 * pace);
236
+ await p.evaluate(pageCursor, { action: 'pickerClose' }).catch(() => {});
237
+ }
238
+ }
239
+
240
+ async function cursorOff(p) {
241
+ if (!cursorOf(p)) return false;
242
+ const { x, y } = cursorOf(p);
243
+ delete tabState(p).cursor;
244
+ cursorStep(p, '(cursor off)', { x, y }, 0);
245
+ await p.evaluate(pageCursor, { action: 'remove', fade: true }).catch(() => {});
246
+ return true;
247
+ }
248
+
249
+ onShutdown(async () => {
250
+ if (!state.browser || state.connectionLost) return;
251
+ const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
252
+ await Promise.all(pages.map(p => cursorOff(p).catch(() => {})));
253
+ });
254
+
255
+ const commands = {
256
+ async cursor(args) {
257
+ const arg = (args || '').trim();
258
+ const p = state.page;
259
+ const cursor = cursorOf(p);
260
+ if (!arg) {
261
+ out.log(cursor
262
+ ? `The cursor is on in the selected tab, at ${cursor.x}, ${cursor.y}${cursor.owner ? ` (by ${cursor.owner})` : ''}; cursor off hides it`
263
+ : 'The cursor is off in the selected tab; cursor on shows it');
264
+ return;
265
+ }
266
+ if (arg === 'off') {
267
+ out.log(await cursorOff(p) ? 'The cursor is off' : 'The cursor was not on in the selected tab');
268
+ return;
269
+ }
270
+ const usage = 'Usage: cursor [on [<selector> | <x> <y>] | off]';
271
+ const on = /^on(?:\s+([\s\S]+))?$/.exec(arg);
272
+ if (!on) throw new Error(usage);
273
+ if (cursor) { out.log(`The cursor is already on in the selected tab, at ${cursor.x}, ${cursor.y}; mousemove or a command on an element moves it`); return; }
274
+ // Where it is asked to appear (a video's first element, say), or where the mouse last went, if the
275
+ // REPL knows; otherwise the middle of the viewport.
276
+ const where = (on[1] || '').trim();
277
+ let at;
278
+ if (/^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?$/.test(where)) {
279
+ const [x, y] = where.split(/\s+/).map(n => Math.round(Number(n)));
280
+ at = { x, y };
281
+ } else if (where) {
282
+ const target = p.locator(toSelector(unquote(where))).first();
283
+ try {
284
+ await target.scrollIntoViewIfNeeded({ timeout: 5000 });
285
+ } catch {
286
+ throw new Error(`No element matches ${where} that can be shown (waited 5s); the cursor is still off`);
287
+ }
288
+ const box = await target.boundingBox();
289
+ if (!box) throw new Error(`${where} is not on the page to show the cursor at; the cursor is still off`);
290
+ at = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
291
+ } else {
292
+ at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
293
+ }
294
+ await p.evaluate(pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
295
+ tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
296
+ cursorStep(p, '(cursor on)', at, 0);
297
+ keepAcrossPages(p);
298
+ 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`);
299
+ },
300
+ };
301
+
302
+ module.exports = { commands, cursorOf, cursorOff, aiming, aimAt, glideTo, showClick, showSelect };
package/lib/elements.js CHANGED
@@ -7,6 +7,8 @@ const { takeOptions } = require('./cli-names');
7
7
  const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
8
8
  const { numbers, hints } = require('./util');
9
9
  const { tabState } = require('./tabstate');
10
+ const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
11
+ const { recordingOf, stepTarget } = require('./record');
10
12
 
11
13
  // The type a page sees for a file it is given, from its extension.
12
14
  const MIME_TYPES = {
@@ -177,19 +179,25 @@ function mouseButton(args, name) {
177
179
  // click, dblclick and hover: on the match chooseMatch picks, or as Playwright does.
178
180
  // A page that renders the chosen element again (as React lists do) replaces it before it is
179
181
  // acted on; it is chosen again then, as Playwright finds a locator's element again.
180
- async function pointerAction(selector, act, options = {}) {
182
+ async function pointerAction(selector, act, options = {}, clicks = 0) {
181
183
  const TIMEOUT = 5000;
182
184
  const deadline = Date.now() + TIMEOUT;
183
185
  const left = () => Math.max(1000, deadline - Date.now());
184
186
  for (;;) {
185
187
  const chosen = await onElement(selector, () => chooseMatch(selector, left()));
186
188
  if (!chosen) {
187
- // Only counted so far, so the whole time is Playwright's, as it was before.
188
- await onElement(selector, () => act(state.page, selector, { ...options, timeout: TIMEOUT }));
189
+ // Only counted so far, so the whole time is Playwright's, as it was before; the cursor's glide, if it
190
+ // is on, waits for the element as part of it.
191
+ const glided = Date.now();
192
+ const point = await onElement(selector, () => aimAt(state.page, state.page.locator(selector).first(), TIMEOUT));
193
+ await onElement(selector, () => act(state.page, selector, { ...options, timeout: Math.max(1000, TIMEOUT - (Date.now() - glided)) }));
194
+ await landed(point, clicks);
189
195
  return '';
190
196
  }
191
197
  try {
198
+ const point = await aimAt(state.page, chosen.handle, left());
192
199
  await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
200
+ await landed(point, clicks);
193
201
  return chosen.position === 1 ? '' : ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
194
202
  } catch (error) {
195
203
  if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
@@ -199,6 +207,63 @@ async function pointerAction(selector, act, options = {}) {
199
207
  }
200
208
  }
201
209
 
210
+ // After a pointer action: Playwright's mouse is where it acted, at the centre the cursor glided to,
211
+ // or somewhere the REPL does not know without the cursor. A click shows where it landed.
212
+ async function landed(point, clicks) {
213
+ tabState(state.page).mouseAt = point;
214
+ if (clicks) await showClick(state.page, clicks);
215
+ }
216
+
217
+ // Finds the element a command without a pointer acts on (fill, select...), for the cursor to glide
218
+ // to, as someone would go to it first, and for a recording's steps; the mouse itself stays where it is. What is left of the command's
219
+ // 5s is its timeout, so the glide does not make it wait longer for an element that never comes.
220
+ async function aimFor(selector) {
221
+ if (!aiming(state.page)) return 5000;
222
+ const started = Date.now();
223
+ await onElement(selector, () => aimAt(state.page, state.page.locator(selector).first(), 5000));
224
+ return Math.max(1000, 5000 - (Date.now() - started));
225
+ }
226
+
227
+ // While the tab records, a wheel turn goes in small steps, eased, over 400ms to 1s by its length, so the
228
+ // video scrolls rather than jumps; the same distance in all.
229
+ async function smoothWheel(dx, dy) {
230
+ const ms = Math.min(1000, Math.max(400, Math.hypot(dx, dy) * 0.8));
231
+ const steps = Math.max(1, Math.round(ms / 25));
232
+ const ease = t => t * t * (3 - 2 * t);
233
+ let sentX = 0;
234
+ let sentY = 0;
235
+ for (let i = 1; i <= steps; i += 1) {
236
+ const x = Math.round(dx * ease(i / steps));
237
+ const y = Math.round(dy * ease(i / steps));
238
+ if (x !== sentX || y !== sentY) await state.page.mouse.wheel(x - sentX, y - sentY);
239
+ sentX = x;
240
+ sentY = y;
241
+ await new Promise(resolve => setTimeout(resolve, ms / steps));
242
+ }
243
+ }
244
+
245
+ // While the tab records, text is typed at a pace a viewer can follow, steady rather than human-like.
246
+ // Otherwise at once, as before.
247
+ function typingPace() {
248
+ return recordingOf(state.page) ? 60 : 0;
249
+ }
250
+
251
+ // check, uncheck and upload's button click it at its centre, where aimFor put the cursor.
252
+ async function clickedHere() {
253
+ const cursor = cursorOf(state.page);
254
+ if (!cursor) return;
255
+ tabState(state.page).mouseAt = { x: cursor.x, y: cursor.y };
256
+ await showClick(state.page);
257
+ }
258
+
259
+ // The mouse commands act where the mouse is: the cursor goes back there first, if it went elsewhere.
260
+ async function glideToMouse() {
261
+ const at = tabState(state.page).mouseAt;
262
+ if (!at) return;
263
+ stepTarget(state.page, null, at);
264
+ await glideTo(state.page, at.x, at.y);
265
+ }
266
+
202
267
  // Highlights: Playwright's own overlay, as playwright-cli's highlight draws it. Each is a mode of its
203
268
  // tab, so modes shows it and modes off hides it, and none is left on the page when the REPL stops.
204
269
  function highlightsOf(p) {
@@ -230,13 +295,13 @@ onShutdown(async () => {
230
295
  const commands = {
231
296
  async click(args) {
232
297
  const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
233
- const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options);
298
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, 1);
234
299
  out.log(`Clicked: ${shown}${which}`);
235
300
  },
236
301
 
237
302
  async dblclick(args) {
238
303
  const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
239
- const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options);
304
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
240
305
  out.log(`Double-clicked: ${shown}${which}`);
241
306
  },
242
307
 
@@ -286,18 +351,24 @@ const commands = {
286
351
  async mousemove(args) {
287
352
  const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
288
353
  if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
354
+ stepTarget(state.page, null, { x, y });
355
+ await glideTo(state.page, x, y);
289
356
  await state.page.mouse.move(x, y);
357
+ tabState(state.page).mouseAt = { x, y };
290
358
  out.log(`Moved the mouse to ${x}, ${y}`);
291
359
  },
292
360
 
293
361
  async mousedown(args) {
294
362
  const button = mouseButton(args, 'mousedown');
363
+ await glideToMouse();
295
364
  await state.page.mouse.down({ button });
365
+ await showClick(state.page);
296
366
  out.log(`Pressed the ${button} button`);
297
367
  },
298
368
 
299
369
  async mouseup(args) {
300
370
  const button = mouseButton(args, 'mouseup');
371
+ await glideToMouse();
301
372
  await state.page.mouse.up({ button });
302
373
  out.log(`Released the ${button} button`);
303
374
  },
@@ -306,20 +377,27 @@ const commands = {
306
377
  async mousewheel(args) {
307
378
  const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
308
379
  if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
309
- await state.page.mouse.wheel(dx, dy);
380
+ await glideToMouse();
381
+ if (recordingOf(state.page)) await smoothWheel(dx, dy);
382
+ else await state.page.mouse.wheel(dx, dy);
310
383
  out.log(`Turned the wheel by ${dx}, ${dy}`);
311
384
  },
312
385
 
313
386
  async fill(line) {
314
387
  const { args, submit } = submitFlag(line);
315
388
  const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
316
- await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
389
+ const timeout = await aimFor(selector);
390
+ await onElement(selector, () => state.page.fill(selector, value, { timeout }));
317
391
  if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
318
392
  out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
319
393
  },
320
394
 
321
395
  async type(line) {
322
- const { args, submit } = submitFlag(line);
396
+ // --delay=<ms> goes first, so text with --delay in it is still text.
397
+ const paced = /^\s*--delay(?:=|\s+)(\S*)(?:\s+|$)/.exec(line || '');
398
+ if (paced && !/^\d+$/.test(paced[1])) throw new Error('Usage: type --delay=<ms> [<selector>] <text>, the pause between keys, e.g. type --delay=80 #search garden hose');
399
+ const { args, submit } = submitFlag(paced ? line.slice(paced[0].length) : line);
400
+ const delay = () => (paced ? Number(paced[1]) : typingPace());
323
401
  // One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
324
402
  const sole = splitSelector(args);
325
403
  if (sole && sole.rest === undefined) {
@@ -334,12 +412,13 @@ const commands = {
334
412
  const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
335
413
  if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
336
414
  if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
337
- await state.page.keyboard.type(sole.word);
415
+ await state.page.keyboard.type(sole.word, { delay: delay() });
338
416
  if (submit) await state.page.keyboard.press('Enter');
339
417
  out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
340
418
  return;
341
419
  }
342
420
  const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
421
+ const timeout = await aimFor(selector);
343
422
  // Typed after what the field already holds, as someone clicking into it
344
423
  // at the end would; focusing it alone leaves the caret at the start.
345
424
  await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
@@ -353,8 +432,8 @@ const commands = {
353
432
  getSelection().removeAllRanges();
354
433
  getSelection().addRange(range);
355
434
  }
356
- }, null, { timeout: 5000 }));
357
- await state.page.keyboard.type(value);
435
+ }, null, { timeout }));
436
+ await state.page.keyboard.type(value, { delay: delay() });
358
437
  if (submit) await state.page.keyboard.press('Enter');
359
438
  out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
360
439
  },
@@ -368,25 +447,32 @@ const commands = {
368
447
  return;
369
448
  }
370
449
  const key = keyName(unquote(parsed.rest));
371
- await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
450
+ const timeout = await aimFor(parsed.selector);
451
+ await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout }));
372
452
  out.log(`Pressed ${key} on ${parsed.word}`);
373
453
  },
374
454
 
375
455
  async select(args) {
376
456
  const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
377
- await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
457
+ const timeout = await aimFor(selector);
458
+ const target = state.page.locator(selector).first();
459
+ await onElement(selector, () => showSelect(state.page, target, value, () => state.page.selectOption(selector, value, { timeout })));
378
460
  out.log(`Selected "${value}" in ${shown}`);
379
461
  },
380
462
 
381
463
  async check(args) {
382
464
  const selector = soleSelector(args, 'check <selector>');
383
- await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
465
+ const timeout = await aimFor(selector);
466
+ await onElement(selector, () => state.page.check(selector, { timeout }));
467
+ await clickedHere();
384
468
  out.log(`Checked: ${args.trim()}`);
385
469
  },
386
470
 
387
471
  async uncheck(args) {
388
472
  const selector = soleSelector(args, 'uncheck <selector>');
389
- await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
473
+ const timeout = await aimFor(selector);
474
+ await onElement(selector, () => state.page.uncheck(selector, { timeout }));
475
+ await clickedHere();
390
476
  out.log(`Unchecked: ${args.trim()}`);
391
477
  },
392
478
 
@@ -408,6 +494,7 @@ const commands = {
408
494
  return { name: path.basename(file), mimeType: mimeType(file), buffer };
409
495
  });
410
496
  const { selector } = parsed;
497
+ await aimFor(selector);
411
498
  await onElement(selector, async () => {
412
499
  try {
413
500
  await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
@@ -416,6 +503,7 @@ const commands = {
416
503
  // A button that opens the file picker itself: click it, and answer the picker.
417
504
  const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
418
505
  await chooser.setFiles(payloads);
506
+ await clickedHere();
419
507
  }
420
508
  });
421
509
  out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
package/lib/help.js CHANGED
@@ -74,7 +74,7 @@ fill for selectors with spaces. mousemove and the other mouse commands act at a
74
74
  },
75
75
  inspect: {
76
76
  intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
77
- commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
77
+ commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'cursor', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
78
78
  },
79
79
  network: {
80
80
  intro: `requests and console record all the time; a capture records only while it runs.`,
@@ -196,10 +196,22 @@ Examples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada`,
196
196
  usage: 'type [<selector>] <text> [--submit]',
197
197
  summary: 'type key by key after what the field holds; text as for fill',
198
198
  detail: `With one word, or one quoted, it is typed into the element that has focus: type "garden hose".
199
- --submit at the end presses Enter afterwards.`,
199
+ --submit at the end presses Enter afterwards.
200
+
201
+ Keys are sent all at once. While the tab is recording (help record), they are 60ms apart, so the video
202
+ shows the text being typed. type --delay=<ms> <selector> <text> sets the gap between keys yourself. The
203
+ whole text takes its length times the gap, and send waits 20s by default: about 300 characters at 60ms
204
+ need a longer send -t, or a shorter --delay. It is a gap between keys, not a wait before starting like
205
+ screenshot's --delay. A page that reacts to each key, such as search as you type, may react to each one.`,
200
206
  },
201
207
  press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
202
- select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
208
+ select: {
209
+ usage: 'select <selector> <value>',
210
+ summary: 'choose an option in a select (the value is the rest of the line)',
211
+ detail: `The value is an option's value or its label. With the cursor on (help cursor), select also draws the
212
+ select's list opening and the cursor choosing the option, since a native select's list opens outside
213
+ the page, where no screenshot or recording sees it; the page gets the same input either way.`,
214
+ },
203
215
  check: { usage: 'check <selector>', summary: 'check a checkbox' },
204
216
  uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
205
217
  upload: {
@@ -322,6 +334,28 @@ is at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on,
322
334
  phone shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit.
323
335
 
324
336
  --delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.`,
337
+ },
338
+ cursor: {
339
+ usage: 'cursor [on [<selector> | <x> <y>] | off]',
340
+ summary: 'draw a pointer where the REPL acts, for videos',
341
+ detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things
342
+ happening with nothing pointing at them. cursor on draws a pointer in the selected tab. After that, these
343
+ commands move it smoothly to their element or point before they act: click, dblclick, hover, fill, type
344
+ <selector>, press <selector>, select, check, uncheck, upload and mousemove. Each move takes 150 to 600ms,
345
+ depending on the distance (300 to 1000ms while the tab is recording), and the command waits for it. Clicks (click, dblclick, check, uncheck,
346
+ mousedown, and upload when it clicks a button) show a ring where they land.
347
+
348
+ It shows only the REPL's input, from every client, never the user's own mouse. Only the drawing moves: no
349
+ mouse events are sent along the way, so the page gets the same input as with the cursor off. fill, select
350
+ and the like move the drawing to their element but leave the real mouse where it was; mousedown, mouseup
351
+ and mousewheel act at the real mouse, so the pointer moves back there first.
352
+
353
+ cursor on fades it in where the mouse last went (or the middle), cursor on <selector> or <x> <y> fades it in
354
+ there instead (a video's first element, say), cursor off fades it out, and cursor alone says whether it
355
+ is on and where. It is a
356
+ mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at the same
357
+ spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it takes no
358
+ clicks, and it points at elements inside frames too.`,
325
359
  },
326
360
  record: {
327
361
  usage: 'record [on [file] [seconds] | off [file]]',
@@ -341,11 +375,20 @@ it stops by itself after them (at most 3600, which is also the default); command
341
375
  It brings the tab to the front of its window first, as screenshot does. It records the page only,
342
376
  from inside the browser, at the viewport's size in CSS pixels, scrollbars included, made even (an
343
377
  odd width or height loses a pixel), a phone's too under emulate mobile: a Pixel 7's is 412 wide, less
344
- sharp than the phone draws it. It has no pointer and no browser toolbars,
378
+ sharp than the phone draws it. It has no pointer (cursor on draws one) and no browser toolbars,
345
379
  and a tab behind another, hidden or headless keeps recording. Set the size first with viewport
346
380
  (1280x720 fills a 16:9 player); a page that changes size while recording is fitted into the first size.
347
381
  Chrome sends a frame as the page draws, and a still page keeps its real length.
348
382
 
383
+ While a tab records, type sends keys at a pace the video can show (help type), and mousewheel scrolls in
384
+ small eased steps over up to 1s rather than at once; fill still sets the whole
385
+ value at once, so use type for text the video should show being typed. record off also saves the steps
386
+ next to the video, as clip.steps.txt for clip.webm: one line for each command run on the tab while it
387
+ recorded, with when it began and ended in seconds into the video, the element or point it acted on in the
388
+ video's pixels, and the command with what it acts on, never what it types or chooses, the code it runs or
389
+ the URL it goes to. A (page) line marks each new page, loaded or routed to by a single-page app. They
390
+ are for editing the video afterwards, such as zooming in on a step. After the page changes size, the steps leave out where they acted.
391
+
349
392
  It needs ffmpeg: $PW_FFMPEG, and only it when it is set (the REPL's, not send's); otherwise Playwright's own (npx playwright-core install ffmpeg, in $PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright), then one on the PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. record on fails at once, recording nothing, without an ffmpeg that can write its format. If ffmpeg exits while recording, the recording ends then, the pane says what was saved, and the next record off fails with it. ffmpeg writes in blocks, so what it has not written yet is lost: the last seconds, or at first all of it. WebM plays in browsers,
350
393
  GitHub, GitLab and Slack; MP4 plays everywhere. It takes about 15 MB a minute.
351
394
 
@@ -520,10 +563,10 @@ Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScrip
520
563
  modes: {
521
564
  usage: 'modes [off [--mine]]',
522
565
  summary: 'the modes on in every tab; modes off turns them all off',
523
- detail: `The modes are watch, network off or slow, route, capture, record, emulate, viewport and highlight.
566
+ detail: `The modes are watch, network off or slow, route, capture, record, emulate, viewport, highlight and cursor.
524
567
  Each is turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
525
568
  route off <glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
526
- viewport off, highlight <selector> | highlight off.
569
+ viewport off, highlight <selector> | highlight off, cursor on|off.
527
570
 
528
571
  modes off turns off every one in every tab; a capture it stops is kept for capture to show, and a
529
572
  recording it stops is saved, as record off would. With
@@ -577,8 +620,9 @@ function renderAll() {
577
620
  const VIDEO = `There are three ways to record a video of a page, and each gives something up.
578
621
 
579
622
  record on (help record) records the selected tab's page from inside the browser: headless, hidden and
580
- background tabs too, with nothing to set up but ffmpeg. It has no pointer and no browser toolbars. For
581
- quick clips, and for an agent recording its own work.
623
+ background tabs too, with nothing to set up but ffmpeg. It has no pointer and no browser toolbars:
624
+ cursor on draws a pointer that shows where the REPL clicks. For quick clips, and for an agent recording
625
+ its own work.
582
626
 
583
627
  A screen recorder on the person's machine (OBS, ffmpeg -f x11grab, macOS screencapture -v) records their
584
628
  screen as they see it, pointer included, smooth and in real time. It records the screen or a window, not
package/lib/modes.js CHANGED
@@ -10,6 +10,7 @@ const { recordingOf, finishRecording } = require('./record');
10
10
  const { currentCapture, endCapture } = require('./capture');
11
11
  const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
12
12
  const { hideHighlights } = require('./elements');
13
+ const { cursorOf, cursorOff } = require('./cursor');
13
14
 
14
15
  // The modes turned on in a tab, in the order the prompt shows them.
15
16
  function activeModes(p) {
@@ -25,6 +26,7 @@ function activeModes(p) {
25
26
  if (routes) modes.push(`routes:${routes}`);
26
27
  const highlights = tabState(p).highlights?.size;
27
28
  if (highlights) modes.push(`highlight:${highlights}`);
29
+ if (cursorOf(p)) modes.push('cursor');
28
30
  if (currentCapture()?.page === p) modes.push('capture');
29
31
  if (recordingOf(p)) modes.push('record');
30
32
  return modes;
@@ -74,6 +76,8 @@ function modeOwners(p) {
74
76
  if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
75
77
  for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
76
78
  for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
79
+ const cursor = cursorOf(p);
80
+ if (cursor) owners.push([cursor.owner ?? null, 'cursor']);
77
81
  const capture = currentCapture();
78
82
  if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
79
83
  const recording = recordingOf(p);
@@ -110,6 +114,7 @@ async function allModesOff(mine = false) {
110
114
  if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
111
115
  const hidden = await hideHighlights(p, mine);
112
116
  if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
117
+ if (cursorOf(p) && ours(cursorOf(p).owner) && await cursorOff(p)) done.push('cursor off');
113
118
  // Before the page's size is reset, which the recording would otherwise end with.
114
119
  const recording = recordingOf(p);
115
120
  if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
package/lib/record.js CHANGED
@@ -7,7 +7,7 @@ const { state, onShutdown } = require('./state');
7
7
  const out = require('./output');
8
8
  const { tabState } = require('./tabstate');
9
9
  const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
10
- const { shellWords } = require('./syntax');
10
+ const { shellWords, splitSelector } = require('./syntax');
11
11
 
12
12
  const FPS = 25;
13
13
  const MAX_SECONDS = 3600;
@@ -142,7 +142,7 @@ async function startRecording(p, file, seconds, format) {
142
142
  const rec = {
143
143
  page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
144
144
  size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
145
- ffmpegError: '', starting: true,
145
+ ffmpegError: '', starting: true, scale: 1, steps: [], step: null, stepsFile: null,
146
146
  };
147
147
  // The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
148
148
  // its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
@@ -153,7 +153,8 @@ async function startRecording(p, file, seconds, format) {
153
153
  const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
154
154
  const { result } = await session.send('Runtime.evaluate', { expression: '[innerWidth, innerHeight]', returnByValue: true });
155
155
  const [width, height] = result.value;
156
- rec.size = { width: even(width * (vv.scale || 1)), height: even(height * (vv.scale || 1)) };
156
+ rec.scale = vv.scale || 1;
157
+ rec.size = { width: even(width * rec.scale), height: even(height * rec.scale) };
157
158
  } catch (error) {
158
159
  session.detach().catch(() => {});
159
160
  throw error;
@@ -208,6 +209,17 @@ async function startRecording(p, file, seconds, format) {
208
209
  }, seconds * 1000);
209
210
  rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
210
211
  p.once('close', rec.onClose);
212
+ // A new page, loaded or routed to by a single-page app, is a step of its own: a zoom that follows the
213
+ // cursor ends there. Its URL is left out, as a step's values are.
214
+ rec.onNavigated = frame => {
215
+ if (frame !== p.mainFrame() || rec.finishing) return;
216
+ const t = videoTime(rec);
217
+ // An app that sets its URL twice in a row (push, then replace) is one new page.
218
+ const last = rec.steps[rec.steps.length - 1];
219
+ if (last?.command === '(page)' && t - last.t0 < 0.5) return;
220
+ rec.steps.push({ rec, t0: t, t1: t, command: '(page)', box: null, point: null, offSize: false, failed: false });
221
+ };
222
+ p.on('framenavigated', rec.onNavigated);
211
223
  return rec;
212
224
  }
213
225
 
@@ -267,6 +279,7 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
267
279
  async function finishFile(rec, shuttingDown) {
268
280
  clearTimeout(rec.limit);
269
281
  if (rec.onClose) rec.page.off('close', rec.onClose);
282
+ if (rec.onNavigated) rec.page.off('framenavigated', rec.onNavigated);
270
283
  if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
271
284
  // The last frame runs to now, and a recording has one frame at least.
272
285
  if (rec.ff && rec.latest && !rec.ffExited) {
@@ -314,7 +327,97 @@ async function finishFile(rec, shuttingDown) {
314
327
  else if (rec.exitedEarly) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? ` (${rec.ffmpegError.trim()})` : ''}, ${short}`);
315
328
  else if (code !== 0) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
316
329
  const length = cut || rec.exitedEarly ? '' : `${seconds.toFixed(1)}s, `;
317
- return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
330
+ const steps = writeSteps(rec, seconds);
331
+ const stepsLine = !steps ? '' : steps.error ? `\nIts steps were not saved: ${steps.error}` : `\nSteps: ${steps.file} (${steps.count}; when each command ran, and where)`;
332
+ return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}${stepsLine}`;
333
+ }
334
+
335
+ // The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
336
+ // seconds, and the element or point it acted on in the video's pixels, saved next to the video for
337
+ // whoever edits it (a zoom on a step, say). Only that: watch is the log of what happened.
338
+
339
+ // The video's clock: frames are written against the wall clock since the first, less those dropped.
340
+ function videoTime(rec) {
341
+ return rec.firstFrameAt === null ? 0 : Math.max(0, (Date.now() - rec.firstFrameAt) / 1000 - rec.dropped / FPS);
342
+ }
343
+
344
+ // What a step is, for its line: the command and what it acts on, never what it types or chooses, the
345
+ // code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
346
+ // video goes, and the video shows a password as dots.
347
+ const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
348
+ const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
349
+ function stepLabel(cmd, args) {
350
+ if (WHOLE.has(cmd)) return `${cmd} ${args.replace(/\s+--modifiers=\S+/, '')}`.trim();
351
+ if (!TARGET_FIRST.has(cmd)) return cmd;
352
+ const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
353
+ const parsed = splitSelector(rest);
354
+ // type and press with one word act on the focused element: the word is the text or the key.
355
+ return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
356
+ }
357
+
358
+ function stepStarted(p, command) {
359
+ const rec = recordingOf(p);
360
+ if (!rec || rec.finishing) return null;
361
+ // After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
362
+ rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
363
+ rec.steps.push(rec.step);
364
+ return rec.step;
365
+ }
366
+
367
+ function stepEnded(step, failed) {
368
+ if (!step) return;
369
+ step.t1 = videoTime(step.rec);
370
+ step.failed = failed;
371
+ if (step.rec.step === step) step.rec.step = null;
372
+ }
373
+
374
+ // The element (box) or point the running step acts on, in CSS pixels.
375
+ function stepTarget(p, box, point) {
376
+ const rec = recordingOf(p);
377
+ if (!rec?.step) return;
378
+ const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
379
+ rec.step.box = scaled(box);
380
+ rec.step.point = scaled(point);
381
+ }
382
+
383
+ // Where the cursor went (cursor on), and when: a line of its own, not a command's step.
384
+ function cursorStep(p, what, point, ms) {
385
+ const rec = recordingOf(p);
386
+ if (!rec || rec.finishing) return;
387
+ const t0 = videoTime(rec);
388
+ const scaled = Math.round(point.x * rec.scale);
389
+ rec.steps.push({ rec, t0, t1: t0 + ms / 1000, command: what.replace(/-?\d+(?:\.\d+)?/g, n => String(Math.round(Number(n) * rec.scale))), box: null, point: { x: scaled, y: Math.round(point.y * rec.scale) }, offSize: rec.resizedAt !== null, failed: false });
390
+ }
391
+
392
+ function stepsPath(file) {
393
+ return file.replace(/\.[^./]+$/, '') + '.steps.txt';
394
+ }
395
+
396
+ // One line a step: t0 t1, then x y w h of its element (a point is x y 0 0; none, or not in the video's
397
+ // pixels, is - - - -), then the command. Plain words, for a shell script to read.
398
+ function writeSteps(rec, seconds) {
399
+ if (!rec.steps.length) return null;
400
+ const file = stepsPath(rec.file);
401
+ const lines = [
402
+ `# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
403
+ '# t0 t1: when the command began and ended, in seconds into the video',
404
+ '# x y w h: its element in the video\'s pixels (a point: w and h are 0; none: -)',
405
+ '# (cursor from X Y): the cursor (cursor on) glided from X Y to x y between t0 and t1',
406
+ '# (page): the tab went to a new page, loaded or routed to by a single-page app',
407
+ ...(rec.resizedAt !== null ? [`# the page changed size at ${rec.resizedAt.toFixed(2)}s: boxes after it are left out`] : []),
408
+ ];
409
+ for (const s of rec.steps) {
410
+ const where = s.offSize ? null : s.box || (s.point && { x: s.point.x, y: s.point.y, width: 0, height: 0 });
411
+ const xywh = where ? `${where.x} ${where.y} ${where.width} ${where.height}` : '- - - -';
412
+ lines.push(`${s.t0.toFixed(2)} ${(s.t1 ?? videoTime(rec)).toFixed(2)} ${xywh} ${s.command}${s.failed ? ' # failed' : ''}`);
413
+ }
414
+ try {
415
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
416
+ } catch (error) {
417
+ return { file, error: error.code === 'EEXIST' ? `${file} already exists` : error.message };
418
+ }
419
+ rec.stepsFile = file;
420
+ return { file, count: rec.steps.length };
318
421
  }
319
422
 
320
423
  const TAB_CLOSED = 'Its tab closed, which ended it';
@@ -439,9 +542,18 @@ const commands = {
439
542
  } catch (error) {
440
543
  throw new Error(`${text}\nIt could not be renamed to ${rename} (${error.message}), so it stays where it was saved`);
441
544
  }
545
+ // Its steps go with it, unless a file has the new name already.
546
+ let renamed = t => t.split(rec.file).join(rename);
547
+ if (rec.stepsFile && !fs.existsSync(stepsPath(rename))) {
548
+ try {
549
+ moveRecording(rec.stepsFile, stepsPath(rename));
550
+ const before = renamed;
551
+ renamed = t => before(t.split(rec.stepsFile).join(stepsPath(rename)));
552
+ } catch {}
553
+ }
442
554
  const last = lastEnded.get(rec.owner ?? null);
443
- if (last) last.text = last.text.split(rec.file).join(rename);
444
- out.log(text.split(rec.file).join(rename));
555
+ if (last) last.text = renamed(last.text);
556
+ out.log(renamed(text));
445
557
  return;
446
558
  }
447
559
  if (numbers.length > 1) throw new Error(usage);
@@ -481,4 +593,4 @@ const commands = {
481
593
  },
482
594
  };
483
595
 
484
- module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize };
596
+ module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
package/lib/runner.js CHANGED
@@ -7,6 +7,7 @@ const { commands, cliCommands, needsTab } = require('./commands');
7
7
  const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
8
8
  const { activeModes } = require('./modes');
9
9
  const { NO_TAB } = require('./util');
10
+ const { stepStarted, stepEnded, stepLabel } = require('./record');
10
11
  const cliNames = require('./cli-names');
11
12
 
12
13
  // playwright-cli names already explained, per client: each client is told once.
@@ -105,9 +106,13 @@ async function executeCommand(text) {
105
106
  out.error(`Error: ${NO_TAB}`);
106
107
  return { status: 'error', cmd };
107
108
  }
109
+ // A recording tab notes when each command that may change it ran, for its steps file (help record).
110
+ const step = READ_ONLY.has(cmd) || NO_TAB_NEEDED.has(cmd) || !state.page || state.page.isClosed() ? null : stepStarted(state.page, stepLabel(cmd, args));
108
111
  try {
109
112
  await run(args, all);
113
+ stepEnded(step, false);
110
114
  } catch (e) {
115
+ stepEnded(step, true);
111
116
  // Playwright colours its call log; the codes are noise anywhere but a terminal.
112
117
  e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
113
118
  out.error(`Error: ${e.message}`);
package/lib/tabstate.js CHANGED
@@ -11,6 +11,9 @@
11
11
  // with the owner
12
12
  // viewport the size viewport set: { width, height }, with the owner
13
13
  // recording its recording (record on): the ffmpeg writing it, its file, its owner
14
+ // cursor its cursor (cursor on): where it is drawn { x, y }, with the owner
15
+ // cursorKept its cursor is drawn again on each new page
16
+ // mouseAt where the REPL last put the mouse { x, y }, when it knows
14
17
  // routes Map(glob -> { status, body, handler, owner })
15
18
  // log its recent requests (requests)
16
19
  // consoleLog its recent console messages and page errors (console)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -205,8 +205,9 @@ pw-repl send -c <name> close # your modes off; tab close
205
205
  - To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
206
206
  without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
207
207
  cover what is below it; `help highlight` says how to mark a screenshot without one.
208
- - For a video, `help video` says which way suits what you want: `record on` records a tab's page, and a
209
- screen recorder on the person's machine records what they see.
208
+ - For a video, `help video` says which way suits what you want: `record on` records a tab's page (`cursor
209
+ on` draws a pointer where you act, for the viewer), and a screen recorder on the person's machine records
210
+ what they see.
210
211
 
211
212
  ## Environment
212
213