pw-repl 0.14.0 → 0.15.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 +1 -1
- package/README.md +2 -1
- package/lib/cli-names.js +4 -0
- package/lib/commands.js +4 -0
- package/lib/elements.js +83 -73
- package/lib/help.js +52 -62
- package/lib/record.js +2 -2
- package/lib/runner.js +5 -0
- package/lib/touch.js +93 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
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
|
@@ -439,6 +439,84 @@ function dateInputHolds(kind, value, what, selector) {
|
|
|
439
439
|
return `, a ${kind} input, which now holds ${value}`;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
// mouse's actions.
|
|
443
|
+
const MOUSE = {
|
|
444
|
+
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
445
|
+
async move(args) {
|
|
446
|
+
const [x, y, extra] = numbers(args, 'mouse move <x> <y>');
|
|
447
|
+
if (y === undefined || extra !== undefined) throw new Error('Usage: mouse move <x> <y>, in CSS pixels from the top left of the viewport');
|
|
448
|
+
stepTarget(state.page, null, { x, y });
|
|
449
|
+
await glideTo(state.page, x, y);
|
|
450
|
+
await state.page.mouse.move(x, y);
|
|
451
|
+
tabState(state.page).mouseAt = { x, y };
|
|
452
|
+
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
|
|
456
|
+
async click(line) {
|
|
457
|
+
const usage = 'mouse click <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
|
|
458
|
+
const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
|
|
459
|
+
const clickCount = clickCountOf(found, usage);
|
|
460
|
+
const delay = found.find(o => o.name === 'delay');
|
|
461
|
+
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`);
|
|
462
|
+
const held = delay ? Number(delay.value) : 0;
|
|
463
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
464
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
465
|
+
stepTarget(state.page, null, { x, y });
|
|
466
|
+
await glideTo(state.page, x, y);
|
|
467
|
+
await state.page.mouse.move(x, y);
|
|
468
|
+
tabState(state.page).mouseAt = { x, y };
|
|
469
|
+
// Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
|
|
470
|
+
// mouse down's does, while a --delay holds the button.
|
|
471
|
+
for (let count = 1; count <= clickCount; count += 1) {
|
|
472
|
+
await state.page.mouse.down({ button, clickCount: count });
|
|
473
|
+
if (count === 1) await showClick(state.page, clickCount);
|
|
474
|
+
if (held) await new Promise(resolve => setTimeout(resolve, held));
|
|
475
|
+
await state.page.mouse.up({ button, clickCount: count });
|
|
476
|
+
}
|
|
477
|
+
out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
// Two mouse clicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
|
|
481
|
+
async dblclick(line) {
|
|
482
|
+
const usage = 'mouse dblclick <x> <y> [left|right|middle] [--button=<button>]';
|
|
483
|
+
const { words, button } = mouseArgs(line, usage);
|
|
484
|
+
const [x, y, extra] = numbers(words.join(' '), usage);
|
|
485
|
+
if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
|
|
486
|
+
stepTarget(state.page, null, { x, y });
|
|
487
|
+
await glideTo(state.page, x, y);
|
|
488
|
+
await state.page.mouse.dblclick(x, y, { button });
|
|
489
|
+
tabState(state.page).mouseAt = { x, y };
|
|
490
|
+
await showClick(state.page, 2);
|
|
491
|
+
out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
async down(args) {
|
|
495
|
+
const button = mouseButton(args, 'mouse down');
|
|
496
|
+
await glideToMouse();
|
|
497
|
+
await state.page.mouse.down({ button });
|
|
498
|
+
await showClick(state.page);
|
|
499
|
+
out.log(`Pressed the ${button} button`);
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
async up(args) {
|
|
503
|
+
const button = mouseButton(args, 'mouse up');
|
|
504
|
+
await glideToMouse();
|
|
505
|
+
await state.page.mouse.up({ button });
|
|
506
|
+
out.log(`Released the ${button} button`);
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// Wheel events where the mouse is (mouse move puts it there): a map zooms around that point.
|
|
510
|
+
async wheel(args) {
|
|
511
|
+
const [dx, dy, extra] = numbers(args, 'mouse wheel <dx> <dy>');
|
|
512
|
+
if (dy === undefined || extra !== undefined) throw new Error('Usage: mouse wheel <dx> <dy>, e.g. mouse wheel 0 300 scrolls down (negative dy up)');
|
|
513
|
+
await glideToMouse();
|
|
514
|
+
if (recordingOf(state.page)) await smoothWheel(dx, dy);
|
|
515
|
+
else await state.page.mouse.wheel(dx, dy);
|
|
516
|
+
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
|
|
442
520
|
const commands = {
|
|
443
521
|
async click(args) {
|
|
444
522
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]] [--force]', true);
|
|
@@ -545,79 +623,11 @@ const commands = {
|
|
|
545
623
|
pauseToShow(state.page);
|
|
546
624
|
},
|
|
547
625
|
|
|
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}`);
|
|
626
|
+
// mouse <action>, as Playwright's page.mouse: at a point, or where the mouse is.
|
|
627
|
+
async mouse(line) {
|
|
628
|
+
const [action, ...rest] = (line || '').trim().split(/\s+/);
|
|
629
|
+
if (!Object.hasOwn(MOUSE, action)) throw new Error(`Usage: mouse ${Object.keys(MOUSE).join('|')} ...; help mouse says what each takes`);
|
|
630
|
+
await MOUSE[action](rest.join(' '));
|
|
621
631
|
},
|
|
622
632
|
|
|
623
633
|
async fill(line) {
|
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).`,
|
|
@@ -244,61 +245,51 @@ sent, as when the page stops answering, the outcome is unknown.`,
|
|
|
244
245
|
detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
|
|
245
246
|
first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
|
|
246
247
|
draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
|
|
247
|
-
through points of its own:
|
|
248
|
+
through points of its own: mouse move, mouse down, mouse move, mouse up.`,
|
|
248
249
|
},
|
|
249
|
-
|
|
250
|
-
usage: '
|
|
251
|
-
summary: '
|
|
250
|
+
touch: {
|
|
251
|
+
usage: 'touch <gesture> <x> <y> ...',
|
|
252
|
+
summary: 'tap, swipe or pinch at a point, as a finger',
|
|
252
253
|
args: [
|
|
253
|
-
['<x> <y>', '
|
|
254
|
+
['tap <x> <y>', 'a tap: the page gets pointer and touch events, then click.'],
|
|
255
|
+
['doubletap <x> <y>', 'two taps: a dblclick after them.'],
|
|
256
|
+
['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.'],
|
|
257
|
+
['swipe <x> <y> <dx> <dy>', 'one finger down at x,y, moved by dx,dy in small steps about a frame apart, then lifted.'],
|
|
258
|
+
['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
259
|
],
|
|
255
|
-
detail:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
detail: `<x> <y> are CSS pixels from the top left of the viewport, as for mouse commands. It needs a touch screen:
|
|
261
|
+
emulate mobile, then reload, so the page sees one; without it, touch refuses.
|
|
262
|
+
|
|
263
|
+
Chromium takes a tap sent soon after a swipe or pinch as part of it, with no click: up to a few seconds
|
|
264
|
+
after (sleep 3s). A page that pans or zooms itself on touch (no touch-action: none) ends a swipe with
|
|
265
|
+
pointercancel, not pointerup, as it would for a finger.
|
|
266
|
+
|
|
267
|
+
For what these do not make, cdp Input.dispatchTouchEvent, one event at a time (help cdp). Each finger has an id,
|
|
268
|
+
and touchEnd lists the fingers lifted; the others stay down. One finger lifted mid-pinch, the other moving on:
|
|
269
|
+
a touchStart with two points (ids 0 and 1), then cdp Input.dispatchTouchEvent
|
|
270
|
+
{"type":"touchEnd","touchPoints":[{"x":150,"y":300,"id":0}]}, then touchMove listing only id 1, then touchEnd
|
|
271
|
+
with no points, which lifts the rest.`,
|
|
260
272
|
},
|
|
261
|
-
|
|
262
|
-
usage: '
|
|
263
|
-
summary: 'click at a point
|
|
273
|
+
mouse: {
|
|
274
|
+
usage: 'mouse <action> ...',
|
|
275
|
+
summary: 'move, click or press at a point, or turn the wheel',
|
|
264
276
|
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.'],
|
|
277
|
+
['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.'],
|
|
278
|
+
['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.'],
|
|
279
|
+
['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.'],
|
|
280
|
+
['down [button] | up [button]', 'presses or releases a button where the mouse is. A drag: mouse down, mouse move to another point, mouse up.'],
|
|
281
|
+
['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
282
|
],
|
|
273
283
|
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. The cursor (help cursor) moves there first and shows a ring at
|
|
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).`,
|
|
284
|
+
clicked with click, which waits for it. The mouse stays where it went, for a mouse wheel or mouse down
|
|
285
|
+
after it. The cursor (help cursor) moves there first and shows a ring at a press. Under emulate mobile it is
|
|
286
|
+
still the mouse; touch sends a finger's events (help touch).
|
|
287
|
+
|
|
288
|
+
A snapshot has no coordinates; eval of getBoundingClientRect gives an element's centre:
|
|
289
|
+
eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
|
|
290
|
+
.getBoundingClientRect())
|
|
291
|
+
|
|
292
|
+
playwright-cli's mouse move, mouse down, mouse up and mouse wheel run these.`,
|
|
302
293
|
},
|
|
303
294
|
fill: {
|
|
304
295
|
usage: 'fill <selector> <value> [--submit]',
|
|
@@ -594,14 +585,14 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
|
|
|
594
585
|
detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
|
|
595
586
|
with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
|
|
596
587
|
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,
|
|
588
|
+
uncheck, upload, mouse move, mouse click and mouse dblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
|
|
589
|
+
records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouse click, mouse dblclick, mouse down, and upload
|
|
599
590
|
when it clicks a button) show a ring where they land.
|
|
600
591
|
|
|
601
592
|
Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
|
|
602
593
|
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
|
-
|
|
594
|
+
select and the like move the drawing but leave the real mouse where it was; mouse down, mouse up and
|
|
595
|
+
mouse wheel act at the real mouse, so the pointer moves back there first.
|
|
605
596
|
|
|
606
597
|
It is a mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at
|
|
607
598
|
the same spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it
|
|
@@ -629,7 +620,7 @@ or headless tab keeps recording. It brings the tab to the front first, as screen
|
|
|
629
620
|
owner-only, as a screenshot is, and never replaces one. One recording per tab; another client with the
|
|
630
621
|
tab selected can stop it, and both are told whose it was.
|
|
631
622
|
|
|
632
|
-
While recording: type types at a pace the video can show (help type),
|
|
623
|
+
While recording: type types at a pace the video can show (help type), mouse wheel scrolls in eased steps,
|
|
633
624
|
an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
|
|
634
625
|
on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
|
|
635
626
|
viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
|
|
@@ -1103,11 +1094,6 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1103
1094
|
const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
|
|
1104
1095
|
if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
|
|
1105
1096
|
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
1097
|
const full = /^(\S+)\s+--all$/.exec(topicOrCommand);
|
|
1112
1098
|
if (full && Object.hasOwn(COMMANDS, full[1])) return renderCommand(full[1], true);
|
|
1113
1099
|
// A playwright-cli name: what it runs here, with that command's help.
|
|
@@ -1124,9 +1110,13 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
1124
1110
|
// pw-repl's own commands are run at the shell, so their help is the usage.
|
|
1125
1111
|
const SHELL_COMMANDS = new Set(['run', 'serve', 'send', 'attach', 'stop', 'where', 'skill']);
|
|
1126
1112
|
|
|
1113
|
+
// Commands that were renamed, so the old name says where it went.
|
|
1114
|
+
const RENAMED = { mouseclick: 'mouse click', mousedblclick: 'mouse dblclick' };
|
|
1115
|
+
|
|
1127
1116
|
function notFound(topic) {
|
|
1117
|
+
if (Object.hasOwn(RENAMED, topic)) return `${topic} is now ${RENAMED[topic]}: help ${RENAMED[topic].split(' ')[0]}`;
|
|
1128
1118
|
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
1119
|
return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
1130
1120
|
}
|
|
1131
1121
|
|
|
1132
|
-
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
|
|
1122
|
+
module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES, RENAMED };
|
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/touch.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
|
|
10
|
+
const GESTURES = ['tap', 'doubletap', 'longpress', 'swipe', 'pinch'];
|
|
11
|
+
const LONGPRESS = 800;
|
|
12
|
+
// A move every frame or so: one jump would read as no swipe to a page that measures its speed.
|
|
13
|
+
const STEP_MS = 16;
|
|
14
|
+
const STEPS = 12;
|
|
15
|
+
// Where pinch's two fingers start: this far apart, side by side about the point.
|
|
16
|
+
const PINCH_SPAN = 100;
|
|
17
|
+
|
|
18
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
|
|
20
|
+
async function send(type, points) {
|
|
21
|
+
const session = await keptSession(state.page);
|
|
22
|
+
await session.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function tap(x, y) {
|
|
26
|
+
await send('touchStart', [[x, y]]);
|
|
27
|
+
await send('touchEnd', []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usageOf(gesture) {
|
|
31
|
+
return {
|
|
32
|
+
tap: 'touch tap <x> <y>',
|
|
33
|
+
doubletap: 'touch doubletap <x> <y>',
|
|
34
|
+
longpress: 'touch longpress <x> <y> [time]',
|
|
35
|
+
swipe: 'touch swipe <x> <y> <dx> <dy>',
|
|
36
|
+
pinch: 'touch pinch <x> <y> <scale>',
|
|
37
|
+
}[gesture];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const commands = {
|
|
41
|
+
async touch(args) {
|
|
42
|
+
const [gesture, ...rest] = (args || '').trim().split(/\s+/);
|
|
43
|
+
if (!GESTURES.includes(gesture)) throw new Error(`Usage: touch ${GESTURES.join('|')} <x> <y> ...; help touch says what each takes`);
|
|
44
|
+
const usage = `Usage: ${usageOf(gesture)}`;
|
|
45
|
+
// Without it the page gets touch events but no touch screen: pointerType and the like say otherwise.
|
|
46
|
+
if (!tabState(state.page).emulation?.device) throw new Error('touch needs a touch screen: emulate mobile first, then reload');
|
|
47
|
+
const counts = { tap: 2, doubletap: 2, longpress: 3, swipe: 4, pinch: 3 }[gesture];
|
|
48
|
+
if (gesture === 'longpress' && rest.length === 3) {
|
|
49
|
+
const held = parseDuration(rest[2]);
|
|
50
|
+
if (held === null) throw new Error(`${usage}, the time in ms or seconds (1.5s)`);
|
|
51
|
+
rest[2] = String(held);
|
|
52
|
+
}
|
|
53
|
+
const values = numbers(rest.join(' '), usageOf(gesture));
|
|
54
|
+
if (values.length < (gesture === 'longpress' ? 2 : counts) || values.length > counts) throw new Error(usage);
|
|
55
|
+
const [x, y] = values;
|
|
56
|
+
if (gesture === 'tap') {
|
|
57
|
+
await tap(x, y);
|
|
58
|
+
out.log(`Tapped at ${x}, ${y}`);
|
|
59
|
+
} else if (gesture === 'doubletap') {
|
|
60
|
+
await tap(x, y);
|
|
61
|
+
await tap(x, y);
|
|
62
|
+
out.log(`Double-tapped at ${x}, ${y}`);
|
|
63
|
+
} else if (gesture === 'longpress') {
|
|
64
|
+
const held = values[2] ?? LONGPRESS;
|
|
65
|
+
await send('touchStart', [[x, y]]);
|
|
66
|
+
await wait(held);
|
|
67
|
+
await send('touchEnd', []);
|
|
68
|
+
out.log(`Long-pressed at ${x}, ${y} for ${durationText(held)}`);
|
|
69
|
+
} else if (gesture === 'swipe') {
|
|
70
|
+
const [, , dx, dy] = values;
|
|
71
|
+
await send('touchStart', [[x, y]]);
|
|
72
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
73
|
+
await wait(STEP_MS);
|
|
74
|
+
await send('touchMove', [[x + dx * i / STEPS, y + dy * i / STEPS]]);
|
|
75
|
+
}
|
|
76
|
+
await send('touchEnd', []);
|
|
77
|
+
out.log(`Swiped from ${x}, ${y} to ${x + dx}, ${y + dy}`);
|
|
78
|
+
} else {
|
|
79
|
+
const scale = values[2];
|
|
80
|
+
if (!(scale > 0)) throw new Error(`${usage}: a scale above 0; under 1 pinches in`);
|
|
81
|
+
const fingers = span => [[x - span / 2, y], [x + span / 2, y]];
|
|
82
|
+
await send('touchStart', fingers(PINCH_SPAN));
|
|
83
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
84
|
+
await wait(STEP_MS);
|
|
85
|
+
await send('touchMove', fingers(PINCH_SPAN * (1 + (scale - 1) * i / STEPS)));
|
|
86
|
+
}
|
|
87
|
+
await send('touchEnd', []);
|
|
88
|
+
out.log(`Pinched at ${x}, ${y} to ${scale}x`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
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
|
|
|
@@ -183,8 +183,9 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
183
183
|
Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
|
|
184
184
|
gives usage and caveats: read it before you use a command, since this file does not repeat them. When a
|
|
185
185
|
command fails or does something you did not expect, read its `help <command>` before working around it.
|
|
186
|
-
`help --all` prints everything
|
|
187
|
-
|
|
186
|
+
`help --all` prints everything, tens of kilobytes: a last resort, when the topics and commands have not
|
|
187
|
+
answered. pw-repl answers help itself: it needs no running REPL, and never reaches one. If you know
|
|
188
|
+
playwright-cli, its command names work too (`help playwright-cli` lists them).
|
|
188
189
|
|
|
189
190
|
### Coming from playwright-cli
|
|
190
191
|
|