pw-repl 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/README.md +23 -16
- package/bin/pw-repl.js +12 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +36 -50
- package/lib/elements.js +133 -18
- package/lib/emulation.js +13 -1
- package/lib/help.js +108 -29
- package/lib/inspect.js +65 -6
- package/lib/modes.js +11 -2
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +15 -11
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +73 -56
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
14
14
|
`READ_ONLY` or `INSPECTION`, and one that takes fixed words in `complete` (`lib/commands.js`).
|
|
15
15
|
- The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
|
|
16
16
|
`lib/elements.js` (choosing a match; the interact and mouse commands; highlight), `lib/cursor.js` (the pointer
|
|
17
|
-
drawn over the page, which the element and mouse commands glide first), `lib/
|
|
17
|
+
drawn over the page, which the element and mouse commands glide first), `lib/toast.js` (a line of text
|
|
18
|
+
over the page), `lib/overlay.js` (the element the cursor and toast are drawn in, one layer each), `lib/inspect.js` (reading the
|
|
18
19
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
19
20
|
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/record.js`
|
|
20
21
|
(video, through ffmpeg, and the steps file saved with it), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# playwright-repl
|
|
2
2
|
|
|
3
3
|
A command REPL for a Chromium you are already using, shared by you and your agents. It connects over CDP
|
|
4
|
-
through Playwright;
|
|
4
|
+
through Playwright; users click in the browser and type at the prompt, agents send the same commands,
|
|
5
5
|
and every command shows in the REPL, so each sees what the other does.
|
|
6
6
|
|
|
7
7
|
## Why use it
|
|
@@ -13,7 +13,8 @@ For working on a web page together with an agent, in the browser you already use
|
|
|
13
13
|
- **One session, seen by everyone.** You and any number of agents drive the same browser, each with a
|
|
14
14
|
selected tab of its own, and every command shows in the REPL, whoever sent it.
|
|
15
15
|
- **What you did, for an agent to read.** `watch` records each step you take in a tab, with the requests
|
|
16
|
-
it caused, so you can reproduce a bug by hand and the agent can see exactly what happened
|
|
16
|
+
it caused, so you can reproduce a bug by hand and the agent can see exactly what happened; `watch save`
|
|
17
|
+
keeps the steps as a file of commands that runs them again.
|
|
17
18
|
- **Breaking things on purpose.** Fake, patch, delay or fail responses, cut or slow the network, emulate a
|
|
18
19
|
phone, a locale or a timezone, all per tab, so your other tabs are left alone.
|
|
19
20
|
|
|
@@ -87,7 +88,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
87
88
|
|
|
88
89
|
| I want to… | Commands |
|
|
89
90
|
| ------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
90
|
-
| see where I am | `tab
|
|
91
|
+
| see where I am | `tab` (it marks the tab you are looking at `(in front)`), `info` |
|
|
91
92
|
| see what is on the page | `snapshot`, `screenshot` |
|
|
92
93
|
| do something on it | `click`, `fill`, `press` |
|
|
93
94
|
| wait for a page or element | `wait load`, `wait <selector>`, `wait <selector> --gone` |
|
|
@@ -97,10 +98,12 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
97
98
|
| see console messages and errors | `console` |
|
|
98
99
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
99
100
|
| see each step as I click | `watch on --live` |
|
|
101
|
+
| replay what I clicked through | `watch on`, click around, `watch save flow.txt`, then `pw-repl send --file flow.txt` |
|
|
100
102
|
| record requests and console together | `capture on`, then `capture off` |
|
|
101
103
|
| record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
|
|
102
104
|
| show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
|
|
103
|
-
|
|
|
105
|
+
| say what is happening on screen | `toast <text>`, then `toast off` (`--duration=3s` hides it by itself) |
|
|
106
|
+
| point something out on the page | `highlight <ref>`: a box, unlabelled unless `--labels on`; `highlight off` hides it |
|
|
104
107
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
105
108
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
106
109
|
| slow the whole network | `network slow` |
|
|
@@ -110,29 +113,33 @@ Everything else is in `help <topic>`; `help <command>` has usage and caveats.
|
|
|
110
113
|
|
|
111
114
|
### Modes
|
|
112
115
|
|
|
113
|
-
`watch`, `capture`, `route`, `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight
|
|
114
|
-
stay on until you turn them off: `watch on|off`, `capture on|off`, `route ...|route off`,
|
|
115
|
-
`emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`,
|
|
116
|
-
`cursor on|off
|
|
116
|
+
`watch`, `capture`, `route`, `network off` or `slow`, `emulate`, `viewport`, `record`, `highlight`, `cursor`
|
|
117
|
+
and `toast` stay on until you turn them off: `watch on|off`, `capture on|off`, `route ...|route off`,
|
|
118
|
+
`network off|slow|on`, `emulate ...|emulate off`, `viewport <WxH>|viewport off`, `record on|off`,
|
|
119
|
+
`highlight <selector>|highlight off` (and `highlight --labels on|off`), `cursor on|off`,
|
|
120
|
+
`toast <text>|toast off`.
|
|
117
121
|
While any are on in the selected tab, the prompt shows them: `(watch network:off routes:2) pw>`. `modes`
|
|
118
122
|
lists them for every tab, and `modes off` turns them all off.
|
|
119
123
|
|
|
120
|
-
`tab`, `watch`, `capture`, `route`, `network`, `emulate`, `viewport`, `record`, `highlight`, `cursor
|
|
121
|
-
their own show their state and what you can run next.
|
|
124
|
+
`tab`, `watch`, `capture`, `route`, `network`, `emulate`, `viewport`, `record`, `highlight`, `cursor`, `toast`
|
|
125
|
+
and `modes` on their own show their state and what you can run next.
|
|
122
126
|
|
|
123
127
|
## How it differs from playwright-cli
|
|
124
128
|
|
|
125
129
|
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
126
|
-
gives each agent a browser session of its own; pw-repl shares one browser between
|
|
130
|
+
gives each agent a browser session of its own; pw-repl shares one browser between users and agents.
|
|
127
131
|
Its command names and most of their options work here too (`help playwright-cli` lists them), and where
|
|
128
|
-
pw-repl differs, it is because someone else may be using the browser:
|
|
132
|
+
pw-repl differs, it is mostly because someone else may be using the browser:
|
|
129
133
|
|
|
130
134
|
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
131
135
|
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
132
|
-
- File pickers are not caught, since they may be the
|
|
133
|
-
- There is no snapshot after every command, since
|
|
136
|
+
- File pickers are not caught, since they may be the user's: `upload` names the input instead.
|
|
137
|
+
- There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
|
|
134
138
|
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
135
|
-
- `watch` records what
|
|
139
|
+
- `watch` records what the user does as steps with their requests, and `watch save` writes them as
|
|
140
|
+
pw-repl commands to run again, not as Playwright code.
|
|
141
|
+
- `highlight` draws its box without Playwright's locator label, which covers what is below it and puts
|
|
142
|
+
selectors in screenshots and videos; `highlight --labels on` shows it.
|
|
136
143
|
|
|
137
144
|
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
138
145
|
playwright-cli is the better fit. `record on` records a tab's page, and `cursor on` draws a pointer where the
|
|
@@ -144,7 +151,7 @@ agent acts; for the shared browser as you see it, use a screen recorder (`ffmpeg
|
|
|
144
151
|
### A skill for agents
|
|
145
152
|
|
|
146
153
|
`pw-repl skill` prints an agent skill (`SKILL.md`) that teaches an agent to use the REPL: how to start
|
|
147
|
-
it, send commands, and share the browser with
|
|
154
|
+
it, send commands, and share the browser with the user. Save it as `pw-repl/SKILL.md` in the folder
|
|
148
155
|
your agent reads skills from:
|
|
149
156
|
|
|
150
157
|
```bash
|
package/bin/pw-repl.js
CHANGED
|
@@ -17,7 +17,7 @@ const USAGE = `Usage:
|
|
|
17
17
|
pw-repl stop [-e endpoint]
|
|
18
18
|
stop a background REPL, and say what became of each recording it ended
|
|
19
19
|
|
|
20
|
-
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...> | --file <file>
|
|
20
|
+
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] [--no-vars] <command...> | --file <file>
|
|
21
21
|
run one command in a running REPL and print its output; --file runs a file of them, one per line
|
|
22
22
|
|
|
23
23
|
pw-repl where [-e endpoint | -s session]
|
|
@@ -45,6 +45,16 @@ folder, as on send's command line, and # comments and blank lines skipped. It st
|
|
|
45
45
|
command that fails, saying its line, with its exit status. -t is each command's own limit. Other
|
|
46
46
|
clients' commands can run between its lines, on their own tabs.
|
|
47
47
|
|
|
48
|
+
A {{ PW_NAME }} in a command, or in a line of the file, is the variable PW_NAME from send's environment,
|
|
49
|
+
e.g. a password kept out of the file and the REPL's log. In a file: fill '#password' "{{ PW_PASSWORD }}",
|
|
50
|
+
run with PW_PASSWORD=... pw-repl send --file login.txt; or one command: PW_PASSWORD=... pw-repl send
|
|
51
|
+
fill '#password' "{{ PW_PASSWORD }}". Only names starting PW_ are variables: any other {{ ... }}, as in a
|
|
52
|
+
page's template text, is sent as written. In a file, put it in double quotes: there its value is
|
|
53
|
+
escaped as they read it (in single quotes, which escape nothing, a value with a ' is refused). The REPL
|
|
54
|
+
fills it in as it runs the command, and its pane and log show the command as written. A variable that
|
|
55
|
+
is not set stops the command, or the file at that line, before it is sent. --no-vars sends
|
|
56
|
+
{{ PW_NAME }} as it is. Variables need the command server (pw-repl serve), not the tmux pane.
|
|
57
|
+
|
|
48
58
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
49
59
|
|
|
50
60
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
@@ -87,6 +97,7 @@ function parseSendArgs(args, allowCommand) {
|
|
|
87
97
|
options.file = require('path').resolve(args[++i]);
|
|
88
98
|
continue;
|
|
89
99
|
}
|
|
100
|
+
if (arg === '--no-vars') { options.noVars = true; continue; }
|
|
90
101
|
if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
|
|
91
102
|
const value = args[++i];
|
|
92
103
|
if (value === undefined) usage();
|
package/lib/background.js
CHANGED
|
@@ -69,13 +69,23 @@ function claimPidFile(pidFile) {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Why a REPL cannot serve on an endpoint another one serves on, and what to do instead; null if none does.
|
|
73
|
+
async function alreadyServing(endpoint) {
|
|
74
|
+
const name = client.describe(endpoint);
|
|
75
|
+
const another = `; to start another, give it a socket of its own: -e /tmp/<name>.sock`;
|
|
76
|
+
const background = runningPid(filesFor(endpoint).pidFile);
|
|
77
|
+
if (background) return `a background REPL is already serving on ${name} (pid ${background}): pw-repl attach uses it, pw-repl stop stops it${another}`;
|
|
78
|
+
const info = await client.healthInfo(endpoint, 2000);
|
|
79
|
+
if (!info) return null;
|
|
80
|
+
return `a REPL is already serving on ${name}${info.pid ? ` (pid ${info.pid})` : ''}, in a terminal: pw-repl send uses it, and quit at its prompt stops it${another}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
async function start(options) {
|
|
73
84
|
const endpoint = client.parseEndpoint(options.endpoint);
|
|
74
85
|
const name = client.describe(endpoint);
|
|
75
86
|
const { pidFile, logFile } = filesFor(endpoint);
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
|
|
87
|
+
const serving = await alreadyServing(endpoint);
|
|
88
|
+
if (serving) return fail(serving);
|
|
79
89
|
// Appended to, so an earlier run's log stays; each run is marked where it starts and stops. One grown
|
|
80
90
|
// past LOG_LIMIT is moved to <log>.1 first, replacing the one there.
|
|
81
91
|
try { if (fs.statSync(logFile).size > LOG_LIMIT) fs.renameSync(logFile, `${logFile}.1`); } catch {}
|
|
@@ -234,4 +244,4 @@ function describeBackground(endpoint) {
|
|
|
234
244
|
return `background: pid ${pid}${since}; log ${logFile} (pw-repl attach${e}, pw-repl stop${e})`;
|
|
235
245
|
}
|
|
236
246
|
|
|
237
|
-
module.exports = { filesFor, endpointFlag, markStopInLog, claimPidFile, start, stop, attach, describeBackground };
|
|
247
|
+
module.exports = { alreadyServing, filesFor, endpointFlag, markStopInLog, claimPidFile, start, stop, attach, describeBackground };
|
package/lib/cdp.js
CHANGED
|
@@ -20,6 +20,12 @@ async function keepDrawing(p) {
|
|
|
20
20
|
tabState(p).drawing = true;
|
|
21
21
|
try {
|
|
22
22
|
const session = await keptSession(p);
|
|
23
|
+
// A tab behind another draws no frames, and Playwright's actions wait for frames: told it is in
|
|
24
|
+
// front and focused, it draws, and clicks in it work. Only in tabs the REPL uses (the browser is
|
|
25
|
+
// connected without Playwright's own, which would tell every tab, the user's too), so the user's
|
|
26
|
+
// tabs say truly whether they are in front.
|
|
27
|
+
await session.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
28
|
+
tabState(p).focusEmulated = true;
|
|
23
29
|
session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
|
|
24
30
|
await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
|
|
25
31
|
} catch {
|
package/lib/cli-names.js
CHANGED
|
@@ -91,7 +91,7 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
|
|
|
91
91
|
NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
|
|
92
92
|
}
|
|
93
93
|
for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
|
|
94
|
-
NEAREST['video-chapter'] = 'not here:
|
|
94
|
+
NEAREST['video-chapter'] = 'not here: toast <text> --duration=3s shows a title over the page instead, in the video';
|
|
95
95
|
// Theirs labels each action and pauses on it; the cursor is a pointer that moves to each action instead.
|
|
96
96
|
NEAREST['video-show-actions'] = 'not here; cursor on draws a pointer that moves to each action, without labels';
|
|
97
97
|
NEAREST['video-hide-actions'] = 'not here; cursor off hides the pointer cursor on draws';
|
package/lib/client.js
CHANGED
|
@@ -61,9 +61,10 @@ async function ready(endpoint, timeoutMs) {
|
|
|
61
61
|
// One command. Resolves to { result }, { timeout: true }, { dropped: reason }
|
|
62
62
|
// (the connection closed after the command was sent, so it may have run) or
|
|
63
63
|
// { unreachable: reason } (it was never sent).
|
|
64
|
-
|
|
64
|
+
// vars: the values of the {{ PW_NAME }} variables in command, which the REPL fills in as it runs it.
|
|
65
|
+
function request(endpoint, command, timeoutMs, client = null, vars = null) {
|
|
65
66
|
return new Promise(resolve => {
|
|
66
|
-
const body = JSON.stringify(client ? {
|
|
67
|
+
const body = JSON.stringify({ command, ...(client ? { client } : {}), ...(vars ? { vars } : {}) });
|
|
67
68
|
let connected = false;
|
|
68
69
|
const req = http.request({
|
|
69
70
|
...target(endpoint),
|
package/lib/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ const { tabState } = require('./tabstate');
|
|
|
7
7
|
const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
|
|
8
8
|
const { commands: elementsCommands } = require('./elements');
|
|
9
9
|
const { commands: cursorCommands } = require('./cursor');
|
|
10
|
+
const { commands: toastCommands } = require('./toast');
|
|
10
11
|
const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
|
|
11
12
|
const { commands: routesCommands, routesFor } = require('./routes');
|
|
12
13
|
const { commands: networkCommands } = require('./network');
|
|
@@ -23,6 +24,7 @@ const commands = {
|
|
|
23
24
|
...tabsCommands,
|
|
24
25
|
...elementsCommands,
|
|
25
26
|
...cursorCommands,
|
|
27
|
+
...toastCommands,
|
|
26
28
|
...inspectCommands,
|
|
27
29
|
...watchCommands,
|
|
28
30
|
...requestlogCommands,
|
|
@@ -116,6 +118,7 @@ function complete(line) {
|
|
|
116
118
|
if (command === 'capture') return match(['on', 'off']);
|
|
117
119
|
if (command === 'record') return match(['on', 'off']);
|
|
118
120
|
if (command === 'cursor') return match(['on', 'off']);
|
|
121
|
+
if (command === 'toast') return match(['off']);
|
|
119
122
|
if (command === 'viewport') return match(['off']);
|
|
120
123
|
if (command === 'route') return match(['off']);
|
|
121
124
|
}
|
package/lib/cursor.js
CHANGED
|
@@ -7,6 +7,7 @@ const out = require('./output');
|
|
|
7
7
|
const { tabState } = require('./tabstate');
|
|
8
8
|
const { unquote, toSelector } = require('./syntax');
|
|
9
9
|
const { recordingOf, stepTarget, cursorStep } = require('./record');
|
|
10
|
+
const { inOverlay, keepAcrossPages, forget } = require('./overlay');
|
|
10
11
|
|
|
11
12
|
// Scaled with the distance, so a short hop is quick and a long one does not drag. Slower while the tab
|
|
12
13
|
// records, so a viewer can follow it.
|
|
@@ -19,37 +20,21 @@ const glideTime = (distance, recording) => {
|
|
|
19
20
|
return Math.round(Math.min(max, Math.max(min, base + distance * perPixel)));
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
// Runs in the page
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// which a page's Trusted Types or style-src rules do not block. It is shown as a popover, in the top
|
|
26
|
-
// layer, above the page's own dialogs, as Playwright's highlight is. Kept on the document under a
|
|
27
|
-
// symbol, so each call finds it again, and one the page removed is drawn again.
|
|
28
|
-
function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
|
|
29
|
-
const KEY = Symbol.for('pw-repl-cursor');
|
|
30
|
-
// Important on the element in the page, whose own rules could reach it; inside, the page's cannot,
|
|
31
|
-
// and an important transform would win over the glide's animation.
|
|
32
|
-
const set = (el, styles, important = '') => { for (const [name, value] of Object.entries(styles)) el.style.setProperty(name, value, important); };
|
|
23
|
+
// Runs in the page, as the overlay's cursor layer (lib/overlay.js).
|
|
24
|
+
function pageCursor(overlay, { x, y, action, ms, count, fade, box, items, index }) {
|
|
25
|
+
const { set } = overlay;
|
|
33
26
|
const place = (el, px, py) => set(el, { transform: `translate(${px}px, ${py}px)` });
|
|
34
|
-
let cursor =
|
|
27
|
+
let cursor = overlay.cursor;
|
|
35
28
|
if (action === 'remove') {
|
|
36
29
|
if (!cursor) return;
|
|
37
|
-
delete
|
|
38
|
-
if (!fade
|
|
30
|
+
delete overlay.cursor;
|
|
31
|
+
if (!fade) { overlay.drop('cursor'); return; }
|
|
39
32
|
// Waited on, as a glide is, but not past its time in a tab that draws no frames.
|
|
40
33
|
const fading = cursor.arrow.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' });
|
|
41
|
-
return Promise.race([fading.finished, new Promise(resolve => setTimeout(resolve, 500))]).then(() =>
|
|
34
|
+
return Promise.race([fading.finished, new Promise(resolve => setTimeout(resolve, 500))]).then(() => overlay.drop('cursor'), () => overlay.drop('cursor'));
|
|
42
35
|
}
|
|
43
|
-
if (!cursor || !cursor
|
|
44
|
-
const
|
|
45
|
-
host.setAttribute('aria-hidden', 'true');
|
|
46
|
-
host.setAttribute('popover', 'manual');
|
|
47
|
-
set(host, {
|
|
48
|
-
display: 'block', position: 'fixed', inset: '0', width: '100%', height: '100%', 'max-width': 'none',
|
|
49
|
-
'max-height': 'none', margin: '0', padding: '0', border: 'none', background: 'transparent',
|
|
50
|
-
overflow: 'visible', 'pointer-events': 'none', 'z-index': '2147483647',
|
|
51
|
-
}, 'important');
|
|
52
|
-
const root = host.attachShadow({ mode: 'closed' });
|
|
36
|
+
if (!cursor || !overlay.has('cursor')) {
|
|
37
|
+
const root = overlay.layer('cursor');
|
|
53
38
|
const NS = 'http://www.w3.org/2000/svg';
|
|
54
39
|
const arrow = document.createElementNS(NS, 'svg');
|
|
55
40
|
arrow.setAttribute('width', '20');
|
|
@@ -65,14 +50,11 @@ function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
|
|
|
65
50
|
shape.setAttribute('stroke-linejoin', 'round');
|
|
66
51
|
arrow.append(shape);
|
|
67
52
|
root.append(arrow);
|
|
68
|
-
|
|
69
|
-
cursor = document[KEY] = { host, root, arrow, x, y };
|
|
53
|
+
cursor = overlay.cursor = { root, arrow, x, y };
|
|
70
54
|
place(arrow, x, y);
|
|
71
55
|
// Faded in when turned on; drawn again on a new page, it is there at once, as it was.
|
|
72
56
|
if (fade) arrow.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });
|
|
73
57
|
}
|
|
74
|
-
// Shown again, so it is above a dialog the page opened since.
|
|
75
|
-
try { cursor.host.hidePopover(); cursor.host.showPopover(); } catch {}
|
|
76
58
|
if (action === 'move') {
|
|
77
59
|
const from = `translate(${cursor.x}px, ${cursor.y}px)`;
|
|
78
60
|
const to = `translate(${x}px, ${y}px)`;
|
|
@@ -143,16 +125,6 @@ function cursorOf(p) {
|
|
|
143
125
|
return tabState(p).cursor || null;
|
|
144
126
|
}
|
|
145
127
|
|
|
146
|
-
// A new document drops the cursor: it is drawn again where it was.
|
|
147
|
-
function keepAcrossPages(p) {
|
|
148
|
-
if (tabState(p).cursorKept) return;
|
|
149
|
-
tabState(p).cursorKept = true;
|
|
150
|
-
p.on('domcontentloaded', () => {
|
|
151
|
-
const cursor = cursorOf(p);
|
|
152
|
-
if (cursor) p.evaluate(pageCursor, { x: cursor.x, y: cursor.y, action: 'draw' }).catch(() => {});
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
128
|
// Glides the tab's cursor to a point, if it is on, and waits for it to get there.
|
|
157
129
|
async function glideTo(p, x, y) {
|
|
158
130
|
const cursor = cursorOf(p);
|
|
@@ -162,7 +134,7 @@ async function glideTo(p, x, y) {
|
|
|
162
134
|
if (ms) cursorStep(p, `(cursor from ${cursor.x} ${cursor.y})`, { x, y }, ms);
|
|
163
135
|
cursor.x = x;
|
|
164
136
|
cursor.y = y;
|
|
165
|
-
await p
|
|
137
|
+
await inOverlay(p, pageCursor, { x, y, action: 'move', ms }).catch(() => {});
|
|
166
138
|
}
|
|
167
139
|
|
|
168
140
|
// Whether a command should find its element first: to glide the cursor there, or, while recording, to
|
|
@@ -235,7 +207,7 @@ async function aimAt(p, target, timeout) {
|
|
|
235
207
|
async function showClick(p, count = 1) {
|
|
236
208
|
const cursor = cursorOf(p);
|
|
237
209
|
if (!cursor) return;
|
|
238
|
-
await p
|
|
210
|
+
await inOverlay(p, pageCursor, { x: cursor.x, y: cursor.y, action: 'click', count }).catch(() => {});
|
|
239
211
|
}
|
|
240
212
|
|
|
241
213
|
// select with the cursor on: a native select's list opens outside the page, where no recording (or
|
|
@@ -257,17 +229,17 @@ async function showSelect(p, target, value, act) {
|
|
|
257
229
|
if (!before || before.chosen < 0 || before.chosen >= 10) return act();
|
|
258
230
|
const pace = recordingOf(p) ? 1 : 0.5;
|
|
259
231
|
await showClick(p);
|
|
260
|
-
const opened = await p
|
|
232
|
+
const opened = await inOverlay(p, pageCursor, { action: 'pickerOpen', box, items: before.items, index: before.index }).catch(() => null);
|
|
261
233
|
if (!opened) return act();
|
|
262
234
|
await sleep(350 * pace);
|
|
263
235
|
await glideTo(p, Math.round(cursor.x), Math.round(opened.rowY + before.chosen * opened.rowHeight));
|
|
264
|
-
await p
|
|
236
|
+
await inOverlay(p, pageCursor, { action: 'pickerChoose', index: before.chosen }).catch(() => {});
|
|
265
237
|
await showClick(p);
|
|
266
238
|
try {
|
|
267
239
|
return await act();
|
|
268
240
|
} finally {
|
|
269
241
|
await sleep(250 * pace);
|
|
270
|
-
await p
|
|
242
|
+
await inOverlay(p, pageCursor, { action: 'pickerClose' }).catch(() => {});
|
|
271
243
|
}
|
|
272
244
|
}
|
|
273
245
|
|
|
@@ -276,7 +248,8 @@ async function cursorOff(p) {
|
|
|
276
248
|
const { x, y } = cursorOf(p);
|
|
277
249
|
delete tabState(p).cursor;
|
|
278
250
|
cursorStep(p, '(cursor off)', { x, y }, 0);
|
|
279
|
-
|
|
251
|
+
forget(p, 'cursor');
|
|
252
|
+
await inOverlay(p, pageCursor, { action: 'remove', fade: true }, false).catch(() => {});
|
|
280
253
|
return true;
|
|
281
254
|
}
|
|
282
255
|
|
|
@@ -316,13 +289,25 @@ const commands = {
|
|
|
316
289
|
} else if (where) {
|
|
317
290
|
const target = p.locator(toSelector(unquote(where))).first();
|
|
318
291
|
try {
|
|
319
|
-
await target.
|
|
292
|
+
await target.waitFor({ state: 'visible', timeout: 5000 });
|
|
320
293
|
} catch {
|
|
321
294
|
throw new Error(`No element matches ${where} that can be shown (waited 5s); ${still}`);
|
|
322
295
|
}
|
|
323
|
-
|
|
296
|
+
// Only pointing: an element partly in view is pointed at where it shows, not scrolled, so that
|
|
297
|
+
// one the page cuts off still looks cut off (a bug in a video). One wholly out of view is scrolled to.
|
|
298
|
+
const view = await p.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
299
|
+
const inView = b => b && b.x < view.width && b.y < view.height && b.x + b.width > 0 && b.y + b.height > 0;
|
|
300
|
+
let box = await target.boundingBox();
|
|
301
|
+
if (!inView(box)) {
|
|
302
|
+
await target.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
|
|
303
|
+
box = await target.boundingBox();
|
|
304
|
+
}
|
|
324
305
|
if (!box) throw new Error(`${where} is not on the page to show the cursor at; ${still}`);
|
|
325
|
-
|
|
306
|
+
const left = Math.max(box.x, 0);
|
|
307
|
+
const top = Math.max(box.y, 0);
|
|
308
|
+
const right = Math.min(box.x + box.width, view.width);
|
|
309
|
+
const bottom = Math.min(box.y + box.height, view.height);
|
|
310
|
+
at = { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
|
|
326
311
|
} else {
|
|
327
312
|
at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
|
|
328
313
|
}
|
|
@@ -332,10 +317,11 @@ const commands = {
|
|
|
332
317
|
out.log(`The cursor moved to ${at.x}, ${at.y}`);
|
|
333
318
|
return;
|
|
334
319
|
}
|
|
335
|
-
await p
|
|
320
|
+
await inOverlay(p, pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
|
|
336
321
|
tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
|
|
337
322
|
cursorStep(p, '(cursor on)', at, 0);
|
|
338
|
-
|
|
323
|
+
// A new document drops it: it is drawn again where it was.
|
|
324
|
+
keepAcrossPages(p, 'cursor', async () => { const c = cursorOf(p); if (c) await inOverlay(p, pageCursor, { x: c.x, y: c.y, action: 'draw' }); });
|
|
339
325
|
out.log(`The cursor is on, at ${at.x}, ${at.y}: commands on elements and the mouse glide it there first, and clicks show where they land; cursor off hides it`);
|
|
340
326
|
},
|
|
341
327
|
};
|