pw-repl 0.8.1 → 0.9.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 +8 -3
- package/README.md +3 -3
- package/bin/pw-repl.js +33 -41
- package/lib/cli-names.js +4 -3
- package/lib/client.js +3 -0
- package/lib/cursor.js +50 -9
- package/lib/elements.js +5 -4
- package/lib/emulation.js +1 -1
- package/lib/help.js +496 -349
- package/lib/inspect.js +1 -1
- package/lib/record.js +119 -24
- package/lib/runner.js +2 -1
- package/lib/send.js +68 -1
- package/package.json +1 -1
- package/skill/SKILL.md +19 -10
package/lib/inspect.js
CHANGED
|
@@ -33,7 +33,7 @@ async function deviceShot(device, { ref, full, type }) {
|
|
|
33
33
|
const box = await onElement(selector, async () => {
|
|
34
34
|
const locator = state.page.locator(selector);
|
|
35
35
|
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
36
|
-
return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; });
|
|
36
|
+
return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; }, null, { timeout: 5000 });
|
|
37
37
|
});
|
|
38
38
|
clip = box;
|
|
39
39
|
}
|
package/lib/record.js
CHANGED
|
@@ -8,9 +8,15 @@ const out = require('./output');
|
|
|
8
8
|
const { tabState } = require('./tabstate');
|
|
9
9
|
const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
|
|
10
10
|
const { shellWords, splitSelector } = require('./syntax');
|
|
11
|
+
const { keptSession } = require('./cdp');
|
|
12
|
+
const { deviceMetrics, viewportMetrics, settled } = require('./emulation');
|
|
11
13
|
|
|
12
14
|
const FPS = 25;
|
|
13
15
|
const MAX_SECONDS = 3600;
|
|
16
|
+
// While recording: a moment after each action for its result to be seen, and still page at both ends,
|
|
17
|
+
// so a video neither starts nor stops on an action (record on --pause, --lead, --tail).
|
|
18
|
+
const PACING = { pause: 750, lead: 1000, tail: 1000 };
|
|
19
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
20
|
// Frames waiting for ffmpeg past this are dropped rather than held in memory.
|
|
15
21
|
const MAX_QUEUED = 32 * 1024 * 1024;
|
|
16
22
|
// How long ffmpeg may go without progress on the frames still queued for it before it is stopped,
|
|
@@ -126,23 +132,39 @@ function recordingOf(p) {
|
|
|
126
132
|
return tabState(p).recording || null;
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
async function restoreMetrics(p, rec) {
|
|
136
|
+
if (rec?.finishing) return;
|
|
137
|
+
const tab = tabState(p);
|
|
138
|
+
if (!tab.viewport && !tab.emulation?.device) return;
|
|
139
|
+
const kept = await keptSession(p);
|
|
140
|
+
if (rec?.finishing) return;
|
|
141
|
+
if (tab.viewport) await viewportMetrics(kept, tab.viewport);
|
|
142
|
+
else if (tab.emulation?.device) await deviceMetrics(kept, tab.emulation.device);
|
|
143
|
+
await settled(p);
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
// The tabs recording now, for the modes they count as.
|
|
130
147
|
function recordingPages() {
|
|
131
148
|
if (!state.browser) return [];
|
|
132
149
|
return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
async function startRecording(p, file, seconds, format) {
|
|
152
|
+
async function startRecording(p, file, seconds, format, keepSteps, pacing) {
|
|
136
153
|
const exe = findFfmpeg(format);
|
|
137
154
|
// As screenshot does: a tab that has not been in front since it was emulated as a phone can
|
|
138
155
|
// be drawn at the window's size.
|
|
139
156
|
await p.bringToFront();
|
|
157
|
+
// Bringing an emulated tab to the front can leave Chrome's capture surface at the window's size
|
|
158
|
+
// after its next navigation. Reapply the active screen metrics on the kept session before measuring
|
|
159
|
+
// or starting this recording, then wait for the page to draw at them.
|
|
160
|
+
await restoreMetrics(p);
|
|
140
161
|
// Its own session: Chrome keeps one screencast per session, and the kept session's keeps the tab drawing.
|
|
141
162
|
const session = await p.context().newCDPSession(p);
|
|
142
163
|
const rec = {
|
|
143
164
|
page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
|
|
144
165
|
size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
|
|
145
|
-
ffmpegError: '', starting: true, scale: 1, steps: [], step: null, stepsFile: null,
|
|
166
|
+
ffmpegError: '', starting: true, scale: 1, keepSteps, pacing, steps: [], step: null, stepsFile: null,
|
|
167
|
+
holdFrames: false, restarting: false, navigationVersion: 0, restoreError: null, mainFrameId: null, screencastParams: null,
|
|
146
168
|
};
|
|
147
169
|
// The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
|
|
148
170
|
// its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
|
|
@@ -150,11 +172,39 @@ async function startRecording(p, file, seconds, format) {
|
|
|
150
172
|
// frames have them (innerWidth has them; the viewport's clientWidth does not).
|
|
151
173
|
const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
|
|
152
174
|
try {
|
|
175
|
+
await session.send('Page.enable');
|
|
176
|
+
const tree = await session.send('Page.getFrameTree');
|
|
177
|
+
rec.mainFrameId = tree.frameTree.frame.id;
|
|
178
|
+
// Full document navigations reset Chrome's capture surface; SPA route changes do not.
|
|
179
|
+
session.on('Page.frameNavigated', ({ frame }) => {
|
|
180
|
+
if (frame.id !== rec.mainFrameId || rec.finishing) return;
|
|
181
|
+
rec.navigationVersion += 1;
|
|
182
|
+
const tab = tabState(p);
|
|
183
|
+
rec.holdFrames = !!(tab.viewport || tab.emulation?.device);
|
|
184
|
+
});
|
|
185
|
+
session.on('Page.domContentEventFired', async () => {
|
|
186
|
+
if (!rec.holdFrames || rec.finishing) return;
|
|
187
|
+
const version = rec.navigationVersion;
|
|
188
|
+
try {
|
|
189
|
+
await restoreMetrics(p, rec);
|
|
190
|
+
if (rec.finishing || version !== rec.navigationVersion) return;
|
|
191
|
+
await session.send('Page.stopScreencast');
|
|
192
|
+
if (rec.finishing || version !== rec.navigationVersion) return;
|
|
193
|
+
rec.holdFrames = false;
|
|
194
|
+
rec.restarting = true;
|
|
195
|
+
await session.send('Page.startScreencast', rec.screencastParams);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (rec.finishing || p.isClosed() || version !== rec.navigationVersion) return;
|
|
198
|
+
rec.restoreError = error.message;
|
|
199
|
+
out.notice(await finishRecording(rec, `Could not restore the recording size: ${error.message}`), p);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
153
202
|
const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
|
|
154
203
|
const { result } = await session.send('Runtime.evaluate', { expression: '[innerWidth, innerHeight]', returnByValue: true });
|
|
155
204
|
const [width, height] = result.value;
|
|
156
205
|
rec.scale = vv.scale || 1;
|
|
157
206
|
rec.size = { width: even(width * rec.scale), height: even(height * rec.scale) };
|
|
207
|
+
rec.screencastParams = { format: 'jpeg', quality: 85, maxWidth: rec.size.width, maxHeight: rec.size.height };
|
|
158
208
|
} catch (error) {
|
|
159
209
|
session.detach().catch(() => {});
|
|
160
210
|
throw error;
|
|
@@ -180,6 +230,11 @@ async function startRecording(p, file, seconds, format) {
|
|
|
180
230
|
if (!size) return;
|
|
181
231
|
// A frame the size of the video, to a pixel (Chrome scales it to fit, keeping its shape).
|
|
182
232
|
const fits = Math.abs(size.width - rec.size.width) <= 1 && Math.abs(size.height - rec.size.height) <= 1;
|
|
233
|
+
if (rec.restarting && rec.latest) {
|
|
234
|
+
if (!fits) { tick(); return; }
|
|
235
|
+
rec.restarting = false;
|
|
236
|
+
}
|
|
237
|
+
if (rec.holdFrames && rec.latest) { tick(); return; }
|
|
183
238
|
if (!rec.ff) {
|
|
184
239
|
rec.firstFrameAt = Date.now();
|
|
185
240
|
rec.ff = spawnFfmpeg(rec);
|
|
@@ -195,7 +250,7 @@ async function startRecording(p, file, seconds, format) {
|
|
|
195
250
|
rec.latest = image;
|
|
196
251
|
});
|
|
197
252
|
try {
|
|
198
|
-
await session.send('Page.startScreencast',
|
|
253
|
+
await session.send('Page.startScreencast', rec.screencastParams);
|
|
199
254
|
} catch (error) {
|
|
200
255
|
delete tabState(p).recording;
|
|
201
256
|
session.detach().catch(() => {});
|
|
@@ -209,10 +264,10 @@ async function startRecording(p, file, seconds, format) {
|
|
|
209
264
|
}, seconds * 1000);
|
|
210
265
|
rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
|
|
211
266
|
p.once('close', rec.onClose);
|
|
212
|
-
// A new page, loaded or routed to by a single-page app, is a step of its own
|
|
213
|
-
//
|
|
267
|
+
// A new page, loaded or routed to by a single-page app, is a step of its own, for whoever edits the
|
|
268
|
+
// video to cut or caption there. Its URL is left out, as a step's values are.
|
|
214
269
|
rec.onNavigated = frame => {
|
|
215
|
-
if (frame !== p.mainFrame() || rec.finishing) return;
|
|
270
|
+
if (frame !== p.mainFrame() || rec.finishing || !keepSteps) return;
|
|
216
271
|
const t = videoTime(rec);
|
|
217
272
|
// An app that sets its URL twice in a row (push, then replace) is one new page.
|
|
218
273
|
const last = rec.steps[rec.steps.length - 1];
|
|
@@ -242,7 +297,7 @@ function spawnFfmpeg(rec) {
|
|
|
242
297
|
// record on, still starting it, says so itself.
|
|
243
298
|
if (!rec.finishing) {
|
|
244
299
|
rec.exitedEarly = true;
|
|
245
|
-
finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
300
|
+
finishRecording(rec, 'ffmpeg exited while recording, which ended it (help record --all)').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
246
301
|
}
|
|
247
302
|
}));
|
|
248
303
|
return ff;
|
|
@@ -263,7 +318,7 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
|
|
|
263
318
|
const text = typeof result === 'string' ? result : result.text;
|
|
264
319
|
// A recording that saved nothing, or that ffmpeg cut short, makes record off fail rather than
|
|
265
320
|
// report it as done.
|
|
266
|
-
rec.failed = typeof result !== 'string' || !!rec.exitedEarly;
|
|
321
|
+
rec.failed = typeof result !== 'string' || !!rec.exitedEarly || !!rec.restoreError;
|
|
267
322
|
const said = why ? `${why}. ${text}` : text;
|
|
268
323
|
// A failure is kept unreported until record off (or record on) has failed with it. One that
|
|
269
324
|
// ended by itself is unseen until record shows it, and the REPL stopping logs it for stop.
|
|
@@ -318,7 +373,7 @@ async function finishFile(rec, shuttingDown) {
|
|
|
318
373
|
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
319
374
|
}
|
|
320
375
|
const notes = [];
|
|
321
|
-
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
376
|
+
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size (help record --all)`);
|
|
322
377
|
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
323
378
|
// An ffmpeg stopped before it finished leaves only what it had written, which it writes in
|
|
324
379
|
// blocks: seconds of video short of the frames it was sent, and no length in the file's header.
|
|
@@ -334,7 +389,7 @@ async function finishFile(rec, shuttingDown) {
|
|
|
334
389
|
|
|
335
390
|
// The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
|
|
336
391
|
// 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
|
|
392
|
+
// whoever edits it (a caption or a cut at a step, say). Only that: watch is the log of what happened.
|
|
338
393
|
|
|
339
394
|
// The video's clock: frames are written against the wall clock since the first, less those dropped.
|
|
340
395
|
function videoTime(rec) {
|
|
@@ -347,7 +402,12 @@ function videoTime(rec) {
|
|
|
347
402
|
const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
|
|
348
403
|
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
349
404
|
function stepLabel(cmd, args) {
|
|
350
|
-
if (WHOLE.has(cmd))
|
|
405
|
+
if (WHOLE.has(cmd)) {
|
|
406
|
+
const words = args.replace(/\s+--modifiers=\S+/, '').trim();
|
|
407
|
+
// An element's selector as written, without the quotes it needed on the command line (click's button after it).
|
|
408
|
+
const parsed = /^mouse/.test(cmd) ? null : splitSelector(words);
|
|
409
|
+
return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
|
|
410
|
+
}
|
|
351
411
|
if (!TARGET_FIRST.has(cmd)) return cmd;
|
|
352
412
|
const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
|
|
353
413
|
const parsed = splitSelector(rest);
|
|
@@ -355,8 +415,14 @@ function stepLabel(cmd, args) {
|
|
|
355
415
|
return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
|
|
356
416
|
}
|
|
357
417
|
|
|
358
|
-
|
|
418
|
+
// The recording that keeps its steps (record on --steps), if the tab has one.
|
|
419
|
+
function stepsOf(p) {
|
|
359
420
|
const rec = recordingOf(p);
|
|
421
|
+
return rec?.keepSteps ? rec : null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function stepStarted(p, command) {
|
|
425
|
+
const rec = stepsOf(p);
|
|
360
426
|
if (!rec || rec.finishing) return null;
|
|
361
427
|
// After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
|
|
362
428
|
rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
|
|
@@ -373,7 +439,7 @@ function stepEnded(step, failed) {
|
|
|
373
439
|
|
|
374
440
|
// The element (box) or point the running step acts on, in CSS pixels.
|
|
375
441
|
function stepTarget(p, box, point) {
|
|
376
|
-
const rec =
|
|
442
|
+
const rec = stepsOf(p);
|
|
377
443
|
if (!rec?.step) return;
|
|
378
444
|
const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
|
|
379
445
|
rec.step.box = scaled(box);
|
|
@@ -382,13 +448,20 @@ function stepTarget(p, box, point) {
|
|
|
382
448
|
|
|
383
449
|
// Where the cursor went (cursor on), and when: a line of its own, not a command's step.
|
|
384
450
|
function cursorStep(p, what, point, ms) {
|
|
385
|
-
const rec =
|
|
451
|
+
const rec = stepsOf(p);
|
|
386
452
|
if (!rec || rec.finishing) return;
|
|
387
453
|
const t0 = videoTime(rec);
|
|
388
454
|
const scaled = Math.round(point.x * rec.scale);
|
|
389
455
|
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
456
|
}
|
|
391
457
|
|
|
458
|
+
// The pause after an action on a recording tab, for its result to be seen (record on --pause).
|
|
459
|
+
const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
|
|
460
|
+
async function pauseAfter(p, cmd) {
|
|
461
|
+
const rec = p && !p.isClosed() ? recordingOf(p) : null;
|
|
462
|
+
if (rec && !rec.finishing && rec.pacing.pause && ACTIONS.has(cmd)) await sleep(rec.pacing.pause);
|
|
463
|
+
}
|
|
464
|
+
|
|
392
465
|
function stepsPath(file) {
|
|
393
466
|
return file.replace(/\.[^./]+$/, '') + '.steps.txt';
|
|
394
467
|
}
|
|
@@ -396,7 +469,7 @@ function stepsPath(file) {
|
|
|
396
469
|
// 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
470
|
// pixels, is - - - -), then the command. Plain words, for a shell script to read.
|
|
398
471
|
function writeSteps(rec, seconds) {
|
|
399
|
-
if (!rec.steps.length) return null;
|
|
472
|
+
if (!rec.keepSteps || !rec.steps.length) return null;
|
|
400
473
|
const file = stepsPath(rec.file);
|
|
401
474
|
const lines = [
|
|
402
475
|
`# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
|
|
@@ -440,6 +513,12 @@ async function finishAll() {
|
|
|
440
513
|
|
|
441
514
|
onShutdown(finishAll);
|
|
442
515
|
|
|
516
|
+
// The pacing options record on was given, where they differ from the defaults.
|
|
517
|
+
function pacingText(pacing) {
|
|
518
|
+
const changed = Object.keys(PACING).filter(k => pacing[k] !== PACING[k]).map(k => `--${k}=${pacing[k]}`);
|
|
519
|
+
return changed.length ? `, ${changed.join(' ')}` : '';
|
|
520
|
+
}
|
|
521
|
+
|
|
443
522
|
function statusText(p) {
|
|
444
523
|
const rec = recordingOf(p);
|
|
445
524
|
if (!rec) {
|
|
@@ -449,8 +528,8 @@ function statusText(p) {
|
|
|
449
528
|
}
|
|
450
529
|
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
451
530
|
const size = rec.size ? `, ${rec.size.width}x${rec.size.height}` : ', no frame yet';
|
|
452
|
-
const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size` : '';
|
|
453
|
-
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.`;
|
|
531
|
+
const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size (help record --all)` : '';
|
|
532
|
+
return `Recording the selected tab to ${rec.file} since ${clock(rec.startedAt)} (${since}s${size})${rec.owner ? `, by ${rec.owner}` : ''}${rec.keepSteps ? ', with its steps' : ''}${pacingText(rec.pacing)}${resized}; record off stops it and saves the file.`;
|
|
454
533
|
}
|
|
455
534
|
|
|
456
535
|
// A file word, as given or as playwright-cli's --filename=<file> (or --filename <file>); any other
|
|
@@ -458,9 +537,17 @@ function statusText(p) {
|
|
|
458
537
|
function fileWords(words, usage) {
|
|
459
538
|
const files = [];
|
|
460
539
|
const rest = [];
|
|
540
|
+
let steps = false;
|
|
541
|
+
const pacing = {};
|
|
461
542
|
for (let i = 0; i < words.length; i++) {
|
|
462
543
|
const word = words[i];
|
|
463
|
-
|
|
544
|
+
const timing = /^--(pause|lead|tail)(?:=(.*))?$/.exec(word);
|
|
545
|
+
if (word === '--steps') steps = true;
|
|
546
|
+
else if (timing) {
|
|
547
|
+
if (!/^\d+$/.test(timing[2] ?? '') || Number(timing[2]) > 10000) throw new Error(`record on --${timing[1]}=<ms> takes milliseconds, from 0 to 10000 (default ${PACING[timing[1]]})`);
|
|
548
|
+
pacing[timing[1]] = Number(timing[2]);
|
|
549
|
+
}
|
|
550
|
+
else if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
464
551
|
else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
|
|
465
552
|
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
553
|
// playwright-cli's video-start --size: here the video is the page's size.
|
|
@@ -470,7 +557,7 @@ function fileWords(words, usage) {
|
|
|
470
557
|
else files.push(word);
|
|
471
558
|
}
|
|
472
559
|
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}`);
|
|
473
|
-
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
560
|
+
return { file: files.length ? path.resolve(files[0]) : null, rest, steps, pacing };
|
|
474
561
|
}
|
|
475
562
|
|
|
476
563
|
function checkFile(file, format) {
|
|
@@ -499,7 +586,7 @@ const commands = {
|
|
|
499
586
|
async record(args) {
|
|
500
587
|
// A file may be quoted, with spaces in it.
|
|
501
588
|
const words = shellWords(args);
|
|
502
|
-
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
589
|
+
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] [--steps] [--pause=<ms>] [--lead=<ms>] [--tail=<ms>] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
503
590
|
if (!words.length) {
|
|
504
591
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
505
592
|
out.log(statusText(state.page));
|
|
@@ -511,8 +598,10 @@ const commands = {
|
|
|
511
598
|
const noTab = !state.page || state.page.isClosed();
|
|
512
599
|
if (noTab && what === 'on') throw new Error(NO_TAB);
|
|
513
600
|
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
514
|
-
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
601
|
+
const { file: named, rest: numbers, steps, pacing } = fileWords(rest, usage);
|
|
515
602
|
if (what === 'off') {
|
|
603
|
+
if (Object.keys(pacing).length) throw new Error(`--${Object.keys(pacing)[0]} goes on record on`);
|
|
604
|
+
if (steps) throw new Error('--steps goes on record on, which notes each step from the start: record off, then record on <file> --steps for a take with its steps');
|
|
516
605
|
if (numbers.length) throw new Error(usage);
|
|
517
606
|
const rec = noTab ? null : recordingOf(state.page);
|
|
518
607
|
if (!rec) {
|
|
@@ -534,6 +623,8 @@ const commands = {
|
|
|
534
623
|
// Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
|
|
535
624
|
const other = (rec.owner ?? null) !== (state.client ?? null);
|
|
536
625
|
const clientName = c => c ?? 'the unnamed client';
|
|
626
|
+
// The tail: still page after the last action. Only record off has one; the seconds limit is a hard stop.
|
|
627
|
+
if (!rec.finishing) await sleep(rec.pacing.tail);
|
|
537
628
|
const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
|
|
538
629
|
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
539
630
|
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
@@ -574,7 +665,7 @@ const commands = {
|
|
|
574
665
|
}
|
|
575
666
|
let rec;
|
|
576
667
|
try {
|
|
577
|
-
rec = await startRecording(state.page, file, seconds, format);
|
|
668
|
+
rec = await startRecording(state.page, file, seconds, format, steps, { ...PACING, ...pacing });
|
|
578
669
|
} catch (error) {
|
|
579
670
|
fs.rmSync(file, { force: true });
|
|
580
671
|
throw error;
|
|
@@ -588,9 +679,13 @@ const commands = {
|
|
|
588
679
|
return;
|
|
589
680
|
}
|
|
590
681
|
rec.starting = false;
|
|
682
|
+
// The lead: still page before the first action, counted from the first frame.
|
|
683
|
+
const until = Date.now() + 2000;
|
|
684
|
+
while (rec.firstFrameAt === null && !rec.finishing && Date.now() < until) await sleep(20);
|
|
685
|
+
if (rec.firstFrameAt !== null && !rec.finishing) await sleep(Math.max(0, rec.pacing.lead - (Date.now() - rec.firstFrameAt)));
|
|
591
686
|
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
592
|
-
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
687
|
+
out.log(`Recording the selected tab to ${file} ${limit}${steps ? ', with its steps' : ''}${pacingText(rec.pacing)}; record off stops it and saves the file.`);
|
|
593
688
|
},
|
|
594
689
|
};
|
|
595
690
|
|
|
596
|
-
module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
|
691
|
+
module.exports = { commands, recordingOf, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
package/lib/runner.js
CHANGED
|
@@ -7,7 +7,7 @@ const { commands, cliCommands, needsTab } = require('./commands');
|
|
|
7
7
|
const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
|
|
8
8
|
const { activeModes } = require('./modes');
|
|
9
9
|
const { NO_TAB } = require('./util');
|
|
10
|
-
const { stepStarted, stepEnded, stepLabel } = require('./record');
|
|
10
|
+
const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
|
|
11
11
|
const cliNames = require('./cli-names');
|
|
12
12
|
|
|
13
13
|
// playwright-cli names already explained, per client: each client is told once.
|
|
@@ -111,6 +111,7 @@ async function executeCommand(text) {
|
|
|
111
111
|
try {
|
|
112
112
|
await run(args, all);
|
|
113
113
|
stepEnded(step, false);
|
|
114
|
+
await pauseAfter(state.page, cmd);
|
|
114
115
|
} catch (e) {
|
|
115
116
|
stepEnded(step, true);
|
|
116
117
|
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
package/lib/send.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const client = require('./client');
|
|
9
|
+
const { shellWords } = require('./syntax');
|
|
9
10
|
|
|
10
11
|
// The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
|
|
11
12
|
const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
|
|
@@ -87,7 +88,73 @@ async function viaTmux(session, command, timeoutMs) {
|
|
|
87
88
|
return 2;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// A command's words as send was given them, made into the line the REPL reads: quoted again where the
|
|
92
|
+
// shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
|
|
93
|
+
// folder, since the REPL's may be another.
|
|
94
|
+
function commandLine(words) {
|
|
95
|
+
// For a command that reads a quoted selector (fill "text=Your name" Ada), a
|
|
96
|
+
// word the shell kept whole is quoted again so it stays one word. Any other
|
|
97
|
+
// command takes the rest of its line as it is (eval, route's JSON), so its
|
|
98
|
+
// words are joined as they are.
|
|
99
|
+
const { SELECTOR_FIRST } = require('./syntax');
|
|
100
|
+
// So does playwright-cli's storage key (localstorage-set "my key" v).
|
|
101
|
+
// And record's file, which may have spaces in it.
|
|
102
|
+
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]));
|
|
103
|
+
// An empty word (fill #name "") is the empty value.
|
|
104
|
+
// upload's files are read by the REPL, whose folder may not be this one.
|
|
105
|
+
// So are the files upload reads and screenshot --filename writes.
|
|
106
|
+
const resolve = require('path').resolve;
|
|
107
|
+
let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
|
|
108
|
+
// record on <file> writes it too.
|
|
109
|
+
if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
|
|
110
|
+
resolved = words.map((w, n) => {
|
|
111
|
+
if (n < (words[0] === 'record' ? 2 : 1)) return w;
|
|
112
|
+
if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
|
|
113
|
+
return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (words[0] === 'screenshot') {
|
|
117
|
+
resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
|
|
118
|
+
}
|
|
119
|
+
const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
|
|
120
|
+
// An option's value the shell kept whole stays whole too: --device='iPhone 15'.
|
|
121
|
+
// Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
|
|
122
|
+
for (const [n, w] of quoted.entries()) {
|
|
123
|
+
const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
|
|
124
|
+
if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
|
|
125
|
+
}
|
|
126
|
+
return quoted.join(' ').trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A file of commands, one per line, run in turn until one fails. A recording it started and did not
|
|
130
|
+
// stop is still on: the next take would fail to start, so it says so.
|
|
131
|
+
const PATHS = /^(?:upload|record|video-start|video-stop|screenshot)$/;
|
|
132
|
+
async function sendFile(options) {
|
|
133
|
+
let text;
|
|
134
|
+
try { text = fs.readFileSync(options.file, 'utf8'); } catch (error) { return fail(`cannot read ${options.file} (${error.code || error.message})`); }
|
|
135
|
+
const lines = text.split(/\r?\n/);
|
|
136
|
+
let recording = false;
|
|
137
|
+
for (const [i, raw] of lines.entries()) {
|
|
138
|
+
const line = raw.trim();
|
|
139
|
+
if (!line || line.startsWith('#')) continue;
|
|
140
|
+
console.log(`> ${line}`);
|
|
141
|
+
// Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
|
|
142
|
+
const words = shellWords(line);
|
|
143
|
+
const command = PATHS.test(words[0]) ? commandLine(words) : line;
|
|
144
|
+
const status = await send({ ...options, file: null, command });
|
|
145
|
+
if (status !== 0) {
|
|
146
|
+
console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}), exit status ${status}`);
|
|
147
|
+
if (recording) console.error('pw-repl: record off was not reached, so the recording is still on; pw-repl send record off ends it');
|
|
148
|
+
return status;
|
|
149
|
+
}
|
|
150
|
+
if (/^(?:record\s+on|video-start)\b/.test(line)) recording = true;
|
|
151
|
+
if (/^(?:record\s+off|video-stop)\b/.test(line)) recording = false;
|
|
152
|
+
}
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
async function send(options) {
|
|
157
|
+
if (options.file) return sendFile(options);
|
|
91
158
|
const command = options.command;
|
|
92
159
|
if (!command) return fail('no command given');
|
|
93
160
|
// A newline would reach the REPL as a second, unmarked command.
|
|
@@ -157,4 +224,4 @@ async function where(options) {
|
|
|
157
224
|
return 64;
|
|
158
225
|
}
|
|
159
226
|
|
|
160
|
-
module.exports = { send, where };
|
|
227
|
+
module.exports = { send, where, commandLine };
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -63,10 +63,15 @@ opens in a new tab.
|
|
|
63
63
|
tmux send-keys -t playwright-repl Enter
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
A REPL of your own goes on a socket of its own, named with `-e` on every command, `serve` included:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pw-repl serve --background --launch -e /tmp/mine.sock
|
|
70
|
+
pw-repl send -e /tmp/mine.sock tab new http://localhost:3000
|
|
71
|
+
pw-repl stop -e /tmp/mine.sock
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A socket file left by a REPL that died is replaced when the next one starts there. The log of a background REPL is next to its socket
|
|
70
75
|
(`/tmp/mine.log`), appended to run after run, with a line where each starts and stops (past 5 MB, a
|
|
71
76
|
start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along without a terminal to attach from.
|
|
72
77
|
`serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no access control.
|
|
@@ -114,6 +119,10 @@ spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL
|
|
|
114
119
|
`pw-repl where` says which one a command would reach (the server, or the tmux pane running the
|
|
115
120
|
REPL), or why neither is reachable, without running anything.
|
|
116
121
|
|
|
122
|
+
`send --file take.txt` runs a file of commands, one per line, as typed at the prompt (no shell quoting;
|
|
123
|
+
files relative to your folder, as on `send`'s command line; `#` comments), and stops at the first that fails, saying which line.
|
|
124
|
+
It is the way to record a video without gaps (`help record`), and to keep a repro to run again.
|
|
125
|
+
|
|
117
126
|
`send` takes one command; chain several in your shell. Use `&&` when a step only makes sense if the one
|
|
118
127
|
before it worked: it stops at any exit status but 0, a failure and an outcome not confirmed included.
|
|
119
128
|
Use `;` when the steps do not depend on each other. `-t` is each command's own limit. Write each
|
|
@@ -157,7 +166,7 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
157
166
|
|
|
158
167
|
## Learn the commands
|
|
159
168
|
|
|
160
|
-
Run `pw-repl send help`. It lists
|
|
169
|
+
Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
|
|
161
170
|
gives usage and caveats, and `help --all` prints everything at once. pw-repl answers it itself: it needs
|
|
162
171
|
no running REPL, and never reaches one. The
|
|
163
172
|
help is the command reference; this file does not repeat it. If you know playwright-cli, its command
|
|
@@ -205,9 +214,9 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
205
214
|
- To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
|
|
206
215
|
without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
|
|
207
216
|
cover what is below it; `help highlight` says how to mark a screenshot without one.
|
|
208
|
-
- For a video, `help video` says
|
|
209
|
-
on`
|
|
210
|
-
what they see.
|
|
217
|
+
- For a video, `help video` says how: a take written as a file and sent with `send --file`, `record on`
|
|
218
|
+
around it, `cursor on` for a pointer, and the pacing recording adds for the viewer; a screen recorder on
|
|
219
|
+
the user's machine records what they see.
|
|
211
220
|
|
|
212
221
|
## Environment
|
|
213
222
|
|
|
@@ -223,8 +232,8 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
223
232
|
a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
|
|
224
233
|
prints each file's path; other REPLs may save theirs in the same place. Remove only your
|
|
225
234
|
own, and keep those too if they are needed, e.g. as evidence or as something to hand over.
|
|
226
|
-
- `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `
|
|
227
|
-
looks for when neither is given (default `/tmp/playwright-repl.sock`).
|
|
235
|
+
- `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `serve`
|
|
236
|
+
serves on and `send` looks for when neither is given (default `/tmp/playwright-repl.sock`).
|
|
228
237
|
|
|
229
238
|
## Custom rules
|
|
230
239
|
|