pw-repl 0.14.0 → 0.15.1
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 +1 -1
- package/README.md +2 -1
- package/lib/cli-names.js +4 -0
- package/lib/commands.js +4 -0
- package/lib/elements.js +138 -75
- package/lib/emulation.js +20 -1
- package/lib/help.js +68 -66
- package/lib/inspect.js +83 -62
- package/lib/record.js +2 -2
- package/lib/runner.js +5 -0
- package/lib/send.js +6 -3
- package/lib/touch.js +97 -0
- package/package.json +1 -1
- package/skill/SKILL.md +13 -11
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
17
17
|
`READ_ONLY` or `INSPECTION`, and one that takes fixed words in `complete` (`lib/commands.js`).
|
|
18
18
|
- The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
|
|
19
19
|
`lib/elements.js` (choosing a match; the interact and mouse commands; highlight), `lib/cursor.js` (the pointer
|
|
20
|
-
drawn over the page, which the element and mouse commands glide first), `lib/toast.js` (a line of text
|
|
20
|
+
drawn over the page, which the element and mouse commands glide first), `lib/touch.js` (touch gestures, under emulate mobile), `lib/toast.js` (a line of text
|
|
21
21
|
over the page), `lib/overlay.js` (the element the cursor and toast are drawn in, one layer each), `lib/inspect.js` (reading the
|
|
22
22
|
page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
|
|
23
23
|
`lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/profile.js` (performance traces;
|
package/README.md
CHANGED
|
@@ -140,11 +140,12 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
|
|
140
140
|
| see where I am | `tab`, `info` |
|
|
141
141
|
| see what is on the page | `snapshot`, `screenshot` |
|
|
142
142
|
| do something on it | `click`, `fill`, `type`, `press`, `drag` |
|
|
143
|
-
| click what has no selector (a canvas) | `
|
|
143
|
+
| click what has no selector (a canvas) | `mouse click <x> <y>`, `mouse dblclick <x> <y>` |
|
|
144
144
|
| run some JavaScript in the page | `eval document.title`; `eval --file=check.js` for a long one |
|
|
145
145
|
| wait for a page or element | `wait load`, `wait <selector> [--gone]`, `wait text <text>` |
|
|
146
146
|
| choose a file in a file input | `upload <selector> <file>` |
|
|
147
147
|
| see it as a phone, or in dark mode | `emulate mobile`, `emulate dark` |
|
|
148
|
+
| tap, swipe or pinch as a finger | `touch tap <x> <y>`, `touch pinch <x> <y> <scale>` |
|
|
148
149
|
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
149
150
|
| see console messages and errors | `console` |
|
|
150
151
|
| show an agent what I do | `watch on`, click around, then `watch` |
|
package/lib/cli-names.js
CHANGED
|
@@ -17,6 +17,10 @@ const NAMES = {
|
|
|
17
17
|
// It ends playwright-cli's own browser; this one is shared, so tabs stay open.
|
|
18
18
|
close: [() => 'close', 'modes off --mine; tabs stay open; lists the files you wrote'],
|
|
19
19
|
'go-back': [() => 'back', 'back'],
|
|
20
|
+
mousemove: [a => `mouse move ${a}`, 'mouse move <x> <y>'],
|
|
21
|
+
mousedown: [a => `mouse down ${a}`.trim(), 'mouse down [button]'],
|
|
22
|
+
mouseup: [a => `mouse up ${a}`.trim(), 'mouse up [button]'],
|
|
23
|
+
mousewheel: [a => `mouse wheel ${a}`, 'mouse wheel <dx> <dy>'],
|
|
20
24
|
'go-forward': [() => 'forward', 'forward'],
|
|
21
25
|
find: [a => `snapshot --grep ${a}`, 'snapshot --grep <text>'],
|
|
22
26
|
'dialog-accept': [a => `dialog accept ${a}`.trim(), 'dialog accept [text]'],
|
package/lib/commands.js
CHANGED
|
@@ -8,6 +8,7 @@ const { tabState } = require('./tabstate');
|
|
|
8
8
|
const { commands: dialogsCommands, ensureDialogHandler } = require('./dialogs');
|
|
9
9
|
const { commands: elementsCommands } = require('./elements');
|
|
10
10
|
const { commands: cursorCommands } = require('./cursor');
|
|
11
|
+
const { commands: touchCommands, GESTURES } = require('./touch');
|
|
11
12
|
const { commands: toastCommands } = require('./toast');
|
|
12
13
|
const { commands: requestlogCommands, ensureRecentLog } = require('./requestlog');
|
|
13
14
|
const { commands: routesCommands, routesFor } = require('./routes');
|
|
@@ -26,6 +27,7 @@ const commands = {
|
|
|
26
27
|
...tabsCommands,
|
|
27
28
|
...elementsCommands,
|
|
28
29
|
...cursorCommands,
|
|
30
|
+
...touchCommands,
|
|
29
31
|
...toastCommands,
|
|
30
32
|
...inspectCommands,
|
|
31
33
|
...watchCommands,
|
|
@@ -114,6 +116,8 @@ function complete(line) {
|
|
|
114
116
|
if (words.length === 2) {
|
|
115
117
|
if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
|
|
116
118
|
if (command === 'tab') return match(['new', 'close']);
|
|
119
|
+
if (command === 'touch') return match(GESTURES);
|
|
120
|
+
if (command === 'mouse') return match(['move', 'click', 'dblclick', 'down', 'up', 'wheel']);
|
|
117
121
|
if (command === 'network') return match(['on', 'off', 'slow']);
|
|
118
122
|
if (command === 'emulate') return match([...EMULATE_KINDS, 'off']);
|
|
119
123
|
if (command === 'wait') return match(['text', 'request', 'load']);
|
package/lib/elements.js
CHANGED
|
@@ -9,7 +9,7 @@ const { numbers, hints, BUSY_PAGE } = require('./util');
|
|
|
9
9
|
const { tabState } = require('./tabstate');
|
|
10
10
|
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
11
11
|
const { recordingOf, stepTarget, pauseToShow } = require('./record');
|
|
12
|
-
const { settled } = require('./emulation');
|
|
12
|
+
const { settled, outsideViewport, IN_VIEW } = require('./emulation');
|
|
13
13
|
|
|
14
14
|
// The type a page sees for a file it is given, from its extension.
|
|
15
15
|
const MIME_TYPES = {
|
|
@@ -439,6 +439,135 @@ function dateInputHolds(kind, value, what, selector) {
|
|
|
439
439
|
return `, a ${kind} input, which now holds ${value}`;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
// drag <x1> <y1> <x2> <y2>: for what has no element to drag by, such as a canvas or a map's pan. Moved in
|
|
443
|
+
// steps, as a page that tracks the pointer needs moves between the two points, not one jump.
|
|
444
|
+
const DRAG_STEPS = 10;
|
|
445
|
+
|
|
446
|
+
async function dragBetween(x1, y1, x2, y2) {
|
|
447
|
+
await refuseOutside(x1, y1, 'dragged');
|
|
448
|
+
stepTarget(state.page, null, { x: x1, y: y1 });
|
|
449
|
+
await glideTo(state.page, x1, y1);
|
|
450
|
+
await state.page.mouse.move(x1, y1);
|
|
451
|
+
await state.page.mouse.down();
|
|
452
|
+
tabState(state.page).mouseAt = { x: x1, y: y1 };
|
|
453
|
+
// Pressed from here on: a failure would leave the button down, which the page would take as a drag still going.
|
|
454
|
+
try {
|
|
455
|
+
await showClick(state.page);
|
|
456
|
+
await glideTo(state.page, x2, y2);
|
|
457
|
+
await state.page.mouse.move(x2, y2, { steps: DRAG_STEPS });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
await state.page.mouse.up().catch(() => {});
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
await state.page.mouse.up();
|
|
463
|
+
tabState(state.page).mouseAt = { x: x2, y: y2 };
|
|
464
|
+
out.log(`Dragged from ${x1}, ${y1} to ${x2}, ${y2}${await outsideNote(x2, y2)}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// mouse's actions.
|
|
468
|
+
// Playwright's mouse starts at the top left.
|
|
469
|
+
const mouseAt = () => tabState(state.page).mouseAt || { x: 0, y: 0 };
|
|
470
|
+
|
|
471
|
+
// A press outside the viewport reaches nothing, so it is refused rather than reported done.
|
|
472
|
+
async function refuseOutside(x, y, nothing) {
|
|
473
|
+
const outside = await outsideViewport(x, y);
|
|
474
|
+
if (outside) throw new Error(`${x}, ${y} is outside the viewport (${outside}); nothing was ${nothing}. ${IN_VIEW}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// mouse down and wheel act where the mouse is.
|
|
478
|
+
async function refuseMouseOutside(nothing) {
|
|
479
|
+
const { x, y } = mouseAt();
|
|
480
|
+
const outside = await outsideViewport(x, y);
|
|
481
|
+
if (outside) throw new Error(`The mouse is at ${x}, ${y}, outside the viewport (${outside}); nothing was ${nothing}. mouse move to a point inside it first, or scroll what you want into view`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// A move or a release may go outside it, as a drag the page holds on to can.
|
|
485
|
+
async function outsideNote(x, y) {
|
|
486
|
+
const outside = await outsideViewport(x, y);
|
|
487
|
+
return outside ? ` (outside the viewport, ${outside})` : '';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const MOUSE = {
|
|
491
|
+
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
492
|
+
async move(args) {
|
|
493
|
+
const [x, y, extra] = numbers(args, 'mouse move <x> <y>');
|
|
494
|
+
if (y === undefined || extra !== undefined) throw new Error('Usage: mouse move <x> <y>, in CSS pixels from the top left of the viewport');
|
|
495
|
+
stepTarget(state.page, null, { x, y });
|
|
496
|
+
await glideTo(state.page, x, y);
|
|
497
|
+
await state.page.mouse.move(x, y);
|
|
498
|
+
tabState(state.page).mouseAt = { x, y };
|
|
499
|
+
out.log(`Moved the mouse to ${x}, ${y}${await outsideNote(x, y)}`);
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
503
|
+
async click(line) {
|
|
504
|
+
const usage = 'mouse click <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
505
|
+
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
506
|
+
const clickCount = clickCountOf(found, usage);
|
|
507
|
+
const delay = found.find(o => o.name === 'delay');
|
|
508
|
+
if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouse click 400 300 --delay=500`);
|
|
509
|
+
const held = delay ? Number(delay.value) : 0;
|
|
510
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
511
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
512
|
+
await refuseOutside(x, y, 'clicked');
|
|
513
|
+
stepTarget(state.page, null, { x, y });
|
|
514
|
+
await glideTo(state.page, x, y);
|
|
515
|
+
await state.page.mouse.move(x, y);
|
|
516
|
+
tabState(state.page).mouseAt = { x, y };
|
|
517
|
+
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
518
|
+
// mouse down's does, while a --delay holds the button.
|
|
519
|
+
for (let count = 1; count <= clickCount; count += 1) {
|
|
520
|
+
await state.page.mouse.down({ button, clickCount: count });
|
|
521
|
+
if (count === 1) await showClick(state.page, clickCount);
|
|
522
|
+
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
523
|
+
await state.page.mouse.up({ button, clickCount: count });
|
|
524
|
+
}
|
|
525
|
+
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
// Two mouse clicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
529
|
+
async dblclick(line) {
|
|
530
|
+
const usage = 'mouse dblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
531
|
+
const { words, button } = mouseArgs(line, usage);
|
|
532
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
533
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
534
|
+
await refuseOutside(x, y, 'clicked');
|
|
535
|
+
stepTarget(state.page, null, { x, y });
|
|
536
|
+
await glideTo(state.page, x, y);
|
|
537
|
+
await state.page.mouse.dblclick(x, y, { button });
|
|
538
|
+
tabState(state.page).mouseAt = { x, y };
|
|
539
|
+
await showClick(state.page, 2);
|
|
540
|
+
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
async down(args) {
|
|
544
|
+
const button = mouseButton(args, 'mouse down');
|
|
545
|
+
await refuseMouseOutside('pressed');
|
|
546
|
+
await glideToMouse();
|
|
547
|
+
await state.page.mouse.down({ button });
|
|
548
|
+
await showClick(state.page);
|
|
549
|
+
out.log(`Pressed the ${button} button`);
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
async up(args) {
|
|
553
|
+
const button = mouseButton(args, 'mouse up');
|
|
554
|
+
await glideToMouse();
|
|
555
|
+
await state.page.mouse.up({ button });
|
|
556
|
+
out.log(`Released the ${button} button${await outsideNote(mouseAt().x, mouseAt().y)}`);
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
// Wheel events where the mouse is (mouse move puts it there): a map zooms around that point.
|
|
560
|
+
async wheel(args) {
|
|
561
|
+
const [dx, dy, extra] = numbers(args, 'mouse wheel <dx> <dy>');
|
|
562
|
+
if (dy === undefined || extra !== undefined) throw new Error('Usage: mouse wheel <dx> <dy>, e.g. mouse wheel 0 300 scrolls down (negative dy up)');
|
|
563
|
+
await refuseMouseOutside('scrolled');
|
|
564
|
+
await glideToMouse();
|
|
565
|
+
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
566
|
+
else await state.page.mouse.wheel(dx, dy);
|
|
567
|
+
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
442
571
|
const commands = {
|
|
443
572
|
async click(args) {
|
|
444
573
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]] [--force]', true);
|
|
@@ -456,7 +585,9 @@ const commands = {
|
|
|
456
585
|
// source, press, hover the target, release), so the cursor can glide to each in turn. Chromium turns
|
|
457
586
|
// the moves into an HTML5 drag when the source is draggable.
|
|
458
587
|
async drag(args) {
|
|
459
|
-
const usage = 'Usage: drag <from> <to>, each a selector or a ref, quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#list li"';
|
|
588
|
+
const usage = 'Usage: drag <from> <to>, each a selector or a ref, quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#list li"; or drag <x1> <y1> <x2> <y2>, points in the viewport';
|
|
589
|
+
const words = (args || '').trim().split(/\s+/);
|
|
590
|
+
if (words.length === 4 && words.every(w => /^-?\d+(?:\.\d+)?$/.test(w))) return dragBetween(...words.map(Number));
|
|
460
591
|
const from = splitSelector(args);
|
|
461
592
|
const to = from?.rest !== undefined ? splitSelector(from.rest) : null;
|
|
462
593
|
if (!to || to.rest !== undefined) throw new Error(usage);
|
|
@@ -545,79 +676,11 @@ const commands = {
|
|
|
545
676
|
pauseToShow(state.page);
|
|
546
677
|
},
|
|
547
678
|
|
|
548
|
-
//
|
|
549
|
-
async
|
|
550
|
-
const [
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
await glideTo(state.page, x, y);
|
|
554
|
-
await state.page.mouse.move(x, y);
|
|
555
|
-
tabState(state.page).mouseAt = { x, y };
|
|
556
|
-
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
557
|
-
},
|
|
558
|
-
|
|
559
|
-
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
560
|
-
async mouseclick(line) {
|
|
561
|
-
const usage = 'mouseclick <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
562
|
-
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
563
|
-
const clickCount = clickCountOf(found, usage);
|
|
564
|
-
const delay = found.find(o => o.name === 'delay');
|
|
565
|
-
if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
|
|
566
|
-
const held = delay ? Number(delay.value) : 0;
|
|
567
|
-
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
568
|
-
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
569
|
-
stepTarget(state.page, null, { x, y });
|
|
570
|
-
await glideTo(state.page, x, y);
|
|
571
|
-
await state.page.mouse.move(x, y);
|
|
572
|
-
tabState(state.page).mouseAt = { x, y };
|
|
573
|
-
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
574
|
-
// mousedown's does, while a --delay holds the button.
|
|
575
|
-
for (let count = 1; count <= clickCount; count += 1) {
|
|
576
|
-
await state.page.mouse.down({ button, clickCount: count });
|
|
577
|
-
if (count === 1) await showClick(state.page, clickCount);
|
|
578
|
-
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
579
|
-
await state.page.mouse.up({ button, clickCount: count });
|
|
580
|
-
}
|
|
581
|
-
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
582
|
-
},
|
|
583
|
-
|
|
584
|
-
// Two mouseclicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
585
|
-
async mousedblclick(line) {
|
|
586
|
-
const usage = 'mousedblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
587
|
-
const { words, button } = mouseArgs(line, usage);
|
|
588
|
-
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
589
|
-
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
590
|
-
stepTarget(state.page, null, { x, y });
|
|
591
|
-
await glideTo(state.page, x, y);
|
|
592
|
-
await state.page.mouse.dblclick(x, y, { button });
|
|
593
|
-
tabState(state.page).mouseAt = { x, y };
|
|
594
|
-
await showClick(state.page, 2);
|
|
595
|
-
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
596
|
-
},
|
|
597
|
-
|
|
598
|
-
async mousedown(args) {
|
|
599
|
-
const button = mouseButton(args, 'mousedown');
|
|
600
|
-
await glideToMouse();
|
|
601
|
-
await state.page.mouse.down({ button });
|
|
602
|
-
await showClick(state.page);
|
|
603
|
-
out.log(`Pressed the ${button} button`);
|
|
604
|
-
},
|
|
605
|
-
|
|
606
|
-
async mouseup(args) {
|
|
607
|
-
const button = mouseButton(args, 'mouseup');
|
|
608
|
-
await glideToMouse();
|
|
609
|
-
await state.page.mouse.up({ button });
|
|
610
|
-
out.log(`Released the ${button} button`);
|
|
611
|
-
},
|
|
612
|
-
|
|
613
|
-
// Wheel events where the mouse is (mousemove puts it there): a map zooms around that point.
|
|
614
|
-
async mousewheel(args) {
|
|
615
|
-
const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
|
|
616
|
-
if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
|
|
617
|
-
await glideToMouse();
|
|
618
|
-
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
619
|
-
else await state.page.mouse.wheel(dx, dy);
|
|
620
|
-
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
679
|
+
// mouse <action>, as Playwright's page.mouse: at a point, or where the mouse is.
|
|
680
|
+
async mouse(line) {
|
|
681
|
+
const [action, ...rest] = (line || '').trim().split(/\s+/);
|
|
682
|
+
if (!Object.hasOwn(MOUSE, action)) throw new Error(`Usage: mouse ${Object.keys(MOUSE).join('|')} ...; help mouse says what each takes`);
|
|
683
|
+
await MOUSE[action](rest.join(' '));
|
|
621
684
|
},
|
|
622
685
|
|
|
623
686
|
async fill(line) {
|
package/lib/emulation.js
CHANGED
|
@@ -24,6 +24,25 @@ async function viewportText() {
|
|
|
24
24
|
return size ? `${size} (the window's page area)` : 'unknown';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The viewport's size, in the CSS pixels mouse and touch points are given in: a phone's, a size set
|
|
28
|
+
// with viewport, or else the page's (a tab attached over CDP has none set, so Playwright knows none).
|
|
29
|
+
async function viewportSize() {
|
|
30
|
+
const device = tabState(state.page).emulation?.device;
|
|
31
|
+
if (device) return devices[device].viewport;
|
|
32
|
+
const set = tabState(state.page).viewport;
|
|
33
|
+
if (set) return set;
|
|
34
|
+
return state.page.evaluate(() => ({ width: innerWidth, height: innerHeight })).catch(() => null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The viewport's size (1280x657) when a point is outside it; null when it is inside, or the size is unknown.
|
|
38
|
+
async function outsideViewport(x, y) {
|
|
39
|
+
const size = await viewportSize();
|
|
40
|
+
if (!size || x >= 0 && y >= 0 && x < size.width && y < size.height) return null;
|
|
41
|
+
return `${size.width}x${size.height}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const IN_VIEW = 'A point is in CSS pixels from the top left of the viewport: scroll what you want into view (eval document.querySelector(<selector>).scrollIntoView(), or mouse wheel), then measure it again';
|
|
45
|
+
|
|
27
46
|
const DEFAULT_DEVICE = 'Pixel 7';
|
|
28
47
|
const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
|
|
29
48
|
|
|
@@ -234,4 +253,4 @@ const commands = {
|
|
|
234
253
|
},
|
|
235
254
|
};
|
|
236
255
|
|
|
237
|
-
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled, reapplyMetrics };
|
|
256
|
+
module.exports = { commands, viewportText, outsideViewport, IN_VIEW, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled, reapplyMetrics };
|
package/lib/help.js
CHANGED
|
@@ -27,11 +27,12 @@ const TASKS = [
|
|
|
27
27
|
['see where I am', 'tab, info'],
|
|
28
28
|
['see what is on the page', 'snapshot, screenshot'],
|
|
29
29
|
['do something on it', 'click, fill, type, press, drag'],
|
|
30
|
-
['click what has no selector (a canvas)', '
|
|
30
|
+
['click what has no selector (a canvas)', 'mouse click <x> <y>, mouse dblclick <x> <y>'],
|
|
31
31
|
['run some JavaScript in the page', 'eval document.title; eval --file=check.js for a long one'],
|
|
32
32
|
['wait for a page or element', 'wait load, wait <selector> [--gone], wait text <text>'],
|
|
33
33
|
['choose a file in a file input', 'upload <selector> <file>'],
|
|
34
34
|
['see it as a phone, or in dark mode', 'emulate mobile, emulate dark'],
|
|
35
|
+
['tap, swipe or pinch as a finger', 'touch tap <x> <y>, touch pinch <x> <y> <scale>'],
|
|
35
36
|
['see what the page requested', 'requests, then body <#> for what one got back'],
|
|
36
37
|
['see console messages and errors', 'console'],
|
|
37
38
|
['show an agent what I do', 'watch on, click around, then watch'],
|
|
@@ -85,9 +86,9 @@ tab on its own lists them all.`,
|
|
|
85
86
|
interact: {
|
|
86
87
|
intro: `Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.
|
|
87
88
|
Commands use the first match (click, dblclick and hover the first they can use: help click); see help
|
|
88
|
-
fill for selectors with spaces.
|
|
89
|
+
fill for selectors with spaces. mouse and touch act at a point instead. An action
|
|
89
90
|
scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
|
|
90
|
-
commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', '
|
|
91
|
+
commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mouse', 'touch'],
|
|
91
92
|
},
|
|
92
93
|
inspect: {
|
|
93
94
|
intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
|
|
@@ -236,69 +237,62 @@ sent, as when the page stops answering, the outcome is unknown.`,
|
|
|
236
237
|
},
|
|
237
238
|
hover: { usage: 'hover <selector>', summary: 'hover, choosing the match as click does' },
|
|
238
239
|
drag: {
|
|
239
|
-
usage: 'drag <from> <to>',
|
|
240
|
-
summary: 'drag
|
|
240
|
+
usage: 'drag <from> <to> | <x1> <y1> <x2> <y2>',
|
|
241
|
+
summary: 'drag an element onto another, or between points',
|
|
241
242
|
args: [
|
|
242
243
|
['<from> <to>', 'selectors or snapshot refs, each quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#done ul".'],
|
|
244
|
+
['<x1> <y1> <x2> <y2>', 'points, as for mouse commands: presses the left button at the first, moves to the second in 10 steps, and releases. For what has no element to drag by: a canvas, panning a map. The first point must be in the viewport; the second may be outside it.'],
|
|
243
245
|
],
|
|
244
246
|
detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
|
|
245
247
|
first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
|
|
246
248
|
draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
|
|
247
|
-
through points
|
|
249
|
+
through more points than two: mouse move, mouse down, mouse move as often as needed, mouse up.`,
|
|
248
250
|
},
|
|
249
|
-
|
|
250
|
-
usage: '
|
|
251
|
-
summary: '
|
|
251
|
+
touch: {
|
|
252
|
+
usage: 'touch <gesture> <x> <y> ...',
|
|
253
|
+
summary: 'tap, swipe or pinch at a point, as a finger',
|
|
252
254
|
args: [
|
|
253
|
-
['<x> <y>', '
|
|
255
|
+
['tap <x> <y>', 'a tap: the page gets pointer and touch events, then click.'],
|
|
256
|
+
['doubletap <x> <y>', 'two taps: a dblclick after them.'],
|
|
257
|
+
['longpress <x> <y> [time]', 'held down this long, then lifted. Default: 800ms. In headless Chromium it is still a tap, with click and no contextmenu; a page\'s own timer on pointerdown sees it held.'],
|
|
258
|
+
['swipe <x> <y> <dx> <dy>', 'one finger down at x,y, moved by dx,dy in small steps about a frame apart, then lifted.'],
|
|
259
|
+
['pinch <x> <y> <scale>', 'two fingers side by side about x,y, 100px apart, moved apart until they are scale times that far apart, then lifted: 2 zooms in, under 1 (0.5) pinches in.'],
|
|
254
260
|
],
|
|
255
|
-
detail:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
261
|
+
detail: `<x> <y> are CSS pixels from the top left of the viewport, as for mouse commands. A finger put down outside the
|
|
262
|
+
viewport is refused; a swipe may end outside it. It needs a touch screen:
|
|
263
|
+
emulate mobile, then reload, so the page sees one; without it, touch refuses.
|
|
264
|
+
|
|
265
|
+
Chromium takes a tap sent soon after a swipe or pinch as part of it, with no click: up to a few seconds
|
|
266
|
+
after (sleep 3s). A page that pans or zooms itself on touch (no touch-action: none) ends a swipe with
|
|
267
|
+
pointercancel, not pointerup, as it would for a finger.
|
|
268
|
+
|
|
269
|
+
For what these do not make, cdp Input.dispatchTouchEvent, one event at a time (help cdp). Each finger has an id,
|
|
270
|
+
and touchEnd lists the fingers lifted; the others stay down. One finger lifted mid-pinch, the other moving on:
|
|
271
|
+
a touchStart with two points (ids 0 and 1), then cdp Input.dispatchTouchEvent
|
|
272
|
+
{"type":"touchEnd","touchPoints":[{"x":150,"y":300,"id":0}]}, then touchMove listing only id 1, then touchEnd
|
|
273
|
+
with no points, which lifts the rest.`,
|
|
260
274
|
},
|
|
261
|
-
|
|
262
|
-
usage: '
|
|
263
|
-
summary: 'click at a point
|
|
275
|
+
mouse: {
|
|
276
|
+
usage: 'mouse <action> ...',
|
|
277
|
+
summary: 'move, click or press at a point, or turn the wheel',
|
|
264
278
|
args: [
|
|
265
|
-
['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as
|
|
266
|
-
['[button]', 'left, right or middle
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
['
|
|
270
|
-
['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
|
|
271
|
-
['--delay=<ms>', 'holds the button down this long between press and release, as Playwright\'s mouse.click delay. Default: 0.'],
|
|
279
|
+
['move <x> <y>', 'moves the mouse there: CSS pixels from the top left of the tab\'s viewport (not the page), as mouse events have them.'],
|
|
280
|
+
['click <x> <y> [button]', 'clicks there: left, right or middle (Default: left), or --button=<button>; --click-count=<n> as click\'s (2 a double-click, 3 a triple-click); --delay=<ms> holds the button that long, as Playwright\'s mouse.click delay.'],
|
|
281
|
+
['dblclick <x> <y> [button]', 'mouse click --click-count=2, as Playwright\'s mouse.dblclick: two clicks and a dblclick. Two mouse clicks are two single clicks, which fire no dblclick.'],
|
|
282
|
+
['down [button] | up [button]', 'presses or releases a button where the mouse is. From one point to another, drag <x1> <y1> <x2> <y2> drags; through more: mouse down, mouse move to each point, mouse up.'],
|
|
283
|
+
['wheel <dx> <dy>', 'turns the wheel where the mouse is; positive dy scrolls down. A map zooms around the mouse: mouse move 400 300, then mouse wheel 0 -300 zooms in. It returns once the events are sent, not once the page has redrawn; while recording, it scrolls in eased steps over up to 1s (help record).'],
|
|
272
284
|
],
|
|
273
285
|
detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
|
|
274
|
-
clicked with click, which waits for it.
|
|
275
|
-
it.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
options: [
|
|
285
|
-
['--button=<button>', 'the button, as click\'s.'],
|
|
286
|
-
],
|
|
287
|
-
detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
|
|
288
|
-
dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
|
|
289
|
-
<x> <y> --click-count=3.`,
|
|
290
|
-
},
|
|
291
|
-
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
292
|
-
mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
|
|
293
|
-
mousewheel: {
|
|
294
|
-
usage: 'mousewheel <dx> <dy>',
|
|
295
|
-
summary: 'turn the wheel where the mouse is',
|
|
296
|
-
args: [
|
|
297
|
-
['<dx> <dy>', 'CSS pixels; positive dy scrolls down.'],
|
|
298
|
-
],
|
|
299
|
-
detail: `The events go to the mouse's point (mousemove puts it there): a map zooms around it, so mousemove 400
|
|
300
|
-
300, then mousewheel 0 -300 zooms in. It returns once the events are sent, not once the page has
|
|
301
|
-
scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (help record).`,
|
|
286
|
+
clicked with click, which waits for it. The mouse stays where it went, for a mouse wheel or mouse down
|
|
287
|
+
after it. A click, or a mouse down or wheel, outside the viewport is refused: nothing there gets
|
|
288
|
+
it. mouse move and mouse up go there and say so, as a drag the page holds on to can. The cursor (help cursor) moves there first and shows a ring at a press. Under emulate mobile it is
|
|
289
|
+
still the mouse; touch sends a finger's events (help touch).
|
|
290
|
+
|
|
291
|
+
A snapshot has no coordinates; eval of getBoundingClientRect gives an element's centre:
|
|
292
|
+
eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
|
|
293
|
+
.getBoundingClientRect())
|
|
294
|
+
|
|
295
|
+
playwright-cli's mouse move, mouse down, mouse up and mouse wheel run these.`,
|
|
302
296
|
},
|
|
303
297
|
fill: {
|
|
304
298
|
usage: 'fill <selector> <value> [--submit]',
|
|
@@ -594,14 +588,14 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
|
|
|
594
588
|
detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
|
|
595
589
|
with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
|
|
596
590
|
point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
|
|
597
|
-
uncheck, upload,
|
|
598
|
-
records), and the command waits for it. Clicks (click, dblclick, check, uncheck,
|
|
591
|
+
uncheck, upload, mouse move, mouse click and mouse dblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
|
|
592
|
+
records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouse click, mouse dblclick, mouse down, and upload
|
|
599
593
|
when it clicks a button) show a ring where they land.
|
|
600
594
|
|
|
601
595
|
Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
|
|
602
596
|
the cursor off. It shows only the REPL's input, from every client, never the user's own mouse. fill,
|
|
603
|
-
select and the like move the drawing but leave the real mouse where it was;
|
|
604
|
-
|
|
597
|
+
select and the like move the drawing but leave the real mouse where it was; mouse down, mouse up and
|
|
598
|
+
mouse wheel act at the real mouse, so the pointer moves back there first.
|
|
605
599
|
|
|
606
600
|
It is a mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at
|
|
607
601
|
the same spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it
|
|
@@ -629,7 +623,7 @@ or headless tab keeps recording. It brings the tab to the front first, as screen
|
|
|
629
623
|
owner-only, as a screenshot is, and never replaces one. One recording per tab; another client with the
|
|
630
624
|
tab selected can stop it, and both are told whose it was.
|
|
631
625
|
|
|
632
|
-
While recording: type types at a pace the video can show (help type),
|
|
626
|
+
While recording: type types at a pace the video can show (help type), mouse wheel scrolls in eased steps,
|
|
633
627
|
an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
|
|
634
628
|
on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
|
|
635
629
|
viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
|
|
@@ -905,10 +899,12 @@ in front of it usually holds the request open, so the page spins instead of fail
|
|
|
905
899
|
],
|
|
906
900
|
options: [
|
|
907
901
|
['--file=<path>', 'the JavaScript is the file\'s text: anything the line takes, over many lines and with // comments. With a ref or a selector after the path, it is a function called with that element (the first match). Over send, a relative path is from your shell\'s current folder.'],
|
|
908
|
-
['--timeout=<time>', 'how long the page has to answer
|
|
902
|
+
['--timeout=<time>', 'how long the page has to answer. Default: 15s. Past 20s, give send time too, or it gives up first and cancels the eval: pw-repl send -t 70s eval --timeout=60s ...'],
|
|
909
903
|
['--all', 'the whole result, not capped.'],
|
|
910
904
|
],
|
|
911
|
-
detail: `
|
|
905
|
+
detail: `Its options go before the JavaScript, in any order; --all may also go at the very end, after it.
|
|
906
|
+
|
|
907
|
+
eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
|
|
912
908
|
already happened (requests lists those).
|
|
913
909
|
|
|
914
910
|
Long work (--timeout): every other command waits while an eval runs, the other clients' too. For work of
|
|
@@ -1103,11 +1099,6 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1103
1099
|
const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
|
|
1104
1100
|
if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
|
|
1105
1101
|
if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
|
|
1106
|
-
// Not a topic: the mouse commands are in interact, with the others that act on the page.
|
|
1107
|
-
if (topicOrCommand === 'mouse') {
|
|
1108
|
-
const mice = TOPICS.interact.commands.filter(c => c.startsWith('mouse'));
|
|
1109
|
-
return ['The mouse commands, which act at a point (part of help interact):', '', ...commandLines(mice)].join('\n');
|
|
1110
|
-
}
|
|
1111
1102
|
const full = /^(\S+)\s+--all$/.exec(topicOrCommand);
|
|
1112
1103
|
if (full && Object.hasOwn(COMMANDS, full[1])) return renderCommand(full[1], true);
|
|
1113
1104
|
// A playwright-cli name: what it runs here, with that command's help.
|
|
@@ -1124,9 +1115,20 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1124
1115
|
// pw-repl's own commands are run at the shell, so their help is the usage.
|
|
1125
1116
|
const SHELL_COMMANDS = new Set(['run', 'serve', 'send', 'attach', 'stop', 'where', 'skill']);
|
|
1126
1117
|
|
|
1118
|
+
// Commands that were renamed, so the old name says where it went.
|
|
1119
|
+
const RENAMED = { mouseclick: 'mouse click', mousedblclick: 'mouse dblclick' };
|
|
1120
|
+
|
|
1121
|
+
// An action of mouse or touch (help swipe) is found under its command; one that is a command itself (click) has its own help.
|
|
1122
|
+
function actionOf(topic) {
|
|
1123
|
+
return ['mouse', 'touch'].find(command => COMMANDS[command].args.some(([usage]) => usage.split(' | ').some(u => u.split(' ')[0] === topic)));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1127
1126
|
function notFound(topic) {
|
|
1127
|
+
if (Object.hasOwn(RENAMED, topic)) return `${topic} is now ${RENAMED[topic]}: help ${RENAMED[topic].split(' ')[0]}`;
|
|
1128
|
+
const command = actionOf(topic);
|
|
1129
|
+
if (command) return `${topic} is ${command} ${topic}: help ${command}`;
|
|
1128
1130
|
if (SHELL_COMMANDS.has(topic)) return `${topic} is run at the shell (pw-repl ${topic}), not in the REPL; pw-repl --help has its options`;
|
|
1129
1131
|
return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
1130
1132
|
}
|
|
1131
1133
|
|
|
1132
|
-
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
|
|
1134
|
+
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES, RENAMED };
|
package/lib/inspect.js
CHANGED
|
@@ -493,75 +493,96 @@ const commands = {
|
|
|
493
493
|
|
|
494
494
|
async eval(args, all) {
|
|
495
495
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
496
|
-
//
|
|
496
|
+
// Its options, in any order, before the JavaScript; once that starts, nothing is an option (x --all is JavaScript).
|
|
497
|
+
// --timeout=<time>: how long the page has to answer. --file=<path>: the JavaScript is the file's text.
|
|
497
498
|
let timeout = COMMAND_TIMEOUT;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
|
|
499
|
+
let file = null;
|
|
500
|
+
args = args.trim();
|
|
501
|
+
for (let option; (option = /^--(all|timeout|file)(?=[=\s]|$)/.exec(args));) {
|
|
502
|
+
if (option[1] === 'all') {
|
|
503
|
+
if (!/^--all(?:\s|$)/.test(args)) break;
|
|
504
|
+
all = true;
|
|
505
|
+
args = args.slice(5).trimStart();
|
|
506
|
+
} else if (option[1] === 'timeout') {
|
|
507
|
+
const timed = /^--timeout(?:=|\s+)(\S+)(?:\s+|$)/.exec(args);
|
|
508
|
+
timeout = parseDuration(timed?.[1]);
|
|
509
|
+
if (!timeout) throw new Error('Usage: eval --timeout=<time> <JavaScript>, a time in ms or with s: eval --timeout=60s ...');
|
|
510
|
+
args = args.slice(timed[0].length);
|
|
511
|
+
} else {
|
|
512
|
+
const named = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+|$)/.exec(args);
|
|
513
|
+
if (!named) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
|
|
514
|
+
file = path.resolve(unquote(named[1]));
|
|
515
|
+
args = args.slice(named[0].length);
|
|
516
516
|
}
|
|
517
|
-
if (!args) throw new Error(`eval --file: ${file} is empty`);
|
|
518
|
-
ref = target && unquote(target);
|
|
519
|
-
} else {
|
|
520
|
-
// eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
|
|
521
|
-
const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
|
|
522
|
-
if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
|
|
523
517
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
518
|
+
// An option after the JavaScript is JavaScript to the page; a SyntaxError from it says so.
|
|
519
|
+
const late = !file && /(?:^|\s)(--(?:timeout|file|all))(?=[=\s]|$)/.exec(args)?.[1];
|
|
520
|
+
const run = async () => {
|
|
521
|
+
let ref = null;
|
|
522
|
+
// eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
|
|
523
|
+
// it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
|
|
524
|
+
if (file) {
|
|
525
|
+
const target = args || null;
|
|
526
|
+
try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
|
|
527
|
+
// eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
|
|
528
|
+
throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
|
|
529
|
+
}
|
|
530
|
+
if (!args) throw new Error(`eval --file: ${file} is empty`);
|
|
531
|
+
ref = target && unquote(target);
|
|
532
|
+
} else {
|
|
533
|
+
if (!args) throw new Error('Usage: eval [--all] [--timeout=<time>] <JavaScript>');
|
|
534
|
+
// eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
|
|
535
|
+
const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args);
|
|
536
|
+
if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
|
|
537
|
+
}
|
|
538
|
+
if (ref) {
|
|
539
|
+
const selector = toSelector(ref);
|
|
540
|
+
const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
|
|
531
541
|
try {
|
|
532
|
-
|
|
542
|
+
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
543
|
+
// refuse it, and in the element's own frame, which may be an iframe.
|
|
544
|
+
const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
|
|
545
|
+
try {
|
|
546
|
+
printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
|
|
547
|
+
} finally {
|
|
548
|
+
await fn.dispose().catch(() => {});
|
|
549
|
+
}
|
|
533
550
|
} finally {
|
|
534
|
-
await
|
|
551
|
+
await el.dispose().catch(() => {});
|
|
535
552
|
}
|
|
536
|
-
|
|
537
|
-
await el.dispose().catch(() => {});
|
|
553
|
+
return;
|
|
538
554
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
555
|
+
// A function, as playwright-cli's eval takes, is called rather than returned.
|
|
556
|
+
// Only a single expression can be one; statements are evaluated as they are.
|
|
557
|
+
// await at the top level: one expression is run as an async function; statements as DevTools'
|
|
558
|
+
// console runs them (replMode), which gives back the last one's value.
|
|
559
|
+
const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
|
|
560
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
561
|
+
const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
|
|
562
|
+
const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
|
|
563
|
+
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
564
|
+
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
565
|
+
const session = await keptSession(state.page);
|
|
566
|
+
const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), timeout);
|
|
567
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
568
|
+
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
572
|
+
const value = await evaluated(state.page.evaluate(expression), timeout);
|
|
573
|
+
// An empty string is shown as one, not as no output at all.
|
|
574
|
+
printOutput(value === '' ? '""' : value, all);
|
|
575
|
+
// Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
|
|
576
|
+
// one plain literal only, not "a" + b or a template with ${...} in it, which ran.
|
|
577
|
+
if (typeof value === 'string' && value && /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`)$/s.test(args.trim())) {
|
|
578
|
+
out.log('(That is a string: the JavaScript was in quotes, which eval keeps. Without them, it runs: eval document.title)');
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
try {
|
|
582
|
+
await run();
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (late && /SyntaxError/.test(error.message)) error.message += `\n(${late} after the JavaScript is read as JavaScript: eval's options go before it)`;
|
|
585
|
+
throw error;
|
|
565
586
|
}
|
|
566
587
|
},
|
|
567
588
|
|
package/lib/record.js
CHANGED
|
@@ -393,13 +393,13 @@ function videoTime(rec) {
|
|
|
393
393
|
// What a step is, for its line: the command and what it acts on, never what it types or chooses, the
|
|
394
394
|
// code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
|
|
395
395
|
// video goes, and the video shows a password as dots.
|
|
396
|
-
const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', '
|
|
396
|
+
const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mouse']);
|
|
397
397
|
const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
|
|
398
398
|
function stepLabel(cmd, args) {
|
|
399
399
|
if (WHOLE.has(cmd)) {
|
|
400
400
|
const words = args.replace(/\s+--modifiers=\S+/, '').trim();
|
|
401
401
|
// An element's selector as written, without the quotes it needed on the command line (click's button after it).
|
|
402
|
-
const parsed =
|
|
402
|
+
const parsed = cmd === 'mouse' ? null : splitSelector(words);
|
|
403
403
|
return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
|
|
404
404
|
}
|
|
405
405
|
// A toast's text is what it said in the video, for whoever edits it.
|
package/lib/runner.js
CHANGED
|
@@ -7,6 +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 { RENAMED } = require('./help');
|
|
10
11
|
const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
|
|
11
12
|
const cliNames = require('./cli-names');
|
|
12
13
|
const { expandVariables } = require('./syntax');
|
|
@@ -108,6 +109,10 @@ async function executeCommand(text) {
|
|
|
108
109
|
out.error(`Error: ${cmd} is a pw-repl subcommand, run at the shell: pw-repl ${cmd}${args ? ' ...' : ''}`);
|
|
109
110
|
return { status: 'error', cmd };
|
|
110
111
|
}
|
|
112
|
+
if (Object.hasOwn(RENAMED, cmd)) {
|
|
113
|
+
out.error(`Error: ${cmd} is now ${RENAMED[cmd]}: help ${RENAMED[cmd].split(' ')[0]}`);
|
|
114
|
+
return { status: 'error', cmd };
|
|
115
|
+
}
|
|
111
116
|
out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
|
|
112
117
|
return { status: 'error', cmd };
|
|
113
118
|
}
|
package/lib/send.js
CHANGED
|
@@ -93,12 +93,15 @@ function doubleQuote(word) {
|
|
|
93
93
|
return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
// Where eval's --file is,
|
|
96
|
+
// Where eval's --file is, among its options before the JavaScript (--all, --timeout); -1 when it has none.
|
|
97
97
|
function evalFileAt(words) {
|
|
98
98
|
if (words[0] !== 'eval') return -1;
|
|
99
99
|
let at = 1;
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (words[at] === '--all' || /^--timeout=/.test(words[at] || '')) at += 1;
|
|
102
|
+
else if (words[at] === '--timeout') at += 2;
|
|
103
|
+
else break;
|
|
104
|
+
}
|
|
102
105
|
return /^--file(?:=|$)/.test(words[at] || '') ? at : -1;
|
|
103
106
|
}
|
|
104
107
|
|
package/lib/touch.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// touch: gestures at points in the viewport, as a finger makes them, for a tab under emulate mobile.
|
|
2
|
+
// Raw touch events through the tab's kept CDP session, not Chrome's synthesize gestures, which send
|
|
3
|
+
// no click or dblclick.
|
|
4
|
+
const { state } = require('./state');
|
|
5
|
+
const out = require('./output');
|
|
6
|
+
const { numbers, parseDuration, durationText } = require('./util');
|
|
7
|
+
const { tabState } = require('./tabstate');
|
|
8
|
+
const { keptSession } = require('./cdp');
|
|
9
|
+
const { outsideViewport, IN_VIEW } = require('./emulation');
|
|
10
|
+
|
|
11
|
+
const GESTURES = ['tap', 'doubletap', 'longpress', 'swipe', 'pinch'];
|
|
12
|
+
const LONGPRESS = 800;
|
|
13
|
+
// A move every frame or so: one jump would read as no swipe to a page that measures its speed.
|
|
14
|
+
const STEP_MS = 16;
|
|
15
|
+
const STEPS = 12;
|
|
16
|
+
// Where pinch's two fingers start: this far apart, side by side about the point.
|
|
17
|
+
const PINCH_SPAN = 100;
|
|
18
|
+
|
|
19
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
async function send(type, points) {
|
|
22
|
+
const session = await keptSession(state.page);
|
|
23
|
+
await session.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function tap(x, y) {
|
|
27
|
+
await send('touchStart', [[x, y]]);
|
|
28
|
+
await send('touchEnd', []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function usageOf(gesture) {
|
|
32
|
+
return {
|
|
33
|
+
tap: 'touch tap <x> <y>',
|
|
34
|
+
doubletap: 'touch doubletap <x> <y>',
|
|
35
|
+
longpress: 'touch longpress <x> <y> [time]',
|
|
36
|
+
swipe: 'touch swipe <x> <y> <dx> <dy>',
|
|
37
|
+
pinch: 'touch pinch <x> <y> <scale>',
|
|
38
|
+
}[gesture];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const commands = {
|
|
42
|
+
async touch(args) {
|
|
43
|
+
const [gesture, ...rest] = (args || '').trim().split(/\s+/);
|
|
44
|
+
if (!GESTURES.includes(gesture)) throw new Error(`Usage: touch ${GESTURES.join('|')} <x> <y> ...; help touch says what each takes`);
|
|
45
|
+
const usage = `Usage: ${usageOf(gesture)}`;
|
|
46
|
+
// Without it the page gets touch events but no touch screen: pointerType and the like say otherwise.
|
|
47
|
+
if (!tabState(state.page).emulation?.device) throw new Error('touch needs a touch screen: emulate mobile first, then reload');
|
|
48
|
+
const counts = { tap: 2, doubletap: 2, longpress: 3, swipe: 4, pinch: 3 }[gesture];
|
|
49
|
+
if (gesture === 'longpress' && rest.length === 3) {
|
|
50
|
+
const held = parseDuration(rest[2]);
|
|
51
|
+
if (held === null) throw new Error(`${usage}, the time in ms or seconds (1.5s)`);
|
|
52
|
+
rest[2] = String(held);
|
|
53
|
+
}
|
|
54
|
+
const values = numbers(rest.join(' '), usageOf(gesture));
|
|
55
|
+
if (values.length < (gesture === 'longpress' ? 2 : counts) || values.length > counts) throw new Error(usage);
|
|
56
|
+
const [x, y] = values;
|
|
57
|
+
// A finger put down outside the viewport touches nothing; a swipe may still end outside it.
|
|
58
|
+
const outside = await outsideViewport(x, y);
|
|
59
|
+
if (outside) throw new Error(`${x}, ${y} is outside the viewport (${outside}); nothing was touched. ${IN_VIEW}`);
|
|
60
|
+
if (gesture === 'tap') {
|
|
61
|
+
await tap(x, y);
|
|
62
|
+
out.log(`Tapped at ${x}, ${y}`);
|
|
63
|
+
} else if (gesture === 'doubletap') {
|
|
64
|
+
await tap(x, y);
|
|
65
|
+
await tap(x, y);
|
|
66
|
+
out.log(`Double-tapped at ${x}, ${y}`);
|
|
67
|
+
} else if (gesture === 'longpress') {
|
|
68
|
+
const held = values[2] ?? LONGPRESS;
|
|
69
|
+
await send('touchStart', [[x, y]]);
|
|
70
|
+
await wait(held);
|
|
71
|
+
await send('touchEnd', []);
|
|
72
|
+
out.log(`Long-pressed at ${x}, ${y} for ${durationText(held)}`);
|
|
73
|
+
} else if (gesture === 'swipe') {
|
|
74
|
+
const [, , dx, dy] = values;
|
|
75
|
+
await send('touchStart', [[x, y]]);
|
|
76
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
77
|
+
await wait(STEP_MS);
|
|
78
|
+
await send('touchMove', [[x + dx * i / STEPS, y + dy * i / STEPS]]);
|
|
79
|
+
}
|
|
80
|
+
await send('touchEnd', []);
|
|
81
|
+
out.log(`Swiped from ${x}, ${y} to ${x + dx}, ${y + dy}`);
|
|
82
|
+
} else {
|
|
83
|
+
const scale = values[2];
|
|
84
|
+
if (!(scale > 0)) throw new Error(`${usage}: a scale above 0; under 1 pinches in`);
|
|
85
|
+
const fingers = span => [[x - span / 2, y], [x + span / 2, y]];
|
|
86
|
+
await send('touchStart', fingers(PINCH_SPAN));
|
|
87
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
88
|
+
await wait(STEP_MS);
|
|
89
|
+
await send('touchMove', fingers(PINCH_SPAN * (1 + (scale - 1) * i / STEPS)));
|
|
90
|
+
}
|
|
91
|
+
await send('touchEnd', []);
|
|
92
|
+
out.log(`Pinched at ${x}, ${y} to ${scale}x`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
module.exports = { commands, GESTURES };
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pw-repl
|
|
3
|
-
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), requests and their bodies, console messages, what a page copied to the clipboard, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, profiling what makes a page slow (long tasks, forced layouts, input delay, dropped frames), and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
3
|
+
description: Inspect and drive a Chromium browser, possibly one the user is using, through the pw-repl REPL - tabs, page snapshots, clicks and typing, the mouse at a point (moves, buttons, wheel), touch gestures on an emulated phone (tap, swipe, pinch), requests and their bodies, console messages, what a page copied to the clipboard, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, recording what the user does, profiling what makes a page slow (long tasks, forced layouts, input delay, dropped frames), and recording a video of a tab, with a pointer and on-screen captions. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
|
|
4
4
|
allowed-tools: Bash(pw-repl:*) Bash(npx pw-repl@latest:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -53,14 +53,15 @@ first tab).
|
|
|
53
53
|
- `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
|
|
54
54
|
`pw[serve]>`. It stops at that prompt (`quit`, or Ctrl-C); `pw-repl stop` is for a background one.
|
|
55
55
|
- Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
|
|
56
|
-
own instead of connecting to one: headless unless `--headed`, in a 1280x800 window
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
passed to that Chromium (only flags: a
|
|
60
|
-
among them is used instead of the temporary
|
|
61
|
-
Chromium picks up where it left off, as it would for
|
|
62
|
-
session cookies with them, so a login stays. They start
|
|
63
|
-
`requests` and `console` have only what came after, often
|
|
56
|
+
own instead of connecting to one: headless unless `--headed`, in a 1280x800 window (a 1280x657 page
|
|
57
|
+
headless; info shows the viewport mouse points are in), in a temporary profile, on a free port of its
|
|
58
|
+
own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns once it
|
|
59
|
+
has exited. It prints the command it ran; flags after `--` are passed to that Chromium (only flags: a
|
|
60
|
+
start URL goes before `--`), and a `--user-data-dir=<dir>` among them is used instead of the temporary
|
|
61
|
+
profile, and kept. Started again in a kept profile, Chromium picks up where it left off, as it would for
|
|
62
|
+
the user, headless too: its tabs come back, and session cookies with them, so a login stays. They start
|
|
63
|
+
loading before the REPL is attached, so `requests` and `console` have only what came after, often
|
|
64
|
+
nothing: `reload` for a full record.
|
|
64
65
|
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
65
66
|
the tmux session `pw-repl`:
|
|
66
67
|
|
|
@@ -183,8 +184,9 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
183
184
|
Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
|
|
184
185
|
gives usage and caveats: read it before you use a command, since this file does not repeat them. When a
|
|
185
186
|
command fails or does something you did not expect, read its `help <command>` before working around it.
|
|
186
|
-
`help --all` prints everything
|
|
187
|
-
|
|
187
|
+
`help --all` prints everything, tens of kilobytes: a last resort, when the topics and commands have not
|
|
188
|
+
answered. pw-repl answers help itself: it needs no running REPL, and never reaches one. If you know
|
|
189
|
+
playwright-cli, its command names work too (`help playwright-cli` lists them).
|
|
188
190
|
|
|
189
191
|
### Coming from playwright-cli
|
|
190
192
|
|