pw-repl 0.2.1 → 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
@@ -73,7 +73,8 @@ function parseSendArgs(args, allowCommand) {
73
73
  // words are joined as they are.
74
74
  const { SELECTOR_FIRST } = require('../lib/syntax');
75
75
  const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
76
- const quoted = requote ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
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;
77
78
  options.command = quoted.join(' ').trim();
78
79
  return options;
79
80
  }
@@ -119,7 +120,8 @@ async function main() {
119
120
  case 'stop': {
120
121
  const options = parseSendArgs(args, false);
121
122
  if (options.session) usage();
122
- 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;
123
125
  process.exit(await require('../lib/background')[subcommand]({ endpoint }));
124
126
  }
125
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
@@ -97,13 +97,66 @@ function hints(pairs) {
97
97
 
98
98
  const dialogPages = new WeakSet();
99
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
+
100
104
  function ensureDialogHandler(p) {
101
105
  if (dialogPages.has(p)) return;
102
106
  dialogPages.add(p);
103
107
  p.on('dialog', dialog => {
108
+ openDialogs.set(p, dialog);
104
109
  out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
105
- 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.');
106
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(() => {});
107
160
  }
108
161
 
109
162
  const SCREENSHOT_DELAY_MAX = 60;
@@ -300,16 +353,24 @@ const WATCH_SCRIPT = `(() => {
300
353
  // An element holding an editable area would be named partly by what was typed there.
301
354
  const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
302
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
+ };
303
362
  const nameOf = el => {
304
363
  const labelledBy = el.getAttribute('aria-labelledby');
305
364
  const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
306
365
  const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
307
- 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')
308
367
  || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
309
368
  };
310
369
  const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
311
370
  // el null: the page itself, e.g. Escape with nothing focused.
312
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);
313
374
  if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
314
375
  };
315
376
  document.addEventListener('click', e => {
@@ -925,18 +986,20 @@ const commands = {
925
986
  async goto(args) {
926
987
  if (!args) throw new Error('Usage: goto <url>');
927
988
  let url = args;
928
- 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}`;
929
992
  await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
930
993
  out.log(`${state.page.url()} — ${await state.page.title()}`);
931
994
  },
932
995
 
933
996
  async back() {
934
- await state.page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
997
+ await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
935
998
  out.log(`Back to: ${state.page.url()}`);
936
999
  },
937
1000
 
938
1001
  async forward() {
939
- await state.page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
1002
+ await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
940
1003
  out.log(`Forward to: ${state.page.url()}`);
941
1004
  },
942
1005
 
@@ -948,8 +1011,7 @@ const commands = {
948
1011
  async info() {
949
1012
  out.log(` URL: ${state.page.url()}`);
950
1013
  out.log(` Title: ${await state.page.title()}`);
951
- const vp = state.page.viewportSize();
952
- if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
1014
+ out.log(` Viewport: ${await viewportText()}`);
953
1015
  },
954
1016
 
955
1017
  async click(args) {
@@ -978,7 +1040,21 @@ const commands = {
978
1040
 
979
1041
  async type(args) {
980
1042
  const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
981
- 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);
982
1058
  out.log(`Typed into: ${shown}`);
983
1059
  },
984
1060
 
@@ -1139,11 +1215,7 @@ const commands = {
1139
1215
  },
1140
1216
 
1141
1217
  async viewport(args) {
1142
- if (!args) {
1143
- const vp = state.page.viewportSize();
1144
- out.log(vp ? `${vp.width}x${vp.height}` : 'No viewport set');
1145
- return;
1146
- }
1218
+ if (!args) { out.log(await viewportText()); return; }
1147
1219
  const [w, h] = args.split('x').map(Number);
1148
1220
  if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
1149
1221
  await state.page.setViewportSize({ width: w, height: h });
@@ -1414,6 +1486,10 @@ const commands = {
1414
1486
  out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
1415
1487
  },
1416
1488
 
1489
+ async dialog(args) {
1490
+ out.log(await dialogCommand(args));
1491
+ },
1492
+
1417
1493
  async modes(args) {
1418
1494
  const arg = (args || '').trim();
1419
1495
  if (!arg) return listModes();
@@ -1459,6 +1535,7 @@ function complete(line) {
1459
1535
  if (command === 'tab') return match(['new', 'close']);
1460
1536
  if (command === 'network') return match(['on', 'off']);
1461
1537
  if (command === 'modes') return match(['off']);
1538
+ if (command === 'dialog') return match(['accept', 'dismiss']);
1462
1539
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
1463
1540
  if (command === 'capture') return match(['on', 'off']);
1464
1541
  if (command === 'route') return match(['off']);
@@ -1469,4 +1546,4 @@ function complete(line) {
1469
1546
  return [[], current];
1470
1547
  }
1471
1548
 
1472
- 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
 
@@ -74,7 +74,11 @@ const COMMANDS = {
74
74
  summary: 'list tabs (* is selected); select, open or close one',
75
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
76
  },
77
- goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
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.',
81
+ },
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' },
@@ -168,6 +172,11 @@ const COMMANDS = {
168
172
  summary: 'the modes on in every tab; modes off turns them all off',
169
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.',
170
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.',
179
+ },
171
180
  help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
172
181
  quit: {
173
182
  usage: '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'];
@@ -117,9 +117,10 @@ function prompt(preserveCursor) {
117
117
  state.rl.prompt(preserveCursor);
118
118
  }
119
119
 
120
- // 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.
121
122
  function enqueue(line) {
122
- 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());
123
124
  const run = () => handleLine(line);
124
125
  if (direct) run().catch(e => out.error(`Error: ${e.message}`));
125
126
  else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
@@ -128,6 +129,7 @@ function enqueue(line) {
128
129
  // Server commands share the prompt's queue and are echoed to the pane, so
129
130
  // someone watching sees everything an agent does.
130
131
  function submit(text) {
132
+ if (/^dialog(?:\s|$)/.test(text)) return answerDialog(text);
131
133
  return new Promise(resolve => {
132
134
  queue = queue.then(async () => {
133
135
  if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
@@ -156,6 +158,18 @@ function submit(text) {
156
158
  });
157
159
  }
158
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
+
159
173
  function drained() {
160
174
  return queue;
161
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)) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.2.1",
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
@@ -62,7 +62,7 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
62
62
 
63
63
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
64
64
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
65
- 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
66
66
  refuses (exit 64). Wait and retry.
67
67
  - `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
68
68
  as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
@@ -72,14 +72,13 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
72
72
  REPL), or why neither is reachable, without running anything.
73
73
 
74
74
  Every command you run and its output show in the REPL's pane, or in `attach` and the log for a
75
- background REPL (server commands as `[server]` lines), so the user sees what you do. The pane shows REPL
76
- commands only, not what the user clicked in the browser (unless `watch on --live` is on); for that, look
77
- at the browser itself: `tab` and `info` for where they are, `requests` for the requests their clicks
78
- made (`body <#>` for what one returned), `console` for console messages and page errors. When the user
79
- wants to show you what they do, `watch on` on their tab records each step with the requests it caused
80
- (`watch on --changes` adds what each step changed on the page); `watch` reads it back, and `watch new`
81
- only what it has not shown yet. Watching and reading are fine on the user's tabs; the rule below is
82
- 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.
83
82
 
84
83
  Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
85
84
  blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
@@ -90,23 +89,26 @@ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their command
90
89
  gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
91
90
  help is the command reference; this file does not repeat it.
92
91
 
93
- ## Shared-browser rules
94
-
95
- - Act only on tabs you opened (`tab new`), unless the user asks you to act on theirs (e.g. a `route` in
96
- their tab while they test); then say what you are doing and undo it the moment you are done. No tab
97
- is selected when the REPL starts (unless it was given a start URL); `tab` lists them. Closing your
98
- tab goes back only to a tab you opened; otherwise no tab is selected. Tab numbers change when tabs
99
- open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by its URL and refuse if it is
100
- ambiguous.
101
- - Dialogs are never answered automatically. The person at the browser handles them.
102
- - Before leaving: turn off the modes you turned on (`modes` lists what is on in every tab; the prompt
103
- shows the selected tab's, e.g. `(watch routes:1) pw>`), and close the tabs you opened. `modes off`
104
- turns off everything, including modes the user turned on, so use it only when they are all yours.
105
- - 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.
106
107
 
107
108
  ## Environment
108
109
 
109
- - 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`.
110
112
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
111
113
  - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
112
114
  so `rm /tmp/screenshot-*.png` cleans up.