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 +12 -7
- package/README.md +11 -6
- package/bin/pw-repl.js +7 -6
- package/lib/background.js +16 -1
- package/lib/cli-names.js +1 -1
- package/lib/commands.js +3 -0
- package/lib/cursor.js +302 -0
- package/lib/dialogs.js +18 -3
- package/lib/elements.js +173 -18
- package/lib/help.js +87 -21
- package/lib/inspect.js +2 -0
- package/lib/modes.js +11 -0
- package/lib/record.js +244 -40
- package/lib/runner.js +12 -2
- package/lib/syntax.js +8 -1
- package/lib/tabs.js +3 -0
- package/lib/tabstate.js +3 -0
- package/lib/util.js +6 -1
- package/package.json +1 -1
- package/skill/SKILL.md +13 -5
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/
|
|
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)
|
|
31
|
-
trained on
|
|
32
|
-
|
|
33
|
-
|
|
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`,
|
|
112
|
-
`watch on|off`, `capture on|off`, `route ...|route off`, `network off|slow|on`,
|
|
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
|
|
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,
|
|
135
|
-
browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS
|
|
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,
|
|
45
|
-
(Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
46
|
-
--user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
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
|
-
|
|
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 ${
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|