pw-repl 0.12.6 → 0.12.8

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/lib/elements.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const { state, onShutdown } = require('./state');
5
5
  const out = require('./output');
6
6
  const { takeOptions } = require('./cli-names');
7
- const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
7
+ const { unquote, unescapeDouble, toSelector, keyName, splitSelector, REF } = require('./syntax');
8
8
  const { numbers, hints, BUSY_PAGE } = require('./util');
9
9
  const { tabState } = require('./tabstate');
10
10
  const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
@@ -35,18 +35,37 @@ const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
35
35
 
36
36
  // click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
37
37
  // takes them. The button is read only after a ref or a quoted selector, so
38
- // click text=Turn right is still one selector.
39
- function clickArgs(args, usage) {
38
+ // click text=Turn right is still one selector; --button=right, as Playwright's option, after any.
39
+ // click takes --click-count too; dblclick is click's count of 2.
40
+ function clickArgs(args, usage, counts = false) {
40
41
  // Found outside quotes only, so a quoted selector keeps whatever is in it.
41
- const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
42
- const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
42
+ const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value', button: 'value', 'click-count': 'value' });
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
+ const clickCount = clickCountOf(found, usage);
45
+ const modifiers = found.filter(o => o.name === 'modifiers').flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
43
46
  const unknown = modifiers.find(m => !MODIFIERS.includes(m));
44
47
  if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
45
48
  const parsed = splitSelector(text);
46
- const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
47
- const selector = button ? parsed.selector : soleSelector(text, usage);
48
- const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
49
- return { selector, shown: args.trim(), options };
49
+ const positional = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
50
+ const button = chooseButton(found, positional, usage);
51
+ const selector = positional ? parsed.selector : soleSelector(text, usage);
52
+ const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}), ...(clickCount > 1 ? { clickCount } : {}) };
53
+ // The selector as written, then the kind of click, so an option never reads as part of the selector.
54
+ const kind = [
55
+ ...(button && button !== 'left' ? [`${button} button`] : []),
56
+ ...(clickCount > 1 ? [`${clickCount} times`] : []),
57
+ ...(modifiers.length ? [`with ${modifiers.join('+')}`] : []),
58
+ ];
59
+ return { selector, shown: [positional ? parsed.word : unquote(text), ...kind].join(', '), options };
60
+ }
61
+
62
+ // --click-count=<n>, as Playwright's clickCount: 3 selects a line or a field's text. Past 3 a browser
63
+ // does nothing more.
64
+ function clickCountOf(found, usage) {
65
+ const given = found.filter(o => o.name === 'click-count');
66
+ if (!given.length) return 1;
67
+ if (given.length > 1 || !/^[1-3]$/.test(given[0].value || '')) throw new Error(`Usage: ${usage}; --click-count is 1, 2 or 3, once (2 is a double-click, 3 a triple-click)`);
68
+ return Number(given[0].value);
50
69
  }
51
70
 
52
71
  // A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
@@ -177,9 +196,30 @@ async function chooseMatch(selector, timeout) {
177
196
  throw error;
178
197
  }
179
198
 
199
+ // The button, from a word (click e5 right) or --button=right, as Playwright's option is named; not both.
200
+ function chooseButton(found, positional, usage) {
201
+ const named = found.filter(o => o.name === 'button');
202
+ if (named.length > 1 || (named.length && !/^(?:left|right|middle)$/.test(named[0].value || ''))) throw new Error(`Usage: ${usage}; --button is left, right or middle, once`);
203
+ if (positional && named.length) throw new Error(`Usage: ${usage}; the button goes once, as a word or as --button, not both`);
204
+ return positional || named[0]?.value || null;
205
+ }
206
+
207
+ // A mouse command's words, its button (a last word, or --button) and its options. Any other --option is
208
+ // refused before a mouse event is sent.
209
+ function mouseArgs(line, usage, spec = {}) {
210
+ const { found, rest } = takeOptions((line || '').trim(), { button: 'value', ...spec });
211
+ const words = rest.split(/\s+/).filter(Boolean);
212
+ const unknown = words.find(w => w.startsWith('--'));
213
+ if (unknown) throw new Error(`Usage: ${usage}; ${unknown.split('=')[0]} is not one of its options`);
214
+ const last = /^[a-z]+$/.test(words[words.length - 1] || '') ? words.pop() : null;
215
+ if (last && !/^(?:left|right|middle)$/.test(last)) throw new Error(`Usage: ${usage}`);
216
+ return { words, found, button: chooseButton(found, last, usage) || 'left' };
217
+ }
218
+
180
219
  function mouseButton(args, name) {
181
- const button = (args || '').trim() || 'left';
182
- if (!/^(?:left|right|middle)$/.test(button)) throw new Error(`Usage: ${name} [left|right|middle]`);
220
+ const usage = `${name} [left|right|middle]`;
221
+ const { words, button } = mouseArgs(args, usage);
222
+ if (words.length) throw new Error(`Usage: ${usage}`);
183
223
  return button;
184
224
  }
185
225
 
@@ -386,17 +426,48 @@ function dateInputHolds(kind, value, what, selector) {
386
426
 
387
427
  const commands = {
388
428
  async click(args) {
389
- const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
390
- const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, 1);
429
+ const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--button=<button>] [--click-count=<n>] [--modifiers=<key>[,<key>]]', true);
430
+ const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options, options.clickCount || 1);
391
431
  out.log(`Clicked: ${shown}${which}`);
392
432
  },
393
433
 
394
434
  async dblclick(args) {
395
- const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
435
+ const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--button=<button>] [--modifiers=<key>[,<key>]]');
396
436
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options, 2);
397
437
  out.log(`Double-clicked: ${shown}${which}`);
398
438
  },
399
439
 
440
+ // drag <from> <to>, as playwright-cli's drag: Playwright's locator.dragTo, in its steps (hover the
441
+ // source, press, hover the target, release), so the cursor can glide to each in turn. Chromium turns
442
+ // the moves into an HTML5 drag when the source is draggable.
443
+ async drag(args) {
444
+ 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"';
445
+ const from = splitSelector(args);
446
+ const to = from?.rest !== undefined ? splitSelector(from.rest) : null;
447
+ if (!to || to.rest !== undefined) throw new Error(usage);
448
+ const TIMEOUT = 5000;
449
+ const deadline = Date.now() + TIMEOUT;
450
+ const left = () => Math.max(1000, deadline - Date.now());
451
+ const source = state.page.locator(from.selector).first();
452
+ const target = state.page.locator(to.selector).first();
453
+ await onElement(from.selector, () => aimAt(state.page, source, left()));
454
+ await onElement(from.selector, () => source.hover({ timeout: left() }));
455
+ await state.page.mouse.down();
456
+ // Pressed from here on: a failure would leave the button down, which the page would take as a drag still going.
457
+ try {
458
+ await showClick(state.page);
459
+ await onElement(to.selector, () => aimAt(state.page, target, left()));
460
+ await onElement(to.selector, () => target.hover({ timeout: left() }));
461
+ } catch (error) {
462
+ await state.page.mouse.up().catch(() => {});
463
+ throw error;
464
+ }
465
+ await state.page.mouse.up();
466
+ const box = await target.boundingBox({ timeout: 1000 }).catch(() => null);
467
+ tabState(state.page).mouseAt = box ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } : null;
468
+ out.log(`Dragged ${from.word} to ${to.word}`);
469
+ },
470
+
400
471
  async hover(args) {
401
472
  const selector = soleSelector(args, 'hover <selector>');
402
473
  const which = await pointerAction(selector, (on, sel, o) => (sel ? on.hover(sel, o) : on.hover(o)));
@@ -472,23 +543,41 @@ const commands = {
472
543
 
473
544
  // A click at a point, as Playwright's mouse.click: for what has no selector, such as a marker drawn on a canvas.
474
545
  async mouseclick(line) {
475
- const usage = 'mouseclick <x> <y> [left|right|middle] [--delay=<ms>]';
476
- const held = /(?:^|\s)--delay(?:=|\s+)(\S*)(?=\s|$)/.exec(line || '');
477
- if (held && !/^\d+$/.test(held[1])) throw new Error(`Usage: ${usage}, the ms between press and release, e.g. mouseclick 400 300 --delay=500`);
478
- const words = (held ? line.replace(held[0], ' ') : line || '').trim().split(/\s+/).filter(Boolean);
479
- const button = /^[a-z]+$/.test(words[words.length - 1] || '') ? mouseButton(words.pop(), 'mouseclick <x> <y>') : 'left';
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;
480
552
  const [x, y, extra] = numbers(words.join(' '), usage);
481
553
  if (y === undefined || extra !== undefined) throw new Error(`Usage: ${usage}, in CSS pixels from the top left of the viewport`);
482
554
  stepTarget(state.page, null, { x, y });
483
555
  await glideTo(state.page, x, y);
484
556
  await state.page.mouse.move(x, y);
485
557
  tabState(state.page).mouseAt = { x, y };
486
- // The ring shows at the press, as mousedown's does, while a --delay holds the button.
487
- await state.page.mouse.down({ button });
488
- await showClick(state.page);
489
- if (held) await new Promise(resolve => setTimeout(resolve, Number(held[1])));
490
- await state.page.mouse.up({ button });
491
- out.log(`Clicked the ${button} button at ${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}`);
492
581
  },
493
582
 
494
583
  async mousedown(args) {
@@ -633,7 +722,7 @@ const commands = {
633
722
  if (!parsed || parsed.rest === undefined) throw new Error(usage);
634
723
  // A path, not a selector; // starts an XPath.
635
724
  if (/^(?:\.{1,2}\/|\/(?!\/)|~)/.test(parsed.word) || (!REF.test(parsed.word) && fs.statSync(path.resolve(parsed.word), { throwIfNoEntry: false })?.isFile())) throw new Error(`${parsed.word} is a file, not the input: ${usage.slice(7)}`);
636
- const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
725
+ const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? unescapeDouble(m[1]) : m[2] ?? m[3]));
637
726
  const payloads = files.map(file => {
638
727
  let buffer;
639
728
  try { buffer = fs.readFileSync(file); } catch (error) { throw new Error(`Cannot read ${file}: ${error.code === 'ENOENT' ? 'no such file' : error.code === 'EISDIR' ? 'it is a folder' : error.message}`); }
package/lib/emulation.js CHANGED
@@ -20,7 +20,8 @@ async function viewportText() {
20
20
  const set = tabState(state.page).viewport;
21
21
  if (set) return `${set.width}x${set.height} (set with viewport)`;
22
22
  const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
23
- return size ? `${size} (the window's size)` : 'unknown';
23
+ // The window less its frame (headless Chromium counts one too: a 1280x800 window has a 1280x657 page).
24
+ return size ? `${size} (the window's page area)` : 'unknown';
24
25
  }
25
26
 
26
27
  const DEFAULT_DEVICE = 'Pixel 7';
@@ -175,9 +176,10 @@ const commands = {
175
176
  out.log(`Viewport off: ${await viewportText()}`);
176
177
  return;
177
178
  }
178
- const match = /^(\d+)x(\d+)$/.exec(arg);
179
+ const match = /^(\d+)x(\d+)$/i.exec(arg);
179
180
  const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
180
- if (!w || !h) throw new Error('Usage: viewport [<width>x<height> | off]');
181
+ if (!match) throw new Error(`A size is <width>x<height>, e.g. viewport 1280x900 (got ${arg}). Usage: viewport [<width>x<height> | off]`);
182
+ if (!w || !h) throw new Error(`A width and a height are 1 or more (got ${arg})`);
181
183
  // Both set the screen size; whichever came last would win without saying so.
182
184
  if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
183
185
  await setViewport(state.page, { width: w, height: h });
package/lib/help.js CHANGED
@@ -22,36 +22,50 @@ function wrap(text, width = WIDTH) {
22
22
  }).join('\n\n');
23
23
  }
24
24
 
25
+ // The common tasks, as the README's table lists them too (a unit test keeps the two the same).
26
+ const TASKS = [
27
+ ['see where I am', 'tab, info'],
28
+ ['see what is on the page', 'snapshot, screenshot'],
29
+ ['do something on it', 'click, fill, type, press, drag'],
30
+ ['click what has no selector (a canvas)', 'mouseclick <x> <y>, mousedblclick <x> <y>'],
31
+ ['run some JavaScript in the page', 'eval document.title; eval --file=check.js for a long one'],
32
+ ['wait for a page or element', 'wait load, wait <selector> [--gone], wait text <text>'],
33
+ ['choose a file in a file input', 'upload <selector> <file>'],
34
+ ['see it as a phone, or in dark mode', 'emulate mobile, emulate dark'],
35
+ ['see what the page requested', 'requests, then body <#> for what one got back'],
36
+ ['see console messages and errors', 'console'],
37
+ ['show an agent what I do', 'watch on, click around, then watch'],
38
+ ['see each step as I click', 'watch on --live'],
39
+ ['watch a tab someone will open', 'watch on --next-tab [url-part]'],
40
+ ['replay what I clicked through', 'watch save flow.txt, then pw-repl send --file flow.txt'],
41
+ ['record requests and console together', 'capture on, then capture off'],
42
+ ['find what makes the page slow', 'profile on, then profile off; profile reload for a load'],
43
+ ['fake a backend response', 'route <glob> <status> <json>, route <glob> abort'],
44
+ ['change or slow an API response', 'route <glob> patch <json>, route <glob> delay <secs>'],
45
+ ['cut or slow the whole network', 'network off, network slow, then network on'],
46
+ ['record a video of the page', 'record on, then record off (help video for a take)'],
47
+ ['show a pointer where the agent acts', 'cursor on, then cursor off'],
48
+ ['say what is happening on screen', 'toast <text>, then toast off'],
49
+ ['point something out on the page', 'highlight <ref>, then highlight off'],
50
+ ['clean up', 'modes off'],
51
+ ];
52
+
53
+ // The commands that turn on a mode, as the README lists them too.
54
+ const MODES = 'watch, capture, profile, record, route, network off or slow, emulate, viewport, highlight, cursor and toast';
55
+
25
56
  const OVERVIEW = wrap(`pw-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
26
57
  The browser may be a shared session, with other users' tabs open in it.
27
58
 
28
59
  Common tasks:
29
- where am I? tab, info
30
- what is on the page? snapshot, screenshot
31
- do something on it click, fill, press
32
- wait for a page or element wait load, wait <selector>, wait <selector> --gone
33
- choose a file in a file input upload <selector> <file>
34
- see it as a phone, or dark emulate mobile, emulate dark
35
- what did the page request? requests, then body <#> for what one got back
36
- console messages and errors console
37
- show an agent what I do watch on, click around, then watch
38
- see each step as I click watch on --live
39
- watch a tab someone will open watch on --next-tab [url-part]
40
- requests and console together capture on, then capture off
41
- a video of what I do help video
42
- break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
43
- change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
44
- slow the whole network network slow
45
- clean up modes off
46
-
47
- Modes (watch, capture, record, route, network off or slow, emulate, viewport) stay on until turned off;
48
- the prompt shows the selected tab's: (watch routes:1) pw>. tab, watch, capture, record, route, network,
49
- emulate and modes on their own show their state and what you can run next.
60
+ ${TASKS.map(([task, commands]) => ` ${task.padEnd(38)} ${commands}`).join('\n')}
61
+
62
+ Modes (${MODES}) stay on until turned off; the prompt shows the selected tab's: (watch routes:1) pw>.
63
+ Each of them on its own, and modes, shows its state and what you can run next.
50
64
 
51
65
  Topics (help <topic>):
52
66
  tabs open, select, close, navigate network requests, bodies, console, fakes, network off
53
67
  interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
54
- inspect snapshot, watch, text, screenshots session modes, markers, server, quitting
68
+ inspect snapshot, watch, wait, screenshots session modes, server, clients, quitting
55
69
  video record a take, with a pointer
56
70
 
57
71
  help <command> for usage and caveats (e.g. help route), help <command> --all for all of it; help --all
@@ -71,7 +85,7 @@ tab on its own lists them all.`,
71
85
  Commands use the first match (click, dblclick and hover the first they can use: help click); see help
72
86
  fill for selectors with spaces. mousemove and the other mouse commands act at a point instead. An action
73
87
  scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
74
- commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel'],
88
+ commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel'],
75
89
  },
76
90
  inspect: {
77
91
  intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
@@ -124,12 +138,7 @@ still show sensitive data (cdp Network.getCookies shows cookie values).`,
124
138
  commands: ['eval', 'cdp', 'cookies', 'storage'],
125
139
  },
126
140
  session: {
127
- intro: `Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.
128
-
129
- Unknown outcome: a command that timed out after it began acting on the page may or may not have done
130
- it. It says so (pw-repl send exits 2), and the REPL carries on.
131
-
132
- Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
141
+ intro: `Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
133
142
 
134
143
  Clients: pw-repl send -c <name> (or $PW_CLIENT) sends as a client of its own. It has a selected tab of
135
144
  its own, so clients do not move each other's; its commands show as [server:<name>] lines. modes and tab
@@ -143,7 +152,12 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
143
152
 
144
153
  Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
145
154
 
146
- Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.`,
155
+ Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.
156
+
157
+ Unknown outcome: a command that timed out after it began acting on the page may or may not have done
158
+ it. It says so (pw-repl send exits 2), and the REPL carries on.
159
+
160
+ Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.`,
147
161
  commands: ['modes', 'dialog', 'help', 'quit'],
148
162
  },
149
163
  };
@@ -197,6 +211,8 @@ afresh.`,
197
211
  ['[button]', 'left, right or middle, after a ref or a quoted selector: click e5 right. Default: left.'],
198
212
  ],
199
213
  options: [
214
+ ['--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"\').'],
215
+ ['--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.'],
200
216
  ['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
201
217
  ],
202
218
  detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
@@ -208,8 +224,23 @@ it to be ready.
208
224
  Timed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts pointer
209
225
  events) and that the outcome is unknown; Playwright's full call log goes to the REPL's pane or log.`,
210
226
  },
211
- dblclick: { usage: 'dblclick <selector> [button]', summary: 'double-click, choosing the match as click does' },
227
+ dblclick: {
228
+ usage: 'dblclick <selector> [button]',
229
+ summary: 'double-click, as click does, with its options',
230
+ detail: `click --click-count=2, as Playwright's dblclick is. For a triple-click: click --click-count=3.`,
231
+ },
212
232
  hover: { usage: 'hover <selector>', summary: 'hover, choosing the match as click does' },
233
+ drag: {
234
+ usage: 'drag <from> <to>',
235
+ summary: 'drag one element onto another',
236
+ args: [
237
+ ['<from> <to>', 'selectors or snapshot refs, each quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#done ul".'],
238
+ ],
239
+ detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
240
+ first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
241
+ draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
242
+ through points of its own: mousemove, mousedown, mousemove, mouseup.`,
243
+ },
213
244
  mousemove: {
214
245
  usage: 'mousemove <x> <y>',
215
246
  summary: 'move the mouse to a point in the viewport',
@@ -220,7 +251,7 @@ events) and that the outcome is unknown; Playwright's full call log goes to the
220
251
  eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
221
252
  .getBoundingClientRect())
222
253
 
223
- A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
254
+ A click at a point: mouseclick <x> <y>; a double-click: mousedblclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
224
255
  },
225
256
  mouseclick: {
226
257
  usage: 'mouseclick <x> <y> [button]',
@@ -230,14 +261,30 @@ A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to ano
230
261
  ['[button]', 'left, right or middle. Default: left.'],
231
262
  ],
232
263
  options: [
264
+ ['--button=<button>', 'the button, as click\'s: mouseclick 400 300 --button=right.'],
265
+ ['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
233
266
  ['--delay=<ms>', 'holds the button down this long between press and release, as Playwright\'s mouse.click delay. Default: 0.'],
234
267
  ],
235
268
  detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
236
269
  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
237
270
  it. The cursor (help cursor) moves there first and shows a ring at the press.`,
238
271
  },
239
- mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)' },
240
- mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is' },
272
+ mousedblclick: {
273
+ usage: 'mousedblclick <x> <y> [button]',
274
+ summary: 'double-click at a point in the viewport',
275
+ args: [
276
+ ['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
277
+ ['[button]', 'left, right or middle. Default: left.'],
278
+ ],
279
+ options: [
280
+ ['--button=<button>', 'the button, as click\'s.'],
281
+ ],
282
+ detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
283
+ dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
284
+ <x> <y> --click-count=3.`,
285
+ },
286
+ 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.' },
287
+ 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.' },
241
288
  mousewheel: {
242
289
  usage: 'mousewheel <dx> <dy>',
243
290
  summary: 'turn the wheel where the mouse is',
@@ -259,14 +306,19 @@ scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (h
259
306
  ['--submit', 'at the end: presses Enter in it afterwards.'],
260
307
  ],
261
308
  detail: `Examples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada. It sets the whole value at
262
- once, even while recording: type shows it being typed.`,
309
+ once, even while recording: type shows it being typed.
310
+
311
+ At the prompt and in a send --file line, \\n, \\t and \\r in double quotes are a newline, a tab and a
312
+ carriage return (fill #notes "line 1\\nline 2"), and \\\\ is a backslash. Over send, the shell's quoting comes
313
+ first, and its "a\\nb" is a literal \\n, typed as a backslash and an n: give it a real newline, as
314
+ $'line 1\\nline 2'.`,
263
315
  },
264
316
  type: {
265
317
  usage: 'type [--delay=<ms>] [<selector>] <text> [--submit]',
266
318
  summary: 'type key by key after what the field holds',
267
319
  args: [
268
320
  ['[<selector>]', 'as for fill. Without it (one word, or one quoted), into what has focus: type "garden hose".'],
269
- ['<text>', 'the rest of the line, as fill\'s value.'],
321
+ ['<text>', 'the rest of the line, as fill\'s value: a newline in it presses Enter, which in a textarea starts a new line (help fill says how to give one).'],
270
322
  ],
271
323
  options: [
272
324
  ['--delay=<ms>', 'right after type: the gap between keys. Default: none, or 60 while the tab records.'],
@@ -324,6 +376,9 @@ all.`,
324
376
  to the tab whose snapshot gave it, and can change when the page changes (e102 may become f4e98): take a
325
377
  new snapshot after it does.
326
378
 
379
+ Quotes: some names and text are quoted, YAML-style, as one with a quote mark in it is, and their quotes
380
+ are escaped: paragraph: "Say \\"hi\\"" is the text Say "hi" on the page, with no backslashes.
381
+
327
382
  --grep: a hit with no named element around it prints without a path. A hit on text (a label) keeps the
328
383
  ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
329
384
  to the label.
@@ -381,7 +436,14 @@ one no selector finds alone) stays a comment saying why, and the save says how m
381
436
  ends with the last step, not with what it brought: to end on a result that comes later (search results
382
437
  loading), add a wait for it at the end (wait text ...).
383
438
 
384
- Shared: every client sees the same steps, and watch new's place is shared.`,
439
+ Watching someone: read a response body over 100 KB that matters as soon as it shows in requests, since
440
+ the browser drops it once the tab navigates. watch leaves out images and other static files, failed ones
441
+ too; console error and requests --all show what failed. Without a url-part, --next-tab takes the first
442
+ tab anyone opens: check tab and info once it fires, since another tab opening later does not show in
443
+ watch.
444
+
445
+ Shared: every client sees the same steps, and watch new's place is shared: when another client watches
446
+ the tab too, read it with watch <n>, since each one's watch new would hide steps from the other.`,
385
447
  moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
386
448
  more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
387
449
  after the tab navigates, even when the page navigates as soon as the request answers.
@@ -526,9 +588,9 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
526
588
  ],
527
589
  detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
528
590
  with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
529
- point before they act: click, dblclick, hover, fill, type <selector>, press <selector>, select, check,
530
- uncheck, upload, mousemove and mouseclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
531
- records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedown, and upload
591
+ point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
592
+ uncheck, upload, mousemove, mouseclick and mousedblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
593
+ records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedblclick, mousedown, and upload
532
594
  when it clicks a button) show a ring where they land.
533
595
 
534
596
  Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
@@ -607,10 +669,16 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
607
669
  summary: 'show, set or unset the viewport size',
608
670
  args: [
609
671
  ['<W>x<H>', 'sets the selected tab\'s page to that size, until viewport off, modes off or the REPL exits. The window is not resized.'],
610
- ['off', 'back to the window\'s size.'],
611
- ['(none)', 'shows the size, and whether it was set or is the window\'s.'],
672
+ ['off', 'back to the window\'s page area.'],
673
+ ['(none)', 'shows the size, and whether it was set or is the window\'s page area.'],
612
674
  ],
613
- detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.`,
675
+ detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.
676
+
677
+ The window's page area is the page's size without a viewport set. Chromium's --window-size flag sizes the whole
678
+ window, frame included, so the page is smaller: --window-size=1280,900 gives 1280x757 in a headless one.
679
+ --launch starts its Chromium at --window-size=1280,800 (a page of 1280x657 headless), unless the flags
680
+ after -- give one; a profile it was given and has used before may keep its own last size.
681
+ For a page of an exact size, use viewport <W>x<H>.`,
614
682
  },
615
683
  emulate: {
616
684
  usage: 'emulate [<what> [off] | off]',
@@ -828,9 +896,10 @@ in front of it usually holds the request open, so the page spins instead of fail
828
896
  summary: 'evaluate JavaScript in the selected tab and print the result',
829
897
  args: [
830
898
  ['<JavaScript>', 'the rest of the line. Promises are awaited, await works at the top level, and a function (() => document.title) is called.'],
831
- ['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5.'],
899
+ ['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5. Quoted, its \\n is a newline and its \\" a quote: write \\\\n for a JS string\'s \\n.'],
832
900
  ],
833
901
  options: [
902
+ ['--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.'],
834
903
  ['--all', 'the whole result, not capped.'],
835
904
  ],
836
905
  detail: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
@@ -986,9 +1055,11 @@ Different on purpose, since the browser may be someone else's too:
986
1055
  if (Object.hasOwn(TOPICS, topicOrCommand)) {
987
1056
  const topic = TOPICS[topicOrCommand];
988
1057
  const text = [topic.intro, '', ...commandLines(topic.commands)].join('\n');
989
- // A topic can share its name with a command (network); both are shown.
990
- return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${renderCommand(topicOrCommand)}` : text;
1058
+ // A topic can share its name with a command (network): the topic, and where the command's help is.
1059
+ return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${topicOrCommand} is also a command: help ${topicOrCommand} --command` : text;
991
1060
  }
1061
+ const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
1062
+ if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
992
1063
  if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
993
1064
  // Not a topic: the mouse commands are in interact, with the others that act on the page.
994
1065
  if (topicOrCommand === 'mouse') {
@@ -1016,4 +1087,4 @@ function notFound(topic) {
1016
1087
  return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
1017
1088
  }
1018
1089
 
1019
- module.exports = { render, notFound, TOPICS, COMMANDS };
1090
+ module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
package/lib/inspect.js CHANGED
@@ -442,7 +442,12 @@ const commands = {
442
442
  const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
443
443
  const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
444
444
  if (tookSeconds) seconds = Number(tokens.pop());
445
- if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
445
+ if (!tokens.length) throw new Error(`${usage} (1-${WAIT_MAX})`);
446
+ if (seconds < 1 || seconds > WAIT_MAX) {
447
+ // Most likely text that ends in a number, not seconds at all.
448
+ const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${seconds}"` : '';
449
+ throw new Error(`wait takes 1-${WAIT_MAX} seconds (got ${seconds})${text}`);
450
+ }
446
451
  const timeout = seconds * 1000;
447
452
  // Said plainly, not as Playwright's call log.
448
453
  const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
@@ -479,15 +484,32 @@ const commands = {
479
484
 
480
485
  async eval(args, all) {
481
486
  if (!args) throw new Error('Usage: eval <js-expression>');
482
- // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
483
- const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
484
- if (onRef && isFunctionText(unquote(onRef[1]))) {
485
- const selector = toSelector(onRef[2]);
487
+ let ref = null;
488
+ // eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
489
+ // it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
490
+ const fromFile = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
491
+ if (/^--file(?:[=\s]|$)/.test(args.trim())) {
492
+ if (!fromFile) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
493
+ const file = path.resolve(unquote(fromFile[1]));
494
+ const target = fromFile[2]?.trim() || null;
495
+ try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
496
+ // eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
497
+ throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
498
+ }
499
+ if (!args) throw new Error(`eval --file: ${file} is empty`);
500
+ ref = target && unquote(target);
501
+ } else {
502
+ // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
503
+ const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
504
+ if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
505
+ }
506
+ if (ref) {
507
+ const selector = toSelector(ref);
486
508
  const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
487
509
  try {
488
510
  // Made from its text by the debugger, as eval is, so a page's CSP cannot
489
511
  // refuse it, and in the element's own frame, which may be an iframe.
490
- const fn = await evaluated((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])));
512
+ const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
491
513
  try {
492
514
  printOutput(await el.evaluate((e, f) => f(e), fn), all);
493
515
  } finally {
package/lib/launch.js CHANGED
@@ -139,7 +139,9 @@ async function launch({ headed = false, extraArgs = [] }) {
139
139
  const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
140
140
  process.on('exit', onExit);
141
141
  const onSpawn = proc => { group = proc.pid; };
142
- const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
142
+ // A laptop's window, rather than Chromium's headless 800x600 or so, unless the flags after -- size it.
143
+ const sized = extraArgs.some(a => a.startsWith('--window-size=')) ? [] : ['--window-size=1280,800'];
144
+ const ours = [...(headed ? [] : ['--headless=new']), ...sized, '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
143
145
  '--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
144
146
  // A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
145
147
  // Local State is written at its root on the first run, whichever --profile-directory is used.
package/lib/record.js CHANGED
@@ -391,7 +391,7 @@ function videoTime(rec) {
391
391
  // What a step is, for its line: the command and what it acts on, never what it types or chooses, the
392
392
  // code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
393
393
  // video goes, and the video shows a password as dots.
394
- const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel']);
394
+ const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel']);
395
395
  const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
396
396
  function stepLabel(cmd, args) {
397
397
  if (WHOLE.has(cmd)) {