pw-repl 0.3.2 → 0.4.1
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 +39 -2
- package/bin/pw-repl.js +32 -7
- package/lib/cli-names.js +282 -0
- package/lib/client.js +2 -2
- package/lib/commands.js +514 -97
- package/lib/help.js +69 -26
- 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 +56 -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
|
|
@@ -178,6 +180,28 @@ function keptSession(p) {
|
|
|
178
180
|
return keptSessions.get(p);
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
// Chrome all but stops drawing a tab that is not in front once it has had
|
|
184
|
+
// input (about a frame a second), and Playwright waits for frames to see an
|
|
185
|
+
// element hold still before acting on it, so each click in a tab opened in the
|
|
186
|
+
// background took 1-2s. A screencast keeps the tab drawing without bringing it
|
|
187
|
+
// to the front, for as long as the REPL runs (the throttle is back within
|
|
188
|
+
// seconds of stopping it). Its frames are tiny and rare; the cost is that the
|
|
189
|
+
// tab renders at full rate, as if in front, an animation or a video included.
|
|
190
|
+
const drawingPages = new WeakSet();
|
|
191
|
+
|
|
192
|
+
async function keepDrawing(p) {
|
|
193
|
+
if (drawingPages.has(p)) return;
|
|
194
|
+
drawingPages.add(p);
|
|
195
|
+
try {
|
|
196
|
+
const session = await keptSession(p);
|
|
197
|
+
session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
|
|
198
|
+
await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
|
|
199
|
+
} catch {
|
|
200
|
+
// Only slower without it.
|
|
201
|
+
drawingPages.delete(p);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
const networkEnabled = new WeakSet();
|
|
182
206
|
// page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
|
|
183
207
|
const networkChanged = new WeakMap();
|
|
@@ -196,7 +220,7 @@ async function setNetwork(p, setting) {
|
|
|
196
220
|
downloadThroughput: setting?.down ? setting.down * 125 : -1,
|
|
197
221
|
uploadThroughput: setting?.up ? setting.up * 125 : -1,
|
|
198
222
|
});
|
|
199
|
-
if (setting) networkChanged.set(p, setting); else networkChanged.delete(p);
|
|
223
|
+
if (setting) networkChanged.set(p, { ...setting, owner: state.client }); else networkChanged.delete(p);
|
|
200
224
|
}
|
|
201
225
|
|
|
202
226
|
function networkText(setting) {
|
|
@@ -266,7 +290,7 @@ async function setEmulation(p, next) {
|
|
|
266
290
|
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
267
291
|
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
268
292
|
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);
|
|
293
|
+
if (emulatedKinds(next).length) emulations.set(p, { ...next, owner: state.client }); else emulations.delete(p);
|
|
270
294
|
}
|
|
271
295
|
|
|
272
296
|
function showEmulation() {
|
|
@@ -339,14 +363,22 @@ function routeKind(how, rest, usage) {
|
|
|
339
363
|
if (/^\d{3}$/.test(how)) {
|
|
340
364
|
const status = Number(how);
|
|
341
365
|
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
366
|
+
// JSON unless its type is given, so a typo in the JSON is not served as text.
|
|
367
|
+
const typed = /^--content-type=("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(rest || '');
|
|
368
|
+
const body = typed ? typed[2] || '' : rest || '';
|
|
369
|
+
if (!typed && body) {
|
|
370
|
+
try { JSON.parse(body); } catch (error) {
|
|
371
|
+
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>`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Quoted when it has a parameter: --content-type="text/html; charset=utf-8".
|
|
375
|
+
const contentType = typed ? unquote(typed[1]) : body ? 'application/json' : undefined;
|
|
376
|
+
const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
|
|
345
377
|
return {
|
|
346
|
-
text: `${status} ${preview}`,
|
|
378
|
+
text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
|
|
347
379
|
async handle(r, req, tag) {
|
|
348
380
|
markChanged(req, status, 'faked');
|
|
349
|
-
await r.fulfill({ status, contentType
|
|
381
|
+
await r.fulfill({ status, contentType, body });
|
|
350
382
|
out.notice(`Faked: ${tag()} -> ${status}`);
|
|
351
383
|
},
|
|
352
384
|
};
|
|
@@ -398,7 +430,8 @@ function routeKind(how, rest, usage) {
|
|
|
398
430
|
function listRoutes() {
|
|
399
431
|
const routes = routesFor(state.page);
|
|
400
432
|
const forms = [
|
|
401
|
-
['route <url-glob> <status>
|
|
433
|
+
['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
|
|
434
|
+
['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
|
|
402
435
|
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
403
436
|
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
404
437
|
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
@@ -442,6 +475,11 @@ const BODY_TIMEOUT = 15000;
|
|
|
442
475
|
// Hidden unless asked for: they are rarely what is being checked and crowd out
|
|
443
476
|
// the API calls that are.
|
|
444
477
|
const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
|
|
478
|
+
// The bodies body prints; any other is binary.
|
|
479
|
+
const TEXT_BODY = /json|^text\/|javascript|xml|html|x-www-form-urlencoded/;
|
|
480
|
+
// A watched tab keeps each body watch shows as it arrives, since the browser
|
|
481
|
+
// drops them once the tab navigates, often before anyone has read them.
|
|
482
|
+
const KEPT_BODY_MAX = 100 * 1024;
|
|
445
483
|
const recentLogs = new WeakMap();
|
|
446
484
|
const consoleLogs = new WeakMap();
|
|
447
485
|
// request -> how a route changed it (faked or patched), shown after its status.
|
|
@@ -451,6 +489,8 @@ const requestEntries = new WeakMap();
|
|
|
451
489
|
const loadedAt = new WeakMap();
|
|
452
490
|
// Tabs this REPL opened with tab new.
|
|
453
491
|
const openedTabs = new WeakSet();
|
|
492
|
+
// page -> the client that opened it (null: the prompt, or a sender without a name).
|
|
493
|
+
const tabOwners = new WeakMap();
|
|
454
494
|
|
|
455
495
|
function clipText(text) {
|
|
456
496
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
@@ -463,6 +503,17 @@ function consoleText(msg) {
|
|
|
463
503
|
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
464
504
|
}
|
|
465
505
|
|
|
506
|
+
// A level includes the ones above it, as playwright-cli's console [min-level]
|
|
507
|
+
// does, which sorts the types the same way; a page error is an error.
|
|
508
|
+
const CONSOLE_LEVELS = ['error', 'warning', 'info', 'debug'];
|
|
509
|
+
const DEBUG_TYPES = new Set(['clear', 'debug', 'endGroup', 'profile', 'profileEnd', 'startGroup', 'startGroupCollapsed', 'trace']);
|
|
510
|
+
|
|
511
|
+
function consoleLevel(type) {
|
|
512
|
+
if (type === 'error' || type === 'assert' || type === 'pageerror') return 'error';
|
|
513
|
+
if (type === 'warning') return 'warning';
|
|
514
|
+
return DEBUG_TYPES.has(type) ? 'debug' : 'info';
|
|
515
|
+
}
|
|
516
|
+
|
|
466
517
|
function keep(log, entry) {
|
|
467
518
|
log.push(entry);
|
|
468
519
|
if (log.length > RECENT_MAX) log.shift();
|
|
@@ -473,12 +524,14 @@ function ensureRecentLog(p) {
|
|
|
473
524
|
const log = [];
|
|
474
525
|
const logs = [];
|
|
475
526
|
let nextId = 1;
|
|
527
|
+
let newDocument = false;
|
|
476
528
|
recentLogs.set(p, log);
|
|
477
529
|
consoleLogs.set(p, logs);
|
|
478
530
|
p.on('request', req => {
|
|
479
531
|
let navigation = false;
|
|
480
532
|
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
481
533
|
const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
534
|
+
if (navigation) newDocument = true;
|
|
482
535
|
requestEntries.set(req, entry);
|
|
483
536
|
keep(log, entry);
|
|
484
537
|
});
|
|
@@ -497,9 +550,21 @@ function ensureRecentLog(p) {
|
|
|
497
550
|
if (res) status = String(res.status());
|
|
498
551
|
} catch {}
|
|
499
552
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
553
|
+
keepBody(p, requestEntries.get(req), res);
|
|
554
|
+
});
|
|
555
|
+
p.on('requestfailed', req => {
|
|
556
|
+
finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
|
|
557
|
+
// A page that never loaded: the app's next route change is not a load either.
|
|
558
|
+
if (requestEntries.get(req)?.navigation) newDocument = false;
|
|
500
559
|
});
|
|
501
|
-
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
502
560
|
p.on('load', () => loadedAt.set(p, Date.now()));
|
|
561
|
+
// A page load, marked among the messages it comes between; it ages out with them. Only a new
|
|
562
|
+
// document counts: an app's own route change (pushState, a hash) navigates without loading.
|
|
563
|
+
p.on('framenavigated', frame => {
|
|
564
|
+
if (frame !== p.mainFrame() || !newDocument) return;
|
|
565
|
+
newDocument = false;
|
|
566
|
+
keep(logs, { t: Date.now(), type: 'load', text: frame.url() });
|
|
567
|
+
});
|
|
503
568
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
504
569
|
// Uncaught exceptions never reach the console event.
|
|
505
570
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -527,10 +592,43 @@ const WATCH_MAX = 200;
|
|
|
527
592
|
const WATCH_REQUESTS_SHOWN = 5;
|
|
528
593
|
// Scripts too: a dev server loads dozens per navigation, crowding out the API calls.
|
|
529
594
|
const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
|
|
595
|
+
|
|
596
|
+
// Tabs whose watch on --next-tab is still starting: the page can load before it is ready.
|
|
597
|
+
const startingWatches = new WeakSet();
|
|
598
|
+
|
|
599
|
+
// A page that navigates as soon as a request answers (a login that redirects)
|
|
600
|
+
// takes the body with it before the request is even reported finished; told to
|
|
601
|
+
// in time, the browser keeps them outside the page instead. Once per tab, from
|
|
602
|
+
// its first watch, and never switched off: up to 50 MB of the browser's memory
|
|
603
|
+
// per watched tab, for as long as the REPL runs.
|
|
604
|
+
const DURABLE_TOTAL = 50 * 1024 * 1024;
|
|
605
|
+
const durablePages = new WeakSet();
|
|
606
|
+
|
|
607
|
+
async function keepBodiesDurable(p) {
|
|
608
|
+
if (durablePages.has(p)) return;
|
|
609
|
+
durablePages.add(p);
|
|
610
|
+
try {
|
|
611
|
+
const session = await keptSession(p);
|
|
612
|
+
await session.send('Network.configureDurableMessages', { maxTotalBufferSize: DURABLE_TOTAL, maxResourceBufferSize: KEPT_BODY_MAX });
|
|
613
|
+
} catch {
|
|
614
|
+
// An older Chrome: bodies are still kept once their requests finish.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function keepBody(p, entry, res) {
|
|
619
|
+
if (!entry || !res || !(watches.get(p)?.on || startingWatches.has(p)) || WATCH_HIDDEN_TYPES.has(entry.type)) return;
|
|
620
|
+
const headers = res.headers();
|
|
621
|
+
if (!TEXT_BODY.test(headers['content-type'] || '') || Number(headers['content-length']) > KEPT_BODY_MAX) return;
|
|
622
|
+
try {
|
|
623
|
+
const buffer = await res.body();
|
|
624
|
+
if (buffer.length <= KEPT_BODY_MAX) entry.kept = buffer;
|
|
625
|
+
} catch {}
|
|
626
|
+
}
|
|
530
627
|
const TYPING_BACKDATE_MAX = 60000;
|
|
531
628
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
532
|
-
//
|
|
629
|
+
// page -> its binding's record function; the binding is added once per tab.
|
|
533
630
|
const watchBindings = new WeakMap();
|
|
631
|
+
const WATCH_BINDING = '__pwReplWatch';
|
|
534
632
|
|
|
535
633
|
// Runs in the page. Describes elements the way snapshot does: role and name.
|
|
536
634
|
const WATCH_SCRIPT = `(() => {
|
|
@@ -575,7 +673,7 @@ const WATCH_SCRIPT = `(() => {
|
|
|
575
673
|
const send = (action, el, extra, since) => {
|
|
576
674
|
// Typing still waiting for its pause is recorded first, so the steps stay in order.
|
|
577
675
|
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 });
|
|
676
|
+
if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch(JSON.stringify({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 }));
|
|
579
677
|
};
|
|
580
678
|
document.addEventListener('click', e => {
|
|
581
679
|
const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
|
|
@@ -772,6 +870,7 @@ function printLive(p, watch) {
|
|
|
772
870
|
}
|
|
773
871
|
|
|
774
872
|
async function startWatching(p, changes = false, live = false) {
|
|
873
|
+
await keepBodiesDurable(p);
|
|
775
874
|
let watch = watches.get(p);
|
|
776
875
|
if (!watch) {
|
|
777
876
|
// shownSeq and shownRequestId mark how far watch new has read.
|
|
@@ -790,12 +889,22 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
790
889
|
scheduleSettle(p, created);
|
|
791
890
|
};
|
|
792
891
|
// 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.
|
|
892
|
+
// accepted from it, as plain strings; navigations come from here. A CDP
|
|
893
|
+
// binding of its own, not Playwright's exposeBinding: every Playwright
|
|
894
|
+
// client shares that one, so another tool attached to the browser would
|
|
895
|
+
// answer each call with a "not exposed" error thrown into the page.
|
|
794
896
|
if (!watchBindings.has(p)) {
|
|
795
897
|
const binding = { record: null };
|
|
796
|
-
await p
|
|
797
|
-
|
|
898
|
+
const session = await keptSession(p);
|
|
899
|
+
session.on('Runtime.bindingCalled', ({ name, payload }) => {
|
|
900
|
+
if (name !== WATCH_BINDING || !binding.record) return;
|
|
901
|
+
let event;
|
|
902
|
+
try { event = JSON.parse(payload); } catch { return; }
|
|
903
|
+
if (event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
|
|
798
904
|
});
|
|
905
|
+
// Calls are reported only to a session with Runtime on.
|
|
906
|
+
await session.send('Runtime.enable');
|
|
907
|
+
await session.send('Runtime.addBinding', { name: WATCH_BINDING });
|
|
799
908
|
watchBindings.set(p, binding);
|
|
800
909
|
}
|
|
801
910
|
watchBindings.get(p).record = record;
|
|
@@ -827,6 +936,63 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
827
936
|
watch.on = true;
|
|
828
937
|
}
|
|
829
938
|
|
|
939
|
+
// Set by watch on --next-tab: the next tab to open (by anyone), or the next
|
|
940
|
+
// whose URL contains part, is watched from its first page and selected. Each
|
|
941
|
+
// new tab is watched from the moment it opens, since its first page may load
|
|
942
|
+
// at once; one that turns out not to be it is left as it was.
|
|
943
|
+
let nextTab = null;
|
|
944
|
+
|
|
945
|
+
// The sender's own --next-tab is still waiting: the tab will be selected for it.
|
|
946
|
+
function waitingForMe() {
|
|
947
|
+
return !!nextTab && nextTab.client === state.client;
|
|
948
|
+
}
|
|
949
|
+
const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
|
|
950
|
+
|
|
951
|
+
function waitingText() {
|
|
952
|
+
return `Waiting to watch the next tab that opens${nextTab.part ? ` with a URL containing ${nextTab.part}` : ''}`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function watchNewTab(p) {
|
|
956
|
+
const want = nextTab;
|
|
957
|
+
startingWatches.add(p);
|
|
958
|
+
keepBodiesDurable(p);
|
|
959
|
+
startWatching(p, want.changes, want.live).then(() => {
|
|
960
|
+
startingWatches.delete(p);
|
|
961
|
+
watches.get(p).owner = want.client;
|
|
962
|
+
const onNavigate = frame => {
|
|
963
|
+
if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
|
|
964
|
+
const url = frame.url();
|
|
965
|
+
p.off('framenavigated', onNavigate);
|
|
966
|
+
const watch = watches.get(p);
|
|
967
|
+
if (nextTab !== want || (want.part && !url.includes(want.part))) {
|
|
968
|
+
stopWatching(p, watch);
|
|
969
|
+
watch.events.length = 0;
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
nextTab = null;
|
|
973
|
+
// Its record starts at its own page, not the new-tab page before it.
|
|
974
|
+
const first = watch.events.findIndex(e => e.action === 'navigate' && e.target === clipText(url));
|
|
975
|
+
if (first > 0) watch.events.splice(0, first);
|
|
976
|
+
// A page that loaded before the watch was ready: its step is added, at its request's time.
|
|
977
|
+
if (first < 0) {
|
|
978
|
+
const request = (recentLogs.get(p) || []).filter(e => e.navigation && e.url === url).pop();
|
|
979
|
+
watch.events.unshift({ seq: watch.nextSeq++, t: request ? request.t : Date.now(), action: 'navigate', target: clipText(url), extra: '' });
|
|
980
|
+
}
|
|
981
|
+
// Selected for the client that asked, whoever's command runs now.
|
|
982
|
+
const record = clientRecord(want.client);
|
|
983
|
+
if (record.page !== p) { record.previousPage = record.page; record.page = p; }
|
|
984
|
+
keepDrawing(p);
|
|
985
|
+
out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`);
|
|
986
|
+
};
|
|
987
|
+
p.on('framenavigated', onNavigate);
|
|
988
|
+
// Opened on its page already, as a link to a new tab does.
|
|
989
|
+
if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
|
|
990
|
+
}).catch(error => {
|
|
991
|
+
startingWatches.delete(p);
|
|
992
|
+
out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
830
996
|
// For each step of a watch, the requests made between it and the next step.
|
|
831
997
|
function requestsByStep(watch, p = state.page) {
|
|
832
998
|
const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
|
|
@@ -893,6 +1059,8 @@ function listModes() {
|
|
|
893
1059
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
894
1060
|
state.tabListing = all.slice();
|
|
895
1061
|
const on = all.map((p, i) => [p, i, activeModes(p)]).filter(([, , modes]) => modes.length);
|
|
1062
|
+
if (nextTab) out.log(`${waitingText()} (watch off stops waiting)${nextTab.client ? `, for ${nextTab.client}` : ''}${on.length ? '\n' : ''}`);
|
|
1063
|
+
if (!on.length && nextTab) return;
|
|
896
1064
|
if (!on.length) {
|
|
897
1065
|
out.log('No modes are on in any tab.');
|
|
898
1066
|
out.log(hints([
|
|
@@ -905,28 +1073,54 @@ function listModes() {
|
|
|
905
1073
|
return;
|
|
906
1074
|
}
|
|
907
1075
|
const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
|
|
908
|
-
for (const [p, i, modes] of on)
|
|
1076
|
+
for (const [p, i, modes] of on) {
|
|
1077
|
+
out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
|
|
1078
|
+
const owners = modeOwners(p);
|
|
1079
|
+
if (owners.some(([owner]) => owner)) out.log(` on by ${ownersText(owners)}`);
|
|
1080
|
+
}
|
|
909
1081
|
out.log(hints([['modes off', 'turn them all off']]));
|
|
910
1082
|
}
|
|
911
1083
|
|
|
1084
|
+
// Who turned on each mode in a tab: [client, what], client null for the prompt or an unnamed sender.
|
|
1085
|
+
function modeOwners(p) {
|
|
1086
|
+
const owners = [];
|
|
1087
|
+
const watch = watches.get(p);
|
|
1088
|
+
if (watch?.on) owners.push([watch.owner ?? null, 'watch']);
|
|
1089
|
+
const network = networkChanged.get(p);
|
|
1090
|
+
if (network) owners.push([network.owner ?? null, network.offline ? 'network:off' : 'network:slow']);
|
|
1091
|
+
const emulated = emulations.get(p);
|
|
1092
|
+
if (emulated) owners.push([emulated.owner ?? null, 'emulate']);
|
|
1093
|
+
for (const [glob, route] of pageRoutes.get(p) || []) owners.push([route.owner ?? null, `route ${glob}`]);
|
|
1094
|
+
if (cap && cap.page === p) owners.push([cap.owner ?? null, 'capture']);
|
|
1095
|
+
return owners;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function ownersText(owners) {
|
|
1099
|
+
const byOwner = new Map();
|
|
1100
|
+
for (const [owner, what] of owners) byOwner.set(owner, [...(byOwner.get(owner) || []), what]);
|
|
1101
|
+
return [...byOwner].map(([owner, whats]) => `${owner || 'unnamed'}: ${whats.join(', ')}`).join('; ');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
912
1104
|
// 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() {
|
|
1105
|
+
// behind keeps acting on someone's browser. mine: only what the running client turned on.
|
|
1106
|
+
async function allModesOff(mine = false) {
|
|
915
1107
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
1108
|
+
const ours = owner => !mine || (owner ?? null) === state.client;
|
|
916
1109
|
let any = false;
|
|
917
1110
|
let failed = 0;
|
|
1111
|
+
if (nextTab && ours(nextTab.client)) { nextTab = null; any = true; out.log('Stopped waiting to watch a new tab'); }
|
|
918
1112
|
// A tab that fails (e.g. it crashed) must not keep the others' modes on.
|
|
919
1113
|
for (const p of all) {
|
|
920
1114
|
const done = [];
|
|
921
1115
|
let problem = null;
|
|
922
1116
|
try {
|
|
923
1117
|
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)
|
|
1118
|
+
if (watch?.on && ours(watch.owner)) { stopWatching(p, watch); done.push('watch off'); }
|
|
1119
|
+
if (cap && cap.page === p && ours(cap.owner)) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
1120
|
+
const globs = [...(pageRoutes.get(p) || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
|
|
927
1121
|
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'); }
|
|
1122
|
+
if (networkChanged.has(p) && ours(networkChanged.get(p).owner)) { await setNetwork(p, null); done.push('network on'); }
|
|
1123
|
+
if (emulations.has(p) && ours(emulations.get(p).owner)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
930
1124
|
} catch (error) {
|
|
931
1125
|
problem = error.message;
|
|
932
1126
|
}
|
|
@@ -934,7 +1128,7 @@ async function allModesOff() {
|
|
|
934
1128
|
if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
|
|
935
1129
|
}
|
|
936
1130
|
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.');
|
|
1131
|
+
if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
|
|
938
1132
|
}
|
|
939
1133
|
|
|
940
1134
|
// The type a page sees for a file it is given, from its extension.
|
|
@@ -957,6 +1151,31 @@ function soleSelector(args, usage) {
|
|
|
957
1151
|
return toSelector(text);
|
|
958
1152
|
}
|
|
959
1153
|
|
|
1154
|
+
const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
|
|
1155
|
+
|
|
1156
|
+
// click <selector> [button] [--modifiers=<keys>], as playwright-cli's click
|
|
1157
|
+
// takes them. The button is read only after a ref or a quoted selector, so
|
|
1158
|
+
// click text=Turn right is still one selector.
|
|
1159
|
+
function clickArgs(args, usage) {
|
|
1160
|
+
// Found outside quotes only, so a quoted selector keeps whatever is in it.
|
|
1161
|
+
const { found, rest: text } = takeOptions((args || '').trim(), { modifiers: 'value' });
|
|
1162
|
+
const modifiers = found.flatMap(o => (o.value || '').split(',').filter(Boolean).map(keyName));
|
|
1163
|
+
const unknown = modifiers.find(m => !MODIFIERS.includes(m));
|
|
1164
|
+
if (unknown) throw new Error(`Not a modifier: ${unknown}; they are ${MODIFIERS.join(', ')}`);
|
|
1165
|
+
const parsed = splitSelector(text);
|
|
1166
|
+
const button = parsed && /^(?:left|right|middle)$/.test(parsed.rest || '') && (REF.test(parsed.word) || /^["']/.test(text)) ? parsed.rest : null;
|
|
1167
|
+
const selector = button ? parsed.selector : soleSelector(text, usage);
|
|
1168
|
+
const options = { ...(button ? { button } : {}), ...(modifiers.length ? { modifiers } : {}) };
|
|
1169
|
+
return { selector, shown: args.trim(), options };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// A trailing --submit, as playwright-cli's fill and type take: Enter afterwards.
|
|
1173
|
+
function submitFlag(args) {
|
|
1174
|
+
if (/(?:^|\s)--submit\s/.test((args || '').trim())) throw new Error('--submit goes at the end of the line');
|
|
1175
|
+
const match = /^([\s\S]*?)\s+--submit$/.exec((args || '').trim());
|
|
1176
|
+
return match ? { args: match[1], submit: true } : { args, submit: false };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
960
1179
|
function selectorAndValue(args, usage, example) {
|
|
961
1180
|
const parsed = splitSelector(args);
|
|
962
1181
|
if (!parsed || parsed.rest === undefined) {
|
|
@@ -1046,15 +1265,17 @@ const SNAPSHOT_HINT_LINES = 60;
|
|
|
1046
1265
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
1047
1266
|
// with the named elements it sits in, so a match can be placed without the
|
|
1048
1267
|
// rest of the tree.
|
|
1268
|
+
// needle: text to find in any case, or a RegExp.
|
|
1049
1269
|
function grepSnapshot(text, needle) {
|
|
1050
|
-
const lower = needle.toLowerCase();
|
|
1270
|
+
const lower = typeof needle === 'string' ? needle.toLowerCase() : null;
|
|
1271
|
+
const matches = line => (lower === null ? needle.test(line) : line.toLowerCase().includes(lower));
|
|
1051
1272
|
const stack = [];
|
|
1052
1273
|
const hits = [];
|
|
1053
1274
|
const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
|
|
1054
1275
|
for (const line of text.split('\n')) {
|
|
1055
1276
|
const indent = line.length - line.trimStart().length;
|
|
1056
1277
|
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
1057
|
-
if (line
|
|
1278
|
+
if (matches(line)) {
|
|
1058
1279
|
const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
|
|
1059
1280
|
// A hit with no ref of its own (text) keeps the ref of what holds it, so
|
|
1060
1281
|
// snapshot <ref> can show what sits beside it: the value next to a label.
|
|
@@ -1067,6 +1288,17 @@ function grepSnapshot(text, needle) {
|
|
|
1067
1288
|
return hits;
|
|
1068
1289
|
}
|
|
1069
1290
|
|
|
1291
|
+
// For --regex, as playwright-cli's find --regex and requests --filter take one.
|
|
1292
|
+
function regexOf(pattern) {
|
|
1293
|
+
try { return new RegExp(pattern); } catch (error) { throw new Error(`Not a regular expression: ${error.message}`); }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// A pattern without its quotes, and with \" inside them as ", as snapshot
|
|
1297
|
+
// reads one: other backslashes are the regexp's own (\d).
|
|
1298
|
+
function patternText(text) {
|
|
1299
|
+
return text.replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1070
1302
|
// Local time with its offset (18:16:15.721-06:00), so it reads against the
|
|
1071
1303
|
// user's clock and is still unambiguous.
|
|
1072
1304
|
function clock(t) {
|
|
@@ -1107,7 +1339,16 @@ function discardCapture() {
|
|
|
1107
1339
|
// person using the browser; Playwright's newPage would bring it to the front.
|
|
1108
1340
|
const OPEN_TIMEOUT = 10000;
|
|
1109
1341
|
|
|
1342
|
+
// True while tab new opens a tab: watch on --next-tab waits for one someone
|
|
1343
|
+
// else opens, not one a client of the REPL opens for itself.
|
|
1344
|
+
let opening = false;
|
|
1345
|
+
|
|
1110
1346
|
async function openTab() {
|
|
1347
|
+
opening = true;
|
|
1348
|
+
try { return await openOwnTab(); } finally { opening = false; }
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function openOwnTab() {
|
|
1111
1352
|
const ctx = state.browser.contexts()[0];
|
|
1112
1353
|
let opened = null;
|
|
1113
1354
|
const session = await state.browser.newBrowserCDPSession().catch(() => null);
|
|
@@ -1122,6 +1363,8 @@ async function openTab() {
|
|
|
1122
1363
|
// A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
|
|
1123
1364
|
if (!opened) opened = await ctx.newPage();
|
|
1124
1365
|
openedTabs.add(opened);
|
|
1366
|
+
tabOwners.set(opened, state.client);
|
|
1367
|
+
keepDrawing(opened);
|
|
1125
1368
|
return opened;
|
|
1126
1369
|
}
|
|
1127
1370
|
|
|
@@ -1165,11 +1408,17 @@ async function listTabs() {
|
|
|
1165
1408
|
// URL first on its own line: it is what distinguishes otherwise
|
|
1166
1409
|
// identically titled tabs, and titles wrap less badly when indented.
|
|
1167
1410
|
if (i) out.log('');
|
|
1168
|
-
out.log(`${marker} [${i}] ${p.url()}`);
|
|
1411
|
+
out.log(`${marker} [${i}] ${p.url()}${tabOwners.get(p) ? ` (opened by ${tabOwners.get(p)})` : ''}`);
|
|
1169
1412
|
out.log(` ${JSON.stringify(title)}`);
|
|
1170
1413
|
}
|
|
1171
1414
|
}
|
|
1172
1415
|
|
|
1416
|
+
// A function written out (el => ..., function (el) {...}), as one expression.
|
|
1417
|
+
function isFunctionText(text) {
|
|
1418
|
+
if (!/^\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/.test(text)) return false;
|
|
1419
|
+
try { new Function(`return (${text}\n);`); return true; } catch { return false; }
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1173
1422
|
const commands = {
|
|
1174
1423
|
async tab(args) {
|
|
1175
1424
|
const trimmed = (args || '').trim();
|
|
@@ -1194,6 +1443,7 @@ const commands = {
|
|
|
1194
1443
|
if (p !== state.page) state.previousPage = state.page;
|
|
1195
1444
|
state.page = p;
|
|
1196
1445
|
ensureDialogHandler(p);
|
|
1446
|
+
keepDrawing(p);
|
|
1197
1447
|
};
|
|
1198
1448
|
const byUrl = part => {
|
|
1199
1449
|
const matches = all.filter(p => p.url().includes(part));
|
|
@@ -1270,15 +1520,15 @@ const commands = {
|
|
|
1270
1520
|
},
|
|
1271
1521
|
|
|
1272
1522
|
async click(args) {
|
|
1273
|
-
const selector =
|
|
1274
|
-
await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
|
|
1275
|
-
out.log(`Clicked: ${
|
|
1523
|
+
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
1524
|
+
await onElement(selector, () => state.page.click(selector, { ...options, timeout: 5000 }));
|
|
1525
|
+
out.log(`Clicked: ${shown}`);
|
|
1276
1526
|
},
|
|
1277
1527
|
|
|
1278
1528
|
async dblclick(args) {
|
|
1279
|
-
const selector =
|
|
1280
|
-
await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
|
|
1281
|
-
out.log(`Double-clicked: ${
|
|
1529
|
+
const { selector, shown, options } = clickArgs(args, 'dblclick <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
1530
|
+
await onElement(selector, () => state.page.dblclick(selector, { ...options, timeout: 5000 }));
|
|
1531
|
+
out.log(`Double-clicked: ${shown}`);
|
|
1282
1532
|
},
|
|
1283
1533
|
|
|
1284
1534
|
async hover(args) {
|
|
@@ -1287,14 +1537,36 @@ const commands = {
|
|
|
1287
1537
|
out.log(`Hovered: ${args.trim()}`);
|
|
1288
1538
|
},
|
|
1289
1539
|
|
|
1290
|
-
async fill(
|
|
1291
|
-
const {
|
|
1540
|
+
async fill(line) {
|
|
1541
|
+
const { args, submit } = submitFlag(line);
|
|
1542
|
+
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value> [--submit]', 'fill #name Ada Lovelace');
|
|
1292
1543
|
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
1293
|
-
|
|
1544
|
+
if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
|
|
1545
|
+
out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1294
1546
|
},
|
|
1295
1547
|
|
|
1296
|
-
async type(
|
|
1297
|
-
const {
|
|
1548
|
+
async type(line) {
|
|
1549
|
+
const { args, submit } = submitFlag(line);
|
|
1550
|
+
// One word, or one quoted, is typed into the focused element, as playwright-cli's type does.
|
|
1551
|
+
const sole = splitSelector(args);
|
|
1552
|
+
if (sole && sole.rest === undefined) {
|
|
1553
|
+
// Named, since it may not be the element meant; one that takes no text is refused.
|
|
1554
|
+
const focused = await state.page.evaluate(() => {
|
|
1555
|
+
const el = document.activeElement;
|
|
1556
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
1557
|
+
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])');
|
|
1558
|
+
const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
|
|
1559
|
+
return { editable, name: `${el.tagName.toLowerCase()}${id}` };
|
|
1560
|
+
});
|
|
1561
|
+
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
1562
|
+
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
1563
|
+
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
1564
|
+
await state.page.keyboard.type(sole.word);
|
|
1565
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
1566
|
+
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
1298
1570
|
// Typed after what the field already holds, as someone clicking into it
|
|
1299
1571
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
1300
1572
|
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
@@ -1310,18 +1582,19 @@ const commands = {
|
|
|
1310
1582
|
}
|
|
1311
1583
|
}, null, { timeout: 5000 }));
|
|
1312
1584
|
await state.page.keyboard.type(value);
|
|
1313
|
-
|
|
1585
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
1586
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1314
1587
|
},
|
|
1315
1588
|
|
|
1316
1589
|
async press(args) {
|
|
1317
1590
|
const parsed = splitSelector(args);
|
|
1318
1591
|
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
1319
1592
|
if (parsed.rest === undefined) {
|
|
1320
|
-
await state.page.keyboard.press(parsed.word);
|
|
1321
|
-
out.log(`Pressed: ${parsed.word}`);
|
|
1593
|
+
await state.page.keyboard.press(keyName(parsed.word));
|
|
1594
|
+
out.log(`Pressed: ${keyName(parsed.word)}`);
|
|
1322
1595
|
return;
|
|
1323
1596
|
}
|
|
1324
|
-
const key = unquote(parsed.rest);
|
|
1597
|
+
const key = keyName(unquote(parsed.rest));
|
|
1325
1598
|
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
1326
1599
|
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
1327
1600
|
},
|
|
@@ -1349,7 +1622,12 @@ const commands = {
|
|
|
1349
1622
|
// container, where the paths mean nothing.
|
|
1350
1623
|
async upload(args) {
|
|
1351
1624
|
const parsed = splitSelector(args);
|
|
1352
|
-
|
|
1625
|
+
// playwright-cli's upload <file> answers a file picker a click opened. Here
|
|
1626
|
+
// the input is named instead: catching pickers would catch the person's too.
|
|
1627
|
+
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';
|
|
1628
|
+
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
1629
|
+
// A path, not a selector; // starts an XPath.
|
|
1630
|
+
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
1631
|
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1354
1632
|
const payloads = files.map(file => {
|
|
1355
1633
|
let buffer;
|
|
@@ -1459,14 +1737,21 @@ const commands = {
|
|
|
1459
1737
|
},
|
|
1460
1738
|
|
|
1461
1739
|
async screenshot(args) {
|
|
1462
|
-
const usage = 'Usage: screenshot [--full] [--delay|-d <seconds>] [name]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
1463
|
-
|
|
1740
|
+
const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
1741
|
+
// The file first: a path may be quoted, with spaces in it.
|
|
1742
|
+
let file = null;
|
|
1743
|
+
const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
|
|
1744
|
+
const tokens = rest.trim().split(/\s+/).filter(Boolean);
|
|
1464
1745
|
let full = false;
|
|
1465
1746
|
let delay = 0;
|
|
1747
|
+
let ref = null;
|
|
1466
1748
|
const names = [];
|
|
1467
1749
|
for (let i = 0; i < tokens.length; i++) {
|
|
1468
1750
|
const token = tokens[i];
|
|
1469
|
-
|
|
1751
|
+
// --full-page and --filename are playwright-cli's.
|
|
1752
|
+
if (token === '--full' || token === '--full-page') { full = true; continue; }
|
|
1753
|
+
if (token === '--filename' || token.startsWith('--filename=')) throw new Error(usage);
|
|
1754
|
+
if (!ref && !names.length && REF.test(token)) { ref = token; continue; }
|
|
1470
1755
|
if (token === '--delay' || token === '-d') {
|
|
1471
1756
|
const value = tokens[++i];
|
|
1472
1757
|
// Capped low on purpose: a timed shot is human-in-the-loop, and a typo
|
|
@@ -1477,7 +1762,7 @@ const commands = {
|
|
|
1477
1762
|
}
|
|
1478
1763
|
names.push(token);
|
|
1479
1764
|
}
|
|
1480
|
-
if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0]))) throw new Error(usage);
|
|
1765
|
+
if (names.length > 1 || (names[0] && !/^[a-zA-Z0-9_-]+$/.test(names[0])) || (file && names.length) || (ref && full)) throw new Error(usage);
|
|
1481
1766
|
const name = names[0] || null;
|
|
1482
1767
|
// Counted down out loud so a watcher can time a hover or menu state, and so
|
|
1483
1768
|
// the wait is distinguishable from a hung command in a tmux capture.
|
|
@@ -1486,13 +1771,23 @@ const commands = {
|
|
|
1486
1771
|
await state.page.waitForTimeout(1000);
|
|
1487
1772
|
}
|
|
1488
1773
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1489
|
-
|
|
1774
|
+
// A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
|
|
1775
|
+
const filepath = file ? path.resolve(file) : nextScreenshotPath(name);
|
|
1776
|
+
const type = /\.jpe?g$/i.test(filepath) ? 'jpeg' : 'png';
|
|
1777
|
+
// Checked before the tab is brought to the front, which can move someone's view.
|
|
1778
|
+
if (fs.existsSync(filepath)) throw new Error(`Screenshot already exists: ${filepath}`);
|
|
1779
|
+
if (!fs.existsSync(path.dirname(filepath))) throw new Error(`No folder ${path.dirname(filepath)} to save ${path.basename(filepath)} in`);
|
|
1490
1780
|
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1491
1781
|
// background, so the tab is brought to the front for the shot.
|
|
1492
1782
|
await state.page.bringToFront();
|
|
1493
1783
|
let image;
|
|
1494
1784
|
try {
|
|
1495
|
-
|
|
1785
|
+
if (ref) {
|
|
1786
|
+
const selector = toSelector(ref);
|
|
1787
|
+
image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
|
|
1788
|
+
} else {
|
|
1789
|
+
image = await state.page.screenshot({ fullPage: full, type });
|
|
1790
|
+
}
|
|
1496
1791
|
} finally {
|
|
1497
1792
|
// Playwright's screenshot resets the screen size and pixel ratio it
|
|
1498
1793
|
// finds; an emulated phone's are set again.
|
|
@@ -1517,9 +1812,11 @@ const commands = {
|
|
|
1517
1812
|
const full = /^--full(?:\s|$)/.test(rest);
|
|
1518
1813
|
if (full) rest = rest.slice(6).trim();
|
|
1519
1814
|
let needle = null;
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1815
|
+
const grep = /^--(grep|regex)(?:\s|$)/.exec(rest);
|
|
1816
|
+
if (grep) {
|
|
1817
|
+
needle = rest.slice(grep[0].length).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1818
|
+
if (!needle) throw new Error(`Usage: snapshot [--full] --${grep[1]} <${grep[1] === 'grep' ? 'text' : 'pattern'}>`);
|
|
1819
|
+
if (grep[1] === 'regex') needle = regexOf(needle);
|
|
1523
1820
|
rest = '';
|
|
1524
1821
|
}
|
|
1525
1822
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
@@ -1566,6 +1863,8 @@ const commands = {
|
|
|
1566
1863
|
if (value === 'off') {
|
|
1567
1864
|
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1568
1865
|
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
1866
|
+
// As when turned on: the page keeps what it read at load until it loads again.
|
|
1867
|
+
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
1868
|
return;
|
|
1570
1869
|
}
|
|
1571
1870
|
let next;
|
|
@@ -1586,8 +1885,8 @@ const commands = {
|
|
|
1586
1885
|
await setEmulation(state.page, { ...current, [key]: next });
|
|
1587
1886
|
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1588
1887
|
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.');
|
|
1888
|
+
// The page reads these when it loads; the rest applies at once. A blank tab has nothing to reload.
|
|
1889
|
+
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
1890
|
},
|
|
1592
1891
|
|
|
1593
1892
|
async wait(args) {
|
|
@@ -1619,8 +1918,8 @@ const commands = {
|
|
|
1619
1918
|
}
|
|
1620
1919
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1621
1920
|
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1622
|
-
await state.page.waitForSelector(selector, { state: '
|
|
1623
|
-
out.log(`
|
|
1921
|
+
await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
|
|
1922
|
+
out.log(`Visible: ${tokens.join(' ')}`);
|
|
1624
1923
|
},
|
|
1625
1924
|
|
|
1626
1925
|
async sleep(args) {
|
|
@@ -1661,12 +1960,15 @@ const commands = {
|
|
|
1661
1960
|
async route(args) {
|
|
1662
1961
|
const trimmed = (args || '').trim();
|
|
1663
1962
|
if (!trimmed) return listRoutes();
|
|
1963
|
+
// A glob may be quoted, as it is in a shell.
|
|
1664
1964
|
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
1665
|
-
if (off) return removeRoutes(off[1]);
|
|
1666
|
-
const usage = 'Usage: route <url-glob> <status>
|
|
1667
|
-
const
|
|
1965
|
+
if (off) return removeRoutes(off[1] && unquote(off[1]));
|
|
1966
|
+
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';
|
|
1967
|
+
const parsed = splitSelector(trimmed);
|
|
1968
|
+
const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
|
|
1668
1969
|
if (!match) throw new Error(usage);
|
|
1669
|
-
const
|
|
1970
|
+
const glob = parsed.word;
|
|
1971
|
+
const [, how, rest] = match;
|
|
1670
1972
|
const route = routeKind(how, rest, usage);
|
|
1671
1973
|
const routes = routesFor(state.page);
|
|
1672
1974
|
const previous = routes.get(glob);
|
|
@@ -1683,21 +1985,29 @@ const commands = {
|
|
|
1683
1985
|
}
|
|
1684
1986
|
};
|
|
1685
1987
|
await state.page.route(glob, handler);
|
|
1686
|
-
routes.set(glob, { text: route.text, handler });
|
|
1988
|
+
routes.set(glob, { text: route.text, handler, owner: state.client });
|
|
1687
1989
|
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
1688
1990
|
},
|
|
1689
1991
|
|
|
1690
1992
|
async requests(args) {
|
|
1691
|
-
|
|
1993
|
+
// --regex <pattern> takes the rest of the line.
|
|
1994
|
+
let regex = null;
|
|
1995
|
+
const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
|
|
1996
|
+
if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
|
|
1997
|
+
regex = regexOf(patternText(pattern.trim()));
|
|
1998
|
+
return '';
|
|
1999
|
+
});
|
|
2000
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
1692
2001
|
const everything = tokens.includes('--all');
|
|
1693
2002
|
if (everything) tokens.splice(tokens.indexOf('--all'), 1);
|
|
1694
2003
|
let count = RECENT_DEFAULT;
|
|
1695
2004
|
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];
|
|
2005
|
+
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})`);
|
|
2006
|
+
const filter = regex ? String(regex) : tokens[0];
|
|
2007
|
+
const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
|
|
1698
2008
|
const log = recentLogs.get(state.page) || [];
|
|
1699
2009
|
const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
|
|
1700
|
-
const matches = shown.filter(e =>
|
|
2010
|
+
const matches = shown.filter(e => wanted(e.url)).slice(-count);
|
|
1701
2011
|
if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
|
|
1702
2012
|
for (const e of matches) {
|
|
1703
2013
|
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
@@ -1712,29 +2022,34 @@ const commands = {
|
|
|
1712
2022
|
},
|
|
1713
2023
|
|
|
1714
2024
|
async body(args, all) {
|
|
1715
|
-
const
|
|
1716
|
-
if (!
|
|
1717
|
-
const
|
|
1718
|
-
const
|
|
1719
|
-
|
|
2025
|
+
const arg = (args || '').trim();
|
|
2026
|
+
if (!arg) throw new Error('Usage: body <#> | body <url-part> (the number from requests, or the latest request whose URL contains url-part)');
|
|
2027
|
+
const log = recentLogs.get(state.page) || [];
|
|
2028
|
+
const match = /^#?(\d+)$/.exec(arg);
|
|
2029
|
+
// The latest that has finished: one still pending has no body yet.
|
|
2030
|
+
const entry = match ? log.find(e => e.id === Number(match[1])) : log.filter(e => e.url.includes(arg) && e.status !== 'pending').pop();
|
|
2031
|
+
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}`);
|
|
2032
|
+
const id = entry.id;
|
|
1720
2033
|
// A fake is marked answered before the browser hands over its response.
|
|
1721
2034
|
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1722
2035
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1723
2036
|
}
|
|
1724
2037
|
if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2038
|
+
let buffer = entry.kept;
|
|
2039
|
+
if (!buffer) {
|
|
2040
|
+
// Not withTimeout: its "timed out" error would disconnect the REPL, and
|
|
2041
|
+
// reading a body cannot have changed anything.
|
|
2042
|
+
let timer;
|
|
2043
|
+
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
2044
|
+
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
2045
|
+
// Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
|
|
2046
|
+
catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates; a watched tab keeps them as they arrive)`); }
|
|
2047
|
+
finally { clearTimeout(timer); }
|
|
2048
|
+
}
|
|
1734
2049
|
const type = entry.response.headers()['content-type'] || '';
|
|
1735
2050
|
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
|
1736
2051
|
if (!buffer.length) return;
|
|
1737
|
-
if (
|
|
2052
|
+
if (!TEXT_BODY.test(type)) { out.log('[binary body not shown]'); return; }
|
|
1738
2053
|
let text = buffer.toString('utf8');
|
|
1739
2054
|
if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
|
|
1740
2055
|
printOutput(text, all);
|
|
@@ -1744,28 +2059,53 @@ const commands = {
|
|
|
1744
2059
|
const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1745
2060
|
let count = RECENT_DEFAULT;
|
|
1746
2061
|
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})`);
|
|
2062
|
+
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [level | filter] (n from 1 to ${RECENT_MAX})`);
|
|
1748
2063
|
const filter = tokens[0];
|
|
1749
|
-
const
|
|
1750
|
-
|
|
1751
|
-
.
|
|
2064
|
+
const level = CONSOLE_LEVELS.indexOf(filter);
|
|
2065
|
+
const shown = level === -1
|
|
2066
|
+
? e => !filter || e.type.includes(filter) || e.text.includes(filter)
|
|
2067
|
+
: e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
|
|
2068
|
+
const logs = consoleLogs.get(state.page) || [];
|
|
2069
|
+
const matches = logs.filter(e => e.type !== 'load' && shown(e)).slice(-count);
|
|
1752
2070
|
if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
|
|
1753
|
-
|
|
2071
|
+
// Where the page loaded again, so a message from an earlier load does not read as the current one's.
|
|
2072
|
+
// From the load the first message shown came from, if it is still kept.
|
|
2073
|
+
let first = logs.indexOf(matches[0]);
|
|
2074
|
+
while (first > 0 && logs[first].type !== 'load') first -= 1;
|
|
2075
|
+
const lines = logs.filter((e, i) => i >= first && (e.type === 'load' || matches.includes(e)));
|
|
2076
|
+
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
2077
|
},
|
|
1755
2078
|
|
|
1756
2079
|
async watch(args, all) {
|
|
1757
2080
|
const arg = (args || '').trim();
|
|
1758
2081
|
const watch = watches.get(state.page);
|
|
1759
2082
|
const [sub, ...flags] = arg.split(/\s+/);
|
|
2083
|
+
const next = flags.indexOf('--next-tab');
|
|
2084
|
+
if (sub === 'on' && next !== -1) {
|
|
2085
|
+
const part = flags[next + 1] && !flags[next + 1].startsWith('--') ? flags[next + 1] : null;
|
|
2086
|
+
const rest = flags.filter((f, i) => i !== next && !(part && i === next + 1));
|
|
2087
|
+
if (!rest.every(f => f === '--changes' || f === '--live')) throw new Error('Usage: watch on [--changes] [--live] --next-tab [url-part]');
|
|
2088
|
+
nextTab = { part, changes: rest.includes('--changes'), live: rest.includes('--live'), client: state.client };
|
|
2089
|
+
out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
if (sub !== 'on' && arg !== 'off' && waitingForMe() && (!state.page || state.page.isClosed())) {
|
|
2093
|
+
out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
1760
2096
|
if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
|
|
2097
|
+
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
2098
|
const changes = flags.includes('--changes');
|
|
1762
2099
|
const live = flags.includes('--live');
|
|
1763
2100
|
await startWatching(state.page, changes, live);
|
|
2101
|
+
watches.get(state.page).owner = state.client;
|
|
1764
2102
|
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
2103
|
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
2104
|
return;
|
|
1767
2105
|
}
|
|
1768
2106
|
if (arg === 'off') {
|
|
2107
|
+
if (nextTab) { nextTab = null; out.log('Stopped waiting to watch a new tab'); }
|
|
2108
|
+
if (!state.page || state.page.isClosed()) return;
|
|
1769
2109
|
if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
|
|
1770
2110
|
stopWatching(state.page, watch);
|
|
1771
2111
|
const steps = watch.events.length;
|
|
@@ -1810,7 +2150,45 @@ const commands = {
|
|
|
1810
2150
|
|
|
1811
2151
|
async eval(args, all) {
|
|
1812
2152
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
1813
|
-
|
|
2153
|
+
// eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
|
|
2154
|
+
const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
|
|
2155
|
+
if (onRef && isFunctionText(unquote(onRef[1]))) {
|
|
2156
|
+
const selector = toSelector(onRef[2]);
|
|
2157
|
+
const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
|
|
2158
|
+
try {
|
|
2159
|
+
// Made from its text by the debugger, as eval is, so a page's CSP cannot
|
|
2160
|
+
// refuse it, and in the element's own frame, which may be an iframe.
|
|
2161
|
+
const fn = await withTimeout((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2162
|
+
try {
|
|
2163
|
+
printOutput(await el.evaluate((e, f) => f(e), fn), all);
|
|
2164
|
+
} finally {
|
|
2165
|
+
await fn.dispose().catch(() => {});
|
|
2166
|
+
}
|
|
2167
|
+
} finally {
|
|
2168
|
+
await el.dispose().catch(() => {});
|
|
2169
|
+
}
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
// A function, as playwright-cli's eval takes, is called rather than returned.
|
|
2173
|
+
// Only a single expression can be one; statements are evaluated as they are.
|
|
2174
|
+
// await at the top level: one expression is run as an async function; statements as DevTools'
|
|
2175
|
+
// console runs them (replMode), which gives back the last one's value.
|
|
2176
|
+
const compiles = (Kind, body) => { try { new Kind(body); return true; } catch { return false; } };
|
|
2177
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
2178
|
+
const awaits = /\bawait\b/.test(args) && !compiles(Function, `return (${args}\n);`);
|
|
2179
|
+
const single = compiles(awaits ? AsyncFunction : Function, `return (${args}\n);`);
|
|
2180
|
+
const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
|
|
2181
|
+
if (awaits && !single && compiles(AsyncFunction, args)) {
|
|
2182
|
+
const session = await keptSession(state.page);
|
|
2183
|
+
const { result, exceptionDetails } = await withTimeout(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2184
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
|
|
2185
|
+
printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
|
|
2189
|
+
const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
|
|
2190
|
+
// An empty string is shown as one, not as no output at all.
|
|
2191
|
+
printOutput(value === '' ? '""' : value, all);
|
|
1814
2192
|
},
|
|
1815
2193
|
|
|
1816
2194
|
async cdp(args, all) {
|
|
@@ -1871,14 +2249,14 @@ const commands = {
|
|
|
1871
2249
|
async modes(args) {
|
|
1872
2250
|
const arg = (args || '').trim();
|
|
1873
2251
|
if (!arg) return listModes();
|
|
1874
|
-
if (arg !== 'off') throw new Error('Usage: modes [off]');
|
|
1875
|
-
await allModesOff();
|
|
2252
|
+
if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
|
|
2253
|
+
await allModesOff(arg === 'off --mine');
|
|
1876
2254
|
},
|
|
1877
2255
|
|
|
1878
2256
|
async help(args) {
|
|
1879
2257
|
const topic = (args || '').trim();
|
|
1880
2258
|
const text = HELP.render(topic);
|
|
1881
|
-
if (text === null) throw new Error(
|
|
2259
|
+
if (text === null) throw new Error(HELP.notFound(topic));
|
|
1882
2260
|
out.log(text);
|
|
1883
2261
|
},
|
|
1884
2262
|
|
|
@@ -1888,6 +2266,38 @@ const commands = {
|
|
|
1888
2266
|
}
|
|
1889
2267
|
};
|
|
1890
2268
|
|
|
2269
|
+
// Run for playwright-cli's names (lib/cli-names.js) where no command here does the same.
|
|
2270
|
+
const cliCommands = {
|
|
2271
|
+
// By index only: tab <number> falls back to a tab whose URL contains the number.
|
|
2272
|
+
// The index is into the sender's last listing, so a tab someone opened or
|
|
2273
|
+
// closed since cannot shift it onto another tab; with none yet, the tabs now.
|
|
2274
|
+
async 'tab-select'(args) {
|
|
2275
|
+
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
2276
|
+
if (!/^\d+$/.test(args)) throw new Error('Usage: tab-select <index>, as tab-list lists the tabs');
|
|
2277
|
+
if (!state.tabListing.length) {
|
|
2278
|
+
if (!all[Number(args)]) throw new Error(`No tab [${args}]; there ${all.length === 1 ? 'is 1' : `are ${all.length}`}, and tab-list lists them`);
|
|
2279
|
+
state.tabListing = all.slice();
|
|
2280
|
+
}
|
|
2281
|
+
const target = state.tabListing[Number(args)];
|
|
2282
|
+
if (!target) throw new Error(`No tab [${args}] in your last listing; tab-list lists them again`);
|
|
2283
|
+
if (target.isClosed() || !all.includes(target)) throw new Error(`The tab at [${args}] in your last listing has closed; tab-list lists them again`);
|
|
2284
|
+
return commands.tab(args);
|
|
2285
|
+
},
|
|
2286
|
+
|
|
2287
|
+
// playwright-cli's close ends its own session. The browser here is shared, so
|
|
2288
|
+
// it ends the sender's part in it: its modes are turned off, and every tab,
|
|
2289
|
+
// even one it opened, is left open for someone who may be using it.
|
|
2290
|
+
async close(args) {
|
|
2291
|
+
if (args) throw new Error('Usage: close');
|
|
2292
|
+
if (!state.client) {
|
|
2293
|
+
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.');
|
|
2294
|
+
}
|
|
2295
|
+
await allModesOff(true);
|
|
2296
|
+
const own = state.browser.contexts().flatMap(c => c.pages()).filter(p => tabOwners.get(p) === state.client);
|
|
2297
|
+
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.`);
|
|
2298
|
+
},
|
|
2299
|
+
};
|
|
2300
|
+
|
|
1891
2301
|
onShutdown(discardCapture);
|
|
1892
2302
|
onShutdown(resetEmulations);
|
|
1893
2303
|
|
|
@@ -1895,6 +2305,13 @@ onShutdown(resetEmulations);
|
|
|
1895
2305
|
function watchPage(p) {
|
|
1896
2306
|
ensureDialogHandler(p);
|
|
1897
2307
|
ensureRecentLog(p);
|
|
2308
|
+
if (nextTab && !opening) watchNewTab(p);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// watch on --next-tab waits for a tab, and watch off stops waiting, with none selected;
|
|
2312
|
+
// watch says it is still waiting.
|
|
2313
|
+
function needsTab(cmd, args) {
|
|
2314
|
+
return !(cmd === 'watch' && (/(?:^|\s)--next-tab(?:\s|$)/.test(args) || /^\s*off\s*$/.test(args) || waitingForMe()));
|
|
1898
2315
|
}
|
|
1899
2316
|
|
|
1900
2317
|
// Tab completion for the prompt: command names first, then the fixed words
|
|
@@ -1921,11 +2338,11 @@ function complete(line) {
|
|
|
1921
2338
|
if (command === 'capture') return match(['on', 'off']);
|
|
1922
2339
|
if (command === 'route') return match(['off']);
|
|
1923
2340
|
}
|
|
1924
|
-
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
|
|
2341
|
+
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
|
|
1925
2342
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1926
2343
|
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1927
2344
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1928
2345
|
return [[], current];
|
|
1929
2346
|
}
|
|
1930
2347
|
|
|
1931
|
-
module.exports = { commands, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
|
2348
|
+
module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|