pw-repl 0.2.1 → 0.3.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/README.md CHANGED
@@ -21,6 +21,21 @@ 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
+ **No browser to hand, or one you would rather not share?** Skip this step: `pw-repl run --launch` (or
25
+ `serve --launch`) starts a private headless Chromium for the REPL and stops it with the REPL. It prints the
26
+ command it ran, so you can start one your own way instead. If there is no Chromium at all,
27
+ `npx playwright-core install chromium` downloads Playwright's.
28
+
29
+ **Headless** works the same way:
30
+
31
+ ```bash
32
+ chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
33
+ ```
34
+
35
+ With nobody at the browser, `dialog accept` or `dialog dismiss` answers a dialog (`alert`, `confirm`);
36
+ the REPL never answers one on its own. Pages start at about 800x600; `viewport 1280x800` sets another
37
+ size.
38
+
24
39
  ### 2. Install and start the REPL
25
40
 
26
41
  ```bash
@@ -51,18 +66,19 @@ Commands act on the selected tab (`tab` lists the tabs, with `*` on the selected
51
66
 
52
67
  ## Common tasks
53
68
 
54
- | I want to… | Commands |
55
- | ------------------------------------ | --------------------------------------------------------------- |
56
- | see where I am | `tab`, `info` |
57
- | see what is on the page | `snapshot`, `screenshot` |
58
- | do something on it | `click`, `fill`, `press` |
59
- | see what the page requested | `requests`, then `body <#>` for what one got back |
60
- | see console messages and errors | `console` |
61
- | show an agent what I do | `watch on`, click around in the browser, then `watch` |
62
- | see each step as I click | `watch on --live` |
63
- | record requests and console together | `capture on`, then `capture off` |
64
- | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `network off` |
65
- | clean up | `modes off` |
69
+ | I want to… | Commands |
70
+ | ------------------------------------ | ------------------------------------------------------------------------------------- |
71
+ | see where I am | `tab`, `info` |
72
+ | see what is on the page | `snapshot`, `screenshot` |
73
+ | do something on it | `click`, `fill`, `press` |
74
+ | see what the page requested | `requests`, then `body <#>` for what one got back |
75
+ | see console messages and errors | `console` |
76
+ | show an agent what I do | `watch on`, click around in the browser, then `watch` |
77
+ | see each step as I click | `watch on --live` |
78
+ | record requests and console together | `capture on`, then `capture off` |
79
+ | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
80
+ | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
81
+ | clean up | `modes off` |
66
82
 
67
83
  Everything else is in `help <topic>`; `help <command>` has usage and caveats.
68
84
 
package/bin/pw-repl.js CHANGED
@@ -4,10 +4,10 @@
4
4
  const { looksLikeEndpoint } = require('../lib/client');
5
5
 
6
6
  const USAGE = `Usage:
7
- pw-repl run [start-url]
7
+ pw-repl run [--launch [--headed]] [start-url] [-- <chromium flags>]
8
8
  connect to the browser and open the prompt
9
9
 
10
- pw-repl serve [--background] [endpoint] [start-url]
10
+ pw-repl serve [--background] [--launch [--headed]] [endpoint] [start-url] [-- <chromium flags>]
11
11
  the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock);
12
12
  --background runs it detached, with its output in a log next to the socket
13
13
 
@@ -37,6 +37,11 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
37
37
 
38
38
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
39
39
 
40
+ --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
41
+ a temporary profile), prints the command it ran, and stops it with the REPL. Flags after -- go to that
42
+ Chromium; PW_CHROME picks which one. To set a browser up your own way, start it yourself and use
43
+ PW_CDP_URL.
44
+
40
45
  In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
41
46
 
42
47
  const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
@@ -73,15 +78,23 @@ function parseSendArgs(args, allowCommand) {
73
78
  // words are joined as they are.
74
79
  const { SELECTOR_FIRST } = require('../lib/syntax');
75
80
  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;
81
+ // An empty word (fill #name "") is the empty value.
82
+ const quoted = requote ? words.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
77
83
  options.command = quoted.join(' ').trim();
78
84
  return options;
79
85
  }
80
86
 
81
87
  function startOptions(args, serve) {
82
- const options = { serve, endpoint: null, startUrl: null, background: false, pidFile: process.env.PW_REPL_PID_FILE || null };
83
- const rest = [...args];
84
- if (serve && rest.includes('--background')) { options.background = true; rest.splice(rest.indexOf('--background'), 1); }
88
+ const options = { serve, endpoint: null, startUrl: null, background: false, launch: false, headed: false, chromeArgs: [], pidFile: process.env.PW_REPL_PID_FILE || null };
89
+ // Whatever follows -- is for the Chromium that --launch starts.
90
+ const split = args.indexOf('--');
91
+ const rest = split === -1 ? [...args] : args.slice(0, split);
92
+ if (split !== -1) options.chromeArgs = args.slice(split + 1);
93
+ const flag = name => { const i = rest.indexOf(name); if (i === -1) return false; rest.splice(i, 1); return true; };
94
+ if (serve) options.background = flag('--background');
95
+ options.launch = flag('--launch');
96
+ options.headed = flag('--headed');
97
+ if ((options.headed || options.chromeArgs.length) && !options.launch) usage();
85
98
  if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
86
99
  if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
87
100
  options.startUrl = rest[0] || null;
@@ -119,7 +132,8 @@ async function main() {
119
132
  case 'stop': {
120
133
  const options = parseSendArgs(args, false);
121
134
  if (options.session) usage();
122
- const endpoint = options.endpoint || process.env.PW_ENDPOINT || null;
135
+ // The same socket send would use when there is no -e: PW_ENDPOINT, then PW_SOCKET.
136
+ const endpoint = options.endpoint || process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
123
137
  process.exit(await require('../lib/background')[subcommand]({ endpoint }));
124
138
  }
125
139
  // falls through never: process.exit above
package/lib/background.js CHANGED
@@ -64,7 +64,9 @@ async function start(options) {
64
64
  if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
65
65
  const log = fs.openSync(logFile, 'w', 0o600);
66
66
  fs.fchmodSync(log, 0o600);
67
- const args = [BIN, 'serve', ...(options.endpoint ? [options.endpoint] : []), ...(options.startUrl ? [options.startUrl] : [])];
67
+ const args = [BIN, 'serve', ...(options.launch ? ['--launch'] : []), ...(options.headed ? ['--headed'] : []),
68
+ ...(options.endpoint ? [options.endpoint] : []), ...(options.startUrl ? [options.startUrl] : []),
69
+ ...(options.chromeArgs.length ? ['--', ...options.chromeArgs] : [])];
68
70
  const child = spawn(process.execPath, args, {
69
71
  detached: true,
70
72
  stdio: ['ignore', log, log],
@@ -79,7 +81,7 @@ async function start(options) {
79
81
  }
80
82
  if (exited || !runningPid(pidFile)) {
81
83
  if (!exited) child.kill();
82
- const last = tail(logFile, 10);
84
+ const last = tail(logFile, 12);
83
85
  return fail(`the background REPL did not start${last ? `; the end of ${logFile}:\n${last}` : ''}`);
84
86
  }
85
87
  child.unref();
@@ -103,7 +105,7 @@ async function stop(options) {
103
105
  const deadline = Date.now() + STOP_TIMEOUT;
104
106
  while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
105
107
  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}`);
108
+ console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${filesFor(endpoint).logFile}`);
107
109
  return 0;
108
110
  }
109
111
 
package/lib/client.js CHANGED
@@ -32,17 +32,27 @@ function target(endpoint) {
32
32
  }
33
33
 
34
34
  // GET /health: does not go through the command queue or show in the pane.
35
- function health(endpoint, timeoutMs) {
35
+ // Resolves to what the REPL says about itself (e.g. its browser), or null.
36
+ function healthInfo(endpoint, timeoutMs) {
36
37
  return new Promise(resolve => {
37
38
  const req = http.get({ ...target(endpoint), path: '/health', timeout: timeoutMs }, res => {
38
- res.resume();
39
- resolve(res.statusCode === 200);
39
+ let text = '';
40
+ res.setEncoding('utf8');
41
+ res.on('data', chunk => { text += chunk; });
42
+ res.on('end', () => {
43
+ if (res.statusCode !== 200) return resolve(null);
44
+ try { resolve(JSON.parse(text)); } catch { resolve({}); }
45
+ });
40
46
  });
41
- req.on('timeout', () => { req.destroy(); resolve(false); });
42
- req.on('error', () => resolve(false));
47
+ req.on('timeout', () => { req.destroy(); resolve(null); });
48
+ req.on('error', () => resolve(null));
43
49
  });
44
50
  }
45
51
 
52
+ async function health(endpoint, timeoutMs) {
53
+ return (await healthInfo(endpoint, timeoutMs)) !== null;
54
+ }
55
+
46
56
  // One command. Resolves to { result }, { timeout: true }, { dropped: reason }
47
57
  // (the connection closed after the command was sent, so it may have run) or
48
58
  // { unreachable: reason } (it was never sent).
@@ -75,4 +85,4 @@ function request(endpoint, command, timeoutMs) {
75
85
  });
76
86
  }
77
87
 
78
- module.exports = { DEFAULT_SOCKET, parseEndpoint, looksLikeEndpoint, describe, health, request };
88
+ module.exports = { DEFAULT_SOCKET, parseEndpoint, looksLikeEndpoint, describe, health, healthInfo, request };
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;
@@ -144,23 +197,108 @@ function routesFor(p) {
144
197
  return pageRoutes.get(p);
145
198
  }
146
199
 
200
+ const ROUTE_DELAY_MAX = 120;
201
+
202
+ // Marks a request a route answered, now: the browser reports it finished only later.
203
+ function markChanged(req, status, how) {
204
+ changedRequests.set(req, how);
205
+ const entry = requestEntries.get(req);
206
+ if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
207
+ }
208
+
209
+ // A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
210
+ function mergePatch(target, patch) {
211
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
212
+ const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
213
+ for (const [key, value] of Object.entries(patch)) {
214
+ if (value === null) delete result[key];
215
+ else result[key] = mergePatch(result[key], value);
216
+ }
217
+ return result;
218
+ }
219
+
220
+ function parseJson(text, what) {
221
+ try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
222
+ }
223
+
224
+ // How a route treats the requests it matches: its text for listing, and what it does to each one.
225
+ function routeKind(how, rest, usage) {
226
+ if (/^\d{3}$/.test(how)) {
227
+ const status = Number(how);
228
+ if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
229
+ if (!rest) throw new Error(usage);
230
+ parseJson(rest, 'Body');
231
+ const preview = rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest;
232
+ return {
233
+ text: `${status} ${preview}`,
234
+ async handle(r, req, tag) {
235
+ markChanged(req, status, 'faked');
236
+ await r.fulfill({ status, contentType: 'application/json', body: rest });
237
+ out.notice(`Faked: ${tag()} -> ${status}`);
238
+ },
239
+ };
240
+ }
241
+ if (how === 'patch') {
242
+ if (!rest) throw new Error(usage);
243
+ const patch = parseJson(rest, 'Patch');
244
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
245
+ return {
246
+ text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
247
+ async handle(r, req, tag) {
248
+ const response = await r.fetch();
249
+ let body;
250
+ try { body = await response.json(); } catch {
251
+ await r.fulfill({ response });
252
+ out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
253
+ return;
254
+ }
255
+ markChanged(req, response.status(), 'patched');
256
+ await r.fulfill({ response, json: mergePatch(body, patch) });
257
+ out.notice(`Patched: ${tag()} -> ${response.status()}`);
258
+ },
259
+ };
260
+ }
261
+ if (how === 'delay') {
262
+ const seconds = Number(rest);
263
+ if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
264
+ return {
265
+ text: `delay ${seconds}s`,
266
+ async handle(r, req, tag) {
267
+ await new Promise(resolve => setTimeout(resolve, seconds * 1000));
268
+ await r.continue();
269
+ out.notice(`Delayed ${seconds}s: ${tag()}`);
270
+ },
271
+ };
272
+ }
273
+ if (how === 'abort' && !rest) {
274
+ return {
275
+ text: 'abort',
276
+ async handle(r, req, tag) {
277
+ await r.abort('failed');
278
+ out.notice(`Aborted: ${tag()}`);
279
+ },
280
+ };
281
+ }
282
+ throw new Error(usage);
283
+ }
284
+
147
285
  function listRoutes() {
148
286
  const routes = routesFor(state.page);
287
+ const forms = [
288
+ ['route <url-glob> <status> <json-body>', 'answer it with this status and JSON'],
289
+ ['route <url-glob> patch <json>', 'let it through, then change its JSON'],
290
+ ['route <url-glob> delay <seconds>', 'hold it, then let it through'],
291
+ ['route <url-glob> abort', 'fail it as if the connection broke'],
292
+ ];
149
293
  if (!routes.size) {
150
- out.log('No fake responses on the selected tab.');
151
- out.log(hints([['route <url-glob> <status> <json-body>', 'add one']]));
294
+ out.log('No routes on the selected tab.');
295
+ out.log(hints(forms));
152
296
  return;
153
297
  }
154
- out.log(`Fake responses on the selected tab (${routes.size}):`);
298
+ out.log(`Routes on the selected tab (${routes.size}):`);
155
299
  const width = Math.max(...[...routes.keys()].map(glob => glob.length));
156
- for (const [glob, { status, body }] of routes) {
157
- const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
158
- out.log(` ${glob.padEnd(width)} ${status} ${preview}`);
159
- }
160
- out.log(hints([
161
- ['route <url-glob> <status> <json-body>', 'add one, or replace the one for that glob'],
162
- ['route off <url-glob> | route off --all', 'remove'],
163
- ]));
300
+ for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
301
+ out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
164
302
  }
165
303
 
166
304
  async function removeRoutes(glob) {
@@ -169,7 +307,7 @@ async function removeRoutes(glob) {
169
307
  if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
170
308
  const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
171
309
  for (const g of removed) out.log(`Removed: ${g}`);
172
- if (!removed.length) out.log('No fake responses on the selected tab');
310
+ if (!removed.length) out.log('No routes on the selected tab');
173
311
  }
174
312
 
175
313
  async function unroute(p, globs) {
@@ -193,7 +331,8 @@ const BODY_TIMEOUT = 15000;
193
331
  const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
194
332
  const recentLogs = new WeakMap();
195
333
  const consoleLogs = new WeakMap();
196
- const fakedRequests = new WeakSet();
334
+ // request -> how a route changed it (faked or patched), shown after its status.
335
+ const changedRequests = new WeakMap();
197
336
  const requestEntries = new WeakMap();
198
337
  // Tabs this REPL opened with tab new.
199
338
  const openedTabs = new WeakSet();
@@ -240,7 +379,7 @@ function ensureRecentLog(p) {
240
379
  res = await req.response();
241
380
  if (res) status = String(res.status());
242
381
  } catch {}
243
- finish(req, fakedRequests.has(req) ? `${status} faked` : status, res);
382
+ finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
244
383
  });
245
384
  p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
246
385
  p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
@@ -300,16 +439,24 @@ const WATCH_SCRIPT = `(() => {
300
439
  // An element holding an editable area would be named partly by what was typed there.
301
440
  const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
302
441
  const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
442
+ // A label's own words, without the options or values of the controls inside it.
443
+ const labelText = label => {
444
+ const copy = label.cloneNode(true);
445
+ copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
446
+ return copy.textContent;
447
+ };
303
448
  const nameOf = el => {
304
449
  const labelledBy = el.getAttribute('aria-labelledby');
305
450
  const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
306
451
  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')
452
+ return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
308
453
  || el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
309
454
  };
310
455
  const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
311
456
  // el null: the page itself, e.g. Escape with nothing focused.
312
457
  const send = (action, el, extra, since) => {
458
+ // Typing still waiting for its pause is recorded first, so the steps stay in order.
459
+ if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
313
460
  if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
314
461
  };
315
462
  document.addEventListener('click', e => {
@@ -925,18 +1072,20 @@ const commands = {
925
1072
  async goto(args) {
926
1073
  if (!args) throw new Error('Usage: goto <url>');
927
1074
  let url = args;
928
- if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = 'https://' + url;
1075
+ // As a browser's address bar does: a local dev server is almost always plain http.
1076
+ const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
1077
+ if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
929
1078
  await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
930
1079
  out.log(`${state.page.url()} — ${await state.page.title()}`);
931
1080
  },
932
1081
 
933
1082
  async back() {
934
- await state.page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
1083
+ await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
935
1084
  out.log(`Back to: ${state.page.url()}`);
936
1085
  },
937
1086
 
938
1087
  async forward() {
939
- await state.page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
1088
+ await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
940
1089
  out.log(`Forward to: ${state.page.url()}`);
941
1090
  },
942
1091
 
@@ -948,8 +1097,7 @@ const commands = {
948
1097
  async info() {
949
1098
  out.log(` URL: ${state.page.url()}`);
950
1099
  out.log(` Title: ${await state.page.title()}`);
951
- const vp = state.page.viewportSize();
952
- if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
1100
+ out.log(` Viewport: ${await viewportText()}`);
953
1101
  },
954
1102
 
955
1103
  async click(args) {
@@ -978,7 +1126,21 @@ const commands = {
978
1126
 
979
1127
  async type(args) {
980
1128
  const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
981
- await onElement(selector, () => state.page.type(selector, value, { timeout: 5000 }));
1129
+ // Typed after what the field already holds, as someone clicking into it
1130
+ // at the end would; focusing it alone leaves the caret at the start.
1131
+ await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
1132
+ el.focus();
1133
+ if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
1134
+ try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
1135
+ } else if (el.isContentEditable) {
1136
+ const range = document.createRange();
1137
+ range.selectNodeContents(el);
1138
+ range.collapse(false);
1139
+ getSelection().removeAllRanges();
1140
+ getSelection().addRange(range);
1141
+ }
1142
+ }, null, { timeout: 5000 }));
1143
+ await state.page.keyboard.type(value);
982
1144
  out.log(`Typed into: ${shown}`);
983
1145
  },
984
1146
 
@@ -1033,6 +1195,36 @@ const commands = {
1033
1195
  printOutput(result, all);
1034
1196
  },
1035
1197
 
1198
+ // The page's own event listeners on an element, which only DevTools can
1199
+ // list: the element is handed to a CDP session through the page, so any
1200
+ // Playwright selector works, and the session asks for its listeners.
1201
+ async listeners(args, all) {
1202
+ const text = (args || '').trim();
1203
+ const usage = 'listeners <selector> | listeners document | listeners window';
1204
+ if (!text) throw new Error(`Usage: ${usage}`);
1205
+ const which = text === 'document' || text === 'window' ? text : null;
1206
+ const selector = which ? null : soleSelector(args, usage);
1207
+ if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
1208
+ const cdp = await state.page.context().newCDPSession(state.page);
1209
+ try {
1210
+ // Chrome includes each handler's source only for an object in a named group.
1211
+ const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
1212
+ if (!result.objectId) throw new Error(`No element matches ${text}`);
1213
+ const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
1214
+ if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
1215
+ const lines = listeners.map(l => {
1216
+ const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
1217
+ const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
1218
+ return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
1219
+ });
1220
+ printOutput(lines.join('\n'), all);
1221
+ } finally {
1222
+ await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
1223
+ await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
1224
+ await cdp.detach().catch(() => {});
1225
+ }
1226
+ },
1227
+
1036
1228
  async count(args) {
1037
1229
  const selector = soleSelector(args, 'count <selector>');
1038
1230
  const els = await state.page.$$(selector);
@@ -1126,7 +1318,7 @@ const commands = {
1126
1318
  // Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
1127
1319
  const selector = toSelector(rest);
1128
1320
  const target = selector ? state.page.locator(selector).first() : state.page;
1129
- const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1321
+ const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
1130
1322
  const text = full ? raw : compactSnapshot(raw);
1131
1323
  if (needle !== null) {
1132
1324
  const hits = grepSnapshot(text, needle);
@@ -1139,11 +1331,7 @@ const commands = {
1139
1331
  },
1140
1332
 
1141
1333
  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
- }
1334
+ if (!args) { out.log(await viewportText()); return; }
1147
1335
  const [w, h] = args.split('x').map(Number);
1148
1336
  if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
1149
1337
  await state.page.setViewportSize({ width: w, height: h });
@@ -1206,35 +1394,28 @@ const commands = {
1206
1394
  if (!trimmed) return listRoutes();
1207
1395
  const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
1208
1396
  if (off) return removeRoutes(off[1]);
1209
- const match = /^(\S+)\s+(\d{3})\s+([\s\S]+)$/.exec(trimmed);
1210
- if (!match) throw new Error('Usage: route <url-glob> <status> <json-body> | route off <url-glob>|--all');
1211
- const [, glob, statusText, body] = match;
1212
- const status = Number(statusText);
1213
- if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
1214
- try { JSON.parse(body); } catch (error) { throw new Error(`Body is not valid JSON: ${error.message}`); }
1397
+ 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';
1398
+ const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
1399
+ if (!match) throw new Error(usage);
1400
+ const [, glob, how, rest] = match;
1401
+ const route = routeKind(how, rest, usage);
1215
1402
  const routes = routesFor(state.page);
1216
1403
  const previous = routes.get(glob);
1217
1404
  if (previous) await state.page.unroute(glob, previous.handler);
1218
1405
  const handler = async r => {
1219
1406
  const req = r.request();
1220
- fakedRequests.add(req);
1407
+ const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
1221
1408
  try {
1222
- await r.fulfill({ status, contentType: 'application/json', body });
1223
- // Recorded now: the browser reports the request finished only later.
1224
- const entry = requestEntries.get(req);
1225
- if (entry) { entry.status = `${status} faked`; entry.ms = Date.now() - entry.t; }
1226
- const id = entry?.id;
1227
- out.notice(`Faked: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} -> ${status}`);
1409
+ await route.handle(r, req, tag);
1228
1410
  } catch (error) {
1229
- // Aborted, never continued: a request meant to be faked must not reach the network.
1411
+ // Aborted, never continued: a request meant to be changed must not reach the page as it was.
1230
1412
  await r.abort().catch(() => {});
1231
- const id = requestEntries.get(req)?.id;
1232
- out.notice(`Fake failed: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} — ${error.message}; the request was aborted`);
1413
+ out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
1233
1414
  }
1234
1415
  };
1235
1416
  await state.page.route(glob, handler);
1236
- routes.set(glob, { status, body, handler });
1237
- out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${status}`);
1417
+ routes.set(glob, { text: route.text, handler });
1418
+ out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
1238
1419
  },
1239
1420
 
1240
1421
  async requests(args) {
@@ -1268,7 +1449,7 @@ const commands = {
1268
1449
  const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
1269
1450
  if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
1270
1451
  // A fake is marked answered before the browser hands over its response.
1271
- for (let waited = 0; !entry.response && /faked$/.test(entry.status) && waited < 2000; waited += 50) {
1452
+ for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
1272
1453
  await new Promise(resolve => setTimeout(resolve, 50));
1273
1454
  }
1274
1455
  if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
@@ -1414,6 +1595,10 @@ const commands = {
1414
1595
  out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
1415
1596
  },
1416
1597
 
1598
+ async dialog(args) {
1599
+ out.log(await dialogCommand(args));
1600
+ },
1601
+
1417
1602
  async modes(args) {
1418
1603
  const arg = (args || '').trim();
1419
1604
  if (!arg) return listModes();
@@ -1459,6 +1644,7 @@ function complete(line) {
1459
1644
  if (command === 'tab') return match(['new', 'close']);
1460
1645
  if (command === 'network') return match(['on', 'off']);
1461
1646
  if (command === 'modes') return match(['off']);
1647
+ if (command === 'dialog') return match(['accept', 'dismiss']);
1462
1648
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
1463
1649
  if (command === 'capture') return match(['on', 'off']);
1464
1650
  if (command === 'route') return match(['off']);
@@ -1469,4 +1655,4 @@ function complete(line) {
1469
1655
  return [[], current];
1470
1656
  }
1471
1657
 
1472
- module.exports = { commands, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
1658
+ module.exports = { commands, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
package/lib/help.js CHANGED
@@ -15,7 +15,8 @@ Common tasks:
15
15
  show an agent what I do watch on, click around, then watch
16
16
  see each step as I click watch on --live
17
17
  requests and console together capture on, then capture off
18
- break the backend on purpose route <glob> <status> <json>, network off
18
+ break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
19
+ change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
19
20
  clean up modes off
20
21
 
21
22
  Modes (watch, capture, route, network off) stay on until turned off; the prompt shows the selected tab's:
@@ -40,7 +41,7 @@ const TOPICS = {
40
41
  },
41
42
  inspect: {
42
43
  intro: 'Output is capped; put --all right after the command for everything (e.g. text --all body).',
43
- commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
44
+ commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
44
45
  },
45
46
  network: {
46
47
  intro: 'requests and console record all the time; a capture records only while it runs.',
@@ -63,8 +64,8 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
63
64
 
64
65
  Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
65
66
 
66
- Dialogs are reported and never answered automatically.`,
67
- commands: ['modes', 'help', 'quit'],
67
+ Dialogs are reported and never answered on their own; dialog answers one.`,
68
+ commands: ['modes', 'dialog', 'help', 'quit'],
68
69
  },
69
70
  };
70
71
 
@@ -74,7 +75,11 @@ const COMMANDS = {
74
75
  summary: 'list tabs (* is selected); select, open or close one',
75
76
  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
  },
77
- goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
78
+ goto: {
79
+ usage: 'goto <url>',
80
+ summary: 'navigate the selected tab',
81
+ 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.',
82
+ },
78
83
  back: { usage: 'back', summary: 'go back one history entry' },
79
84
  forward: { usage: 'forward', summary: 'go forward one history entry' },
80
85
  reload: { usage: 'reload', summary: 'reload the selected tab' },
@@ -87,7 +92,7 @@ const COMMANDS = {
87
92
  summary: 'clear an input and fill it',
88
93
  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
94
  },
90
- type: { usage: 'type <selector> <text>', summary: 'type into an input key by key; the text is as for fill' },
95
+ type: { usage: 'type <selector> <text>', summary: 'type key by key after what the field holds; text as for fill' },
91
96
  press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A), optionally on an element' },
92
97
  select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
93
98
  check: { usage: 'check <selector>', summary: 'check a checkbox' },
@@ -105,6 +110,11 @@ const COMMANDS = {
105
110
  text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
106
111
  html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
107
112
  attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
113
+ listeners: {
114
+ usage: 'listeners <selector>|document|window',
115
+ summary: 'the page\'s event listeners on an element',
116
+ detail: 'One line each: the event, how it was added (capture, once, passive), the start of the handler and\nits line in its script. Listeners added on a parent (e.g. document, for delegation) are not the\nelement\'s own: check listeners document too.',
117
+ },
108
118
  count: { usage: 'count <selector>', summary: 'number of matches' },
109
119
  visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
110
120
  links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
@@ -142,9 +152,9 @@ const COMMANDS = {
142
152
  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
153
  },
144
154
  route: {
145
- usage: 'route <glob> <status> <json> | off <glob>|--all',
146
- summary: 'answer the selected tab\'s matching requests with fake JSON',
147
- detail: 'The fake is fulfilled inside the browser, so the page handles it as a real response and the request\nnever reaches the network; it still answers while the network is off. Each one prints "Faked: #<n>\n<METHOD> <url> -> <status>" in the REPL window (and in the answer to a command running then), with\n#<n> as in requests. If fulfilling fails it prints "Fake failed" and aborts the request, so it never\nreaches the network.\n\nStatus: 200-599. wait request <glob> after the page loads prints the request with its status, so a\nfake shows as <status> faked.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes.\n\nExample: route **/api/health_check 503 {"detail":{"code":"service_unavailable"}}',
155
+ usage: 'route <glob> <how> | off <glob>|--all',
156
+ summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
157
+ detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON; it never reaches the network,\n so it still answers while the network is off\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests; requests shows a fake as\n<status> faked and a patch as <status> patched. If a route fails it prints "Route failed" and aborts\nthe request.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes.\n\nExample: route **/api/cart patch {"total": 0}',
148
158
  },
149
159
  network: {
150
160
  usage: 'network [on|off]',
@@ -168,6 +178,11 @@ const COMMANDS = {
168
178
  summary: 'the modes on in every tab; modes off turns them all off',
169
179
  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
180
  },
181
+ dialog: {
182
+ usage: 'dialog [accept [text] | dismiss]',
183
+ summary: 'show, accept or dismiss an open alert, confirm or prompt',
184
+ 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.',
185
+ },
171
186
  help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
172
187
  quit: {
173
188
  usage: 'quit',
package/lib/launch.js ADDED
@@ -0,0 +1,115 @@
1
+ // pw-repl run|serve --launch: a Chromium of the REPL's own, started for it and
2
+ // stopped with it, in a profile that is removed afterwards. It is the quick
3
+ // start; a browser set up any other way is reached with PW_CDP_URL instead.
4
+ const { spawn } = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const START_TIMEOUT = 20000;
10
+ const STOP_TIMEOUT = 5000;
11
+ const INSTALL = 'npx playwright-core install chromium';
12
+ const ON_PATH = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome', 'microsoft-edge'];
13
+ const MAC_APPS = [
14
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
15
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
16
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
17
+ ];
18
+
19
+ // PW_CHROME, then Playwright's own Chromium (wherever PLAYWRIGHT_BROWSERS_PATH
20
+ // puts it), then any other revision of it, then a Chromium on the PATH.
21
+ function findChrome() {
22
+ if (process.env.PW_CHROME) return process.env.PW_CHROME;
23
+ try {
24
+ const bundled = require('playwright-core').chromium.executablePath();
25
+ if (fs.existsSync(bundled)) return bundled;
26
+ } catch {}
27
+ // Any Chromium works over CDP, so a revision other than this Playwright's is fine.
28
+ const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
29
+ let names = [];
30
+ try { names = fs.readdirSync(dir).filter(n => /^chromium-\d+$/.test(n)).sort().reverse(); } catch {}
31
+ for (const name of names) {
32
+ for (const sub of ['chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium']) {
33
+ const candidate = path.join(dir, name, sub);
34
+ if (fs.existsSync(candidate)) return candidate;
35
+ }
36
+ }
37
+ for (const folder of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
38
+ for (const name of ON_PATH) {
39
+ const candidate = path.join(folder, name);
40
+ try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
41
+ }
42
+ }
43
+ return MAC_APPS.find(app => fs.existsSync(app)) || null;
44
+ }
45
+
46
+ // Containers often give /dev/shm too little room, and Chromium's tabs then crash.
47
+ function smallShm() {
48
+ try { const shm = fs.statfsSync('/dev/shm'); return shm.blocks * shm.bsize < 1024 ** 3; } catch { return false; }
49
+ }
50
+
51
+ // Starts it and waits for the port it picked, which it writes into its profile.
52
+ function start(exe, args, profile) {
53
+ return new Promise(resolve => {
54
+ // Its own process group, so stopping it also stops the helper processes it starts.
55
+ const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
56
+ let stderr = '';
57
+ let settled = false;
58
+ const done = result => { if (!settled) { settled = true; clearInterval(poll); clearTimeout(timer); resolve(result); } };
59
+ proc.stderr.on('data', d => { if (stderr.length < 20000) stderr += d; });
60
+ proc.on('error', error => done({ error: error.message, stderr }));
61
+ proc.on('exit', code => done({ error: `it exited with code ${code}`, stderr }));
62
+ const portFile = path.join(profile, 'DevToolsActivePort');
63
+ const poll = setInterval(() => {
64
+ try { const port = fs.readFileSync(portFile, 'utf8').split('\n')[0].trim(); if (port) done({ proc, port }); } catch {}
65
+ }, 100);
66
+ const timer = setTimeout(() => { proc.kill('SIGKILL'); done({ error: `no debugging port within ${START_TIMEOUT / 1000}s`, stderr }); }, START_TIMEOUT);
67
+ });
68
+ }
69
+
70
+ function quoted(args) {
71
+ return args.map(a => (/[\s"'$]/.test(a) ? JSON.stringify(a) : a)).join(' ');
72
+ }
73
+
74
+ async function launch({ headed = false, extraArgs = [] }) {
75
+ const exe = findChrome();
76
+ if (!exe) {
77
+ throw new Error(`No Chromium found to launch. Install Playwright's with:\n ${INSTALL}\nor set PW_CHROME to one, or start one yourself with --remote-debugging-port and set PW_CDP_URL.`);
78
+ }
79
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-repl-chrome-'));
80
+ const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', `--user-data-dir=${profile}`,
81
+ '--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
82
+ // Flags given after -- come after these, so they can override them.
83
+ let args = [...ours, ...extraArgs, 'about:blank'];
84
+ let result = await start(exe, args, profile);
85
+ let note = null;
86
+ // Tried with the sandbox first; a container often cannot give Chromium one.
87
+ if (result.error && /sandbox/i.test(result.stderr)) {
88
+ args = [...ours, '--no-sandbox', ...extraArgs, 'about:blank'];
89
+ result = await start(exe, args, profile);
90
+ note = 'Chromium could not use its sandbox here, so it runs with --no-sandbox.';
91
+ }
92
+ const command = quoted([exe, ...args]);
93
+ if (result.error) {
94
+ fs.rmSync(profile, { recursive: true, force: true });
95
+ const last = result.stderr.trim().split('\n').slice(-5).join('\n');
96
+ throw new Error(`Chromium did not start (${result.error}):\n ${command}${last ? `\n${last}` : ''}`);
97
+ }
98
+ const { proc, port } = result;
99
+ const signal = name => { try { process.kill(-proc.pid, name); } catch {} };
100
+ const stop = async () => {
101
+ if (proc.exitCode === null && proc.signalCode === null) {
102
+ signal('SIGTERM');
103
+ await new Promise(resolve => { const t = setTimeout(() => { signal('SIGKILL'); resolve(); }, STOP_TIMEOUT); proc.once('exit', () => { clearTimeout(t); resolve(); }); });
104
+ }
105
+ // Its helper processes can still be writing to the profile for a moment.
106
+ for (let tries = 0; tries < 30; tries++) {
107
+ try { fs.rmSync(profile, { recursive: true, force: true }); return; } catch { await new Promise(resolve => setTimeout(resolve, 100)); }
108
+ }
109
+ };
110
+ // If the REPL goes without shutting down, the browser still goes with it.
111
+ process.on('exit', () => signal('SIGKILL'));
112
+ return { url: `http://127.0.0.1:${port}`, command, note, headed, stop };
113
+ }
114
+
115
+ module.exports = { findChrome, launch, INSTALL };
package/lib/runner.js CHANGED
@@ -3,19 +3,19 @@
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
10
10
  // do; that is reported, and the REPL carries on.
11
- const READ_ONLY = new Set(['info', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
11
+ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
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
- const INSPECTION = ['info', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
18
+ const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
19
19
 
20
20
  let queue = Promise.resolve();
21
21
  // The server command running now, so a quit at the prompt can answer it.
@@ -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,9 +112,16 @@ 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
+ const info = way.kind === 'server' ? await client.healthInfo(way.endpoint, 5000) : null;
116
+ // The browser a running REPL uses (one it launched has its own address); otherwise the one it would
117
+ // use, so a REPL that will not start can be told apart from one that is not running.
118
+ const cdp = info?.browser || process.env.PW_CDP_URL || 'http://localhost:9222';
119
+ const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
120
+ const whose = info?.launched ? ', launched by this REPL' : '';
121
+ console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
113
122
  if (way.kind === 'server') {
114
123
  const name = client.describe(way.endpoint);
115
- if (await client.health(way.endpoint, 5000)) {
124
+ if (info) {
116
125
  const background = require('./background').describeBackground(way.endpoint);
117
126
  console.log(`server: ${name} (the REPL was started with pw-repl serve${background ? ' --background' : ''})`);
118
127
  if (background) console.log(background);
package/lib/server.js CHANGED
@@ -5,7 +5,7 @@ const net = require('net');
5
5
  const fs = require('fs');
6
6
  const out = require('./output');
7
7
  const runner = require('./runner');
8
- const { onShutdown, onBeforeExit } = require('./state');
8
+ const { state, onShutdown, onBeforeExit } = require('./state');
9
9
  const { parseEndpoint, describe } = require('./client');
10
10
 
11
11
  const MAX_BODY = 1024 * 1024;
@@ -29,7 +29,7 @@ function handle(req, res) {
29
29
  // cross-origin POST, and cannot send a JSON content type without a
30
30
  // preflight this server never answers.
31
31
  if (req.headers.origin) return send(res, 403, { status: 'error', output: 'Requests from web pages are refused' });
32
- if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: 'ok' });
32
+ if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: 'ok', browser: state.cdpUrl, launched: state.launched });
33
33
  if (req.method !== 'POST' || req.url !== '/run') return send(res, 404, { status: 'error', output: 'Use POST /run or GET /health' });
34
34
  if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return send(res, 415, { status: 'error', output: 'Content-Type must be application/json' });
35
35
  let body = '';
package/lib/start.js CHANGED
@@ -1,17 +1,47 @@
1
1
  // Connects to the browser and runs the prompt (and the server with serve).
2
2
  const { chromium } = require('playwright-core');
3
3
  const readline = require('readline');
4
- const { state, withTimeout, shutdown, beforeExit } = require('./state');
4
+ const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./state');
5
5
  const out = require('./output');
6
6
  const { listTabs, openTab, watchPage, complete } = require('./commands');
7
7
  const runner = require('./runner');
8
8
 
9
- const CDP_URL = process.env.PW_CDP_URL || 'http://localhost:9222';
9
+ // A browser --launch starts has its own address instead.
10
+ let CDP_URL = process.env.PW_CDP_URL || 'http://localhost:9222';
11
+ const CONNECT_TIMEOUT = 15000;
12
+
13
+ // What to do about a browser that cannot be reached, rather than the bare socket error.
14
+ function connectHelp(error) {
15
+ const reason = String(error.message).split('\n')[0].replace(/^browserType\.connectOverCDP: /, '');
16
+ if (/timeout/i.test(reason)) {
17
+ 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.`;
18
+ }
19
+ return `No browser answered at ${CDP_URL} (${reason}).
20
+
21
+ pw-repl needs a Chromium-based browser (Chrome, Chromium, Edge, ...) started with remote debugging:
22
+ chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
23
+ chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
24
+ It needs a --user-data-dir of its own: Chrome does not open the port on its default profile.
25
+ curl ${CDP_URL}/json/version checks that it answers; PW_CDP_URL points at a browser elsewhere.
26
+ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve --launch).`;
27
+ }
10
28
 
11
29
  async function start(options) {
12
30
  const START_URL = options.startUrl || process.env.PW_START_URL || null;
31
+ if (options.launch) {
32
+ const launched = await require('./launch').launch({ headed: options.headed, extraArgs: options.chromeArgs });
33
+ onBeforeExit(launched.stop);
34
+ CDP_URL = launched.url;
35
+ out.log(`Launched a ${launched.headed ? 'visible' : 'headless'} Chromium of this REPL's own; it stops with the REPL:`);
36
+ out.log(` ${launched.command}`);
37
+ if (launched.note) out.log(launched.note);
38
+ out.log(`Another REPL reaches it with PW_CDP_URL=${CDP_URL}`);
39
+ }
40
+ state.cdpUrl = CDP_URL;
41
+ state.launched = !!options.launch;
13
42
  out.log(`Connecting to ${CDP_URL}...`);
14
- state.browser = await chromium.connectOverCDP(CDP_URL);
43
+ try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
44
+ catch (error) { throw new Error(connectHelp(error)); }
15
45
  if (state.stopping) {
16
46
  try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
17
47
  catch (error) { process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
@@ -23,7 +53,7 @@ async function start(options) {
23
53
  state.stopping = true;
24
54
  process.exitCode = 1;
25
55
  out.error('Chromium connection lost; queued commands will not run.');
26
- void shutdown().then(() => process.exit(1));
56
+ void shutdown().then(beforeExit).then(() => process.exit(1));
27
57
  });
28
58
  out.log('Connected to Chromium via CDP');
29
59
 
@@ -102,7 +132,7 @@ async function start(options) {
102
132
  endPromptLine();
103
133
  out.error('Input ended; disconnecting.');
104
134
  runner.drained().then(() => shutdown(), () => shutdown())
105
- .then(() => process.exit(process.exitCode || 0));
135
+ .then(beforeExit).then(() => process.exit(process.exitCode || 0));
106
136
  });
107
137
  rl.on('SIGINT', stop);
108
138
  }
@@ -123,4 +153,4 @@ function stop() {
123
153
  // SIGHUP too: closing the terminal must still remove the server's socket.
124
154
  for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, stop);
125
155
 
126
- module.exports = { start: options => start(options).catch(e => { out.error(e.message); shutdown().finally(() => process.exit(1)); }) };
156
+ module.exports = { start: options => start(options).catch(e => { out.error(e.message); shutdown().then(beforeExit).finally(() => process.exit(1)); }) };
package/lib/state.js CHANGED
@@ -11,6 +11,9 @@ const state = {
11
11
  tabListing: [],
12
12
  rl: null,
13
13
  promptBase: 'pw> ',
14
+ // The browser the REPL is connected to, and whether --launch started it.
15
+ cdpUrl: null,
16
+ launched: false,
14
17
  stopping: false,
15
18
  connectionLost: false,
16
19
  shutdownFailed: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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
@@ -27,6 +27,9 @@ one; each takes an optional start URL, which opens in a new tab.
27
27
  and takes commands; `pw-repl stop` stops it.
28
28
  - `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
29
29
  `pw[serve]>`.
30
+ - Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
31
+ own instead of connecting to one: headless unless `--headed`, in a temporary profile, stopped with the
32
+ REPL. It prints the command it ran; flags after `--` are passed to that Chromium.
30
33
  - `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
31
34
  the tmux session `playwright-repl`:
32
35
 
@@ -62,7 +65,7 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
62
65
 
63
66
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
64
67
  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
68
+ of what someone is typing; while a command is running, someone is typing, or the REPL is exiting, it
66
69
  refuses (exit 64). Wait and retry.
67
70
  - `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
68
71
  as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
@@ -72,14 +75,13 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
72
75
  REPL), or why neither is reachable, without running anything.
73
76
 
74
77
  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.
78
+ background REPL (server commands as `[server]` lines), so whoever looks there sees what you do. The pane
79
+ shows REPL commands only, not what was clicked in the browser (unless `watch on --live` is on); for
80
+ that, look at the browser itself: `tab` and `info` for where things are, `requests` for the requests the
81
+ clicks made (`body <#>` for what one returned), `console` for console messages and page errors. `watch
82
+ on` records each step someone takes in a tab, with the requests it caused (`watch on --changes` adds
83
+ what each step changed on the page); `watch` reads it back, and `watch new` only what it has not shown
84
+ yet.
83
85
 
84
86
  Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
85
87
  blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
@@ -90,23 +92,27 @@ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their command
90
92
  gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
91
93
  help is the command reference; this file does not repeat it.
92
94
 
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.
95
+ ## Sharing the browser
96
+
97
+ - The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
98
+ one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
99
+ clear, ask.
100
+ - No tab is selected when the REPL starts (unless it was given a start URL). Closing a tab the REPL
101
+ opened goes back to the tab before it, if the REPL opened that one too; otherwise no tab is selected.
102
+ Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
103
+ its URL and refuse if it is ambiguous.
104
+ - Dialogs are never answered on their own. While one is open, its page and the commands that read it
105
+ wait; `dialog` shows it, and `dialog accept` or `dialog dismiss` answers it.
106
+ - Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
107
+ lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
108
+ `modes off` turns off every mode in every tab.
109
+ - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
106
110
 
107
111
  ## Environment
108
112
 
109
- - Chromium must be running with `--remote-debugging-port=9222`.
113
+ - Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
114
+ one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
115
+ - `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
110
116
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
111
117
  - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
112
118
  so `rm /tmp/screenshot-*.png` cleans up.