pw-repl 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -21,6 +21,16 @@ chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
21
21
  Use a separate `--user-data-dir`: recent Chrome versions do not open the debugging port on the default
22
22
  profile. Check it is up with `curl http://localhost:9222/json/version`.
23
23
 
24
+ **Headless** works the same way:
25
+
26
+ ```bash
27
+ chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
28
+ ```
29
+
30
+ With nobody at the browser, `dialog accept` or `dialog dismiss` answers a dialog (`alert`, `confirm`);
31
+ the REPL never answers one on its own. Pages start at about 800x600; `viewport 1280x800` sets another
32
+ size.
33
+
24
34
  ### 2. Install and start the REPL
25
35
 
26
36
  ```bash
package/bin/pw-repl.js CHANGED
@@ -67,9 +67,14 @@ function parseSendArgs(args, allowCommand) {
67
67
  }
68
68
  const words = args.slice(i);
69
69
  if (!allowCommand && words.length) usage();
70
- // A word the shell kept whole (fill "text=Your name" Ada) is quoted again, so
71
- // it stays one word; a command given as a single word is sent as it is.
72
- const quoted = words.length > 1 ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
70
+ // For a command that reads a quoted selector (fill "text=Your name" Ada), a
71
+ // word the shell kept whole is quoted again so it stays one word. Any other
72
+ // command takes the rest of its line as it is (eval, route's JSON), so its
73
+ // words are joined as they are.
74
+ const { SELECTOR_FIRST } = require('../lib/syntax');
75
+ const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
76
+ // An empty word (fill #name "") is the empty value.
77
+ const quoted = requote ? words.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
73
78
  options.command = quoted.join(' ').trim();
74
79
  return options;
75
80
  }
@@ -115,7 +120,8 @@ async function main() {
115
120
  case 'stop': {
116
121
  const options = parseSendArgs(args, false);
117
122
  if (options.session) usage();
118
- const endpoint = options.endpoint || process.env.PW_ENDPOINT || null;
123
+ // The same socket send would use when there is no -e: PW_ENDPOINT, then PW_SOCKET.
124
+ const endpoint = options.endpoint || process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
119
125
  process.exit(await require('../lib/background')[subcommand]({ endpoint }));
120
126
  }
121
127
  // falls through never: process.exit above
package/lib/background.js CHANGED
@@ -79,7 +79,7 @@ async function start(options) {
79
79
  }
80
80
  if (exited || !runningPid(pidFile)) {
81
81
  if (!exited) child.kill();
82
- const last = tail(logFile, 10);
82
+ const last = tail(logFile, 12);
83
83
  return fail(`the background REPL did not start${last ? `; the end of ${logFile}:\n${last}` : ''}`);
84
84
  }
85
85
  child.unref();
@@ -103,7 +103,7 @@ async function stop(options) {
103
103
  const deadline = Date.now() + STOP_TIMEOUT;
104
104
  while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
105
105
  if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
106
- console.log(`Stopped the background REPL (pid ${pid}); its log stays at ${filesFor(endpoint).logFile}`);
106
+ console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${filesFor(endpoint).logFile}`);
107
107
  return 0;
108
108
  }
109
109
 
package/lib/commands.js CHANGED
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const { state, withTimeout, onShutdown, shutdown } = require('./state');
4
4
  const out = require('./output');
5
5
  const HELP = require('./help');
6
+ const { toSelector, unquote, splitSelector } = require('./syntax');
6
7
 
7
8
  const { printOutput, OUTPUT_LIMIT } = out;
8
9
  const SCREENSHOT_DIR = process.env.PW_SCREENSHOT_DIR || '/tmp';
@@ -96,13 +97,66 @@ function hints(pairs) {
96
97
 
97
98
  const dialogPages = new WeakSet();
98
99
 
100
+ // page -> its open dialog. The page, and every command that reads it, waits
101
+ // until the dialog is answered, in the browser or with the dialog command.
102
+ const openDialogs = new Map();
103
+
99
104
  function ensureDialogHandler(p) {
100
105
  if (dialogPages.has(p)) return;
101
106
  dialogPages.add(p);
102
107
  p.on('dialog', dialog => {
108
+ openDialogs.set(p, dialog);
103
109
  out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
104
- out.notice('Handle this dialog in the browser window; the triggering command is waiting.');
110
+ out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.');
105
111
  });
112
+ p.on('close', () => openDialogs.delete(p));
113
+ }
114
+
115
+ // Runs ahead of the command queue (see runner.js), so it still works while
116
+ // commands wait on the dialog; it returns its output rather than printing it.
117
+ async function dialogCommand(args) {
118
+ const [action, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
119
+ const pages = state.browser.contexts().flatMap(c => c.pages());
120
+ for (const p of openDialogs.keys()) if (p.isClosed() || !pages.includes(p)) openDialogs.delete(p);
121
+ const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
122
+ if (!action) {
123
+ if (!openDialogs.size) return 'No dialog is open.';
124
+ return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
125
+ }
126
+ if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
127
+ // The selected tab's dialog, or the only one open.
128
+ const page = openDialogs.has(state.page) ? state.page : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
129
+ 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.');
130
+ const dialog = openDialogs.get(page);
131
+ openDialogs.delete(page);
132
+ const line = describe(page, dialog);
133
+ try {
134
+ if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
135
+ else await dialog.dismiss();
136
+ } catch (error) {
137
+ // Answered in the browser meanwhile.
138
+ return `No dialog is open (${error.message.split('\n')[0]})`;
139
+ }
140
+ return `${action === 'accept' ? 'Accepted' : 'Dismissed'}: ${line}`;
141
+ }
142
+
143
+ // A tab in a browser the REPL connected to has no viewport set, so its size is
144
+ // the window's; it is read from the page.
145
+ async function viewportText() {
146
+ const set = state.page.viewportSize();
147
+ if (set) return `${set.width}x${set.height} (set with viewport)`;
148
+ const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
149
+ return size ? `${size} (the window's size)` : 'unknown';
150
+ }
151
+
152
+ // A page restored from the back/forward cache never fires its load events
153
+ // again, so back and forward wait only for the navigation, then briefly for
154
+ // the page, rather than time out on a page that is already there.
155
+ async function historyStep(go, direction) {
156
+ const before = state.page.url();
157
+ const response = await go();
158
+ if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
159
+ await state.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
106
160
  }
107
161
 
108
162
  const SCREENSHOT_DELAY_MAX = 60;
@@ -299,16 +353,24 @@ const WATCH_SCRIPT = `(() => {
299
353
  // An element holding an editable area would be named partly by what was typed there.
300
354
  const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
301
355
  const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
356
+ // A label's own words, without the options or values of the controls inside it.
357
+ const labelText = label => {
358
+ const copy = label.cloneNode(true);
359
+ copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
360
+ return copy.textContent;
361
+ };
302
362
  const nameOf = el => {
303
363
  const labelledBy = el.getAttribute('aria-labelledby');
304
364
  const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
305
365
  const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
306
- return clip(el.getAttribute('aria-label') || byId || (label && label !== el && label.innerText) || el.getAttribute('alt')
366
+ return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
307
367
  || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
308
368
  };
309
369
  const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
310
370
  // el null: the page itself, e.g. Escape with nothing focused.
311
371
  const send = (action, el, extra, since) => {
372
+ // Typing still waiting for its pause is recorded first, so the steps stay in order.
373
+ if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
312
374
  if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
313
375
  };
314
376
  document.addEventListener('click', e => {
@@ -666,28 +728,6 @@ async function allModesOff() {
666
728
  if (!any) out.log('No modes were on.');
667
729
  }
668
730
 
669
- // A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
670
- const REF = /^(?:f\d+)?e\d+$/;
671
-
672
- function toSelector(word) {
673
- return REF.test(word) ? `aria-ref=${word}` : word;
674
- }
675
-
676
- // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
677
- function unquote(text) {
678
- const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
679
- if (!match) return text;
680
- return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
681
- }
682
-
683
- // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
684
- function splitSelector(args) {
685
- const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
686
- if (!match) return null;
687
- const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
688
- return { word, selector: toSelector(word), rest: match[4] };
689
- }
690
-
691
731
  // Commands that take only a selector take the whole line, spaces and all.
692
732
  function soleSelector(args, usage) {
693
733
  const text = unquote((args || '').trim());
@@ -703,14 +743,19 @@ function selectorAndValue(args, usage, example) {
703
743
  return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
704
744
  }
705
745
 
706
- // A ref that no longer matches usually means the page changed since the snapshot.
746
+ // An element that never appeared is said plainly, not as Playwright's call
747
+ // log. A ref that no longer matches usually means the page changed since the
748
+ // snapshot it came from.
707
749
  async function onElement(selector, action) {
708
750
  try {
709
751
  return await action();
710
752
  } catch (error) {
711
- if (selector.startsWith('aria-ref=') && !/resolved to/.test(error.message)) {
712
- error.message += `\n${selector.slice(9)} is a snapshot ref; if the page changed since that snapshot, take a new one.`;
713
- }
753
+ if (/resolved to/.test(error.message)) throw error;
754
+ const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
755
+ const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
756
+ const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
757
+ if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
758
+ error.message += staleRef;
714
759
  throw error;
715
760
  }
716
761
  }
@@ -802,13 +847,56 @@ function discardCapture() {
802
847
  cap = null;
803
848
  }
804
849
 
805
- // A tab of the REPL's own: closing it can go back to the tab before.
850
+ // A tab of the REPL's own: closing it can go back to the tab before. It opens
851
+ // in the background, so it does not take the front of the window from the
852
+ // person using the browser; Playwright's newPage would bring it to the front.
853
+ const OPEN_TIMEOUT = 10000;
854
+
806
855
  async function openTab() {
807
- const opened = await state.browser.contexts()[0].newPage();
856
+ const ctx = state.browser.contexts()[0];
857
+ let opened = null;
858
+ const session = await state.browser.newBrowserCDPSession().catch(() => null);
859
+ if (session) {
860
+ try {
861
+ const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
862
+ opened = await pageForTarget(ctx, targetId);
863
+ } catch {} finally {
864
+ await session.detach().catch(() => {});
865
+ }
866
+ }
867
+ // A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
868
+ if (!opened) opened = await ctx.newPage();
808
869
  openedTabs.add(opened);
809
870
  return opened;
810
871
  }
811
872
 
873
+ // page -> its CDP target id, asked for once per page.
874
+ const targetIds = new WeakMap();
875
+
876
+ async function targetIdOf(ctx, p) {
877
+ if (!targetIds.has(p)) {
878
+ const cdp = await ctx.newCDPSession(p).catch(() => null);
879
+ if (!cdp) return null;
880
+ const info = await cdp.send('Target.getTargetInfo').catch(() => null);
881
+ await cdp.detach().catch(() => {});
882
+ if (!info) return null;
883
+ targetIds.set(p, info.targetInfo.targetId);
884
+ }
885
+ return targetIds.get(p);
886
+ }
887
+
888
+ async function pageForTarget(ctx, targetId) {
889
+ const deadline = Date.now() + OPEN_TIMEOUT;
890
+ while (Date.now() < deadline) {
891
+ for (const p of ctx.pages()) {
892
+ if (openedTabs.has(p)) continue;
893
+ if (await targetIdOf(ctx, p) === targetId) return p;
894
+ }
895
+ await new Promise(resolve => setTimeout(resolve, 50));
896
+ }
897
+ return null;
898
+ }
899
+
812
900
  // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
813
901
  async function listTabs() {
814
902
  const all = state.browser.contexts().flatMap(c => c.pages());
@@ -898,18 +986,20 @@ const commands = {
898
986
  async goto(args) {
899
987
  if (!args) throw new Error('Usage: goto <url>');
900
988
  let url = args;
901
- if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = 'https://' + url;
989
+ // As a browser's address bar does: a local dev server is almost always plain http.
990
+ const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
991
+ if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
902
992
  await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
903
993
  out.log(`${state.page.url()} — ${await state.page.title()}`);
904
994
  },
905
995
 
906
996
  async back() {
907
- await state.page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
997
+ await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
908
998
  out.log(`Back to: ${state.page.url()}`);
909
999
  },
910
1000
 
911
1001
  async forward() {
912
- await state.page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
1002
+ await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
913
1003
  out.log(`Forward to: ${state.page.url()}`);
914
1004
  },
915
1005
 
@@ -921,8 +1011,7 @@ const commands = {
921
1011
  async info() {
922
1012
  out.log(` URL: ${state.page.url()}`);
923
1013
  out.log(` Title: ${await state.page.title()}`);
924
- const vp = state.page.viewportSize();
925
- if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
1014
+ out.log(` Viewport: ${await viewportText()}`);
926
1015
  },
927
1016
 
928
1017
  async click(args) {
@@ -951,7 +1040,21 @@ const commands = {
951
1040
 
952
1041
  async type(args) {
953
1042
  const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
954
- await onElement(selector, () => state.page.type(selector, value, { timeout: 5000 }));
1043
+ // Typed after what the field already holds, as someone clicking into it
1044
+ // at the end would; focusing it alone leaves the caret at the start.
1045
+ await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
1046
+ el.focus();
1047
+ if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
1048
+ try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
1049
+ } else if (el.isContentEditable) {
1050
+ const range = document.createRange();
1051
+ range.selectNodeContents(el);
1052
+ range.collapse(false);
1053
+ getSelection().removeAllRanges();
1054
+ getSelection().addRange(range);
1055
+ }
1056
+ }, null, { timeout: 5000 }));
1057
+ await state.page.keyboard.type(value);
955
1058
  out.log(`Typed into: ${shown}`);
956
1059
  },
957
1060
 
@@ -1073,6 +1176,9 @@ const commands = {
1073
1176
  }
1074
1177
  // Named after the countdown so a default filename timestamps the capture.
1075
1178
  const filepath = nextScreenshotPath(name);
1179
+ // Chrome does not draw a tab that is not in front, and tabs open in the
1180
+ // background, so the tab is brought to the front for the shot.
1181
+ await state.page.bringToFront();
1076
1182
  const image = await state.page.screenshot({ fullPage: full });
1077
1183
  try {
1078
1184
  fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
@@ -1109,11 +1215,7 @@ const commands = {
1109
1215
  },
1110
1216
 
1111
1217
  async viewport(args) {
1112
- if (!args) {
1113
- const vp = state.page.viewportSize();
1114
- out.log(vp ? `${vp.width}x${vp.height}` : 'No viewport set');
1115
- return;
1116
- }
1218
+ if (!args) { out.log(await viewportText()); return; }
1117
1219
  const [w, h] = args.split('x').map(Number);
1118
1220
  if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
1119
1221
  await state.page.setViewportSize({ width: w, height: h });
@@ -1226,7 +1328,8 @@ const commands = {
1226
1328
  // The numbers skip what is hidden; say so, or they look like requests went missing.
1227
1329
  const first = matches[0].id;
1228
1330
  const last = matches[matches.length - 1].id;
1229
- const hidden = everything ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
1331
+ // With a filter, the numbers skip what does not match too, so the count would mislead.
1332
+ const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
1230
1333
  if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
1231
1334
  },
1232
1335
 
@@ -1247,7 +1350,8 @@ const commands = {
1247
1350
  const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
1248
1351
  let buffer;
1249
1352
  try { buffer = await Promise.race([entry.response.body(), late]); }
1250
- catch (error) { throw new Error(`The body of #${id} is not available: ${error.message}`); }
1353
+ // Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
1354
+ 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)`); }
1251
1355
  finally { clearTimeout(timer); }
1252
1356
  const type = entry.response.headers()['content-type'] || '';
1253
1357
  out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
@@ -1382,6 +1486,10 @@ const commands = {
1382
1486
  out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
1383
1487
  },
1384
1488
 
1489
+ async dialog(args) {
1490
+ out.log(await dialogCommand(args));
1491
+ },
1492
+
1385
1493
  async modes(args) {
1386
1494
  const arg = (args || '').trim();
1387
1495
  if (!arg) return listModes();
@@ -1427,6 +1535,7 @@ function complete(line) {
1427
1535
  if (command === 'tab') return match(['new', 'close']);
1428
1536
  if (command === 'network') return match(['on', 'off']);
1429
1537
  if (command === 'modes') return match(['off']);
1538
+ if (command === 'dialog') return match(['accept', 'dismiss']);
1430
1539
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
1431
1540
  if (command === 'capture') return match(['on', 'off']);
1432
1541
  if (command === 'route') return match(['off']);
@@ -1437,4 +1546,4 @@ function complete(line) {
1437
1546
  return [[], current];
1438
1547
  }
1439
1548
 
1440
- module.exports = { commands, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
1549
+ module.exports = { commands, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
package/lib/help.js CHANGED
@@ -63,8 +63,8 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
63
63
 
64
64
  Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
65
65
 
66
- Dialogs are reported and never answered automatically.`,
67
- commands: ['modes', 'help', 'quit'],
66
+ Dialogs are reported and never answered on their own; dialog answers one.`,
67
+ commands: ['modes', 'dialog', 'help', 'quit'],
68
68
  },
69
69
  };
70
70
 
@@ -72,9 +72,13 @@ const COMMANDS = {
72
72
  tab: {
73
73
  usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
74
74
  summary: 'list tabs (* is selected); select, open or close one',
75
- detail: 'tab <index> uses the numbers from the latest tab listing; they change when tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens and selects a tab. tab close closes the selected tab, or the one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
75
+ detail: 'tab <index> uses the numbers from the latest tab listing; they change when tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens a tab behind the one in front, so it does not take the window from whoever is using it,\nand selects it. tab close closes the selected tab, or the one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
76
+ },
77
+ goto: {
78
+ usage: 'goto <url>',
79
+ summary: 'navigate the selected tab',
80
+ detail: 'Without a scheme, http:// is assumed for localhost, 127.x and [::1], and https:// otherwise, as in\nthe address bar. tab new <url> reads its URL the same way.',
76
81
  },
77
- goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
78
82
  back: { usage: 'back', summary: 'go back one history entry' },
79
83
  forward: { usage: 'forward', summary: 'go forward one history entry' },
80
84
  reload: { usage: 'reload', summary: 'reload the selected tab' },
@@ -87,7 +91,7 @@ const COMMANDS = {
87
91
  summary: 'clear an input and fill it',
88
92
  detail: 'The selector is the first word; quote it if it has spaces. The rest of the line is the value, and\nquotes around all of it are removed, so fill #name "" clears the field.\n\nExamples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada',
89
93
  },
90
- type: { usage: 'type <selector> <text>', summary: 'type into an input key by key; the text is as for fill' },
94
+ type: { usage: 'type <selector> <text>', summary: 'type key by key after what the field holds; text as for fill' },
91
95
  press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A), optionally on an element' },
92
96
  select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
93
97
  check: { usage: 'check <selector>', summary: 'check a checkbox' },
@@ -112,7 +116,7 @@ const COMMANDS = {
112
116
  screenshot: {
113
117
  usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
114
118
  summary: 'save a PNG of the viewport (or --full page)',
115
- detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp).\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
119
+ detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
116
120
  },
117
121
  viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
118
122
  wait: {
@@ -139,7 +143,7 @@ const COMMANDS = {
139
143
  capture: {
140
144
  usage: 'capture on [requests|console] [secs] | off',
141
145
  summary: 'record requests and console together, in time order',
142
- detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints; the REPL waits meanwhile.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
146
+ detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints. Other commands wait until a timed\ncapture ends, so it suits recording what someone does in the browser; around your own commands, use\ncapture on and capture off.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
143
147
  },
144
148
  route: {
145
149
  usage: 'route <glob> <status> <json> | off <glob>|--all',
@@ -166,7 +170,12 @@ const COMMANDS = {
166
170
  modes: {
167
171
  usage: 'modes [off]',
168
172
  summary: 'the modes on in every tab; modes off turns them all off',
169
- detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
173
+ detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off <glob>|--all, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
174
+ },
175
+ dialog: {
176
+ usage: 'dialog [accept [text] | dismiss]',
177
+ summary: 'show, accept or dismiss an open alert, confirm or prompt',
178
+ detail: 'While a dialog is open, its page and the commands that read it wait. dialog runs at once, ahead\nof them; it answers the selected tab\'s dialog, or the only one open. accept text answers a prompt.\n\nWith nobody at the browser (e.g. headless), it is the only way to answer one.',
170
179
  },
171
180
  help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
172
181
  quit: {
package/lib/runner.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const readline = require('readline');
4
4
  const { state, beforeExit } = require('./state');
5
5
  const out = require('./output');
6
- const { commands, activeModes } = require('./commands');
6
+ const { commands, dialogCommand, activeModes } = require('./commands');
7
7
 
8
8
  // A timeout here cannot have changed anything, so it is an ordinary error. Any
9
9
  // other command that times out may or may not have done what it was sent to
@@ -12,7 +12,7 @@ const READ_ONLY = new Set(['info', 'text', 'html', 'attrs', 'count', 'visible',
12
12
  'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
13
13
 
14
14
  // Commands that work with no tab selected.
15
- const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'help', 'quit']);
15
+ const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit']);
16
16
 
17
17
  // Commands that accept a leading --all to lift the output limit.
18
18
  const INSPECTION = ['info', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
@@ -72,6 +72,8 @@ async function executeCommand(text) {
72
72
  try {
73
73
  await commands[cmd](args, all);
74
74
  } catch (e) {
75
+ // Playwright colours its call log; the codes are noise anywhere but a terminal.
76
+ e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
75
77
  out.error(`Error: ${e.message}`);
76
78
  const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
77
79
  if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
@@ -115,9 +117,10 @@ function prompt(preserveCursor) {
115
117
  state.rl.prompt(preserveCursor);
116
118
  }
117
119
 
118
- // quit skips the queue so it still works while a long command is running.
120
+ // quit and dialog skip the queue: quit so it works while a long command runs,
121
+ // dialog because the commands queued behind a dialog wait for its answer.
119
122
  function enqueue(line) {
120
- const direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?quit(?:\s|$)/.test(line.trim());
123
+ const direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?(?:quit|dialog)(?:\s|$)/.test(line.trim());
121
124
  const run = () => handleLine(line);
122
125
  if (direct) run().catch(e => out.error(`Error: ${e.message}`));
123
126
  else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
@@ -126,6 +129,7 @@ function enqueue(line) {
126
129
  // Server commands share the prompt's queue and are echoed to the pane, so
127
130
  // someone watching sees everything an agent does.
128
131
  function submit(text) {
132
+ if (/^dialog(?:\s|$)/.test(text)) return answerDialog(text);
129
133
  return new Promise(resolve => {
130
134
  queue = queue.then(async () => {
131
135
  if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
@@ -154,6 +158,18 @@ function submit(text) {
154
158
  });
155
159
  }
156
160
 
161
+ // Outside the queue, and so outside out.collect, whose single sink belongs to
162
+ // the command that is waiting on the dialog.
163
+ async function answerDialog(text) {
164
+ console.log(`[server] ${text}`);
165
+ let result;
166
+ try { result = { status: 'ok', output: await dialogCommand(text.slice(6)) }; }
167
+ catch (error) { result = { status: 'error', output: `Error: ${error.message}` }; }
168
+ console.log(result.output);
169
+ if (!state.stopping) prompt(true);
170
+ return result;
171
+ }
172
+
157
173
  function drained() {
158
174
  return queue;
159
175
  }
package/lib/send.js CHANGED
@@ -18,14 +18,16 @@ function hasSession(session) {
18
18
  try { tmux('has-session', '-t', session); return true; } catch { return false; }
19
19
  }
20
20
 
21
- // Which way a command goes: an explicit -e or -s wins; otherwise the server
22
- // when its socket exists, else tmux.
21
+ // Which way a command goes: an explicit -e or -s wins, and so does a socket
22
+ // named in PW_SOCKET, even one that is gone (a tmux pane is then someone
23
+ // else's); otherwise the server when the default socket exists, else tmux.
23
24
  function route(options) {
24
25
  const socket = process.env.PW_SOCKET || client.DEFAULT_SOCKET;
25
26
  const endpoint = options.endpoint || process.env.PW_ENDPOINT || '';
26
27
  const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
27
28
  if (options.session) return { kind: 'tmux', session };
28
29
  if (endpoint) return { kind: 'server', endpoint: client.parseEndpoint(endpoint), explicit: true };
30
+ if (process.env.PW_SOCKET) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: true };
29
31
  if (fs.existsSync(socket) && fs.statSync(socket).isSocket()) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: false, socket };
30
32
  return { kind: 'tmux', session };
31
33
  }
@@ -110,6 +112,10 @@ async function send(options) {
110
112
  // Reports the route a command would take, checking it the way a command would.
111
113
  async function where(options) {
112
114
  const way = route(options);
115
+ // Whether the browser answers, so a REPL that will not start can be told apart from one that is not running.
116
+ const cdp = process.env.PW_CDP_URL || 'http://localhost:9222';
117
+ const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
118
+ console.log(version ? `browser: ${cdp} answers (${version.Browser})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
113
119
  if (way.kind === 'server') {
114
120
  const name = client.describe(way.endpoint);
115
121
  if (await client.health(way.endpoint, 5000)) {
@@ -122,10 +128,15 @@ async function where(options) {
122
128
  if (way.explicit) return 64;
123
129
  }
124
130
  const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
125
- if (!hasSession(session)) { console.error(`tmux: no session '${session}'`); return 64; }
131
+ if (!hasSession(session)) {
132
+ console.error(`tmux: no session '${session}'`);
133
+ console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
134
+ return 64;
135
+ }
126
136
  const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
127
137
  if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
128
138
  console.error(`tmux: session '${session}' is running '${running}', not the REPL`);
139
+ console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
129
140
  return 64;
130
141
  }
131
142
 
package/lib/start.js CHANGED
@@ -7,11 +7,28 @@ const { listTabs, openTab, watchPage, complete } = require('./commands');
7
7
  const runner = require('./runner');
8
8
 
9
9
  const CDP_URL = process.env.PW_CDP_URL || 'http://localhost:9222';
10
+ const CONNECT_TIMEOUT = 15000;
11
+
12
+ // What to do about a browser that cannot be reached, rather than the bare socket error.
13
+ function connectHelp(error) {
14
+ const reason = String(error.message).split('\n')[0].replace(/^browserType\.connectOverCDP: /, '');
15
+ if (/timeout/i.test(reason)) {
16
+ return `Chromium at ${CDP_URL} answered but did not finish attaching within ${CONNECT_TIMEOUT / 1000}s. A tab with a\ndialog open (alert, confirm) holds this up: answer it or close that tab, then try again.`;
17
+ }
18
+ return `No browser answered at ${CDP_URL} (${reason}).
19
+
20
+ pw-repl needs a Chromium-based browser (Chrome, Chromium, Edge, ...) started with remote debugging:
21
+ chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
22
+ chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
23
+ It needs a --user-data-dir of its own: Chrome does not open the port on its default profile.
24
+ curl ${CDP_URL}/json/version checks that it answers; PW_CDP_URL points at a browser elsewhere.`;
25
+ }
10
26
 
11
27
  async function start(options) {
12
28
  const START_URL = options.startUrl || process.env.PW_START_URL || null;
13
29
  out.log(`Connecting to ${CDP_URL}...`);
14
- state.browser = await chromium.connectOverCDP(CDP_URL);
30
+ try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
31
+ catch (error) { throw new Error(connectHelp(error)); }
15
32
  if (state.stopping) {
16
33
  try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
17
34
  catch (error) { process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
package/lib/syntax.js ADDED
@@ -0,0 +1,29 @@
1
+ // How a command line's words are read, shared by the REPL and pw-repl send.
2
+
3
+ // Commands whose first word is a selector, quoted if it has spaces, and whose
4
+ // value is the rest of the line. Other commands take the rest of the line as it is.
5
+ const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press']);
6
+
7
+ // A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
8
+ const REF = /^(?:f\d+)?e\d+$/;
9
+
10
+ function toSelector(word) {
11
+ return REF.test(word) ? `aria-ref=${word}` : word;
12
+ }
13
+
14
+ // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
15
+ function unquote(text) {
16
+ const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
17
+ if (!match) return text;
18
+ return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
19
+ }
20
+
21
+ // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
22
+ function splitSelector(args) {
23
+ const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
24
+ if (!match) return null;
25
+ const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
26
+ return { word, selector: toSelector(word), rest: match[4] };
27
+ }
28
+
29
+ module.exports = { SELECTOR_FIRST, toSelector, unquote, splitSelector };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -11,6 +11,12 @@ shared session: a person can have their own tabs open in it and be using it whil
11
11
  `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install,
12
12
  `npx pw-repl` works the same; from a clone, `<clone>/bin/pw-repl.js`.
13
13
 
14
+ ## Arguments
15
+
16
+ Freeform args describe what to do in the browser: a page to look at, a flow to try, a failure to
17
+ reproduce (e.g. `pw-repl find out why the cart total shows 0 after adding an item`). They are the task;
18
+ the sections below are how to carry it out. With no args, get a REPL running and ask what to do.
19
+
14
20
  ## Start it
15
21
 
16
22
  `pw-repl where` says whether a REPL is running and how `send` reaches it. There are three ways to run
@@ -29,8 +35,18 @@ one; each takes an optional start URL, which opens in a new tab.
29
35
  tmux send-keys -t playwright-repl Enter
30
36
  ```
31
37
 
32
- `serve <port>` listens on TCP 127.0.0.1 instead of the socket, with no access control. A REPL in a
33
- terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
38
+ `serve` and `serve --background` take a socket path of your own instead of the default
39
+ (`pw-repl serve --background /tmp/mine.sock`); `send`, `attach`, `stop` and `where` then need
40
+ `-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`. The log of a background REPL is next to its socket
41
+ (`/tmp/mine.log`); `tail -f` on it follows along without a terminal to attach from. `serve <port>`
42
+ listens on TCP 127.0.0.1 instead of a socket, with no access control.
43
+
44
+ Several REPLs can run at once, each on its own socket, e.g. one per agent. Each has its own selected tab,
45
+ command queue and modes, so they do not wait on or select for each other. They share the browser,
46
+ though: each sees every tab, `modes` lists only its own REPL's modes, and two REPLs acting on the same
47
+ tab can undo each other's routes or network setting.
48
+
49
+ A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
34
50
 
35
51
  ## Send commands
36
52
 
@@ -40,12 +56,13 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
40
56
  ```
41
57
 
42
58
  Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
43
- are the command, and a word quoted in your shell stays one word:
44
- `pw-repl send fill "text=Your name" Ada`. It works however the REPL was started:
59
+ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your shell stays one word
60
+ (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are
61
+ (`pw-repl send eval "document.title + ' x'"`). It works however the REPL was started:
45
62
 
46
63
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
47
64
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
48
- of what the user is typing; while a command is running, the user is typing, or the REPL is exiting, it
65
+ of what someone is typing; while a command is running, someone is typing, or the REPL is exiting, it
49
66
  refuses (exit 64). Wait and retry.
50
67
  - `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
51
68
  as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
@@ -55,14 +72,13 @@ are the command, and a word quoted in your shell stays one word:
55
72
  REPL), or why neither is reachable, without running anything.
56
73
 
57
74
  Every command you run and its output show in the REPL's pane, or in `attach` and the log for a
58
- background REPL (server commands as `[server]` lines), so the user sees what you do. The pane shows REPL
59
- commands only, not what the user clicked in the browser (unless `watch on --live` is on); for that, look
60
- at the browser itself: `tab` and `info` for where they are, `requests` for the requests their clicks
61
- made (`body <#>` for what one returned), `console` for console messages and page errors. When the user
62
- wants to show you what they do, `watch on` on their tab records each step with the requests it caused
63
- (`watch on --changes` adds what each step changed on the page); `watch` reads it back, and `watch new`
64
- only what it has not shown yet. Watching and reading are fine on the user's tabs; the rule below is
65
- about acting on them.
75
+ background REPL (server commands as `[server]` lines), so whoever looks there sees what you do. The pane
76
+ shows REPL commands only, not what was clicked in the browser (unless `watch on --live` is on); for
77
+ that, look at the browser itself: `tab` and `info` for where things are, `requests` for the requests the
78
+ clicks made (`body <#>` for what one returned), `console` for console messages and page errors. `watch
79
+ on` records each step someone takes in a tab, with the requests it caused (`watch on --changes` adds
80
+ what each step changed on the page); `watch` reads it back, and `watch new` only what it has not shown
81
+ yet.
66
82
 
67
83
  Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
68
84
  blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
@@ -73,23 +89,26 @@ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their command
73
89
  gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
74
90
  help is the command reference; this file does not repeat it.
75
91
 
76
- ## Shared-browser rules
77
-
78
- - Act only on tabs you opened (`tab new`), unless the user asks you to act on theirs (e.g. a `route` in
79
- their tab while they test); then say what you are doing and undo it the moment you are done. No tab
80
- is selected when the REPL starts (unless it was given a start URL); `tab` lists them. Closing your
81
- tab goes back only to a tab you opened; otherwise no tab is selected. Tab numbers change when tabs
82
- open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by its URL and refuse if it is
83
- ambiguous.
84
- - Dialogs are never answered automatically. The person at the browser handles them.
85
- - Before leaving: turn off the modes you turned on (`modes` lists what is on in every tab; the prompt
86
- shows the selected tab's, e.g. `(watch routes:1) pw>`), and close the tabs you opened. `modes off`
87
- turns off everything, including modes the user turned on, so use it only when they are all yours.
88
- - The tmux session may be attached by the user. Never kill it.
92
+ ## Sharing the browser
93
+
94
+ - The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
95
+ one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
96
+ clear, ask.
97
+ - No tab is selected when the REPL starts (unless it was given a start URL). Closing a tab the REPL
98
+ opened goes back to the tab before it, if the REPL opened that one too; otherwise no tab is selected.
99
+ Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
100
+ its URL and refuse if it is ambiguous.
101
+ - Dialogs are never answered on their own. While one is open, its page and the commands that read it
102
+ wait; `dialog` shows it, and `dialog accept` or `dialog dismiss` answers it.
103
+ - Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
104
+ lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
105
+ `modes off` turns off every mode in every tab.
106
+ - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
89
107
 
90
108
  ## Environment
91
109
 
92
- - Chromium must be running with `--remote-debugging-port=9222`.
110
+ - Chromium must be running with `--remote-debugging-port=9222`. A headless one works too
111
+ (`--headless=new`); nobody answers its dialogs but `dialog`.
93
112
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
94
113
  - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
95
114
  so `rm /tmp/screenshot-*.png` cleans up.