pw-repl 0.3.2 → 0.4.1

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