pw-repl 0.2.1 → 0.2.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 +10 -0
- package/bin/pw-repl.js +4 -2
- package/lib/background.js +2 -2
- package/lib/commands.js +91 -14
- package/lib/help.js +13 -4
- package/lib/runner.js +18 -4
- package/lib/send.js +8 -2
- package/lib/start.js +18 -1
- package/package.json +1 -1
- package/skill/SKILL.md +25 -23
package/README.md
CHANGED
|
@@ -21,6 +21,16 @@ 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
|
+
**Headless** works the same way:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With nobody at the browser, `dialog accept` or `dialog dismiss` answers a dialog (`alert`, `confirm`);
|
|
31
|
+
the REPL never answers one on its own. Pages start at about 800x600; `viewport 1280x800` sets another
|
|
32
|
+
size.
|
|
33
|
+
|
|
24
34
|
### 2. Install and start the REPL
|
|
25
35
|
|
|
26
36
|
```bash
|
package/bin/pw-repl.js
CHANGED
|
@@ -73,7 +73,8 @@ function parseSendArgs(args, allowCommand) {
|
|
|
73
73
|
// words are joined as they are.
|
|
74
74
|
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
75
75
|
const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
|
|
76
|
-
|
|
76
|
+
// An empty word (fill #name "") is the empty value.
|
|
77
|
+
const quoted = requote ? words.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
|
|
77
78
|
options.command = quoted.join(' ').trim();
|
|
78
79
|
return options;
|
|
79
80
|
}
|
|
@@ -119,7 +120,8 @@ async function main() {
|
|
|
119
120
|
case 'stop': {
|
|
120
121
|
const options = parseSendArgs(args, false);
|
|
121
122
|
if (options.session) usage();
|
|
122
|
-
|
|
123
|
+
// The same socket send would use when there is no -e: PW_ENDPOINT, then PW_SOCKET.
|
|
124
|
+
const endpoint = options.endpoint || process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
|
|
123
125
|
process.exit(await require('../lib/background')[subcommand]({ endpoint }));
|
|
124
126
|
}
|
|
125
127
|
// falls through never: process.exit above
|
package/lib/background.js
CHANGED
|
@@ -79,7 +79,7 @@ async function start(options) {
|
|
|
79
79
|
}
|
|
80
80
|
if (exited || !runningPid(pidFile)) {
|
|
81
81
|
if (!exited) child.kill();
|
|
82
|
-
const last = tail(logFile,
|
|
82
|
+
const last = tail(logFile, 12);
|
|
83
83
|
return fail(`the background REPL did not start${last ? `; the end of ${logFile}:\n${last}` : ''}`);
|
|
84
84
|
}
|
|
85
85
|
child.unref();
|
|
@@ -103,7 +103,7 @@ async function stop(options) {
|
|
|
103
103
|
const deadline = Date.now() + STOP_TIMEOUT;
|
|
104
104
|
while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
|
|
105
105
|
if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
|
|
106
|
-
console.log(`Stopped the background REPL (pid ${pid}); its log stays at ${filesFor(endpoint).logFile}`);
|
|
106
|
+
console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${filesFor(endpoint).logFile}`);
|
|
107
107
|
return 0;
|
|
108
108
|
}
|
|
109
109
|
|
package/lib/commands.js
CHANGED
|
@@ -97,13 +97,66 @@ function hints(pairs) {
|
|
|
97
97
|
|
|
98
98
|
const dialogPages = new WeakSet();
|
|
99
99
|
|
|
100
|
+
// page -> its open dialog. The page, and every command that reads it, waits
|
|
101
|
+
// until the dialog is answered, in the browser or with the dialog command.
|
|
102
|
+
const openDialogs = new Map();
|
|
103
|
+
|
|
100
104
|
function ensureDialogHandler(p) {
|
|
101
105
|
if (dialogPages.has(p)) return;
|
|
102
106
|
dialogPages.add(p);
|
|
103
107
|
p.on('dialog', dialog => {
|
|
108
|
+
openDialogs.set(p, dialog);
|
|
104
109
|
out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
|
|
105
|
-
out.notice('
|
|
110
|
+
out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.');
|
|
106
111
|
});
|
|
112
|
+
p.on('close', () => openDialogs.delete(p));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Runs ahead of the command queue (see runner.js), so it still works while
|
|
116
|
+
// commands wait on the dialog; it returns its output rather than printing it.
|
|
117
|
+
async function dialogCommand(args) {
|
|
118
|
+
const [action, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
119
|
+
const pages = state.browser.contexts().flatMap(c => c.pages());
|
|
120
|
+
for (const p of openDialogs.keys()) if (p.isClosed() || !pages.includes(p)) openDialogs.delete(p);
|
|
121
|
+
const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
|
|
122
|
+
if (!action) {
|
|
123
|
+
if (!openDialogs.size) return 'No dialog is open.';
|
|
124
|
+
return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
|
|
125
|
+
}
|
|
126
|
+
if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
|
|
127
|
+
// The selected tab's dialog, or the only one open.
|
|
128
|
+
const page = openDialogs.has(state.page) ? state.page : openDialogs.size === 1 ? [...openDialogs.keys()][0] : null;
|
|
129
|
+
if (!page) throw new Error(openDialogs.size ? 'Several tabs have a dialog open; select one with tab <index|url-part> first' : 'No dialog is open.');
|
|
130
|
+
const dialog = openDialogs.get(page);
|
|
131
|
+
openDialogs.delete(page);
|
|
132
|
+
const line = describe(page, dialog);
|
|
133
|
+
try {
|
|
134
|
+
if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
|
|
135
|
+
else await dialog.dismiss();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Answered in the browser meanwhile.
|
|
138
|
+
return `No dialog is open (${error.message.split('\n')[0]})`;
|
|
139
|
+
}
|
|
140
|
+
return `${action === 'accept' ? 'Accepted' : 'Dismissed'}: ${line}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A tab in a browser the REPL connected to has no viewport set, so its size is
|
|
144
|
+
// the window's; it is read from the page.
|
|
145
|
+
async function viewportText() {
|
|
146
|
+
const set = state.page.viewportSize();
|
|
147
|
+
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
148
|
+
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
149
|
+
return size ? `${size} (the window's size)` : 'unknown';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A page restored from the back/forward cache never fires its load events
|
|
153
|
+
// again, so back and forward wait only for the navigation, then briefly for
|
|
154
|
+
// the page, rather than time out on a page that is already there.
|
|
155
|
+
async function historyStep(go, direction) {
|
|
156
|
+
const before = state.page.url();
|
|
157
|
+
const response = await go();
|
|
158
|
+
if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
|
|
159
|
+
await state.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
|
|
107
160
|
}
|
|
108
161
|
|
|
109
162
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
@@ -300,16 +353,24 @@ const WATCH_SCRIPT = `(() => {
|
|
|
300
353
|
// An element holding an editable area would be named partly by what was typed there.
|
|
301
354
|
const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
|
|
302
355
|
const clip = text => (text || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
|
|
356
|
+
// A label's own words, without the options or values of the controls inside it.
|
|
357
|
+
const labelText = label => {
|
|
358
|
+
const copy = label.cloneNode(true);
|
|
359
|
+
copy.querySelectorAll('select,textarea,input,button').forEach(n => n.remove());
|
|
360
|
+
return copy.textContent;
|
|
361
|
+
};
|
|
303
362
|
const nameOf = el => {
|
|
304
363
|
const labelledBy = el.getAttribute('aria-labelledby');
|
|
305
364
|
const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
|
|
306
365
|
const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
|
|
307
|
-
return clip(el.getAttribute('aria-label') || byId || (label && label !== el && label
|
|
366
|
+
return clip(el.getAttribute('aria-label') || byId || (label && label !== el && labelText(label)) || el.getAttribute('alt')
|
|
308
367
|
|| el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
|
|
309
368
|
};
|
|
310
369
|
const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
|
|
311
370
|
// el null: the page itself, e.g. Escape with nothing focused.
|
|
312
371
|
const send = (action, el, extra, since) => {
|
|
372
|
+
// Typing still waiting for its pause is recorded first, so the steps stay in order.
|
|
373
|
+
if (action !== 'type') for (const field of [...pending.keys()]) flushTyping(field);
|
|
313
374
|
if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
|
|
314
375
|
};
|
|
315
376
|
document.addEventListener('click', e => {
|
|
@@ -925,18 +986,20 @@ const commands = {
|
|
|
925
986
|
async goto(args) {
|
|
926
987
|
if (!args) throw new Error('Usage: goto <url>');
|
|
927
988
|
let url = args;
|
|
928
|
-
|
|
989
|
+
// As a browser's address bar does: a local dev server is almost always plain http.
|
|
990
|
+
const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
|
|
991
|
+
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
|
|
929
992
|
await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
930
993
|
out.log(`${state.page.url()} — ${await state.page.title()}`);
|
|
931
994
|
},
|
|
932
995
|
|
|
933
996
|
async back() {
|
|
934
|
-
await state.page.goBack({ waitUntil: '
|
|
997
|
+
await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
|
|
935
998
|
out.log(`Back to: ${state.page.url()}`);
|
|
936
999
|
},
|
|
937
1000
|
|
|
938
1001
|
async forward() {
|
|
939
|
-
await state.page.goForward({ waitUntil: '
|
|
1002
|
+
await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
|
|
940
1003
|
out.log(`Forward to: ${state.page.url()}`);
|
|
941
1004
|
},
|
|
942
1005
|
|
|
@@ -948,8 +1011,7 @@ const commands = {
|
|
|
948
1011
|
async info() {
|
|
949
1012
|
out.log(` URL: ${state.page.url()}`);
|
|
950
1013
|
out.log(` Title: ${await state.page.title()}`);
|
|
951
|
-
|
|
952
|
-
if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
|
|
1014
|
+
out.log(` Viewport: ${await viewportText()}`);
|
|
953
1015
|
},
|
|
954
1016
|
|
|
955
1017
|
async click(args) {
|
|
@@ -978,7 +1040,21 @@ const commands = {
|
|
|
978
1040
|
|
|
979
1041
|
async type(args) {
|
|
980
1042
|
const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
|
|
981
|
-
|
|
1043
|
+
// Typed after what the field already holds, as someone clicking into it
|
|
1044
|
+
// at the end would; focusing it alone leaves the caret at the start.
|
|
1045
|
+
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
1046
|
+
el.focus();
|
|
1047
|
+
if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
|
|
1048
|
+
try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
|
|
1049
|
+
} else if (el.isContentEditable) {
|
|
1050
|
+
const range = document.createRange();
|
|
1051
|
+
range.selectNodeContents(el);
|
|
1052
|
+
range.collapse(false);
|
|
1053
|
+
getSelection().removeAllRanges();
|
|
1054
|
+
getSelection().addRange(range);
|
|
1055
|
+
}
|
|
1056
|
+
}, null, { timeout: 5000 }));
|
|
1057
|
+
await state.page.keyboard.type(value);
|
|
982
1058
|
out.log(`Typed into: ${shown}`);
|
|
983
1059
|
},
|
|
984
1060
|
|
|
@@ -1139,11 +1215,7 @@ const commands = {
|
|
|
1139
1215
|
},
|
|
1140
1216
|
|
|
1141
1217
|
async viewport(args) {
|
|
1142
|
-
if (!args) {
|
|
1143
|
-
const vp = state.page.viewportSize();
|
|
1144
|
-
out.log(vp ? `${vp.width}x${vp.height}` : 'No viewport set');
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1218
|
+
if (!args) { out.log(await viewportText()); return; }
|
|
1147
1219
|
const [w, h] = args.split('x').map(Number);
|
|
1148
1220
|
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
1149
1221
|
await state.page.setViewportSize({ width: w, height: h });
|
|
@@ -1414,6 +1486,10 @@ const commands = {
|
|
|
1414
1486
|
out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
|
|
1415
1487
|
},
|
|
1416
1488
|
|
|
1489
|
+
async dialog(args) {
|
|
1490
|
+
out.log(await dialogCommand(args));
|
|
1491
|
+
},
|
|
1492
|
+
|
|
1417
1493
|
async modes(args) {
|
|
1418
1494
|
const arg = (args || '').trim();
|
|
1419
1495
|
if (!arg) return listModes();
|
|
@@ -1459,6 +1535,7 @@ function complete(line) {
|
|
|
1459
1535
|
if (command === 'tab') return match(['new', 'close']);
|
|
1460
1536
|
if (command === 'network') return match(['on', 'off']);
|
|
1461
1537
|
if (command === 'modes') return match(['off']);
|
|
1538
|
+
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
1462
1539
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
1463
1540
|
if (command === 'capture') return match(['on', 'off']);
|
|
1464
1541
|
if (command === 'route') return match(['off']);
|
|
@@ -1469,4 +1546,4 @@ function complete(line) {
|
|
|
1469
1546
|
return [[], current];
|
|
1470
1547
|
}
|
|
1471
1548
|
|
|
1472
|
-
module.exports = { commands, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
|
1549
|
+
module.exports = { commands, dialogCommand, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
package/lib/help.js
CHANGED
|
@@ -63,8 +63,8 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
|
|
|
63
63
|
|
|
64
64
|
Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
|
|
65
65
|
|
|
66
|
-
Dialogs are reported and never answered
|
|
67
|
-
commands: ['modes', 'help', 'quit'],
|
|
66
|
+
Dialogs are reported and never answered on their own; dialog answers one.`,
|
|
67
|
+
commands: ['modes', 'dialog', 'help', 'quit'],
|
|
68
68
|
},
|
|
69
69
|
};
|
|
70
70
|
|
|
@@ -74,7 +74,11 @@ const COMMANDS = {
|
|
|
74
74
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
75
75
|
detail: 'tab <index> uses the numbers from the latest tab listing; they change when tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens a tab behind the one in front, so it does not take the window from whoever is using it,\nand selects it. tab close closes the selected tab, or the one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
|
|
76
76
|
},
|
|
77
|
-
goto: {
|
|
77
|
+
goto: {
|
|
78
|
+
usage: 'goto <url>',
|
|
79
|
+
summary: 'navigate the selected tab',
|
|
80
|
+
detail: 'Without a scheme, http:// is assumed for localhost, 127.x and [::1], and https:// otherwise, as in\nthe address bar. tab new <url> reads its URL the same way.',
|
|
81
|
+
},
|
|
78
82
|
back: { usage: 'back', summary: 'go back one history entry' },
|
|
79
83
|
forward: { usage: 'forward', summary: 'go forward one history entry' },
|
|
80
84
|
reload: { usage: 'reload', summary: 'reload the selected tab' },
|
|
@@ -87,7 +91,7 @@ const COMMANDS = {
|
|
|
87
91
|
summary: 'clear an input and fill it',
|
|
88
92
|
detail: 'The selector is the first word; quote it if it has spaces. The rest of the line is the value, and\nquotes around all of it are removed, so fill #name "" clears the field.\n\nExamples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada',
|
|
89
93
|
},
|
|
90
|
-
type: { usage: 'type <selector> <text>', summary: 'type
|
|
94
|
+
type: { usage: 'type <selector> <text>', summary: 'type key by key after what the field holds; text as for fill' },
|
|
91
95
|
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A), optionally on an element' },
|
|
92
96
|
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
93
97
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
@@ -168,6 +172,11 @@ const COMMANDS = {
|
|
|
168
172
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
169
173
|
detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off <glob>|--all, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
|
|
170
174
|
},
|
|
175
|
+
dialog: {
|
|
176
|
+
usage: 'dialog [accept [text] | dismiss]',
|
|
177
|
+
summary: 'show, accept or dismiss an open alert, confirm or prompt',
|
|
178
|
+
detail: 'While a dialog is open, its page and the commands that read it wait. dialog runs at once, ahead\nof them; it answers the selected tab\'s dialog, or the only one open. accept text answers a prompt.\n\nWith nobody at the browser (e.g. headless), it is the only way to answer one.',
|
|
179
|
+
},
|
|
171
180
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
172
181
|
quit: {
|
|
173
182
|
usage: 'quit',
|
package/lib/runner.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { state, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const { commands, activeModes } = require('./commands');
|
|
6
|
+
const { commands, dialogCommand, activeModes } = require('./commands');
|
|
7
7
|
|
|
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
|
|
@@ -12,7 +12,7 @@ const READ_ONLY = new Set(['info', 'text', 'html', 'attrs', 'count', 'visible',
|
|
|
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
|
-
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'help', 'quit']);
|
|
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
18
|
const INSPECTION = ['info', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
|
|
@@ -117,9 +117,10 @@ function prompt(preserveCursor) {
|
|
|
117
117
|
state.rl.prompt(preserveCursor);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
// quit
|
|
120
|
+
// quit and dialog skip the queue: quit so it works while a long command runs,
|
|
121
|
+
// dialog because the commands queued behind a dialog wait for its answer.
|
|
121
122
|
function enqueue(line) {
|
|
122
|
-
const direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?quit(?:\s|$)/.test(line.trim());
|
|
123
|
+
const direct = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\s+)?(?:quit|dialog)(?:\s|$)/.test(line.trim());
|
|
123
124
|
const run = () => handleLine(line);
|
|
124
125
|
if (direct) run().catch(e => out.error(`Error: ${e.message}`));
|
|
125
126
|
else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
|
|
@@ -128,6 +129,7 @@ function enqueue(line) {
|
|
|
128
129
|
// Server commands share the prompt's queue and are echoed to the pane, so
|
|
129
130
|
// someone watching sees everything an agent does.
|
|
130
131
|
function submit(text) {
|
|
132
|
+
if (/^dialog(?:\s|$)/.test(text)) return answerDialog(text);
|
|
131
133
|
return new Promise(resolve => {
|
|
132
134
|
queue = queue.then(async () => {
|
|
133
135
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
|
@@ -156,6 +158,18 @@ function submit(text) {
|
|
|
156
158
|
});
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
// Outside the queue, and so outside out.collect, whose single sink belongs to
|
|
162
|
+
// the command that is waiting on the dialog.
|
|
163
|
+
async function answerDialog(text) {
|
|
164
|
+
console.log(`[server] ${text}`);
|
|
165
|
+
let result;
|
|
166
|
+
try { result = { status: 'ok', output: await dialogCommand(text.slice(6)) }; }
|
|
167
|
+
catch (error) { result = { status: 'error', output: `Error: ${error.message}` }; }
|
|
168
|
+
console.log(result.output);
|
|
169
|
+
if (!state.stopping) prompt(true);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
function drained() {
|
|
160
174
|
return queue;
|
|
161
175
|
}
|
package/lib/send.js
CHANGED
|
@@ -18,14 +18,16 @@ function hasSession(session) {
|
|
|
18
18
|
try { tmux('has-session', '-t', session); return true; } catch { return false; }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
// Which way a command goes: an explicit -e or -s wins
|
|
22
|
-
//
|
|
21
|
+
// Which way a command goes: an explicit -e or -s wins, and so does a socket
|
|
22
|
+
// named in PW_SOCKET, even one that is gone (a tmux pane is then someone
|
|
23
|
+
// else's); otherwise the server when the default socket exists, else tmux.
|
|
23
24
|
function route(options) {
|
|
24
25
|
const socket = process.env.PW_SOCKET || client.DEFAULT_SOCKET;
|
|
25
26
|
const endpoint = options.endpoint || process.env.PW_ENDPOINT || '';
|
|
26
27
|
const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
|
|
27
28
|
if (options.session) return { kind: 'tmux', session };
|
|
28
29
|
if (endpoint) return { kind: 'server', endpoint: client.parseEndpoint(endpoint), explicit: true };
|
|
30
|
+
if (process.env.PW_SOCKET) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: true };
|
|
29
31
|
if (fs.existsSync(socket) && fs.statSync(socket).isSocket()) return { kind: 'server', endpoint: client.parseEndpoint(socket), explicit: false, socket };
|
|
30
32
|
return { kind: 'tmux', session };
|
|
31
33
|
}
|
|
@@ -110,6 +112,10 @@ async function send(options) {
|
|
|
110
112
|
// Reports the route a command would take, checking it the way a command would.
|
|
111
113
|
async function where(options) {
|
|
112
114
|
const way = route(options);
|
|
115
|
+
// Whether the browser answers, so a REPL that will not start can be told apart from one that is not running.
|
|
116
|
+
const cdp = process.env.PW_CDP_URL || 'http://localhost:9222';
|
|
117
|
+
const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
|
|
118
|
+
console.log(version ? `browser: ${cdp} answers (${version.Browser})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
|
|
113
119
|
if (way.kind === 'server') {
|
|
114
120
|
const name = client.describe(way.endpoint);
|
|
115
121
|
if (await client.health(way.endpoint, 5000)) {
|
package/lib/start.js
CHANGED
|
@@ -7,11 +7,28 @@ const { listTabs, openTab, watchPage, complete } = require('./commands');
|
|
|
7
7
|
const runner = require('./runner');
|
|
8
8
|
|
|
9
9
|
const CDP_URL = process.env.PW_CDP_URL || 'http://localhost:9222';
|
|
10
|
+
const CONNECT_TIMEOUT = 15000;
|
|
11
|
+
|
|
12
|
+
// What to do about a browser that cannot be reached, rather than the bare socket error.
|
|
13
|
+
function connectHelp(error) {
|
|
14
|
+
const reason = String(error.message).split('\n')[0].replace(/^browserType\.connectOverCDP: /, '');
|
|
15
|
+
if (/timeout/i.test(reason)) {
|
|
16
|
+
return `Chromium at ${CDP_URL} answered but did not finish attaching within ${CONNECT_TIMEOUT / 1000}s. A tab with a\ndialog open (alert, confirm) holds this up: answer it or close that tab, then try again.`;
|
|
17
|
+
}
|
|
18
|
+
return `No browser answered at ${CDP_URL} (${reason}).
|
|
19
|
+
|
|
20
|
+
pw-repl needs a Chromium-based browser (Chrome, Chromium, Edge, ...) started with remote debugging:
|
|
21
|
+
chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
|
|
22
|
+
chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-headless
|
|
23
|
+
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
|
+
}
|
|
10
26
|
|
|
11
27
|
async function start(options) {
|
|
12
28
|
const START_URL = options.startUrl || process.env.PW_START_URL || null;
|
|
13
29
|
out.log(`Connecting to ${CDP_URL}...`);
|
|
14
|
-
state.browser = await chromium.connectOverCDP(CDP_URL);
|
|
30
|
+
try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
|
|
31
|
+
catch (error) { throw new Error(connectHelp(error)); }
|
|
15
32
|
if (state.stopping) {
|
|
16
33
|
try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
|
|
17
34
|
catch (error) { process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -62,7 +62,7 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
|
|
|
62
62
|
|
|
63
63
|
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
64
64
|
It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|
|
65
|
-
of what
|
|
65
|
+
of what someone is typing; while a command is running, someone is typing, or the REPL is exiting, it
|
|
66
66
|
refuses (exit 64). Wait and retry.
|
|
67
67
|
- `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
|
|
68
68
|
as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
|
|
@@ -72,14 +72,13 @@ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your
|
|
|
72
72
|
REPL), or why neither is reachable, without running anything.
|
|
73
73
|
|
|
74
74
|
Every command you run and its output show in the REPL's pane, or in `attach` and the log for a
|
|
75
|
-
background REPL (server commands as `[server]` lines), so
|
|
76
|
-
commands only, not what
|
|
77
|
-
at the browser itself: `tab` and `info` for where
|
|
78
|
-
made (`body <#>` for what one returned), `console` for console messages and page errors.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
about acting on them.
|
|
75
|
+
background REPL (server commands as `[server]` lines), so whoever looks there sees what you do. The pane
|
|
76
|
+
shows REPL commands only, not what was clicked in the browser (unless `watch on --live` is on); for
|
|
77
|
+
that, look at the browser itself: `tab` and `info` for where things are, `requests` for the requests the
|
|
78
|
+
clicks made (`body <#>` for what one returned), `console` for console messages and page errors. `watch
|
|
79
|
+
on` records each step someone takes in a tab, with the requests it caused (`watch on --changes` adds
|
|
80
|
+
what each step changed on the page); `watch` reads it back, and `watch new` only what it has not shown
|
|
81
|
+
yet.
|
|
83
82
|
|
|
84
83
|
Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
|
|
85
84
|
blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
|
|
@@ -90,23 +89,26 @@ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their command
|
|
|
90
89
|
gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
|
|
91
90
|
help is the command reference; this file does not repeat it.
|
|
92
91
|
|
|
93
|
-
##
|
|
94
|
-
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
92
|
+
## Sharing the browser
|
|
93
|
+
|
|
94
|
+
- The browser may have tabs that are not yours, and someone may be using it. Whether to read or act in
|
|
95
|
+
one of those tabs, or to open your own (`tab new <url>`), depends on the task; when that is not
|
|
96
|
+
clear, ask.
|
|
97
|
+
- No tab is selected when the REPL starts (unless it was given a start URL). Closing a tab the REPL
|
|
98
|
+
opened goes back to the tab before it, if the REPL opened that one too; otherwise no tab is selected.
|
|
99
|
+
Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
|
|
100
|
+
its URL and refuse if it is ambiguous.
|
|
101
|
+
- Dialogs are never answered on their own. While one is open, its page and the commands that read it
|
|
102
|
+
wait; `dialog` shows it, and `dialog accept` or `dialog dismiss` answers it.
|
|
103
|
+
- Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
|
|
104
|
+
lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
|
|
105
|
+
`modes off` turns off every mode in every tab.
|
|
106
|
+
- Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
|
|
106
107
|
|
|
107
108
|
## Environment
|
|
108
109
|
|
|
109
|
-
- Chromium must be running with `--remote-debugging-port=9222`.
|
|
110
|
+
- Chromium must be running with `--remote-debugging-port=9222`. A headless one works too
|
|
111
|
+
(`--headless=new`); nobody answers its dialogs but `dialog`.
|
|
110
112
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
111
113
|
- `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
|
|
112
114
|
so `rm /tmp/screenshot-*.png` cleans up.
|