pw-repl 0.13.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;
@@ -56,48 +56,24 @@ If you hit a limitation or write a workaround, consider adding the capability to
56
56
  after an intended change to it, save it again (the command is in `test/units.test.js`). The common
57
57
  tasks and modes on help's front page (`TASKS` and `MODES`) are the README's too: change both, as a
58
58
  unit test compares them.
59
- - Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`).
60
- Compare with the last commit that has one (`git log --grep='npm test:'`): a run that grew by more than
61
- a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
59
+ - Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`),
60
+ with the load average (`uptime`) when it is high. Compare with the last commit that has one (`git log
61
+ --grep='npm test:'`): a run that grew by more than a few seconds, at a like load (a busy machine slows
62
+ every test), means a slow test crept in, usually one waiting out a real timeout (5s for a click).
62
63
  Find it (each test prints its time) and make it faster, or say why it can't be, before committing.
63
64
  - Don't race the clock in a test: a page that changes on a timer, or a command timed against a budget,
64
65
  fails on a busy machine. Have the page change when the test asks (eval), or measure the thing itself
65
66
  (frames drawn, not clicks timed). A click waits for its element to hold still for two frames.
66
67
  - For anything the tests can't reach, exercise the change in a running REPL of your own: on a socket of
67
68
  your own, with `--launch` unless the change needs the user's browser, and then in a tab of your own.
69
+ One started before a change to `lib/` runs the old code: restart it after a change. Stop a process by
70
+ its pid, after checking what it is; a broad `pkill -f` pattern can match your own shell, or a REPL
71
+ someone else is using.
68
72
  - `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
69
73
  used, and leave its Custom rules section empty. Keep its `<!-- pw-repl skill stamp -->` line: `pw-repl
70
74
  skill` replaces it with the version and a hash of the skill's text, which `where` reports, so agents
71
75
  can tell a saved copy is out of date.
72
76
 
73
- ## Waves
74
-
75
- Work goes in waves, and each one ends with a release that is ready to push.
76
-
77
- 1. Build what was asked, with tests, and try it in a running REPL. For anything the user will look at
78
- (a cursor, an overlay, a video), show them a demo while it is still a prototype (a recording or
79
- screenshots they can open) and get their go-ahead on how it looks before writing tests or
80
- building on it.
81
- 2. Try it on a fresh agent that has not seen the code or the change (a guinea pig), on something real: a
82
- small local page whose planted bugs need the change to find.
83
- - Brief it with goals only: what to find out or do in the browser, never the commands.
84
- - First it reads back its plan, learned from `pw-repl skill` and help alone: the commands, where it
85
- found them, and what is unclear. It runs nothing yet.
86
- - A wrong or unsure plan means the docs or the brief were unclear: find out which, fix that, then
87
- ask again.
88
- - Then it carries the plan out, in a browser and on a socket of its own, and reports as it goes:
89
- above all, where the tool did something other than what the docs led it to expect.
90
- - Once it has confirmed the fixes work, it reviews them as a second pair of eyes: anything important
91
- they miss or get wrong, not nitpicks.
92
- 3. Triage what it reports. Fix what matters, and what is small and useful; don't put off something
93
- useful for later. Leave trivia alone, and say why.
94
- 4. Repeat 2 and 3, with a fresh agent each time, until a pass comes back clean: no real bugs, and
95
- nothing unclear in the docs or the brief that changed the plan. A pass after fixes tries the fixes,
96
- and something not tried yet.
97
- 5. Only then cut the release (Releasing, steps 1-3), and say it is ready. The maintainer pushes and
98
- publishes it, and the next wave starts from what they find. A release cut too early and not pushed
99
- yet is undone (its commit and tag) and cut again after the fixes.
100
-
101
77
  ## Releasing
102
78
 
103
79
  Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`,
@@ -106,9 +82,9 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
106
82
 
107
83
  1. `npm test` passes and the working tree is clean.
108
84
  2. `npm outdated` and `npm audit` show nothing that needs doing first. Dependencies are always the newest
109
- stable release, never pinned back (user ruling). When the newest breaks a test, fix it in our code;
110
- only a regression of the dependency's own, with the fix already in its next release, keeps the version
111
- before it, until that release ships. playwright-core stays at 1.63 for that reason: on 1.64.1 a click
85
+ stable release, never pinned back. When the newest breaks a test, fix it in our code; only a
86
+ regression of the dependency's own, with the fix already in its next release, keeps the version before
87
+ it, until that release ships. playwright-core stays at 1.63 for that reason: on 1.64.1 a click
112
88
  on a link to a 204 hangs at "waiting for scheduled navigations to finish" until its timeout, in plain
113
89
  Playwright too (chromium.launch, a page with a link to a 204, page.click on it); 1.65's alphas fix it.
114
90
  The test "waits for the page to load, counting a navigation that just began" shows it: move up once it
@@ -118,4 +94,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
118
94
  4. `git push --follow-tags` pushes the commits and the tag.
119
95
  5. `npm publish` publishes it; `prepublishOnly` runs the tests again first.
120
96
 
97
+ A release cut too early and not pushed yet is undone (its commit and tag) and cut again after the fixes.
98
+
121
99
  Pushing and publishing need the maintainer's GitHub and npm credentials.
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` |
@@ -199,7 +200,10 @@ pw-repl where # which REPL send would reach
199
200
 
200
201
  `pw-repl send` sends one command and prints its result, through the server when one is running, and
201
202
  through the `pw-repl` tmux session otherwise. Chain several in the shell: `&&` stops at the first that
202
- fails or is not confirmed (exit 1 or 2), and `;` carries on.
203
+ fails or is not confirmed (exit 1 or 2), and `;` carries on. `-t` is how long it waits for the answer
204
+ (default 20s). Times, there and in every command, are in milliseconds, as in Playwright, or in seconds
205
+ with an `s`: `-t 90000` or `-t 90s`. A send that gives up cancels its command: one still queued never
206
+ runs, and one running is no longer waited for.
203
207
 
204
208
  Several agents can share one REPL. `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client
205
209
  with a selected tab of its own, shown in the REPL as `[server:<name>]`. `modes` says which client turned
@@ -227,7 +231,8 @@ The body may also have `"client": "<name>"`, and `"vars": {"PW_NAME": "<value>"}
227
231
  "error", "output": "..."}`, plus `"unconfirmed": true` when the command may or may not have done what it
228
232
  was sent to do (it timed out, or the REPL quit while it ran and it was not read-only). While the REPL
229
233
  starts, `/run` answers 503 with `"starting": true`: retry. `GET /health` says `"status": "starting"` until
230
- it serves, then `"ok"`. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
234
+ it serves, then `"ok"`. A client that closes its connection before the answer cancels the command, as
235
+ `send` does when it gives up. `pw-repl serve <port>` serves TCP on 127.0.0.1; other addresses are refused.
231
236
 
232
237
  ## How it differs from playwright-cli
233
238
 
@@ -237,6 +242,7 @@ playwright-cli's command names and most of their options work here too (`help pl
237
242
  them). Where pw-repl differs, it is mostly because someone else may be using the browser:
238
243
 
239
244
  - `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
245
+ `close` also lists the files you wrote that are still there.
240
246
  - Routes, network and emulation apply to one tab, not the whole browser.
241
247
  - File pickers are not caught, since they may be the user's: `upload` names the input instead.
242
248
  - There is no snapshot after every command, since the user reads the REPL too; `snapshot` shows one.
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
@@ -39,7 +39,7 @@ const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
39
39
  // click takes --click-count too; dblclick is click's count of 2.
40
40
  function clickArgs(args, usage, counts = false) {
41
41
  // Found outside quotes only, so a quoted selector keeps whatever is in it.
42
- const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value' });
42
+ const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value', force: 'flag' });
43
43
  if (!counts && found.some(o => o.name === 'click-count')) throw new Error('dblclick is a click count of 2; for another count, click --click-count=<n>');
44
44
  const clickCount = clickCountOf(found, usage);
45
45
  const modifiers = found.filter(o => o.name === 'modifiers').flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
@@ -49,12 +49,14 @@ function clickArgs(args, usage, counts = false) {
49
49
  const positional = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
50
50
  const button = chooseButton(found, positional, usage);
51
51
  const selector = positional ? parsed.selector : soleSelector(text, usage);
52
- const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}) };
52
+ const force = found.some(o => o.name === 'force');
53
+ const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}), ...(force ? { force } : {}) };
53
54
  // The selector as written, then the kind of click, so an option never reads as part of the selector.
54
55
  const kind = [
55
56
  ...(button && button !== 'left' ? [`${button} button`] : []),
56
57
  ...(clickCount > 1 ? [`${clickCount} times`] : []),
57
58
  ...(modifiers.length ? [`with ${modifiers.join('+')}`] : []),
59
+ ...(force ? ['forced'] : []),
58
60
  ];
59
61
  return { selector, shown: [positional ? parsed.word : unquote(text), ...kind].join(', '), options };
60
62
  }
@@ -104,8 +106,9 @@ async function onElement(selector, action, budget = 5000) {
104
106
  console.error(`Playwright's call log:\n${message}`);
105
107
  // Found, so the ref is not stale: no hint.
106
108
  const summary = new Error(`Timed out after ${budget / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
107
- // Worked out from the whole log: the summary no longer says whether the element was found.
108
- summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
109
+ // Unknown only once input was sent, in any retry. Playwright's checks (visible, enabled, stable, and
110
+ // an element covering the point) come before it logs "performing <x> action" and sends it.
111
+ summary.uncertain = /performing \w+ action/.test(message);
109
112
  throw summary;
110
113
  }
111
114
  }
@@ -227,6 +230,18 @@ function mouseButton(args, name) {
227
230
  // A page that renders the chosen element again (as React lists do) replaces it before it is
228
231
  // acted on; it is chosen again then, as Playwright finds a locator's element again.
229
232
  async function pointerAction(selector, act, options = {}, clicks = 0) {
233
+ try {
234
+ return await choosingPointerAction(selector, act, options, clicks);
235
+ } catch (error) {
236
+ // Playwright counts aria-disabled as disabled, though a page may keep such an element clickable.
237
+ if (clicks && !options.force && /: element is not enabled \(/.test(error.message)) {
238
+ error.message += '\nIt may be aria-disabled, which counts as disabled; --force clicks it anyway (help click).';
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ async function choosingPointerAction(selector, act, options, clicks) {
230
245
  const TIMEOUT = 5000;
231
246
  const deadline = Date.now() + TIMEOUT;
232
247
  const left = () => Math.max(1000, deadline - Date.now());
@@ -424,15 +439,93 @@ function dateInputHolds(kind, value, what, selector) {
424
439
  return `, a ${kind} input, which now holds ${value}`;
425
440
  }
426
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
+
427
520
  const commands = {
428
521
  async click(args) {
429
- const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]]', true);
522
+ const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]] [--force]', true);
430
523
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, options.clickCount || 1);
431
524
  out.log(`Clicked: ${shown}${which}`);
432
525
  },
433
526
 
434
527
  async dblclick(args) {
435
- const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]]');
528
+ const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]] [--force]');
436
529
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
437
530
  out.log(`Double-clicked: ${shown}${which}`);
438
531
  },
@@ -530,79 +623,11 @@ const commands = {
530
623
  pauseToShow(state.page);
531
624
  },
532
625
 
533
- // At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
534
- async mousemove(args) {
535
- const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
536
- if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
537
- stepTarget(state.page, null, { x, y });
538
- await glideTo(state.page, x, y);
539
- await state.page.mouse.move(x, y);
540
- tabState(state.page).mouseAt = { x, y };
541
- out.log(`Moved the mouse to ${x}, ${y}`);
542
- },
543
-
544
- // A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
545
- async mouseclick(line) {
546
- const usage = 'mouseclick <x> <y> [left|right|middle] [--button=<button>] [--click-count=<n>] [--delay=<ms>]';
547
- const { words, found, button } = mouseArgs(line, usage, { 'click-count': 'value', delay: 'value' });
548
- const clickCount = clickCountOf(found, usage);
549
- const delay = found.find(o => o.name === 'delay');
550
- if (delay && !/^\d+$/.test(delay.value || '')) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
551
- const held = delay ? Number(delay.value) : 0;
552
- const [x, y, extra] = numbers(words.join(' '), usage);
553
- if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
554
- stepTarget(state.page, null, { x, y });
555
- await glideTo(state.page, x, y);
556
- await state.page.mouse.move(x, y);
557
- tabState(state.page).mouseAt = { x, y };
558
- // Each press counts up, as Playwright's mouse.click does it. The ring shows at the first press, as
559
- // mousedown's does, while a --delay holds the button.
560
- for (let count = 1; count <= clickCount; count += 1) {
561
- await state.page.mouse.down({ button, clickCount: count });
562
- if (count === 1) await showClick(state.page, clickCount);
563
- if (held) await new Promise(resolve => setTimeout(resolve, held));
564
- await state.page.mouse.up({ button, clickCount: count });
565
- }
566
- out.log(`Clicked the ${button} button at ${x}, ${y}${clickCount > 1 ? ` ${clickCount} times` : ''}`);
567
- },
568
-
569
- // Two mouseclicks are two single clicks (each event's detail is 1), so no dblclick fires: this is one.
570
- async mousedblclick(line) {
571
- const usage = 'mousedblclick <x> <y> [left|right|middle] [--button=<button>]';
572
- const { words, button } = mouseArgs(line, usage);
573
- const [x, y, extra] = numbers(words.join(' '), usage);
574
- if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
575
- stepTarget(state.page, null, { x, y });
576
- await glideTo(state.page, x, y);
577
- await state.page.mouse.dblclick(x, y, { button });
578
- tabState(state.page).mouseAt = { x, y };
579
- await showClick(state.page, 2);
580
- out.log(`Double-clicked the ${button} button at ${x}, ${y}`);
581
- },
582
-
583
- async mousedown(args) {
584
- const button = mouseButton(args, 'mousedown');
585
- await glideToMouse();
586
- await state.page.mouse.down({ button });
587
- await showClick(state.page);
588
- out.log(`Pressed the ${button} button`);
589
- },
590
-
591
- async mouseup(args) {
592
- const button = mouseButton(args, 'mouseup');
593
- await glideToMouse();
594
- await state.page.mouse.up({ button });
595
- out.log(`Released the ${button} button`);
596
- },
597
-
598
- // Wheel events where the mouse is (mousemove puts it there): a map zooms around that point.
599
- async mousewheel(args) {
600
- const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
601
- if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
602
- await glideToMouse();
603
- if (recordingOf(state.page)) await smoothWheel(dx, dy);
604
- else await state.page.mouse.wheel(dx, dy);
605
- 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(' '));
606
631
  },
607
632
 
608
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).`,
@@ -216,6 +217,7 @@ afresh.`,
216
217
  ['--button=<button>', 'the button, after any selector: click #menu --button=right. Over send, the shell takes the quotes, so pw-repl send click \'#menu\' right clicks a selector "#menu right": use --button, or quote it twice (\'"#menu"\').'],
217
218
  ['--click-count=<n>', 'clicks this many times at once, as Playwright\'s clickCount: 2 is a double-click (as dblclick), 3 a triple-click, which selects a line of text or all of a field\'s. 1 to 3. Default: 1.'],
218
219
  ['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
220
+ ['--force', 'skips Playwright\'s actionability checks (enabled, stable, not covered), as Playwright\'s force. For an element marked aria-disabled, which Playwright counts as disabled, that a page keeps clickable (a greyed menu item that says why). Several matches are still chosen from as below; a covered ref or single match is clicked where it is, so what covers it gets the click.'],
219
221
  ],
220
222
  detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
221
223
  and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
@@ -223,8 +225,10 @@ one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none
223
225
  says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
224
226
  it to be ready.
225
227
 
226
- Timed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts pointer
227
- events) and that the outcome is unknown; Playwright's full call log goes to the REPL's pane or log.`,
228
+ Timed out, it says what stopped it, and Playwright's full call log goes to the REPL's pane or log. Stopped by
229
+ one of Playwright's checks before the click is sent, it did nothing: the element is not visible, enabled
230
+ or stable, or <div class="overlay"> intercepts pointer events (something covers it). Stopped after it was
231
+ sent, as when the page stops answering, the outcome is unknown.`,
228
232
  },
229
233
  dblclick: {
230
234
  usage: 'dblclick <selector> [button]',
@@ -241,61 +245,51 @@ events) and that the outcome is unknown; Playwright's full call log goes to the
241
245
  detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
242
246
  first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
243
247
  draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
244
- through points of its own: mousemove, mousedown, mousemove, mouseup.`,
248
+ through points of its own: mouse move, mouse down, mouse move, mouse up.`,
245
249
  },
246
- mousemove: {
247
- usage: 'mousemove <x> <y>',
248
- 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',
249
253
  args: [
250
- ['<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.'],
251
259
  ],
252
- detail: `A snapshot has no coordinates; eval of getBoundingClientRect gives an element's centre:
253
- eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
254
- .getBoundingClientRect())
255
-
256
- 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.`,
257
272
  },
258
- mouseclick: {
259
- usage: 'mouseclick <x> <y> [button]',
260
- 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',
261
276
  args: [
262
- ['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
263
- ['[button]', 'left, right or middle. Default: left.'],
264
- ],
265
- options: [
266
- ['--button=<button>', 'the button, as click\'s: mouseclick 400 300 --button=right.'],
267
- ['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
268
- ['--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).'],
269
282
  ],
270
283
  detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
271
- 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
272
- it. The cursor (help cursor) moves there first and shows a ring at the press.`,
273
- },
274
- mousedblclick: {
275
- usage: 'mousedblclick <x> <y> [button]',
276
- summary: 'double-click at a point in the viewport',
277
- args: [
278
- ['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
279
- ['[button]', 'left, right or middle. Default: left.'],
280
- ],
281
- options: [
282
- ['--button=<button>', 'the button, as click\'s.'],
283
- ],
284
- detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
285
- dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
286
- <x> <y> --click-count=3.`,
287
- },
288
- 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.' },
289
- 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.' },
290
- mousewheel: {
291
- usage: 'mousewheel <dx> <dy>',
292
- summary: 'turn the wheel where the mouse is',
293
- args: [
294
- ['<dx> <dy>', 'CSS pixels; positive dy scrolls down.'],
295
- ],
296
- detail: `The events go to the mouse's point (mousemove puts it there): a map zooms around it, so mousemove 400
297
- 300, then mousewheel 0 -300 zooms in. It returns once the events are sent, not once the page has
298
- 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.`,
299
293
  },
300
294
  fill: {
301
295
  usage: 'fill <selector> <value> [--submit]',
@@ -533,7 +527,7 @@ circle). That changes the page, so put it back afterwards, as it was if it had a
533
527
  summary: 'save a PNG of the viewport, --full page or an element',
534
528
  args: [
535
529
  ['[<ref>]', 'only that element, from a snapshot ref: screenshot e5.'],
536
- ['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp.'],
530
+ ['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp. A word with a / or ending in .png, .jpg or .jpeg is a file instead, as --filename: x.png is the file x.png, not screenshot-x.png. At the prompt, a path with a space goes in --filename="<file>"; through pw-repl send, either works.'],
537
531
  ],
538
532
  options: [
539
533
  ['--full', 'the whole page, not only the viewport; --full-page too.'],
@@ -591,14 +585,14 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
591
585
  detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
592
586
  with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
593
587
  point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
594
- uncheck, upload, mousemove, mouseclick and mousedblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
595
- 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
596
590
  when it clicks a button) show a ring where they land.
597
591
 
598
592
  Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
599
593
  the cursor off. It shows only the REPL's input, from every client, never the user's own mouse. fill,
600
- select and the like move the drawing but leave the real mouse where it was; mousedown, mouseup and
601
- 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.
602
596
 
603
597
  It is a mode of its tab: modes lists it and modes off turns it off. It stays on across page loads, at
604
598
  the same spot, and shows in screenshots and recordings. Selectors, snapshot and text don't see it, it
@@ -626,7 +620,7 @@ or headless tab keeps recording. It brings the tab to the front first, as screen
626
620
  owner-only, as a screenshot is, and never replaces one. One recording per tab; another client with the
627
621
  tab selected can stop it, and both are told whose it was.
628
622
 
629
- 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,
630
624
  an element out of view is scrolled to smoothly, each action and each highlight drawn is followed by --pause, and with the cursor
631
625
  on it rests 300ms on the element before acting. fill still sets a value at once: use type for text the
632
626
  viewer should see typed. Send the take as a file of commands (pw-repl send --file take.txt), or the time
@@ -915,7 +909,7 @@ window.__done until it is set.`,
915
909
  },
916
910
  cdp: {
917
911
  usage: 'cdp [--all] <method> <JSON object>',
918
- summary: 'send one CDP command through a temporary session',
912
+ summary: 'send one CDP command to the selected tab',
919
913
  args: [
920
914
  ['<method>', 'a CDP method: Network.getCookies.'],
921
915
  ['<JSON object>', 'its parameters: {} for none.'],
@@ -923,8 +917,21 @@ window.__done until it is set.`,
923
917
  options: [
924
918
  ['--all', 'the whole result, not capped.'],
925
919
  ],
926
- detail: `The session is detached afterwards, so subscriptions and settings do not persist. Browser.close and
927
- Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog answers one.`,
920
+ detail: `A command goes through a temporary session, detached afterwards, so subscriptions and settings do not
921
+ persist (input aside, below). Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
922
+ answers one.
923
+
924
+ Input (Input.dispatchTouchEvent, dispatchMouseEvent, the synthesize gestures): through a session kept for
925
+ the tab, so a sequence sent one call at a time stays one sequence (Chrome drops it when the session that
926
+ began it detaches). End one before other commands (touchEnd, or touchCancel to drop it): left open, the
927
+ next click lands mid-touch. Input.setIgnoreInputEvents and setInterceptDrags go through a temporary session,
928
+ as they would otherwise stay on and break later clicks.
929
+
930
+ Touch (under emulate mobile, for pointerType touch): touchStart and touchEnd sent this way make a tap, with
931
+ click, and two in a row a dblclick; held, it is still a tap, with no contextmenu. Chrome takes a tap sent
932
+ soon after another gesture as part of it, with no click: up to a few seconds after a drag (sleep 3s). The synthesize gestures
933
+ (Input.synthesizeTapGesture, synthesizePinchGesture, synthesizeScrollGesture) send pointer and touch events
934
+ but no click, dblclick or contextmenu.`,
928
935
  },
929
936
  clipboard: {
930
937
  usage: 'clipboard [--all] [--shared-clipboard]',
@@ -1087,11 +1094,6 @@ Different on purpose, since the browser may be someone else's too:
1087
1094
  const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
1088
1095
  if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
1089
1096
  if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
1090
- // Not a topic: the mouse commands are in interact, with the others that act on the page.
1091
- if (topicOrCommand === 'mouse') {
1092
- const mice = TOPICS.interact.commands.filter(c => c.startsWith('mouse'));
1093
- return ['The mouse commands, which act at a point (part of help interact):', '', ...commandLines(mice)].join('\n');
1094
- }
1095
1097
  const full = /^(\S+)\s+--all$/.exec(topicOrCommand);
1096
1098
  if (full && Object.hasOwn(COMMANDS, full[1])) return renderCommand(full[1], true);
1097
1099
  // A playwright-cli name: what it runs here, with that command's help.
@@ -1108,9 +1110,13 @@ Different on purpose, since the browser may be someone else's too:
1108
1110
  // pw-repl's own commands are run at the shell, so their help is the usage.
1109
1111
  const SHELL_COMMANDS = new Set(['run', 'serve', 'send', 'attach', 'stop', 'where', 'skill']);
1110
1112
 
1113
+ // Commands that were renamed, so the old name says where it went.
1114
+ const RENAMED = { mouseclick: 'mouse click', mousedblclick: 'mouse dblclick' };
1115
+
1111
1116
  function notFound(topic) {
1117
+ if (Object.hasOwn(RENAMED, topic)) return `${topic} is now ${RENAMED[topic]}: help ${RENAMED[topic].split(' ')[0]}`;
1112
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`;
1113
1119
  return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
1114
1120
  }
1115
1121
 
1116
- module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
1122
+ module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES, RENAMED };
package/lib/inspect.js CHANGED
@@ -5,7 +5,7 @@ const { state, withTimeout } = require('./state');
5
5
  const out = require('./output');
6
6
  const { printOutput } = out;
7
7
  const { devices } = require('playwright-core');
8
- const { toSelector, REF, unquote } = require('./syntax');
8
+ const { toSelector, REF, SCREENSHOT_FILE, unquote } = require('./syntax');
9
9
  const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE, TIME, parseDuration, durationText } = require('./util');
10
10
  const { tabState } = require('./tabstate');
11
11
  const { noteWritten } = require('./written');
@@ -316,7 +316,7 @@ const commands = {
316
316
  },
317
317
 
318
318
  async screenshot(args) {
319
- const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
319
+ const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | <file> | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens; a word with a / or ending in .png, .jpg or .jpeg is a file.';
320
320
  // The file first: a path may be quoted, with spaces in it.
321
321
  let file = null;
322
322
  const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
@@ -339,6 +339,11 @@ const commands = {
339
339
  if (delay === null || delay > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <time> (ms, or 5s; at most ${durationText(SCREENSHOT_DELAY_MAX)})`);
340
340
  continue;
341
341
  }
342
+ if (SCREENSHOT_FILE.test(token)) {
343
+ if (file) throw new Error(usage);
344
+ file = token;
345
+ continue;
346
+ }
342
347
  names.push(token);
343
348
  }
344
349
  if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
@@ -599,6 +604,13 @@ const commands = {
599
604
  if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
600
605
  const params = JSON.parse(match[2]);
601
606
  if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
607
+ // Input sent through the tab's kept session: Chrome drops a touch or mouse sequence when the session
608
+ // that began it detaches. Not Input's two switches, which would stay on and break later clicks.
609
+ if (/^Input\./.test(match[1]) && !['Input.setIgnoreInputEvents', 'Input.setInterceptDrags'].includes(match[1])) {
610
+ const kept = await keptSession(state.page);
611
+ printOutput(await withTimeout(kept.send(match[1], params), 'CDP operation', COMMAND_TIMEOUT), all);
612
+ return;
613
+ }
602
614
  const operation = async () => {
603
615
  const cdp = await state.page.context().newCDPSession(state.page);
604
616
  try {
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
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const crypto = require('crypto');
7
7
  const { execFileSync } = require('child_process');
8
8
  const client = require('./client');
9
- const { shellWords, variablesIn } = require('./syntax');
9
+ const { shellWords, variablesIn, SCREENSHOT_FILE } = require('./syntax');
10
10
 
11
11
  // The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
12
12
  const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
@@ -139,7 +139,8 @@ function commandLine(words) {
139
139
  if (file) resolved = [...words.slice(0, at), `--file=${resolve(file)}`, ...rest];
140
140
  }
141
141
  if (words[0] === 'screenshot') {
142
- resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
142
+ // A file as a word of its own is sent as --filename, so a space in it stays in it.
143
+ resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : n && !w.startsWith('-') && SCREENSHOT_FILE.test(w) ? `--filename=${resolve(w)}` : w));
143
144
  }
144
145
  const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
145
146
  // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
package/lib/syntax.js CHANGED
@@ -89,4 +89,7 @@ function expandVariables(line, values) {
89
89
  });
90
90
  }
91
91
 
92
- module.exports = { SELECTOR_FIRST, REF, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
92
+ // A screenshot's file given as a word of its own: a name has neither a / nor an extension.
93
+ const SCREENSHOT_FILE = /\/|\.(?:png|jpe?g)$/i;
94
+
95
+ module.exports = { SELECTOR_FIRST, REF, SCREENSHOT_FILE, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
package/lib/tabs.js CHANGED
@@ -21,6 +21,12 @@ async function navigating(go) {
21
21
  for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
22
22
  const navigation = find();
23
23
  if (!navigation) throw error;
24
+ // Chrome commits its error page just after the request fails: waited for, so info right after reads it.
25
+ // Capped at 1s, for a failure with no error page; a tab already on one matches at once, which info
26
+ // still reads right, from the log.
27
+ if (!stayedPut(navigation) && !/ERR_ABORTED/.test(`${navigation.status} ${navigation.failed}`)) {
28
+ for (let waited = 0; !state.page.url().startsWith('chrome-error://') && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
29
+ }
24
30
  throw new Error(notLoaded(navigation));
25
31
  }
26
32
  }
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.13.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