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/AGENTS.md +10 -6
- package/README.md +31 -21
- package/bin/pw-repl.js +20 -6
- package/lib/background.js +3 -1
- package/lib/client.js +16 -6
- package/lib/commands.js +424 -73
- package/lib/help.js +36 -16
- package/lib/launch.js +115 -0
- package/lib/runner.js +2 -2
- package/lib/send.js +7 -4
- package/lib/server.js +2 -2
- package/lib/start.js +19 -6
- package/lib/state.js +5 -1
- package/lib/syntax.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -4
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
|
|
22
|
-
(watch routes:1) pw>. tab, watch, capture, route, network and modes on
|
|
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
|
|
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> <
|
|
150
|
-
summary: '
|
|
151
|
-
detail: '
|
|
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
|
|
156
|
-
detail: 'Stopping a service is not the same: a dev proxy in front of
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
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,
|
|
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
|
|
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
|
|
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.
|