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/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
- const SCREENSHOT_DIR = process.env.PW_SCREENSHOT_DIR || '/tmp';
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(state.page) ? state.page : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
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 page, rather than time out on a page that is already there.
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.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
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
- return path.join(SCREENSHOT_DIR, `${filename}.png`);
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 found = Object.keys(devices).find(d => d.toLowerCase() === name.toLowerCase());
217
- if (!found) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
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 send('Emulation.setDeviceMetricsOverride', {
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
- if (!rest) throw new Error(usage);
330
- parseJson(rest, 'Body');
331
- const preview = rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest;
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: 'application/json', body: rest });
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> <json-body>', 'answer it with this status and JSON'],
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 => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
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
- // Playwright refuses a second binding with the same name, so a retry must not re-register it.
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.exposeBinding('__pwReplWatch', (_source, event) => {
784
- if (binding.record && event && PAGE_ACTIONS.has(event.action)) binding.record(event.action, String(event.target || ''), String(event.extra || ''), event.since);
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) out.log(`${p === state.page ? '*' : ' '} ${`[${i}] ${p.url()}`.padEnd(width)} (${modes.join(' ')})`);
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)?.keys() || [])];
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.toLowerCase().includes(lower)) {
1045
- const path = stack.filter(a => !/^generic(?: \[|$)/.test(a.label)).map(a => a.label.replace(/ \[ref=[^\]]+\]/g, ''));
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 = soleSelector(args, 'click <selector>');
1257
- await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
1258
- out.log(`Clicked: ${args.trim()}`);
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 = soleSelector(args, 'dblclick <selector>');
1263
- await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
1264
- out.log(`Double-clicked: ${args.trim()}`);
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(args) {
1274
- const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value>', 'fill #name Ada Lovelace');
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
- out.log(`Filled: ${shown}`);
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(args) {
1280
- const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
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
- out.log(`Typed into: ${shown}`);
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
- if (!parsed || parsed.rest === undefined) throw new Error('Usage: upload <selector> <file>..., e.g. upload "input[type=file]" photo.png; quote a path with spaces');
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
- const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
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
- if (token === '--full') { full = true; continue; }
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
- const filepath = nextScreenshotPath(name);
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
- const image = await state.page.screenshot({ fullPage: full });
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
- if (/^--grep(?:\s|$)/.test(rest)) {
1492
- needle = rest.slice(6).trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
1493
- if (!needle) throw new Error('Usage: snapshot [--full] --grep <text>');
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: 'attached', timeout });
1592
- out.log(`Found: ${tokens.join(' ')}`);
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> <json-body> | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
1636
- const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
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 [, glob, how, rest] = match;
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
- const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
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 => !filter || e.url.includes(filter)).slice(-count);
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 match = /^#?(\d+)$/.exec((args || '').trim());
1685
- if (!match) throw new Error('Usage: body <#> (the number from requests)');
1686
- const id = Number(match[1]);
1687
- const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
1688
- if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
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 matches = (consoleLogs.get(state.page) || [])
1719
- .filter(e => !filter || e.type.includes(filter) || e.text.includes(filter))
1720
- .slice(-count);
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
- printOutput(matches.map(e => `${clock(e.t)} [${e.type}] ${e.text}`).join('\n'), all);
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
- printOutput(await withTimeout(state.page.evaluate(args), 'Page evaluation', COMMAND_TIMEOUT), all);
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 };