pw-repl 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -12
- package/bin/pw-repl.js +17 -5
- package/lib/background.js +3 -1
- package/lib/client.js +16 -6
- package/lib/commands.js +143 -34
- package/lib/help.js +11 -5
- 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 +3 -0
- package/package.json +1 -1
- package/skill/SKILL.md +6 -2
package/README.md
CHANGED
|
@@ -21,6 +21,11 @@ chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
|
|
|
21
21
|
Use a separate `--user-data-dir`: recent Chrome versions do not open the debugging port on the default
|
|
22
22
|
profile. Check it is up with `curl http://localhost:9222/json/version`.
|
|
23
23
|
|
|
24
|
+
**No browser to hand, or one you would rather not share?** Skip this step: `pw-repl run --launch` (or
|
|
25
|
+
`serve --launch`) starts a private headless Chromium for the REPL and stops it with the REPL. It prints the
|
|
26
|
+
command it ran, so you can start one your own way instead. If there is no Chromium at all,
|
|
27
|
+
`npx playwright-core install chromium` downloads Playwright's.
|
|
28
|
+
|
|
24
29
|
**Headless** works the same way:
|
|
25
30
|
|
|
26
31
|
```bash
|
|
@@ -61,18 +66,19 @@ Commands act on the selected tab (`tab` lists the tabs, with `*` on the selected
|
|
|
61
66
|
|
|
62
67
|
## Common tasks
|
|
63
68
|
|
|
64
|
-
| I want to… | Commands
|
|
65
|
-
| ------------------------------------ |
|
|
66
|
-
| see where I am | `tab`, `info`
|
|
67
|
-
| see what is on the page | `snapshot`, `screenshot`
|
|
68
|
-
| do something on it | `click`, `fill`, `press`
|
|
69
|
-
| see what the page requested | `requests`, then `body <#>` for what one got back
|
|
70
|
-
| see console messages and errors | `console`
|
|
71
|
-
| show an agent what I do | `watch on`, click around in the browser, then `watch`
|
|
72
|
-
| see each step as I click | `watch on --live`
|
|
73
|
-
| record requests and console together | `capture on`, then `capture off`
|
|
74
|
-
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `network off` |
|
|
75
|
-
|
|
|
69
|
+
| I want to… | Commands |
|
|
70
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
71
|
+
| see where I am | `tab`, `info` |
|
|
72
|
+
| see what is on the page | `snapshot`, `screenshot` |
|
|
73
|
+
| do something on it | `click`, `fill`, `press` |
|
|
74
|
+
| see what the page requested | `requests`, then `body <#>` for what one got back |
|
|
75
|
+
| see console messages and errors | `console` |
|
|
76
|
+
| show an agent what I do | `watch on`, click around in the browser, then `watch` |
|
|
77
|
+
| see each step as I click | `watch on --live` |
|
|
78
|
+
| record requests and console together | `capture on`, then `capture off` |
|
|
79
|
+
| break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
|
|
80
|
+
| change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
|
|
81
|
+
| clean up | `modes off` |
|
|
76
82
|
|
|
77
83
|
Everything else is in `help <topic>`; `help <command>` has usage and caveats.
|
|
78
84
|
|
package/bin/pw-repl.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
const { looksLikeEndpoint } = require('../lib/client');
|
|
5
5
|
|
|
6
6
|
const USAGE = `Usage:
|
|
7
|
-
pw-repl run [start-url]
|
|
7
|
+
pw-repl run [--launch [--headed]] [start-url] [-- <chromium flags>]
|
|
8
8
|
connect to the browser and open the prompt
|
|
9
9
|
|
|
10
|
-
pw-repl serve [--background] [endpoint] [start-url]
|
|
10
|
+
pw-repl serve [--background] [--launch [--headed]] [endpoint] [start-url] [-- <chromium flags>]
|
|
11
11
|
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock);
|
|
12
12
|
--background runs it detached, with its output in a log next to the socket
|
|
13
13
|
|
|
@@ -37,6 +37,11 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
|
|
|
37
37
|
|
|
38
38
|
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
39
39
|
|
|
40
|
+
--launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
|
|
41
|
+
a temporary profile), prints the command it ran, and stops it with the REPL. Flags after -- go to that
|
|
42
|
+
Chromium; PW_CHROME picks which one. To set a browser up your own way, start it yourself and use
|
|
43
|
+
PW_CDP_URL.
|
|
44
|
+
|
|
40
45
|
In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
41
46
|
|
|
42
47
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
@@ -80,9 +85,16 @@ function parseSendArgs(args, allowCommand) {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
function startOptions(args, serve) {
|
|
83
|
-
const options = { serve, endpoint: null, startUrl: null, background: false, pidFile: process.env.PW_REPL_PID_FILE || null };
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
const options = { serve, endpoint: null, startUrl: null, background: false, launch: false, headed: false, chromeArgs: [], pidFile: process.env.PW_REPL_PID_FILE || null };
|
|
89
|
+
// Whatever follows -- is for the Chromium that --launch starts.
|
|
90
|
+
const split = args.indexOf('--');
|
|
91
|
+
const rest = split === -1 ? [...args] : args.slice(0, split);
|
|
92
|
+
if (split !== -1) options.chromeArgs = args.slice(split + 1);
|
|
93
|
+
const flag = name => { const i = rest.indexOf(name); if (i === -1) return false; rest.splice(i, 1); return true; };
|
|
94
|
+
if (serve) options.background = flag('--background');
|
|
95
|
+
options.launch = flag('--launch');
|
|
96
|
+
options.headed = flag('--headed');
|
|
97
|
+
if ((options.headed || options.chromeArgs.length) && !options.launch) usage();
|
|
86
98
|
if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
|
|
87
99
|
if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
|
|
88
100
|
options.startUrl = rest[0] || null;
|
package/lib/background.js
CHANGED
|
@@ -64,7 +64,9 @@ async function start(options) {
|
|
|
64
64
|
if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
|
|
65
65
|
const log = fs.openSync(logFile, 'w', 0o600);
|
|
66
66
|
fs.fchmodSync(log, 0o600);
|
|
67
|
-
const args = [BIN, 'serve', ...(options.
|
|
67
|
+
const args = [BIN, 'serve', ...(options.launch ? ['--launch'] : []), ...(options.headed ? ['--headed'] : []),
|
|
68
|
+
...(options.endpoint ? [options.endpoint] : []), ...(options.startUrl ? [options.startUrl] : []),
|
|
69
|
+
...(options.chromeArgs.length ? ['--', ...options.chromeArgs] : [])];
|
|
68
70
|
const child = spawn(process.execPath, args, {
|
|
69
71
|
detached: true,
|
|
70
72
|
stdio: ['ignore', log, log],
|
package/lib/client.js
CHANGED
|
@@ -32,17 +32,27 @@ function target(endpoint) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// GET /health: does not go through the command queue or show in the pane.
|
|
35
|
-
|
|
35
|
+
// Resolves to what the REPL says about itself (e.g. its browser), or null.
|
|
36
|
+
function healthInfo(endpoint, timeoutMs) {
|
|
36
37
|
return new Promise(resolve => {
|
|
37
38
|
const req = http.get({ ...target(endpoint), path: '/health', timeout: timeoutMs }, res => {
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
let text = '';
|
|
40
|
+
res.setEncoding('utf8');
|
|
41
|
+
res.on('data', chunk => { text += chunk; });
|
|
42
|
+
res.on('end', () => {
|
|
43
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
44
|
+
try { resolve(JSON.parse(text)); } catch { resolve({}); }
|
|
45
|
+
});
|
|
40
46
|
});
|
|
41
|
-
req.on('timeout', () => { req.destroy(); resolve(
|
|
42
|
-
req.on('error', () => resolve(
|
|
47
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
48
|
+
req.on('error', () => resolve(null));
|
|
43
49
|
});
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
async function health(endpoint, timeoutMs) {
|
|
53
|
+
return (await healthInfo(endpoint, timeoutMs)) !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
// One command. Resolves to { result }, { timeout: true }, { dropped: reason }
|
|
47
57
|
// (the connection closed after the command was sent, so it may have run) or
|
|
48
58
|
// { unreachable: reason } (it was never sent).
|
|
@@ -75,4 +85,4 @@ function request(endpoint, command, timeoutMs) {
|
|
|
75
85
|
});
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
module.exports = { DEFAULT_SOCKET, parseEndpoint, looksLikeEndpoint, describe, health, request };
|
|
88
|
+
module.exports = { DEFAULT_SOCKET, parseEndpoint, looksLikeEndpoint, describe, health, healthInfo, request };
|
package/lib/commands.js
CHANGED
|
@@ -197,23 +197,108 @@ function routesFor(p) {
|
|
|
197
197
|
return pageRoutes.get(p);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
const ROUTE_DELAY_MAX = 120;
|
|
201
|
+
|
|
202
|
+
// Marks a request a route answered, now: the browser reports it finished only later.
|
|
203
|
+
function markChanged(req, status, how) {
|
|
204
|
+
changedRequests.set(req, how);
|
|
205
|
+
const entry = requestEntries.get(req);
|
|
206
|
+
if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
|
|
210
|
+
function mergePatch(target, patch) {
|
|
211
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
|
|
212
|
+
const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
|
|
213
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
214
|
+
if (value === null) delete result[key];
|
|
215
|
+
else result[key] = mergePatch(result[key], value);
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseJson(text, what) {
|
|
221
|
+
try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// How a route treats the requests it matches: its text for listing, and what it does to each one.
|
|
225
|
+
function routeKind(how, rest, usage) {
|
|
226
|
+
if (/^\d{3}$/.test(how)) {
|
|
227
|
+
const status = Number(how);
|
|
228
|
+
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
229
|
+
if (!rest) throw new Error(usage);
|
|
230
|
+
parseJson(rest, 'Body');
|
|
231
|
+
const preview = rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest;
|
|
232
|
+
return {
|
|
233
|
+
text: `${status} ${preview}`,
|
|
234
|
+
async handle(r, req, tag) {
|
|
235
|
+
markChanged(req, status, 'faked');
|
|
236
|
+
await r.fulfill({ status, contentType: 'application/json', body: rest });
|
|
237
|
+
out.notice(`Faked: ${tag()} -> ${status}`);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (how === 'patch') {
|
|
242
|
+
if (!rest) throw new Error(usage);
|
|
243
|
+
const patch = parseJson(rest, 'Patch');
|
|
244
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
|
|
245
|
+
return {
|
|
246
|
+
text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
|
|
247
|
+
async handle(r, req, tag) {
|
|
248
|
+
const response = await r.fetch();
|
|
249
|
+
let body;
|
|
250
|
+
try { body = await response.json(); } catch {
|
|
251
|
+
await r.fulfill({ response });
|
|
252
|
+
out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
markChanged(req, response.status(), 'patched');
|
|
256
|
+
await r.fulfill({ response, json: mergePatch(body, patch) });
|
|
257
|
+
out.notice(`Patched: ${tag()} -> ${response.status()}`);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (how === 'delay') {
|
|
262
|
+
const seconds = Number(rest);
|
|
263
|
+
if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
|
|
264
|
+
return {
|
|
265
|
+
text: `delay ${seconds}s`,
|
|
266
|
+
async handle(r, req, tag) {
|
|
267
|
+
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
268
|
+
await r.continue();
|
|
269
|
+
out.notice(`Delayed ${seconds}s: ${tag()}`);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (how === 'abort' && !rest) {
|
|
274
|
+
return {
|
|
275
|
+
text: 'abort',
|
|
276
|
+
async handle(r, req, tag) {
|
|
277
|
+
await r.abort('failed');
|
|
278
|
+
out.notice(`Aborted: ${tag()}`);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
throw new Error(usage);
|
|
283
|
+
}
|
|
284
|
+
|
|
200
285
|
function listRoutes() {
|
|
201
286
|
const routes = routesFor(state.page);
|
|
287
|
+
const forms = [
|
|
288
|
+
['route <url-glob> <status> <json-body>', 'answer it with this status and JSON'],
|
|
289
|
+
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
290
|
+
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
291
|
+
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
292
|
+
];
|
|
202
293
|
if (!routes.size) {
|
|
203
|
-
out.log('No
|
|
204
|
-
out.log(hints(
|
|
294
|
+
out.log('No routes on the selected tab.');
|
|
295
|
+
out.log(hints(forms));
|
|
205
296
|
return;
|
|
206
297
|
}
|
|
207
|
-
out.log(`
|
|
298
|
+
out.log(`Routes on the selected tab (${routes.size}):`);
|
|
208
299
|
const width = Math.max(...[...routes.keys()].map(glob => glob.length));
|
|
209
|
-
for (const [glob, {
|
|
210
|
-
|
|
211
|
-
out.log(` ${glob.padEnd(width)} ${status} ${preview}`);
|
|
212
|
-
}
|
|
213
|
-
out.log(hints([
|
|
214
|
-
['route <url-glob> <status> <json-body>', 'add one, or replace the one for that glob'],
|
|
215
|
-
['route off <url-glob> | route off --all', 'remove'],
|
|
216
|
-
]));
|
|
300
|
+
for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
|
|
301
|
+
out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
|
|
217
302
|
}
|
|
218
303
|
|
|
219
304
|
async function removeRoutes(glob) {
|
|
@@ -222,7 +307,7 @@ async function removeRoutes(glob) {
|
|
|
222
307
|
if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
|
|
223
308
|
const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
|
|
224
309
|
for (const g of removed) out.log(`Removed: ${g}`);
|
|
225
|
-
if (!removed.length) out.log('No
|
|
310
|
+
if (!removed.length) out.log('No routes on the selected tab');
|
|
226
311
|
}
|
|
227
312
|
|
|
228
313
|
async function unroute(p, globs) {
|
|
@@ -246,7 +331,8 @@ const BODY_TIMEOUT = 15000;
|
|
|
246
331
|
const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
|
|
247
332
|
const recentLogs = new WeakMap();
|
|
248
333
|
const consoleLogs = new WeakMap();
|
|
249
|
-
|
|
334
|
+
// request -> how a route changed it (faked or patched), shown after its status.
|
|
335
|
+
const changedRequests = new WeakMap();
|
|
250
336
|
const requestEntries = new WeakMap();
|
|
251
337
|
// Tabs this REPL opened with tab new.
|
|
252
338
|
const openedTabs = new WeakSet();
|
|
@@ -293,7 +379,7 @@ function ensureRecentLog(p) {
|
|
|
293
379
|
res = await req.response();
|
|
294
380
|
if (res) status = String(res.status());
|
|
295
381
|
} catch {}
|
|
296
|
-
finish(req,
|
|
382
|
+
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
297
383
|
});
|
|
298
384
|
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
299
385
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
@@ -1109,6 +1195,36 @@ const commands = {
|
|
|
1109
1195
|
printOutput(result, all);
|
|
1110
1196
|
},
|
|
1111
1197
|
|
|
1198
|
+
// The page's own event listeners on an element, which only DevTools can
|
|
1199
|
+
// list: the element is handed to a CDP session through the page, so any
|
|
1200
|
+
// Playwright selector works, and the session asks for its listeners.
|
|
1201
|
+
async listeners(args, all) {
|
|
1202
|
+
const text = (args || '').trim();
|
|
1203
|
+
const usage = 'listeners <selector> | listeners document | listeners window';
|
|
1204
|
+
if (!text) throw new Error(`Usage: ${usage}`);
|
|
1205
|
+
const which = text === 'document' || text === 'window' ? text : null;
|
|
1206
|
+
const selector = which ? null : soleSelector(args, usage);
|
|
1207
|
+
if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
|
|
1208
|
+
const cdp = await state.page.context().newCDPSession(state.page);
|
|
1209
|
+
try {
|
|
1210
|
+
// Chrome includes each handler's source only for an object in a named group.
|
|
1211
|
+
const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
|
|
1212
|
+
if (!result.objectId) throw new Error(`No element matches ${text}`);
|
|
1213
|
+
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
|
|
1214
|
+
if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
|
|
1215
|
+
const lines = listeners.map(l => {
|
|
1216
|
+
const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
|
|
1217
|
+
const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
|
|
1218
|
+
return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
|
|
1219
|
+
});
|
|
1220
|
+
printOutput(lines.join('\n'), all);
|
|
1221
|
+
} finally {
|
|
1222
|
+
await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
|
|
1223
|
+
await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
|
|
1224
|
+
await cdp.detach().catch(() => {});
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
|
|
1112
1228
|
async count(args) {
|
|
1113
1229
|
const selector = soleSelector(args, 'count <selector>');
|
|
1114
1230
|
const els = await state.page.$$(selector);
|
|
@@ -1202,7 +1318,7 @@ const commands = {
|
|
|
1202
1318
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
1203
1319
|
const selector = toSelector(rest);
|
|
1204
1320
|
const target = selector ? state.page.locator(selector).first() : state.page;
|
|
1205
|
-
const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1321
|
+
const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1206
1322
|
const text = full ? raw : compactSnapshot(raw);
|
|
1207
1323
|
if (needle !== null) {
|
|
1208
1324
|
const hits = grepSnapshot(text, needle);
|
|
@@ -1278,35 +1394,28 @@ const commands = {
|
|
|
1278
1394
|
if (!trimmed) return listRoutes();
|
|
1279
1395
|
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
1280
1396
|
if (off) return removeRoutes(off[1]);
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
try { JSON.parse(body); } catch (error) { throw new Error(`Body is not valid JSON: ${error.message}`); }
|
|
1397
|
+
const usage = 'Usage: route <url-glob> <status> <json-body> | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
|
|
1398
|
+
const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
|
|
1399
|
+
if (!match) throw new Error(usage);
|
|
1400
|
+
const [, glob, how, rest] = match;
|
|
1401
|
+
const route = routeKind(how, rest, usage);
|
|
1287
1402
|
const routes = routesFor(state.page);
|
|
1288
1403
|
const previous = routes.get(glob);
|
|
1289
1404
|
if (previous) await state.page.unroute(glob, previous.handler);
|
|
1290
1405
|
const handler = async r => {
|
|
1291
1406
|
const req = r.request();
|
|
1292
|
-
|
|
1407
|
+
const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
|
|
1293
1408
|
try {
|
|
1294
|
-
await
|
|
1295
|
-
// Recorded now: the browser reports the request finished only later.
|
|
1296
|
-
const entry = requestEntries.get(req);
|
|
1297
|
-
if (entry) { entry.status = `${status} faked`; entry.ms = Date.now() - entry.t; }
|
|
1298
|
-
const id = entry?.id;
|
|
1299
|
-
out.notice(`Faked: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} -> ${status}`);
|
|
1409
|
+
await route.handle(r, req, tag);
|
|
1300
1410
|
} catch (error) {
|
|
1301
|
-
// Aborted, never continued: a request meant to be
|
|
1411
|
+
// Aborted, never continued: a request meant to be changed must not reach the page as it was.
|
|
1302
1412
|
await r.abort().catch(() => {});
|
|
1303
|
-
|
|
1304
|
-
out.notice(`Fake failed: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} — ${error.message}; the request was aborted`);
|
|
1413
|
+
out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
|
|
1305
1414
|
}
|
|
1306
1415
|
};
|
|
1307
1416
|
await state.page.route(glob, handler);
|
|
1308
|
-
routes.set(glob, {
|
|
1309
|
-
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${
|
|
1417
|
+
routes.set(glob, { text: route.text, handler });
|
|
1418
|
+
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
1310
1419
|
},
|
|
1311
1420
|
|
|
1312
1421
|
async requests(args) {
|
|
@@ -1340,7 +1449,7 @@ const commands = {
|
|
|
1340
1449
|
const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
|
|
1341
1450
|
if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
|
|
1342
1451
|
// A fake is marked answered before the browser hands over its response.
|
|
1343
|
-
for (let waited = 0; !entry.response && /faked$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1452
|
+
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1344
1453
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1345
1454
|
}
|
|
1346
1455
|
if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
|
package/lib/help.js
CHANGED
|
@@ -15,7 +15,8 @@ Common tasks:
|
|
|
15
15
|
show an agent what I do watch on, click around, then watch
|
|
16
16
|
see each step as I click watch on --live
|
|
17
17
|
requests and console together capture on, then capture off
|
|
18
|
-
break the backend on purpose route <glob> <status> <json>, network off
|
|
18
|
+
break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
|
|
19
|
+
change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
|
|
19
20
|
clean up modes off
|
|
20
21
|
|
|
21
22
|
Modes (watch, capture, route, network off) stay on until turned off; the prompt shows the selected tab's:
|
|
@@ -40,7 +41,7 @@ const TOPICS = {
|
|
|
40
41
|
},
|
|
41
42
|
inspect: {
|
|
42
43
|
intro: 'Output is capped; put --all right after the command for everything (e.g. text --all body).',
|
|
43
|
-
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
|
|
44
|
+
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'wait', 'sleep'],
|
|
44
45
|
},
|
|
45
46
|
network: {
|
|
46
47
|
intro: 'requests and console record all the time; a capture records only while it runs.',
|
|
@@ -109,6 +110,11 @@ const COMMANDS = {
|
|
|
109
110
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
110
111
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
111
112
|
attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
|
|
113
|
+
listeners: {
|
|
114
|
+
usage: 'listeners <selector>|document|window',
|
|
115
|
+
summary: 'the page\'s event listeners on an element',
|
|
116
|
+
detail: 'One line each: the event, how it was added (capture, once, passive), the start of the handler and\nits line in its script. Listeners added on a parent (e.g. document, for delegation) are not the\nelement\'s own: check listeners document too.',
|
|
117
|
+
},
|
|
112
118
|
count: { usage: 'count <selector>', summary: 'number of matches' },
|
|
113
119
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
114
120
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
@@ -146,9 +152,9 @@ const COMMANDS = {
|
|
|
146
152
|
detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints. Other commands wait until a timed\ncapture ends, so it suits recording what someone does in the browser; around your own commands, use\ncapture on and capture off.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
|
|
147
153
|
},
|
|
148
154
|
route: {
|
|
149
|
-
usage: 'route <glob> <
|
|
150
|
-
summary: '
|
|
151
|
-
detail: '
|
|
155
|
+
usage: 'route <glob> <how> | off <glob>|--all',
|
|
156
|
+
summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
|
|
157
|
+
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON; it never reaches the network,\n so it still answers while the network is off\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests; requests shows a fake as\n<status> faked and a patch as <status> patched. If a route fails it prints "Route failed" and aborts\nthe request.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes.\n\nExample: route **/api/cart patch {"total": 0}',
|
|
152
158
|
},
|
|
153
159
|
network: {
|
|
154
160
|
usage: 'network [on|off]',
|
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
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -27,6 +27,9 @@ one; each takes an optional start URL, which opens in a new tab.
|
|
|
27
27
|
and takes commands; `pw-repl stop` stops it.
|
|
28
28
|
- `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
|
|
29
29
|
`pw[serve]>`.
|
|
30
|
+
- Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
|
|
31
|
+
own instead of connecting to one: headless unless `--headed`, in a temporary profile, stopped with the
|
|
32
|
+
REPL. It prints the command it ran; flags after `--` are passed to that Chromium.
|
|
30
33
|
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
31
34
|
the tmux session `playwright-repl`:
|
|
32
35
|
|
|
@@ -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.
|