pw-repl 0.4.2 → 0.6.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 +34 -8
- package/README.md +90 -101
- package/bin/pw-repl.js +12 -2
- package/lib/background.js +4 -3
- package/lib/capture.js +139 -0
- package/lib/cdp.js +31 -0
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +34 -2285
- package/lib/dialogs.js +58 -0
- package/lib/elements.js +360 -0
- package/lib/emulation.js +180 -0
- package/lib/help.js +298 -39
- package/lib/inspect.js +460 -0
- package/lib/modes.js +115 -0
- package/lib/network.js +59 -0
- package/lib/output.js +12 -2
- package/lib/requestlog.js +367 -0
- package/lib/routes.js +179 -0
- package/lib/runner.js +7 -3
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +10 -5
- package/lib/state.js +5 -0
- package/lib/tabs.js +237 -0
- package/lib/tabstate.js +35 -0
- package/lib/util.js +49 -0
- package/lib/watch.js +530 -0
- package/package.json +1 -1
- package/skill/SKILL.md +35 -12
package/lib/dialogs.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Dialogs: each tab's handler, and answering one ahead of the command queue.
|
|
2
|
+
const { state } = require('./state');
|
|
3
|
+
const out = require('./output');
|
|
4
|
+
const { OUTPUT_LIMIT } = out;
|
|
5
|
+
const { unquote } = require('./syntax');
|
|
6
|
+
const { hints } = require('./util');
|
|
7
|
+
const { tabState } = require('./tabstate');
|
|
8
|
+
|
|
9
|
+
// page -> its open dialog. The page, and every command that reads it, waits
|
|
10
|
+
// until the dialog is answered, in the browser or with the dialog command.
|
|
11
|
+
const openDialogs = new Map();
|
|
12
|
+
|
|
13
|
+
function ensureDialogHandler(p) {
|
|
14
|
+
if ('dialogHandled' in tabState(p)) return;
|
|
15
|
+
tabState(p).dialogHandled = true;
|
|
16
|
+
p.on('dialog', dialog => {
|
|
17
|
+
openDialogs.set(p, dialog);
|
|
18
|
+
out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`, p);
|
|
19
|
+
out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.', p);
|
|
20
|
+
});
|
|
21
|
+
p.on('close', () => openDialogs.delete(p));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Runs ahead of the command queue (see runner.js), so it still works while
|
|
25
|
+
// commands wait on the dialog; it returns its output rather than printing it.
|
|
26
|
+
async function dialogCommand(args, selected = state.page) {
|
|
27
|
+
const [action, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
28
|
+
const pages = state.browser.contexts().flatMap(c => c.pages());
|
|
29
|
+
for (const p of openDialogs.keys()) if (p.isClosed() || !pages.includes(p)) openDialogs.delete(p);
|
|
30
|
+
const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
|
|
31
|
+
if (!action) {
|
|
32
|
+
if (!openDialogs.size) return 'No dialog is open.';
|
|
33
|
+
return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
|
|
34
|
+
}
|
|
35
|
+
if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
|
|
36
|
+
// The selected tab's dialog, or the only one open.
|
|
37
|
+
const page = openDialogs.has(selected) ? selected : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
|
|
38
|
+
if (!page) throw new Error(openDialogs.size ? 'Several tabs have a dialog open; select one with tab <index|url-part> first' : 'No dialog is open.');
|
|
39
|
+
const dialog = openDialogs.get(page);
|
|
40
|
+
openDialogs.delete(page);
|
|
41
|
+
const line = describe(page, dialog);
|
|
42
|
+
try {
|
|
43
|
+
if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
|
|
44
|
+
else await dialog.dismiss();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// Answered in the browser meanwhile.
|
|
47
|
+
return `No dialog is open (${error.message.split('\n')[0]})`;
|
|
48
|
+
}
|
|
49
|
+
return `${action === 'accept' ? 'Accepted' : 'Dismissed'}: ${line}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const commands = {
|
|
53
|
+
async dialog(args) {
|
|
54
|
+
out.log(await dialogCommand(args));
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
module.exports = { commands, ensureDialogHandler, dialogCommand };
|
package/lib/elements.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Elements and the mouse: finding the match to act on, and the commands that click, type and choose.
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { state } = require('./state');
|
|
5
|
+
const out = require('./output');
|
|
6
|
+
const { takeOptions } = require('./cli-names');
|
|
7
|
+
const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
|
|
8
|
+
const { numbers } = require('./util');
|
|
9
|
+
|
|
10
|
+
// The type a page sees for a file it is given, from its extension.
|
|
11
|
+
const MIME_TYPES = {
|
|
12
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
13
|
+
pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
|
|
14
|
+
xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
|
|
15
|
+
wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
16
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function mimeType(file) {
|
|
20
|
+
return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Commands that take only a selector take the whole line, spaces and all.
|
|
24
|
+
function soleSelector(args, usage) {
|
|
25
|
+
const text = unquote((args || '').trim());
|
|
26
|
+
if (!text) throw new Error(`Usage: ${usage}`);
|
|
27
|
+
return toSelector(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
|
|
31
|
+
|
|
32
|
+
// click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
|
|
33
|
+
// takes them. The button is read only after a ref or a quoted selector, so
|
|
34
|
+
// click text=Turn right is still one selector.
|
|
35
|
+
function clickArgs(args, usage) {
|
|
36
|
+
// Found outside quotes only, so a quoted selector keeps whatever is in it.
|
|
37
|
+
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
|
|
38
|
+
const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
|
|
39
|
+
const unknown = modifiers.find(m => !MODIFIERS.includes(m));
|
|
40
|
+
if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
|
|
41
|
+
const parsed = splitSelector(text);
|
|
42
|
+
const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
|
|
43
|
+
const selector = button ? parsed.selector : soleSelector(text, usage);
|
|
44
|
+
const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
|
|
45
|
+
return { selector, shown: args.trim(), options };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
|
|
49
|
+
function submitFlag(args) {
|
|
50
|
+
if (/(?:^|\s)--submit\s/.test((args || '').trim())) throw new Error('--submit goes at the end of the line');
|
|
51
|
+
const match = /^([\s\S]*?)\s+--submit$/.exec((args || '').trim());
|
|
52
|
+
return match ? { args: match[1], submit: true } : { args, submit: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function selectorAndValue(args, usage, example) {
|
|
56
|
+
const parsed = splitSelector(args);
|
|
57
|
+
if (!parsed || parsed.rest === undefined) {
|
|
58
|
+
throw new Error(`Usage: ${usage}, e.g. ${example}; quote a selector with spaces: "text=Your name"`);
|
|
59
|
+
}
|
|
60
|
+
return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An element that never appeared is said plainly, not as Playwright's call
|
|
64
|
+
// log, and so is one that timed out: its call log (dozens of lines of retries) goes
|
|
65
|
+
// to the REPL's pane and log only. A ref that no longer matches usually means the page
|
|
66
|
+
// changed since the snapshot it came from.
|
|
67
|
+
async function onElement(selector, action) {
|
|
68
|
+
try {
|
|
69
|
+
return await action();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = String(error.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
72
|
+
if (/strict mode violation/.test(message)) throw error;
|
|
73
|
+
const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
|
|
74
|
+
const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
|
|
75
|
+
const timeout = /Timeout (\d+)ms exceeded/.exec(message);
|
|
76
|
+
if (timeout && /waiting for locator/.test(message) && !/resolved to/.test(message)) {
|
|
77
|
+
throw new Error(`No element matches ${ref || selector} (waited ${timeout[1] / 1000}s)${staleRef}`);
|
|
78
|
+
}
|
|
79
|
+
if (!timeout) { error.message += staleRef; throw error; }
|
|
80
|
+
console.error(`Playwright's call log:\n${message}`);
|
|
81
|
+
// Found, so the ref is not stale: no hint.
|
|
82
|
+
const summary = new Error(`Timed out after ${timeout[1] / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
|
|
83
|
+
// Worked out from the whole log: the summary no longer says whether the element was found.
|
|
84
|
+
summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
|
|
85
|
+
throw summary;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What last stopped the action, from Playwright's call log: the element that took the click,
|
|
90
|
+
// or the check the element kept failing.
|
|
91
|
+
function lastBlocker(log) {
|
|
92
|
+
const lines = log.split('\n').map(line => line.trim().replace(/^-\s*/, ''));
|
|
93
|
+
const line = lines.reverse().find(l => /intercepts pointer events|element is not |element is outside|not attached|detached/.test(l));
|
|
94
|
+
if (!line) return 'it never became ready to act on';
|
|
95
|
+
return line.length > 240 ? `${line.slice(0, 240)}…` : line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Where a match stands for a click: hidden elements are left out before this. Tested in
|
|
99
|
+
// its own frame, at its centre, and only when that is in view: scrolling each one to test
|
|
100
|
+
// it would move a page someone may be looking at.
|
|
101
|
+
function hitTest(el) {
|
|
102
|
+
if (window !== window.top) return { kind: 'unchecked' };
|
|
103
|
+
const r = el.getBoundingClientRect();
|
|
104
|
+
const x = r.left + r.width / 2;
|
|
105
|
+
const y = r.top + r.height / 2;
|
|
106
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return { kind: 'unchecked' };
|
|
107
|
+
let top = document.elementFromPoint(x, y);
|
|
108
|
+
while (top?.shadowRoot) {
|
|
109
|
+
const inner = top.shadowRoot.elementFromPoint(x, y);
|
|
110
|
+
if (!inner || inner === top) break;
|
|
111
|
+
top = inner;
|
|
112
|
+
}
|
|
113
|
+
for (let n = top; n; n = n.parentNode || n.host) if (n === el) return { kind: 'hit' };
|
|
114
|
+
const name = e => {
|
|
115
|
+
const classes = typeof e.className === 'string' ? e.className.trim().split(/\s+/).filter(Boolean).slice(0, 3) : [];
|
|
116
|
+
return `<${e.tagName.toLowerCase()}${e.id ? `#${e.id}` : ''}${classes.map(c => `.${c}`).join('')}>`;
|
|
117
|
+
};
|
|
118
|
+
return { kind: 'covered', by: top ? name(top) : 'nothing (off the page)' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Playwright acts on the first match and retries it until it times out, even when that one is
|
|
122
|
+
// hidden or under an overlay and a later one could be used. With several matches, this picks
|
|
123
|
+
// the first visible one that is not covered (or not checkable here, which Playwright then
|
|
124
|
+
// checks as it acts), waiting for one as Playwright waits; with none it fails before acting,
|
|
125
|
+
// saying why. The chosen element's handle is what is acted on, so the list changing
|
|
126
|
+
// meanwhile cannot shift the choice. Null: a ref or a single match, left to Playwright.
|
|
127
|
+
async function chooseMatch(selector, timeout) {
|
|
128
|
+
if (selector.startsWith('aria-ref=')) return null;
|
|
129
|
+
const locator = state.page.locator(selector);
|
|
130
|
+
if (await locator.count() <= 1) return null;
|
|
131
|
+
const deadline = Date.now() + timeout;
|
|
132
|
+
let seen = { count: 0, handles: [], results: [] };
|
|
133
|
+
for (;;) {
|
|
134
|
+
const count = await locator.count();
|
|
135
|
+
if (count <= 1 && !seen.handles.length) return null;
|
|
136
|
+
const handles = await locator.filter({ visible: true }).elementHandles();
|
|
137
|
+
const results = await Promise.all(handles.map(h => h.evaluate(hitTest).catch(() => ({ kind: 'gone' }))));
|
|
138
|
+
// In page order: a later match in view must not win over an earlier one Playwright can scroll to.
|
|
139
|
+
const index = results.findIndex(r => r.kind === 'hit' || r.kind === 'unchecked');
|
|
140
|
+
if (index !== -1) {
|
|
141
|
+
await Promise.all(seen.handles.concat(handles.filter((_, i) => i !== index)).map(h => h.dispose().catch(() => {})));
|
|
142
|
+
const all = await locator.elementHandles();
|
|
143
|
+
const position = (await Promise.all(all.map(h => h.evaluate((a, b) => a === b, handles[index])))).indexOf(true);
|
|
144
|
+
await Promise.all(all.map(h => h.dispose().catch(() => {})));
|
|
145
|
+
return { handle: handles[index], position: position + 1, count };
|
|
146
|
+
}
|
|
147
|
+
await Promise.all(seen.handles.map(h => h.dispose().catch(() => {})));
|
|
148
|
+
seen = { count, handles, results };
|
|
149
|
+
if (Date.now() >= deadline) break;
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
151
|
+
}
|
|
152
|
+
const listed = await Promise.all(seen.handles.slice(0, 5).map(async (h, i) => {
|
|
153
|
+
const text = await h.evaluate(el => `<${el.tagName.toLowerCase()}> ${(el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)}`).catch(() => '(gone)');
|
|
154
|
+
const r = seen.results[i];
|
|
155
|
+
return ` ${text}: ${r.kind === 'covered' ? `covered by ${r.by}` : 'gone from the page'}`;
|
|
156
|
+
}));
|
|
157
|
+
await Promise.all(seen.handles.map(h => h.dispose().catch(() => {})));
|
|
158
|
+
const hidden = seen.count - seen.handles.length;
|
|
159
|
+
const error = new Error([
|
|
160
|
+
`None of the ${seen.count} matches for ${selector} could be acted on after ${timeout / 1000}s, so nothing was done:`,
|
|
161
|
+
...listed,
|
|
162
|
+
...(seen.handles.length > 5 ? [` and ${seen.handles.length - 5} more like these`] : []),
|
|
163
|
+
...(hidden ? [` ${hidden} hidden`] : []),
|
|
164
|
+
'A more specific selector, or a ref from snapshot, picks one; covered ones may be under a dialog or overlay.',
|
|
165
|
+
].join('\n'));
|
|
166
|
+
error.uncertain = false;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mouseButton(args, name) {
|
|
171
|
+
const button = (args || '').trim() || 'left';
|
|
172
|
+
if (!/^(?:left|right|middle)$/.test(button)) throw new Error(`Usage: ${name} [left|right|middle]`);
|
|
173
|
+
return button;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// click, dblclick and hover: on the match chooseMatch picks, or as Playwright does.
|
|
177
|
+
// A page that renders the chosen element again (as React lists do) replaces it before it is
|
|
178
|
+
// acted on; it is chosen again then, as Playwright finds a locator's element again.
|
|
179
|
+
async function pointerAction(selector, act, options = {}) {
|
|
180
|
+
const TIMEOUT = 5000;
|
|
181
|
+
const deadline = Date.now() + TIMEOUT;
|
|
182
|
+
const left = () => Math.max(1000, deadline - Date.now());
|
|
183
|
+
for (;;) {
|
|
184
|
+
const chosen = await onElement(selector, () => chooseMatch(selector, left()));
|
|
185
|
+
if (!chosen) {
|
|
186
|
+
// Only counted so far, so the whole time is Playwright's, as it was before.
|
|
187
|
+
await onElement(selector, () => act(state.page, selector, { ...options, timeout: TIMEOUT }));
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
|
|
192
|
+
return chosen.position === 1 ? '' : ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
|
|
195
|
+
} finally {
|
|
196
|
+
await chosen.handle.dispose().catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const commands = {
|
|
202
|
+
async click(args) {
|
|
203
|
+
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
204
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.click(sel, o) : on.click(o)), options);
|
|
205
|
+
out.log(`Clicked: ${shown}${which}`);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async dblclick(args) {
|
|
209
|
+
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
210
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.dblclick(sel, o) : on.dblclick(o)), options);
|
|
211
|
+
out.log(`Double-clicked: ${shown}${which}`);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async hover(args) {
|
|
215
|
+
const selector = soleSelector(args, 'hover <selector>');
|
|
216
|
+
const which = await pointerAction(selector, (on, sel, o) => (sel ? on.hover(sel, o) : on.hover(o)));
|
|
217
|
+
out.log(`Hovered: ${args.trim()}${which}`);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
|
|
221
|
+
async mousemove(args) {
|
|
222
|
+
const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
|
|
223
|
+
if (y === undefined || extra !== undefined) throw new Error('Usage: mousemove <x> <y>, in CSS pixels from the top left of the viewport');
|
|
224
|
+
await state.page.mouse.move(x, y);
|
|
225
|
+
out.log(`Moved the mouse to ${x}, ${y}`);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async mousedown(args) {
|
|
229
|
+
const button = mouseButton(args, 'mousedown');
|
|
230
|
+
await state.page.mouse.down({ button });
|
|
231
|
+
out.log(`Pressed the ${button} button`);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async mouseup(args) {
|
|
235
|
+
const button = mouseButton(args, 'mouseup');
|
|
236
|
+
await state.page.mouse.up({ button });
|
|
237
|
+
out.log(`Released the ${button} button`);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
// Wheel events where the mouse is (mousemove puts it there): a map zooms around that point.
|
|
241
|
+
async mousewheel(args) {
|
|
242
|
+
const [dx, dy, extra] = numbers(args, 'mousewheel <dx> <dy>');
|
|
243
|
+
if (dy === undefined || extra !== undefined) throw new Error('Usage: mousewheel <dx> <dy>, e.g. mousewheel 0 300 scrolls down (negative dy up)');
|
|
244
|
+
await state.page.mouse.wheel(dx, dy);
|
|
245
|
+
out.log(`Turned the wheel by ${dx}, ${dy}`);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async fill(line) {
|
|
249
|
+
const { args, submit } = submitFlag(line);
|
|
250
|
+
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
|
|
251
|
+
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
252
|
+
if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
|
|
253
|
+
out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async type(line) {
|
|
257
|
+
const { args, submit } = submitFlag(line);
|
|
258
|
+
// One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
|
|
259
|
+
const sole = splitSelector(args);
|
|
260
|
+
if (sole && sole.rest === undefined) {
|
|
261
|
+
// Named, since it may not be the element meant; one that takes no text is refused.
|
|
262
|
+
const focused = await state.page.evaluate(() => {
|
|
263
|
+
const el = document.activeElement;
|
|
264
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
265
|
+
const editable = el.isContentEditable || el.matches('textarea, input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color],[type=hidden])');
|
|
266
|
+
const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
|
|
267
|
+
return { editable, name: `${el.tagName.toLowerCase()}${id}` };
|
|
268
|
+
});
|
|
269
|
+
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
270
|
+
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
271
|
+
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
272
|
+
await state.page.keyboard.type(sole.word);
|
|
273
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
274
|
+
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
278
|
+
// Typed after what the field already holds, as someone clicking into it
|
|
279
|
+
// at the end would; focusing it alone leaves the caret at the start.
|
|
280
|
+
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
281
|
+
el.focus();
|
|
282
|
+
if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
|
|
283
|
+
try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
|
|
284
|
+
} else if (el.isContentEditable) {
|
|
285
|
+
const range = document.createRange();
|
|
286
|
+
range.selectNodeContents(el);
|
|
287
|
+
range.collapse(false);
|
|
288
|
+
getSelection().removeAllRanges();
|
|
289
|
+
getSelection().addRange(range);
|
|
290
|
+
}
|
|
291
|
+
}, null, { timeout: 5000 }));
|
|
292
|
+
await state.page.keyboard.type(value);
|
|
293
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
294
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
async press(args) {
|
|
298
|
+
const parsed = splitSelector(args);
|
|
299
|
+
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
300
|
+
if (parsed.rest === undefined) {
|
|
301
|
+
await state.page.keyboard.press(keyName(parsed.word));
|
|
302
|
+
out.log(`Pressed: ${keyName(parsed.word)}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const key = keyName(unquote(parsed.rest));
|
|
306
|
+
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
307
|
+
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async select(args) {
|
|
311
|
+
const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
|
|
312
|
+
await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
|
|
313
|
+
out.log(`Selected "${value}" in ${shown}`);
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
async check(args) {
|
|
317
|
+
const selector = soleSelector(args, 'check <selector>');
|
|
318
|
+
await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
|
|
319
|
+
out.log(`Checked: ${args.trim()}`);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async uncheck(args) {
|
|
323
|
+
const selector = soleSelector(args, 'uncheck <selector>');
|
|
324
|
+
await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
|
|
325
|
+
out.log(`Unchecked: ${args.trim()}`);
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
// The files are read here and handed to the page as their contents, not
|
|
329
|
+
// their paths: the browser may run on another machine, or in another
|
|
330
|
+
// container, where the paths mean nothing.
|
|
331
|
+
async upload(args) {
|
|
332
|
+
const parsed = splitSelector(args);
|
|
333
|
+
// playwright-cli's upload <file> answers a file picker a click opened. Here
|
|
334
|
+
// the input is named instead: catching pickers would catch the person's too.
|
|
335
|
+
const usage = 'Usage: upload <selector> <file>..., naming the file input (or the button that opens it) first, e.g. upload e12 ./doc.pdf; quote a path with spaces';
|
|
336
|
+
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
337
|
+
// A path, not a selector; // starts an XPath.
|
|
338
|
+
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)}`);
|
|
339
|
+
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
340
|
+
const payloads = files.map(file => {
|
|
341
|
+
let buffer;
|
|
342
|
+
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}`); }
|
|
343
|
+
return { name: path.basename(file), mimeType: mimeType(file), buffer };
|
|
344
|
+
});
|
|
345
|
+
const { selector } = parsed;
|
|
346
|
+
await onElement(selector, async () => {
|
|
347
|
+
try {
|
|
348
|
+
await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (!/not an HTMLInputElement/.test(error.message)) throw error;
|
|
351
|
+
// A button that opens the file picker itself: click it, and answer the picker.
|
|
352
|
+
const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
|
|
353
|
+
await chooser.setFiles(payloads);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
module.exports = { commands, soleSelector, onElement };
|
package/lib/emulation.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Emulating a phone, a color scheme, a locale or a timezone in a tab, and its viewport.
|
|
2
|
+
const { state, onShutdown } = require('./state');
|
|
3
|
+
const out = require('./output');
|
|
4
|
+
const { devices } = require('playwright-core');
|
|
5
|
+
const { hints } = require('./util');
|
|
6
|
+
const { tabState } = require('./tabstate');
|
|
7
|
+
const { keptSession } = require('./cdp');
|
|
8
|
+
|
|
9
|
+
// A tab in a browser the REPL connected to has no viewport set, so its size is
|
|
10
|
+
// the window's; it is read from the page.
|
|
11
|
+
async function viewportText() {
|
|
12
|
+
const device = tabState(state.page).emulation?.device;
|
|
13
|
+
if (device) {
|
|
14
|
+
const { width, height } = devices[device].viewport;
|
|
15
|
+
// A page with no viewport meta tag is laid out as on a desktop, and the phone shows it shrunk.
|
|
16
|
+
const laidOut = await state.page.evaluate(() => innerWidth).catch(() => null);
|
|
17
|
+
const wider = laidOut && laidOut > width ? `; the page lays out ${laidOut} wide, shown shrunk: it has no viewport meta tag` : '';
|
|
18
|
+
return `${width}x${height} (emulate mobile: ${device}${wider})`;
|
|
19
|
+
}
|
|
20
|
+
const set = state.page.viewportSize();
|
|
21
|
+
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
22
|
+
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
23
|
+
return size ? `${size} (the window's size)` : 'unknown';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_DEVICE = 'Pixel 7';
|
|
27
|
+
const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
|
|
28
|
+
|
|
29
|
+
function emulatedKinds(e) {
|
|
30
|
+
return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEVICES_SHOWN = 20;
|
|
34
|
+
|
|
35
|
+
function deviceNamed(name) {
|
|
36
|
+
const names = Object.keys(devices);
|
|
37
|
+
const found = names.find(d => d.toLowerCase() === name.toLowerCase());
|
|
38
|
+
if (found) return found;
|
|
39
|
+
// The names that have every word typed, so a near miss finds the exact name.
|
|
40
|
+
const words = name.toLowerCase().split(/\s+/);
|
|
41
|
+
const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
|
|
42
|
+
if (!close.length) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
|
|
43
|
+
const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
|
|
44
|
+
throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deviceText(name) {
|
|
48
|
+
const d = devices[name];
|
|
49
|
+
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Sends only what changed. Chrome takes the user agent and the languages
|
|
53
|
+
// together, so a change to either sends both.
|
|
54
|
+
function deviceMetrics(session, name) {
|
|
55
|
+
const d = devices[name];
|
|
56
|
+
return session.send('Emulation.setDeviceMetricsOverride', {
|
|
57
|
+
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
58
|
+
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function setEmulation(p, next) {
|
|
63
|
+
const session = await keptSession(p);
|
|
64
|
+
const previous = tabState(p).emulation || {};
|
|
65
|
+
const send = (method, params) => session.send(method, params);
|
|
66
|
+
if (next.device !== previous.device) {
|
|
67
|
+
const d = devices[next.device];
|
|
68
|
+
if (d) {
|
|
69
|
+
await deviceMetrics(session, next.device);
|
|
70
|
+
} else {
|
|
71
|
+
await send('Emulation.clearDeviceMetricsOverride');
|
|
72
|
+
}
|
|
73
|
+
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
74
|
+
}
|
|
75
|
+
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
76
|
+
if (next.device || next.locale) {
|
|
77
|
+
const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
|
|
78
|
+
await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
|
|
79
|
+
} else {
|
|
80
|
+
// An empty user agent ends the override.
|
|
81
|
+
await send('Emulation.setUserAgentOverride', { userAgent: '' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
85
|
+
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
86
|
+
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
87
|
+
if (emulatedKinds(next).length) tabState(p).emulation = { ...next, owner: state.client }; else delete tabState(p).emulation;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function showEmulation() {
|
|
91
|
+
const e = tabState(state.page).emulation;
|
|
92
|
+
const forms = [
|
|
93
|
+
['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
|
|
94
|
+
['emulate dark | light', 'the color scheme the page sees'],
|
|
95
|
+
['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
|
|
96
|
+
['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
|
|
97
|
+
];
|
|
98
|
+
if (!e) {
|
|
99
|
+
out.log('Nothing is emulated in the selected tab.');
|
|
100
|
+
out.log(hints(forms));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
out.log('Emulated in the selected tab:');
|
|
104
|
+
const rows = [
|
|
105
|
+
e.device && ['mobile', deviceText(e.device)],
|
|
106
|
+
e.scheme && ['color scheme', e.scheme],
|
|
107
|
+
e.locale && ['locale', e.locale],
|
|
108
|
+
e.timezone && ['timezone', e.timezone],
|
|
109
|
+
].filter(Boolean);
|
|
110
|
+
for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
|
|
111
|
+
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Resets every tab's emulation, which detaching would only partly undo.
|
|
115
|
+
async function resetEmulations() {
|
|
116
|
+
if (!state.browser || state.connectionLost) return;
|
|
117
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => 'emulation' in tabState(p) && !p.isClosed());
|
|
118
|
+
await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onShutdown(resetEmulations);
|
|
122
|
+
|
|
123
|
+
const commands = {
|
|
124
|
+
async viewport(args) {
|
|
125
|
+
if (!args) { out.log(await viewportText()); return; }
|
|
126
|
+
const [w, h] = args.split('x').map(Number);
|
|
127
|
+
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
128
|
+
// Both set the screen size; whichever came last would win without saying so.
|
|
129
|
+
if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
130
|
+
await state.page.setViewportSize({ width: w, height: h });
|
|
131
|
+
out.log(`Viewport set to ${w}x${h}`);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Per tab, like network: the settings live in the tab's kept CDP session.
|
|
135
|
+
async emulate(args) {
|
|
136
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
137
|
+
if (!words.length) return showEmulation();
|
|
138
|
+
const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
|
|
139
|
+
const [what, ...rest] = words;
|
|
140
|
+
const value = rest.join(' ');
|
|
141
|
+
const current = tabState(state.page).emulation || {};
|
|
142
|
+
if (what === 'off' && !rest.length) {
|
|
143
|
+
const was = emulatedKinds(current);
|
|
144
|
+
await setEmulation(state.page, {});
|
|
145
|
+
out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
|
|
149
|
+
const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
|
|
150
|
+
if (value === 'off') {
|
|
151
|
+
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
152
|
+
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
153
|
+
// As when turned on: the page keeps what it read at load until it loads again.
|
|
154
|
+
if ((what === 'mobile' || what === 'locale') && state.page.url() !== 'about:blank') out.log('The page sees its own user agent, touch and languages again from its next load: reload to see all of it.');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let next;
|
|
158
|
+
if (what === 'mobile') {
|
|
159
|
+
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
160
|
+
} else if (what === 'dark' || what === 'light') {
|
|
161
|
+
if (value) throw new Error(usage);
|
|
162
|
+
next = what;
|
|
163
|
+
} else if (!value || rest.length > 1) {
|
|
164
|
+
throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
|
|
165
|
+
} else if (what === 'locale') {
|
|
166
|
+
try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
|
|
167
|
+
} else {
|
|
168
|
+
// Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
|
|
169
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
|
|
170
|
+
next = value;
|
|
171
|
+
}
|
|
172
|
+
await setEmulation(state.page, { ...current, [key]: next });
|
|
173
|
+
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
174
|
+
out.log(`Emulating ${shown} in the selected tab`);
|
|
175
|
+
// The page reads these when it loads; the rest applies at once. A blank tab has nothing to reload.
|
|
176
|
+
if ((what === 'mobile' || what === 'locale') && state.page.url() !== 'about:blank') out.log('The page sees its user agent, touch and languages from its next load: reload to see all of it.');
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, setEmulation };
|