pw-repl 0.6.1 → 0.7.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 -2
- package/README.md +3 -2
- package/bin/pw-repl.js +10 -1
- package/lib/cli-names.js +13 -3
- package/lib/commands.js +4 -0
- package/lib/emulation.js +52 -9
- package/lib/help.js +81 -15
- package/lib/inspect.js +5 -4
- package/lib/modes.js +18 -2
- package/lib/record.js +392 -0
- package/lib/runner.js +1 -1
- package/lib/tabstate.js +2 -0
- package/package.json +1 -1
- package/skill/SKILL.md +6 -7
package/AGENTS.md
CHANGED
|
@@ -15,8 +15,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
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), `lib/inspect.js` (reading the
|
|
17
17
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
18
|
-
`lib/routes.js`, `lib/network.js`, `lib/emulation.js
|
|
19
|
-
`lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
|
|
18
|
+
`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
20
|
session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
|
|
21
21
|
`lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
|
|
22
22
|
- `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
|
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
98
98
|
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
99
99
|
| see each step as I click | `watch on --live` |
|
|
100
100
|
| record requests and console together | `capture on`, then `capture off` |
|
|
101
|
+
| record a video of the page | `record on`, then `record off` (`help video` for the other ways) |
|
|
101
102
|
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
102
103
|
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
103
104
|
| slow the whole network | `network slow` |
|
|
@@ -130,8 +131,8 @@ pw-repl differs, it is because someone else may be using the browser:
|
|
|
130
131
|
- `watch` records what a person does as steps with their requests, not as generated code.
|
|
131
132
|
|
|
132
133
|
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
133
|
-
playwright-cli is the better fit.
|
|
134
|
-
see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
|
|
134
|
+
playwright-cli is the better fit. `record on` records a tab's page, without the pointer; for the shared
|
|
135
|
+
browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
|
|
135
136
|
|
|
136
137
|
## From scripts and agents
|
|
137
138
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -50,7 +50,8 @@ In a tmux session named playwright-repl, pw-repl send reaches pw-repl run withou
|
|
|
50
50
|
|
|
51
51
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
52
52
|
|
|
53
|
-
pw-repl help <topic | command | --all> shows one part of it
|
|
53
|
+
pw-repl help <topic | command | --all> shows one part of it; pw-repl help video and pw-repl help
|
|
54
|
+
playwright-cli are guides.`;
|
|
54
55
|
|
|
55
56
|
function usage() {
|
|
56
57
|
console.error(USAGE);
|
|
@@ -108,6 +109,14 @@ function parseSendArgs(args, allowCommand) {
|
|
|
108
109
|
// So are the files upload reads and screenshot --filename writes.
|
|
109
110
|
const resolve = require('path').resolve;
|
|
110
111
|
let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
|
|
112
|
+
// record on <file> writes it too.
|
|
113
|
+
if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
|
|
114
|
+
resolved = words.map((w, n) => {
|
|
115
|
+
if (n < (words[0] === 'record' ? 2 : 1)) return w;
|
|
116
|
+
if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
|
|
117
|
+
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
111
120
|
if (words[0] === 'screenshot') {
|
|
112
121
|
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
113
122
|
}
|
package/lib/cli-names.js
CHANGED
|
@@ -31,6 +31,9 @@ const NAMES = {
|
|
|
31
31
|
unroute: [a => (a ? `route off ${a}` : 'route off --all'), 'route off <glob>|--all'],
|
|
32
32
|
request: [a => `body ${a}`, 'body <#>, the response body only'],
|
|
33
33
|
'response-body': [a => `body ${a}`, 'body <#>'],
|
|
34
|
+
// The file is named when recording starts here.
|
|
35
|
+
'video-start': [a => `record on ${a}`.trim(), 'record on [file.webm|file.mp4]; --filename=<file> is the file'],
|
|
36
|
+
'video-stop': [a => `record off ${a}`.trim(), 'record off [file]'],
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
// Storage by eval: a key is the first word, quoted if it has spaces, and a value the rest.
|
|
@@ -88,8 +91,8 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
|
|
|
88
91
|
NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
|
|
89
92
|
}
|
|
90
93
|
for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
|
|
91
|
-
for (const name of ['video-
|
|
92
|
-
NEAREST[name] = 'not here:
|
|
94
|
+
for (const name of ['video-chapter', 'video-show-actions', 'video-hide-actions']) {
|
|
95
|
+
NEAREST[name] = 'not here: record on|off records a video, without chapters or action labels';
|
|
93
96
|
}
|
|
94
97
|
for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
|
|
95
98
|
|
|
@@ -277,4 +280,11 @@ function table() {
|
|
|
277
280
|
return [...lines, '', 'Not here as such:', ...nearest].join('\n');
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
|
|
283
|
+
// What help says for a playwright-cli name: what it is here, or why it is not.
|
|
284
|
+
function describe(name) {
|
|
285
|
+
if (Object.hasOwn(NAMES, name)) return { as: NAMES[name][1] };
|
|
286
|
+
if (Object.hasOwn(NEAREST, name)) return { nearest: NEAREST[name] };
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { translate, table, takeOptions, describe, NAME_LIST };
|
package/lib/commands.js
CHANGED
|
@@ -11,6 +11,7 @@ const { commands: routesCommands, routesFor } = require('./routes');
|
|
|
11
11
|
const { commands: networkCommands } = require('./network');
|
|
12
12
|
const { commands: emulationCommands, EMULATE_KINDS } = require('./emulation');
|
|
13
13
|
const { commands: captureCommands } = require('./capture');
|
|
14
|
+
const { commands: recordCommands } = require('./record');
|
|
14
15
|
const { commands: inspectCommands } = require('./inspect');
|
|
15
16
|
const { commands: watchCommands, nextTabWanted, waitingForMe, watchNewTab } = require('./watch');
|
|
16
17
|
const { commands: tabsCommands, isOpening } = require('./tabs');
|
|
@@ -27,6 +28,7 @@ const commands = {
|
|
|
27
28
|
...networkCommands,
|
|
28
29
|
...emulationCommands,
|
|
29
30
|
...captureCommands,
|
|
31
|
+
...recordCommands,
|
|
30
32
|
...dialogsCommands,
|
|
31
33
|
...modesCommands,
|
|
32
34
|
|
|
@@ -110,6 +112,8 @@ function complete(line) {
|
|
|
110
112
|
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
111
113
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
112
114
|
if (command === 'capture') return match(['on', 'off']);
|
|
115
|
+
if (command === 'record') return match(['on', 'off']);
|
|
116
|
+
if (command === 'viewport') return match(['off']);
|
|
113
117
|
if (command === 'route') return match(['off']);
|
|
114
118
|
}
|
|
115
119
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
|
package/lib/emulation.js
CHANGED
|
@@ -17,7 +17,7 @@ async function viewportText() {
|
|
|
17
17
|
const wider = laidOut && laidOut > width ? `; the page lays out ${laidOut} wide, shown shrunk: it has no viewport meta tag` : '';
|
|
18
18
|
return `${width}x${height} (emulate mobile: ${device}${wider})`;
|
|
19
19
|
}
|
|
20
|
-
const set = state.page.
|
|
20
|
+
const set = tabState(state.page).viewport;
|
|
21
21
|
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
22
22
|
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
23
23
|
return size ? `${size} (the window's size)` : 'unknown';
|
|
@@ -49,6 +49,35 @@ function deviceText(name) {
|
|
|
49
49
|
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// A viewport set with viewport, on the kept session, as a phone's size is, so that viewport off can
|
|
53
|
+
// undo it: Playwright's setViewportSize has no undo. As Playwright sets it, at one device pixel per
|
|
54
|
+
// CSS pixel, and the window is left as it is.
|
|
55
|
+
function viewportMetrics(session, { width, height }) {
|
|
56
|
+
return session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Chrome answers a change of screen size before the page has it (a clear, often), so the size read
|
|
60
|
+
// next, a screenshot or a recording would be the old one: two frames later it has it. Bounded, as a
|
|
61
|
+
// dialog holds the page.
|
|
62
|
+
function settled(p) {
|
|
63
|
+
return Promise.race([
|
|
64
|
+
p.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))).catch(() => {}),
|
|
65
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function setViewport(p, size) {
|
|
70
|
+
const session = await keptSession(p);
|
|
71
|
+
if (size) {
|
|
72
|
+
await viewportMetrics(session, size);
|
|
73
|
+
tabState(p).viewport = { ...size, owner: state.client };
|
|
74
|
+
} else {
|
|
75
|
+
await session.send('Emulation.clearDeviceMetricsOverride');
|
|
76
|
+
delete tabState(p).viewport;
|
|
77
|
+
}
|
|
78
|
+
await settled(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
// Sends only what changed. Chrome takes the user agent and the languages
|
|
53
82
|
// together, so a change to either sends both.
|
|
54
83
|
function deviceMetrics(session, name) {
|
|
@@ -71,6 +100,7 @@ async function setEmulation(p, next) {
|
|
|
71
100
|
await send('Emulation.clearDeviceMetricsOverride');
|
|
72
101
|
}
|
|
73
102
|
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
103
|
+
await settled(p);
|
|
74
104
|
}
|
|
75
105
|
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
76
106
|
if (next.device || next.locale) {
|
|
@@ -111,23 +141,34 @@ function showEmulation() {
|
|
|
111
141
|
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
112
142
|
}
|
|
113
143
|
|
|
114
|
-
// Resets every tab's emulation, which detaching would only partly undo.
|
|
144
|
+
// Resets every tab's emulation and viewport, which detaching would only partly undo.
|
|
115
145
|
async function resetEmulations() {
|
|
116
146
|
if (!state.browser || state.connectionLost) return;
|
|
117
|
-
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p =>
|
|
118
|
-
await Promise.all(pages.map(
|
|
147
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
148
|
+
await Promise.all(pages.map(async p => {
|
|
149
|
+
if ('emulation' in tabState(p)) await setEmulation(p, {}).catch(() => {});
|
|
150
|
+
if ('viewport' in tabState(p)) await setViewport(p, null).catch(() => {});
|
|
151
|
+
}));
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
onShutdown(resetEmulations);
|
|
122
155
|
|
|
123
156
|
const commands = {
|
|
124
157
|
async viewport(args) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
158
|
+
const arg = (args || '').trim();
|
|
159
|
+
if (!arg) { out.log(await viewportText()); return; }
|
|
160
|
+
if (arg === 'off') {
|
|
161
|
+
if (!tabState(state.page).viewport) { out.log(`No viewport is set in the selected tab: ${await viewportText()}`); return; }
|
|
162
|
+
await setViewport(state.page, null);
|
|
163
|
+
out.log(`Viewport off: ${await viewportText()}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const match = /^(\d+)x(\d+)$/.exec(arg);
|
|
167
|
+
const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
|
|
168
|
+
if (!w || !h) throw new Error('Usage: viewport [<width>x<height> | off]');
|
|
128
169
|
// Both set the screen size; whichever came last would win without saying so.
|
|
129
170
|
if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
130
|
-
await state.page
|
|
171
|
+
await setViewport(state.page, { width: w, height: h });
|
|
131
172
|
out.log(`Viewport set to ${w}x${h}`);
|
|
132
173
|
},
|
|
133
174
|
|
|
@@ -156,6 +197,8 @@ const commands = {
|
|
|
156
197
|
}
|
|
157
198
|
let next;
|
|
158
199
|
if (what === 'mobile') {
|
|
200
|
+
// One screen size at a time, as viewport refuses while a phone's is set.
|
|
201
|
+
if (tabState(state.page).viewport) throw new Error('viewport sets the size of the selected tab; viewport off first');
|
|
159
202
|
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
160
203
|
} else if (what === 'dark' || what === 'light') {
|
|
161
204
|
if (value) throw new Error(usage);
|
|
@@ -177,4 +220,4 @@ const commands = {
|
|
|
177
220
|
},
|
|
178
221
|
};
|
|
179
222
|
|
|
180
|
-
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, setEmulation };
|
|
223
|
+
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport };
|
package/lib/help.js
CHANGED
|
@@ -38,14 +38,15 @@ Common tasks:
|
|
|
38
38
|
see each step as I click watch on --live
|
|
39
39
|
watch a tab someone will open watch on --next-tab [url-part]
|
|
40
40
|
requests and console together capture on, then capture off
|
|
41
|
+
a video of the page record on, then record off
|
|
41
42
|
break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
|
|
42
43
|
change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
|
|
43
44
|
slow the whole network network slow
|
|
44
45
|
clean up modes off
|
|
45
46
|
|
|
46
|
-
Modes (watch, capture, route, network off or slow, emulate) stay on until turned off;
|
|
47
|
-
the selected tab's: (watch routes:1) pw>. tab, watch, capture, route, network,
|
|
48
|
-
their own show their state and what you can run next.
|
|
47
|
+
Modes (watch, capture, record, route, network off or slow, emulate, viewport) stay on until turned off;
|
|
48
|
+
the prompt shows the selected tab's: (watch routes:1) pw>. tab, watch, capture, record, route, network,
|
|
49
|
+
emulate and modes on their own show their state and what you can run next.
|
|
49
50
|
|
|
50
51
|
Topics (help <topic>):
|
|
51
52
|
tabs open, select, close, navigate network requests, bodies, console, fakes, network off
|
|
@@ -55,7 +56,9 @@ Topics (help <topic>):
|
|
|
55
56
|
help <command> for usage and caveats (e.g. help route); help --all for everything.
|
|
56
57
|
|
|
57
58
|
Know playwright-cli? Its command names work here too (tab-list, go-back, set-color-scheme, route
|
|
58
|
-
--status=...); help playwright-cli lists them
|
|
59
|
+
--status=...); help playwright-cli lists them.
|
|
60
|
+
|
|
61
|
+
Recording video? help video says which way suits what you want.`);
|
|
59
62
|
|
|
60
63
|
const TOPICS = {
|
|
61
64
|
tabs: {
|
|
@@ -71,15 +74,15 @@ fill for selectors with spaces. mousemove and the other mouse commands act at a
|
|
|
71
74
|
},
|
|
72
75
|
inspect: {
|
|
73
76
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
74
|
-
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
77
|
+
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
75
78
|
},
|
|
76
79
|
network: {
|
|
77
80
|
intro: `requests and console record all the time; a capture records only while it runs.`,
|
|
78
81
|
commands: ['requests', 'body', 'console', 'capture', 'route', 'network'],
|
|
79
82
|
},
|
|
80
83
|
devtools: {
|
|
81
|
-
intro: `Cookie and storage listings omit values. eval, cdp, html, screenshots and URLs can
|
|
82
|
-
sensitive data (cdp Network.getCookies shows cookie values).`,
|
|
84
|
+
intro: `Cookie and storage listings omit values. eval, cdp, html, screenshots, recordings and URLs can
|
|
85
|
+
still show sensitive data (cdp Network.getCookies shows cookie values).`,
|
|
83
86
|
commands: ['eval', 'cdp', 'cookies', 'storage'],
|
|
84
87
|
},
|
|
85
88
|
session: {
|
|
@@ -219,7 +222,7 @@ out; --full shows it unchanged.
|
|
|
219
222
|
case), each with the named elements around it. A hit with no named element around it prints without
|
|
220
223
|
a path. A hit on text (a label) keeps the ref of the element holding it, so snapshot <that ref>
|
|
221
224
|
shows what sits beside it, such as the value next to the label. --regex <pattern> is --grep by a
|
|
222
|
-
regular expression: snapshot --regex "Total: \\d+".
|
|
225
|
+
regular expression: snapshot --regex "Total: \\d+". Refs belong to the tab whose snapshot gave them.
|
|
223
226
|
|
|
224
227
|
An element with no role or name of its own (a card that is only a div) is left out, with its ref;
|
|
225
228
|
snapshot --full keeps it.
|
|
@@ -299,7 +302,42 @@ phone shows: a page laid out wider than the phone (no viewport meta tag) comes o
|
|
|
299
302
|
|
|
300
303
|
--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.`,
|
|
301
304
|
},
|
|
302
|
-
|
|
305
|
+
record: {
|
|
306
|
+
usage: 'record [on [file] [seconds] | off [file]]',
|
|
307
|
+
summary: 'record the selected tab\'s page to a video',
|
|
308
|
+
detail: `record on starts recording the selected tab, and record off stops it and saves the file: its
|
|
309
|
+
path, length, size and bytes. record on its own, or record off when nothing is recording, says whether the
|
|
310
|
+
selected tab is recording, and how your last recording ended (record off, its seconds, its tab closing
|
|
311
|
+
or the REPL stopping) with what record off would have said.
|
|
312
|
+
|
|
313
|
+
record off <file> saves it under that name instead. --filename=<file> is file too, as playwright-cli
|
|
314
|
+
writes it.
|
|
315
|
+
|
|
316
|
+
The file is a .webm, or an .mp4 if its name ends so, absolute or relative to the folder pw-repl send runs in, and
|
|
317
|
+
owner-only, as a screenshot is; it never replaces a file. Without one, it is recording-<timestamp>.webm where screenshot saves. With seconds,
|
|
318
|
+
it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab.
|
|
319
|
+
|
|
320
|
+
It brings the tab to the front of its window first, as screenshot does. It records the page only,
|
|
321
|
+
from inside the browser, at the page's size in CSS pixels, made even (an
|
|
322
|
+
odd width or height loses a pixel), a phone's too under emulate mobile: a Pixel 7's is 412 wide, less
|
|
323
|
+
sharp than the phone draws it. It has no pointer and no browser toolbars,
|
|
324
|
+
and a tab behind another, hidden or headless keeps recording. Set the size first with viewport
|
|
325
|
+
(1280x720 fills a 16:9 player); a page that changes size while recording is fitted into the first size.
|
|
326
|
+
Chrome sends a frame as the page draws, and a still page keeps its real length.
|
|
327
|
+
|
|
328
|
+
It needs ffmpeg: $PW_FFMPEG, then Playwright's own (npx playwright-core install ffmpeg), then one on the
|
|
329
|
+
PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. WebM plays in browsers,
|
|
330
|
+
GitHub, GitLab and Slack; MP4 plays everywhere. It takes about 15 MB a minute.
|
|
331
|
+
|
|
332
|
+
For a video with the pointer, or of the person's screen, help video compares the other ways.`,
|
|
333
|
+
},
|
|
334
|
+
viewport: {
|
|
335
|
+
usage: 'viewport [WxH | off]',
|
|
336
|
+
summary: 'show, set or unset the viewport size',
|
|
337
|
+
detail: `viewport 1280x720 sets the selected tab's page to that size, until viewport off, modes off or the
|
|
338
|
+
REPL exits. The window is not resized. viewport on its own shows the size, and whether it was set or is
|
|
339
|
+
the window's. emulate mobile sets a phone's size instead, and each refuses while the other is on.`,
|
|
340
|
+
},
|
|
303
341
|
emulate: {
|
|
304
342
|
usage: 'emulate [<what> [off] | off]',
|
|
305
343
|
summary: 'emulate a phone, dark mode, a locale or a timezone',
|
|
@@ -312,10 +350,12 @@ emulate locale <tag> the language (Accept-Language, navigator.language) and
|
|
|
312
350
|
emulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo
|
|
313
351
|
emulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all
|
|
314
352
|
|
|
315
|
-
Per tab, until turned off or the REPL exits
|
|
353
|
+
Per tab, until turned off or the REPL exits; emulate mobile <device> while another is on switches to it.
|
|
354
|
+
emulate on its own shows what is on.
|
|
316
355
|
|
|
317
356
|
The page sees the user agent, touch and navigator.languages from its next load: reload after
|
|
318
|
-
turning mobile or locale on or off.
|
|
357
|
+
turning mobile or locale on or off. Touch means the page sees a touch screen; click still clicks
|
|
358
|
+
with the mouse, and sends no touch events. Dark, light and timezone apply at once, but what the page
|
|
319
359
|
already drew with them (times, colors set by its script) changes only when it reloads.
|
|
320
360
|
|
|
321
361
|
While mobile is on, viewport refuses: the device sets the size.`,
|
|
@@ -460,11 +500,12 @@ Browser.close and Target.closeTarget are refused.`,
|
|
|
460
500
|
modes: {
|
|
461
501
|
usage: 'modes [off [--mine]]',
|
|
462
502
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
463
|
-
detail: `The modes are watch, network off or slow, route, capture and
|
|
464
|
-
its own command: watch on|off, network off|slow|on, route <glob> ... | route off
|
|
465
|
-
capture on|off, emulate ... | emulate off.
|
|
503
|
+
detail: `The modes are watch, network off or slow, route, capture, record, emulate and viewport. Each is
|
|
504
|
+
turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... | route off
|
|
505
|
+
<glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> | viewport off.
|
|
466
506
|
|
|
467
|
-
modes off turns off every one in every tab; a capture it stops is kept for capture to show
|
|
507
|
+
modes off turns off every one in every tab; a capture it stops is kept for capture to show, and a
|
|
508
|
+
recording it stops is saved, as record off would. With
|
|
468
509
|
clients (help session), modes says which client turned each on, and modes off --mine turns off only
|
|
469
510
|
the sender's own.`,
|
|
470
511
|
},
|
|
@@ -510,6 +551,22 @@ function renderAll() {
|
|
|
510
551
|
return [OVERVIEW, ...sections].join('\n\n');
|
|
511
552
|
}
|
|
512
553
|
|
|
554
|
+
// Not a command: the ways to record a video, since none is right for every use.
|
|
555
|
+
const VIDEO = `There are three ways to record a video of a page, and each gives something up.
|
|
556
|
+
|
|
557
|
+
record on (help record) records the selected tab's page from inside the browser: headless, hidden and
|
|
558
|
+
background tabs too, with nothing to set up but ffmpeg. It has no pointer and no browser toolbars. For
|
|
559
|
+
quick clips, and for an agent recording its own work.
|
|
560
|
+
|
|
561
|
+
A screen recorder on the person's machine (OBS, ffmpeg -f x11grab, macOS screencapture -v) records their
|
|
562
|
+
screen as they see it, pointer included, smooth and in real time. It records the screen or a window, not
|
|
563
|
+
a tab: size the window, and crop off the toolbars, for just the page. Ask before starting one, or have
|
|
564
|
+
them start it. It cannot see a headless browser: for a browser of the REPL's own, --launch --headed on
|
|
565
|
+
a screen. For a polished video of a person's browser.
|
|
566
|
+
|
|
567
|
+
Microsoft's playwright-cli (the npm package @playwright/cli; video-start and video-stop) records a
|
|
568
|
+
browser of its own, not this one, which starts logged out. For a scripted flow in a clean browser.`;
|
|
569
|
+
|
|
513
570
|
function render(topicOrCommand) {
|
|
514
571
|
if (!topicOrCommand) return OVERVIEW;
|
|
515
572
|
if (topicOrCommand === 'playwright-cli') {
|
|
@@ -530,6 +587,7 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
530
587
|
no snapshot after each command a person reads the pane too; snapshot when you need one
|
|
531
588
|
-s=<session> -c <name> is the nearest: a client with a selected tab of its own`);
|
|
532
589
|
}
|
|
590
|
+
if (topicOrCommand === 'video') return wrap(VIDEO);
|
|
533
591
|
if (topicOrCommand === '--all') return renderAll();
|
|
534
592
|
if (Object.hasOwn(TOPICS, topicOrCommand)) {
|
|
535
593
|
const topic = TOPICS[topicOrCommand];
|
|
@@ -538,6 +596,14 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
538
596
|
return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${renderCommand(topicOrCommand)}` : text;
|
|
539
597
|
}
|
|
540
598
|
if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
|
|
599
|
+
// A playwright-cli name: what it runs here, with that command's help.
|
|
600
|
+
const cli = require('./cli-names').describe(topicOrCommand);
|
|
601
|
+
if (cli?.nearest) return wrap(`${topicOrCommand} is playwright-cli's; ${cli.nearest}`);
|
|
602
|
+
if (cli) {
|
|
603
|
+
const here = cli.as.split(/\s/)[0];
|
|
604
|
+
const intro = wrap(`${topicOrCommand} is playwright-cli's name for ${cli.as} here.`);
|
|
605
|
+
return Object.hasOwn(COMMANDS, here) ? `${intro}\n\n${renderCommand(here)}` : intro;
|
|
606
|
+
}
|
|
541
607
|
return null;
|
|
542
608
|
}
|
|
543
609
|
|
package/lib/inspect.js
CHANGED
|
@@ -11,7 +11,7 @@ const { tabState } = require('./tabstate');
|
|
|
11
11
|
const { keptSession } = require('./cdp');
|
|
12
12
|
const { onElement, soleSelector } = require('./elements');
|
|
13
13
|
const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
14
|
-
const { deviceMetrics } = require('./emulation');
|
|
14
|
+
const { deviceMetrics, viewportMetrics } = require('./emulation');
|
|
15
15
|
|
|
16
16
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
17
17
|
|
|
@@ -283,11 +283,12 @@ const commands = {
|
|
|
283
283
|
}
|
|
284
284
|
} finally {
|
|
285
285
|
// Playwright's screenshot resets the screen size and pixel ratio it
|
|
286
|
-
// finds; an emulated phone's are set again.
|
|
287
|
-
|
|
286
|
+
// finds; an emulated phone's, or a viewport's, are set again.
|
|
287
|
+
const viewport = tabState(state.page).viewport;
|
|
288
|
+
if (device || viewport) {
|
|
288
289
|
const session = await keptSession(state.page);
|
|
289
290
|
await session.send('Emulation.clearDeviceMetricsOverride');
|
|
290
|
-
await deviceMetrics(session, device);
|
|
291
|
+
await (device ? deviceMetrics(session, device) : viewportMetrics(session, viewport));
|
|
291
292
|
}
|
|
292
293
|
}
|
|
293
294
|
try {
|
package/lib/modes.js
CHANGED
|
@@ -5,7 +5,8 @@ const { hints } = require('./util');
|
|
|
5
5
|
const { tabState } = require('./tabstate');
|
|
6
6
|
const { unroute } = require('./routes');
|
|
7
7
|
const { setNetwork } = require('./network');
|
|
8
|
-
const { emulatedKinds, setEmulation } = require('./emulation');
|
|
8
|
+
const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
|
|
9
|
+
const { recordingOf, finishRecording } = require('./record');
|
|
9
10
|
const { currentCapture, endCapture } = require('./capture');
|
|
10
11
|
const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
|
|
11
12
|
|
|
@@ -17,9 +18,12 @@ function activeModes(p) {
|
|
|
17
18
|
if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
|
|
18
19
|
const emulated = emulatedKinds(tabState(p).emulation);
|
|
19
20
|
if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
|
|
21
|
+
const viewport = tabState(p).viewport;
|
|
22
|
+
if (viewport) modes.push(`viewport:${viewport.width}x${viewport.height}`);
|
|
20
23
|
const routes = tabState(p).routes?.size;
|
|
21
24
|
if (routes) modes.push(`routes:${routes}`);
|
|
22
25
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
26
|
+
if (recordingOf(p)) modes.push('record');
|
|
23
27
|
return modes;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -47,7 +51,11 @@ function listModes() {
|
|
|
47
51
|
const owners = modeOwners(p);
|
|
48
52
|
if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
|
|
49
53
|
}
|
|
50
|
-
|
|
54
|
+
// With clients' modes among them, the sender's own first: modes off turns off everyone's.
|
|
55
|
+
const clients = on.some(([p]) => modeOwners(p).some(([owner]) => owner));
|
|
56
|
+
out.log(hints(clients && state.client
|
|
57
|
+
? [['modes off --mine', 'turn off the ones you turned on'], ['modes off', 'turn them all off, everyone\'s']]
|
|
58
|
+
: [['modes off', 'turn them all off']]));
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
// Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
|
|
@@ -59,9 +67,13 @@ function modeOwners(p) {
|
|
|
59
67
|
if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
|
|
60
68
|
const emulated = tabState(p).emulation;
|
|
61
69
|
if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
|
|
70
|
+
const viewport = tabState(p).viewport;
|
|
71
|
+
if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
|
|
62
72
|
for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
63
73
|
const capture = currentCapture();
|
|
64
74
|
if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
|
|
75
|
+
const recording = recordingOf(p);
|
|
76
|
+
if (recording) owners.push([recording.owner ?? null, 'record']);
|
|
65
77
|
return owners;
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -92,7 +104,11 @@ async function allModesOff(mine = false) {
|
|
|
92
104
|
const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
93
105
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
94
106
|
if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
107
|
+
// Before the page's size is reset, which the recording would otherwise end with.
|
|
108
|
+
const recording = recordingOf(p);
|
|
109
|
+
if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
|
|
95
110
|
if ('emulation' in tabState(p) && ours(tabState(p).emulation.owner)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
111
|
+
if ('viewport' in tabState(p) && ours(tabState(p).viewport.owner)) { await setViewport(p, null); done.push('viewport off'); }
|
|
96
112
|
} catch (error) {
|
|
97
113
|
problem = error.message;
|
|
98
114
|
}
|
package/lib/record.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Recording a tab's page to a video, from Chrome's screencast of it through ffmpeg.
|
|
2
|
+
const { spawn, execFileSync } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { state, onShutdown } = require('./state');
|
|
7
|
+
const out = require('./output');
|
|
8
|
+
const { tabState } = require('./tabstate');
|
|
9
|
+
const { clock, NO_TAB } = require('./util');
|
|
10
|
+
|
|
11
|
+
const FPS = 25;
|
|
12
|
+
const MAX_SECONDS = 3600;
|
|
13
|
+
// Frames waiting for ffmpeg past this are dropped rather than held in memory.
|
|
14
|
+
const MAX_QUEUED = 32 * 1024 * 1024;
|
|
15
|
+
// How long ffmpeg may go without progress on the frames still queued for it before it is stopped,
|
|
16
|
+
// and the file may be cut short; at shutdown, how long it may take at all, within the REPL's cleanup.
|
|
17
|
+
const FINISH_TIMEOUT = 5000;
|
|
18
|
+
const SHUTDOWN_FINISH_TIMEOUT = 3000;
|
|
19
|
+
const FIRST_FRAME_TIMEOUT = 2000;
|
|
20
|
+
const INSTALL = 'npx playwright-core install ffmpeg (WebM only), or ffmpeg from your system\'s packages';
|
|
21
|
+
// One thread each: ffmpeg's default of one per core thrashes on a busy machine (a 720p WebM took
|
|
22
|
+
// 26s of work for 10s of video with 20 cores, and 1s with one thread), and it shares the machine
|
|
23
|
+
// with the browser it records.
|
|
24
|
+
const FORMATS = {
|
|
25
|
+
webm: { encoder: 'libvpx', args: ['-c:v', 'vp8', '-b:v', '2M', '-g', String(FPS * 2), '-deadline', 'realtime', '-cpu-used', '8', '-threads', '1'] },
|
|
26
|
+
// Fragmented, so a recording cut short still plays up to its last fragment.
|
|
27
|
+
mp4: { encoder: 'libx264', args: ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-threads', '1', '-g', String(FPS * 2), '-movflags', '+frag_keyframe+empty_moov+default_base_moof'] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// PW_FFMPEG, then Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
|
|
31
|
+
function ffmpegCandidates() {
|
|
32
|
+
const found = [];
|
|
33
|
+
if (process.env.PW_FFMPEG) found.push(process.env.PW_FFMPEG);
|
|
34
|
+
const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
|
|
35
|
+
let names = [];
|
|
36
|
+
try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
for (const exe of ['ffmpeg-linux', 'ffmpeg-mac', 'ffmpeg-win64.exe']) {
|
|
39
|
+
const candidate = path.join(dir, name, exe);
|
|
40
|
+
if (fs.existsSync(candidate)) found.push(candidate);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const folder of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
44
|
+
const candidate = path.join(folder, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
|
45
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); found.push(candidate); } catch {}
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(found)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const encoderLists = new Map();
|
|
51
|
+
|
|
52
|
+
function hasEncoder(exe, encoder) {
|
|
53
|
+
if (!encoderLists.has(exe)) {
|
|
54
|
+
let list = '';
|
|
55
|
+
try { list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }); } catch {}
|
|
56
|
+
encoderLists.set(exe, list);
|
|
57
|
+
}
|
|
58
|
+
return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(encoderLists.get(exe));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The first ffmpeg that can write the format.
|
|
62
|
+
function findFfmpeg(format) {
|
|
63
|
+
const candidates = ffmpegCandidates();
|
|
64
|
+
if (!candidates.length) throw new Error(`record needs ffmpeg, and none was found: ${INSTALL}; PW_FFMPEG names one`);
|
|
65
|
+
const exe = candidates.find(c => hasEncoder(c, FORMATS[format].encoder));
|
|
66
|
+
if (exe) return exe;
|
|
67
|
+
if (format === 'mp4') throw new Error(`No ffmpeg found can write MP4 (H.264): Playwright's writes only WebM. Install ffmpeg from your system's packages, or name one with PW_FFMPEG; or record to a .webm file`);
|
|
68
|
+
throw new Error(`No ffmpeg found can write WebM (VP8): ${INSTALL}; PW_FFMPEG names one`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A JPEG's width and height, from its frame header.
|
|
72
|
+
function jpegSize(buffer) {
|
|
73
|
+
let i = 2;
|
|
74
|
+
while (i + 9 < buffer.length) {
|
|
75
|
+
if (buffer[i] !== 0xff) return null;
|
|
76
|
+
const marker = buffer[i + 1];
|
|
77
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
78
|
+
return { width: buffer.readUInt16BE(i + 7), height: buffer.readUInt16BE(i + 5) };
|
|
79
|
+
}
|
|
80
|
+
i += 2 + buffer.readUInt16BE(i + 2);
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nextRecordingPath() {
|
|
86
|
+
// Where screenshots go, so both are found in one place.
|
|
87
|
+
return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `recording-${Date.now()}.webm`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sizeText(bytes) {
|
|
91
|
+
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Each client's last recording that ended, and how, for record to report: one that ends on
|
|
95
|
+
// its own (its seconds, its tab closing) says so only in the pane.
|
|
96
|
+
const lastEnded = new Map();
|
|
97
|
+
// Each client's recordings still being saved, which record waits for before it reports.
|
|
98
|
+
const saving = new Map();
|
|
99
|
+
|
|
100
|
+
function recordingOf(p) {
|
|
101
|
+
return tabState(p).recording || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The tabs recording now, for the modes they count as.
|
|
105
|
+
function recordingPages() {
|
|
106
|
+
if (!state.browser) return [];
|
|
107
|
+
return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function startRecording(p, file, seconds, format) {
|
|
111
|
+
const exe = findFfmpeg(format);
|
|
112
|
+
// As screenshot does: a tab that has not been in front since it was emulated as a phone can
|
|
113
|
+
// be drawn at the window's size.
|
|
114
|
+
await p.bringToFront();
|
|
115
|
+
// Its own session: Chrome keeps one screencast per session, and the kept session's keeps the tab drawing.
|
|
116
|
+
const session = await p.context().newCDPSession(p);
|
|
117
|
+
const rec = {
|
|
118
|
+
page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
|
|
119
|
+
size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
|
|
120
|
+
ffmpegError: '',
|
|
121
|
+
};
|
|
122
|
+
// The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
|
|
123
|
+
// its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
|
|
124
|
+
// reload or a change of emulation, that can still be the window's.
|
|
125
|
+
const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
|
|
126
|
+
try {
|
|
127
|
+
const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
|
|
128
|
+
rec.size = { width: even(vv.clientWidth * (vv.scale || 1)), height: even(vv.clientHeight * (vv.scale || 1)) };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
session.detach().catch(() => {});
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
tabState(p).recording = rec;
|
|
134
|
+
let firstFrame;
|
|
135
|
+
const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
|
|
136
|
+
const tick = () => {
|
|
137
|
+
if (!rec.latest || !rec.ff || rec.finishing) return;
|
|
138
|
+
// The video keeps the wall clock: Chrome sends a frame only when the page redraws,
|
|
139
|
+
// so the latest one is repeated while the page is still.
|
|
140
|
+
const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
|
|
141
|
+
for (; rec.written < due; rec.written++) {
|
|
142
|
+
if (rec.ff.stdin.writableLength > MAX_QUEUED) { rec.dropped += 1; continue; }
|
|
143
|
+
rec.ff.stdin.write(rec.latest);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
session.on('Page.screencastFrame', frame => {
|
|
147
|
+
session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
|
|
148
|
+
if (rec.finishing) return;
|
|
149
|
+
const image = Buffer.from(frame.data, 'base64');
|
|
150
|
+
const size = jpegSize(image);
|
|
151
|
+
if (!size) return;
|
|
152
|
+
// A frame the size of the video, to a pixel (Chrome scales it to fit, keeping its shape).
|
|
153
|
+
const fits = Math.abs(size.width - rec.size.width) <= 1 && Math.abs(size.height - rec.size.height) <= 1;
|
|
154
|
+
if (!rec.ff) {
|
|
155
|
+
rec.firstFrameAt = Date.now();
|
|
156
|
+
rec.ff = spawnFfmpeg(rec);
|
|
157
|
+
rec.timer = setInterval(tick, 1000 / FPS);
|
|
158
|
+
firstFrame();
|
|
159
|
+
} else {
|
|
160
|
+
tick();
|
|
161
|
+
// Once the page has been drawn at its size, one of another size means it changed; it is
|
|
162
|
+
// fitted into the video's.
|
|
163
|
+
if (rec.resizedAt === null && rec.fitted && !fits) rec.resizedAt = (Date.now() - rec.firstFrameAt) / 1000;
|
|
164
|
+
}
|
|
165
|
+
if (fits) rec.fitted = true;
|
|
166
|
+
rec.latest = image;
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
await session.send('Page.startScreencast', { format: 'jpeg', quality: 85, maxWidth: rec.size.width, maxHeight: rec.size.height });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
delete tabState(p).recording;
|
|
172
|
+
session.detach().catch(() => {});
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
// Chrome sends the page as it is at once; the recording starts with it. A page that has not
|
|
176
|
+
// drawn yet (a tab still opening) starts it when it does.
|
|
177
|
+
await Promise.race([gotFirstFrame, new Promise(resolve => setTimeout(resolve, FIRST_FRAME_TIMEOUT))]);
|
|
178
|
+
rec.limit = setTimeout(() => {
|
|
179
|
+
finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
|
|
180
|
+
}, seconds * 1000);
|
|
181
|
+
rec.onClose = () => finishRecording(rec, 'Its tab closed, which ended it').then(text => out.notice(text, p));
|
|
182
|
+
p.once('close', rec.onClose);
|
|
183
|
+
return rec;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function spawnFfmpeg(rec) {
|
|
187
|
+
const { width, height } = rec.size;
|
|
188
|
+
// A frame of another size (the viewport or window changed) is scaled to fit, and centred.
|
|
189
|
+
const fit = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`;
|
|
190
|
+
// Warnings too (an empty output is one), kept for a failure's message only.
|
|
191
|
+
const ff = spawn(rec.exe, ['-hide_banner', '-loglevel', 'warning', '-threads', '1', '-filter_threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-r', String(FPS), '-i', 'pipe:0',
|
|
192
|
+
'-vf', fit, ...FORMATS[rec.format].args, '-an', '-y', rec.file], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
193
|
+
ff.stderr.setEncoding('utf8');
|
|
194
|
+
ff.stderr.on('data', chunk => { rec.ffmpegError = (rec.ffmpegError + chunk).slice(-2000); });
|
|
195
|
+
// A write after ffmpeg has gone is reported when the recording finishes, not thrown.
|
|
196
|
+
ff.stdin.on('error', () => {});
|
|
197
|
+
ff.on('error', error => { rec.ffmpegError += error.message; });
|
|
198
|
+
rec.exited = new Promise(resolve => ff.once('close', code => resolve(code)));
|
|
199
|
+
return ff;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
|
|
203
|
+
function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
204
|
+
if (rec.finishing) return rec.finishing;
|
|
205
|
+
const owner = rec.owner ?? null;
|
|
206
|
+
const stoppedAt = Date.now();
|
|
207
|
+
rec.finishing = (async () => {
|
|
208
|
+
const result = await finishFile(rec, shuttingDown);
|
|
209
|
+
const text = typeof result === 'string' ? result : result.text;
|
|
210
|
+
// A recording that saved nothing makes record off fail, rather than report it as done.
|
|
211
|
+
rec.failed = typeof result !== 'string';
|
|
212
|
+
const said = why ? `${why}. ${text}` : text;
|
|
213
|
+
lastEnded.set(owner, { text: said, at: stoppedAt });
|
|
214
|
+
return said;
|
|
215
|
+
})();
|
|
216
|
+
const pending = saving.get(owner) || new Set();
|
|
217
|
+
saving.set(owner, pending.add(rec.finishing));
|
|
218
|
+
rec.finishing.finally(() => pending.delete(rec.finishing)).catch(() => {});
|
|
219
|
+
return rec.finishing;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function finishFile(rec, shuttingDown) {
|
|
223
|
+
clearTimeout(rec.limit);
|
|
224
|
+
if (rec.onClose) rec.page.off('close', rec.onClose);
|
|
225
|
+
if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
|
|
226
|
+
// The last frame runs to now, and a recording has one frame at least.
|
|
227
|
+
if (rec.ff && rec.latest) {
|
|
228
|
+
const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
|
|
229
|
+
for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
|
|
230
|
+
}
|
|
231
|
+
clearInterval(rec.timer);
|
|
232
|
+
await rec.session.send('Page.stopScreencast').catch(() => {});
|
|
233
|
+
rec.session.detach().catch(() => {});
|
|
234
|
+
if (!rec.ff) {
|
|
235
|
+
fs.rmSync(rec.file, { force: true });
|
|
236
|
+
return { failed: true, text: `Nothing was recorded: the tab drew no frame, so no file was saved (${rec.file}).` };
|
|
237
|
+
}
|
|
238
|
+
rec.ff.stdin.end();
|
|
239
|
+
let cut = false;
|
|
240
|
+
// Frames still queued are encoded before the file is done, however long that takes, while
|
|
241
|
+
// ffmpeg keeps getting through them.
|
|
242
|
+
const progress = () => `${rec.ff.stdin.writableLength}:${fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0}`;
|
|
243
|
+
const stalled = new Promise(resolve => {
|
|
244
|
+
if (shuttingDown) { setTimeout(() => resolve('timeout'), SHUTDOWN_FINISH_TIMEOUT); return; }
|
|
245
|
+
let last = progress();
|
|
246
|
+
let since = Date.now();
|
|
247
|
+
const check = setInterval(() => {
|
|
248
|
+
const now = progress();
|
|
249
|
+
if (now !== last) { last = now; since = Date.now(); } else if (Date.now() - since > FINISH_TIMEOUT) { clearInterval(check); resolve('timeout'); }
|
|
250
|
+
}, 250);
|
|
251
|
+
rec.exited.then(() => clearInterval(check));
|
|
252
|
+
});
|
|
253
|
+
const code = await Promise.race([rec.exited, stalled]);
|
|
254
|
+
if (code === 'timeout') { cut = true; rec.ff.kill('SIGKILL'); await rec.exited; }
|
|
255
|
+
const seconds = (rec.written - rec.dropped) / FPS;
|
|
256
|
+
const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
|
|
257
|
+
if (!saved) {
|
|
258
|
+
fs.rmSync(rec.file, { force: true });
|
|
259
|
+
const why = cut ? 'it stopped making progress and was stopped' : `it exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
|
|
260
|
+
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
261
|
+
}
|
|
262
|
+
const notes = [];
|
|
263
|
+
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
264
|
+
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
265
|
+
if (cut) notes.push(`ffmpeg ${shuttingDown ? `did not finish within ${SHUTDOWN_FINISH_TIMEOUT / 1000}s as the REPL stopped` : 'stopped making progress'} and was stopped, so the file is likely cut short of the ${seconds.toFixed(1)}s recorded`);
|
|
266
|
+
else if (code !== 0) notes.push(`ffmpeg exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
|
|
267
|
+
return `Saved: ${rec.file} (${seconds.toFixed(1)}s, ${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Ends every recording, as the REPL stops, and says in the pane and the log what each saved.
|
|
271
|
+
async function finishAll() {
|
|
272
|
+
await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), 'The REPL stopped, which ended a recording', { shuttingDown: true }).then(text => out.log(text), () => {})));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onShutdown(finishAll);
|
|
276
|
+
|
|
277
|
+
function statusText(p) {
|
|
278
|
+
const rec = recordingOf(p);
|
|
279
|
+
if (!rec) {
|
|
280
|
+
const last = lastEnded.get(state.client ?? null);
|
|
281
|
+
return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
|
|
282
|
+
}
|
|
283
|
+
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
284
|
+
const size = rec.size ? `, ${rec.size.width}x${rec.size.height}` : ', no frame yet';
|
|
285
|
+
const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size` : '';
|
|
286
|
+
return `Recording the selected tab to ${rec.file} since ${clock(rec.startedAt)} (${since}s${size})${rec.owner ? `, by ${rec.owner}` : ''}${resized}; record off stops it and saves the file.`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A file word, as given or as playwright-cli's --filename=<file> (or --filename <file>); any other
|
|
290
|
+
// option is refused, never taken for a file name.
|
|
291
|
+
function fileWords(words, usage) {
|
|
292
|
+
const files = [];
|
|
293
|
+
const rest = [];
|
|
294
|
+
for (let i = 0; i < words.length; i++) {
|
|
295
|
+
const word = words[i];
|
|
296
|
+
if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
297
|
+
else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
|
|
298
|
+
else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
|
|
299
|
+
else if (/^\d+$/.test(word)) rest.push(word);
|
|
300
|
+
else files.push(word);
|
|
301
|
+
}
|
|
302
|
+
if (files.length > 1) throw new Error(usage);
|
|
303
|
+
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function checkFile(file, format) {
|
|
307
|
+
const ext = path.extname(file).toLowerCase().slice(1);
|
|
308
|
+
if (!FORMATS[ext]) throw new Error(`A recording is a .webm or .mp4 file, not ${path.basename(file)}`);
|
|
309
|
+
if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
|
|
310
|
+
if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
|
|
311
|
+
if (!fs.existsSync(path.dirname(file))) throw new Error(`No folder ${path.dirname(file)} to save ${path.basename(file)} in`);
|
|
312
|
+
return ext;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Moves a saved recording to the name record off was given, across file systems too.
|
|
316
|
+
function moveRecording(from, to) {
|
|
317
|
+
try {
|
|
318
|
+
fs.renameSync(from, to);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error.code !== 'EXDEV') throw error;
|
|
321
|
+
fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
322
|
+
fs.chmodSync(to, 0o600);
|
|
323
|
+
fs.unlinkSync(from);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const commands = {
|
|
328
|
+
async record(args) {
|
|
329
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
330
|
+
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
331
|
+
if (!words.length) {
|
|
332
|
+
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
333
|
+
out.log(statusText(state.page));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const [what, ...rest] = words;
|
|
337
|
+
// record on its own works with no tab selected, as after the recorded tab closed.
|
|
338
|
+
if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
|
|
339
|
+
if (what !== 'on' && what !== 'off') throw new Error(usage);
|
|
340
|
+
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
341
|
+
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
342
|
+
if (what === 'off') {
|
|
343
|
+
if (numbers.length) throw new Error(usage);
|
|
344
|
+
const rec = recordingOf(state.page);
|
|
345
|
+
if (!rec) {
|
|
346
|
+
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
347
|
+
out.log(statusText(state.page));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
|
|
351
|
+
const rename = named && named !== rec.file ? named : null;
|
|
352
|
+
// Checked first, so a name that cannot be used leaves it recording, and says so.
|
|
353
|
+
if (rename) {
|
|
354
|
+
try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
|
|
355
|
+
}
|
|
356
|
+
const text = await finishRecording(rec);
|
|
357
|
+
if (rec.failed) throw new Error(text);
|
|
358
|
+
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
359
|
+
moveRecording(rec.file, rename);
|
|
360
|
+
const last = lastEnded.get(rec.owner ?? null);
|
|
361
|
+
if (last) last.text = last.text.split(rec.file).join(rename);
|
|
362
|
+
out.log(text.split(rec.file).join(rename));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (numbers.length > 1) throw new Error(usage);
|
|
366
|
+
const seconds = numbers.length ? Number(numbers[0]) : MAX_SECONDS;
|
|
367
|
+
if (seconds < 1 || seconds > MAX_SECONDS) throw new Error(usage);
|
|
368
|
+
const file = named || nextRecordingPath();
|
|
369
|
+
const current = recordingOf(state.page);
|
|
370
|
+
if (current) throw new Error(`The selected tab is already being recorded to ${current.file}; record off stops it`);
|
|
371
|
+
const format = checkFile(file);
|
|
372
|
+
findFfmpeg(format);
|
|
373
|
+
// Created here, owner-only as a screenshot is, since a recording shows whatever the page does;
|
|
374
|
+
// ffmpeg writes over it and keeps the mode. It never replaces a file.
|
|
375
|
+
try {
|
|
376
|
+
fs.closeSync(fs.openSync(file, 'wx', 0o600));
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
await startRecording(state.page, file, seconds, format);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
fs.rmSync(file, { force: true });
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
388
|
+
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
module.exports = { commands, recordingOf, finishRecording, findFfmpeg, jpegSize };
|
package/lib/runner.js
CHANGED
|
@@ -31,7 +31,7 @@ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count'
|
|
|
31
31
|
'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
|
|
32
32
|
|
|
33
33
|
// Commands that work with no tab selected.
|
|
34
|
-
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
34
|
+
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'record', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
35
35
|
|
|
36
36
|
// Commands that accept --all, right after the command or at the end, to lift the output limit.
|
|
37
37
|
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
|
package/lib/tabstate.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// (ms, kbps), with the owner
|
|
10
10
|
// emulation what emulate changed: { device, scheme, locale, timezone }, each unset when off,
|
|
11
11
|
// with the owner
|
|
12
|
+
// viewport the size viewport set: { width, height }, with the owner
|
|
13
|
+
// recording its recording (record on): the ffmpeg writing it, its file, its owner
|
|
12
14
|
// routes Map(glob -> { status, body, handler, owner })
|
|
13
15
|
// log its recent requests (requests)
|
|
14
16
|
// consoleLog its recent console messages and page errors (console)
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pw-repl
|
|
3
|
-
description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone,
|
|
3
|
+
description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the person does, and recording a video of a tab. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
4
4
|
allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -158,7 +158,8 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
158
158
|
## Learn the commands
|
|
159
159
|
|
|
160
160
|
Run `pw-repl send help`. It lists six topics; `help <topic>` lists their commands, `help <command>`
|
|
161
|
-
gives usage and caveats, and `help --all` prints everything at once.
|
|
161
|
+
gives usage and caveats, and `help --all` prints everything at once. pw-repl answers it itself: it needs
|
|
162
|
+
no running REPL, and never reaches one. The
|
|
162
163
|
help is the command reference; this file does not repeat it. If you know playwright-cli, its command
|
|
163
164
|
names work too (`help playwright-cli` lists them).
|
|
164
165
|
|
|
@@ -200,17 +201,15 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
200
201
|
lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
|
|
201
202
|
`modes off` turns off every mode in every tab.
|
|
202
203
|
- Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
|
|
203
|
-
-
|
|
204
|
-
|
|
205
|
-
person sees it, a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS). A screen
|
|
206
|
-
recorder records their whole screen, not the tab: ask before starting one, or have them start it. A
|
|
207
|
-
headless browser is not on the screen, so it does not show.
|
|
204
|
+
- For a video, `help video` says which way suits what you want: `record on` records a tab's page, and a
|
|
205
|
+
screen recorder on the person's machine records what they see.
|
|
208
206
|
|
|
209
207
|
## Environment
|
|
210
208
|
|
|
211
209
|
- Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
|
|
212
210
|
one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
|
|
213
211
|
- `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
|
|
212
|
+
- `PW_FFMPEG` — the ffmpeg `record` uses (default: Playwright's own, then one on the `PATH`).
|
|
214
213
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
215
214
|
- `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
|
|
216
215
|
a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
|