pw-repl 0.7.0 → 0.7.2
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/bin/pw-repl.js +7 -6
- package/lib/background.js +16 -1
- package/lib/cli-names.js +1 -1
- package/lib/dialogs.js +18 -3
- package/lib/elements.js +68 -3
- package/lib/help.js +38 -16
- package/lib/inspect.js +2 -0
- package/lib/modes.js +6 -0
- package/lib/record.js +130 -38
- package/lib/runner.js +7 -2
- package/lib/syntax.js +8 -1
- package/lib/tabs.js +3 -0
- package/lib/util.js +6 -1
- package/package.json +1 -1
- package/skill/SKILL.md +10 -3
package/bin/pw-repl.js
CHANGED
|
@@ -15,7 +15,7 @@ const USAGE = `Usage:
|
|
|
15
15
|
see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
|
|
16
16
|
|
|
17
17
|
pw-repl stop [-e endpoint]
|
|
18
|
-
stop a background REPL
|
|
18
|
+
stop a background REPL, and say what became of each recording it ended
|
|
19
19
|
|
|
20
20
|
pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
|
|
21
21
|
run one command in a running REPL and print its output
|
|
@@ -41,10 +41,10 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
|
|
|
41
41
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
42
42
|
|
|
43
43
|
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
44
|
-
a temporary profile, on a free port of its own), prints the command it ran,
|
|
45
|
-
(Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
46
|
-
--user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
47
|
-
which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
44
|
+
a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
|
|
45
|
+
and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
|
|
46
|
+
Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
|
|
47
|
+
kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
|
|
48
48
|
|
|
49
49
|
In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
50
50
|
|
|
@@ -103,7 +103,8 @@ function parseSendArgs(args, allowCommand) {
|
|
|
103
103
|
// words are joined as they are.
|
|
104
104
|
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
105
105
|
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
106
|
-
|
|
106
|
+
// And record's file, which may have spaces in it.
|
|
107
|
+
const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
|
|
107
108
|
// An empty word (fill #name "") is the empty value.
|
|
108
109
|
// upload's files are read by the REPL, whose folder may not be this one.
|
|
109
110
|
// So are the files upload reads and screenshot --filename writes.
|
package/lib/background.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const readline = require('readline');
|
|
6
6
|
const { spawn } = require('child_process');
|
|
7
7
|
const client = require('./client');
|
|
8
|
+
const { STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
8
9
|
|
|
9
10
|
const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
|
|
10
11
|
const START_TIMEOUT = 20000;
|
|
@@ -132,12 +133,26 @@ async function stop(options) {
|
|
|
132
133
|
const { pidFile } = filesFor(endpoint);
|
|
133
134
|
const pid = runningPid(pidFile);
|
|
134
135
|
if (!pid) return fail(`no background REPL is serving on ${client.describe(endpoint)}`);
|
|
136
|
+
// What it logs as it stops, from here on, holds what became of each recording it ended.
|
|
137
|
+
const { logFile } = filesFor(endpoint);
|
|
138
|
+
let logFrom = 0;
|
|
139
|
+
try { logFrom = fs.statSync(logFile).size; } catch {}
|
|
135
140
|
// Like Ctrl-C: a command still running is answered, and the socket is removed.
|
|
136
141
|
process.kill(pid, 'SIGTERM');
|
|
137
142
|
const deadline = Date.now() + STOP_TIMEOUT;
|
|
138
143
|
while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
|
|
139
144
|
if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
|
|
140
|
-
console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${
|
|
145
|
+
console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${logFile}`);
|
|
146
|
+
// The REPL is gone, so record can no longer say where a recording it ended went, or that it was lost.
|
|
147
|
+
let logged = '';
|
|
148
|
+
try {
|
|
149
|
+
const fd = fs.openSync(logFile, 'r');
|
|
150
|
+
const buffer = Buffer.alloc(Math.max(0, fs.fstatSync(fd).size - logFrom));
|
|
151
|
+
fs.readSync(fd, buffer, 0, buffer.length, logFrom);
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
logged = buffer.toString('utf8');
|
|
154
|
+
} catch {}
|
|
155
|
+
for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN)) console.log(line);
|
|
141
156
|
return 0;
|
|
142
157
|
}
|
|
143
158
|
|
package/lib/cli-names.js
CHANGED
|
@@ -69,7 +69,7 @@ const NEAREST = {
|
|
|
69
69
|
list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
|
|
70
70
|
attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
|
|
71
71
|
detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
|
|
72
|
-
drag: 'not here yet', drop: 'not here yet',
|
|
72
|
+
drag: 'not here yet', drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
|
|
73
73
|
keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
|
|
74
74
|
pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
|
|
75
75
|
'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
|
package/lib/dialogs.js
CHANGED
|
@@ -5,11 +5,17 @@ const { OUTPUT_LIMIT } = out;
|
|
|
5
5
|
const { unquote } = require('./syntax');
|
|
6
6
|
const { hints } = require('./util');
|
|
7
7
|
const { tabState } = require('./tabstate');
|
|
8
|
+
const { keptSession } = require('./cdp');
|
|
8
9
|
|
|
9
10
|
// page -> its open dialog. The page, and every command that reads it, waits
|
|
10
11
|
// until the dialog is answered, in the browser or with the dialog command.
|
|
11
12
|
const openDialogs = new Map();
|
|
12
13
|
|
|
14
|
+
// cdp's session is new, and Chrome lets only one that saw a dialog open answer it: it would wait out its
|
|
15
|
+
// timeout. Refused ahead of the queue too, which the dialog it means holds up.
|
|
16
|
+
const CDP_ANSWER = /^cdp(?:\s+--all)?\s+Page\.handleJavaScriptDialog(?:\s|$)/;
|
|
17
|
+
const CDP_REFUSED = 'cdp cannot answer a dialog, which Chrome lets only a session that saw it open do; dialog accept or dialog dismiss answers it';
|
|
18
|
+
|
|
13
19
|
function ensureDialogHandler(p) {
|
|
14
20
|
if ('dialogHandled' in tabState(p)) return;
|
|
15
21
|
tabState(p).dialogHandled = true;
|
|
@@ -19,6 +25,12 @@ function ensureDialogHandler(p) {
|
|
|
19
25
|
out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.', p);
|
|
20
26
|
});
|
|
21
27
|
p.on('close', () => openDialogs.delete(p));
|
|
28
|
+
// One answered in the browser is gone too, which Playwright does not report. The tab's own session
|
|
29
|
+
// shares Playwright's connection, so a dialog's closing comes in before the next one opening.
|
|
30
|
+
keptSession(p).then(async session => {
|
|
31
|
+
session.on('Page.javascriptDialogClosed', () => openDialogs.delete(p));
|
|
32
|
+
await session.send('Page.enable');
|
|
33
|
+
}).catch(() => {});
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
// Runs ahead of the command queue (see runner.js), so it still works while
|
|
@@ -30,7 +42,9 @@ async function dialogCommand(args, selected = state.page) {
|
|
|
30
42
|
const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
|
|
31
43
|
if (!action) {
|
|
32
44
|
if (!openDialogs.size) return 'No dialog is open.';
|
|
33
|
-
|
|
45
|
+
// With several, the one accept and dismiss answer is marked, as tab marks the selected tab.
|
|
46
|
+
const mark = p => (openDialogs.size > 1 ? (p === selected ? '* ' : ' ') : '');
|
|
47
|
+
return [...[...openDialogs].map(([p, d]) => `${mark(p)}${describe(p, d)}`), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
|
|
34
48
|
}
|
|
35
49
|
if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
|
|
36
50
|
// The selected tab's dialog, or the only one open.
|
|
@@ -40,7 +54,8 @@ async function dialogCommand(args, selected = state.page) {
|
|
|
40
54
|
openDialogs.delete(page);
|
|
41
55
|
const line = describe(page, dialog);
|
|
42
56
|
try {
|
|
43
|
-
|
|
57
|
+
// Without text, as OK in the browser: a prompt's default stays its answer.
|
|
58
|
+
if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : dialog.defaultValue());
|
|
44
59
|
else await dialog.dismiss();
|
|
45
60
|
} catch (error) {
|
|
46
61
|
// Answered in the browser meanwhile.
|
|
@@ -55,4 +70,4 @@ const commands = {
|
|
|
55
70
|
},
|
|
56
71
|
};
|
|
57
72
|
|
|
58
|
-
module.exports = { commands, ensureDialogHandler, dialogCommand };
|
|
73
|
+
module.exports = { commands, ensureDialogHandler, dialogCommand, CDP_ANSWER, CDP_REFUSED };
|
package/lib/elements.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Elements and the mouse: finding the match to act on, and the commands that click, type and choose.
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const { state } = require('./state');
|
|
4
|
+
const { state, onShutdown } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { takeOptions } = require('./cli-names');
|
|
7
7
|
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
|
-
const { numbers } = require('./util');
|
|
8
|
+
const { numbers, hints } = require('./util');
|
|
9
|
+
const { tabState } = require('./tabstate');
|
|
9
10
|
|
|
10
11
|
// The type a page sees for a file it is given, from its extension.
|
|
11
12
|
const MIME_TYPES = {
|
|
@@ -198,6 +199,34 @@ async function pointerAction(selector, act, options = {}) {
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// Highlights: Playwright's own overlay, as playwright-cli's highlight draws it. Each is a mode of its
|
|
203
|
+
// tab, so modes shows it and modes off hides it, and none is left on the page when the REPL stops.
|
|
204
|
+
function highlightsOf(p) {
|
|
205
|
+
if (!tabState(p).highlights) {
|
|
206
|
+
tabState(p).highlights = new Map();
|
|
207
|
+
// A new document drops the overlay; a page that routes on the client keeps it.
|
|
208
|
+
p.on('domcontentloaded', () => tabState(p).highlights?.clear());
|
|
209
|
+
}
|
|
210
|
+
return tabState(p).highlights;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Hides a tab's highlights, or only the running client's (mine).
|
|
214
|
+
async function hideHighlights(p, mine = false) {
|
|
215
|
+
const highlights = tabState(p).highlights;
|
|
216
|
+
if (!highlights?.size) return 0;
|
|
217
|
+
const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
|
|
218
|
+
if (hidden.length === highlights.size) await p.hideHighlight();
|
|
219
|
+
else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
|
|
220
|
+
for (const [selector] of hidden) highlights.delete(selector);
|
|
221
|
+
return hidden.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onShutdown(async () => {
|
|
225
|
+
if (!state.browser || state.connectionLost) return;
|
|
226
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
227
|
+
await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
|
|
228
|
+
});
|
|
229
|
+
|
|
201
230
|
const commands = {
|
|
202
231
|
async click(args) {
|
|
203
232
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
@@ -217,6 +246,42 @@ const commands = {
|
|
|
217
246
|
out.log(`Hovered: ${args.trim()}${which}`);
|
|
218
247
|
},
|
|
219
248
|
|
|
249
|
+
async highlight(args) {
|
|
250
|
+
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
|
|
251
|
+
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
|
|
252
|
+
const style = found.find(o => o.name === 'style');
|
|
253
|
+
const hide = found.some(o => o.name === 'hide');
|
|
254
|
+
const highlights = highlightsOf(state.page);
|
|
255
|
+
if (!rest && !found.length) {
|
|
256
|
+
if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
|
|
257
|
+
for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
258
|
+
out.log(hints([['highlight --hide [<selector>]', 'hide one, or all']]));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (rest === 'off' && !found.length || hide && !rest) {
|
|
262
|
+
const count = await hideHighlights(state.page);
|
|
263
|
+
out.log(count ? `Hid ${count} highlight${count === 1 ? '' : 's'}` : 'Nothing was highlighted in the selected tab.');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!rest || style && (hide || !style.value)) throw new Error(usage);
|
|
267
|
+
const selector = soleSelector(rest, usage);
|
|
268
|
+
const locator = state.page.locator(selector);
|
|
269
|
+
if (hide) {
|
|
270
|
+
if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
|
|
271
|
+
await locator.hideHighlight();
|
|
272
|
+
highlights.delete(selector);
|
|
273
|
+
out.log(`Hid the highlight on ${rest}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const count = await locator.count();
|
|
277
|
+
if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
|
|
278
|
+
// Drawn again, with its new style, rather than twice.
|
|
279
|
+
if (highlights.has(selector)) await locator.hideHighlight();
|
|
280
|
+
await locator.highlight(style ? { style: style.value } : {});
|
|
281
|
+
highlights.set(selector, { owner: state.client, style: style?.value || null });
|
|
282
|
+
out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
|
|
283
|
+
},
|
|
284
|
+
|
|
220
285
|
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
221
286
|
async mousemove(args) {
|
|
222
287
|
const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
|
|
@@ -357,4 +422,4 @@ const commands = {
|
|
|
357
422
|
},
|
|
358
423
|
};
|
|
359
424
|
|
|
360
|
-
module.exports = { commands, soleSelector, onElement };
|
|
425
|
+
module.exports = { commands, soleSelector, onElement, hideHighlights };
|
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', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
77
|
+
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', '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.`,
|
|
@@ -105,7 +105,7 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
|
|
|
105
105
|
|
|
106
106
|
Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
|
|
107
107
|
|
|
108
|
-
Dialogs are reported and never answered on their own; dialog answers one.`,
|
|
108
|
+
Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.`,
|
|
109
109
|
commands: ['modes', 'dialog', 'help', 'quit'],
|
|
110
110
|
},
|
|
111
111
|
};
|
|
@@ -287,6 +287,27 @@ says how many.`,
|
|
|
287
287
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
288
288
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
289
289
|
inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
|
|
290
|
+
highlight: {
|
|
291
|
+
usage: 'highlight [<selector> [--style=<css>] | off]',
|
|
292
|
+
summary: 'draw a box over elements, for a person or a screenshot',
|
|
293
|
+
detail: `highlight <selector> draws Playwright's highlight over every match, as playwright-cli's does: a
|
|
294
|
+
tinted box, labelled with its locator, that follows the element as the page scrolls or changes, and is
|
|
295
|
+
in screenshots. --style=<css> adds CSS to the box, quoted if it has spaces; this circles e5 in blue:
|
|
296
|
+
highlight e5 --style="outline: 3px solid blue; border-radius: 50%"
|
|
297
|
+
|
|
298
|
+
highlight on its own lists the selected tab's highlights. highlight --hide <selector> hides one, and
|
|
299
|
+
highlight --hide or highlight off hides them all. A highlight is a mode of its tab: modes shows it,
|
|
300
|
+
modes off hides it, and the REPL hides every one as it stops. Loading a new page drops them; a page that
|
|
301
|
+
changes its route without loading keeps them.
|
|
302
|
+
|
|
303
|
+
The box is drawn over the page, in an element of Playwright's own added to it (x-pw-glass); the page's
|
|
304
|
+
own elements are not changed, and clicks and typing reach them as before. Playwright always labels the
|
|
305
|
+
box, and the label can cover what is just below it. For a screenshot without one, eval can outline
|
|
306
|
+
the element itself (with border-radius: 50% for a circle). That changes the page, so put it back
|
|
307
|
+
afterwards, as it was if it had a style of its own:
|
|
308
|
+
eval document.querySelector("#rate-3").style.outline = "3px solid blue"
|
|
309
|
+
eval document.querySelector("#rate-3").style.outline = ""`,
|
|
310
|
+
},
|
|
290
311
|
screenshot: {
|
|
291
312
|
usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
|
|
292
313
|
summary: 'save a PNG of the viewport, --full page or an element',
|
|
@@ -308,25 +329,24 @@ phone shows: a page laid out wider than the phone (no viewport meta tag) comes o
|
|
|
308
329
|
detail: `record on starts recording the selected tab, and record off stops it and saves the file: its
|
|
309
330
|
path, length, size and bytes. record on its own, or record off when nothing is recording, says whether the
|
|
310
331
|
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.
|
|
332
|
+
or the REPL stopping) with what record off would have said. tab close says what became of a recording it ended. A REPL stopped with pw-repl stop cannot be asked: stop prints what became of each recording it ended, and of one that ended by itself that record has not shown since.
|
|
312
333
|
|
|
313
|
-
record off <file> saves it under that name instead. --filename=<file> is file too, as playwright-cli
|
|
334
|
+
record off <file> saves it under that name instead. The format is set by record on: a name that does not end as its file does, or cannot be used, is refused, and it goes on recording. --filename=<file> is file too, as playwright-cli
|
|
314
335
|
writes it.
|
|
315
336
|
|
|
316
|
-
The file
|
|
337
|
+
The file's ending picks the format: record on clip.mp4 records an MP4, and record on clip.webm, or record on with no file, a WebM (there is no --mp4 or --webm). The file is absolute or relative to the folder pw-repl send runs in, quoted if it has spaces, and
|
|
317
338
|
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.
|
|
339
|
+
it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab. Another client with the tab selected can stop it too, as it can any mode; both are then told whose it was.
|
|
319
340
|
|
|
320
341
|
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
|
|
342
|
+
from inside the browser, at the viewport's size in CSS pixels, scrollbars included, made even (an
|
|
322
343
|
odd width or height loses a pixel), a phone's too under emulate mobile: a Pixel 7's is 412 wide, less
|
|
323
344
|
sharp than the phone draws it. It has no pointer and no browser toolbars,
|
|
324
345
|
and a tab behind another, hidden or headless keeps recording. Set the size first with viewport
|
|
325
346
|
(1280x720 fills a 16:9 player); a page that changes size while recording is fitted into the first size.
|
|
326
347
|
Chrome sends a frame as the page draws, and a still page keeps its real length.
|
|
327
348
|
|
|
328
|
-
It needs ffmpeg: $PW_FFMPEG,
|
|
329
|
-
PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. WebM plays in browsers,
|
|
349
|
+
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,
|
|
330
350
|
GitHub, GitLab and Slack; MP4 plays everywhere. It takes about 15 MB a minute.
|
|
331
351
|
|
|
332
352
|
For a video with the pointer, or of the person's screen, help video compares the other ways.`,
|
|
@@ -493,16 +513,17 @@ already happened (requests lists those).`,
|
|
|
493
513
|
summary: 'send one CDP command through a temporary session',
|
|
494
514
|
detail: `The session is detached afterwards, so subscriptions and settings do not persist.
|
|
495
515
|
|
|
496
|
-
Browser.close and Target.closeTarget are refused.`,
|
|
516
|
+
Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog answers one.`,
|
|
497
517
|
},
|
|
498
518
|
cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
|
|
499
519
|
storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
|
|
500
520
|
modes: {
|
|
501
521
|
usage: 'modes [off [--mine]]',
|
|
502
522
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
503
|
-
detail: `The modes are watch, network off or slow, route, capture, record, emulate and
|
|
504
|
-
turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
|
|
505
|
-
<glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
|
|
523
|
+
detail: `The modes are watch, network off or slow, route, capture, record, emulate, viewport and highlight.
|
|
524
|
+
Each is turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
|
|
525
|
+
route off <glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
|
|
526
|
+
viewport off, highlight <selector> | highlight off.
|
|
506
527
|
|
|
507
528
|
modes off turns off every one in every tab; a capture it stops is kept for capture to show, and a
|
|
508
529
|
recording it stops is saved, as record off would. With
|
|
@@ -512,10 +533,11 @@ the sender's own.`,
|
|
|
512
533
|
dialog: {
|
|
513
534
|
usage: 'dialog [accept [text] | dismiss]',
|
|
514
535
|
summary: 'show, accept or dismiss an open alert, confirm or prompt',
|
|
515
|
-
detail: `While a dialog is open, its page and the commands that read it wait. dialog runs at once, ahead
|
|
516
|
-
of them; it answers the selected tab's dialog, or the only one open. accept text answers a prompt.
|
|
536
|
+
detail: `While a dialog is open, its page and the commands that read it wait, a click that opened it included, until it is answered. Commands run one at a time, so every client's commands behind that one wait too, in any tab: in a shared REPL, answer a dialog you open. dialog runs at once, ahead
|
|
537
|
+
of them; it answers the selected tab's dialog (marked *), or the only one open. accept text answers a prompt, and accept alone keeps its default, as OK in the browser does.
|
|
517
538
|
|
|
518
|
-
|
|
539
|
+
An agent can answer any dialog, one it opened included; one the person answers in the browser is gone
|
|
540
|
+
from dialog then. cdp cannot answer one: Chrome lets only a session that saw it open do so. With nobody at the browser (e.g. headless), it is the only way to answer one.`,
|
|
519
541
|
},
|
|
520
542
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
521
543
|
quit: {
|
package/lib/inspect.js
CHANGED
|
@@ -9,6 +9,7 @@ const { toSelector, REF, unquote } = require('./syntax');
|
|
|
9
9
|
const { regexOf, COMMAND_TIMEOUT } = require('./util');
|
|
10
10
|
const { tabState } = require('./tabstate');
|
|
11
11
|
const { keptSession } = require('./cdp');
|
|
12
|
+
const { CDP_REFUSED } = require('./dialogs');
|
|
12
13
|
const { onElement, soleSelector } = require('./elements');
|
|
13
14
|
const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
14
15
|
const { deviceMetrics, viewportMetrics } = require('./emulation');
|
|
@@ -429,6 +430,7 @@ const commands = {
|
|
|
429
430
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
|
|
430
431
|
if (!match) throw new Error('Usage: cdp <method> <JSON object>');
|
|
431
432
|
if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
|
|
433
|
+
if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
|
|
432
434
|
const params = JSON.parse(match[2]);
|
|
433
435
|
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
|
|
434
436
|
const operation = async () => {
|
package/lib/modes.js
CHANGED
|
@@ -9,6 +9,7 @@ const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
|
|
|
9
9
|
const { recordingOf, finishRecording } = require('./record');
|
|
10
10
|
const { currentCapture, endCapture } = require('./capture');
|
|
11
11
|
const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
|
|
12
|
+
const { hideHighlights } = require('./elements');
|
|
12
13
|
|
|
13
14
|
// The modes turned on in a tab, in the order the prompt shows them.
|
|
14
15
|
function activeModes(p) {
|
|
@@ -22,6 +23,8 @@ function activeModes(p) {
|
|
|
22
23
|
if (viewport) modes.push(`viewport:${viewport.width}x${viewport.height}`);
|
|
23
24
|
const routes = tabState(p).routes?.size;
|
|
24
25
|
if (routes) modes.push(`routes:${routes}`);
|
|
26
|
+
const highlights = tabState(p).highlights?.size;
|
|
27
|
+
if (highlights) modes.push(`highlight:${highlights}`);
|
|
25
28
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
26
29
|
if (recordingOf(p)) modes.push('record');
|
|
27
30
|
return modes;
|
|
@@ -70,6 +73,7 @@ function modeOwners(p) {
|
|
|
70
73
|
const viewport = tabState(p).viewport;
|
|
71
74
|
if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
|
|
72
75
|
for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
76
|
+
for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
|
|
73
77
|
const capture = currentCapture();
|
|
74
78
|
if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
|
|
75
79
|
const recording = recordingOf(p);
|
|
@@ -104,6 +108,8 @@ async function allModesOff(mine = false) {
|
|
|
104
108
|
const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
105
109
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
106
110
|
if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
111
|
+
const hidden = await hideHighlights(p, mine);
|
|
112
|
+
if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
|
|
107
113
|
// Before the page's size is reset, which the recording would otherwise end with.
|
|
108
114
|
const recording = recordingOf(p);
|
|
109
115
|
if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
|
package/lib/record.js
CHANGED
|
@@ -6,7 +6,8 @@ const path = require('path');
|
|
|
6
6
|
const { state, onShutdown } = require('./state');
|
|
7
7
|
const out = require('./output');
|
|
8
8
|
const { tabState } = require('./tabstate');
|
|
9
|
-
const { clock, NO_TAB } = require('./util');
|
|
9
|
+
const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
10
|
+
const { shellWords } = require('./syntax');
|
|
10
11
|
|
|
11
12
|
const FPS = 25;
|
|
12
13
|
const MAX_SECONDS = 3600;
|
|
@@ -27,10 +28,11 @@ const FORMATS = {
|
|
|
27
28
|
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
|
};
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
const FORMAT_NAMES = { webm: 'WebM (VP8)', mp4: 'MP4 (H.264)' };
|
|
32
|
+
|
|
33
|
+
// Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
|
|
31
34
|
function ffmpegCandidates() {
|
|
32
35
|
const found = [];
|
|
33
|
-
if (process.env.PW_FFMPEG) found.push(process.env.PW_FFMPEG);
|
|
34
36
|
const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
|
|
35
37
|
let names = [];
|
|
36
38
|
try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
|
|
@@ -49,20 +51,43 @@ function ffmpegCandidates() {
|
|
|
49
51
|
|
|
50
52
|
const encoderLists = new Map();
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// What an ffmpeg's -encoders lists, or why it could not say. Only a list is kept: a probe that
|
|
55
|
+
// failed, as one slowed past its timeout on a busy machine can, is tried again the next time.
|
|
56
|
+
function encoderList(exe) {
|
|
57
|
+
if (encoderLists.has(exe)) return { list: encoderLists.get(exe) };
|
|
58
|
+
try {
|
|
59
|
+
const list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
56
60
|
encoderLists.set(exe, list);
|
|
61
|
+
return { list };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code === 'ENOENT') return { error: 'not found' };
|
|
64
|
+
if (error.code === 'EACCES') return { error: 'not executable' };
|
|
65
|
+
if (error.code === 'ETIMEDOUT') return { error: '-encoders did not answer within 5s' };
|
|
66
|
+
return { error: `-encoders failed (${error.status !== null && error.status !== undefined ? `exit ${error.status}` : error.signal || error.message})` };
|
|
57
67
|
}
|
|
58
|
-
return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(encoderLists.get(exe));
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
|
|
70
|
+
function hasEncoder(list, encoder) {
|
|
71
|
+
return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(list || '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// PW_FFMPEG when it is set, and never another in its place; otherwise the first ffmpeg that can
|
|
75
|
+
// write the format.
|
|
62
76
|
function findFfmpeg(format) {
|
|
77
|
+
const { encoder } = FORMATS[format];
|
|
78
|
+
const named = process.env.PW_FFMPEG;
|
|
79
|
+
if (named) {
|
|
80
|
+
const { list, error } = encoderList(named);
|
|
81
|
+
if (error) throw new Error(`PW_FFMPEG=${named} cannot be used: ${error}`);
|
|
82
|
+
if (!hasEncoder(list, encoder)) throw new Error(`PW_FFMPEG=${named} cannot write ${FORMAT_NAMES[format]}: its -encoders lists no ${encoder}`);
|
|
83
|
+
return named;
|
|
84
|
+
}
|
|
63
85
|
const candidates = ffmpegCandidates();
|
|
64
|
-
if (!candidates.length)
|
|
65
|
-
|
|
86
|
+
if (!candidates.length) {
|
|
87
|
+
const how = format === 'mp4' ? 'an .mp4 needs ffmpeg from your system\'s packages, with H.264 (Playwright\'s writes only WebM)' : INSTALL;
|
|
88
|
+
throw new Error(`record needs ffmpeg, and none was found: ${how}; PW_FFMPEG names one`);
|
|
89
|
+
}
|
|
90
|
+
const exe = candidates.find(c => hasEncoder(encoderList(c).list, encoder));
|
|
66
91
|
if (exe) return exe;
|
|
67
92
|
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
93
|
throw new Error(`No ffmpeg found can write WebM (VP8): ${INSTALL}; PW_FFMPEG names one`);
|
|
@@ -117,15 +142,18 @@ async function startRecording(p, file, seconds, format) {
|
|
|
117
142
|
const rec = {
|
|
118
143
|
page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
|
|
119
144
|
size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
|
|
120
|
-
ffmpegError: '',
|
|
145
|
+
ffmpegError: '', starting: true,
|
|
121
146
|
};
|
|
122
147
|
// The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
|
|
123
148
|
// 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.
|
|
149
|
+
// reload or a change of emulation, that can still be the window's. Its scrollbars too, as the
|
|
150
|
+
// frames have them (innerWidth has them; the viewport's clientWidth does not).
|
|
125
151
|
const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
|
|
126
152
|
try {
|
|
127
153
|
const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
|
|
128
|
-
|
|
154
|
+
const { result } = await session.send('Runtime.evaluate', { expression: '[innerWidth, innerHeight]', returnByValue: true });
|
|
155
|
+
const [width, height] = result.value;
|
|
156
|
+
rec.size = { width: even(width * (vv.scale || 1)), height: even(height * (vv.scale || 1)) };
|
|
129
157
|
} catch (error) {
|
|
130
158
|
session.detach().catch(() => {});
|
|
131
159
|
throw error;
|
|
@@ -134,7 +162,7 @@ async function startRecording(p, file, seconds, format) {
|
|
|
134
162
|
let firstFrame;
|
|
135
163
|
const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
|
|
136
164
|
const tick = () => {
|
|
137
|
-
if (!rec.latest || !rec.ff || rec.finishing) return;
|
|
165
|
+
if (!rec.latest || !rec.ff || rec.ffExited || rec.finishing) return;
|
|
138
166
|
// The video keeps the wall clock: Chrome sends a frame only when the page redraws,
|
|
139
167
|
// so the latest one is repeated while the page is still.
|
|
140
168
|
const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
|
|
@@ -178,7 +206,7 @@ async function startRecording(p, file, seconds, format) {
|
|
|
178
206
|
rec.limit = setTimeout(() => {
|
|
179
207
|
finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
|
|
180
208
|
}, seconds * 1000);
|
|
181
|
-
rec.onClose = () => finishRecording(rec,
|
|
209
|
+
rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
|
|
182
210
|
p.once('close', rec.onClose);
|
|
183
211
|
return rec;
|
|
184
212
|
}
|
|
@@ -195,10 +223,24 @@ function spawnFfmpeg(rec) {
|
|
|
195
223
|
// A write after ffmpeg has gone is reported when the recording finishes, not thrown.
|
|
196
224
|
ff.stdin.on('error', () => {});
|
|
197
225
|
ff.on('error', error => { rec.ffmpegError += error.message; });
|
|
198
|
-
rec.exited = new Promise(resolve => ff.once('close', code =>
|
|
226
|
+
rec.exited = new Promise(resolve => ff.once('close', (code, signal) => {
|
|
227
|
+
rec.ffExited = true;
|
|
228
|
+
resolve(code ?? signal);
|
|
229
|
+
// ffmpeg gone before record off ends the recording now, so no one goes on thinking it records;
|
|
230
|
+
// record on, still starting it, says so itself.
|
|
231
|
+
if (!rec.finishing) {
|
|
232
|
+
rec.exitedEarly = true;
|
|
233
|
+
finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
199
236
|
return ff;
|
|
200
237
|
}
|
|
201
238
|
|
|
239
|
+
// An exit code, or the signal that ended ffmpeg.
|
|
240
|
+
function exitText(code) {
|
|
241
|
+
return typeof code === 'string' ? `was killed by ${code}` : `exited with ${code}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
202
244
|
// Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
|
|
203
245
|
function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
204
246
|
if (rec.finishing) return rec.finishing;
|
|
@@ -207,10 +249,13 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
|
207
249
|
rec.finishing = (async () => {
|
|
208
250
|
const result = await finishFile(rec, shuttingDown);
|
|
209
251
|
const text = typeof result === 'string' ? result : result.text;
|
|
210
|
-
// A recording that saved nothing makes record off fail
|
|
211
|
-
|
|
252
|
+
// A recording that saved nothing, or that ffmpeg cut short, makes record off fail rather than
|
|
253
|
+
// report it as done.
|
|
254
|
+
rec.failed = typeof result !== 'string' || !!rec.exitedEarly;
|
|
212
255
|
const said = why ? `${why}. ${text}` : text;
|
|
213
|
-
|
|
256
|
+
// A failure is kept unreported until record off (or record on) has failed with it. One that
|
|
257
|
+
// ended by itself is unseen until record shows it, and the REPL stopping logs it for stop.
|
|
258
|
+
lastEnded.set(owner, { text: said, at: stoppedAt, failed: rec.failed, reported: false, seen: !why || shuttingDown });
|
|
214
259
|
return said;
|
|
215
260
|
})();
|
|
216
261
|
const pending = saving.get(owner) || new Set();
|
|
@@ -224,7 +269,7 @@ async function finishFile(rec, shuttingDown) {
|
|
|
224
269
|
if (rec.onClose) rec.page.off('close', rec.onClose);
|
|
225
270
|
if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
|
|
226
271
|
// The last frame runs to now, and a recording has one frame at least.
|
|
227
|
-
if (rec.ff && rec.latest) {
|
|
272
|
+
if (rec.ff && rec.latest && !rec.ffExited) {
|
|
228
273
|
const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
|
|
229
274
|
for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
|
|
230
275
|
}
|
|
@@ -256,20 +301,38 @@ async function finishFile(rec, shuttingDown) {
|
|
|
256
301
|
const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
|
|
257
302
|
if (!saved) {
|
|
258
303
|
fs.rmSync(rec.file, { force: true });
|
|
259
|
-
const why = cut ? 'it stopped making progress and was stopped' : `it
|
|
304
|
+
const why = cut ? 'it stopped making progress and was stopped' : `it ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
|
|
260
305
|
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
261
306
|
}
|
|
262
307
|
const notes = [];
|
|
263
308
|
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
264
309
|
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
// An ffmpeg stopped before it finished leaves only what it had written, which it writes in
|
|
311
|
+
// blocks: seconds of video short of the frames it was sent, and no length in the file's header.
|
|
312
|
+
const short = `so the file holds only what it had written, short of the ${seconds.toFixed(1)}s recorded, and may not show its length or seek`;
|
|
313
|
+
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, ${short}`);
|
|
314
|
+
else if (rec.exitedEarly) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? ` (${rec.ffmpegError.trim()})` : ''}, ${short}`);
|
|
315
|
+
else if (code !== 0) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
|
|
316
|
+
const length = cut || rec.exitedEarly ? '' : `${seconds.toFixed(1)}s, `;
|
|
317
|
+
return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const TAB_CLOSED = 'Its tab closed, which ended it';
|
|
321
|
+
|
|
322
|
+
// What became of a recording that tab close ended by closing its tab, for it to say.
|
|
323
|
+
async function endedByTabClose(rec) {
|
|
324
|
+
const text = await finishRecording(rec, TAB_CLOSED);
|
|
325
|
+
const last = lastEnded.get(rec.owner ?? null);
|
|
326
|
+
if (last?.text === text) last.seen = true;
|
|
327
|
+
return text;
|
|
268
328
|
}
|
|
269
329
|
|
|
270
|
-
// Ends every recording, as the REPL stops, and says in the pane and the log what each saved
|
|
330
|
+
// Ends every recording, as the REPL stops, and says in the pane and the log what each saved, and
|
|
331
|
+
// what became of any that ended by itself that record has not shown since.
|
|
271
332
|
async function finishAll() {
|
|
272
|
-
|
|
333
|
+
for (const last of lastEnded.values()) if (!last.seen) out.log(`${ENDED_UNSEEN}: ${last.text.replace(/\n/g, '; ')}`);
|
|
334
|
+
// One line each, for stop to find in the log.
|
|
335
|
+
await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), STOPPED_RECORDING, { shuttingDown: true }).then(text => out.log(text.replace(/\n/g, '; ')), () => {})));
|
|
273
336
|
}
|
|
274
337
|
|
|
275
338
|
onShutdown(finishAll);
|
|
@@ -278,6 +341,7 @@ function statusText(p) {
|
|
|
278
341
|
const rec = recordingOf(p);
|
|
279
342
|
if (!rec) {
|
|
280
343
|
const last = lastEnded.get(state.client ?? null);
|
|
344
|
+
if (last) last.seen = true;
|
|
281
345
|
return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
|
|
282
346
|
}
|
|
283
347
|
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
@@ -295,11 +359,14 @@ function fileWords(words, usage) {
|
|
|
295
359
|
const word = words[i];
|
|
296
360
|
if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
297
361
|
else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
|
|
362
|
+
else if (/^--(?:mp4|webm)$/.test(word)) throw new Error(`record takes no ${word}: the file's ending picks the format, as in record on clip.${word.slice(2)}`);
|
|
363
|
+
// playwright-cli's video-start --size: here the video is the page's size.
|
|
364
|
+
else if (/^--size(?:=|$)/.test(word)) throw new Error(`record takes no --size: the video is the page's size; set it first with viewport <width>x<height>`);
|
|
298
365
|
else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
|
|
299
366
|
else if (/^\d+$/.test(word)) rest.push(word);
|
|
300
367
|
else files.push(word);
|
|
301
368
|
}
|
|
302
|
-
if (files.length > 1) throw new Error(usage);
|
|
369
|
+
if (files.length > 1) throw new Error(`record takes one file, and ${files.map(f => `"${f}"`).join(' and ')} are two: quote a file name with spaces in it. ${usage}`);
|
|
303
370
|
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
304
371
|
}
|
|
305
372
|
|
|
@@ -309,6 +376,7 @@ function checkFile(file, format) {
|
|
|
309
376
|
if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
|
|
310
377
|
if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
|
|
311
378
|
if (!fs.existsSync(path.dirname(file))) throw new Error(`No folder ${path.dirname(file)} to save ${path.basename(file)} in`);
|
|
379
|
+
try { fs.accessSync(path.dirname(file), fs.constants.W_OK); } catch { throw new Error(`Cannot write in ${path.dirname(file)} to save ${path.basename(file)} there`); }
|
|
312
380
|
return ext;
|
|
313
381
|
}
|
|
314
382
|
|
|
@@ -326,7 +394,8 @@ function moveRecording(from, to) {
|
|
|
326
394
|
|
|
327
395
|
const commands = {
|
|
328
396
|
async record(args) {
|
|
329
|
-
|
|
397
|
+
// A file may be quoted, with spaces in it.
|
|
398
|
+
const words = shellWords(args);
|
|
330
399
|
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
331
400
|
if (!words.length) {
|
|
332
401
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
@@ -334,17 +403,23 @@ const commands = {
|
|
|
334
403
|
return;
|
|
335
404
|
}
|
|
336
405
|
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
406
|
if (what !== 'on' && what !== 'off') throw new Error(usage);
|
|
407
|
+
// record on its own, and record off, work with no tab selected, as after the recorded tab closed.
|
|
408
|
+
const noTab = !state.page || state.page.isClosed();
|
|
409
|
+
if (noTab && what === 'on') throw new Error(NO_TAB);
|
|
340
410
|
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
341
411
|
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
342
412
|
if (what === 'off') {
|
|
343
413
|
if (numbers.length) throw new Error(usage);
|
|
344
|
-
const rec = recordingOf(state.page);
|
|
414
|
+
const rec = noTab ? null : recordingOf(state.page);
|
|
345
415
|
if (!rec) {
|
|
346
416
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
347
|
-
|
|
417
|
+
// A recording that ended by itself and saved nothing fails record off, once, as it would
|
|
418
|
+
// have had record off ended it: its exit status is what an agent checks.
|
|
419
|
+
const last = lastEnded.get(state.client ?? null);
|
|
420
|
+
const text = statusText(state.page);
|
|
421
|
+
if (last?.failed && !last.reported) { last.reported = true; throw new Error(text); }
|
|
422
|
+
out.log(text);
|
|
348
423
|
return;
|
|
349
424
|
}
|
|
350
425
|
// Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
|
|
@@ -353,10 +428,17 @@ const commands = {
|
|
|
353
428
|
if (rename) {
|
|
354
429
|
try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
|
|
355
430
|
}
|
|
356
|
-
|
|
357
|
-
|
|
431
|
+
// Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
|
|
432
|
+
const other = (rec.owner ?? null) !== (state.client ?? null);
|
|
433
|
+
const clientName = c => c ?? 'the unnamed client';
|
|
434
|
+
const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
|
|
435
|
+
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
358
436
|
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
359
|
-
|
|
437
|
+
try {
|
|
438
|
+
moveRecording(rec.file, rename);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new Error(`${text}\nIt could not be renamed to ${rename} (${error.message}), so it stays where it was saved`);
|
|
441
|
+
}
|
|
360
442
|
const last = lastEnded.get(rec.owner ?? null);
|
|
361
443
|
if (last) last.text = last.text.split(rec.file).join(rename);
|
|
362
444
|
out.log(text.split(rec.file).join(rename));
|
|
@@ -378,15 +460,25 @@ const commands = {
|
|
|
378
460
|
if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
|
|
379
461
|
throw error;
|
|
380
462
|
}
|
|
463
|
+
let rec;
|
|
381
464
|
try {
|
|
382
|
-
await startRecording(state.page, file, seconds, format);
|
|
465
|
+
rec = await startRecording(state.page, file, seconds, format);
|
|
383
466
|
} catch (error) {
|
|
384
467
|
fs.rmSync(file, { force: true });
|
|
385
468
|
throw error;
|
|
386
469
|
}
|
|
470
|
+
// An ffmpeg that stopped as it started (bad arguments, an encoder that failed) has ended it
|
|
471
|
+
// already, and record on says so instead of a notice.
|
|
472
|
+
if (rec.finishing) {
|
|
473
|
+
const text = await rec.finishing;
|
|
474
|
+
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
475
|
+
out.log(text);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
rec.starting = false;
|
|
387
479
|
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
388
480
|
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
389
481
|
},
|
|
390
482
|
};
|
|
391
483
|
|
|
392
|
-
module.exports = { commands, recordingOf, finishRecording, findFfmpeg, jpegSize };
|
|
484
|
+
module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize };
|
package/lib/runner.js
CHANGED
|
@@ -4,7 +4,7 @@ const readline = require('readline');
|
|
|
4
4
|
const { state, clientRecord, asClient, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { commands, cliCommands, needsTab } = require('./commands');
|
|
7
|
-
const { dialogCommand } = require('./dialogs');
|
|
7
|
+
const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
|
|
8
8
|
const { activeModes } = require('./modes');
|
|
9
9
|
const { NO_TAB } = require('./util');
|
|
10
10
|
const cliNames = require('./cli-names');
|
|
@@ -172,7 +172,8 @@ function prompt(preserveCursor) {
|
|
|
172
172
|
function enqueue(line) {
|
|
173
173
|
const text = parseInput(line).text;
|
|
174
174
|
const direct = /^quit(?:\s|$)/.test(text) || DIALOG.test(text);
|
|
175
|
-
if (
|
|
175
|
+
if (CDP_ANSWER.test(text)) out.error(`Error: ${CDP_REFUSED}`);
|
|
176
|
+
else if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
|
|
176
177
|
else queue = queue.then(() => handleLine(line)).catch(e => out.error(`Error: ${e.message}`));
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -181,6 +182,10 @@ function enqueue(line) {
|
|
|
181
182
|
// client names the sender: its commands show as [server:<client>] and use its own selected tab.
|
|
182
183
|
function submit(text, client = null) {
|
|
183
184
|
if (DIALOG.test(text)) return answerDialog(text, client);
|
|
185
|
+
if (CDP_ANSWER.test(text)) {
|
|
186
|
+
console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
|
|
187
|
+
return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
|
|
188
|
+
}
|
|
184
189
|
return new Promise(resolve => {
|
|
185
190
|
queue = queue.then(async () => {
|
|
186
191
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
package/lib/syntax.js
CHANGED
|
@@ -18,6 +18,13 @@ function unquote(text) {
|
|
|
18
18
|
return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
|
|
22
|
+
// (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
|
|
23
|
+
function shellWords(text) {
|
|
24
|
+
return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
|
|
25
|
+
.map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double.replace(/\\(.)/g, '$1') : single)));
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
// The first word of args, or a quoted selector with spaces in it, and the rest of the line.
|
|
22
29
|
function splitSelector(args) {
|
|
23
30
|
const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
|
|
@@ -47,4 +54,4 @@ function keyName(key) {
|
|
|
47
54
|
return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, splitSelector, keyName };
|
|
57
|
+
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName };
|
package/lib/tabs.js
CHANGED
|
@@ -7,6 +7,7 @@ const { keepDrawing } = require('./cdp');
|
|
|
7
7
|
const { ensureDialogHandler } = require('./dialogs');
|
|
8
8
|
const { notLoaded, stayedPut } = require('./requestlog');
|
|
9
9
|
const { viewportText } = require('./emulation');
|
|
10
|
+
const { recordingOf, endedByTabClose } = require('./record');
|
|
10
11
|
|
|
11
12
|
// A navigation that does not load is said as wait load says it, from its request, rather than as
|
|
12
13
|
// Playwright's net:: error and call log.
|
|
@@ -184,6 +185,7 @@ const commands = {
|
|
|
184
185
|
if (all.length <= 1) throw new Error('Refusing to close the last tab');
|
|
185
186
|
const url = target.url();
|
|
186
187
|
const wasSelected = target === state.page;
|
|
188
|
+
const recording = recordingOf(target);
|
|
187
189
|
await target.close();
|
|
188
190
|
if (wasSelected) {
|
|
189
191
|
const back = state.previousPage;
|
|
@@ -191,6 +193,7 @@ const commands = {
|
|
|
191
193
|
state.previousPage = null;
|
|
192
194
|
}
|
|
193
195
|
out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
|
|
196
|
+
if (recording) out.log(await endedByTabClose(recording));
|
|
194
197
|
return listTabs();
|
|
195
198
|
}
|
|
196
199
|
select(byUrl(trimmed));
|
package/lib/util.js
CHANGED
|
@@ -46,4 +46,9 @@ function clock(t) {
|
|
|
46
46
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// How the REPL, as it stops, begins what it says of each recording it ended, and of one that ended
|
|
50
|
+
// by itself unseen: stop prints those lines.
|
|
51
|
+
const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
|
|
52
|
+
const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
|
|
53
|
+
|
|
54
|
+
module.exports = { STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -195,12 +195,16 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
195
195
|
one too; otherwise no tab is selected.
|
|
196
196
|
Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
|
|
197
197
|
its URL and refuse if it is ambiguous.
|
|
198
|
-
-
|
|
199
|
-
|
|
198
|
+
- You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept`
|
|
199
|
+
or `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and
|
|
200
|
+
the commands that read it wait. One answered in the browser is gone from `dialog` too.
|
|
200
201
|
- Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
|
|
201
202
|
lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
|
|
202
203
|
`modes off` turns off every mode in every tab.
|
|
203
204
|
- Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
|
|
205
|
+
- To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
|
|
206
|
+
without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
|
|
207
|
+
cover what is below it; `help highlight` says how to mark a screenshot without one.
|
|
204
208
|
- For a video, `help video` says which way suits what you want: `record on` records a tab's page, and a
|
|
205
209
|
screen recorder on the person's machine records what they see.
|
|
206
210
|
|
|
@@ -209,7 +213,10 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
209
213
|
- Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
|
|
210
214
|
one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
|
|
211
215
|
- `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
|
|
212
|
-
- `PW_FFMPEG` — the ffmpeg `record` uses
|
|
216
|
+
- `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
|
|
217
|
+
Playwright's own, then one on the `PATH`).
|
|
218
|
+
- Playwright's own Chromium and ffmpeg are found where Playwright installs them: `PLAYWRIGHT_BROWSERS_PATH`,
|
|
219
|
+
or `~/.cache/ms-playwright`.
|
|
213
220
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
214
221
|
- `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
|
|
215
222
|
a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
|