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/elements.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Elements and the mouse: finding the match to act on, and the commands that click, type and choose.
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const { state } = require('./state');
|
|
4
|
+
const { state, onShutdown } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { takeOptions } = require('./cli-names');
|
|
7
7
|
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
|
-
const { numbers } = require('./util');
|
|
8
|
+
const { numbers, hints } = require('./util');
|
|
9
|
+
const { tabState } = require('./tabstate');
|
|
10
|
+
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
11
|
+
const { recordingOf, stepTarget } = require('./record');
|
|
9
12
|
|
|
10
13
|
// The type a page sees for a file it is given, from its extension.
|
|
11
14
|
const MIME_TYPES = {
|
|
@@ -176,19 +179,25 @@ function mouseButton(args, name) {
|
|
|
176
179
|
// click, dblclick and hover: on the match chooseMatch picks, or as Playwright does.
|
|
177
180
|
// A page that renders the chosen element again (as React lists do) replaces it before it is
|
|
178
181
|
// acted on; it is chosen again then, as Playwright finds a locator's element again.
|
|
179
|
-
async function pointerAction(selector, act, options = {}) {
|
|
182
|
+
async function pointerAction(selector, act, options = {}, clicks = 0) {
|
|
180
183
|
const TIMEOUT = 5000;
|
|
181
184
|
const deadline = Date.now() + TIMEOUT;
|
|
182
185
|
const left = () => Math.max(1000, deadline - Date.now());
|
|
183
186
|
for (;;) {
|
|
184
187
|
const chosen = await onElement(selector, () => chooseMatch(selector, left()));
|
|
185
188
|
if (!chosen) {
|
|
186
|
-
// Only counted so far, so the whole time is Playwright's, as it was before
|
|
187
|
-
|
|
189
|
+
// Only counted so far, so the whole time is Playwright's, as it was before; the cursor's glide, if it
|
|
190
|
+
// is on, waits for the element as part of it.
|
|
191
|
+
const glided = Date.now();
|
|
192
|
+
const point = await onElement(selector, () => aimAt(state.page, state.page.locator(selector).first(), TIMEOUT));
|
|
193
|
+
await onElement(selector, () => act(state.page, selector, { ...options, timeout: Math.max(1000, TIMEOUT - (Date.now() - glided)) }));
|
|
194
|
+
await landed(point, clicks);
|
|
188
195
|
return '';
|
|
189
196
|
}
|
|
190
197
|
try {
|
|
198
|
+
const point = await aimAt(state.page, chosen.handle, left());
|
|
191
199
|
await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
|
|
200
|
+
await landed(point, clicks);
|
|
192
201
|
return chosen.position === 1 ? '' : ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
|
|
193
202
|
} catch (error) {
|
|
194
203
|
if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
|
|
@@ -198,16 +207,103 @@ async function pointerAction(selector, act, options = {}) {
|
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
// After a pointer action: Playwright's mouse is where it acted, at the centre the cursor glided to,
|
|
211
|
+
// or somewhere the REPL does not know without the cursor. A click shows where it landed.
|
|
212
|
+
async function landed(point, clicks) {
|
|
213
|
+
tabState(state.page).mouseAt = point;
|
|
214
|
+
if (clicks) await showClick(state.page, clicks);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Finds the element a command without a pointer acts on (fill, select...), for the cursor to glide
|
|
218
|
+
// to, as someone would go to it first, and for a recording's steps; the mouse itself stays where it is. What is left of the command's
|
|
219
|
+
// 5s is its timeout, so the glide does not make it wait longer for an element that never comes.
|
|
220
|
+
async function aimFor(selector) {
|
|
221
|
+
if (!aiming(state.page)) return 5000;
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
await onElement(selector, () => aimAt(state.page, state.page.locator(selector).first(), 5000));
|
|
224
|
+
return Math.max(1000, 5000 - (Date.now() - started));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// While the tab records, a wheel turn goes in small steps, eased, over 400ms to 1s by its length, so the
|
|
228
|
+
// video scrolls rather than jumps; the same distance in all.
|
|
229
|
+
async function smoothWheel(dx, dy) {
|
|
230
|
+
const ms = Math.min(1000, Math.max(400, Math.hypot(dx, dy) * 0.8));
|
|
231
|
+
const steps = Math.max(1, Math.round(ms / 25));
|
|
232
|
+
const ease = t => t * t * (3 - 2 * t);
|
|
233
|
+
let sentX = 0;
|
|
234
|
+
let sentY = 0;
|
|
235
|
+
for (let i = 1; i <= steps; i += 1) {
|
|
236
|
+
const x = Math.round(dx * ease(i / steps));
|
|
237
|
+
const y = Math.round(dy * ease(i / steps));
|
|
238
|
+
if (x !== sentX || y !== sentY) await state.page.mouse.wheel(x - sentX, y - sentY);
|
|
239
|
+
sentX = x;
|
|
240
|
+
sentY = y;
|
|
241
|
+
await new Promise(resolve => setTimeout(resolve, ms / steps));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// While the tab records, text is typed at a pace a viewer can follow, steady rather than human-like,
|
|
246
|
+
// and faster for long text, so it takes 8s at most: a paragraph at 60ms a key would outrun send's 20s
|
|
247
|
+
// default and hold every client's commands meanwhile. Otherwise at once, as before.
|
|
248
|
+
function typingPace(text) {
|
|
249
|
+
if (!recordingOf(state.page) || !text.length) return 0;
|
|
250
|
+
return Math.round(Math.min(60, 8000 / text.length));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// check, uncheck and upload's button click it at its centre, where aimFor put the cursor.
|
|
254
|
+
async function clickedHere() {
|
|
255
|
+
const cursor = cursorOf(state.page);
|
|
256
|
+
if (!cursor) return;
|
|
257
|
+
tabState(state.page).mouseAt = { x: cursor.x, y: cursor.y };
|
|
258
|
+
await showClick(state.page);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The mouse commands act where the mouse is: the cursor goes back there first, if it went elsewhere.
|
|
262
|
+
async function glideToMouse() {
|
|
263
|
+
const at = tabState(state.page).mouseAt;
|
|
264
|
+
if (!at) return;
|
|
265
|
+
stepTarget(state.page, null, at);
|
|
266
|
+
await glideTo(state.page, at.x, at.y);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Highlights: Playwright's own overlay, as playwright-cli's highlight draws it. Each is a mode of its
|
|
270
|
+
// tab, so modes shows it and modes off hides it, and none is left on the page when the REPL stops.
|
|
271
|
+
function highlightsOf(p) {
|
|
272
|
+
if (!tabState(p).highlights) {
|
|
273
|
+
tabState(p).highlights = new Map();
|
|
274
|
+
// A new document drops the overlay; a page that routes on the client keeps it.
|
|
275
|
+
p.on('domcontentloaded', () => tabState(p).highlights?.clear());
|
|
276
|
+
}
|
|
277
|
+
return tabState(p).highlights;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Hides a tab's highlights, or only the running client's (mine).
|
|
281
|
+
async function hideHighlights(p, mine = false) {
|
|
282
|
+
const highlights = tabState(p).highlights;
|
|
283
|
+
if (!highlights?.size) return 0;
|
|
284
|
+
const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
|
|
285
|
+
if (hidden.length === highlights.size) await p.hideHighlight();
|
|
286
|
+
else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
|
|
287
|
+
for (const [selector] of hidden) highlights.delete(selector);
|
|
288
|
+
return hidden.length;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
onShutdown(async () => {
|
|
292
|
+
if (!state.browser || state.connectionLost) return;
|
|
293
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
294
|
+
await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
|
|
295
|
+
});
|
|
296
|
+
|
|
201
297
|
const commands = {
|
|
202
298
|
async click(args) {
|
|
203
299
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
204
|
-
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options);
|
|
300
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, 1);
|
|
205
301
|
out.log(`Clicked: ${shown}${which}`);
|
|
206
302
|
},
|
|
207
303
|
|
|
208
304
|
async dblclick(args) {
|
|
209
305
|
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
210
|
-
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options);
|
|
306
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
|
|
211
307
|
out.log(`Double-clicked: ${shown}${which}`);
|
|
212
308
|
},
|
|
213
309
|
|
|
@@ -217,22 +313,64 @@ const commands = {
|
|
|
217
313
|
out.log(`Hovered: ${args.trim()}${which}`);
|
|
218
314
|
},
|
|
219
315
|
|
|
316
|
+
async highlight(args) {
|
|
317
|
+
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
|
|
318
|
+
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
|
|
319
|
+
const style = found.find(o => o.name === 'style');
|
|
320
|
+
const hide = found.some(o => o.name === 'hide');
|
|
321
|
+
const highlights = highlightsOf(state.page);
|
|
322
|
+
if (!rest && !found.length) {
|
|
323
|
+
if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
|
|
324
|
+
for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
325
|
+
out.log(hints([['highlight --hide [<selector>]', 'hide one, or all']]));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (rest === 'off' && !found.length || hide && !rest) {
|
|
329
|
+
const count = await hideHighlights(state.page);
|
|
330
|
+
out.log(count ? `Hid ${count} highlight${count === 1 ? '' : 's'}` : 'Nothing was highlighted in the selected tab.');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!rest || style && (hide || !style.value)) throw new Error(usage);
|
|
334
|
+
const selector = soleSelector(rest, usage);
|
|
335
|
+
const locator = state.page.locator(selector);
|
|
336
|
+
if (hide) {
|
|
337
|
+
if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
|
|
338
|
+
await locator.hideHighlight();
|
|
339
|
+
highlights.delete(selector);
|
|
340
|
+
out.log(`Hid the highlight on ${rest}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const count = await locator.count();
|
|
344
|
+
if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
|
|
345
|
+
// Drawn again, with its new style, rather than twice.
|
|
346
|
+
if (highlights.has(selector)) await locator.hideHighlight();
|
|
347
|
+
await locator.highlight(style ? { style: style.value } : {});
|
|
348
|
+
highlights.set(selector, { owner: state.client, style: style?.value || null });
|
|
349
|
+
out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
|
|
350
|
+
},
|
|
351
|
+
|
|
220
352
|
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
221
353
|
async mousemove(args) {
|
|
222
354
|
const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
|
|
223
355
|
if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
|
|
356
|
+
stepTarget(state.page, null, { x, y });
|
|
357
|
+
await glideTo(state.page, x, y);
|
|
224
358
|
await state.page.mouse.move(x, y);
|
|
359
|
+
tabState(state.page).mouseAt = { x, y };
|
|
225
360
|
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
226
361
|
},
|
|
227
362
|
|
|
228
363
|
async mousedown(args) {
|
|
229
364
|
const button = mouseButton(args, 'mousedown');
|
|
365
|
+
await glideToMouse();
|
|
230
366
|
await state.page.mouse.down({ button });
|
|
367
|
+
await showClick(state.page);
|
|
231
368
|
out.log(`Pressed the ${button} button`);
|
|
232
369
|
},
|
|
233
370
|
|
|
234
371
|
async mouseup(args) {
|
|
235
372
|
const button = mouseButton(args, 'mouseup');
|
|
373
|
+
await glideToMouse();
|
|
236
374
|
await state.page.mouse.up({ button });
|
|
237
375
|
out.log(`Released the ${button} button`);
|
|
238
376
|
},
|
|
@@ -241,20 +379,27 @@ const commands = {
|
|
|
241
379
|
async mousewheel(args) {
|
|
242
380
|
const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
|
|
243
381
|
if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
|
|
244
|
-
await
|
|
382
|
+
await glideToMouse();
|
|
383
|
+
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
384
|
+
else await state.page.mouse.wheel(dx, dy);
|
|
245
385
|
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
246
386
|
},
|
|
247
387
|
|
|
248
388
|
async fill(line) {
|
|
249
389
|
const { args, submit } = submitFlag(line);
|
|
250
390
|
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
|
|
251
|
-
|
|
391
|
+
const timeout = await aimFor(selector);
|
|
392
|
+
await onElement(selector, () => state.page.fill(selector, value, { timeout }));
|
|
252
393
|
if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
|
|
253
394
|
out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
|
|
254
395
|
},
|
|
255
396
|
|
|
256
397
|
async type(line) {
|
|
257
|
-
|
|
398
|
+
// --delay=<ms> goes first, so text with --delay in it is still text.
|
|
399
|
+
const paced = /^\s*--delay(?:=|\s+)(\S*)(?:\s+|$)/.exec(line || '');
|
|
400
|
+
if (paced && !/^\d+$/.test(paced[1])) throw new Error('Usage: type --delay=<ms> [<selector>] <text>, the pause between keys, e.g. type --delay=80 #search garden hose');
|
|
401
|
+
const { args, submit } = submitFlag(paced ? line.slice(paced[0].length) : line);
|
|
402
|
+
const delay = text => (paced ? Number(paced[1]) : typingPace(text));
|
|
258
403
|
// One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
|
|
259
404
|
const sole = splitSelector(args);
|
|
260
405
|
if (sole && sole.rest === undefined) {
|
|
@@ -269,12 +414,13 @@ const commands = {
|
|
|
269
414
|
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
270
415
|
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
271
416
|
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
272
|
-
await state.page.keyboard.type(sole.word);
|
|
417
|
+
await state.page.keyboard.type(sole.word, { delay: delay(sole.word) });
|
|
273
418
|
if (submit) await state.page.keyboard.press('Enter');
|
|
274
419
|
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
275
420
|
return;
|
|
276
421
|
}
|
|
277
422
|
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
423
|
+
const timeout = await aimFor(selector);
|
|
278
424
|
// Typed after what the field already holds, as someone clicking into it
|
|
279
425
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
280
426
|
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
@@ -288,8 +434,8 @@ const commands = {
|
|
|
288
434
|
getSelection().removeAllRanges();
|
|
289
435
|
getSelection().addRange(range);
|
|
290
436
|
}
|
|
291
|
-
}, null, { timeout
|
|
292
|
-
await state.page.keyboard.type(value);
|
|
437
|
+
}, null, { timeout }));
|
|
438
|
+
await state.page.keyboard.type(value, { delay: delay(value) });
|
|
293
439
|
if (submit) await state.page.keyboard.press('Enter');
|
|
294
440
|
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
295
441
|
},
|
|
@@ -303,25 +449,32 @@ const commands = {
|
|
|
303
449
|
return;
|
|
304
450
|
}
|
|
305
451
|
const key = keyName(unquote(parsed.rest));
|
|
306
|
-
|
|
452
|
+
const timeout = await aimFor(parsed.selector);
|
|
453
|
+
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout }));
|
|
307
454
|
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
308
455
|
},
|
|
309
456
|
|
|
310
457
|
async select(args) {
|
|
311
458
|
const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
|
|
312
|
-
|
|
459
|
+
const timeout = await aimFor(selector);
|
|
460
|
+
const target = state.page.locator(selector).first();
|
|
461
|
+
await onElement(selector, () => showSelect(state.page, target, value, () => state.page.selectOption(selector, value, { timeout })));
|
|
313
462
|
out.log(`Selected "${value}" in ${shown}`);
|
|
314
463
|
},
|
|
315
464
|
|
|
316
465
|
async check(args) {
|
|
317
466
|
const selector = soleSelector(args, 'check <selector>');
|
|
318
|
-
|
|
467
|
+
const timeout = await aimFor(selector);
|
|
468
|
+
await onElement(selector, () => state.page.check(selector, { timeout }));
|
|
469
|
+
await clickedHere();
|
|
319
470
|
out.log(`Checked: ${args.trim()}`);
|
|
320
471
|
},
|
|
321
472
|
|
|
322
473
|
async uncheck(args) {
|
|
323
474
|
const selector = soleSelector(args, 'uncheck <selector>');
|
|
324
|
-
|
|
475
|
+
const timeout = await aimFor(selector);
|
|
476
|
+
await onElement(selector, () => state.page.uncheck(selector, { timeout }));
|
|
477
|
+
await clickedHere();
|
|
325
478
|
out.log(`Unchecked: ${args.trim()}`);
|
|
326
479
|
},
|
|
327
480
|
|
|
@@ -343,6 +496,7 @@ const commands = {
|
|
|
343
496
|
return { name: path.basename(file), mimeType: mimeType(file), buffer };
|
|
344
497
|
});
|
|
345
498
|
const { selector } = parsed;
|
|
499
|
+
await aimFor(selector);
|
|
346
500
|
await onElement(selector, async () => {
|
|
347
501
|
try {
|
|
348
502
|
await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
|
|
@@ -351,10 +505,11 @@ const commands = {
|
|
|
351
505
|
// A button that opens the file picker itself: click it, and answer the picker.
|
|
352
506
|
const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
|
|
353
507
|
await chooser.setFiles(payloads);
|
|
508
|
+
await clickedHere();
|
|
354
509
|
}
|
|
355
510
|
});
|
|
356
511
|
out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
|
|
357
512
|
},
|
|
358
513
|
};
|
|
359
514
|
|
|
360
|
-
module.exports = { commands, soleSelector, onElement };
|
|
515
|
+
module.exports = { commands, soleSelector, onElement, hideHighlights };
|
package/lib/help.js
CHANGED
|
@@ -74,7 +74,7 @@ fill for selectors with spaces. mousemove and the other mouse commands act at a
|
|
|
74
74
|
},
|
|
75
75
|
inspect: {
|
|
76
76
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
77
|
-
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
77
|
+
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'cursor', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
78
78
|
},
|
|
79
79
|
network: {
|
|
80
80
|
intro: `requests and console record all the time; a capture records only while it runs.`,
|
|
@@ -105,7 +105,7 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
|
|
|
105
105
|
|
|
106
106
|
Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
|
|
107
107
|
|
|
108
|
-
Dialogs are reported and never answered on their own; dialog answers one.`,
|
|
108
|
+
Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.`,
|
|
109
109
|
commands: ['modes', 'dialog', 'help', 'quit'],
|
|
110
110
|
},
|
|
111
111
|
};
|
|
@@ -196,10 +196,22 @@ Examples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada`,
|
|
|
196
196
|
usage: 'type [<selector>] <text> [--submit]',
|
|
197
197
|
summary: 'type key by key after what the field holds; text as for fill',
|
|
198
198
|
detail: `With one word, or one quoted, it is typed into the element that has focus: type "garden hose".
|
|
199
|
-
--submit at the end presses Enter afterwards
|
|
199
|
+
--submit at the end presses Enter afterwards.
|
|
200
|
+
|
|
201
|
+
Keys are sent all at once. While the tab is recording (help record), they are 60ms apart, so the video
|
|
202
|
+
shows the text being typed; long text goes faster, so typing never takes more than 8s, well within
|
|
203
|
+
pw-repl send's 20s default. type --delay=<ms> <selector> <text> sets the gap between keys yourself, with no limit on the
|
|
204
|
+
total; for long text, raise send's -t. It is a gap between keys, not a wait before starting like
|
|
205
|
+
screenshot's --delay. A page that reacts to each key, such as search as you type, may react to each one.`,
|
|
200
206
|
},
|
|
201
207
|
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
|
|
202
|
-
select: {
|
|
208
|
+
select: {
|
|
209
|
+
usage: 'select <selector> <value>',
|
|
210
|
+
summary: 'choose an option in a select (the value is the rest of the line)',
|
|
211
|
+
detail: `The value is an option's value or its label. With the cursor on (help cursor), select also draws the
|
|
212
|
+
select's list opening and the cursor choosing the option, since a native select's list opens outside
|
|
213
|
+
the page, where no screenshot or recording sees it; the page gets the same input either way.`,
|
|
214
|
+
},
|
|
203
215
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
204
216
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
205
217
|
upload: {
|
|
@@ -287,6 +299,27 @@ says how many.`,
|
|
|
287
299
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
288
300
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
289
301
|
inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
|
|
302
|
+
highlight: {
|
|
303
|
+
usage: 'highlight [<selector> [--style=<css>] | off]',
|
|
304
|
+
summary: 'draw a box over elements, for a person or a screenshot',
|
|
305
|
+
detail: `highlight <selector> draws Playwright's highlight over every match, as playwright-cli's does: a
|
|
306
|
+
tinted box, labelled with its locator, that follows the element as the page scrolls or changes, and is
|
|
307
|
+
in screenshots. --style=<css> adds CSS to the box, quoted if it has spaces; this circles e5 in blue:
|
|
308
|
+
highlight e5 --style="outline: 3px solid blue; border-radius: 50%"
|
|
309
|
+
|
|
310
|
+
highlight on its own lists the selected tab's highlights. highlight --hide <selector> hides one, and
|
|
311
|
+
highlight --hide or highlight off hides them all. A highlight is a mode of its tab: modes shows it,
|
|
312
|
+
modes off hides it, and the REPL hides every one as it stops. Loading a new page drops them; a page that
|
|
313
|
+
changes its route without loading keeps them.
|
|
314
|
+
|
|
315
|
+
The box is drawn over the page, in an element of Playwright's own added to it (x-pw-glass); the page's
|
|
316
|
+
own elements are not changed, and clicks and typing reach them as before. Playwright always labels the
|
|
317
|
+
box, and the label can cover what is just below it. For a screenshot without one, eval can outline
|
|
318
|
+
the element itself (with border-radius: 50% for a circle). That changes the page, so put it back
|
|
319
|
+
afterwards, as it was if it had a style of its own:
|
|
320
|
+
eval document.querySelector("#rate-3").style.outline = "3px solid blue"
|
|
321
|
+
eval document.querySelector("#rate-3").style.outline = ""`,
|
|
322
|
+
},
|
|
290
323
|
screenshot: {
|
|
291
324
|
usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
|
|
292
325
|
summary: 'save a PNG of the viewport, --full page or an element',
|
|
@@ -301,6 +334,28 @@ is at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on,
|
|
|
301
334
|
phone shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit.
|
|
302
335
|
|
|
303
336
|
--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.`,
|
|
337
|
+
},
|
|
338
|
+
cursor: {
|
|
339
|
+
usage: 'cursor [on [<selector> | <x> <y>] | off]',
|
|
340
|
+
summary: 'draw a pointer where the REPL acts, for videos',
|
|
341
|
+
detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things
|
|
342
|
+
happening with nothing pointing at them. cursor on draws a pointer in the selected tab. After that, these
|
|
343
|
+
commands move it smoothly to their element or point before they act: click, dblclick, hover, fill, type
|
|
344
|
+
<selector>, press <selector>, select, check, uncheck, upload and mousemove. Each move takes 150 to 600ms,
|
|
345
|
+
depending on the distance (300 to 1000ms while the tab is recording), and the command waits for it. Clicks (click, dblclick, check, uncheck,
|
|
346
|
+
mousedown, and upload when it clicks a button) show a ring where they land.
|
|
347
|
+
|
|
348
|
+
It shows only the REPL's input, from every client, never the user's own mouse. Only the drawing moves: no
|
|
349
|
+
mouse events are sent along the way, so the page gets the same input as with the cursor off. fill, select
|
|
350
|
+
and the like move the drawing to their element but leave the real mouse where it was; mousedown, mouseup
|
|
351
|
+
and mousewheel act at the real mouse, so the pointer moves back there first.
|
|
352
|
+
|
|
353
|
+
cursor on fades it in where the mouse last went (or the middle), cursor on <selector> or <x> <y> fades it in
|
|
354
|
+
there instead (a video's first element, say), cursor off fades it out, and cursor alone says whether it
|
|
355
|
+
is on and where. It is a
|
|
356
|
+
mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at the same
|
|
357
|
+
spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it takes no
|
|
358
|
+
clicks, and it points at elements inside frames too.`,
|
|
304
359
|
},
|
|
305
360
|
record: {
|
|
306
361
|
usage: 'record [on [file] [seconds] | off [file]]',
|
|
@@ -308,25 +363,33 @@ phone shows: a page laid out wider than the phone (no viewport meta tag) comes o
|
|
|
308
363
|
detail: `record on starts recording the selected tab, and record off stops it and saves the file: its
|
|
309
364
|
path, length, size and bytes. record on its own, or record off when nothing is recording, says whether the
|
|
310
365
|
selected tab is recording, and how your last recording ended (record off, its seconds, its tab closing
|
|
311
|
-
or the REPL stopping) with what record off would have said.
|
|
366
|
+
or the REPL stopping) with what record off would have said. tab close says what became of a recording it ended. A REPL stopped with pw-repl stop cannot be asked: stop prints what became of each recording it ended, and of one that ended by itself that record has not shown since.
|
|
312
367
|
|
|
313
|
-
record off <file> saves it under that name instead. --filename=<file> is file too, as playwright-cli
|
|
368
|
+
record off <file> saves it under that name instead. The format is set by record on: a name that does not end as its file does, or cannot be used, is refused, and it goes on recording. --filename=<file> is file too, as playwright-cli
|
|
314
369
|
writes it.
|
|
315
370
|
|
|
316
|
-
The file
|
|
371
|
+
The file's ending picks the format: record on clip.mp4 records an MP4, and record on clip.webm, or record on with no file, a WebM (there is no --mp4 or --webm). The file is absolute or relative to the folder pw-repl send runs in, quoted if it has spaces, and
|
|
317
372
|
owner-only, as a screenshot is; it never replaces a file. Without one, it is recording-<timestamp>.webm where screenshot saves. With seconds,
|
|
318
|
-
it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab.
|
|
373
|
+
it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab. Another client with the tab selected can stop it too, as it can any mode; both are then told whose it was.
|
|
319
374
|
|
|
320
375
|
It brings the tab to the front of its window first, as screenshot does. It records the page only,
|
|
321
|
-
from inside the browser, at the
|
|
376
|
+
from inside the browser, at the viewport's size in CSS pixels, scrollbars included, made even (an
|
|
322
377
|
odd width or height loses a pixel), a phone's too under emulate mobile: a Pixel 7's is 412 wide, less
|
|
323
|
-
sharp than the phone draws it. It has no pointer and no browser toolbars,
|
|
378
|
+
sharp than the phone draws it. It has no pointer (cursor on draws one) and no browser toolbars,
|
|
324
379
|
and a tab behind another, hidden or headless keeps recording. Set the size first with viewport
|
|
325
380
|
(1280x720 fills a 16:9 player); a page that changes size while recording is fitted into the first size.
|
|
326
381
|
Chrome sends a frame as the page draws, and a still page keeps its real length.
|
|
327
382
|
|
|
328
|
-
|
|
329
|
-
|
|
383
|
+
While a tab records, type sends keys at a pace the video can show (help type), and mousewheel scrolls in
|
|
384
|
+
small eased steps over up to 1s rather than at once; fill still sets the whole
|
|
385
|
+
value at once, so use type for text the video should show being typed. record off also saves the steps
|
|
386
|
+
next to the video, as clip.steps.txt for clip.webm: one line for each command run on the tab while it
|
|
387
|
+
recorded, with when it began and ended in seconds into the video, the element or point it acted on in the
|
|
388
|
+
video's pixels, and the command with what it acts on, never what it types or chooses, the code it runs or
|
|
389
|
+
the URL it goes to. A (page) line marks each new page, loaded or routed to by a single-page app. They
|
|
390
|
+
are for editing the video afterwards, such as zooming in on a step. After the page changes size, the steps leave out where they acted.
|
|
391
|
+
|
|
392
|
+
It needs ffmpeg: $PW_FFMPEG, and only it when it is set (the REPL's, not send's); otherwise Playwright's own (npx playwright-core install ffmpeg, in $PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright), then one on the PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. record on fails at once, recording nothing, without an ffmpeg that can write its format. If ffmpeg exits while recording, the recording ends then, the pane says what was saved, and the next record off fails with it. ffmpeg writes in blocks, so what it has not written yet is lost: the last seconds, or at first all of it. WebM plays in browsers,
|
|
330
393
|
GitHub, GitLab and Slack; MP4 plays everywhere. It takes about 15 MB a minute.
|
|
331
394
|
|
|
332
395
|
For a video with the pointer, or of the person's screen, help video compares the other ways.`,
|
|
@@ -493,16 +556,17 @@ already happened (requests lists those).`,
|
|
|
493
556
|
summary: 'send one CDP command through a temporary session',
|
|
494
557
|
detail: `The session is detached afterwards, so subscriptions and settings do not persist.
|
|
495
558
|
|
|
496
|
-
Browser.close and Target.closeTarget are refused.`,
|
|
559
|
+
Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog answers one.`,
|
|
497
560
|
},
|
|
498
561
|
cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
|
|
499
562
|
storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
|
|
500
563
|
modes: {
|
|
501
564
|
usage: 'modes [off [--mine]]',
|
|
502
565
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
503
|
-
detail: `The modes are watch, network off or slow, route, capture, record, emulate and
|
|
504
|
-
turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
|
|
505
|
-
<glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
|
|
566
|
+
detail: `The modes are watch, network off or slow, route, capture, record, emulate, viewport, highlight and cursor.
|
|
567
|
+
Each is turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
|
|
568
|
+
route off <glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
|
|
569
|
+
viewport off, highlight <selector> | highlight off, cursor on|off.
|
|
506
570
|
|
|
507
571
|
modes off turns off every one in every tab; a capture it stops is kept for capture to show, and a
|
|
508
572
|
recording it stops is saved, as record off would. With
|
|
@@ -512,10 +576,11 @@ the sender's own.`,
|
|
|
512
576
|
dialog: {
|
|
513
577
|
usage: 'dialog [accept [text] | dismiss]',
|
|
514
578
|
summary: 'show, accept or dismiss an open alert, confirm or prompt',
|
|
515
|
-
detail: `While a dialog is open, its page and the commands that read it wait. dialog runs at once, ahead
|
|
516
|
-
of them; it answers the selected tab's dialog, or the only one open. accept text answers a prompt.
|
|
579
|
+
detail: `While a dialog is open, its page and the commands that read it wait, a click that opened it included, until it is answered. Commands run one at a time, so every client's commands behind that one wait too, in any tab: in a shared REPL, answer a dialog you open. dialog runs at once, ahead
|
|
580
|
+
of them; it answers the selected tab's dialog (marked *), or the only one open. accept text answers a prompt, and accept alone keeps its default, as OK in the browser does.
|
|
517
581
|
|
|
518
|
-
|
|
582
|
+
An agent can answer any dialog, one it opened included; one the person answers in the browser is gone
|
|
583
|
+
from dialog then. cdp cannot answer one: Chrome lets only a session that saw it open do so. With nobody at the browser (e.g. headless), it is the only way to answer one.`,
|
|
519
584
|
},
|
|
520
585
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
521
586
|
quit: {
|
|
@@ -555,8 +620,9 @@ function renderAll() {
|
|
|
555
620
|
const VIDEO = `There are three ways to record a video of a page, and each gives something up.
|
|
556
621
|
|
|
557
622
|
record on (help record) records the selected tab's page from inside the browser: headless, hidden and
|
|
558
|
-
background tabs too, with nothing to set up but ffmpeg. It has no pointer and no browser toolbars
|
|
559
|
-
quick clips, and for an agent recording
|
|
623
|
+
background tabs too, with nothing to set up but ffmpeg. It has no pointer and no browser toolbars:
|
|
624
|
+
cursor on draws a pointer that shows where the REPL clicks. For quick clips, and for an agent recording
|
|
625
|
+
its own work.
|
|
560
626
|
|
|
561
627
|
A screen recorder on the person's machine (OBS, ffmpeg -f x11grab, macOS screencapture -v) records their
|
|
562
628
|
screen as they see it, pointer included, smooth and in real time. It records the screen or a window, not
|
package/lib/inspect.js
CHANGED
|
@@ -9,6 +9,7 @@ const { toSelector, REF, unquote } = require('./syntax');
|
|
|
9
9
|
const { regexOf, COMMAND_TIMEOUT } = require('./util');
|
|
10
10
|
const { tabState } = require('./tabstate');
|
|
11
11
|
const { keptSession } = require('./cdp');
|
|
12
|
+
const { CDP_REFUSED } = require('./dialogs');
|
|
12
13
|
const { onElement, soleSelector } = require('./elements');
|
|
13
14
|
const { waitForLoad, waitForRequest } = require('./requestlog');
|
|
14
15
|
const { deviceMetrics, viewportMetrics } = require('./emulation');
|
|
@@ -429,6 +430,7 @@ const commands = {
|
|
|
429
430
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
|
|
430
431
|
if (!match) throw new Error('Usage: cdp <method> <JSON object>');
|
|
431
432
|
if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
|
|
433
|
+
if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
|
|
432
434
|
const params = JSON.parse(match[2]);
|
|
433
435
|
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
|
|
434
436
|
const operation = async () => {
|
package/lib/modes.js
CHANGED
|
@@ -9,6 +9,8 @@ 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');
|
|
13
|
+
const { cursorOf, cursorOff } = require('./cursor');
|
|
12
14
|
|
|
13
15
|
// The modes turned on in a tab, in the order the prompt shows them.
|
|
14
16
|
function activeModes(p) {
|
|
@@ -22,6 +24,9 @@ function activeModes(p) {
|
|
|
22
24
|
if (viewport) modes.push(`viewport:${viewport.width}x${viewport.height}`);
|
|
23
25
|
const routes = tabState(p).routes?.size;
|
|
24
26
|
if (routes) modes.push(`routes:${routes}`);
|
|
27
|
+
const highlights = tabState(p).highlights?.size;
|
|
28
|
+
if (highlights) modes.push(`highlight:${highlights}`);
|
|
29
|
+
if (cursorOf(p)) modes.push('cursor');
|
|
25
30
|
if (currentCapture()?.page === p) modes.push('capture');
|
|
26
31
|
if (recordingOf(p)) modes.push('record');
|
|
27
32
|
return modes;
|
|
@@ -70,6 +75,9 @@ function modeOwners(p) {
|
|
|
70
75
|
const viewport = tabState(p).viewport;
|
|
71
76
|
if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
|
|
72
77
|
for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
78
|
+
for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
|
|
79
|
+
const cursor = cursorOf(p);
|
|
80
|
+
if (cursor) owners.push([cursor.owner ?? null, 'cursor']);
|
|
73
81
|
const capture = currentCapture();
|
|
74
82
|
if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
|
|
75
83
|
const recording = recordingOf(p);
|
|
@@ -104,6 +112,9 @@ async function allModesOff(mine = false) {
|
|
|
104
112
|
const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
105
113
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
106
114
|
if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
115
|
+
const hidden = await hideHighlights(p, mine);
|
|
116
|
+
if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
|
|
117
|
+
if (cursorOf(p) && ours(cursorOf(p).owner) && await cursorOff(p)) done.push('cursor off');
|
|
107
118
|
// Before the page's size is reset, which the recording would otherwise end with.
|
|
108
119
|
const recording = recordingOf(p);
|
|
109
120
|
if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
|