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/AGENTS.md +13 -7
- package/README.md +76 -74
- package/bin/pw-repl.js +65 -40
- package/lib/background.js +2 -2
- package/lib/capture.js +11 -12
- package/lib/cli-names.js +4 -4
- package/lib/commands.js +3 -0
- package/lib/elements.js +115 -26
- package/lib/emulation.js +2 -1
- package/lib/help.js +166 -73
- package/lib/inspect.js +103 -41
- package/lib/launch.js +3 -1
- package/lib/output.js +10 -3
- package/lib/profile.js +16 -14
- package/lib/record.js +18 -14
- package/lib/routes.js +8 -9
- package/lib/runner.js +39 -9
- package/lib/send.js +28 -6
- package/lib/server.js +4 -1
- package/lib/start.js +7 -2
- package/lib/state.js +26 -9
- package/lib/syntax.js +11 -5
- package/lib/toast.js +1 -6
- package/lib/util.js +25 -1
- package/lib/watch.js +4 -2
- package/lib/written.js +67 -0
- package/package.json +1 -1
- package/skill/SKILL.md +39 -44
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
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
|
182
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
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
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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]
|
|
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
|
-
|
|
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';
|