pw-repl 0.8.2 → 0.10.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 +9 -7
- package/bin/pw-repl.js +33 -41
- package/lib/cli-names.js +4 -3
- package/lib/client.js +3 -0
- package/lib/cursor.js +65 -12
- package/lib/elements.js +93 -14
- package/lib/emulation.js +13 -1
- package/lib/help.js +511 -353
- package/lib/inspect.js +31 -3
- package/lib/modes.js +5 -1
- package/lib/record.js +68 -31
- package/lib/runner.js +2 -1
- package/lib/send.js +68 -1
- package/package.json +1 -1
- package/skill/SKILL.md +37 -25
package/lib/inspect.js
CHANGED
|
@@ -12,7 +12,7 @@ const { keptSession } = require('./cdp');
|
|
|
12
12
|
const { CDP_REFUSED } = require('./dialogs');
|
|
13
13
|
const { onElement, soleSelector } = require('./elements');
|
|
14
14
|
const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
15
|
-
const { deviceMetrics, viewportMetrics } = require('./emulation');
|
|
15
|
+
const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
|
|
16
16
|
|
|
17
17
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
18
18
|
|
|
@@ -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
|
}
|
|
@@ -51,6 +51,32 @@ async function deviceShot(device, { ref, full, type }) {
|
|
|
51
51
|
return Buffer.from(data, 'base64');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// An element bigger than the viewport is taken with the page laid out again at its whole size, where
|
|
55
|
+
// the page's scrollbar is gone and what was beside it moves over by its width, after the element was
|
|
56
|
+
// measured: the shot came out shifted. Laid out without the scrollbar for the shot, by a stylesheet
|
|
57
|
+
// adopted for it (under any CSP), the element is measured where it is taken. Someone looking at the
|
|
58
|
+
// page sees its scrollbar go for a moment.
|
|
59
|
+
const NO_SCROLLBAR = 'html:not([data-pw-repl])';
|
|
60
|
+
async function elementShot(locator, type) {
|
|
61
|
+
const fits = await locator.evaluate(el => { const r = el.getBoundingClientRect(); return r.width <= innerWidth && r.height <= innerHeight; }, null, { timeout: 5000 });
|
|
62
|
+
if (fits) return locator.screenshot({ type, timeout: 5000 });
|
|
63
|
+
const sheet = hide => state.page.evaluate(([rule, on]) => {
|
|
64
|
+
const sheets = [...document.adoptedStyleSheets].filter(s => ![...s.cssRules].some(r => r.selectorText === rule));
|
|
65
|
+
if (on) {
|
|
66
|
+
const s = new CSSStyleSheet();
|
|
67
|
+
s.replaceSync(`${rule} { scrollbar-width: none !important; }`);
|
|
68
|
+
sheets.push(s);
|
|
69
|
+
}
|
|
70
|
+
document.adoptedStyleSheets = sheets;
|
|
71
|
+
}, [NO_SCROLLBAR, hide]);
|
|
72
|
+
await sheet(true);
|
|
73
|
+
try {
|
|
74
|
+
return await locator.screenshot({ type, timeout: 5000 });
|
|
75
|
+
} finally {
|
|
76
|
+
await sheet(false).catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
// Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
|
|
55
81
|
// nothing to read. Drop them, lift their children, and drop cursor hints.
|
|
56
82
|
function compactSnapshot(text) {
|
|
@@ -271,6 +297,8 @@ const commands = {
|
|
|
271
297
|
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
272
298
|
// background, so the tab is brought to the front for the shot.
|
|
273
299
|
await state.page.bringToFront();
|
|
300
|
+
// A tab brought to the front before can be drawn at the window's size since its last navigation.
|
|
301
|
+
await reapplyMetrics(state.page);
|
|
274
302
|
let image;
|
|
275
303
|
const device = tabState(state.page).emulation?.device;
|
|
276
304
|
try {
|
|
@@ -278,7 +306,7 @@ const commands = {
|
|
|
278
306
|
image = await deviceShot(device, { ref, full, type });
|
|
279
307
|
} else if (ref) {
|
|
280
308
|
const selector = toSelector(ref);
|
|
281
|
-
image = await onElement(selector, () => state.page.locator(selector)
|
|
309
|
+
image = await onElement(selector, () => elementShot(state.page.locator(selector), type));
|
|
282
310
|
} else {
|
|
283
311
|
image = await state.page.screenshot({ fullPage: full, type });
|
|
284
312
|
}
|
package/lib/modes.js
CHANGED
|
@@ -9,7 +9,7 @@ const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
|
|
|
9
9
|
const { recordingOf, finishRecording } = require('./record');
|
|
10
10
|
const { currentCapture, endCapture } = require('./capture');
|
|
11
11
|
const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
|
|
12
|
-
const { hideHighlights } = require('./elements');
|
|
12
|
+
const { hideHighlights, setLabels } = require('./elements');
|
|
13
13
|
const { cursorOf, cursorOff } = require('./cursor');
|
|
14
14
|
|
|
15
15
|
// The modes turned on in a tab, in the order the prompt shows them.
|
|
@@ -26,6 +26,7 @@ function activeModes(p) {
|
|
|
26
26
|
if (routes) modes.push(`routes:${routes}`);
|
|
27
27
|
const highlights = tabState(p).highlights?.size;
|
|
28
28
|
if (highlights) modes.push(`highlight:${highlights}`);
|
|
29
|
+
if (tabState(p).labelsOn) modes.push('labels');
|
|
29
30
|
if (cursorOf(p)) modes.push('cursor');
|
|
30
31
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
31
32
|
if (recordingOf(p)) modes.push('record');
|
|
@@ -76,6 +77,8 @@ function modeOwners(p) {
|
|
|
76
77
|
if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
|
|
77
78
|
for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
78
79
|
for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
|
|
80
|
+
const labels = tabState(p).labelsOn;
|
|
81
|
+
if (labels) owners.push([labels.owner ?? null, 'labels']);
|
|
79
82
|
const cursor = cursorOf(p);
|
|
80
83
|
if (cursor) owners.push([cursor.owner ?? null, 'cursor']);
|
|
81
84
|
const capture = currentCapture();
|
|
@@ -114,6 +117,7 @@ async function allModesOff(mine = false) {
|
|
|
114
117
|
if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
115
118
|
const hidden = await hideHighlights(p, mine);
|
|
116
119
|
if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
|
|
120
|
+
if (tabState(p).labelsOn && ours(tabState(p).labelsOn.owner)) { await setLabels(p, false); done.push('labels off'); }
|
|
117
121
|
if (cursorOf(p) && ours(cursorOf(p).owner) && await cursorOff(p)) done.push('cursor off');
|
|
118
122
|
// Before the page's size is reset, which the recording would otherwise end with.
|
|
119
123
|
const recording = recordingOf(p);
|
package/lib/record.js
CHANGED
|
@@ -8,11 +8,14 @@ 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 {
|
|
12
|
-
const { deviceMetrics, viewportMetrics, settled } = require('./emulation');
|
|
11
|
+
const { reapplyMetrics } = require('./emulation');
|
|
13
12
|
|
|
14
13
|
const FPS = 25;
|
|
15
14
|
const MAX_SECONDS = 3600;
|
|
15
|
+
// While recording: a moment after each action for its result to be seen, and still page at both ends,
|
|
16
|
+
// so a video neither starts nor stops on an action (record on --pause, --lead, --tail).
|
|
17
|
+
const PACING = { pause: 750, lead: 1000, tail: 1000 };
|
|
18
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
19
|
// Frames waiting for ffmpeg past this are dropped rather than held in memory.
|
|
17
20
|
const MAX_QUEUED = 32 * 1024 * 1024;
|
|
18
21
|
// How long ffmpeg may go without progress on the frames still queued for it before it is stopped,
|
|
@@ -130,13 +133,7 @@ function recordingOf(p) {
|
|
|
130
133
|
|
|
131
134
|
async function restoreMetrics(p, rec) {
|
|
132
135
|
if (rec?.finishing) return;
|
|
133
|
-
|
|
134
|
-
if (!tab.viewport && !tab.emulation?.device) return;
|
|
135
|
-
const kept = await keptSession(p);
|
|
136
|
-
if (rec?.finishing) return;
|
|
137
|
-
if (tab.viewport) await viewportMetrics(kept, tab.viewport);
|
|
138
|
-
else if (tab.emulation?.device) await deviceMetrics(kept, tab.emulation.device);
|
|
139
|
-
await settled(p);
|
|
136
|
+
await reapplyMetrics(p);
|
|
140
137
|
}
|
|
141
138
|
|
|
142
139
|
// The tabs recording now, for the modes they count as.
|
|
@@ -145,7 +142,7 @@ function recordingPages() {
|
|
|
145
142
|
return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
|
|
146
143
|
}
|
|
147
144
|
|
|
148
|
-
async function startRecording(p, file, seconds, format) {
|
|
145
|
+
async function startRecording(p, file, seconds, format, keepSteps, pacing) {
|
|
149
146
|
const exe = findFfmpeg(format);
|
|
150
147
|
// As screenshot does: a tab that has not been in front since it was emulated as a phone can
|
|
151
148
|
// be drawn at the window's size.
|
|
@@ -159,7 +156,7 @@ async function startRecording(p, file, seconds, format) {
|
|
|
159
156
|
const rec = {
|
|
160
157
|
page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
|
|
161
158
|
size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
|
|
162
|
-
ffmpegError: '', starting: true, scale: 1, steps: [], step: null, stepsFile: null,
|
|
159
|
+
ffmpegError: '', starting: true, scale: 1, keepSteps, pacing, steps: [], step: null, stepsFile: null,
|
|
163
160
|
holdFrames: false, restarting: false, navigationVersion: 0, restoreError: null, mainFrameId: null, screencastParams: null,
|
|
164
161
|
};
|
|
165
162
|
// The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
|
|
@@ -260,10 +257,10 @@ async function startRecording(p, file, seconds, format) {
|
|
|
260
257
|
}, seconds * 1000);
|
|
261
258
|
rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
|
|
262
259
|
p.once('close', rec.onClose);
|
|
263
|
-
// A new page, loaded or routed to by a single-page app, is a step of its own
|
|
264
|
-
//
|
|
260
|
+
// A new page, loaded or routed to by a single-page app, is a step of its own, for whoever edits the
|
|
261
|
+
// video to cut or caption there. Its URL is left out, as a step's values are.
|
|
265
262
|
rec.onNavigated = frame => {
|
|
266
|
-
if (frame !== p.mainFrame() || rec.finishing) return;
|
|
263
|
+
if (frame !== p.mainFrame() || rec.finishing || !keepSteps) return;
|
|
267
264
|
const t = videoTime(rec);
|
|
268
265
|
// An app that sets its URL twice in a row (push, then replace) is one new page.
|
|
269
266
|
const last = rec.steps[rec.steps.length - 1];
|
|
@@ -293,7 +290,7 @@ function spawnFfmpeg(rec) {
|
|
|
293
290
|
// record on, still starting it, says so itself.
|
|
294
291
|
if (!rec.finishing) {
|
|
295
292
|
rec.exitedEarly = true;
|
|
296
|
-
finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
293
|
+
finishRecording(rec, 'ffmpeg exited while recording, which ended it (help record --all)').then(text => { if (!rec.starting) out.notice(text, rec.page); });
|
|
297
294
|
}
|
|
298
295
|
}));
|
|
299
296
|
return ff;
|
|
@@ -369,7 +366,7 @@ async function finishFile(rec, shuttingDown) {
|
|
|
369
366
|
return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
|
|
370
367
|
}
|
|
371
368
|
const notes = [];
|
|
372
|
-
if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
|
|
369
|
+
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)`);
|
|
373
370
|
if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
|
|
374
371
|
// An ffmpeg stopped before it finished leaves only what it had written, which it writes in
|
|
375
372
|
// blocks: seconds of video short of the frames it was sent, and no length in the file's header.
|
|
@@ -385,7 +382,7 @@ async function finishFile(rec, shuttingDown) {
|
|
|
385
382
|
|
|
386
383
|
// The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
|
|
387
384
|
// seconds, and the element or point it acted on in the video's pixels, saved next to the video for
|
|
388
|
-
// whoever edits it (a
|
|
385
|
+
// whoever edits it (a caption or a cut at a step, say). Only that: watch is the log of what happened.
|
|
389
386
|
|
|
390
387
|
// The video's clock: frames are written against the wall clock since the first, less those dropped.
|
|
391
388
|
function videoTime(rec) {
|
|
@@ -398,7 +395,12 @@ function videoTime(rec) {
|
|
|
398
395
|
const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
|
|
399
396
|
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
400
397
|
function stepLabel(cmd, args) {
|
|
401
|
-
if (WHOLE.has(cmd))
|
|
398
|
+
if (WHOLE.has(cmd)) {
|
|
399
|
+
const words = args.replace(/\s+--modifiers=\S+/, '').trim();
|
|
400
|
+
// An element's selector as written, without the quotes it needed on the command line (click's button after it).
|
|
401
|
+
const parsed = /^mouse/.test(cmd) ? null : splitSelector(words);
|
|
402
|
+
return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
|
|
403
|
+
}
|
|
402
404
|
if (!TARGET_FIRST.has(cmd)) return cmd;
|
|
403
405
|
const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
|
|
404
406
|
const parsed = splitSelector(rest);
|
|
@@ -406,8 +408,14 @@ function stepLabel(cmd, args) {
|
|
|
406
408
|
return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
|
|
407
409
|
}
|
|
408
410
|
|
|
409
|
-
|
|
411
|
+
// The recording that keeps its steps (record on --steps), if the tab has one.
|
|
412
|
+
function stepsOf(p) {
|
|
410
413
|
const rec = recordingOf(p);
|
|
414
|
+
return rec?.keepSteps ? rec : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function stepStarted(p, command) {
|
|
418
|
+
const rec = stepsOf(p);
|
|
411
419
|
if (!rec || rec.finishing) return null;
|
|
412
420
|
// After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
|
|
413
421
|
rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
|
|
@@ -424,7 +432,7 @@ function stepEnded(step, failed) {
|
|
|
424
432
|
|
|
425
433
|
// The element (box) or point the running step acts on, in CSS pixels.
|
|
426
434
|
function stepTarget(p, box, point) {
|
|
427
|
-
const rec =
|
|
435
|
+
const rec = stepsOf(p);
|
|
428
436
|
if (!rec?.step) return;
|
|
429
437
|
const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
|
|
430
438
|
rec.step.box = scaled(box);
|
|
@@ -433,13 +441,20 @@ function stepTarget(p, box, point) {
|
|
|
433
441
|
|
|
434
442
|
// Where the cursor went (cursor on), and when: a line of its own, not a command's step.
|
|
435
443
|
function cursorStep(p, what, point, ms) {
|
|
436
|
-
const rec =
|
|
444
|
+
const rec = stepsOf(p);
|
|
437
445
|
if (!rec || rec.finishing) return;
|
|
438
446
|
const t0 = videoTime(rec);
|
|
439
447
|
const scaled = Math.round(point.x * rec.scale);
|
|
440
448
|
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 });
|
|
441
449
|
}
|
|
442
450
|
|
|
451
|
+
// The pause after an action on a recording tab, for its result to be seen (record on --pause).
|
|
452
|
+
const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
|
|
453
|
+
async function pauseAfter(p, cmd) {
|
|
454
|
+
const rec = p && !p.isClosed() ? recordingOf(p) : null;
|
|
455
|
+
if (rec && !rec.finishing && rec.pacing.pause && ACTIONS.has(cmd)) await sleep(rec.pacing.pause);
|
|
456
|
+
}
|
|
457
|
+
|
|
443
458
|
function stepsPath(file) {
|
|
444
459
|
return file.replace(/\.[^./]+$/, '') + '.steps.txt';
|
|
445
460
|
}
|
|
@@ -447,7 +462,7 @@ function stepsPath(file) {
|
|
|
447
462
|
// 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
|
|
448
463
|
// pixels, is - - - -), then the command. Plain words, for a shell script to read.
|
|
449
464
|
function writeSteps(rec, seconds) {
|
|
450
|
-
if (!rec.steps.length) return null;
|
|
465
|
+
if (!rec.keepSteps || !rec.steps.length) return null;
|
|
451
466
|
const file = stepsPath(rec.file);
|
|
452
467
|
const lines = [
|
|
453
468
|
`# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
|
|
@@ -491,6 +506,12 @@ async function finishAll() {
|
|
|
491
506
|
|
|
492
507
|
onShutdown(finishAll);
|
|
493
508
|
|
|
509
|
+
// The pacing options record on was given, where they differ from the defaults.
|
|
510
|
+
function pacingText(pacing) {
|
|
511
|
+
const changed = Object.keys(PACING).filter(k => pacing[k] !== PACING[k]).map(k => `--${k}=${pacing[k]}`);
|
|
512
|
+
return changed.length ? `, ${changed.join(' ')}` : '';
|
|
513
|
+
}
|
|
514
|
+
|
|
494
515
|
function statusText(p) {
|
|
495
516
|
const rec = recordingOf(p);
|
|
496
517
|
if (!rec) {
|
|
@@ -500,8 +521,8 @@ function statusText(p) {
|
|
|
500
521
|
}
|
|
501
522
|
const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
|
|
502
523
|
const size = rec.size ? `, ${rec.size.width}x${rec.size.height}` : ', no frame yet';
|
|
503
|
-
const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size` : '';
|
|
504
|
-
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.`;
|
|
524
|
+
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)` : '';
|
|
525
|
+
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.`;
|
|
505
526
|
}
|
|
506
527
|
|
|
507
528
|
// A file word, as given or as playwright-cli's --filename=<file> (or --filename <file>); any other
|
|
@@ -509,9 +530,17 @@ function statusText(p) {
|
|
|
509
530
|
function fileWords(words, usage) {
|
|
510
531
|
const files = [];
|
|
511
532
|
const rest = [];
|
|
533
|
+
let steps = false;
|
|
534
|
+
const pacing = {};
|
|
512
535
|
for (let i = 0; i < words.length; i++) {
|
|
513
536
|
const word = words[i];
|
|
514
|
-
|
|
537
|
+
const timing = /^--(pause|lead|tail)(?:=(.*))?$/.exec(word);
|
|
538
|
+
if (word === '--steps') steps = true;
|
|
539
|
+
else if (timing) {
|
|
540
|
+
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]]})`);
|
|
541
|
+
pacing[timing[1]] = Number(timing[2]);
|
|
542
|
+
}
|
|
543
|
+
else if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
|
|
515
544
|
else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
|
|
516
545
|
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)}`);
|
|
517
546
|
// playwright-cli's video-start --size: here the video is the page's size.
|
|
@@ -521,7 +550,7 @@ function fileWords(words, usage) {
|
|
|
521
550
|
else files.push(word);
|
|
522
551
|
}
|
|
523
552
|
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}`);
|
|
524
|
-
return { file: files.length ? path.resolve(files[0]) : null, rest };
|
|
553
|
+
return { file: files.length ? path.resolve(files[0]) : null, rest, steps, pacing };
|
|
525
554
|
}
|
|
526
555
|
|
|
527
556
|
function checkFile(file, format) {
|
|
@@ -550,7 +579,7 @@ const commands = {
|
|
|
550
579
|
async record(args) {
|
|
551
580
|
// A file may be quoted, with spaces in it.
|
|
552
581
|
const words = shellWords(args);
|
|
553
|
-
const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
|
|
582
|
+
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})`;
|
|
554
583
|
if (!words.length) {
|
|
555
584
|
await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
|
|
556
585
|
out.log(statusText(state.page));
|
|
@@ -562,8 +591,10 @@ const commands = {
|
|
|
562
591
|
const noTab = !state.page || state.page.isClosed();
|
|
563
592
|
if (noTab && what === 'on') throw new Error(NO_TAB);
|
|
564
593
|
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
565
|
-
const { file: named, rest: numbers } = fileWords(rest, usage);
|
|
594
|
+
const { file: named, rest: numbers, steps, pacing } = fileWords(rest, usage);
|
|
566
595
|
if (what === 'off') {
|
|
596
|
+
if (Object.keys(pacing).length) throw new Error(`--${Object.keys(pacing)[0]} goes on record on`);
|
|
597
|
+
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');
|
|
567
598
|
if (numbers.length) throw new Error(usage);
|
|
568
599
|
const rec = noTab ? null : recordingOf(state.page);
|
|
569
600
|
if (!rec) {
|
|
@@ -585,6 +616,8 @@ const commands = {
|
|
|
585
616
|
// Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
|
|
586
617
|
const other = (rec.owner ?? null) !== (state.client ?? null);
|
|
587
618
|
const clientName = c => c ?? 'the unnamed client';
|
|
619
|
+
// The tail: still page after the last action. Only record off has one; the seconds limit is a hard stop.
|
|
620
|
+
if (!rec.finishing) await sleep(rec.pacing.tail);
|
|
588
621
|
const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
|
|
589
622
|
if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
|
|
590
623
|
if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
|
|
@@ -625,7 +658,7 @@ const commands = {
|
|
|
625
658
|
}
|
|
626
659
|
let rec;
|
|
627
660
|
try {
|
|
628
|
-
rec = await startRecording(state.page, file, seconds, format);
|
|
661
|
+
rec = await startRecording(state.page, file, seconds, format, steps, { ...PACING, ...pacing });
|
|
629
662
|
} catch (error) {
|
|
630
663
|
fs.rmSync(file, { force: true });
|
|
631
664
|
throw error;
|
|
@@ -639,9 +672,13 @@ const commands = {
|
|
|
639
672
|
return;
|
|
640
673
|
}
|
|
641
674
|
rec.starting = false;
|
|
675
|
+
// The lead: still page before the first action, counted from the first frame.
|
|
676
|
+
const until = Date.now() + 2000;
|
|
677
|
+
while (rec.firstFrameAt === null && !rec.finishing && Date.now() < until) await sleep(20);
|
|
678
|
+
if (rec.firstFrameAt !== null && !rec.finishing) await sleep(Math.max(0, rec.pacing.lead - (Date.now() - rec.firstFrameAt)));
|
|
642
679
|
const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
|
|
643
|
-
out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
|
|
680
|
+
out.log(`Recording the selected tab to ${file} ${limit}${steps ? ', with its steps' : ''}${pacingText(rec.pacing)}; record off stops it and saves the file.`);
|
|
644
681
|
},
|
|
645
682
|
};
|
|
646
683
|
|
|
647
|
-
module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
|
|
684
|
+
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
|
@@ -15,16 +15,18 @@ shared session: a person can have their own tabs open in it and be using it whil
|
|
|
15
15
|
`npx pw-repl@latest` in its place, in that same form for every command, so each one runs the same
|
|
16
16
|
version; from a clone, `<clone>/bin/pw-repl.js`.
|
|
17
17
|
|
|
18
|
-
`pw-repl where` also names the skill of the pw-repl you run: `skill: e5f6a7b8 (pw-repl 0.4.3)`. When
|
|
19
|
-
is not the skill named at the top of this file, tell the user
|
|
18
|
+
`pw-repl where` also names the skill of the pw-repl you run: `skill: e5f6a7b8 (pw-repl 0.4.3)`. When
|
|
19
|
+
that is not the skill named at the top of this file, tell the user once, in these words and in passing,
|
|
20
|
+
with whatever else you are telling them, and carry on with the task:
|
|
20
21
|
|
|
21
|
-
- if this file's pw-repl version is newer than that one, pw-repl is behind:
|
|
22
|
-
|
|
22
|
+
- if this file's pw-repl version is newer than that one, pw-repl is behind: "🔄 pw-repl is older than
|
|
23
|
+
this skill (pw-repl <that version>, skill from <this file's>); `npx pw-repl@latest` or
|
|
23
24
|
`npm install -g pw-repl` updates it."
|
|
24
|
-
- otherwise, the skill is:
|
|
25
|
-
|
|
26
|
-
replaced."
|
|
27
|
-
|
|
25
|
+
- otherwise, the skill is: "🔄 This pw-repl skill is out of date (from pw-repl <this file's version>;
|
|
26
|
+
<that version> has a newer one). If you'd like me to update it, just ask: everything but its Custom
|
|
27
|
+
rules section would be replaced." Update it only when the user asks for that; a yes to something else
|
|
28
|
+
is not one. To update it, save what `pw-repl skill` prints over this file, with this file's Custom
|
|
29
|
+
rules section in place of the new one's.
|
|
28
30
|
|
|
29
31
|
## Arguments
|
|
30
32
|
|
|
@@ -34,11 +36,12 @@ the sections below are how to carry it out. With no args, get a REPL running and
|
|
|
34
36
|
|
|
35
37
|
## Start it
|
|
36
38
|
|
|
37
|
-
`pw-repl where`
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
Run `pw-repl where` first, before anything else (with `-e <socket>` for a REPL you were told is on one),
|
|
40
|
+
and check the skill it names against the top of this file. It says whether a REPL is running and how
|
|
41
|
+
`send` reaches it, or that one is still starting (retry shortly; it exits 64 until the REPL serves, as
|
|
42
|
+
when none is running). Right after you start one, for a moment before its process is up, it can still
|
|
43
|
+
say none is there: retry for a few seconds before taking that as a failed start. There are three ways to
|
|
44
|
+
run one; each takes an optional start URL, which opens in a new tab.
|
|
42
45
|
|
|
43
46
|
- `pw-repl serve --background` runs it detached, with a command server on `/tmp/playwright-repl.sock`
|
|
44
47
|
(owner-only) and its output in `/tmp/playwright-repl.log`. `pw-repl attach` shows everything it does
|
|
@@ -63,10 +66,15 @@ opens in a new tab.
|
|
|
63
66
|
tmux send-keys -t playwright-repl Enter
|
|
64
67
|
```
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
A REPL of your own goes on a socket of its own, named with `-e` on every command, `serve` included:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pw-repl serve --background --launch -e /tmp/mine.sock
|
|
73
|
+
pw-repl send -e /tmp/mine.sock tab new http://localhost:3000
|
|
74
|
+
pw-repl stop -e /tmp/mine.sock
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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
78
|
(`/tmp/mine.log`), appended to run after run, with a line where each starts and stops (past 5 MB, a
|
|
71
79
|
start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along without a terminal to attach from.
|
|
72
80
|
`serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no access control.
|
|
@@ -114,6 +122,10 @@ spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL
|
|
|
114
122
|
`pw-repl where` says which one a command would reach (the server, or the tmux pane running the
|
|
115
123
|
REPL), or why neither is reachable, without running anything.
|
|
116
124
|
|
|
125
|
+
`send --file take.txt` runs a file of commands, one per line, as typed at the prompt (no shell quoting;
|
|
126
|
+
files relative to your folder, as on `send`'s command line; `#` comments), and stops at the first that fails, saying which line.
|
|
127
|
+
It is the way to record a video without gaps (`help record`), and to keep a repro to run again.
|
|
128
|
+
|
|
117
129
|
`send` takes one command; chain several in your shell. Use `&&` when a step only makes sense if the one
|
|
118
130
|
before it worked: it stops at any exit status but 0, a failure and an outcome not confirmed included.
|
|
119
131
|
Use `;` when the steps do not depend on each other. `-t` is each command's own limit. Write each
|
|
@@ -157,7 +169,7 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
157
169
|
|
|
158
170
|
## Learn the commands
|
|
159
171
|
|
|
160
|
-
Run `pw-repl send help`. It lists
|
|
172
|
+
Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
|
|
161
173
|
gives usage and caveats, and `help --all` prints everything at once. pw-repl answers it itself: it needs
|
|
162
174
|
no running REPL, and never reaches one. The
|
|
163
175
|
help is the command reference; this file does not repeat it. If you know playwright-cli, its command
|
|
@@ -203,11 +215,11 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
203
215
|
`modes off` turns off every mode in every tab.
|
|
204
216
|
- Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
|
|
205
217
|
- 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)
|
|
207
|
-
|
|
208
|
-
- For a video, `help video` says
|
|
209
|
-
on`
|
|
210
|
-
what they see.
|
|
218
|
+
without changing the page (`--style=<css>` restyles it; `highlight off` hides them), without
|
|
219
|
+
Playwright's locator label unless `highlight --labels on`.
|
|
220
|
+
- For a video, `help video` says how: a take written as a file and sent with `send --file`, `record on`
|
|
221
|
+
around it, `cursor on` for a pointer, and the pacing recording adds for the viewer; a screen recorder on
|
|
222
|
+
the user's machine records what they see.
|
|
211
223
|
|
|
212
224
|
## Environment
|
|
213
225
|
|
|
@@ -223,8 +235,8 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
223
235
|
a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
|
|
224
236
|
prints each file's path; other REPLs may save theirs in the same place. Remove only your
|
|
225
237
|
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`).
|
|
238
|
+
- `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `serve`
|
|
239
|
+
serves on and `send` looks for when neither is given (default `/tmp/playwright-repl.sock`).
|
|
228
240
|
|
|
229
241
|
## Custom rules
|
|
230
242
|
|