pw-repl 0.3.2 → 0.4.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 +11 -4
- package/README.md +9 -2
- package/bin/pw-repl.js +31 -6
- package/lib/cli-names.js +282 -0
- package/lib/client.js +2 -2
- package/lib/commands.js +421 -86
- package/lib/help.js +60 -25
- package/lib/runner.js +70 -22
- package/lib/send.js +5 -1
- package/lib/server.js +11 -4
- package/lib/state.js +27 -6
- package/lib/syntax.js +22 -1
- package/package.json +1 -1
- package/skill/SKILL.md +55 -8
package/lib/commands.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { state, withTimeout, onShutdown, shutdown } = require('./state');
|
|
3
|
+
const { state, clientRecord, withTimeout, onShutdown, shutdown } = require('./state');
|
|
4
4
|
const out = require('./output');
|
|
5
5
|
const HELP = require('./help');
|
|
6
|
+
const { takeOptions } = require('./cli-names');
|
|
6
7
|
const { devices } = require('playwright-core');
|
|
7
|
-
const { toSelector, unquote, splitSelector } = require('./syntax');
|
|
8
|
+
const { toSelector, unquote, splitSelector, REF, keyName } = require('./syntax');
|
|
8
9
|
|
|
9
10
|
const { printOutput, OUTPUT_LIMIT } = out;
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
const COMMAND_TIMEOUT = 15000;
|
|
12
13
|
const MAX_CAPTURE_EVENTS = 10000;
|
|
13
14
|
const MAX_CAPTURE_TEXT = 400000;
|
|
@@ -56,7 +57,7 @@ async function startCapture(tokens) {
|
|
|
56
57
|
out.notice('The captured tab closed, which ended the capture; capture shows what it recorded.');
|
|
57
58
|
});
|
|
58
59
|
const capture = {
|
|
59
|
-
page, label, startedAt, events, handlers, wake: null,
|
|
60
|
+
page, label, startedAt, events, handlers, wake: null, owner: state.client,
|
|
60
61
|
get dropped() { return dropped; },
|
|
61
62
|
get shortened() { return shortened; },
|
|
62
63
|
};
|
|
@@ -115,7 +116,7 @@ function ensureDialogHandler(p) {
|
|
|
115
116
|
|
|
116
117
|
// Runs ahead of the command queue (see runner.js), so it still works while
|
|
117
118
|
// commands wait on the dialog; it returns its output rather than printing it.
|
|
118
|
-
async function dialogCommand(args) {
|
|
119
|
+
async function dialogCommand(args, selected = state.page) {
|
|
119
120
|
const [action, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
120
121
|
const pages = state.browser.contexts().flatMap(c => c.pages());
|
|
121
122
|
for (const p of openDialogs.keys()) if (p.isClosed() || !pages.includes(p)) openDialogs.delete(p);
|
|
@@ -126,7 +127,7 @@ async function dialogCommand(args) {
|
|
|
126
127
|
}
|
|
127
128
|
if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
|
|
128
129
|
// The selected tab's dialog, or the only one open.
|
|
129
|
-
const page = openDialogs.has(
|
|
130
|
+
const page = openDialogs.has(selected) ? selected : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
|
|
130
131
|
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.');
|
|
131
132
|
const dialog = openDialogs.get(page);
|
|
132
133
|
openDialogs.delete(page);
|
|
@@ -166,7 +167,8 @@ const SCREENSHOT_DELAY_MAX = 60;
|
|
|
166
167
|
|
|
167
168
|
function nextScreenshotPath(name) {
|
|
168
169
|
const filename = name ? `screenshot-${name}` : `screenshot-${Date.now()}`;
|
|
169
|
-
|
|
170
|
+
// A REPL on a socket of its own keeps its screenshots next to it, as its log is.
|
|
171
|
+
return path.join(process.env.PW_SCREENSHOT_DIR || state.ownDir || '/tmp', `${filename}.png`);
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
// Per page: a CDP session's emulation applies to the page it is attached to
|
|
@@ -196,7 +198,7 @@ async function setNetwork(p, setting) {
|
|
|
196
198
|
downloadThroughput: setting?.down ? setting.down * 125 : -1,
|
|
197
199
|
uploadThroughput: setting?.up ? setting.up * 125 : -1,
|
|
198
200
|
});
|
|
199
|
-
if (setting) networkChanged.set(p, setting); else networkChanged.delete(p);
|
|
201
|
+
if (setting) networkChanged.set(p, { ...setting, owner: state.client }); else networkChanged.delete(p);
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
function networkText(setting) {
|
|
@@ -266,7 +268,7 @@ async function setEmulation(p, next) {
|
|
|
266
268
|
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
267
269
|
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
268
270
|
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
269
|
-
if (emulatedKinds(next).length) emulations.set(p, next); else emulations.delete(p);
|
|
271
|
+
if (emulatedKinds(next).length) emulations.set(p, { ...next, owner: state.client }); else emulations.delete(p);
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
function showEmulation() {
|
|
@@ -339,14 +341,22 @@ function routeKind(how, rest, usage) {
|
|
|
339
341
|
if (/^\d{3}$/.test(how)) {
|
|
340
342
|
const status = Number(how);
|
|
341
343
|
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
344
|
+
// JSON unless its type is given, so a typo in the JSON is not served as text.
|
|
345
|
+
const typed = /^--content-type=("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(rest || '');
|
|
346
|
+
const body = typed ? typed[2] || '' : rest || '';
|
|
347
|
+
if (!typed && body) {
|
|
348
|
+
try { JSON.parse(body); } catch (error) {
|
|
349
|
+
throw new Error(`Body is not valid JSON: ${error.message}; for another kind, give its type: route <url-glob> <status> --content-type=text/plain <body>`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Quoted when it has a parameter: --content-type="text/html; charset=utf-8".
|
|
353
|
+
const contentType = typed ? unquote(typed[1]) : body ? 'application/json' : undefined;
|
|
354
|
+
const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
|
|
345
355
|
return {
|
|
346
|
-
text: `${status} ${preview}`,
|
|
356
|
+
text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
|
|
347
357
|
async handle(r, req, tag) {
|
|
348
358
|
markChanged(req, status, 'faked');
|
|
349
|
-
await r.fulfill({ status, contentType
|
|
359
|
+
await r.fulfill({ status, contentType, body });
|
|
350
360
|
out.notice(`Faked: ${tag()} -> ${status}`);
|
|
351
361
|
},
|
|
352
362
|
};
|
|
@@ -398,7 +408,8 @@ function routeKind(how, rest, usage) {
|
|
|
398
408
|
function listRoutes() {
|
|
399
409
|
const routes = routesFor(state.page);
|
|
400
410
|
const forms = [
|
|
401
|
-
['route <url-glob> <status>
|
|
411
|
+
['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
|
|
412
|
+
['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
|
|
402
413
|
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
403
414
|
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
404
415
|
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
@@ -451,6 +462,8 @@ const requestEntries = new WeakMap();
|
|
|
451
462
|
const loadedAt = new WeakMap();
|
|
452
463
|
// Tabs this REPL opened with tab new.
|
|
453
464
|
const openedTabs = new WeakSet();
|
|
465
|
+
// page -> the client that opened it (null: the prompt, or a sender without a name).
|
|
466
|
+
const tabOwners = new WeakMap();
|
|
454
467
|
|
|
455
468
|
function clipText(text) {
|
|
456
469
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
@@ -463,6 +476,17 @@ function consoleText(msg) {
|
|
|
463
476
|
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
464
477
|
}
|
|
465
478
|
|
|
479
|
+
// A level includes the ones above it, as playwright-cli's console [min-level]
|
|
480
|
+
// does, which sorts the types the same way; a page error is an error.
|
|
481
|
+
const CONSOLE_LEVELS = ['error', 'warning', 'info', 'debug'];
|
|
482
|
+
const DEBUG_TYPES = new Set(['clear', 'debug', 'endGroup', 'profile', 'profileEnd', 'startGroup', 'startGroupCollapsed', 'trace']);
|
|
483
|
+
|
|
484
|
+
function consoleLevel(type) {
|
|
485
|
+
if (type === 'error' || type === 'assert' || type === 'pageerror') return 'error';
|
|
486
|
+
if (type === 'warning') return 'warning';
|
|
487
|
+
return DEBUG_TYPES.has(type) ? 'debug' : 'info';
|
|
488
|
+
}
|
|
489
|
+
|
|
466
490
|
function keep(log, entry) {
|
|
467
491
|
log.push(entry);
|
|
468
492
|
if (log.length > RECENT_MAX) log.shift();
|
|
@@ -473,12 +497,14 @@ function ensureRecentLog(p) {
|
|
|
473
497
|
const log = [];
|
|
474
498
|
const logs = [];
|
|
475
499
|
let nextId = 1;
|
|
500
|
+
let newDocument = false;
|
|
476
501
|
recentLogs.set(p, log);
|
|
477
502
|
consoleLogs.set(p, logs);
|
|
478
503
|
p.on('request', req => {
|
|
479
504
|
let navigation = false;
|
|
480
505
|
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
481
506
|
const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
507
|
+
if (navigation) newDocument = true;
|
|
482
508
|
requestEntries.set(req, entry);
|
|
483
509
|
keep(log, entry);
|
|
484
510
|
});
|
|
@@ -498,8 +524,19 @@ function ensureRecentLog(p) {
|
|
|
498
524
|
} catch {}
|
|
499
525
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
500
526
|
});
|
|
501
|
-
p.on('requestfailed', req =>
|
|
527
|
+
p.on('requestfailed', req => {
|
|
528
|
+
finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
|
|
529
|
+
// A page that never loaded: the app's next route change is not a load either.
|
|
530
|
+
if (requestEntries.get(req)?.navigation) newDocument = false;
|
|
531
|
+
});
|
|
502
532
|
p.on('load', () => loadedAt.set(p, Date.now()));
|
|
533
|
+
// A page load, marked among the messages it comes between; it ages out with them. Only a new
|
|
534
|
+
// document counts: an app's own route change (pushState, a hash) navigates without loading.
|
|
535
|
+
p.on('framenavigated', frame => {
|
|
536
|
+
if (frame !== p.mainFrame() || !newDocument) return;
|
|
537
|
+
newDocument = false;
|
|
538
|
+
keep(logs, { t: Date.now(), type: 'load', text: frame.url() });
|
|
539
|
+
});
|
|
503
540
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
504
541
|
// Uncaught exceptions never reach the console event.
|
|
505
542
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -529,8 +566,9 @@ const WATCH_REQUESTS_SHOWN = 5;
|
|
|
529
566
|
const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
|
|
530
567
|
const TYPING_BACKDATE_MAX = 60000;
|
|
531
568
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
532
|
-
//
|
|
569
|
+
// page -> its binding's record function; the binding is added once per tab.
|
|
533
570
|
const watchBindings = new WeakMap();
|
|
571
|
+
const WATCH_BINDING = '__pwReplWatch';
|
|
534
572
|
|
|
535
573
|
// Runs in the page. Describes elements the way snapshot does: role and name.
|
|
536
574
|
const WATCH_SCRIPT = `(() => {
|
|
@@ -575,7 +613,7 @@ const WATCH_SCRIPT = `(() => {
|
|
|
575
613
|
const send = (action, el, extra, since) => {
|
|
576
614
|
// Typing still waiting for its pause is recorded first, so the steps stay in order.
|
|
577
615
|
if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
|
|
578
|
-
if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
|
|
616
|
+
if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 }));
|
|
579
617
|
};
|
|
580
618
|
document.addEventListener('click', e => {
|
|
581
619
|
const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
|
|
@@ -790,12 +828,22 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
790
828
|
scheduleSettle(p, created);
|
|
791
829
|
};
|
|
792
830
|
// The page can call the binding itself, so only the page-side actions are
|
|
793
|
-
// accepted from it, as plain strings; navigations come from here.
|
|
831
|
+
// accepted from it, as plain strings; navigations come from here. A CDP
|
|
832
|
+
// binding of its own, not Playwright's exposeBinding: every Playwright
|
|
833
|
+
// client shares that one, so another tool attached to the browser would
|
|
834
|
+
// answer each call with a "not exposed" error thrown into the page.
|
|
794
835
|
if (!watchBindings.has(p)) {
|
|
795
836
|
const binding = { record: null };
|
|
796
|
-
await p
|
|
797
|
-
|
|
837
|
+
const session = await keptSession(p);
|
|
838
|
+
session.on('Runtime.bindingCalled', ({ name, payload }) => {
|
|
839
|
+
if (name !== WATCH_BINDING || !binding.record) return;
|
|
840
|
+
let event;
|
|
841
|
+
try { event = JSON.parse(payload); } catch { return; }
|
|
842
|
+
if (event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
|
|
798
843
|
});
|
|
844
|
+
// Calls are reported only to a session with Runtime on.
|
|
845
|
+
await session.send('Runtime.enable');
|
|
846
|
+
await session.send('Runtime.addBinding', { name: WATCH_BINDING });
|
|
799
847
|
watchBindings.set(p, binding);
|
|
800
848
|
}
|
|
801
849
|
watchBindings.get(p).record = record;
|
|
@@ -827,6 +875,51 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
827
875
|
watch.on = true;
|
|
828
876
|
}
|
|
829
877
|
|
|
878
|
+
// Set by watch on --next-tab: the next tab to open (by anyone), or the next
|
|
879
|
+
// whose URL contains part, is watched from its first page and selected. Each
|
|
880
|
+
// new tab is watched from the moment it opens, since its first page may load
|
|
881
|
+
// at once; one that turns out not to be it is left as it was.
|
|
882
|
+
let nextTab = null;
|
|
883
|
+
const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
|
|
884
|
+
|
|
885
|
+
function waitingText() {
|
|
886
|
+
return `Waiting to watch the next tab that opens${nextTab.part ? ` with a URL containing ${nextTab.part}` : ''}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function watchNewTab(p) {
|
|
890
|
+
const want = nextTab;
|
|
891
|
+
startWatching(p, want.changes, want.live).then(() => {
|
|
892
|
+
watches.get(p).owner = want.client;
|
|
893
|
+
const onNavigate = frame => {
|
|
894
|
+
if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
|
|
895
|
+
const url = frame.url();
|
|
896
|
+
p.off('framenavigated', onNavigate);
|
|
897
|
+
const watch = watches.get(p);
|
|
898
|
+
if (nextTab !== want || (want.part && !url.includes(want.part))) {
|
|
899
|
+
stopWatching(p, watch);
|
|
900
|
+
watch.events.length = 0;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
nextTab = null;
|
|
904
|
+
// Its record starts at its own page, not the new-tab page before it.
|
|
905
|
+
const first = watch.events.findIndex(e => e.action === 'navigate' && e.target === clipText(url));
|
|
906
|
+
if (first > 0) watch.events.splice(0, first);
|
|
907
|
+
// A page that loaded before the watch was ready: its step is added, at its request's time.
|
|
908
|
+
if (first < 0) {
|
|
909
|
+
const request = (recentLogs.get(p) || []).filter(e => e.navigation && e.url === url).pop();
|
|
910
|
+
watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '' });
|
|
911
|
+
}
|
|
912
|
+
// Selected for the client that asked, whoever's command runs now.
|
|
913
|
+
const record = clientRecord(want.client);
|
|
914
|
+
if (record.page !== p) { record.previousPage = record.page; record.page = p; }
|
|
915
|
+
out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`);
|
|
916
|
+
};
|
|
917
|
+
p.on('framenavigated', onNavigate);
|
|
918
|
+
// Opened on its page already, as a link to a new tab does.
|
|
919
|
+
if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
|
|
920
|
+
}).catch(error => out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`));
|
|
921
|
+
}
|
|
922
|
+
|
|
830
923
|
// For each step of a watch, the requests made between it and the next step.
|
|
831
924
|
function requestsByStep(watch, p = state.page) {
|
|
832
925
|
const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
|
|
@@ -893,6 +986,8 @@ function listModes() {
|
|
|
893
986
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
894
987
|
state.tabListing = all.slice();
|
|
895
988
|
const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
|
|
989
|
+
if (nextTab) out.log(`${waitingText()} (watch off stops waiting)${nextTab.client ? `, for ${nextTab.client}` : ''}${on.length ? '\n' : ''}`);
|
|
990
|
+
if (!on.length && nextTab) return;
|
|
896
991
|
if (!on.length) {
|
|
897
992
|
out.log('No modes are on in any tab.');
|
|
898
993
|
out.log(hints([
|
|
@@ -905,28 +1000,54 @@ function listModes() {
|
|
|
905
1000
|
return;
|
|
906
1001
|
}
|
|
907
1002
|
const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
|
|
908
|
-
for (const [p, i, modes] of on)
|
|
1003
|
+
for (const [p, i, modes] of on) {
|
|
1004
|
+
out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
|
|
1005
|
+
const owners = modeOwners(p);
|
|
1006
|
+
if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
|
|
1007
|
+
}
|
|
909
1008
|
out.log(hints([['modes off', 'turn them all off']]));
|
|
910
1009
|
}
|
|
911
1010
|
|
|
1011
|
+
// Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
|
|
1012
|
+
function modeOwners(p) {
|
|
1013
|
+
const owners = [];
|
|
1014
|
+
const watch = watches.get(p);
|
|
1015
|
+
if (watch?.on) owners.push([watch.owner ?? null, 'watch']);
|
|
1016
|
+
const network = networkChanged.get(p);
|
|
1017
|
+
if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
|
|
1018
|
+
const emulated = emulations.get(p);
|
|
1019
|
+
if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
|
|
1020
|
+
for (const [glob, route] of pageRoutes.get(p) || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
1021
|
+
if (cap && cap.page === p) owners.push([cap.owner ?? null, 'capture']);
|
|
1022
|
+
return owners;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function ownersText(owners) {
|
|
1026
|
+
const byOwner = new Map();
|
|
1027
|
+
for (const [owner, what] of owners) byOwner.set(owner, [...(byOwner.get(owner) || []), what]);
|
|
1028
|
+
return [...byOwner].map(([owner, whats]) => `${owner || 'unnamed'}: ${whats.join(', ')}`).join('; ');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
912
1031
|
// Turns off everything the REPL turned on, in every tab: nothing it leaves
|
|
913
|
-
// behind keeps acting on someone's browser.
|
|
914
|
-
async function allModesOff() {
|
|
1032
|
+
// behind keeps acting on someone's browser. mine: only what the running client turned on.
|
|
1033
|
+
async function allModesOff(mine = false) {
|
|
915
1034
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
1035
|
+
const ours = owner => !mine || (owner ?? null) === state.client;
|
|
916
1036
|
let any = false;
|
|
917
1037
|
let failed = 0;
|
|
1038
|
+
if (nextTab && ours(nextTab.client)) { nextTab = null; any = true; out.log('Stopped waiting to watch a new tab'); }
|
|
918
1039
|
// A tab that fails (e.g. it crashed) must not keep the others' modes on.
|
|
919
1040
|
for (const p of all) {
|
|
920
1041
|
const done = [];
|
|
921
1042
|
let problem = null;
|
|
922
1043
|
try {
|
|
923
1044
|
const watch = watches.get(p);
|
|
924
|
-
if (watch?.on) { stopWatching(p, watch); done.push('watch off'); }
|
|
925
|
-
if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
926
|
-
const globs = [...(pageRoutes.get(p)
|
|
1045
|
+
if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
|
|
1046
|
+
if (cap && cap.page === p && ours(cap.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
1047
|
+
const globs = [...(pageRoutes.get(p) || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
927
1048
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
928
|
-
if (networkChanged.has(p)) { await setNetwork(p, null); done.push('network on'); }
|
|
929
|
-
if (emulations.has(p)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
1049
|
+
if (networkChanged.has(p) && ours(networkChanged.get(p).owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
1050
|
+
if (emulations.has(p) && ours(emulations.get(p).owner)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
930
1051
|
} catch (error) {
|
|
931
1052
|
problem = error.message;
|
|
932
1053
|
}
|
|
@@ -934,7 +1055,7 @@ async function allModesOff() {
|
|
|
934
1055
|
if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
|
|
935
1056
|
}
|
|
936
1057
|
if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
|
|
937
|
-
if (!any) out.log('No modes were on.');
|
|
1058
|
+
if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
|
|
938
1059
|
}
|
|
939
1060
|
|
|
940
1061
|
// The type a page sees for a file it is given, from its extension.
|
|
@@ -957,6 +1078,31 @@ function soleSelector(args, usage) {
|
|
|
957
1078
|
return toSelector(text);
|
|
958
1079
|
}
|
|
959
1080
|
|
|
1081
|
+
const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
|
|
1082
|
+
|
|
1083
|
+
// click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
|
|
1084
|
+
// takes them. The button is read only after a ref or a quoted selector, so
|
|
1085
|
+
// click text=Turn right is still one selector.
|
|
1086
|
+
function clickArgs(args, usage) {
|
|
1087
|
+
// Found outside quotes only, so a quoted selector keeps whatever is in it.
|
|
1088
|
+
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
|
|
1089
|
+
const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
|
|
1090
|
+
const unknown = modifiers.find(m => !MODIFIERS.includes(m));
|
|
1091
|
+
if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
|
|
1092
|
+
const parsed = splitSelector(text);
|
|
1093
|
+
const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
|
|
1094
|
+
const selector = button ? parsed.selector : soleSelector(text, usage);
|
|
1095
|
+
const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
|
|
1096
|
+
return { selector, shown: args.trim(), options };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
|
|
1100
|
+
function submitFlag(args) {
|
|
1101
|
+
if (/(?:^|\s)--submit\s/.test((args || '').trim())) throw new Error('--submit goes at the end of the line');
|
|
1102
|
+
const match = /^([\s\S]*?)\s+--submit$/.exec((args || '').trim());
|
|
1103
|
+
return match ? { args: match[1], submit: true } : { args, submit: false };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
960
1106
|
function selectorAndValue(args, usage, example) {
|
|
961
1107
|
const parsed = splitSelector(args);
|
|
962
1108
|
if (!parsed || parsed.rest === undefined) {
|
|
@@ -1046,15 +1192,17 @@ const SNAPSHOT_HINT_LINES = 60;
|
|
|
1046
1192
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
1047
1193
|
// with the named elements it sits in, so a match can be placed without the
|
|
1048
1194
|
// rest of the tree.
|
|
1195
|
+
// needle: text to find in any case, or a RegExp.
|
|
1049
1196
|
function grepSnapshot(text, needle) {
|
|
1050
|
-
const lower = needle.toLowerCase();
|
|
1197
|
+
const lower = typeof needle === 'string' ? needle.toLowerCase() : null;
|
|
1198
|
+
const matches = line => (lower === null ? needle.test(line) : line.toLowerCase().includes(lower));
|
|
1051
1199
|
const stack = [];
|
|
1052
1200
|
const hits = [];
|
|
1053
1201
|
const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
|
|
1054
1202
|
for (const line of text.split('\n')) {
|
|
1055
1203
|
const indent = line.length - line.trimStart().length;
|
|
1056
1204
|
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
1057
|
-
if (line
|
|
1205
|
+
if (matches(line)) {
|
|
1058
1206
|
const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
|
|
1059
1207
|
// A hit with no ref of its own (text) keeps the ref of what holds it, so
|
|
1060
1208
|
// snapshot <ref> can show what sits beside it: the value next to a label.
|
|
@@ -1067,6 +1215,17 @@ function grepSnapshot(text, needle) {
|
|
|
1067
1215
|
return hits;
|
|
1068
1216
|
}
|
|
1069
1217
|
|
|
1218
|
+
// For --regex, as playwright-cli's find --regex and requests --filter take one.
|
|
1219
|
+
function regexOf(pattern) {
|
|
1220
|
+
try { return new RegExp(pattern); } catch (error) { throw new Error(`Not a regular expression: ${error.message}`); }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// A pattern without its quotes, and with \" inside them as ", as snapshot
|
|
1224
|
+
// reads one: other backslashes are the regexp's own (\d).
|
|
1225
|
+
function patternText(text) {
|
|
1226
|
+
return text.replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1070
1229
|
// Local time with its offset (18:16:15.721-06:00), so it reads against the
|
|
1071
1230
|
// user's clock and is still unambiguous.
|
|
1072
1231
|
function clock(t) {
|
|
@@ -1107,7 +1266,16 @@ function discardCapture() {
|
|
|
1107
1266
|
// person using the browser; Playwright's newPage would bring it to the front.
|
|
1108
1267
|
const OPEN_TIMEOUT = 10000;
|
|
1109
1268
|
|
|
1269
|
+
// True while tab new opens a tab: watch on --next-tab waits for one someone
|
|
1270
|
+
// else opens, not one a client of the REPL opens for itself.
|
|
1271
|
+
let opening = false;
|
|
1272
|
+
|
|
1110
1273
|
async function openTab() {
|
|
1274
|
+
opening = true;
|
|
1275
|
+
try { return await openOwnTab(); } finally { opening = false; }
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
async function openOwnTab() {
|
|
1111
1279
|
const ctx = state.browser.contexts()[0];
|
|
1112
1280
|
let opened = null;
|
|
1113
1281
|
const session = await state.browser.newBrowserCDPSession().catch(() => null);
|
|
@@ -1122,6 +1290,7 @@ async function openTab() {
|
|
|
1122
1290
|
// A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
|
|
1123
1291
|
if (!opened) opened = await ctx.newPage();
|
|
1124
1292
|
openedTabs.add(opened);
|
|
1293
|
+
tabOwners.set(opened, state.client);
|
|
1125
1294
|
return opened;
|
|
1126
1295
|
}
|
|
1127
1296
|
|
|
@@ -1165,11 +1334,17 @@ async function listTabs() {
|
|
|
1165
1334
|
// URL first on its own line: it is what distinguishes otherwise
|
|
1166
1335
|
// identically titled tabs, and titles wrap less badly when indented.
|
|
1167
1336
|
if (i) out.log('');
|
|
1168
|
-
out.log(`${marker} [${i}] ${p.url()}`);
|
|
1337
|
+
out.log(`${marker} [${i}] ${p.url()}${tabOwners.get(p) ? ` (opened by ${tabOwners.get(p)})` : ''}`);
|
|
1169
1338
|
out.log(` ${JSON.stringify(title)}`);
|
|
1170
1339
|
}
|
|
1171
1340
|
}
|
|
1172
1341
|
|
|
1342
|
+
// A function written out (el => ..., function (el) {...}), as one expression.
|
|
1343
|
+
function isFunctionText(text) {
|
|
1344
|
+
if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
|
|
1345
|
+
try { new Function(`return (${text}\n);`); return true; } catch { return false; }
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1173
1348
|
const commands = {
|
|
1174
1349
|
async tab(args) {
|
|
1175
1350
|
const trimmed = (args || '').trim();
|
|
@@ -1270,15 +1445,15 @@ const commands = {
|
|
|
1270
1445
|
},
|
|
1271
1446
|
|
|
1272
1447
|
async click(args) {
|
|
1273
|
-
const selector =
|
|
1274
|
-
await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
|
|
1275
|
-
out.log(`Clicked: ${
|
|
1448
|
+
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
1449
|
+
await onElement(selector, () => state.page.click(selector, { ...options, timeout: 5000 }));
|
|
1450
|
+
out.log(`Clicked: ${shown}`);
|
|
1276
1451
|
},
|
|
1277
1452
|
|
|
1278
1453
|
async dblclick(args) {
|
|
1279
|
-
const selector =
|
|
1280
|
-
await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
|
|
1281
|
-
out.log(`Double-clicked: ${
|
|
1454
|
+
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
1455
|
+
await onElement(selector, () => state.page.dblclick(selector, { ...options, timeout: 5000 }));
|
|
1456
|
+
out.log(`Double-clicked: ${shown}`);
|
|
1282
1457
|
},
|
|
1283
1458
|
|
|
1284
1459
|
async hover(args) {
|
|
@@ -1287,14 +1462,36 @@ const commands = {
|
|
|
1287
1462
|
out.log(`Hovered: ${args.trim()}`);
|
|
1288
1463
|
},
|
|
1289
1464
|
|
|
1290
|
-
async fill(
|
|
1291
|
-
const {
|
|
1465
|
+
async fill(line) {
|
|
1466
|
+
const { args, submit } = submitFlag(line);
|
|
1467
|
+
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
|
|
1292
1468
|
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
1293
|
-
|
|
1469
|
+
if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
|
|
1470
|
+
out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1294
1471
|
},
|
|
1295
1472
|
|
|
1296
|
-
async type(
|
|
1297
|
-
const {
|
|
1473
|
+
async type(line) {
|
|
1474
|
+
const { args, submit } = submitFlag(line);
|
|
1475
|
+
// One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
|
|
1476
|
+
const sole = splitSelector(args);
|
|
1477
|
+
if (sole && sole.rest === undefined) {
|
|
1478
|
+
// Named, since it may not be the element meant; one that takes no text is refused.
|
|
1479
|
+
const focused = await state.page.evaluate(() => {
|
|
1480
|
+
const el = document.activeElement;
|
|
1481
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
1482
|
+
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])');
|
|
1483
|
+
const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
|
|
1484
|
+
return { editable, name: `${el.tagName.toLowerCase()}${id}` };
|
|
1485
|
+
});
|
|
1486
|
+
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
1487
|
+
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
1488
|
+
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
1489
|
+
await state.page.keyboard.type(sole.word);
|
|
1490
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
1491
|
+
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
1298
1495
|
// Typed after what the field already holds, as someone clicking into it
|
|
1299
1496
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
1300
1497
|
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
@@ -1310,18 +1507,19 @@ const commands = {
|
|
|
1310
1507
|
}
|
|
1311
1508
|
}, null, { timeout: 5000 }));
|
|
1312
1509
|
await state.page.keyboard.type(value);
|
|
1313
|
-
|
|
1510
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
1511
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1314
1512
|
},
|
|
1315
1513
|
|
|
1316
1514
|
async press(args) {
|
|
1317
1515
|
const parsed = splitSelector(args);
|
|
1318
1516
|
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
1319
1517
|
if (parsed.rest === undefined) {
|
|
1320
|
-
await state.page.keyboard.press(parsed.word);
|
|
1321
|
-
out.log(`Pressed: ${parsed.word}`);
|
|
1518
|
+
await state.page.keyboard.press(keyName(parsed.word));
|
|
1519
|
+
out.log(`Pressed: ${keyName(parsed.word)}`);
|
|
1322
1520
|
return;
|
|
1323
1521
|
}
|
|
1324
|
-
const key = unquote(parsed.rest);
|
|
1522
|
+
const key = keyName(unquote(parsed.rest));
|
|
1325
1523
|
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
1326
1524
|
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
1327
1525
|
},
|
|
@@ -1349,7 +1547,12 @@ const commands = {
|
|
|
1349
1547
|
// container, where the paths mean nothing.
|
|
1350
1548
|
async upload(args) {
|
|
1351
1549
|
const parsed = splitSelector(args);
|
|
1352
|
-
|
|
1550
|
+
// playwright-cli's upload <file> answers a file picker a click opened. Here
|
|
1551
|
+
// the input is named instead: catching pickers would catch the person's too.
|
|
1552
|
+
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';
|
|
1553
|
+
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
1554
|
+
// A path, not a selector; // starts an XPath.
|
|
1555
|
+
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)}`);
|
|
1353
1556
|
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1354
1557
|
const payloads = files.map(file => {
|
|
1355
1558
|
let buffer;
|
|
@@ -1459,14 +1662,21 @@ const commands = {
|
|
|
1459
1662
|
},
|
|
1460
1663
|
|
|
1461
1664
|
async screenshot(args) {
|
|
1462
|
-
const usage = 'Usage: screenshot [--full] [--delay|-d <seconds>] [name]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
1463
|
-
|
|
1665
|
+
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
1666
|
+
// The file first: a path may be quoted, with spaces in it.
|
|
1667
|
+
let file = null;
|
|
1668
|
+
const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
|
|
1669
|
+
const tokens = rest.trim().split(/\s+/).filter(Boolean);
|
|
1464
1670
|
let full = false;
|
|
1465
1671
|
let delay = 0;
|
|
1672
|
+
let ref = null;
|
|
1466
1673
|
const names = [];
|
|
1467
1674
|
for (let i = 0; i < tokens.length; i++) {
|
|
1468
1675
|
const token = tokens[i];
|
|
1469
|
-
|
|
1676
|
+
// --full-page and --filename are playwright-cli's.
|
|
1677
|
+
if (token === '--full' || token === '--full-page') { full = true; continue; }
|
|
1678
|
+
if (token === '--filename' || token.startsWith('--filename=')) throw new Error(usage);
|
|
1679
|
+
if (!ref && !names.length && REF.test(token)) { ref = token; continue; }
|
|
1470
1680
|
if (token === '--delay' || token === '-d') {
|
|
1471
1681
|
const value = tokens[++i];
|
|
1472
1682
|
// Capped low on purpose: a timed shot is human-in-the-loop, and a typo
|
|
@@ -1477,7 +1687,7 @@ const commands = {
|
|
|
1477
1687
|
}
|
|
1478
1688
|
names.push(token);
|
|
1479
1689
|
}
|
|
1480
|
-
if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0]))) throw new Error(usage);
|
|
1690
|
+
if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
|
|
1481
1691
|
const name = names[0] || null;
|
|
1482
1692
|
// Counted down out loud so a watcher can time a hover or menu state, and so
|
|
1483
1693
|
// the wait is distinguishable from a hung command in a tmux capture.
|
|
@@ -1486,13 +1696,23 @@ const commands = {
|
|
|
1486
1696
|
await state.page.waitForTimeout(1000);
|
|
1487
1697
|
}
|
|
1488
1698
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1489
|
-
|
|
1699
|
+
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
1700
|
+
const filepath = file ? path.resolve(file) : nextScreenshotPath(name);
|
|
1701
|
+
const type = /\.jpe?g$/i.test(filepath) ? 'jpeg' : 'png';
|
|
1702
|
+
// Checked before the tab is brought to the front, which can move someone's view.
|
|
1703
|
+
if (fs.existsSync(filepath)) throw new Error(`Screenshot already exists: ${filepath}`);
|
|
1704
|
+
if (!fs.existsSync(path.dirname(filepath))) throw new Error(`No folder ${path.dirname(filepath)} to save ${path.basename(filepath)} in`);
|
|
1490
1705
|
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1491
1706
|
// background, so the tab is brought to the front for the shot.
|
|
1492
1707
|
await state.page.bringToFront();
|
|
1493
1708
|
let image;
|
|
1494
1709
|
try {
|
|
1495
|
-
|
|
1710
|
+
if (ref) {
|
|
1711
|
+
const selector = toSelector(ref);
|
|
1712
|
+
image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
|
|
1713
|
+
} else {
|
|
1714
|
+
image = await state.page.screenshot({ fullPage: full, type });
|
|
1715
|
+
}
|
|
1496
1716
|
} finally {
|
|
1497
1717
|
// Playwright's screenshot resets the screen size and pixel ratio it
|
|
1498
1718
|
// finds; an emulated phone's are set again.
|
|
@@ -1517,9 +1737,11 @@ const commands = {
|
|
|
1517
1737
|
const full = /^--full(?:\s|$)/.test(rest);
|
|
1518
1738
|
if (full) rest = rest.slice(6).trim();
|
|
1519
1739
|
let needle = null;
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1740
|
+
const grep = /^--(grep|regex)(?:\s|$)/.exec(rest);
|
|
1741
|
+
if (grep) {
|
|
1742
|
+
needle = rest.slice(grep[0].length).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1743
|
+
if (!needle) throw new Error(`Usage: snapshot [--full] --${grep[1]} <${grep[1] === 'grep' ? 'text' : 'pattern'}>`);
|
|
1744
|
+
if (grep[1] === 'regex') needle = regexOf(needle);
|
|
1523
1745
|
rest = '';
|
|
1524
1746
|
}
|
|
1525
1747
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
@@ -1566,6 +1788,8 @@ const commands = {
|
|
|
1566
1788
|
if (value === 'off') {
|
|
1567
1789
|
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1568
1790
|
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
1791
|
+
// As when turned on: the page keeps what it read at load until it loads again.
|
|
1792
|
+
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.');
|
|
1569
1793
|
return;
|
|
1570
1794
|
}
|
|
1571
1795
|
let next;
|
|
@@ -1586,8 +1810,8 @@ const commands = {
|
|
|
1586
1810
|
await setEmulation(state.page, { ...current, [key]: next });
|
|
1587
1811
|
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1588
1812
|
out.log(`Emulating ${shown} in the selected tab`);
|
|
1589
|
-
// The page reads these when it loads; the rest applies at once.
|
|
1590
|
-
if (what === 'mobile' || what === 'locale') out.log('The page sees its user agent, touch and languages from its next load: reload to see all of it.');
|
|
1813
|
+
// The page reads these when it loads; the rest applies at once. A blank tab has nothing to reload.
|
|
1814
|
+
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.');
|
|
1591
1815
|
},
|
|
1592
1816
|
|
|
1593
1817
|
async wait(args) {
|
|
@@ -1619,8 +1843,8 @@ const commands = {
|
|
|
1619
1843
|
}
|
|
1620
1844
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1621
1845
|
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1622
|
-
await state.page.waitForSelector(selector, { state: '
|
|
1623
|
-
out.log(`
|
|
1846
|
+
await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
|
|
1847
|
+
out.log(`Visible: ${tokens.join(' ')}`);
|
|
1624
1848
|
},
|
|
1625
1849
|
|
|
1626
1850
|
async sleep(args) {
|
|
@@ -1661,12 +1885,15 @@ const commands = {
|
|
|
1661
1885
|
async route(args) {
|
|
1662
1886
|
const trimmed = (args || '').trim();
|
|
1663
1887
|
if (!trimmed) return listRoutes();
|
|
1888
|
+
// A glob may be quoted, as it is in a shell.
|
|
1664
1889
|
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
1665
|
-
if (off) return removeRoutes(off[1]);
|
|
1666
|
-
const usage = 'Usage: route <url-glob> <status>
|
|
1667
|
-
const
|
|
1890
|
+
if (off) return removeRoutes(off[1] && unquote(off[1]));
|
|
1891
|
+
const usage = 'Usage: route <url-glob> <status> [json-body] | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
|
|
1892
|
+
const parsed = splitSelector(trimmed);
|
|
1893
|
+
const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
|
|
1668
1894
|
if (!match) throw new Error(usage);
|
|
1669
|
-
const
|
|
1895
|
+
const glob = parsed.word;
|
|
1896
|
+
const [, how, rest] = match;
|
|
1670
1897
|
const route = routeKind(how, rest, usage);
|
|
1671
1898
|
const routes = routesFor(state.page);
|
|
1672
1899
|
const previous = routes.get(glob);
|
|
@@ -1683,21 +1910,29 @@ const commands = {
|
|
|
1683
1910
|
}
|
|
1684
1911
|
};
|
|
1685
1912
|
await state.page.route(glob, handler);
|
|
1686
|
-
routes.set(glob, { text: route.text, handler });
|
|
1913
|
+
routes.set(glob, { text: route.text, handler, owner: state.client });
|
|
1687
1914
|
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
1688
1915
|
},
|
|
1689
1916
|
|
|
1690
1917
|
async requests(args) {
|
|
1691
|
-
|
|
1918
|
+
// --regex <pattern> takes the rest of the line.
|
|
1919
|
+
let regex = null;
|
|
1920
|
+
const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
|
|
1921
|
+
if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
|
|
1922
|
+
regex = regexOf(patternText(pattern.trim()));
|
|
1923
|
+
return '';
|
|
1924
|
+
});
|
|
1925
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
1692
1926
|
const everything = tokens.includes('--all');
|
|
1693
1927
|
if (everything) tokens.splice(tokens.indexOf('--all'), 1);
|
|
1694
1928
|
let count = RECENT_DEFAULT;
|
|
1695
1929
|
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
1696
|
-
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: requests [--all] [n] [url-filter] (n from 1 to ${RECENT_MAX})`);
|
|
1697
|
-
const filter = tokens[0];
|
|
1930
|
+
if (count < 1 || count > RECENT_MAX || tokens.length > (regex ? 0 : 1)) throw new Error(`Usage: requests [--all] [n] [url-filter | --regex <pattern>] (n from 1 to ${RECENT_MAX})`);
|
|
1931
|
+
const filter = regex ? String(regex) : tokens[0];
|
|
1932
|
+
const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
|
|
1698
1933
|
const log = recentLogs.get(state.page) || [];
|
|
1699
1934
|
const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
|
|
1700
|
-
const matches = shown.filter(e =>
|
|
1935
|
+
const matches = shown.filter(e => wanted(e.url)).slice(-count);
|
|
1701
1936
|
if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
|
|
1702
1937
|
for (const e of matches) {
|
|
1703
1938
|
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
@@ -1712,11 +1947,14 @@ const commands = {
|
|
|
1712
1947
|
},
|
|
1713
1948
|
|
|
1714
1949
|
async body(args, all) {
|
|
1715
|
-
const
|
|
1716
|
-
if (!
|
|
1717
|
-
const
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1950
|
+
const arg = (args || '').trim();
|
|
1951
|
+
if (!arg) throw new Error('Usage: body <#> | body <url-part> (the number from requests, or the latest request whose URL contains url-part)');
|
|
1952
|
+
const log = recentLogs.get(state.page) || [];
|
|
1953
|
+
const match = /^#?(\d+)$/.exec(arg);
|
|
1954
|
+
// The latest that has finished: one still pending has no body yet.
|
|
1955
|
+
const entry = match ? log.find(e => e.id === Number(match[1])) : log.filter(e => e.url.includes(arg) && e.status !== 'pending').pop();
|
|
1956
|
+
if (!entry) throw new Error(match ? `No request #${match[1]} on the selected tab; requests lists them` : `No finished request on the selected tab has a URL containing ${arg}`);
|
|
1957
|
+
const id = entry.id;
|
|
1720
1958
|
// A fake is marked answered before the browser hands over its response.
|
|
1721
1959
|
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1722
1960
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
@@ -1744,28 +1982,49 @@ const commands = {
|
|
|
1744
1982
|
const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1745
1983
|
let count = RECENT_DEFAULT;
|
|
1746
1984
|
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
1747
|
-
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [filter] (n from 1 to ${RECENT_MAX})`);
|
|
1985
|
+
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [level | filter] (n from 1 to ${RECENT_MAX})`);
|
|
1748
1986
|
const filter = tokens[0];
|
|
1749
|
-
const
|
|
1750
|
-
|
|
1751
|
-
.
|
|
1987
|
+
const level = CONSOLE_LEVELS.indexOf(filter);
|
|
1988
|
+
const shown = level === -1
|
|
1989
|
+
? e => !filter || e.type.includes(filter) || e.text.includes(filter)
|
|
1990
|
+
: e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
|
|
1991
|
+
const logs = consoleLogs.get(state.page) || [];
|
|
1992
|
+
const matches = logs.filter(e => e.type !== 'load' && shown(e)).slice(-count);
|
|
1752
1993
|
if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
|
|
1753
|
-
|
|
1994
|
+
// Where the page loaded again, so a message from an earlier load does not read as the current one's.
|
|
1995
|
+
// From the load the first message shown came from, if it is still kept.
|
|
1996
|
+
let first = logs.indexOf(matches[0]);
|
|
1997
|
+
while (first > 0 && logs[first].type !== 'load') first -= 1;
|
|
1998
|
+
const lines = logs.filter((e, i) => i >= first && (e.type === 'load' || matches.includes(e)));
|
|
1999
|
+
printOutput(lines.map(e => (e.type === 'load' ? `${clock(e.t)} --- the page loaded ${e.text}` : `${clock(e.t)} [${e.type}] ${e.text}`)).join('\n'), all);
|
|
1754
2000
|
},
|
|
1755
2001
|
|
|
1756
2002
|
async watch(args, all) {
|
|
1757
2003
|
const arg = (args || '').trim();
|
|
1758
2004
|
const watch = watches.get(state.page);
|
|
1759
2005
|
const [sub, ...flags] = arg.split(/\s+/);
|
|
2006
|
+
const next = flags.indexOf('--next-tab');
|
|
2007
|
+
if (sub === 'on' && next !== -1) {
|
|
2008
|
+
const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
|
|
2009
|
+
const rest = flags.filter((f, i) => i !== next && !(part && i === next + 1));
|
|
2010
|
+
if (!rest.every(f => f === '--changes' || f === '--live')) throw new Error('Usage: watch on [--changes] [--live] --next-tab [url-part]');
|
|
2011
|
+
nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), client: state.client };
|
|
2012
|
+
out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
1760
2015
|
if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
|
|
2016
|
+
if (!state.page || state.page.isClosed()) throw new Error('No tab is selected; select one with tab <index|url-part>, or open one with tab new');
|
|
1761
2017
|
const changes = flags.includes('--changes');
|
|
1762
2018
|
const live = flags.includes('--live');
|
|
1763
2019
|
await startWatching(state.page, changes, live);
|
|
2020
|
+
watches.get(state.page).owner = state.client;
|
|
1764
2021
|
out.log(`Watching the selected tab: clicks, typing, form changes, submits and navigations${changes ? ', and what each changed on screen' : ''} (typed values are not recorded)`);
|
|
1765
2022
|
out.log(live ? 'Each step prints here once it settles; watch shows them again.' : 'watch shows what it has recorded; watch on --live also prints each step here as it happens.');
|
|
1766
2023
|
return;
|
|
1767
2024
|
}
|
|
1768
2025
|
if (arg === 'off') {
|
|
2026
|
+
if (nextTab) { nextTab = null; out.log('Stopped waiting to watch a new tab'); }
|
|
2027
|
+
if (!state.page || state.page.isClosed()) return;
|
|
1769
2028
|
if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
|
|
1770
2029
|
stopWatching(state.page, watch);
|
|
1771
2030
|
const steps = watch.events.length;
|
|
@@ -1810,7 +2069,45 @@ const commands = {
|
|
|
1810
2069
|
|
|
1811
2070
|
async eval(args, all) {
|
|
1812
2071
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
1813
|
-
|
|
2072
|
+
// eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
|
|
2073
|
+
const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
|
|
2074
|
+
if (onRef && isFunctionText(unquote(onRef[1]))) {
|
|
2075
|
+
const selector = toSelector(onRef[2]);
|
|
2076
|
+
const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
|
|
2077
|
+
try {
|
|
2078
|
+
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
2079
|
+
// refuse it, and in the element's own frame, which may be an iframe.
|
|
2080
|
+
const fn = await withTimeout((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2081
|
+
try {
|
|
2082
|
+
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
2083
|
+
} finally {
|
|
2084
|
+
await fn.dispose().catch(() => {});
|
|
2085
|
+
}
|
|
2086
|
+
} finally {
|
|
2087
|
+
await el.dispose().catch(() => {});
|
|
2088
|
+
}
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
// A function, as playwright-cli's eval takes, is called rather than returned.
|
|
2092
|
+
// Only a single expression can be one; statements are evaluated as they are.
|
|
2093
|
+
// await at the top level: one expression is run as an async function; statements as DevTools'
|
|
2094
|
+
// console runs them (replMode), which gives back the last one's value.
|
|
2095
|
+
const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
|
|
2096
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
2097
|
+
const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
|
|
2098
|
+
const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
|
|
2099
|
+
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
2100
|
+
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
2101
|
+
const session = await keptSession(state.page);
|
|
2102
|
+
const { result, exceptionDetails } = await withTimeout(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2103
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
2104
|
+
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
2108
|
+
const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2109
|
+
// An empty string is shown as one, not as no output at all.
|
|
2110
|
+
printOutput(value === '' ? '""' : value, all);
|
|
1814
2111
|
},
|
|
1815
2112
|
|
|
1816
2113
|
async cdp(args, all) {
|
|
@@ -1871,8 +2168,8 @@ const commands = {
|
|
|
1871
2168
|
async modes(args) {
|
|
1872
2169
|
const arg = (args || '').trim();
|
|
1873
2170
|
if (!arg) return listModes();
|
|
1874
|
-
if (arg !== 'off') throw new Error('Usage: modes [off]');
|
|
1875
|
-
await allModesOff();
|
|
2171
|
+
if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
|
|
2172
|
+
await allModesOff(arg === 'off --mine');
|
|
1876
2173
|
},
|
|
1877
2174
|
|
|
1878
2175
|
async help(args) {
|
|
@@ -1888,6 +2185,38 @@ const commands = {
|
|
|
1888
2185
|
}
|
|
1889
2186
|
};
|
|
1890
2187
|
|
|
2188
|
+
// Run for playwright-cli's names (lib/cli-names.js) where no command here does the same.
|
|
2189
|
+
const cliCommands = {
|
|
2190
|
+
// By index only: tab <number> falls back to a tab whose URL contains the number.
|
|
2191
|
+
// The index is into the sender's last listing, so a tab someone opened or
|
|
2192
|
+
// closed since cannot shift it onto another tab; with none yet, the tabs now.
|
|
2193
|
+
async 'tab-select'(args) {
|
|
2194
|
+
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
2195
|
+
if (!/^\d+$/.test(args)) throw new Error('Usage: tab-select <index>, as tab-list lists the tabs');
|
|
2196
|
+
if (!state.tabListing.length) {
|
|
2197
|
+
if (!all[Number(args)]) throw new Error(`No tab [${args}]; there ${all.length === 1 ? 'is 1' : `are ${all.length}`}, and tab-list lists them`);
|
|
2198
|
+
state.tabListing = all.slice();
|
|
2199
|
+
}
|
|
2200
|
+
const target = state.tabListing[Number(args)];
|
|
2201
|
+
if (!target) throw new Error(`No tab [${args}] in your last listing; tab-list lists them again`);
|
|
2202
|
+
if (target.isClosed() || !all.includes(target)) throw new Error(`The tab at [${args}] in your last listing has closed; tab-list lists them again`);
|
|
2203
|
+
return commands.tab(args);
|
|
2204
|
+
},
|
|
2205
|
+
|
|
2206
|
+
// playwright-cli's close ends its own session. The browser here is shared, so
|
|
2207
|
+
// it ends the sender's part in it: its modes are turned off, and every tab,
|
|
2208
|
+
// even one it opened, is left open for someone who may be using it.
|
|
2209
|
+
async close(args) {
|
|
2210
|
+
if (args) throw new Error('Usage: close');
|
|
2211
|
+
if (!state.client) {
|
|
2212
|
+
throw new Error('close turns off the modes you turned on, which needs a client name: pw-repl send -c <name> close. Without one, they cannot be told from the prompt\'s; modes lists what is on.');
|
|
2213
|
+
}
|
|
2214
|
+
await allModesOff(true);
|
|
2215
|
+
const own = state.browser.contexts().flatMap(c => c.pages()).filter(p => tabOwners.get(p) === state.client);
|
|
2216
|
+
if (own.length) out.log(`Tabs you opened are still open: ${own.map(p => p.url()).join(', ')}; tab close <url-part> closes one, or select it and tab close.`);
|
|
2217
|
+
},
|
|
2218
|
+
};
|
|
2219
|
+
|
|
1891
2220
|
onShutdown(discardCapture);
|
|
1892
2221
|
onShutdown(resetEmulations);
|
|
1893
2222
|
|
|
@@ -1895,6 +2224,12 @@ onShutdown(resetEmulations);
|
|
|
1895
2224
|
function watchPage(p) {
|
|
1896
2225
|
ensureDialogHandler(p);
|
|
1897
2226
|
ensureRecentLog(p);
|
|
2227
|
+
if (nextTab && !opening) watchNewTab(p);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// watch on --next-tab waits for a tab, and watch off stops waiting, with none selected.
|
|
2231
|
+
function needsTab(cmd, args) {
|
|
2232
|
+
return !(cmd === 'watch' && (/(?:^|\s)--next-tab(?:\s|$)/.test(args) || /^\s*off\s*$/.test(args)));
|
|
1898
2233
|
}
|
|
1899
2234
|
|
|
1900
2235
|
// Tab completion for the prompt: command names first, then the fixed words
|
|
@@ -1921,11 +2256,11 @@ function complete(line) {
|
|
|
1921
2256
|
if (command === 'capture') return match(['on', 'off']);
|
|
1922
2257
|
if (command === 'route') return match(['off']);
|
|
1923
2258
|
}
|
|
1924
|
-
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
|
|
2259
|
+
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
|
|
1925
2260
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1926
2261
|
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1927
2262
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1928
2263
|
return [[], current];
|
|
1929
2264
|
}
|
|
1930
2265
|
|
|
1931
|
-
module.exports = { commands, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
|
2266
|
+
module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|