pw-repl 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pw-repl.js CHANGED
@@ -41,9 +41,10 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
41
41
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
42
42
 
43
43
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
44
- a temporary profile), prints the command it ran, and stops it with the REPL. Flags after -- go to that
45
- Chromium; PW_CHROME picks which one. To set a browser up your own way, start it yourself and use
46
- PW_CDP_URL.
44
+ a temporary profile, on a free port of its own), prints the command it ran, and stops it with the REPL
45
+ (Ctrl-C while it starts too); stop returns once that Chromium has exited. Flags after -- go to it; a
46
+ --user-data-dir=<dir> among them is used instead of the temporary profile, and kept. PW_CHROME picks
47
+ which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
47
48
 
48
49
  In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
49
50
 
package/lib/background.js CHANGED
@@ -8,7 +8,13 @@ const client = require('./client');
8
8
 
9
9
  const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
10
10
  const START_TIMEOUT = 20000;
11
- const STOP_TIMEOUT = 10000;
11
+ // Longer than --launch gives Chromium, twice when it retries without the sandbox, then the connect: its own
12
+ // error, and its cleanup, come first.
13
+ const LAUNCH_START_TIMEOUT = 2 * require('./launch').START_TIMEOUT + 20000;
14
+ // Longer than a REPL can take to stop: its cleanups and letting go of the browser (up to
15
+ // SHUTDOWN_TIMEOUT each), then stopping a Chromium it launched.
16
+ const STOP_TIMEOUT = 2 * require('./state').SHUTDOWN_TIMEOUT + require('./launch').STOP_WORST + 4000;
17
+ const LOG_LIMIT = 5 * 1024 * 1024;
12
18
  const FOLLOW_INTERVAL = 150;
13
19
  const ATTACH_BACKLOG = 20;
14
20
  const ATTACH_COMMAND_TIMEOUT = 3600000;
@@ -47,6 +53,13 @@ function tail(file, lines) {
47
53
  try { return fs.readFileSync(file, 'utf8').trimEnd().split('\n').slice(-lines).join('\n'); } catch { return ''; }
48
54
  }
49
55
 
56
+ // Called in the background REPL as it starts: however it ends, served or not, its log says so.
57
+ function markStopInLog() {
58
+ process.on('exit', code => {
59
+ try { fs.writeSync(process.stdout.fd, `--- ${new Date().toISOString()} stopped${code ? ` (exit code ${code})` : ''}\n`); } catch {}
60
+ });
61
+ }
62
+
50
63
  // Called in the background REPL once it serves: its pid file says it runs, and goes when it exits.
51
64
  function claimPidFile(pidFile) {
52
65
  fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
@@ -62,8 +75,12 @@ async function start(options) {
62
75
  const running = runningPid(pidFile);
63
76
  if (running) return fail(`a background REPL is already serving on ${name} (pid ${running}); pw-repl attach uses it, pw-repl stop stops it`);
64
77
  if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
65
- const log = fs.openSync(logFile, 'w', 0o600);
78
+ // Appended to, so an earlier run's log stays; each run is marked where it starts and stops. One grown
79
+ // past LOG_LIMIT is moved to <log>.1 first, replacing the one there.
80
+ try { if (fs.statSync(logFile).size > LOG_LIMIT) fs.renameSync(logFile, `${logFile}.1`); } catch {}
81
+ const log = fs.openSync(logFile, 'a', 0o600);
66
82
  fs.fchmodSync(log, 0o600);
83
+ fs.writeSync(log, `--- ${new Date().toISOString()} started\n`);
67
84
  const args = [BIN, 'serve', ...(options.launch ? ['--launch'] : []), ...(options.headed ? ['--headed'] : []),
68
85
  ...(options.endpoint ? [options.endpoint] : []), ...(options.startUrl ? [options.startUrl] : []),
69
86
  ...(options.chromeArgs.length ? ['--', ...options.chromeArgs] : [])];
@@ -74,11 +91,26 @@ async function start(options) {
74
91
  });
75
92
  fs.closeSync(log);
76
93
  let exited = false;
77
- child.on('exit', () => { exited = true; });
78
- const deadline = Date.now() + START_TIMEOUT;
79
- while (!exited && Date.now() < deadline && !(await client.health(endpoint, 1000) && runningPid(pidFile))) {
94
+ const gone = new Promise(resolve => child.on('exit', () => { exited = true; resolve(); }));
95
+ // Ctrl-C while it waits gives up on the start: the REPL is stopped (and a Chromium it launched with it),
96
+ // rather than left starting where stop cannot reach it yet.
97
+ let interrupted = false;
98
+ const interrupt = () => { interrupted = true; };
99
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
100
+ for (const signal of signals) process.on(signal, interrupt);
101
+ const deadline = Date.now() + (options.launch ? LAUNCH_START_TIMEOUT : START_TIMEOUT);
102
+ while (!exited && !interrupted && Date.now() < deadline && !(await client.health(endpoint, 1000) && runningPid(pidFile))) {
80
103
  await new Promise(resolve => setTimeout(resolve, 200));
81
104
  }
105
+ for (const signal of signals) process.removeListener(signal, interrupt);
106
+ if (interrupted) {
107
+ if (!exited) child.kill();
108
+ await Promise.race([gone, new Promise(resolve => setTimeout(resolve, STOP_TIMEOUT))]);
109
+ try { fs.appendFileSync(logFile, `--- ${new Date().toISOString()} the start was interrupted\n`); } catch {}
110
+ console.error(`pw-repl: ${exited ? 'interrupted; the background REPL was stopped before it served' : `interrupted; the background REPL (pid ${child.pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`}`);
111
+ // As a shell reports a command stopped by Ctrl-C.
112
+ return 130;
113
+ }
82
114
  if (exited || !runningPid(pidFile)) {
83
115
  if (!exited) child.kill();
84
116
  const last = tail(logFile, 12);
@@ -186,4 +218,4 @@ function describeBackground(endpoint) {
186
218
  return `background: pid ${pid}${since}; log ${logFile} (pw-repl attach${e}, pw-repl stop${e})`;
187
219
  }
188
220
 
189
- module.exports = { filesFor, endpointFlag, claimPidFile, start, stop, attach, describeBackground };
221
+ module.exports = { filesFor, endpointFlag, markStopInLog, claimPidFile, start, stop, attach, describeBackground };
package/lib/commands.js CHANGED
@@ -477,6 +477,13 @@ const BODY_TIMEOUT = 15000;
477
477
  const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
478
478
  // The bodies body prints; any other is binary.
479
479
  const TEXT_BODY = /json|^text\/|javascript|xml|html|x-www-form-urlencoded/;
480
+
481
+ // UTF-8 with no control characters but tabs and line breaks.
482
+ function looksLikeText(buffer) {
483
+ let text;
484
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); } catch { return false; }
485
+ return !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
486
+ }
480
487
  // A watched tab keeps each body watch shows as it arrives, since the browser
481
488
  // drops them once the tab navigates, often before anyone has read them.
482
489
  const KEPT_BODY_MAX = 100 * 1024;
@@ -491,6 +498,8 @@ const loadedAt = new WeakMap();
491
498
  const openedTabs = new WeakSet();
492
499
  // page -> the client that opened it (null: the prompt, or a sender without a name).
493
500
  const tabOwners = new WeakMap();
501
+ // The tab the REPL opened for its start URL, which tab marks: in a kept profile it sits among restored tabs.
502
+ const startTabs = new WeakSet();
494
503
 
495
504
  function clipText(text) {
496
505
  return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
@@ -1243,6 +1252,12 @@ async function waitForLoad(timeout) {
1243
1252
  const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
1244
1253
  if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
1245
1254
  const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
1255
+ if (loaded && !navigation && (loadedAt.get(p) || 0) < since) {
1256
+ // Nothing loaded since the previous command: a page that changes its URL itself (pushState) does not
1257
+ // load, and may not have changed it yet.
1258
+ out.log(`Loaded before the previous command, nothing since: ${p.url()} — ${await p.title().catch(() => '')}\n(A page that changes its own URL does not load: wait <selector> or wait text waits for its new view.)`);
1259
+ return;
1260
+ }
1246
1261
  if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
1247
1262
  if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
1248
1263
  await new Promise(resolve => setTimeout(resolve, 50));
@@ -1408,7 +1423,8 @@ async function listTabs() {
1408
1423
  // URL first on its own line: it is what distinguishes otherwise
1409
1424
  // identically titled tabs, and titles wrap less badly when indented.
1410
1425
  if (i) out.log('');
1411
- out.log(`${marker} [${i}] ${p.url()}${tabOwners.get(p) ? ` (opened by ${tabOwners.get(p)})` : ''}`);
1426
+ const notes = [startTabs.has(p) && 'the start URL', tabOwners.get(p) && `opened by ${tabOwners.get(p)}`].filter(Boolean);
1427
+ out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
1412
1428
  out.log(` ${JSON.stringify(title)}`);
1413
1429
  }
1414
1430
  }
@@ -1455,8 +1471,15 @@ const commands = {
1455
1471
  if (parts.length) throw new Error(usage);
1456
1472
  const target = state.tabListing[Number(subcommand)];
1457
1473
  if (target && all.includes(target)) { select(target); return commands.info(); }
1474
+ // Not read as part of a URL instead: that could land in someone else's tab without a word.
1475
+ if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
1458
1476
  // A number that is not a tab in the listing may be part of a URL (a port).
1459
- if (!all.some(p => p.url().includes(subcommand))) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
1477
+ const containing = all.filter(p => p.url().includes(subcommand)).length;
1478
+ if (!containing) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
1479
+ if (containing > 1) {
1480
+ const listing = state.tabListing.length ? 'the latest listing' : 'a listing yet: each client has its own, from tab';
1481
+ throw new Error(`No tab [${subcommand}] in ${listing}. As part of a URL, ${subcommand} is in ${containing} tabs. Run tab, then tab <index>.`);
1482
+ }
1460
1483
  }
1461
1484
  if (subcommand === 'new') {
1462
1485
  select(await openTab());
@@ -1925,7 +1948,8 @@ const commands = {
1925
1948
  async sleep(args) {
1926
1949
  if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
1927
1950
  await state.page.waitForTimeout(Number(args));
1928
- out.log(`Slept ${args}ms`);
1951
+ // sleep 3 is easily meant as seconds.
1952
+ out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
1929
1953
  },
1930
1954
 
1931
1955
  // A stopped upstream does not reach the browser as a failure: the dev proxy
@@ -2049,7 +2073,8 @@ const commands = {
2049
2073
  const type = entry.response.headers()['content-type'] || '';
2050
2074
  out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
2051
2075
  if (!buffer.length) return;
2052
- if (!TEXT_BODY.test(type)) { out.log('[binary body not shown]'); return; }
2076
+ // With no content type, text is told by its bytes: an error page's "not found" often has none.
2077
+ if (!TEXT_BODY.test(type) && (type || !looksLikeText(buffer))) { out.log('[binary body not shown]'); return; }
2053
2078
  let text = buffer.toString('utf8');
2054
2079
  if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
2055
2080
  printOutput(text, all);
@@ -2345,4 +2370,4 @@ function complete(line) {
2345
2370
  return [[], current];
2346
2371
  }
2347
2372
 
2348
- module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
2373
+ module.exports = { commands, cliCommands, needsTab, dialogCommand, listTabs, openTab, startTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
package/lib/help.js CHANGED
@@ -48,7 +48,7 @@ const TOPICS = {
48
48
  commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload'],
49
49
  },
50
50
  inspect: {
51
- intro: 'Output is capped; put --all right after the command for everything (e.g. text --all body).',
51
+ intro: 'Output is capped; --all right after the command, or at the end, shows everything (text --all body).',
52
52
  commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'emulate', 'wait', 'sleep'],
53
53
  },
54
54
  network: {
@@ -165,7 +165,7 @@ const COMMANDS = {
165
165
  summary: 'wait for an element, text, a response or a load (10s)',
166
166
  detail: 'wait <selector> waits for a matching element to be visible, and wait text <text> for text to be;\nwait request <url-part|glob> waits for a matching response, counting one that finished since the\nprevious command began (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. A page\nthat changes its own URL without loading (an app\'s own routing) is not a load: wait for something on\nthe new view instead.\n\nwait text matches any part of an element\'s text, in any case.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\nIt is done at once if nothing matches yet, so wait for it to appear first if it may not have.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
167
167
  },
168
- sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
168
+ sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time, in milliseconds (maximum 3600000)' },
169
169
  requests: {
170
170
  usage: 'requests [--all] [n] [url-filter]',
171
171
  summary: 'the selected tab\'s last n requests (default 20)',
package/lib/launch.js CHANGED
@@ -1,13 +1,19 @@
1
1
  // pw-repl run|serve --launch: a Chromium of the REPL's own, started for it and
2
2
  // stopped with it, in a profile that is removed afterwards. It is the quick
3
3
  // start; a browser set up any other way is reached with PW_CDP_URL instead.
4
+ // A --user-data-dir among the flags after -- is used instead, and kept.
4
5
  const { spawn } = require('child_process');
5
6
  const fs = require('fs');
6
7
  const os = require('os');
7
8
  const path = require('path');
8
9
 
9
10
  const START_TIMEOUT = 20000;
10
- const STOP_TIMEOUT = 5000;
11
+ // Stopping: a moment to connect and ask it to close, a few seconds to close, as long again after SIGTERM,
12
+ // and up to 3s to remove a profile its helpers are still writing to. STOP_WORST is what it can take.
13
+ const CLOSE_CONNECT_TIMEOUT = 2000;
14
+ const CLOSE_TIMEOUT = 3000;
15
+ const TERM_TIMEOUT = 3000;
16
+ const STOP_WORST = CLOSE_CONNECT_TIMEOUT + CLOSE_TIMEOUT + TERM_TIMEOUT + 3000;
11
17
  const INSTALL = 'npx playwright-core install chromium';
12
18
  const ON_PATH = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome', 'microsoft-edge'];
13
19
  const MAC_APPS = [
@@ -48,25 +54,69 @@ function smallShm() {
48
54
  try { const shm = fs.statfsSync('/dev/shm'); return shm.blocks * shm.bsize < 1024 ** 3; } catch { return false; }
49
55
  }
50
56
 
51
- // Starts it and waits for the port it picked, which it writes into its profile.
52
- function start(exe, args, profile) {
57
+ const GROUP_GONE_TIMEOUT = 2000;
58
+
59
+ // A launched Chromium runs in its own process group, so this stops the helper processes it starts too.
60
+ function killGroup(pid) {
61
+ try { process.kill(-pid, 'SIGKILL'); } catch {}
62
+ }
63
+
64
+ async function groupGone(pid) {
65
+ const deadline = Date.now() + GROUP_GONE_TIMEOUT;
66
+ while (Date.now() < deadline) {
67
+ try { process.kill(-pid, 0); } catch { return; }
68
+ await new Promise(resolve => setTimeout(resolve, 50));
69
+ }
70
+ }
71
+
72
+ // Starts it and waits for the port it picked, which it prints (as Playwright reads it too): a
73
+ // DevToolsActivePort file in a profile of the caller's can be one an earlier run left behind.
74
+ // One that does not start is stopped, helpers and all, before this returns.
75
+ function start(exe, args, onSpawn) {
53
76
  return new Promise(resolve => {
54
- // Its own process group, so stopping it also stops the helper processes it starts.
55
77
  const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
78
+ onSpawn(proc);
56
79
  let stderr = '';
80
+ let partial = '';
57
81
  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; });
82
+ const done = result => {
83
+ if (settled) return;
84
+ settled = true; clearTimeout(timer);
85
+ if (!result.error) return resolve(result);
86
+ killGroup(proc.pid);
87
+ // A helper still holding the profile's lock would make a retry in the same profile fail.
88
+ groupGone(proc.pid).then(() => resolve(result));
89
+ };
90
+ proc.stderr.on('data', d => {
91
+ if (stderr.length < 20000) stderr += d;
92
+ const text = partial + d;
93
+ const listening = /^DevTools listening on ws:\/\/[^/\s]+:(\d+)\//m.exec(text);
94
+ if (listening) done({ proc, port: listening[1] });
95
+ partial = text.slice(text.lastIndexOf('\n') + 1);
96
+ });
60
97
  proc.on('error', error => done({ error: error.message, stderr }));
61
98
  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);
99
+ const timer = setTimeout(() => done({ error: `no debugging port within ${START_TIMEOUT / 1000}s`, stderr }), START_TIMEOUT);
67
100
  });
68
101
  }
69
102
 
103
+ // Chromium takes only --user-data-dir=<dir>, and the last one given.
104
+ function userDataDir(args) {
105
+ const given = args.filter(a => a.startsWith('--user-data-dir=')).pop();
106
+ return given ? path.resolve(given.slice('--user-data-dir='.length)) : null;
107
+ }
108
+
109
+ async function closeBrowser(url) {
110
+ let browser;
111
+ try {
112
+ browser = await require('playwright-core').chromium.connectOverCDP(url, { timeout: CLOSE_CONNECT_TIMEOUT });
113
+ const session = await browser.newBrowserCDPSession();
114
+ // It closes before it answers, or as it does.
115
+ await session.send('Browser.close').catch(() => {});
116
+ } catch {}
117
+ try { await browser?.close(); } catch {}
118
+ }
119
+
70
120
  function quoted(args) {
71
121
  return args.map(a => (/[\s"'$]/.test(a) ? JSON.stringify(a) : a)).join(' ');
72
122
  }
@@ -76,40 +126,60 @@ async function launch({ headed = false, extraArgs = [] }) {
76
126
  if (!exe) {
77
127
  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
128
  }
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}`,
129
+ const given = userDataDir(extraArgs);
130
+ const profile = given || fs.mkdtempSync(path.join(os.tmpdir(), 'pw-repl-chrome-'));
131
+ const removeProfile = () => { if (!given) fs.rmSync(profile, { recursive: true, force: true }); };
132
+ // Registered before Chromium starts, and kept for the REPL's life: a REPL that goes without shutting
133
+ // down, even while it waits for Chromium, still takes it (and a profile of its own) along.
134
+ let group = null;
135
+ const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
136
+ process.on('exit', onExit);
137
+ const onSpawn = proc => { group = proc.pid; };
138
+ const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
81
139
  '--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
140
+ // A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
141
+ // Local State is written at its root on the first run, whichever --profile-directory is used.
142
+ const blank = given && fs.existsSync(path.join(given, 'Local State')) ? [] : ['about:blank'];
82
143
  // 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);
144
+ let args = [...ours, ...extraArgs, ...blank];
145
+ let result = await start(exe, args, onSpawn);
85
146
  let note = null;
86
147
  // Tried with the sandbox first; a container often cannot give Chromium one.
87
148
  if (result.error && /sandbox/i.test(result.stderr)) {
88
- args = [...ours, '--no-sandbox', ...extraArgs, 'about:blank'];
89
- result = await start(exe, args, profile);
149
+ args = [...ours, '--no-sandbox', ...extraArgs, ...blank];
150
+ result = await start(exe, args, onSpawn);
90
151
  note = 'Chromium could not use its sandbox here, so it runs with --no-sandbox.';
91
152
  }
92
153
  const command = quoted([exe, ...args]);
93
154
  if (result.error) {
94
- fs.rmSync(profile, { recursive: true, force: true });
155
+ process.removeListener('exit', onExit);
156
+ removeProfile();
95
157
  const last = result.stderr.trim().split('\n').slice(-5).join('\n');
96
158
  throw new Error(`Chromium did not start (${result.error}):\n ${command}${last ? `\n${last}` : ''}`);
97
159
  }
98
160
  const { proc, port } = result;
99
- const signal = name => { try { process.kill(-proc.pid, name); } catch {} };
161
+ const url = `http://127.0.0.1:${port}`;
162
+ const exited = new Promise(resolve => { if (proc.exitCode !== null || proc.signalCode !== null) resolve(); else proc.once('exit', resolve); });
163
+ const exitsWithin = ms => Promise.race([exited.then(() => true), new Promise(resolve => setTimeout(resolve, ms, false))]);
100
164
  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(); }); });
165
+ if (!(await exitsWithin(0))) {
166
+ // Browser.close shuts it down in order: its helpers save the profile (what pages last wrote to
167
+ // localStorage) and it removes the profile's lock. SIGTERM to the browser process saves the
168
+ // profile too but leaves the lock; to the whole group, it loses the last writes.
169
+ // Not awaited: a browser that hangs may never answer; its exit is what counts.
170
+ void closeBrowser(url);
171
+ if (!(await exitsWithin(CLOSE_CONNECT_TIMEOUT + CLOSE_TIMEOUT))) {
172
+ try { process.kill(proc.pid, 'SIGTERM'); } catch {}
173
+ await exitsWithin(TERM_TIMEOUT);
174
+ }
104
175
  }
176
+ killGroup(proc.pid);
105
177
  // Its helper processes can still be writing to the profile for a moment.
106
178
  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)); }
179
+ try { removeProfile(); return; } catch { await new Promise(resolve => setTimeout(resolve, 100)); }
108
180
  }
109
181
  };
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 };
182
+ return { url, command, note, headed, stop };
113
183
  }
114
184
 
115
- module.exports = { findChrome, launch, INSTALL };
185
+ module.exports = { findChrome, launch, INSTALL, START_TIMEOUT, STOP_WORST };
package/lib/output.js CHANGED
@@ -61,7 +61,7 @@ function printOutput(value, all = false) {
61
61
  else text = JSON.stringify(value, null, 2);
62
62
  if (text === undefined) text = 'undefined';
63
63
  if (!all && text.length > OUTPUT_LIMIT) {
64
- log(`${text.slice(0, OUTPUT_LIMIT)}\n[truncated; use --all after the command to show all]`);
64
+ log(`${text.slice(0, OUTPUT_LIMIT)}\n[truncated; add --all to the command to show all]`);
65
65
  } else {
66
66
  log(text);
67
67
  }
package/lib/runner.js CHANGED
@@ -30,7 +30,7 @@ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count'
30
30
  // Commands that work with no tab selected.
31
31
  const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit', 'tab-select', 'close']);
32
32
 
33
- // Commands that accept a leading --all to lift the output limit.
33
+ // Commands that accept --all, right after the command or at the end, to lift the output limit.
34
34
  const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
35
35
 
36
36
  let queue = Promise.resolve();
@@ -85,8 +85,10 @@ async function executeCommand(text) {
85
85
  const spaceIdx = text.indexOf(' ');
86
86
  const cmd = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
87
87
  let args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1);
88
- const all = INSPECTION.includes(cmd) && /^--all(?:\s|$)/.test(args);
89
- if (all) args = args.slice(5).trimStart();
88
+ let all = false;
89
+ if (INSPECTION.includes(cmd) && /^--all(?:\s|$)/.test(args)) { all = true; args = args.slice(5).trimStart(); }
90
+ // At the end too, where the truncation note's reader tends to put it (console 200 --all).
91
+ else if (INSPECTION.includes(cmd) && /(?:^|\s)--all$/.test(args)) { all = true; args = args.slice(0, -5).trimEnd(); }
90
92
  // wait request counts responses since the previous command began.
91
93
  state.previousCommandAt = state.currentCommandAt;
92
94
  state.currentCommandAt = Date.now();
package/lib/start.js CHANGED
@@ -3,7 +3,7 @@ const { chromium } = require('playwright-core');
3
3
  const readline = require('readline');
4
4
  const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./state');
5
5
  const out = require('./output');
6
- const { listTabs, openTab, watchPage, complete } = require('./commands');
6
+ const { listTabs, openTab, startTabs, watchPage, complete } = require('./commands');
7
7
  const runner = require('./runner');
8
8
 
9
9
  // A browser --launch starts has its own address instead.
@@ -28,6 +28,7 @@ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve
28
28
 
29
29
  async function start(options) {
30
30
  const START_URL = options.startUrl || process.env.PW_START_URL || null;
31
+ if (options.pidFile) require('./background').markStopInLog();
31
32
  if (options.launch) {
32
33
  const launched = await require('./launch').launch({ headed: options.headed, extraArgs: options.chromeArgs });
33
34
  onBeforeExit(launched.stop);
@@ -72,6 +73,7 @@ async function start(options) {
72
73
 
73
74
  if (START_URL) {
74
75
  state.page = await openTab();
76
+ startTabs.add(state.page);
75
77
  await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
76
78
  out.log(`Opened ${state.page.url()} in a new tab`);
77
79
  }
@@ -150,7 +152,11 @@ function stop() {
150
152
  shutdown().then(beforeExit).then(() => process.exit(process.exitCode || 0));
151
153
  }
152
154
 
153
- // SIGHUP too: closing the terminal must still remove the server's socket.
154
- for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, stop);
155
+ // SIGHUP too: closing the terminal must still remove the server's socket. SIGINT is Ctrl-C before the
156
+ // prompt reads it (while --launch starts Chromium, or while connecting): without this, node exits at
157
+ // once and leaves a launched Chromium running.
158
+ for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT']) process.on(signal, stop);
155
159
 
156
- module.exports = { start: options => start(options).catch(e => { out.error(e.message); shutdown().then(beforeExit).finally(() => process.exit(1)); }) };
160
+ // Stopped while it starts (Ctrl-C), what was under way fails as the browser goes: stop() says nothing
161
+ // of that, and exits.
162
+ module.exports = { start: options => start(options).catch(e => { if (state.stopping) return; out.error(e.message); shutdown().then(beforeExit).finally(() => process.exit(1)); }) };
package/lib/state.js CHANGED
@@ -83,4 +83,4 @@ async function shutdown() {
83
83
  return shutdownPromise;
84
84
  }
85
85
 
86
- module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown };
86
+ module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -25,12 +25,19 @@ one; each takes an optional start URL, which opens in a new tab.
25
25
 
26
26
  - `pw-repl serve --background` runs it detached, with a command server on `/tmp/playwright-repl.sock`
27
27
  (owner-only) and its output in `/tmp/playwright-repl.log`. `pw-repl attach` shows everything it does
28
- and takes commands; `pw-repl stop` stops it.
28
+ and takes commands; `pw-repl stop` stops it. It returns once the REPL serves; Ctrl-C before then gives
29
+ up on the start, stops the REPL, exits 130, and says so in the log.
29
30
  - `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
30
31
  `pw[serve]>`.
31
32
  - Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
32
- own instead of connecting to one: headless unless `--headed`, in a temporary profile, stopped with the
33
- REPL. It prints the command it ran; flags after `--` are passed to that Chromium.
33
+ own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port of
34
+ its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns once
35
+ it has exited. It prints the command it ran; flags after `--` are passed to that Chromium, and a
36
+ `--user-data-dir=<dir>` among them is used instead of the temporary profile, and kept. Started again in
37
+ a kept profile, Chromium picks up where it left off, as it would for a person, headless too: its tabs
38
+ come back, and session cookies with them, so a login stays. They start loading before the REPL is
39
+ attached, so `requests` and `console` have only what came after, often nothing: `reload` for a full
40
+ record.
34
41
  - `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
35
42
  the tmux session `playwright-repl`:
36
43
 
@@ -41,9 +48,11 @@ one; each takes an optional start URL, which opens in a new tab.
41
48
 
42
49
  `serve` and `serve --background` take a socket path of your own instead of the default
43
50
  (`pw-repl serve --background /tmp/mine.sock`); `send`, `attach`, `stop` and `where` then need
44
- `-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`. The log of a background REPL is next to its socket
45
- (`/tmp/mine.log`); `tail -f` on it follows along without a terminal to attach from. `serve <port>`
46
- listens on TCP 127.0.0.1 instead of a socket, with no access control.
51
+ `-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`; a socket file left by a REPL that died is replaced
52
+ when the next one starts there. The log of a background REPL is next to its socket
53
+ (`/tmp/mine.log`), appended to run after run, with a line where each starts and stops (past 5 MB, a
54
+ start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along without a terminal to attach from.
55
+ `serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no access control.
47
56
 
48
57
  ### One REPL, many clients
49
58
 
@@ -145,8 +154,10 @@ pw-repl send -c <name> close # your modes off; tab close
145
154
  - The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
146
155
  one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
147
156
  clear, ask.
148
- - No tab is selected when the REPL starts (unless it was given a start URL). Closing a tab the REPL
149
- opened goes back to the tab before it, if the REPL opened that one too; otherwise no tab is selected.
157
+ - No tab is selected when the REPL starts. A start URL's tab, marked `(the start URL)` in `tab`, is
158
+ selected at its prompt and for `send` without a client name; a named client (`-c`) selects it with
159
+ `tab <index>`. Closing a tab the REPL opened goes back to the tab before it, if the REPL opened that
160
+ one too; otherwise no tab is selected.
150
161
  Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
151
162
  its URL and refuse if it is ambiguous.
152
163
  - Dialogs are never answered on their own. While one is open, its page and the commands that read it