pw-repl 0.3.1 → 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 +34 -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 +463 -97
- package/lib/help.js +68 -29
- package/lib/runner.js +70 -22
- package/lib/send.js +7 -2
- 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 +60 -11
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);
|
|
@@ -154,19 +155,20 @@ async function viewportText() {
|
|
|
154
155
|
|
|
155
156
|
// A page restored from the back/forward cache never fires its load events
|
|
156
157
|
// again, so back and forward wait only for the navigation, then briefly for
|
|
157
|
-
// the
|
|
158
|
+
// the document to be parsed, which a restored one already is.
|
|
158
159
|
async function historyStep(go, direction) {
|
|
159
160
|
const before = state.page.url();
|
|
160
161
|
const response = await go();
|
|
161
162
|
if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
|
|
162
|
-
await state.page.
|
|
163
|
+
await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
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) {
|
|
@@ -212,10 +214,18 @@ function emulatedKinds(e) {
|
|
|
212
214
|
return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
const DEVICES_SHOWN = 20;
|
|
218
|
+
|
|
215
219
|
function deviceNamed(name) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
return found;
|
|
220
|
+
const names = Object.keys(devices);
|
|
221
|
+
const found = names.find(d => d.toLowerCase() === name.toLowerCase());
|
|
222
|
+
if (found) return found;
|
|
223
|
+
// The names that have every word typed, so a near miss finds the exact name.
|
|
224
|
+
const words = name.toLowerCase().split(/\s+/);
|
|
225
|
+
const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
|
|
226
|
+
if (!close.length) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
|
|
227
|
+
const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
|
|
228
|
+
throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
function deviceText(name) {
|
|
@@ -225,6 +235,14 @@ function deviceText(name) {
|
|
|
225
235
|
|
|
226
236
|
// Sends only what changed. Chrome takes the user agent and the languages
|
|
227
237
|
// together, so a change to either sends both.
|
|
238
|
+
function deviceMetrics(session, name) {
|
|
239
|
+
const d = devices[name];
|
|
240
|
+
return session.send('Emulation.setDeviceMetricsOverride', {
|
|
241
|
+
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
242
|
+
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
228
246
|
async function setEmulation(p, next) {
|
|
229
247
|
const session = await keptSession(p);
|
|
230
248
|
const previous = emulations.get(p) || {};
|
|
@@ -232,10 +250,7 @@ async function setEmulation(p, next) {
|
|
|
232
250
|
if (next.device !== previous.device) {
|
|
233
251
|
const d = devices[next.device];
|
|
234
252
|
if (d) {
|
|
235
|
-
await
|
|
236
|
-
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
237
|
-
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
238
|
-
});
|
|
253
|
+
await deviceMetrics(session, next.device);
|
|
239
254
|
} else {
|
|
240
255
|
await send('Emulation.clearDeviceMetricsOverride');
|
|
241
256
|
}
|
|
@@ -253,7 +268,7 @@ async function setEmulation(p, next) {
|
|
|
253
268
|
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
254
269
|
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
255
270
|
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
256
|
-
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);
|
|
257
272
|
}
|
|
258
273
|
|
|
259
274
|
function showEmulation() {
|
|
@@ -326,14 +341,22 @@ function routeKind(how, rest, usage) {
|
|
|
326
341
|
if (/^\d{3}$/.test(how)) {
|
|
327
342
|
const status = Number(how);
|
|
328
343
|
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
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;
|
|
332
355
|
return {
|
|
333
|
-
text: `${status} ${preview}`,
|
|
356
|
+
text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
|
|
334
357
|
async handle(r, req, tag) {
|
|
335
358
|
markChanged(req, status, 'faked');
|
|
336
|
-
await r.fulfill({ status, contentType
|
|
359
|
+
await r.fulfill({ status, contentType, body });
|
|
337
360
|
out.notice(`Faked: ${tag()} -> ${status}`);
|
|
338
361
|
},
|
|
339
362
|
};
|
|
@@ -385,7 +408,8 @@ function routeKind(how, rest, usage) {
|
|
|
385
408
|
function listRoutes() {
|
|
386
409
|
const routes = routesFor(state.page);
|
|
387
410
|
const forms = [
|
|
388
|
-
['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'],
|
|
389
413
|
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
390
414
|
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
391
415
|
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
@@ -438,6 +462,8 @@ const requestEntries = new WeakMap();
|
|
|
438
462
|
const loadedAt = new WeakMap();
|
|
439
463
|
// Tabs this REPL opened with tab new.
|
|
440
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();
|
|
441
467
|
|
|
442
468
|
function clipText(text) {
|
|
443
469
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
@@ -450,6 +476,17 @@ function consoleText(msg) {
|
|
|
450
476
|
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
451
477
|
}
|
|
452
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
|
+
|
|
453
490
|
function keep(log, entry) {
|
|
454
491
|
log.push(entry);
|
|
455
492
|
if (log.length > RECENT_MAX) log.shift();
|
|
@@ -460,12 +497,14 @@ function ensureRecentLog(p) {
|
|
|
460
497
|
const log = [];
|
|
461
498
|
const logs = [];
|
|
462
499
|
let nextId = 1;
|
|
500
|
+
let newDocument = false;
|
|
463
501
|
recentLogs.set(p, log);
|
|
464
502
|
consoleLogs.set(p, logs);
|
|
465
503
|
p.on('request', req => {
|
|
466
504
|
let navigation = false;
|
|
467
505
|
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
468
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;
|
|
469
508
|
requestEntries.set(req, entry);
|
|
470
509
|
keep(log, entry);
|
|
471
510
|
});
|
|
@@ -485,8 +524,19 @@ function ensureRecentLog(p) {
|
|
|
485
524
|
} catch {}
|
|
486
525
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
487
526
|
});
|
|
488
|
-
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
|
+
});
|
|
489
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
|
+
});
|
|
490
540
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
491
541
|
// Uncaught exceptions never reach the console event.
|
|
492
542
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -516,8 +566,9 @@ const WATCH_REQUESTS_SHOWN = 5;
|
|
|
516
566
|
const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
|
|
517
567
|
const TYPING_BACKDATE_MAX = 60000;
|
|
518
568
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
519
|
-
//
|
|
569
|
+
// page -> its binding's record function; the binding is added once per tab.
|
|
520
570
|
const watchBindings = new WeakMap();
|
|
571
|
+
const WATCH_BINDING = '__pwReplWatch';
|
|
521
572
|
|
|
522
573
|
// Runs in the page. Describes elements the way snapshot does: role and name.
|
|
523
574
|
const WATCH_SCRIPT = `(() => {
|
|
@@ -562,7 +613,7 @@ const WATCH_SCRIPT = `(() => {
|
|
|
562
613
|
const send = (action, el, extra, since) => {
|
|
563
614
|
// Typing still waiting for its pause is recorded first, so the steps stay in order.
|
|
564
615
|
if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
|
|
565
|
-
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 }));
|
|
566
617
|
};
|
|
567
618
|
document.addEventListener('click', e => {
|
|
568
619
|
const el = e.target.closest ? (e.target.closest(INTERACTIVE) || e.target) : e.target;
|
|
@@ -777,12 +828,22 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
777
828
|
scheduleSettle(p, created);
|
|
778
829
|
};
|
|
779
830
|
// The page can call the binding itself, so only the page-side actions are
|
|
780
|
-
// 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.
|
|
781
835
|
if (!watchBindings.has(p)) {
|
|
782
836
|
const binding = { record: null };
|
|
783
|
-
await p
|
|
784
|
-
|
|
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);
|
|
785
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 });
|
|
786
847
|
watchBindings.set(p, binding);
|
|
787
848
|
}
|
|
788
849
|
watchBindings.get(p).record = record;
|
|
@@ -814,6 +875,51 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
814
875
|
watch.on = true;
|
|
815
876
|
}
|
|
816
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
|
+
|
|
817
923
|
// For each step of a watch, the requests made between it and the next step.
|
|
818
924
|
function requestsByStep(watch, p = state.page) {
|
|
819
925
|
const requests = (recentLogs.get(p) || []).filter(r => !WATCH_HIDDEN_TYPES.has(r.type) && !r.url.startsWith('chrome-extension://'));
|
|
@@ -880,6 +986,8 @@ function listModes() {
|
|
|
880
986
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
881
987
|
state.tabListing = all.slice();
|
|
882
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;
|
|
883
991
|
if (!on.length) {
|
|
884
992
|
out.log('No modes are on in any tab.');
|
|
885
993
|
out.log(hints([
|
|
@@ -892,28 +1000,54 @@ function listModes() {
|
|
|
892
1000
|
return;
|
|
893
1001
|
}
|
|
894
1002
|
const width = Math.max(...on.map(([p, i]) => `[${i}] ${p.url()}`.length));
|
|
895
|
-
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
|
+
}
|
|
896
1008
|
out.log(hints([['modes off', 'turn them all off']]));
|
|
897
1009
|
}
|
|
898
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
|
+
|
|
899
1031
|
// Turns off everything the REPL turned on, in every tab: nothing it leaves
|
|
900
|
-
// behind keeps acting on someone's browser.
|
|
901
|
-
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) {
|
|
902
1034
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
1035
|
+
const ours = owner => !mine || (owner ?? null) === state.client;
|
|
903
1036
|
let any = false;
|
|
904
1037
|
let failed = 0;
|
|
1038
|
+
if (nextTab && ours(nextTab.client)) { nextTab = null; any = true; out.log('Stopped waiting to watch a new tab'); }
|
|
905
1039
|
// A tab that fails (e.g. it crashed) must not keep the others' modes on.
|
|
906
1040
|
for (const p of all) {
|
|
907
1041
|
const done = [];
|
|
908
1042
|
let problem = null;
|
|
909
1043
|
try {
|
|
910
1044
|
const watch = watches.get(p);
|
|
911
|
-
if (watch?.on) { stopWatching(p, watch); done.push('watch off'); }
|
|
912
|
-
if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
913
|
-
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);
|
|
914
1048
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
915
|
-
if (networkChanged.has(p)) { await setNetwork(p, null); done.push('network on'); }
|
|
916
|
-
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'); }
|
|
917
1051
|
} catch (error) {
|
|
918
1052
|
problem = error.message;
|
|
919
1053
|
}
|
|
@@ -921,7 +1055,7 @@ async function allModesOff() {
|
|
|
921
1055
|
if (problem) { failed += 1; out.error(`${p.url()}: could not turn everything off: ${problem}`); }
|
|
922
1056
|
}
|
|
923
1057
|
if (failed) throw new Error(`Modes may still be on in ${failed} tab${failed === 1 ? '' : 's'}; modes lists them`);
|
|
924
|
-
if (!any) out.log('No modes were on.');
|
|
1058
|
+
if (!any) out.log(mine ? 'None of your modes were on.' : 'No modes were on.');
|
|
925
1059
|
}
|
|
926
1060
|
|
|
927
1061
|
// The type a page sees for a file it is given, from its extension.
|
|
@@ -944,6 +1078,31 @@ function soleSelector(args, usage) {
|
|
|
944
1078
|
return toSelector(text);
|
|
945
1079
|
}
|
|
946
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
|
+
|
|
947
1106
|
function selectorAndValue(args, usage, example) {
|
|
948
1107
|
const parsed = splitSelector(args);
|
|
949
1108
|
if (!parsed || parsed.rest === undefined) {
|
|
@@ -1033,16 +1192,22 @@ const SNAPSHOT_HINT_LINES = 60;
|
|
|
1033
1192
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
1034
1193
|
// with the named elements it sits in, so a match can be placed without the
|
|
1035
1194
|
// rest of the tree.
|
|
1195
|
+
// needle: text to find in any case, or a RegExp.
|
|
1036
1196
|
function grepSnapshot(text, needle) {
|
|
1037
|
-
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));
|
|
1038
1199
|
const stack = [];
|
|
1039
1200
|
const hits = [];
|
|
1040
1201
|
const label = line => line.trim().replace(/^- /, '').replace(/:$/, '').replace(/^'(.*)'$/, '$1');
|
|
1041
1202
|
for (const line of text.split('\n')) {
|
|
1042
1203
|
const indent = line.length - line.trimStart().length;
|
|
1043
1204
|
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
1044
|
-
if (line
|
|
1045
|
-
const
|
|
1205
|
+
if (matches(line)) {
|
|
1206
|
+
const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
|
|
1207
|
+
// A hit with no ref of its own (text) keeps the ref of what holds it, so
|
|
1208
|
+
// snapshot <ref> can show what sits beside it: the value next to a label.
|
|
1209
|
+
const own = /\[ref=/.test(line);
|
|
1210
|
+
const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
|
|
1046
1211
|
hits.push([...path, label(line)].join(' › '));
|
|
1047
1212
|
}
|
|
1048
1213
|
stack.push({ indent, label: label(line) });
|
|
@@ -1050,6 +1215,17 @@ function grepSnapshot(text, needle) {
|
|
|
1050
1215
|
return hits;
|
|
1051
1216
|
}
|
|
1052
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
|
+
|
|
1053
1229
|
// Local time with its offset (18:16:15.721-06:00), so it reads against the
|
|
1054
1230
|
// user's clock and is still unambiguous.
|
|
1055
1231
|
function clock(t) {
|
|
@@ -1090,7 +1266,16 @@ function discardCapture() {
|
|
|
1090
1266
|
// person using the browser; Playwright's newPage would bring it to the front.
|
|
1091
1267
|
const OPEN_TIMEOUT = 10000;
|
|
1092
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
|
+
|
|
1093
1273
|
async function openTab() {
|
|
1274
|
+
opening = true;
|
|
1275
|
+
try { return await openOwnTab(); } finally { opening = false; }
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
async function openOwnTab() {
|
|
1094
1279
|
const ctx = state.browser.contexts()[0];
|
|
1095
1280
|
let opened = null;
|
|
1096
1281
|
const session = await state.browser.newBrowserCDPSession().catch(() => null);
|
|
@@ -1105,6 +1290,7 @@ async function openTab() {
|
|
|
1105
1290
|
// A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
|
|
1106
1291
|
if (!opened) opened = await ctx.newPage();
|
|
1107
1292
|
openedTabs.add(opened);
|
|
1293
|
+
tabOwners.set(opened, state.client);
|
|
1108
1294
|
return opened;
|
|
1109
1295
|
}
|
|
1110
1296
|
|
|
@@ -1148,11 +1334,17 @@ async function listTabs() {
|
|
|
1148
1334
|
// URL first on its own line: it is what distinguishes otherwise
|
|
1149
1335
|
// identically titled tabs, and titles wrap less badly when indented.
|
|
1150
1336
|
if (i) out.log('');
|
|
1151
|
-
out.log(`${marker} [${i}] ${p.url()}`);
|
|
1337
|
+
out.log(`${marker} [${i}] ${p.url()}${tabOwners.get(p) ? ` (opened by ${tabOwners.get(p)})` : ''}`);
|
|
1152
1338
|
out.log(` ${JSON.stringify(title)}`);
|
|
1153
1339
|
}
|
|
1154
1340
|
}
|
|
1155
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
|
+
|
|
1156
1348
|
const commands = {
|
|
1157
1349
|
async tab(args) {
|
|
1158
1350
|
const trimmed = (args || '').trim();
|
|
@@ -1253,15 +1445,15 @@ const commands = {
|
|
|
1253
1445
|
},
|
|
1254
1446
|
|
|
1255
1447
|
async click(args) {
|
|
1256
|
-
const selector =
|
|
1257
|
-
await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
|
|
1258
|
-
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}`);
|
|
1259
1451
|
},
|
|
1260
1452
|
|
|
1261
1453
|
async dblclick(args) {
|
|
1262
|
-
const selector =
|
|
1263
|
-
await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
|
|
1264
|
-
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}`);
|
|
1265
1457
|
},
|
|
1266
1458
|
|
|
1267
1459
|
async hover(args) {
|
|
@@ -1270,14 +1462,36 @@ const commands = {
|
|
|
1270
1462
|
out.log(`Hovered: ${args.trim()}`);
|
|
1271
1463
|
},
|
|
1272
1464
|
|
|
1273
|
-
async fill(
|
|
1274
|
-
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');
|
|
1275
1468
|
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
1276
|
-
|
|
1469
|
+
if (submit) await state.page.press(selector, 'Enter', { timeout: 5000 });
|
|
1470
|
+
out.log(`Filled${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1277
1471
|
},
|
|
1278
1472
|
|
|
1279
|
-
async type(
|
|
1280
|
-
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');
|
|
1281
1495
|
// Typed after what the field already holds, as someone clicking into it
|
|
1282
1496
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
1283
1497
|
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
@@ -1293,18 +1507,19 @@ const commands = {
|
|
|
1293
1507
|
}
|
|
1294
1508
|
}, null, { timeout: 5000 }));
|
|
1295
1509
|
await state.page.keyboard.type(value);
|
|
1296
|
-
|
|
1510
|
+
if (submit) await state.page.keyboard.press('Enter');
|
|
1511
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
1297
1512
|
},
|
|
1298
1513
|
|
|
1299
1514
|
async press(args) {
|
|
1300
1515
|
const parsed = splitSelector(args);
|
|
1301
1516
|
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
1302
1517
|
if (parsed.rest === undefined) {
|
|
1303
|
-
await state.page.keyboard.press(parsed.word);
|
|
1304
|
-
out.log(`Pressed: ${parsed.word}`);
|
|
1518
|
+
await state.page.keyboard.press(keyName(parsed.word));
|
|
1519
|
+
out.log(`Pressed: ${keyName(parsed.word)}`);
|
|
1305
1520
|
return;
|
|
1306
1521
|
}
|
|
1307
|
-
const key = unquote(parsed.rest);
|
|
1522
|
+
const key = keyName(unquote(parsed.rest));
|
|
1308
1523
|
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
1309
1524
|
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
1310
1525
|
},
|
|
@@ -1332,7 +1547,12 @@ const commands = {
|
|
|
1332
1547
|
// container, where the paths mean nothing.
|
|
1333
1548
|
async upload(args) {
|
|
1334
1549
|
const parsed = splitSelector(args);
|
|
1335
|
-
|
|
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)}`);
|
|
1336
1556
|
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1337
1557
|
const payloads = files.map(file => {
|
|
1338
1558
|
let buffer;
|
|
@@ -1442,14 +1662,21 @@ const commands = {
|
|
|
1442
1662
|
},
|
|
1443
1663
|
|
|
1444
1664
|
async screenshot(args) {
|
|
1445
|
-
const usage = 'Usage: screenshot [--full] [--delay|-d <seconds>] [name]\nNames may contain letters, digits, underscores, and hyphens.';
|
|
1446
|
-
|
|
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);
|
|
1447
1670
|
let full = false;
|
|
1448
1671
|
let delay = 0;
|
|
1672
|
+
let ref = null;
|
|
1449
1673
|
const names = [];
|
|
1450
1674
|
for (let i = 0; i < tokens.length; i++) {
|
|
1451
1675
|
const token = tokens[i];
|
|
1452
|
-
|
|
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; }
|
|
1453
1680
|
if (token === '--delay' || token === '-d') {
|
|
1454
1681
|
const value = tokens[++i];
|
|
1455
1682
|
// Capped low on purpose: a timed shot is human-in-the-loop, and a typo
|
|
@@ -1460,7 +1687,7 @@ const commands = {
|
|
|
1460
1687
|
}
|
|
1461
1688
|
names.push(token);
|
|
1462
1689
|
}
|
|
1463
|
-
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);
|
|
1464
1691
|
const name = names[0] || null;
|
|
1465
1692
|
// Counted down out loud so a watcher can time a hover or menu state, and so
|
|
1466
1693
|
// the wait is distinguishable from a hung command in a tmux capture.
|
|
@@ -1469,11 +1696,33 @@ const commands = {
|
|
|
1469
1696
|
await state.page.waitForTimeout(1000);
|
|
1470
1697
|
}
|
|
1471
1698
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1472
|
-
|
|
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`);
|
|
1473
1705
|
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1474
1706
|
// background, so the tab is brought to the front for the shot.
|
|
1475
1707
|
await state.page.bringToFront();
|
|
1476
|
-
|
|
1708
|
+
let image;
|
|
1709
|
+
try {
|
|
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
|
+
}
|
|
1716
|
+
} finally {
|
|
1717
|
+
// Playwright's screenshot resets the screen size and pixel ratio it
|
|
1718
|
+
// finds; an emulated phone's are set again.
|
|
1719
|
+
const device = emulations.get(state.page)?.device;
|
|
1720
|
+
if (device) {
|
|
1721
|
+
const session = await keptSession(state.page);
|
|
1722
|
+
await session.send('Emulation.clearDeviceMetricsOverride');
|
|
1723
|
+
await deviceMetrics(session, device);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1477
1726
|
try {
|
|
1478
1727
|
fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
|
|
1479
1728
|
} catch (error) {
|
|
@@ -1488,9 +1737,11 @@ const commands = {
|
|
|
1488
1737
|
const full = /^--full(?:\s|$)/.test(rest);
|
|
1489
1738
|
if (full) rest = rest.slice(6).trim();
|
|
1490
1739
|
let needle = null;
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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);
|
|
1494
1745
|
rest = '';
|
|
1495
1746
|
}
|
|
1496
1747
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
@@ -1537,6 +1788,8 @@ const commands = {
|
|
|
1537
1788
|
if (value === 'off') {
|
|
1538
1789
|
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1539
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.');
|
|
1540
1793
|
return;
|
|
1541
1794
|
}
|
|
1542
1795
|
let next;
|
|
@@ -1557,8 +1810,8 @@ const commands = {
|
|
|
1557
1810
|
await setEmulation(state.page, { ...current, [key]: next });
|
|
1558
1811
|
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1559
1812
|
out.log(`Emulating ${shown} in the selected tab`);
|
|
1560
|
-
// The page reads these when it loads; the rest applies at once.
|
|
1561
|
-
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.');
|
|
1562
1815
|
},
|
|
1563
1816
|
|
|
1564
1817
|
async wait(args) {
|
|
@@ -1570,6 +1823,8 @@ const commands = {
|
|
|
1570
1823
|
if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
|
|
1571
1824
|
if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
|
|
1572
1825
|
const timeout = seconds * 1000;
|
|
1826
|
+
// Said plainly, not as Playwright's call log.
|
|
1827
|
+
const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
|
|
1573
1828
|
const kind = tokens[0];
|
|
1574
1829
|
if (kind === 'load' && tokens.length === 1) {
|
|
1575
1830
|
if (gone) throw new Error(usage);
|
|
@@ -1582,14 +1837,14 @@ const commands = {
|
|
|
1582
1837
|
return waitForRequest(what, timeout);
|
|
1583
1838
|
}
|
|
1584
1839
|
if (gone) return waitGone(state.page.getByText(what), what, timeout);
|
|
1585
|
-
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
|
|
1840
|
+
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
|
|
1586
1841
|
out.log(`Visible: ${what}`);
|
|
1587
1842
|
return;
|
|
1588
1843
|
}
|
|
1589
1844
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1590
1845
|
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1591
|
-
await state.page.waitForSelector(selector, { state: '
|
|
1592
|
-
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(' ')}`);
|
|
1593
1848
|
},
|
|
1594
1849
|
|
|
1595
1850
|
async sleep(args) {
|
|
@@ -1630,12 +1885,15 @@ const commands = {
|
|
|
1630
1885
|
async route(args) {
|
|
1631
1886
|
const trimmed = (args || '').trim();
|
|
1632
1887
|
if (!trimmed) return listRoutes();
|
|
1888
|
+
// A glob may be quoted, as it is in a shell.
|
|
1633
1889
|
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
1634
|
-
if (off) return removeRoutes(off[1]);
|
|
1635
|
-
const usage = 'Usage: route <url-glob> <status>
|
|
1636
|
-
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 || '');
|
|
1637
1894
|
if (!match) throw new Error(usage);
|
|
1638
|
-
const
|
|
1895
|
+
const glob = parsed.word;
|
|
1896
|
+
const [, how, rest] = match;
|
|
1639
1897
|
const route = routeKind(how, rest, usage);
|
|
1640
1898
|
const routes = routesFor(state.page);
|
|
1641
1899
|
const previous = routes.get(glob);
|
|
@@ -1652,21 +1910,29 @@ const commands = {
|
|
|
1652
1910
|
}
|
|
1653
1911
|
};
|
|
1654
1912
|
await state.page.route(glob, handler);
|
|
1655
|
-
routes.set(glob, { text: route.text, handler });
|
|
1913
|
+
routes.set(glob, { text: route.text, handler, owner: state.client });
|
|
1656
1914
|
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
1657
1915
|
},
|
|
1658
1916
|
|
|
1659
1917
|
async requests(args) {
|
|
1660
|
-
|
|
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);
|
|
1661
1926
|
const everything = tokens.includes('--all');
|
|
1662
1927
|
if (everything) tokens.splice(tokens.indexOf('--all'), 1);
|
|
1663
1928
|
let count = RECENT_DEFAULT;
|
|
1664
1929
|
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
1665
|
-
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: requests [--all] [n] [url-filter] (n from 1 to ${RECENT_MAX})`);
|
|
1666
|
-
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);
|
|
1667
1933
|
const log = recentLogs.get(state.page) || [];
|
|
1668
1934
|
const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
|
|
1669
|
-
const matches = shown.filter(e =>
|
|
1935
|
+
const matches = shown.filter(e => wanted(e.url)).slice(-count);
|
|
1670
1936
|
if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
|
|
1671
1937
|
for (const e of matches) {
|
|
1672
1938
|
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
@@ -1681,11 +1947,14 @@ const commands = {
|
|
|
1681
1947
|
},
|
|
1682
1948
|
|
|
1683
1949
|
async body(args, all) {
|
|
1684
|
-
const
|
|
1685
|
-
if (!
|
|
1686
|
-
const
|
|
1687
|
-
const
|
|
1688
|
-
|
|
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;
|
|
1689
1958
|
// A fake is marked answered before the browser hands over its response.
|
|
1690
1959
|
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1691
1960
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
@@ -1713,28 +1982,49 @@ const commands = {
|
|
|
1713
1982
|
const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1714
1983
|
let count = RECENT_DEFAULT;
|
|
1715
1984
|
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
1716
|
-
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})`);
|
|
1717
1986
|
const filter = tokens[0];
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1720
|
-
.
|
|
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);
|
|
1721
1993
|
if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
|
|
1722
|
-
|
|
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);
|
|
1723
2000
|
},
|
|
1724
2001
|
|
|
1725
2002
|
async watch(args, all) {
|
|
1726
2003
|
const arg = (args || '').trim();
|
|
1727
2004
|
const watch = watches.get(state.page);
|
|
1728
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
|
+
}
|
|
1729
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');
|
|
1730
2017
|
const changes = flags.includes('--changes');
|
|
1731
2018
|
const live = flags.includes('--live');
|
|
1732
2019
|
await startWatching(state.page, changes, live);
|
|
2020
|
+
watches.get(state.page).owner = state.client;
|
|
1733
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)`);
|
|
1734
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.');
|
|
1735
2023
|
return;
|
|
1736
2024
|
}
|
|
1737
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;
|
|
1738
2028
|
if (!watch?.on) { out.log('Not watching the selected tab.'); return; }
|
|
1739
2029
|
stopWatching(state.page, watch);
|
|
1740
2030
|
const steps = watch.events.length;
|
|
@@ -1779,7 +2069,45 @@ const commands = {
|
|
|
1779
2069
|
|
|
1780
2070
|
async eval(args, all) {
|
|
1781
2071
|
if (!args) throw new Error('Usage: eval <js-expression>');
|
|
1782
|
-
|
|
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);
|
|
1783
2111
|
},
|
|
1784
2112
|
|
|
1785
2113
|
async cdp(args, all) {
|
|
@@ -1840,8 +2168,8 @@ const commands = {
|
|
|
1840
2168
|
async modes(args) {
|
|
1841
2169
|
const arg = (args || '').trim();
|
|
1842
2170
|
if (!arg) return listModes();
|
|
1843
|
-
if (arg !== 'off') throw new Error('Usage: modes [off]');
|
|
1844
|
-
await allModesOff();
|
|
2171
|
+
if (arg !== 'off' && arg !== 'off --mine') throw new Error('Usage: modes [off [--mine]]');
|
|
2172
|
+
await allModesOff(arg === 'off --mine');
|
|
1845
2173
|
},
|
|
1846
2174
|
|
|
1847
2175
|
async help(args) {
|
|
@@ -1857,6 +2185,38 @@ const commands = {
|
|
|
1857
2185
|
}
|
|
1858
2186
|
};
|
|
1859
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
|
+
|
|
1860
2220
|
onShutdown(discardCapture);
|
|
1861
2221
|
onShutdown(resetEmulations);
|
|
1862
2222
|
|
|
@@ -1864,6 +2224,12 @@ onShutdown(resetEmulations);
|
|
|
1864
2224
|
function watchPage(p) {
|
|
1865
2225
|
ensureDialogHandler(p);
|
|
1866
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)));
|
|
1867
2233
|
}
|
|
1868
2234
|
|
|
1869
2235
|
// Tab completion for the prompt: command names first, then the fixed words
|
|
@@ -1890,11 +2256,11 @@ function complete(line) {
|
|
|
1890
2256
|
if (command === 'capture') return match(['on', 'off']);
|
|
1891
2257
|
if (command === 'route') return match(['off']);
|
|
1892
2258
|
}
|
|
1893
|
-
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)));
|
|
1894
2260
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1895
2261
|
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1896
2262
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1897
2263
|
return [[], current];
|
|
1898
2264
|
}
|
|
1899
2265
|
|
|
1900
|
-
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 };
|