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 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) | `mouseclick <x> <y>`, `mousedblclick <x> <y>` |
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
- // At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
549
- async mousemove(args) {
550
- const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
551
- if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
552
- stepTarget(state.page, null, { x, y });
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)', 'mouseclick <x> <y>, mousedblclick <x> <y>'],
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. mousemove and the other mouse commands act at a point instead. An action
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', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel'],
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: mousemove, mousedown, mousemove, mouseup.`,
248
+ through points of its own: mouse move, mouse down, mouse move, mouse up.`,
248
249
  },
249
- mousemove: {
250
- usage: 'mousemove <x> <y>',
251
- summary: 'move the mouse to a point in the viewport',
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>', 'CSS pixels from the top left of the tab\'s viewport (not the page), as mouse events have them.'],
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: `A snapshot has no coordinates; eval of getBoundingClientRect gives an element's centre:
256
- eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
257
- .getBoundingClientRect())
258
-
259
- A click at a point: mouseclick <x> <y>; a double-click: mousedblclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
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
- mouseclick: {
262
- usage: 'mouseclick <x> <y> [button]',
263
- summary: 'click at a point in the viewport',
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 for mousemove.'],
266
- ['[button]', 'left, right or middle. Default: left.'],
267
- ],
268
- options: [
269
- ['--button=<button>', 'the button, as click\'s: mouseclick 400 300 --button=right.'],
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. help mousemove shows how to find a point. The mouse stays at the point, for a mousewheel or mousedown after
275
- it. The cursor (help cursor) moves there first and shows a ring at the press.`,
276
- },
277
- mousedblclick: {
278
- usage: 'mousedblclick <x> <y> [button]',
279
- summary: 'double-click at a point in the viewport',
280
- args: [
281
- ['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
282
- ['[button]', 'left, right or middle. Default: left.'],
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, mousemove, mouseclick and mousedblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
598
- records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedblclick, mousedown, and upload
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; mousedown, mouseup and
604
- mousewheel act at the real mouse, so the pointer moves back there first.
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), mousewheel scrolls in eased steps,
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', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel']);
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 = /^mouse/.test(cmd) ? null : splitSelector(words);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
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 at once. pw-repl answers help itself: it needs no running REPL, and never
187
- reaches one. If you know playwright-cli, its command names work too (`help playwright-cli` lists them).
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