pw-repl 0.3.2 → 0.4.0

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