pw-repl 0.6.1 → 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/AGENTS.md +2 -2
- package/README.md +3 -2
- package/bin/pw-repl.js +17 -7
- package/lib/background.js +16 -1
- package/lib/cli-names.js +14 -4
- package/lib/commands.js +4 -0
- package/lib/dialogs.js +18 -3
- package/lib/elements.js +68 -3
- package/lib/emulation.js +52 -9
- package/lib/help.js +108 -20
- package/lib/inspect.js +7 -4
- package/lib/modes.js +24 -2
- package/lib/record.js +484 -0
- package/lib/runner.js +8 -3
- package/lib/syntax.js +8 -1
- package/lib/tabs.js +3 -0
- package/lib/tabstate.js +2 -0
- package/lib/util.js +6 -1
- package/package.json +1 -1
- package/skill/SKILL.md +15 -9
package/lib/record.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
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, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
10
|
+
const { shellWords } = require('./syntax');
|
|
11
|
+
|
|
12
|
+
const FPS = 25;
|
|
13
|
+
const MAX_SECONDS = 3600;
|
|
14
|
+
// Frames waiting for ffmpeg past this are dropped rather than held in memory.
|
|
15
|
+
const MAX_QUEUED = 32 * 1024 * 1024;
|
|
16
|
+
// How long ffmpeg may go without progress on the frames still queued for it before it is stopped,
|
|
17
|
+
// and the file may be cut short; at shutdown, how long it may take at all, within the REPL's cleanup.
|
|
18
|
+
const FINISH_TIMEOUT = 5000;
|
|
19
|
+
const SHUTDOWN_FINISH_TIMEOUT = 3000;
|
|
20
|
+
const FIRST_FRAME_TIMEOUT = 2000;
|
|
21
|
+
const INSTALL = 'npx playwright-core install ffmpeg (WebM only), or ffmpeg from your system\'s packages';
|
|
22
|
+
// One thread each: ffmpeg's default of one per core thrashes on a busy machine (a 720p WebM took
|
|
23
|
+
// 26s of work for 10s of video with 20 cores, and 1s with one thread), and it shares the machine
|
|
24
|
+
// with the browser it records.
|
|
25
|
+
const FORMATS = {
|
|
26
|
+
webm: { encoder: 'libvpx', args: ['-c:v', 'vp8', '-b:v', '2M', '-g', String(FPS * 2), '-deadline', 'realtime', '-cpu-used', '8', '-threads', '1'] },
|
|
27
|
+
// Fragmented, so a recording cut short still plays up to its last fragment.
|
|
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'] },
|
|
29
|
+
};
|
|
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.
|
|
34
|
+
function ffmpegCandidates() {
|
|
35
|
+
const found = [];
|
|
36
|
+
const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
|
|
37
|
+
let names = [];
|
|
38
|
+
try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
for (const exe of ['ffmpeg-linux', 'ffmpeg-mac', 'ffmpeg-win64.exe']) {
|
|
41
|
+
const candidate = path.join(dir, name, exe);
|
|
42
|
+
if (fs.existsSync(candidate)) found.push(candidate);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const folder of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
46
|
+
const candidate = path.join(folder, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
|
47
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); found.push(candidate); } catch {}
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(found)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const encoderLists = new Map();
|
|
53
|
+
|
|
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 });
|
|
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})` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
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.
|
|
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
|
+
}
|
|
85
|
+
const candidates = ffmpegCandidates();
|
|
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));
|
|
91
|
+
if (exe) return exe;
|
|
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`);
|
|
93
|
+
throw new Error(`No ffmpeg found can write WebM (VP8): ${INSTALL}; PW_FFMPEG names one`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A JPEG's width and height, from its frame header.
|
|
97
|
+
function jpegSize(buffer) {
|
|
98
|
+
let i = 2;
|
|
99
|
+
while (i + 9 < buffer.length) {
|
|
100
|
+
if (buffer[i] !== 0xff) return null;
|
|
101
|
+
const marker = buffer[i + 1];
|
|
102
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
103
|
+
return { width: buffer.readUInt16BE(i + 7), height: buffer.readUInt16BE(i + 5) };
|
|
104
|
+
}
|
|
105
|
+
i += 2 + buffer.readUInt16BE(i + 2);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function nextRecordingPath() {
|
|
111
|
+
// Where screenshots go, so both are found in one place.
|
|
112
|
+
return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `recording-${Date.now()}.webm`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sizeText(bytes) {
|
|
116
|
+
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Each client's last recording that ended, and how, for record to report: one that ends on
|
|
120
|
+
// its own (its seconds, its tab closing) says so only in the pane.
|
|
121
|
+
const lastEnded = new Map();
|
|
122
|
+
// Each client's recordings still being saved, which record waits for before it reports.
|
|
123
|
+
const saving = new Map();
|
|
124
|
+
|
|
125
|
+
function recordingOf(p) {
|
|
126
|
+
return tabState(p).recording || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The tabs recording now, for the modes they count as.
|
|
130
|
+
function recordingPages() {
|
|
131
|
+
if (!state.browser) return [];
|
|
132
|
+
return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function startRecording(p, file, seconds, format) {
|
|
136
|
+
const exe = findFfmpeg(format);
|
|
137
|
+
// As screenshot does: a tab that has not been in front since it was emulated as a phone can
|
|
138
|
+
// be drawn at the window's size.
|
|
139
|
+
await p.bringToFront();
|
|
140
|
+
// Its own session: Chrome keeps one screencast per session, and the kept session's keeps the tab drawing.
|
|
141
|
+
const session = await p.context().newCDPSession(p);
|
|
142
|
+
const rec = {
|
|
143
|
+
page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
|
|
144
|
+
size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
|
|
145
|
+
ffmpegError: '', starting: true,
|
|
146
|
+
};
|
|
147
|
+
// The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
|
|
148
|
+
// its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
|
|
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).
|
|
151
|
+
const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
|
|
152
|
+
try {
|
|
153
|
+
const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
|
|
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)) };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
session.detach().catch(() => {});
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
tabState(p).recording = rec;
|
|
162
|
+
let firstFrame;
|
|
163
|
+
const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
|
|
164
|
+
const tick = () => {
|
|
165
|
+
if (!rec.latest || !rec.ff || rec.ffExited || rec.finishing) return;
|
|
166
|
+
// The video keeps the wall clock: Chrome sends a frame only when the page redraws,
|
|
167
|
+
// so the latest one is repeated while the page is still.
|
|
168
|
+
const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
|
|
169
|
+
for (; rec.written < due; rec.written++) {
|
|
170
|
+
if (rec.ff.stdin.writableLength > MAX_QUEUED) { rec.dropped += 1; continue; }
|
|
171
|
+
rec.ff.stdin.write(rec.latest);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
session.on('Page.screencastFrame', frame => {
|
|
175
|
+
session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
|
|
176
|
+
if (rec.finishing) return;
|
|
177
|
+
const image = Buffer.from(frame.data, 'base64');
|
|
178
|
+
const size = jpegSize(image);
|
|
179
|
+
if (!size) return;
|
|
180
|
+
// A frame the size of the video, to a pixel (Chrome scales it to fit, keeping its shape).
|
|
181
|
+
const fits = Math.abs(size.width - rec.size.width) <= 1 && Math.abs(size.height - rec.size.height) <= 1;
|
|
182
|
+
if (!rec.ff) {
|
|
183
|
+
rec.firstFrameAt = Date.now();
|
|
184
|
+
rec.ff = spawnFfmpeg(rec);
|
|
185
|
+
rec.timer = setInterval(tick, 1000 / FPS);
|
|
186
|
+
firstFrame();
|
|
187
|
+
} else {
|
|
188
|
+
tick();
|
|
189
|
+
// Once the page has been drawn at its size, one of another size means it changed; it is
|
|
190
|
+
// fitted into the video's.
|
|
191
|
+
if (rec.resizedAt === null && rec.fitted && !fits) rec.resizedAt = (Date.now() - rec.firstFrameAt) / 1000;
|
|
192
|
+
}
|
|
193
|
+
if (fits) rec.fitted = true;
|
|
194
|
+
rec.latest = image;
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
await session.send('Page.startScreencast', { format: 'jpeg', quality: 85, maxWidth: rec.size.width, maxHeight: rec.size.height });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
delete tabState(p).recording;
|
|
200
|
+
session.detach().catch(() => {});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
// Chrome sends the page as it is at once; the recording starts with it. A page that has not
|
|
204
|
+
// drawn yet (a tab still opening) starts it when it does.
|
|
205
|
+
await Promise.race([gotFirstFrame, new Promise(resolve => setTimeout(resolve, FIRST_FRAME_TIMEOUT))]);
|
|
206
|
+
rec.limit = setTimeout(() => {
|
|
207
|
+
finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
|
|
208
|
+
}, seconds * 1000);
|
|
209
|
+
rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
|
|
210
|
+
p.once('close', rec.onClose);
|
|
211
|
+
return rec;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function spawnFfmpeg(rec) {
|
|
215
|
+
const { width, height } = rec.size;
|
|
216
|
+
// A frame of another size (the viewport or window changed) is scaled to fit, and centred.
|
|
217
|
+
const fit = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`;
|
|
218
|
+
// Warnings too (an empty output is one), kept for a failure's message only.
|
|
219
|
+
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',
|
|
220
|
+
'-vf', fit, ...FORMATS[rec.format].args, '-an', '-y', rec.file], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
221
|
+
ff.stderr.setEncoding('utf8');
|
|
222
|
+
ff.stderr.on('data', chunk => { rec.ffmpegError = (rec.ffmpegError + chunk).slice(-2000); });
|
|
223
|
+
// A write after ffmpeg has gone is reported when the recording finishes, not thrown.
|
|
224
|
+
ff.stdin.on('error', () => {});
|
|
225
|
+
ff.on('error', error => { rec.ffmpegError += error.message; });
|
|
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
|
+
}));
|
|
236
|
+
return ff;
|
|
237
|
+
}
|
|
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
|
+
|
|
244
|
+
// Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
|
|
245
|
+
function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
246
|
+
if (rec.finishing) return rec.finishing;
|
|
247
|
+
const owner = rec.owner ?? null;
|
|
248
|
+
const stoppedAt = Date.now();
|
|
249
|
+
rec.finishing = (async () => {
|
|
250
|
+
const result = await finishFile(rec, shuttingDown);
|
|
251
|
+
const text = typeof result === 'string' ? result : result.text;
|
|
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;
|
|
255
|
+
const said = why ? `${why}. ${text}` : text;
|
|
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 });
|
|
259
|
+
return said;
|
|
260
|
+
})();
|
|
261
|
+
const pending = saving.get(owner) || new Set();
|
|
262
|
+
saving.set(owner, pending.add(rec.finishing));
|
|
263
|
+
rec.finishing.finally(() => pending.delete(rec.finishing)).catch(() => {});
|
|
264
|
+
return rec.finishing;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function finishFile(rec, shuttingDown) {
|
|
268
|
+
clearTimeout(rec.limit);
|
|
269
|
+
if (rec.onClose) rec.page.off('close', rec.onClose);
|
|
270
|
+
if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
|
|
271
|
+
// The last frame runs to now, and a recording has one frame at least.
|
|
272
|
+
if (rec.ff && rec.latest && !rec.ffExited) {
|
|
273
|
+
const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
|
|
274
|
+
for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
|
|
275
|
+
}
|
|
276
|
+
clearInterval(rec.timer);
|
|
277
|
+
await rec.session.send('Page.stopScreencast').catch(() => {});
|
|
278
|
+
rec.session.detach().catch(() => {});
|
|
279
|
+
if (!rec.ff) {
|
|
280
|
+
fs.rmSync(rec.file, { force: true });
|
|
281
|
+
return { failed: true, text: `Nothing was recorded: the tab drew no frame, so no file was saved (${rec.file}).` };
|
|
282
|
+
}
|
|
283
|
+
rec.ff.stdin.end();
|
|
284
|
+
let cut = false;
|
|
285
|
+
// Frames still queued are encoded before the file is done, however long that takes, while
|
|
286
|
+
// ffmpeg keeps getting through them.
|
|
287
|
+
const progress = () => `${rec.ff.stdin.writableLength}:${fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0}`;
|
|
288
|
+
const stalled = new Promise(resolve => {
|
|
289
|
+
if (shuttingDown) { setTimeout(() => resolve('timeout'), SHUTDOWN_FINISH_TIMEOUT); return; }
|
|
290
|
+
let last = progress();
|
|
291
|
+
let since = Date.now();
|
|
292
|
+
const check = setInterval(() => {
|
|
293
|
+
const now = progress();
|
|
294
|
+
if (now !== last) { last = now; since = Date.now(); } else if (Date.now() - since > FINISH_TIMEOUT) { clearInterval(check); resolve('timeout'); }
|
|
295
|
+
}, 250);
|
|
296
|
+
rec.exited.then(() => clearInterval(check));
|
|
297
|
+
});
|
|
298
|
+
const code = await Promise.race([rec.exited, stalled]);
|
|
299
|
+
if (code === 'timeout') { cut = true; rec.ff.kill('SIGKILL'); await rec.exited; }
|
|
300
|
+
const seconds = (rec.written - rec.dropped) / FPS;
|
|
301
|
+
const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
|
|
302
|
+
if (!saved) {
|
|
303
|
+
fs.rmSync(rec.file, { force: true });
|
|
304
|
+
const why = cut ? 'it stopped making progress and was stopped' : `it ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
|
|
305
|
+
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
306
|
+
}
|
|
307
|
+
const notes = [];
|
|
308
|
+
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
309
|
+
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
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;
|
|
328
|
+
}
|
|
329
|
+
|
|
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.
|
|
332
|
+
async function finishAll() {
|
|
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, '; ')), () => {})));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
onShutdown(finishAll);
|
|
339
|
+
|
|
340
|
+
function statusText(p) {
|
|
341
|
+
const rec = recordingOf(p);
|
|
342
|
+
if (!rec) {
|
|
343
|
+
const last = lastEnded.get(state.client ?? null);
|
|
344
|
+
if (last) last.seen = true;
|
|
345
|
+
return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
|
|
346
|
+
}
|
|
347
|
+
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
348
|
+
const size = rec.size ? `, ${rec.size.width}x${rec.size.height}` : ', no frame yet';
|
|
349
|
+
const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size` : '';
|
|
350
|
+
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.`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A file word, as given or as playwright-cli's --filename=<file> (or --filename <file>); any other
|
|
354
|
+
// option is refused, never taken for a file name.
|
|
355
|
+
function fileWords(words, usage) {
|
|
356
|
+
const files = [];
|
|
357
|
+
const rest = [];
|
|
358
|
+
for (let i = 0; i < words.length; i++) {
|
|
359
|
+
const word = words[i];
|
|
360
|
+
if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
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>`);
|
|
365
|
+
else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
|
|
366
|
+
else if (/^\d+$/.test(word)) rest.push(word);
|
|
367
|
+
else files.push(word);
|
|
368
|
+
}
|
|
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}`);
|
|
370
|
+
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function checkFile(file, format) {
|
|
374
|
+
const ext = path.extname(file).toLowerCase().slice(1);
|
|
375
|
+
if (!FORMATS[ext]) throw new Error(`A recording is a .webm or .mp4 file, not ${path.basename(file)}`);
|
|
376
|
+
if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
|
|
377
|
+
if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
|
|
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`); }
|
|
380
|
+
return ext;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Moves a saved recording to the name record off was given, across file systems too.
|
|
384
|
+
function moveRecording(from, to) {
|
|
385
|
+
try {
|
|
386
|
+
fs.renameSync(from, to);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error.code !== 'EXDEV') throw error;
|
|
389
|
+
fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
390
|
+
fs.chmodSync(to, 0o600);
|
|
391
|
+
fs.unlinkSync(from);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const commands = {
|
|
396
|
+
async record(args) {
|
|
397
|
+
// A file may be quoted, with spaces in it.
|
|
398
|
+
const words = shellWords(args);
|
|
399
|
+
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
400
|
+
if (!words.length) {
|
|
401
|
+
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
402
|
+
out.log(statusText(state.page));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const [what, ...rest] = words;
|
|
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);
|
|
410
|
+
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
411
|
+
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
412
|
+
if (what === 'off') {
|
|
413
|
+
if (numbers.length) throw new Error(usage);
|
|
414
|
+
const rec = noTab ? null : recordingOf(state.page);
|
|
415
|
+
if (!rec) {
|
|
416
|
+
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
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);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
|
|
426
|
+
const rename = named && named !== rec.file ? named : null;
|
|
427
|
+
// Checked first, so a name that cannot be used leaves it recording, and says so.
|
|
428
|
+
if (rename) {
|
|
429
|
+
try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
|
|
430
|
+
}
|
|
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); }
|
|
436
|
+
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
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
|
+
}
|
|
442
|
+
const last = lastEnded.get(rec.owner ?? null);
|
|
443
|
+
if (last) last.text = last.text.split(rec.file).join(rename);
|
|
444
|
+
out.log(text.split(rec.file).join(rename));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (numbers.length > 1) throw new Error(usage);
|
|
448
|
+
const seconds = numbers.length ? Number(numbers[0]) : MAX_SECONDS;
|
|
449
|
+
if (seconds < 1 || seconds > MAX_SECONDS) throw new Error(usage);
|
|
450
|
+
const file = named || nextRecordingPath();
|
|
451
|
+
const current = recordingOf(state.page);
|
|
452
|
+
if (current) throw new Error(`The selected tab is already being recorded to ${current.file}; record off stops it`);
|
|
453
|
+
const format = checkFile(file);
|
|
454
|
+
findFfmpeg(format);
|
|
455
|
+
// Created here, owner-only as a screenshot is, since a recording shows whatever the page does;
|
|
456
|
+
// ffmpeg writes over it and keeps the mode. It never replaces a file.
|
|
457
|
+
try {
|
|
458
|
+
fs.closeSync(fs.openSync(file, 'wx', 0o600));
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
let rec;
|
|
464
|
+
try {
|
|
465
|
+
rec = await startRecording(state.page, file, seconds, format);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
fs.rmSync(file, { force: true });
|
|
468
|
+
throw error;
|
|
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;
|
|
479
|
+
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
480
|
+
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
|
|
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');
|
|
@@ -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'];
|
|
@@ -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/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/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 };
|