pw-repl 0.7.0 → 0.8.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
@@ -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/bin/pw-repl.js CHANGED
@@ -15,7 +15,7 @@ const USAGE = `Usage:
15
15
  see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
16
16
 
17
17
  pw-repl stop [-e endpoint]
18
- stop a background REPL
18
+ stop a background REPL, and say what became of each recording it ended
19
19
 
20
20
  pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
21
21
  run one command in a running REPL and print its output
@@ -41,10 +41,10 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
41
41
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
42
42
 
43
43
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
44
- a temporary profile, on a free port of its own), prints the command it ran, and stops it with the REPL
45
- (Ctrl-C while it starts too); stop returns once that Chromium has exited. Flags after -- go to it; a
46
- --user-data-dir=<dir> among them is used instead of the temporary profile, and kept. PW_CHROME picks
47
- which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
44
+ a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
45
+ and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
46
+ Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
47
+ kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
48
48
 
49
49
  In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
50
50
 
@@ -103,7 +103,8 @@ function parseSendArgs(args, allowCommand) {
103
103
  // words are joined as they are.
104
104
  const { SELECTOR_FIRST } = require('../lib/syntax');
105
105
  // So does playwright-cli's storage key (localstorage-set "my key" v).
106
- const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]));
106
+ // And record's file, which may have spaces in it.
107
+ const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
107
108
  // An empty word (fill #name "") is the empty value.
108
109
  // upload's files are read by the REPL, whose folder may not be this one.
109
110
  // So are the files upload reads and screenshot --filename writes.
package/lib/background.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const { spawn } = require('child_process');
7
7
  const client = require('./client');
8
+ const { STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
8
9
 
9
10
  const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
10
11
  const START_TIMEOUT = 20000;
@@ -132,12 +133,26 @@ async function stop(options) {
132
133
  const { pidFile } = filesFor(endpoint);
133
134
  const pid = runningPid(pidFile);
134
135
  if (!pid) return fail(`no background REPL is serving on ${client.describe(endpoint)}`);
136
+ // What it logs as it stops, from here on, holds what became of each recording it ended.
137
+ const { logFile } = filesFor(endpoint);
138
+ let logFrom = 0;
139
+ try { logFrom = fs.statSync(logFile).size; } catch {}
135
140
  // Like Ctrl-C: a command still running is answered, and the socket is removed.
136
141
  process.kill(pid, 'SIGTERM');
137
142
  const deadline = Date.now() + STOP_TIMEOUT;
138
143
  while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
139
144
  if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
140
- console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${filesFor(endpoint).logFile}`);
145
+ console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${logFile}`);
146
+ // The REPL is gone, so record can no longer say where a recording it ended went, or that it was lost.
147
+ let logged = '';
148
+ try {
149
+ const fd = fs.openSync(logFile, 'r');
150
+ const buffer = Buffer.alloc(Math.max(0, fs.fstatSync(fd).size - logFrom));
151
+ fs.readSync(fd, buffer, 0, buffer.length, logFrom);
152
+ fs.closeSync(fd);
153
+ logged = buffer.toString('utf8');
154
+ } catch {}
155
+ for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN)) console.log(line);
141
156
  return 0;
142
157
  }
143
158
 
package/lib/cli-names.js CHANGED
@@ -69,7 +69,7 @@ const NEAREST = {
69
69
  list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
70
70
  attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
71
71
  detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
72
- drag: 'not here yet', drop: 'not here yet', highlight: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
72
+ drag: 'not here yet', drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
73
73
  keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
74
74
  pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
75
75
  'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
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/dialogs.js CHANGED
@@ -5,11 +5,17 @@ const { OUTPUT_LIMIT } = out;
5
5
  const { unquote } = require('./syntax');
6
6
  const { hints } = require('./util');
7
7
  const { tabState } = require('./tabstate');
8
+ const { keptSession } = require('./cdp');
8
9
 
9
10
  // page -> its open dialog. The page, and every command that reads it, waits
10
11
  // until the dialog is answered, in the browser or with the dialog command.
11
12
  const openDialogs = new Map();
12
13
 
14
+ // cdp's session is new, and Chrome lets only one that saw a dialog open answer it: it would wait out its
15
+ // timeout. Refused ahead of the queue too, which the dialog it means holds up.
16
+ const CDP_ANSWER = /^cdp(?:\s+--all)?\s+Page\.handleJavaScriptDialog(?:\s|$)/;
17
+ const CDP_REFUSED = 'cdp cannot answer a dialog, which Chrome lets only a session that saw it open do; dialog accept or dialog dismiss answers it';
18
+
13
19
  function ensureDialogHandler(p) {
14
20
  if ('dialogHandled' in tabState(p)) return;
15
21
  tabState(p).dialogHandled = true;
@@ -19,6 +25,12 @@ function ensureDialogHandler(p) {
19
25
  out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.', p);
20
26
  });
21
27
  p.on('close', () => openDialogs.delete(p));
28
+ // One answered in the browser is gone too, which Playwright does not report. The tab's own session
29
+ // shares Playwright's connection, so a dialog's closing comes in before the next one opening.
30
+ keptSession(p).then(async session => {
31
+ session.on('Page.javascriptDialogClosed', () => openDialogs.delete(p));
32
+ await session.send('Page.enable');
33
+ }).catch(() => {});
22
34
  }
23
35
 
24
36
  // Runs ahead of the command queue (see runner.js), so it still works while
@@ -30,7 +42,9 @@ async function dialogCommand(args, selected = state.page) {
30
42
  const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
31
43
  if (!action) {
32
44
  if (!openDialogs.size) return 'No dialog is open.';
33
- return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
45
+ // With several, the one accept and dismiss answer is marked, as tab marks the selected tab.
46
+ const mark = p => (openDialogs.size > 1 ? (p === selected ? '* ' : ' ') : '');
47
+ return [...[...openDialogs].map(([p, d]) => `${mark(p)}${describe(p, d)}`), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
34
48
  }
35
49
  if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
36
50
  // The selected tab's dialog, or the only one open.
@@ -40,7 +54,8 @@ async function dialogCommand(args, selected = state.page) {
40
54
  openDialogs.delete(page);
41
55
  const line = describe(page, dialog);
42
56
  try {
43
- if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
57
+ // Without text, as OK in the browser: a prompt's default stays its answer.
58
+ if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : dialog.defaultValue());
44
59
  else await dialog.dismiss();
45
60
  } catch (error) {
46
61
  // Answered in the browser meanwhile.
@@ -55,4 +70,4 @@ const commands = {
55
70
  },
56
71
  };
57
72
 
58
- module.exports = { commands, ensureDialogHandler, dialogCommand };
73
+ module.exports = { commands, ensureDialogHandler, dialogCommand, CDP_ANSWER, CDP_REFUSED };