pw-repl 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/help.js CHANGED
@@ -10,17 +10,22 @@ Common tasks:
10
10
  where am I? tab, info
11
11
  what is on the page? snapshot, screenshot
12
12
  do something on it click, fill, press
13
+ wait for a page or element wait load, wait <selector>, wait <selector> --gone
14
+ choose a file in a file input upload <selector> <file>
15
+ see it as a phone, or dark emulate mobile, emulate dark
13
16
  what did the page request? requests, then body <#> for what one got back
14
17
  console messages and errors console
15
18
  show an agent what I do watch on, click around, then watch
16
19
  see each step as I click watch on --live
17
20
  requests and console together capture on, then capture off
18
- break the backend on purpose route <glob> <status> <json>, network off
21
+ break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
22
+ change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
23
+ slow the whole network network slow
19
24
  clean up modes off
20
25
 
21
- Modes (watch, capture, route, network off) stay on until turned off; the prompt shows the selected tab's:
22
- (watch routes:1) pw>. tab, watch, capture, route, network and modes on their own show their state and
23
- what you can run next.
26
+ Modes (watch, capture, route, network off or slow, emulate) stay on until turned off; the prompt shows
27
+ the selected tab's: (watch routes:1) pw>. tab, watch, capture, route, network, emulate and modes on
28
+ their own show their state and what you can run next.
24
29
 
25
30
  Topics (help <topic>):
26
31
  tabs open, select, close, navigate network requests, bodies, console, fakes, network off
@@ -36,11 +41,11 @@ const TOPICS = {
36
41
  },
37
42
  interact: {
38
43
  intro: 'Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.\nCommands use the first match; see help fill for selectors with spaces.',
39
- commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck'],
44
+ commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload'],
40
45
  },
41
46
  inspect: {
42
47
  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'],
48
+ commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'emulate', 'wait', 'sleep'],
44
49
  },
45
50
  network: {
46
51
  intro: 'requests and console record all the time; a capture records only while it runs.',
@@ -96,6 +101,11 @@ const COMMANDS = {
96
101
  select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
97
102
  check: { usage: 'check <selector>', summary: 'check a checkbox' },
98
103
  uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
104
+ upload: {
105
+ usage: 'upload <selector> <file>...',
106
+ summary: 'choose files in a file input, as the file picker would',
107
+ detail: 'The selector is a file input (even a hidden one), its label, or a button that opens the file\npicker. The page then does what it does with a chosen file; often that is the upload itself.\n\nThe REPL reads the files and hands the page their contents, so they need not be where the browser\nruns; up to 50MB in all. Relative paths are from the folder pw-repl send runs in, or the REPL\'s own\nfor a command typed at its prompt. Quote a path with spaces.',
108
+ },
99
109
  snapshot: {
100
110
  usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
101
111
  summary: 'outline by role and name, with [ref=eN] labels',
@@ -109,6 +119,11 @@ const COMMANDS = {
109
119
  text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
110
120
  html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
111
121
  attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
122
+ listeners: {
123
+ usage: 'listeners <selector>|document|window',
124
+ summary: 'the page\'s event listeners on an element',
125
+ 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.',
126
+ },
112
127
  count: { usage: 'count <selector>', summary: 'number of matches' },
113
128
  visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
114
129
  links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
@@ -119,10 +134,15 @@ const COMMANDS = {
119
134
  detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
120
135
  },
121
136
  viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
137
+ emulate: {
138
+ usage: 'emulate [<what> [off] | off]',
139
+ summary: 'emulate a phone, dark mode, a locale or a timezone',
140
+ detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nemulate mobile or emulate locale. While mobile is on, viewport refuses: the device sets the size.',
141
+ },
122
142
  wait: {
123
- usage: 'wait [text|request] <what> [secs]',
124
- summary: 'wait for an element, text, or a response (default 10s)',
125
- detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
143
+ usage: 'wait [text|request] <what> [--gone] [secs]',
144
+ summary: 'wait for an element, text, a response or a load (10s)',
145
+ detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nwait load waits for the page to finish loading (its load event). A navigation that began since the\nprevious command began counts, so click a link, then wait load, waits for the new page.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
126
146
  },
127
147
  sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
128
148
  requests: {
@@ -146,14 +166,14 @@ const COMMANDS = {
146
166
  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.',
147
167
  },
148
168
  route: {
149
- usage: 'route <glob> <status> <json> | off <glob>|--all',
150
- summary: 'answer the selected tab\'s matching requests with fake JSON',
151
- 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"}}',
169
+ usage: 'route <glob> <how> | off <glob>|--all',
170
+ summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
171
+ 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}',
152
172
  },
153
173
  network: {
154
- usage: 'network [on|off]',
155
- summary: 'cut or restore the tab\'s network, like dropped wifi',
156
- detail: 'Stopping a service is not the same: a dev proxy in front of it usually holds the request open, so\nthe page spins instead of failing.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off.',
174
+ usage: 'network [on|off|slow [<ms> [<kbps>]]]',
175
+ summary: 'cut, slow or restore the tab\'s network',
176
+ detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow adds latency to each request and limits its speed: by default as DevTools\' Slow 4G\n(563ms, 1440 kbps down, 675 up); network slow <ms> [<kbps>] sets them. To slow one API, use route\n<glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
157
177
  },
158
178
  eval: {
159
179
  usage: 'eval [--all] <JavaScript>',
@@ -170,7 +190,7 @@ const COMMANDS = {
170
190
  modes: {
171
191
  usage: 'modes [off]',
172
192
  summary: 'the modes on in every tab; modes off turns them all off',
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.',
193
+ detail: 'The modes are watch, network off or slow, route, capture and emulate. Each is turned on and off with\nits own command: watch on|off, network off|slow|on, route <glob> ... | route off <glob>|--all,\ncapture on|off, emulate ... | emulate off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
174
194
  },
175
195
  dialog: {
176
196
  usage: 'dialog [accept [text] | dismiss]',
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
@@ -8,14 +8,14 @@ const { commands, dialogCommand, activeModes } = require('./commands');
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
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.
package/lib/send.js CHANGED
@@ -112,13 +112,16 @@ async function send(options) {
112
112
  // Reports the route a command would take, checking it the way a command would.
113
113
  async function where(options) {
114
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';
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';
117
119
  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`);
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`);
119
122
  if (way.kind === 'server') {
120
123
  const name = client.describe(way.endpoint);
121
- if (await client.health(way.endpoint, 5000)) {
124
+ if (info) {
122
125
  const background = require('./background').describeBackground(way.endpoint);
123
126
  console.log(`server: ${name} (the REPL was started with pw-repl serve${background ? ' --background' : ''})`);
124
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,12 +1,13 @@
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';
10
11
  const CONNECT_TIMEOUT = 15000;
11
12
 
12
13
  // What to do about a browser that cannot be reached, rather than the bare socket error.
@@ -21,11 +22,23 @@ pw-repl needs a Chromium-based browser (Chrome, Chromium, Edge, ...) started wit
21
22
  chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
22
23
  chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
23
24
  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
+ 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).`;
25
27
  }
26
28
 
27
29
  async function start(options) {
28
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;
29
42
  out.log(`Connecting to ${CDP_URL}...`);
30
43
  try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
31
44
  catch (error) { throw new Error(connectHelp(error)); }
@@ -40,7 +53,7 @@ async function start(options) {
40
53
  state.stopping = true;
41
54
  process.exitCode = 1;
42
55
  out.error('Chromium connection lost; queued commands will not run.');
43
- void shutdown().then(() => process.exit(1));
56
+ void shutdown().then(beforeExit).then(() => process.exit(1));
44
57
  });
45
58
  out.log('Connected to Chromium via CDP');
46
59
 
@@ -119,7 +132,7 @@ async function start(options) {
119
132
  endPromptLine();
120
133
  out.error('Input ended; disconnecting.');
121
134
  runner.drained().then(() => shutdown(), () => shutdown())
122
- .then(() => process.exit(process.exitCode || 0));
135
+ .then(beforeExit).then(() => process.exit(process.exitCode || 0));
123
136
  });
124
137
  rl.on('SIGINT', stop);
125
138
  }
@@ -140,4 +153,4 @@ function stop() {
140
153
  // SIGHUP too: closing the terminal must still remove the server's socket.
141
154
  for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, stop);
142
155
 
143
- 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,
@@ -49,7 +52,8 @@ async function shutdown() {
49
52
  if (shutdownPromise) return shutdownPromise;
50
53
  state.stopping = true;
51
54
  shutdownPromise = (async () => {
52
- cleanups.forEach(fn => fn());
55
+ // Some undo what the REPL changed in the browser, so they finish first.
56
+ await withTimeout(Promise.all(cleanups.map(async fn => fn())), 'Cleanup').catch(() => {});
53
57
  if (state.browser) {
54
58
  try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
55
59
  catch (error) { state.shutdownFailed = true; process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
package/lib/syntax.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Commands whose first word is a selector, quoted if it has spaces, and whose
4
4
  // value is the rest of the line. Other commands take the rest of the line as it is.
5
- const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press']);
5
+ const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
6
6
 
7
7
  // A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
8
8
  const REF = /^(?:f\d+)?e\d+$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pw-repl
3
- description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages, fake responses, cutting the network, and recording what the person does. Use when asked to look at, debug or test something in a browser that runs with remote debugging.
3
+ description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages, waiting for pages and elements, choosing files, faking, patching or delaying responses, cutting or slowing the network, emulating a phone, dark mode, a locale or a timezone, and recording what the person does. Use when asked to look at, debug or test something in a web page, in an existing Chromium with remote debugging or in one it starts itself.
4
4
  ---
5
5
 
6
6
  # pw-repl
@@ -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
 
@@ -44,7 +47,7 @@ listens on TCP 127.0.0.1 instead of a socket, with no access control.
44
47
  Several REPLs can run at once, each on its own socket, e.g. one per agent. Each has its own selected tab,
45
48
  command queue and modes, so they do not wait on or select for each other. They share the browser,
46
49
  though: each sees every tab, `modes` lists only its own REPL's modes, and two REPLs acting on the same
47
- tab can undo each other's routes or network setting.
50
+ tab can undo each other's routes, network or emulation settings.
48
51
 
49
52
  A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
50
53
 
@@ -107,8 +110,9 @@ help is the command reference; this file does not repeat it.
107
110
 
108
111
  ## Environment
109
112
 
110
- - Chromium must be running with `--remote-debugging-port=9222`. A headless one works too
111
- (`--headless=new`); nobody answers its dialogs but `dialog`.
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`).
112
116
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
113
117
  - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
114
118
  so `rm /tmp/screenshot-*.png` cleans up.