pw-repl 0.12.7 → 0.13.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/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';