pw-repl 0.4.0 → 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/README.md +30 -0
- package/bin/pw-repl.js +5 -4
- package/lib/background.js +38 -6
- package/lib/commands.js +125 -18
- package/lib/help.js +13 -5
- package/lib/launch.js +97 -27
- package/lib/output.js +1 -1
- package/lib/runner.js +5 -3
- package/lib/start.js +10 -4
- package/lib/state.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +22 -10
package/README.md
CHANGED
|
@@ -6,6 +6,36 @@ one shows in the REPL, so each sees what the other does. The REPL inspects the p
|
|
|
6
6
|
(requests, console, and each step a person takes), fakes, patches or slows responses, and emulates phones,
|
|
7
7
|
dark mode, locales and timezones.
|
|
8
8
|
|
|
9
|
+
## Why use it
|
|
10
|
+
|
|
11
|
+
For working on a web page together with an agent, in the browser you already use:
|
|
12
|
+
|
|
13
|
+
- **Your browser, as it is.** It connects to a running Chromium, with your tabs, sign-ins and state, and
|
|
14
|
+
leaves it running when it stops; nothing is reset to a fresh profile.
|
|
15
|
+
- **One session, seen by everyone.** You and any number of agents drive the same browser, each with a
|
|
16
|
+
selected tab of its own, and every command shows in the REPL, whoever sent it.
|
|
17
|
+
- **What you did, for an agent to read.** `watch` records each step you take in a tab, with the requests
|
|
18
|
+
it caused, so you can reproduce a bug by hand and the agent can see exactly what happened.
|
|
19
|
+
- **Breaking things on purpose.** Fake, patch, delay or fail responses, cut or slow the network, emulate a
|
|
20
|
+
phone, a locale or a timezone, all per tab, so your other tabs are left alone.
|
|
21
|
+
|
|
22
|
+
## How it differs from playwright-cli
|
|
23
|
+
|
|
24
|
+
[playwright-cli](https://github.com/microsoft/playwright-cli) is Microsoft's command line for agents. It
|
|
25
|
+
gives each agent a browser session of its own; pw-repl shares one browser between a person and agents.
|
|
26
|
+
Its command names and most of their options work here too (`help playwright-cli` lists them), and where
|
|
27
|
+
pw-repl differs, it is because someone else may be using the browser:
|
|
28
|
+
|
|
29
|
+
- `open` and `close` open a tab and turn off your modes; they never start, close or wipe a browser.
|
|
30
|
+
- Routes, network and emulation apply to one tab, not the whole browser.
|
|
31
|
+
- File pickers are not caught, since they may be the person's: `upload` names the input instead.
|
|
32
|
+
- There is no snapshot after every command, since a person reads the REPL too; `snapshot` shows one.
|
|
33
|
+
- Instead of `-s=<session>`, an agent is a client of its own (`send -c <name>`).
|
|
34
|
+
- `watch` records what a person does as steps with their requests, not as generated code.
|
|
35
|
+
|
|
36
|
+
For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
|
|
37
|
+
playwright-cli is the better fit.
|
|
38
|
+
|
|
9
39
|
## Getting started
|
|
10
40
|
|
|
11
41
|
### Prerequisites
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
|
@@ -132,7 +133,7 @@ function help(topic) {
|
|
|
132
133
|
const helpText = require('../lib/help');
|
|
133
134
|
const text = helpText.render(topic);
|
|
134
135
|
if (text === null) {
|
|
135
|
-
console.log(`Error:
|
|
136
|
+
console.log(`Error: ${helpText.notFound(topic)}`);
|
|
136
137
|
process.exit(1);
|
|
137
138
|
}
|
|
138
139
|
console.log(text);
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
@@ -180,6 +180,28 @@ function keptSession(p) {
|
|
|
180
180
|
return keptSessions.get(p);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Chrome all but stops drawing a tab that is not in front once it has had
|
|
184
|
+
// input (about a frame a second), and Playwright waits for frames to see an
|
|
185
|
+
// element hold still before acting on it, so each click in a tab opened in the
|
|
186
|
+
// background took 1-2s. A screencast keeps the tab drawing without bringing it
|
|
187
|
+
// to the front, for as long as the REPL runs (the throttle is back within
|
|
188
|
+
// seconds of stopping it). Its frames are tiny and rare; the cost is that the
|
|
189
|
+
// tab renders at full rate, as if in front, an animation or a video included.
|
|
190
|
+
const drawingPages = new WeakSet();
|
|
191
|
+
|
|
192
|
+
async function keepDrawing(p) {
|
|
193
|
+
if (drawingPages.has(p)) return;
|
|
194
|
+
drawingPages.add(p);
|
|
195
|
+
try {
|
|
196
|
+
const session = await keptSession(p);
|
|
197
|
+
session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
|
|
198
|
+
await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
|
|
199
|
+
} catch {
|
|
200
|
+
// Only slower without it.
|
|
201
|
+
drawingPages.delete(p);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
183
205
|
const networkEnabled = new WeakSet();
|
|
184
206
|
// page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
|
|
185
207
|
const networkChanged = new WeakMap();
|
|
@@ -453,6 +475,18 @@ const BODY_TIMEOUT = 15000;
|
|
|
453
475
|
// Hidden unless asked for: they are rarely what is being checked and crowd out
|
|
454
476
|
// the API calls that are.
|
|
455
477
|
const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
|
|
478
|
+
// The bodies body prints; any other is binary.
|
|
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
|
+
}
|
|
487
|
+
// A watched tab keeps each body watch shows as it arrives, since the browser
|
|
488
|
+
// drops them once the tab navigates, often before anyone has read them.
|
|
489
|
+
const KEPT_BODY_MAX = 100 * 1024;
|
|
456
490
|
const recentLogs = new WeakMap();
|
|
457
491
|
const consoleLogs = new WeakMap();
|
|
458
492
|
// request -> how a route changed it (faked or patched), shown after its status.
|
|
@@ -464,6 +498,8 @@ const loadedAt = new WeakMap();
|
|
|
464
498
|
const openedTabs = new WeakSet();
|
|
465
499
|
// page -> the client that opened it (null: the prompt, or a sender without a name).
|
|
466
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();
|
|
467
503
|
|
|
468
504
|
function clipText(text) {
|
|
469
505
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
@@ -523,6 +559,7 @@ function ensureRecentLog(p) {
|
|
|
523
559
|
if (res) status = String(res.status());
|
|
524
560
|
} catch {}
|
|
525
561
|
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
562
|
+
keepBody(p, requestEntries.get(req), res);
|
|
526
563
|
});
|
|
527
564
|
p.on('requestfailed', req => {
|
|
528
565
|
finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`);
|
|
@@ -564,6 +601,38 @@ const WATCH_MAX = 200;
|
|
|
564
601
|
const WATCH_REQUESTS_SHOWN = 5;
|
|
565
602
|
// Scripts too: a dev server loads dozens per navigation, crowding out the API calls.
|
|
566
603
|
const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
|
|
604
|
+
|
|
605
|
+
// Tabs whose watch on --next-tab is still starting: the page can load before it is ready.
|
|
606
|
+
const startingWatches = new WeakSet();
|
|
607
|
+
|
|
608
|
+
// A page that navigates as soon as a request answers (a login that redirects)
|
|
609
|
+
// takes the body with it before the request is even reported finished; told to
|
|
610
|
+
// in time, the browser keeps them outside the page instead. Once per tab, from
|
|
611
|
+
// its first watch, and never switched off: up to 50 MB of the browser's memory
|
|
612
|
+
// per watched tab, for as long as the REPL runs.
|
|
613
|
+
const DURABLE_TOTAL = 50 * 1024 * 1024;
|
|
614
|
+
const durablePages = new WeakSet();
|
|
615
|
+
|
|
616
|
+
async function keepBodiesDurable(p) {
|
|
617
|
+
if (durablePages.has(p)) return;
|
|
618
|
+
durablePages.add(p);
|
|
619
|
+
try {
|
|
620
|
+
const session = await keptSession(p);
|
|
621
|
+
await session.send('Network.configureDurableMessages', { maxTotalBufferSize: DURABLE_TOTAL, maxResourceBufferSize: KEPT_BODY_MAX });
|
|
622
|
+
} catch {
|
|
623
|
+
// An older Chrome: bodies are still kept once their requests finish.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function keepBody(p, entry, res) {
|
|
628
|
+
if (!entry || !res || !(watches.get(p)?.on || startingWatches.has(p)) || WATCH_HIDDEN_TYPES.has(entry.type)) return;
|
|
629
|
+
const headers = res.headers();
|
|
630
|
+
if (!TEXT_BODY.test(headers['content-type'] || '') || Number(headers['content-length']) > KEPT_BODY_MAX) return;
|
|
631
|
+
try {
|
|
632
|
+
const buffer = await res.body();
|
|
633
|
+
if (buffer.length <= KEPT_BODY_MAX) entry.kept = buffer;
|
|
634
|
+
} catch {}
|
|
635
|
+
}
|
|
567
636
|
const TYPING_BACKDATE_MAX = 60000;
|
|
568
637
|
const PAGE_ACTIONS = new Set(['click', 'check', 'uncheck', 'select', 'fill', 'type', 'press', 'submit']);
|
|
569
638
|
// page -> its binding's record function; the binding is added once per tab.
|
|
@@ -810,6 +879,7 @@ function printLive(p, watch) {
|
|
|
810
879
|
}
|
|
811
880
|
|
|
812
881
|
async function startWatching(p, changes = false, live = false) {
|
|
882
|
+
await keepBodiesDurable(p);
|
|
813
883
|
let watch = watches.get(p);
|
|
814
884
|
if (!watch) {
|
|
815
885
|
// shownSeq and shownRequestId mark how far watch new has read.
|
|
@@ -880,6 +950,11 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
880
950
|
// new tab is watched from the moment it opens, since its first page may load
|
|
881
951
|
// at once; one that turns out not to be it is left as it was.
|
|
882
952
|
let nextTab = null;
|
|
953
|
+
|
|
954
|
+
// The sender's own --next-tab is still waiting: the tab will be selected for it.
|
|
955
|
+
function waitingForMe() {
|
|
956
|
+
return !!nextTab && nextTab.client === state.client;
|
|
957
|
+
}
|
|
883
958
|
const BROWSER_PAGE = /^(?:about|chrome|chrome-search|devtools|edge):/;
|
|
884
959
|
|
|
885
960
|
function waitingText() {
|
|
@@ -888,7 +963,10 @@ function waitingText() {
|
|
|
888
963
|
|
|
889
964
|
function watchNewTab(p) {
|
|
890
965
|
const want = nextTab;
|
|
966
|
+
startingWatches.add(p);
|
|
967
|
+
keepBodiesDurable(p);
|
|
891
968
|
startWatching(p, want.changes, want.live).then(() => {
|
|
969
|
+
startingWatches.delete(p);
|
|
892
970
|
watches.get(p).owner = want.client;
|
|
893
971
|
const onNavigate = frame => {
|
|
894
972
|
if (frame !== p.mainFrame() || BROWSER_PAGE.test(frame.url())) return;
|
|
@@ -912,12 +990,16 @@ function watchNewTab(p) {
|
|
|
912
990
|
// Selected for the client that asked, whoever's command runs now.
|
|
913
991
|
const record = clientRecord(want.client);
|
|
914
992
|
if (record.page !== p) { record.previousPage = record.page; record.page = p; }
|
|
993
|
+
keepDrawing(p);
|
|
915
994
|
out.notice(`Watching the new tab ${url}, now the selected tab (watch on --next-tab)`);
|
|
916
995
|
};
|
|
917
996
|
p.on('framenavigated', onNavigate);
|
|
918
997
|
// Opened on its page already, as a link to a new tab does.
|
|
919
998
|
if (p.url() && !BROWSER_PAGE.test(p.url())) onNavigate(p.mainFrame());
|
|
920
|
-
}).catch(error =>
|
|
999
|
+
}).catch(error => {
|
|
1000
|
+
startingWatches.delete(p);
|
|
1001
|
+
out.notice(`Could not watch the new tab ${p.url()}: ${error.message}`);
|
|
1002
|
+
});
|
|
921
1003
|
}
|
|
922
1004
|
|
|
923
1005
|
// For each step of a watch, the requests made between it and the next step.
|
|
@@ -1170,6 +1252,12 @@ async function waitForLoad(timeout) {
|
|
|
1170
1252
|
const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
|
|
1171
1253
|
if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
|
|
1172
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
|
+
}
|
|
1173
1261
|
if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
|
|
1174
1262
|
if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
|
|
1175
1263
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
@@ -1291,6 +1379,7 @@ async function openOwnTab() {
|
|
|
1291
1379
|
if (!opened) opened = await ctx.newPage();
|
|
1292
1380
|
openedTabs.add(opened);
|
|
1293
1381
|
tabOwners.set(opened, state.client);
|
|
1382
|
+
keepDrawing(opened);
|
|
1294
1383
|
return opened;
|
|
1295
1384
|
}
|
|
1296
1385
|
|
|
@@ -1334,7 +1423,8 @@ async function listTabs() {
|
|
|
1334
1423
|
// URL first on its own line: it is what distinguishes otherwise
|
|
1335
1424
|
// identically titled tabs, and titles wrap less badly when indented.
|
|
1336
1425
|
if (i) out.log('');
|
|
1337
|
-
|
|
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(', ')})` : ''}`);
|
|
1338
1428
|
out.log(` ${JSON.stringify(title)}`);
|
|
1339
1429
|
}
|
|
1340
1430
|
}
|
|
@@ -1369,6 +1459,7 @@ const commands = {
|
|
|
1369
1459
|
if (p !== state.page) state.previousPage = state.page;
|
|
1370
1460
|
state.page = p;
|
|
1371
1461
|
ensureDialogHandler(p);
|
|
1462
|
+
keepDrawing(p);
|
|
1372
1463
|
};
|
|
1373
1464
|
const byUrl = part => {
|
|
1374
1465
|
const matches = all.filter(p => p.url().includes(part));
|
|
@@ -1380,8 +1471,15 @@ const commands = {
|
|
|
1380
1471
|
if (parts.length) throw new Error(usage);
|
|
1381
1472
|
const target = state.tabListing[Number(subcommand)];
|
|
1382
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.`);
|
|
1383
1476
|
// A number that is not a tab in the listing may be part of a URL (a port).
|
|
1384
|
-
|
|
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
|
+
}
|
|
1385
1483
|
}
|
|
1386
1484
|
if (subcommand === 'new') {
|
|
1387
1485
|
select(await openTab());
|
|
@@ -1850,7 +1948,8 @@ const commands = {
|
|
|
1850
1948
|
async sleep(args) {
|
|
1851
1949
|
if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
|
|
1852
1950
|
await state.page.waitForTimeout(Number(args));
|
|
1853
|
-
|
|
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`);
|
|
1854
1953
|
},
|
|
1855
1954
|
|
|
1856
1955
|
// A stopped upstream does not reach the browser as a failure: the dev proxy
|
|
@@ -1960,19 +2059,22 @@ const commands = {
|
|
|
1960
2059
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1961
2060
|
}
|
|
1962
2061
|
if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2062
|
+
let buffer = entry.kept;
|
|
2063
|
+
if (!buffer) {
|
|
2064
|
+
// Not withTimeout: its "timed out" error would disconnect the REPL, and
|
|
2065
|
+
// reading a body cannot have changed anything.
|
|
2066
|
+
let timer;
|
|
2067
|
+
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
2068
|
+
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
2069
|
+
// Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
|
|
2070
|
+
catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates; a watched tab keeps them as they arrive)`); }
|
|
2071
|
+
finally { clearTimeout(timer); }
|
|
2072
|
+
}
|
|
1972
2073
|
const type = entry.response.headers()['content-type'] || '';
|
|
1973
2074
|
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
|
1974
2075
|
if (!buffer.length) return;
|
|
1975
|
-
|
|
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; }
|
|
1976
2078
|
let text = buffer.toString('utf8');
|
|
1977
2079
|
if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
|
|
1978
2080
|
printOutput(text, all);
|
|
@@ -2012,6 +2114,10 @@ const commands = {
|
|
|
2012
2114
|
out.log(`${waitingText()}: it is watched from its first page, and selected. watch off stops waiting.`);
|
|
2013
2115
|
return;
|
|
2014
2116
|
}
|
|
2117
|
+
if (sub !== 'on' && arg !== 'off' && waitingForMe() && (!state.page || state.page.isClosed())) {
|
|
2118
|
+
out.log(`${waitingText()} (watch on --next-tab); it is selected once it opens, and watch then shows its steps.`);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2015
2121
|
if (sub === 'on' && flags.every(f => f === '--changes' || f === '--live')) {
|
|
2016
2122
|
if (!state.page || state.page.isClosed()) throw new Error('No tab is selected; select one with tab <index|url-part>, or open one with tab new');
|
|
2017
2123
|
const changes = flags.includes('--changes');
|
|
@@ -2175,7 +2281,7 @@ const commands = {
|
|
|
2175
2281
|
async help(args) {
|
|
2176
2282
|
const topic = (args || '').trim();
|
|
2177
2283
|
const text = HELP.render(topic);
|
|
2178
|
-
if (text === null) throw new Error(
|
|
2284
|
+
if (text === null) throw new Error(HELP.notFound(topic));
|
|
2179
2285
|
out.log(text);
|
|
2180
2286
|
},
|
|
2181
2287
|
|
|
@@ -2227,9 +2333,10 @@ function watchPage(p) {
|
|
|
2227
2333
|
if (nextTab && !opening) watchNewTab(p);
|
|
2228
2334
|
}
|
|
2229
2335
|
|
|
2230
|
-
// watch on --next-tab waits for a tab, and watch off stops waiting, with none selected
|
|
2336
|
+
// watch on --next-tab waits for a tab, and watch off stops waiting, with none selected;
|
|
2337
|
+
// watch says it is still waiting.
|
|
2231
2338
|
function needsTab(cmd, args) {
|
|
2232
|
-
return !(cmd === 'watch' && (/(?:^|\s)--next-tab(?:\s|$)/.test(args) || /^\s*off\s*$/.test(args)));
|
|
2339
|
+
return !(cmd === 'watch' && (/(?:^|\s)--next-tab(?:\s|$)/.test(args) || /^\s*off\s*$/.test(args) || waitingForMe()));
|
|
2233
2340
|
}
|
|
2234
2341
|
|
|
2235
2342
|
// Tab completion for the prompt: command names first, then the fixed words
|
|
@@ -2263,4 +2370,4 @@ function complete(line) {
|
|
|
2263
2370
|
return [[], current];
|
|
2264
2371
|
}
|
|
2265
2372
|
|
|
2266
|
-
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;
|
|
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: {
|
|
@@ -135,7 +135,7 @@ const COMMANDS = {
|
|
|
135
135
|
watch: {
|
|
136
136
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
137
137
|
summary: 'record what happens in the tab; show the last n steps',
|
|
138
|
-
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath, each with its status as in requests\n(body <#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded\nas type.\n\nwatch on --next-tab [url-part] returns at once, then waits for the next tab someone opens (tab new,\na client\'s own, does not count), or the next whose URL contains url-part, and watches it from its\nfirst page and selects it for the client that asked: for a tab someone is about to open. It takes\n--changes and --live too; watch off stops waiting, and modes lists it.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations: what a\npage changes on its own after a step settles (data arriving later) shows under the next step.\n\nA watch belongs to the tab, not to a client: every client sees the same steps, watch new\'s place is\nshared, and anyone\'s watch off stops it.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
138
|
+
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath, each with its status as in requests\n(body <#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded\nas type.\n\nFrom its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one\nafter the tab navigates, even when the page navigates as soon as the request answers.\n\nwatch on --next-tab [url-part] returns at once, then waits for the next tab someone opens (tab new,\na client\'s own, does not count), or the next whose URL contains url-part, and watches it from its\nfirst page and selects it for the client that asked: for a tab someone is about to open. It takes\n--changes and --live too; watch off stops waiting, and modes lists it.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations: what a\npage changes on its own after a step settles (data arriving later) shows under the next step.\n\nA watch belongs to the tab, not to a client: every client sees the same steps, watch new\'s place is\nshared, and anyone\'s watch off stops it.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
139
139
|
},
|
|
140
140
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
141
141
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
@@ -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)',
|
|
@@ -174,7 +174,7 @@ const COMMANDS = {
|
|
|
174
174
|
body: {
|
|
175
175
|
usage: 'body [--all] <#>|<url-part>',
|
|
176
176
|
summary: 'the response body of request <#>, or of the latest one matching',
|
|
177
|
-
detail: 'body <url-part> shows the latest finished request whose URL contains url-part (body slots.json).\n\nRead from the browser on demand: JSON is pretty-printed, binary is not shown, output is capped. The\nbrowser may drop a body (e.g. after the tab navigates); then body reports not available.\n\nBodies can contain sensitive data.',
|
|
177
|
+
detail: 'body <url-part> shows the latest finished request whose URL contains url-part (body slots.json).\n\nRead from the browser on demand: JSON is pretty-printed, binary is not shown, output is capped. The\nbrowser may drop a body (e.g. after the tab navigates); then body reports not available, unless a\nwatch kept it (help watch).\n\nBodies can contain sensitive data.',
|
|
178
178
|
},
|
|
179
179
|
console: {
|
|
180
180
|
usage: 'console [--all] [n] [level|filter]',
|
|
@@ -280,4 +280,12 @@ Different on purpose, since the browser may be someone else's too:
|
|
|
280
280
|
return null;
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
// pw-repl's own commands are run at the shell, so their help is the usage.
|
|
284
|
+
const SHELL_COMMANDS = new Set(['run', 'serve', 'send', 'attach', 'stop', 'where', 'skill']);
|
|
285
|
+
|
|
286
|
+
function notFound(topic) {
|
|
287
|
+
if (SHELL_COMMANDS.has(topic)) return `${topic} is run at the shell (pw-repl ${topic}), not in the REPL; pw-repl --help has its options`;
|
|
288
|
+
return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { render, notFound, TOPICS, COMMANDS };
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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 => {
|
|
59
|
-
|
|
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
|
|
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
|
|
80
|
-
const
|
|
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,
|
|
84
|
-
let result = await start(exe, args,
|
|
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,
|
|
89
|
-
result = await start(exe, args,
|
|
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
|
-
|
|
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
|
|
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 (
|
|
102
|
-
|
|
103
|
-
|
|
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 {
|
|
179
|
+
try { removeProfile(); return; } catch { await new Promise(resolve => setTimeout(resolve, 100)); }
|
|
108
180
|
}
|
|
109
181
|
};
|
|
110
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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,
|
|
33
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
|
@@ -99,8 +108,9 @@ from its first page.
|
|
|
99
108
|
|
|
100
109
|
When watching someone:
|
|
101
110
|
|
|
102
|
-
-
|
|
103
|
-
|
|
111
|
+
- A watched tab keeps response bodies of up to 100 KB, so `body <#>` still has them after the tab
|
|
112
|
+
navigates. Read a bigger one that matters as soon as it shows up in `requests`: the browser drops it
|
|
113
|
+
once the tab navigates.
|
|
104
114
|
- `watch` leaves out images and other static files, failed ones too; `console error` and
|
|
105
115
|
`requests --all` show what failed.
|
|
106
116
|
- Without a url-part, `--next-tab` takes the first tab anyone opens; check `tab` and `info` once it
|
|
@@ -144,8 +154,10 @@ pw-repl send -c <name> close # your modes off; tab close
|
|
|
144
154
|
- The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
|
|
145
155
|
one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
|
|
146
156
|
clear, ask.
|
|
147
|
-
- No tab is selected when the REPL starts
|
|
148
|
-
|
|
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.
|
|
149
161
|
Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
|
|
150
162
|
its URL and refuse if it is ambiguous.
|
|
151
163
|
- Dialogs are never answered on their own. While one is open, its page and the commands that read it
|