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 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
@@ -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
- // 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}`);
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)', '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).`,
@@ -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 one element onto another',
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 of its own: mousemove, mousedown, mousemove, mouseup.`,
249
+ through more points than two: mouse move, mouse down, mouse move as often as needed, mouse up.`,
248
250
  },
249
- mousemove: {
250
- usage: 'mousemove <x> <y>',
251
- summary: 'move the mouse to a point in the viewport',
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>', 'CSS pixels from the top left of the tab\'s viewport (not the page), as mouse events have them.'],
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: `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.`,
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
- mouseclick: {
262
- usage: 'mouseclick <x> <y> [button]',
263
- summary: 'click at a point in the viewport',
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 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.'],
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. 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).`,
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, 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
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; mousedown, mouseup and
604
- mousewheel act at the real mouse, so the pointer moves back there first.
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), mousewheel scrolls in eased steps,
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, before --file. 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 ...'],
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: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
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
- // eval --timeout=<time> [--file=...] <JavaScript>: how long the page has to answer.
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
- const timed = /^--timeout(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
499
- if (/^--timeout(?:[=\s]|$)/.test(args.trim())) {
500
- timeout = parseDuration(timed?.[1]);
501
- if (!timeout) throw new Error('Usage: eval --timeout=<time> <JavaScript>, a time in ms or with s: eval --timeout=60s ...');
502
- args = timed[2] || '';
503
- if (!args.trim()) throw new Error('Usage: eval --timeout=<time> <JavaScript>');
504
- }
505
- let ref = null;
506
- // eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
507
- // it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
508
- const fromFile = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
509
- if (/^--file(?:[=\s]|$)/.test(args.trim())) {
510
- if (!fromFile) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
511
- const file = path.resolve(unquote(fromFile[1]));
512
- const target = fromFile[2]?.trim() || null;
513
- try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
514
- // eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
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
- if (ref) {
525
- const selector = toSelector(ref);
526
- const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
527
- try {
528
- // Made from its text by the debugger, as eval is, so a page's CSP cannot
529
- // refuse it, and in the element's own frame, which may be an iframe.
530
- const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
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
- printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
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 fn.dispose().catch(() => {});
551
+ await el.dispose().catch(() => {});
535
552
  }
536
- } finally {
537
- await el.dispose().catch(() => {});
553
+ return;
538
554
  }
539
- return;
540
- }
541
- // A function, as playwright-cli's eval takes, is called rather than returned.
542
- // Only a single expression can be one; statements are evaluated as they are.
543
- // await at the top level: one expression is run as an async function; statements as DevTools'
544
- // console runs them (replMode), which gives back the last one's value.
545
- const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
546
- const AsyncFunction = (async () => {}).constructor;
547
- const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
548
- const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
549
- const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
550
- if (awaits && !single && compiles(AsyncFunction, args)) {
551
- const session = await keptSession(state.page);
552
- const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), timeout);
553
- if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
554
- printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
555
- return;
556
- }
557
- const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
558
- const value = await evaluated(state.page.evaluate(expression), timeout);
559
- // An empty string is shown as one, not as no output at all.
560
- printOutput(value === '' ? '""' : value, all);
561
- // Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
562
- // one plain literal only, not "a" + b or a template with ${...} in it, which ran.
563
- if (typeof value === 'string' && value && /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`)$/s.test(args.trim())) {
564
- out.log('(That is a string: the JavaScript was in quotes, which eval keeps. Without them, it runs: eval document.title)');
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', '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/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, after its --timeout if it has one; -1 when it has none.
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
- if (/^--timeout=/.test(words[at] || '')) at += 1;
101
- else if (words[at] === '--timeout') at += 2;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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
 
@@ -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, in a temporary
57
- profile, on a free port of its own (never 9222). It stops with the REPL, Ctrl-C while it starts
58
- included, and `stop` returns once it has exited. It prints the command it ran; flags after `--` are
59
- passed to that Chromium (only flags: a start URL goes before `--`), and a `--user-data-dir=<dir>`
60
- among them is used instead of the temporary profile, and kept. Started again in a kept profile,
61
- Chromium picks up where it left off, as it would for the user, headless too: its tabs come back, and
62
- session cookies with them, so a login stays. They start loading before the REPL is attached, so
63
- `requests` and `console` have only what came after, often nothing: `reload` for a full record.
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 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).
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