pw-repl 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -7
- package/README.md +11 -6
- package/bin/pw-repl.js +7 -6
- package/lib/background.js +16 -1
- package/lib/cli-names.js +1 -1
- package/lib/commands.js +3 -0
- package/lib/cursor.js +302 -0
- package/lib/dialogs.js +18 -3
- package/lib/elements.js +173 -18
- package/lib/help.js +87 -21
- package/lib/inspect.js +2 -0
- package/lib/modes.js +11 -0
- package/lib/record.js +244 -40
- package/lib/runner.js +12 -2
- package/lib/syntax.js +8 -1
- package/lib/tabs.js +3 -0
- package/lib/tabstate.js +3 -0
- package/lib/util.js +6 -1
- package/package.json +1 -1
- package/skill/SKILL.md +13 -5
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, splitSelector } = 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,19 @@ 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, scale: 1, steps: [], step: null, stepsFile: null,
|
|
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.scale = vv.scale || 1;
|
|
157
|
+
rec.size = { width: even(width * rec.scale), height: even(height * rec.scale) };
|
|
129
158
|
} catch (error) {
|
|
130
159
|
session.detach().catch(() => {});
|
|
131
160
|
throw error;
|
|
@@ -134,7 +163,7 @@ async function startRecording(p, file, seconds, format) {
|
|
|
134
163
|
let firstFrame;
|
|
135
164
|
const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
|
|
136
165
|
const tick = () => {
|
|
137
|
-
if (!rec.latest || !rec.ff || rec.finishing) return;
|
|
166
|
+
if (!rec.latest || !rec.ff || rec.ffExited || rec.finishing) return;
|
|
138
167
|
// The video keeps the wall clock: Chrome sends a frame only when the page redraws,
|
|
139
168
|
// so the latest one is repeated while the page is still.
|
|
140
169
|
const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
|
|
@@ -178,8 +207,19 @@ async function startRecording(p, file, seconds, format) {
|
|
|
178
207
|
rec.limit = setTimeout(() => {
|
|
179
208
|
finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
|
|
180
209
|
}, seconds * 1000);
|
|
181
|
-
rec.onClose = () => finishRecording(rec,
|
|
210
|
+
rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
|
|
182
211
|
p.once('close', rec.onClose);
|
|
212
|
+
// A new page, loaded or routed to by a single-page app, is a step of its own: a zoom that follows the
|
|
213
|
+
// cursor ends there. Its URL is left out, as a step's values are.
|
|
214
|
+
rec.onNavigated = frame => {
|
|
215
|
+
if (frame !== p.mainFrame() || rec.finishing) return;
|
|
216
|
+
const t = videoTime(rec);
|
|
217
|
+
// An app that sets its URL twice in a row (push, then replace) is one new page.
|
|
218
|
+
const last = rec.steps[rec.steps.length - 1];
|
|
219
|
+
if (last?.command === '(page)' && t - last.t0 < 0.5) return;
|
|
220
|
+
rec.steps.push({ rec, t0: t, t1: t, command: '(page)', box: null, point: null, offSize: false, failed: false });
|
|
221
|
+
};
|
|
222
|
+
p.on('framenavigated', rec.onNavigated);
|
|
183
223
|
return rec;
|
|
184
224
|
}
|
|
185
225
|
|
|
@@ -195,10 +235,24 @@ function spawnFfmpeg(rec) {
|
|
|
195
235
|
// A write after ffmpeg has gone is reported when the recording finishes, not thrown.
|
|
196
236
|
ff.stdin.on('error', () => {});
|
|
197
237
|
ff.on('error', error => { rec.ffmpegError += error.message; });
|
|
198
|
-
rec.exited = new Promise(resolve => ff.once('close', code =>
|
|
238
|
+
rec.exited = new Promise(resolve => ff.once('close', (code, signal) => {
|
|
239
|
+
rec.ffExited = true;
|
|
240
|
+
resolve(code ?? signal);
|
|
241
|
+
// ffmpeg gone before record off ends the recording now, so no one goes on thinking it records;
|
|
242
|
+
// record on, still starting it, says so itself.
|
|
243
|
+
if (!rec.finishing) {
|
|
244
|
+
rec.exitedEarly = true;
|
|
245
|
+
finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
246
|
+
}
|
|
247
|
+
}));
|
|
199
248
|
return ff;
|
|
200
249
|
}
|
|
201
250
|
|
|
251
|
+
// An exit code, or the signal that ended ffmpeg.
|
|
252
|
+
function exitText(code) {
|
|
253
|
+
return typeof code === 'string' ? `was killed by ${code}` : `exited with ${code}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
202
256
|
// Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
|
|
203
257
|
function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
204
258
|
if (rec.finishing) return rec.finishing;
|
|
@@ -207,10 +261,13 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
|
207
261
|
rec.finishing = (async () => {
|
|
208
262
|
const result = await finishFile(rec, shuttingDown);
|
|
209
263
|
const text = typeof result === 'string' ? result : result.text;
|
|
210
|
-
// A recording that saved nothing makes record off fail
|
|
211
|
-
|
|
264
|
+
// A recording that saved nothing, or that ffmpeg cut short, makes record off fail rather than
|
|
265
|
+
// report it as done.
|
|
266
|
+
rec.failed = typeof result !== 'string' || !!rec.exitedEarly;
|
|
212
267
|
const said = why ? `${why}. ${text}` : text;
|
|
213
|
-
|
|
268
|
+
// A failure is kept unreported until record off (or record on) has failed with it. One that
|
|
269
|
+
// ended by itself is unseen until record shows it, and the REPL stopping logs it for stop.
|
|
270
|
+
lastEnded.set(owner, { text: said, at: stoppedAt, failed: rec.failed, reported: false, seen: !why || shuttingDown });
|
|
214
271
|
return said;
|
|
215
272
|
})();
|
|
216
273
|
const pending = saving.get(owner) || new Set();
|
|
@@ -222,9 +279,10 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
|
222
279
|
async function finishFile(rec, shuttingDown) {
|
|
223
280
|
clearTimeout(rec.limit);
|
|
224
281
|
if (rec.onClose) rec.page.off('close', rec.onClose);
|
|
282
|
+
if (rec.onNavigated) rec.page.off('framenavigated', rec.onNavigated);
|
|
225
283
|
if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
|
|
226
284
|
// The last frame runs to now, and a recording has one frame at least.
|
|
227
|
-
if (rec.ff && rec.latest) {
|
|
285
|
+
if (rec.ff && rec.latest && !rec.ffExited) {
|
|
228
286
|
const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
|
|
229
287
|
for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
|
|
230
288
|
}
|
|
@@ -256,20 +314,128 @@ async function finishFile(rec, shuttingDown) {
|
|
|
256
314
|
const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
|
|
257
315
|
if (!saved) {
|
|
258
316
|
fs.rmSync(rec.file, { force: true });
|
|
259
|
-
const why = cut ? 'it stopped making progress and was stopped' : `it
|
|
317
|
+
const why = cut ? 'it stopped making progress and was stopped' : `it ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
|
|
260
318
|
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
261
319
|
}
|
|
262
320
|
const notes = [];
|
|
263
321
|
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
264
322
|
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
323
|
+
// An ffmpeg stopped before it finished leaves only what it had written, which it writes in
|
|
324
|
+
// blocks: seconds of video short of the frames it was sent, and no length in the file's header.
|
|
325
|
+
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`;
|
|
326
|
+
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}`);
|
|
327
|
+
else if (rec.exitedEarly) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? ` (${rec.ffmpegError.trim()})` : ''}, ${short}`);
|
|
328
|
+
else if (code !== 0) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
|
|
329
|
+
const length = cut || rec.exitedEarly ? '' : `${seconds.toFixed(1)}s, `;
|
|
330
|
+
const steps = writeSteps(rec, seconds);
|
|
331
|
+
const stepsLine = !steps ? '' : steps.error ? `\nIts steps were not saved: ${steps.error}` : `\nSteps: ${steps.file} (${steps.count}; when each command ran, and where)`;
|
|
332
|
+
return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}${stepsLine}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
|
|
336
|
+
// seconds, and the element or point it acted on in the video's pixels, saved next to the video for
|
|
337
|
+
// whoever edits it (a zoom on a step, say). Only that: watch is the log of what happened.
|
|
338
|
+
|
|
339
|
+
// The video's clock: frames are written against the wall clock since the first, less those dropped.
|
|
340
|
+
function videoTime(rec) {
|
|
341
|
+
return rec.firstFrameAt === null ? 0 : Math.max(0, (Date.now() - rec.firstFrameAt) / 1000 - rec.dropped / FPS);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// What a step is, for its line: the command and what it acts on, never what it types or chooses, the
|
|
345
|
+
// code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
|
|
346
|
+
// video goes, and the video shows a password as dots.
|
|
347
|
+
const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
|
|
348
|
+
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
349
|
+
function stepLabel(cmd, args) {
|
|
350
|
+
if (WHOLE.has(cmd)) return `${cmd} ${args.replace(/\s+--modifiers=\S+/, '')}`.trim();
|
|
351
|
+
if (!TARGET_FIRST.has(cmd)) return cmd;
|
|
352
|
+
const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
|
|
353
|
+
const parsed = splitSelector(rest);
|
|
354
|
+
// type and press with one word act on the focused element: the word is the text or the key.
|
|
355
|
+
return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function stepStarted(p, command) {
|
|
359
|
+
const rec = recordingOf(p);
|
|
360
|
+
if (!rec || rec.finishing) return null;
|
|
361
|
+
// After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
|
|
362
|
+
rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
|
|
363
|
+
rec.steps.push(rec.step);
|
|
364
|
+
return rec.step;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function stepEnded(step, failed) {
|
|
368
|
+
if (!step) return;
|
|
369
|
+
step.t1 = videoTime(step.rec);
|
|
370
|
+
step.failed = failed;
|
|
371
|
+
if (step.rec.step === step) step.rec.step = null;
|
|
268
372
|
}
|
|
269
373
|
|
|
270
|
-
//
|
|
374
|
+
// The element (box) or point the running step acts on, in CSS pixels.
|
|
375
|
+
function stepTarget(p, box, point) {
|
|
376
|
+
const rec = recordingOf(p);
|
|
377
|
+
if (!rec?.step) return;
|
|
378
|
+
const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
|
|
379
|
+
rec.step.box = scaled(box);
|
|
380
|
+
rec.step.point = scaled(point);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Where the cursor went (cursor on), and when: a line of its own, not a command's step.
|
|
384
|
+
function cursorStep(p, what, point, ms) {
|
|
385
|
+
const rec = recordingOf(p);
|
|
386
|
+
if (!rec || rec.finishing) return;
|
|
387
|
+
const t0 = videoTime(rec);
|
|
388
|
+
const scaled = Math.round(point.x * rec.scale);
|
|
389
|
+
rec.steps.push({ rec, t0, t1: t0 + ms / 1000, command: what.replace(/-?\d+(?:\.\d+)?/g, n => String(Math.round(Number(n) * rec.scale))), box: null, point: { x: scaled, y: Math.round(point.y * rec.scale) }, offSize: rec.resizedAt !== null, failed: false });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stepsPath(file) {
|
|
393
|
+
return file.replace(/\.[^./]+$/, '') + '.steps.txt';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// One line a step: t0 t1, then x y w h of its element (a point is x y 0 0; none, or not in the video's
|
|
397
|
+
// pixels, is - - - -), then the command. Plain words, for a shell script to read.
|
|
398
|
+
function writeSteps(rec, seconds) {
|
|
399
|
+
if (!rec.steps.length) return null;
|
|
400
|
+
const file = stepsPath(rec.file);
|
|
401
|
+
const lines = [
|
|
402
|
+
`# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
|
|
403
|
+
'# t0 t1: when the command began and ended, in seconds into the video',
|
|
404
|
+
'# x y w h: its element in the video\'s pixels (a point: w and h are 0; none: -)',
|
|
405
|
+
'# (cursor from X Y): the cursor (cursor on) glided from X Y to x y between t0 and t1',
|
|
406
|
+
'# (page): the tab went to a new page, loaded or routed to by a single-page app',
|
|
407
|
+
...(rec.resizedAt !== null ? [`# the page changed size at ${rec.resizedAt.toFixed(2)}s: boxes after it are left out`] : []),
|
|
408
|
+
];
|
|
409
|
+
for (const s of rec.steps) {
|
|
410
|
+
const where = s.offSize ? null : s.box || (s.point && { x: s.point.x, y: s.point.y, width: 0, height: 0 });
|
|
411
|
+
const xywh = where ? `${where.x} ${where.y} ${where.width} ${where.height}` : '- - - -';
|
|
412
|
+
lines.push(`${s.t0.toFixed(2)} ${(s.t1 ?? videoTime(rec)).toFixed(2)} ${xywh} ${s.command}${s.failed ? ' # failed' : ''}`);
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return { file, error: error.code === 'EEXIST' ? `${file} already exists` : error.message };
|
|
418
|
+
}
|
|
419
|
+
rec.stepsFile = file;
|
|
420
|
+
return { file, count: rec.steps.length };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const TAB_CLOSED = 'Its tab closed, which ended it';
|
|
424
|
+
|
|
425
|
+
// What became of a recording that tab close ended by closing its tab, for it to say.
|
|
426
|
+
async function endedByTabClose(rec) {
|
|
427
|
+
const text = await finishRecording(rec, TAB_CLOSED);
|
|
428
|
+
const last = lastEnded.get(rec.owner ?? null);
|
|
429
|
+
if (last?.text === text) last.seen = true;
|
|
430
|
+
return text;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Ends every recording, as the REPL stops, and says in the pane and the log what each saved, and
|
|
434
|
+
// what became of any that ended by itself that record has not shown since.
|
|
271
435
|
async function finishAll() {
|
|
272
|
-
|
|
436
|
+
for (const last of lastEnded.values()) if (!last.seen) out.log(`${ENDED_UNSEEN}: ${last.text.replace(/\n/g, '; ')}`);
|
|
437
|
+
// One line each, for stop to find in the log.
|
|
438
|
+
await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), STOPPED_RECORDING, { shuttingDown: true }).then(text => out.log(text.replace(/\n/g, '; ')), () => {})));
|
|
273
439
|
}
|
|
274
440
|
|
|
275
441
|
onShutdown(finishAll);
|
|
@@ -278,6 +444,7 @@ function statusText(p) {
|
|
|
278
444
|
const rec = recordingOf(p);
|
|
279
445
|
if (!rec) {
|
|
280
446
|
const last = lastEnded.get(state.client ?? null);
|
|
447
|
+
if (last) last.seen = true;
|
|
281
448
|
return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
|
|
282
449
|
}
|
|
283
450
|
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
@@ -295,11 +462,14 @@ function fileWords(words, usage) {
|
|
|
295
462
|
const word = words[i];
|
|
296
463
|
if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
297
464
|
else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
|
|
465
|
+
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)}`);
|
|
466
|
+
// playwright-cli's video-start --size: here the video is the page's size.
|
|
467
|
+
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
468
|
else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
|
|
299
469
|
else if (/^\d+$/.test(word)) rest.push(word);
|
|
300
470
|
else files.push(word);
|
|
301
471
|
}
|
|
302
|
-
if (files.length > 1) throw new Error(usage);
|
|
472
|
+
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
473
|
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
304
474
|
}
|
|
305
475
|
|
|
@@ -309,6 +479,7 @@ function checkFile(file, format) {
|
|
|
309
479
|
if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
|
|
310
480
|
if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
|
|
311
481
|
if (!fs.existsSync(path.dirname(file))) throw new Error(`No folder ${path.dirname(file)} to save ${path.basename(file)} in`);
|
|
482
|
+
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
483
|
return ext;
|
|
313
484
|
}
|
|
314
485
|
|
|
@@ -326,7 +497,8 @@ function moveRecording(from, to) {
|
|
|
326
497
|
|
|
327
498
|
const commands = {
|
|
328
499
|
async record(args) {
|
|
329
|
-
|
|
500
|
+
// A file may be quoted, with spaces in it.
|
|
501
|
+
const words = shellWords(args);
|
|
330
502
|
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
331
503
|
if (!words.length) {
|
|
332
504
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
@@ -334,17 +506,23 @@ const commands = {
|
|
|
334
506
|
return;
|
|
335
507
|
}
|
|
336
508
|
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
509
|
if (what !== 'on' && what !== 'off') throw new Error(usage);
|
|
510
|
+
// record on its own, and record off, work with no tab selected, as after the recorded tab closed.
|
|
511
|
+
const noTab = !state.page || state.page.isClosed();
|
|
512
|
+
if (noTab && what === 'on') throw new Error(NO_TAB);
|
|
340
513
|
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
341
514
|
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
342
515
|
if (what === 'off') {
|
|
343
516
|
if (numbers.length) throw new Error(usage);
|
|
344
|
-
const rec = recordingOf(state.page);
|
|
517
|
+
const rec = noTab ? null : recordingOf(state.page);
|
|
345
518
|
if (!rec) {
|
|
346
519
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
347
|
-
|
|
520
|
+
// A recording that ended by itself and saved nothing fails record off, once, as it would
|
|
521
|
+
// have had record off ended it: its exit status is what an agent checks.
|
|
522
|
+
const last = lastEnded.get(state.client ?? null);
|
|
523
|
+
const text = statusText(state.page);
|
|
524
|
+
if (last?.failed && !last.reported) { last.reported = true; throw new Error(text); }
|
|
525
|
+
out.log(text);
|
|
348
526
|
return;
|
|
349
527
|
}
|
|
350
528
|
// Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
|
|
@@ -353,13 +531,29 @@ const commands = {
|
|
|
353
531
|
if (rename) {
|
|
354
532
|
try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
|
|
355
533
|
}
|
|
356
|
-
|
|
357
|
-
|
|
534
|
+
// Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
|
|
535
|
+
const other = (rec.owner ?? null) !== (state.client ?? null);
|
|
536
|
+
const clientName = c => c ?? 'the unnamed client';
|
|
537
|
+
const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
|
|
538
|
+
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
358
539
|
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
359
|
-
|
|
540
|
+
try {
|
|
541
|
+
moveRecording(rec.file, rename);
|
|
542
|
+
} catch (error) {
|
|
543
|
+
throw new Error(`${text}\nIt could not be renamed to ${rename} (${error.message}), so it stays where it was saved`);
|
|
544
|
+
}
|
|
545
|
+
// Its steps go with it, unless a file has the new name already.
|
|
546
|
+
let renamed = t => t.split(rec.file).join(rename);
|
|
547
|
+
if (rec.stepsFile && !fs.existsSync(stepsPath(rename))) {
|
|
548
|
+
try {
|
|
549
|
+
moveRecording(rec.stepsFile, stepsPath(rename));
|
|
550
|
+
const before = renamed;
|
|
551
|
+
renamed = t => before(t.split(rec.stepsFile).join(stepsPath(rename)));
|
|
552
|
+
} catch {}
|
|
553
|
+
}
|
|
360
554
|
const last = lastEnded.get(rec.owner ?? null);
|
|
361
|
-
if (last) last.text = last.text
|
|
362
|
-
out.log(text
|
|
555
|
+
if (last) last.text = renamed(last.text);
|
|
556
|
+
out.log(renamed(text));
|
|
363
557
|
return;
|
|
364
558
|
}
|
|
365
559
|
if (numbers.length > 1) throw new Error(usage);
|
|
@@ -378,15 +572,25 @@ const commands = {
|
|
|
378
572
|
if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
|
|
379
573
|
throw error;
|
|
380
574
|
}
|
|
575
|
+
let rec;
|
|
381
576
|
try {
|
|
382
|
-
await startRecording(state.page, file, seconds, format);
|
|
577
|
+
rec = await startRecording(state.page, file, seconds, format);
|
|
383
578
|
} catch (error) {
|
|
384
579
|
fs.rmSync(file, { force: true });
|
|
385
580
|
throw error;
|
|
386
581
|
}
|
|
582
|
+
// An ffmpeg that stopped as it started (bad arguments, an encoder that failed) has ended it
|
|
583
|
+
// already, and record on says so instead of a notice.
|
|
584
|
+
if (rec.finishing) {
|
|
585
|
+
const text = await rec.finishing;
|
|
586
|
+
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
587
|
+
out.log(text);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
rec.starting = false;
|
|
387
591
|
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
388
592
|
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
389
593
|
},
|
|
390
594
|
};
|
|
391
595
|
|
|
392
|
-
module.exports = { commands, recordingOf, finishRecording, findFfmpeg, jpegSize };
|
|
596
|
+
module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
package/lib/runner.js
CHANGED
|
@@ -4,9 +4,10 @@ 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
|
+
const { stepStarted, stepEnded, stepLabel } = require('./record');
|
|
10
11
|
const cliNames = require('./cli-names');
|
|
11
12
|
|
|
12
13
|
// playwright-cli names already explained, per client: each client is told once.
|
|
@@ -105,9 +106,13 @@ async function executeCommand(text) {
|
|
|
105
106
|
out.error(`Error: ${NO_TAB}`);
|
|
106
107
|
return { status: 'error', cmd };
|
|
107
108
|
}
|
|
109
|
+
// A recording tab notes when each command that may change it ran, for its steps file (help record).
|
|
110
|
+
const step = READ_ONLY.has(cmd) || NO_TAB_NEEDED.has(cmd) || !state.page || state.page.isClosed() ? null : stepStarted(state.page, stepLabel(cmd, args));
|
|
108
111
|
try {
|
|
109
112
|
await run(args, all);
|
|
113
|
+
stepEnded(step, false);
|
|
110
114
|
} catch (e) {
|
|
115
|
+
stepEnded(step, true);
|
|
111
116
|
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
|
112
117
|
e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
113
118
|
out.error(`Error: ${e.message}`);
|
|
@@ -172,7 +177,8 @@ function prompt(preserveCursor) {
|
|
|
172
177
|
function enqueue(line) {
|
|
173
178
|
const text = parseInput(line).text;
|
|
174
179
|
const direct = /^quit(?:\s|$)/.test(text) || DIALOG.test(text);
|
|
175
|
-
if (
|
|
180
|
+
if (CDP_ANSWER.test(text)) out.error(`Error: ${CDP_REFUSED}`);
|
|
181
|
+
else if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
|
|
176
182
|
else queue = queue.then(() => handleLine(line)).catch(e => out.error(`Error: ${e.message}`));
|
|
177
183
|
}
|
|
178
184
|
|
|
@@ -181,6 +187,10 @@ function enqueue(line) {
|
|
|
181
187
|
// client names the sender: its commands show as [server:<client>] and use its own selected tab.
|
|
182
188
|
function submit(text, client = null) {
|
|
183
189
|
if (DIALOG.test(text)) return answerDialog(text, client);
|
|
190
|
+
if (CDP_ANSWER.test(text)) {
|
|
191
|
+
console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
|
|
192
|
+
return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
|
|
193
|
+
}
|
|
184
194
|
return new Promise(resolve => {
|
|
185
195
|
queue = queue.then(async () => {
|
|
186
196
|
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
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// with the owner
|
|
12
12
|
// viewport the size viewport set: { width, height }, with the owner
|
|
13
13
|
// recording its recording (record on): the ffmpeg writing it, its file, its owner
|
|
14
|
+
// cursor its cursor (cursor on): where it is drawn { x, y }, with the owner
|
|
15
|
+
// cursorKept its cursor is drawn again on each new page
|
|
16
|
+
// mouseAt where the REPL last put the mouse { x, y }, when it knows
|
|
14
17
|
// routes Map(glob -> { status, body, handler, owner })
|
|
15
18
|
// log its recent requests (requests)
|
|
16
19
|
// consoleLog its recent console messages and page errors (console)
|
package/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,21 +195,29 @@ 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.
|
|
204
|
-
-
|
|
205
|
-
|
|
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.
|
|
208
|
+
- For a video, `help video` says which way suits what you want: `record on` records a tab's page (`cursor
|
|
209
|
+
on` draws a pointer where you act, for the viewer), and a screen recorder on the person's machine records
|
|
210
|
+
what they see.
|
|
206
211
|
|
|
207
212
|
## Environment
|
|
208
213
|
|
|
209
214
|
- Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
|
|
210
215
|
one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
|
|
211
216
|
- `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
|
|
212
|
-
- `PW_FFMPEG` — the ffmpeg `record` uses
|
|
217
|
+
- `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
|
|
218
|
+
Playwright's own, then one on the `PATH`).
|
|
219
|
+
- Playwright's own Chromium and ffmpeg are found where Playwright installs them: `PLAYWRIGHT_BROWSERS_PATH`,
|
|
220
|
+
or `~/.cache/ms-playwright`.
|
|
213
221
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
214
222
|
- `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
|
|
215
223
|
a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
|