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