pw-repl 0.2.0 → 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 +10 -4
- package/lib/background.js +2 -2
- package/lib/commands.js +153 -44
- package/lib/help.js +17 -8
- package/lib/runner.js +20 -4
- package/lib/send.js +14 -3
- package/lib/start.js +18 -1
- package/lib/syntax.js +29 -0
- package/package.json +1 -1
- package/skill/SKILL.md +46 -27
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
|
@@ -67,9 +67,14 @@ function parseSendArgs(args, allowCommand) {
|
|
|
67
67
|
}
|
|
68
68
|
const words = args.slice(i);
|
|
69
69
|
if (!allowCommand && words.length) usage();
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
70
|
+
// For a command that reads a quoted selector (fill "text=Your name" Ada), a
|
|
71
|
+
// word the shell kept whole is quoted again so it stays one word. Any other
|
|
72
|
+
// command takes the rest of its line as it is (eval, route's JSON), so its
|
|
73
|
+
// words are joined as they are.
|
|
74
|
+
const { SELECTOR_FIRST } = require('../lib/syntax');
|
|
75
|
+
const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
|
|
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;
|
|
73
78
|
options.command = quoted.join(' ').trim();
|
|
74
79
|
return options;
|
|
75
80
|
}
|
|
@@ -115,7 +120,8 @@ async function main() {
|
|
|
115
120
|
case 'stop': {
|
|
116
121
|
const options = parseSendArgs(args, false);
|
|
117
122
|
if (options.session) usage();
|
|
118
|
-
|
|
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;
|
|
119
125
|
process.exit(await require('../lib/background')[subcommand]({ endpoint }));
|
|
120
126
|
}
|
|
121
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
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const { state, withTimeout, onShutdown, shutdown } = require('./state');
|
|
4
4
|
const out = require('./output');
|
|
5
5
|
const HELP = require('./help');
|
|
6
|
+
const { toSelector, unquote, splitSelector } = require('./syntax');
|
|
6
7
|
|
|
7
8
|
const { printOutput, OUTPUT_LIMIT } = out;
|
|
8
9
|
const SCREENSHOT_DIR = process.env.PW_SCREENSHOT_DIR || '/tmp';
|
|
@@ -96,13 +97,66 @@ function hints(pairs) {
|
|
|
96
97
|
|
|
97
98
|
const dialogPages = new WeakSet();
|
|
98
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
|
+
|
|
99
104
|
function ensureDialogHandler(p) {
|
|
100
105
|
if (dialogPages.has(p)) return;
|
|
101
106
|
dialogPages.add(p);
|
|
102
107
|
p.on('dialog', dialog => {
|
|
108
|
+
openDialogs.set(p, dialog);
|
|
103
109
|
out.notice(`Dialog [${dialog.type()}]: ${String(dialog.message()).slice(0, OUTPUT_LIMIT)}`);
|
|
104
|
-
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.');
|
|
105
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(() => {});
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
const SCREENSHOT_DELAY_MAX = 60;
|
|
@@ -299,16 +353,24 @@ const WATCH_SCRIPT = `(() => {
|
|
|
299
353
|
// An element holding an editable area would be named partly by what was typed there.
|
|
300
354
|
const hasEditable = el => !!el.querySelector('[contenteditable]:not([contenteditable=false])');
|
|
301
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
|
+
};
|
|
302
362
|
const nameOf = el => {
|
|
303
363
|
const labelledBy = el.getAttribute('aria-labelledby');
|
|
304
364
|
const byId = labelledBy && labelledBy.split(/\\s+/).map(id => document.getElementById(id)?.innerText).join(' ');
|
|
305
365
|
const label = (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')) || el.closest('label');
|
|
306
|
-
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')
|
|
307
367
|
|| el.getAttribute('placeholder') || el.getAttribute('title') || (typedInto(el) || hasEditable(el) ? '' : el.innerText));
|
|
308
368
|
};
|
|
309
369
|
const describe = el => { const name = nameOf(el); return roleOf(el) + (name ? ' ' + JSON.stringify(name) : ''); };
|
|
310
370
|
// el null: the page itself, e.g. Escape with nothing focused.
|
|
311
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);
|
|
312
374
|
if (typeof window.__pwReplWatch === 'function') window.__pwReplWatch({ action, target: el ? describe(el) : 'page', extra: extra || '', since: since || 0 });
|
|
313
375
|
};
|
|
314
376
|
document.addEventListener('click', e => {
|
|
@@ -666,28 +728,6 @@ async function allModesOff() {
|
|
|
666
728
|
if (!any) out.log('No modes were on.');
|
|
667
729
|
}
|
|
668
730
|
|
|
669
|
-
// A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
|
|
670
|
-
const REF = /^(?:f\d+)?e\d+$/;
|
|
671
|
-
|
|
672
|
-
function toSelector(word) {
|
|
673
|
-
return REF.test(word) ? `aria-ref=${word}` : word;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// Quotes around the whole of a value are removed; \" inside double quotes is a quote.
|
|
677
|
-
function unquote(text) {
|
|
678
|
-
const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
|
|
679
|
-
if (!match) return text;
|
|
680
|
-
return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// The first word of args, or a quoted selector with spaces in it, and the rest of the line.
|
|
684
|
-
function splitSelector(args) {
|
|
685
|
-
const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
|
|
686
|
-
if (!match) return null;
|
|
687
|
-
const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
|
|
688
|
-
return { word, selector: toSelector(word), rest: match[4] };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
731
|
// Commands that take only a selector take the whole line, spaces and all.
|
|
692
732
|
function soleSelector(args, usage) {
|
|
693
733
|
const text = unquote((args || '').trim());
|
|
@@ -703,14 +743,19 @@ function selectorAndValue(args, usage, example) {
|
|
|
703
743
|
return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
|
|
704
744
|
}
|
|
705
745
|
|
|
706
|
-
//
|
|
746
|
+
// An element that never appeared is said plainly, not as Playwright's call
|
|
747
|
+
// log. A ref that no longer matches usually means the page changed since the
|
|
748
|
+
// snapshot it came from.
|
|
707
749
|
async function onElement(selector, action) {
|
|
708
750
|
try {
|
|
709
751
|
return await action();
|
|
710
752
|
} catch (error) {
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
}
|
|
753
|
+
if (/resolved to/.test(error.message)) throw error;
|
|
754
|
+
const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
|
|
755
|
+
const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
|
|
756
|
+
const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
|
|
757
|
+
if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
|
|
758
|
+
error.message += staleRef;
|
|
714
759
|
throw error;
|
|
715
760
|
}
|
|
716
761
|
}
|
|
@@ -802,13 +847,56 @@ function discardCapture() {
|
|
|
802
847
|
cap = null;
|
|
803
848
|
}
|
|
804
849
|
|
|
805
|
-
// A tab of the REPL's own: closing it can go back to the tab before.
|
|
850
|
+
// A tab of the REPL's own: closing it can go back to the tab before. It opens
|
|
851
|
+
// in the background, so it does not take the front of the window from the
|
|
852
|
+
// person using the browser; Playwright's newPage would bring it to the front.
|
|
853
|
+
const OPEN_TIMEOUT = 10000;
|
|
854
|
+
|
|
806
855
|
async function openTab() {
|
|
807
|
-
const
|
|
856
|
+
const ctx = state.browser.contexts()[0];
|
|
857
|
+
let opened = null;
|
|
858
|
+
const session = await state.browser.newBrowserCDPSession().catch(() => null);
|
|
859
|
+
if (session) {
|
|
860
|
+
try {
|
|
861
|
+
const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
|
|
862
|
+
opened = await pageForTarget(ctx, targetId);
|
|
863
|
+
} catch {} finally {
|
|
864
|
+
await session.detach().catch(() => {});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
|
|
868
|
+
if (!opened) opened = await ctx.newPage();
|
|
808
869
|
openedTabs.add(opened);
|
|
809
870
|
return opened;
|
|
810
871
|
}
|
|
811
872
|
|
|
873
|
+
// page -> its CDP target id, asked for once per page.
|
|
874
|
+
const targetIds = new WeakMap();
|
|
875
|
+
|
|
876
|
+
async function targetIdOf(ctx, p) {
|
|
877
|
+
if (!targetIds.has(p)) {
|
|
878
|
+
const cdp = await ctx.newCDPSession(p).catch(() => null);
|
|
879
|
+
if (!cdp) return null;
|
|
880
|
+
const info = await cdp.send('Target.getTargetInfo').catch(() => null);
|
|
881
|
+
await cdp.detach().catch(() => {});
|
|
882
|
+
if (!info) return null;
|
|
883
|
+
targetIds.set(p, info.targetInfo.targetId);
|
|
884
|
+
}
|
|
885
|
+
return targetIds.get(p);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function pageForTarget(ctx, targetId) {
|
|
889
|
+
const deadline = Date.now() + OPEN_TIMEOUT;
|
|
890
|
+
while (Date.now() < deadline) {
|
|
891
|
+
for (const p of ctx.pages()) {
|
|
892
|
+
if (openedTabs.has(p)) continue;
|
|
893
|
+
if (await targetIdOf(ctx, p) === targetId) return p;
|
|
894
|
+
}
|
|
895
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
|
|
812
900
|
// Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
|
|
813
901
|
async function listTabs() {
|
|
814
902
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
@@ -898,18 +986,20 @@ const commands = {
|
|
|
898
986
|
async goto(args) {
|
|
899
987
|
if (!args) throw new Error('Usage: goto <url>');
|
|
900
988
|
let url = args;
|
|
901
|
-
|
|
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}`;
|
|
902
992
|
await state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
903
993
|
out.log(`${state.page.url()} — ${await state.page.title()}`);
|
|
904
994
|
},
|
|
905
995
|
|
|
906
996
|
async back() {
|
|
907
|
-
await state.page.goBack({ waitUntil: '
|
|
997
|
+
await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
|
|
908
998
|
out.log(`Back to: ${state.page.url()}`);
|
|
909
999
|
},
|
|
910
1000
|
|
|
911
1001
|
async forward() {
|
|
912
|
-
await state.page.goForward({ waitUntil: '
|
|
1002
|
+
await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
|
|
913
1003
|
out.log(`Forward to: ${state.page.url()}`);
|
|
914
1004
|
},
|
|
915
1005
|
|
|
@@ -921,8 +1011,7 @@ const commands = {
|
|
|
921
1011
|
async info() {
|
|
922
1012
|
out.log(` URL: ${state.page.url()}`);
|
|
923
1013
|
out.log(` Title: ${await state.page.title()}`);
|
|
924
|
-
|
|
925
|
-
if (vp) out.log(` Viewport: ${vp.width}x${vp.height}`);
|
|
1014
|
+
out.log(` Viewport: ${await viewportText()}`);
|
|
926
1015
|
},
|
|
927
1016
|
|
|
928
1017
|
async click(args) {
|
|
@@ -951,7 +1040,21 @@ const commands = {
|
|
|
951
1040
|
|
|
952
1041
|
async type(args) {
|
|
953
1042
|
const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
|
|
954
|
-
|
|
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);
|
|
955
1058
|
out.log(`Typed into: ${shown}`);
|
|
956
1059
|
},
|
|
957
1060
|
|
|
@@ -1073,6 +1176,9 @@ const commands = {
|
|
|
1073
1176
|
}
|
|
1074
1177
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1075
1178
|
const filepath = nextScreenshotPath(name);
|
|
1179
|
+
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1180
|
+
// background, so the tab is brought to the front for the shot.
|
|
1181
|
+
await state.page.bringToFront();
|
|
1076
1182
|
const image = await state.page.screenshot({ fullPage: full });
|
|
1077
1183
|
try {
|
|
1078
1184
|
fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
|
|
@@ -1109,11 +1215,7 @@ const commands = {
|
|
|
1109
1215
|
},
|
|
1110
1216
|
|
|
1111
1217
|
async viewport(args) {
|
|
1112
|
-
if (!args) {
|
|
1113
|
-
const vp = state.page.viewportSize();
|
|
1114
|
-
out.log(vp ? `${vp.width}x${vp.height}` : 'No viewport set');
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1218
|
+
if (!args) { out.log(await viewportText()); return; }
|
|
1117
1219
|
const [w, h] = args.split('x').map(Number);
|
|
1118
1220
|
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
1119
1221
|
await state.page.setViewportSize({ width: w, height: h });
|
|
@@ -1226,7 +1328,8 @@ const commands = {
|
|
|
1226
1328
|
// The numbers skip what is hidden; say so, or they look like requests went missing.
|
|
1227
1329
|
const first = matches[0].id;
|
|
1228
1330
|
const last = matches[matches.length - 1].id;
|
|
1229
|
-
|
|
1331
|
+
// With a filter, the numbers skip what does not match too, so the count would mislead.
|
|
1332
|
+
const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
|
|
1230
1333
|
if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
|
|
1231
1334
|
},
|
|
1232
1335
|
|
|
@@ -1247,7 +1350,8 @@ const commands = {
|
|
|
1247
1350
|
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
1248
1351
|
let buffer;
|
|
1249
1352
|
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
1250
|
-
|
|
1353
|
+
// Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
|
|
1354
|
+
catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates)`); }
|
|
1251
1355
|
finally { clearTimeout(timer); }
|
|
1252
1356
|
const type = entry.response.headers()['content-type'] || '';
|
|
1253
1357
|
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
|
@@ -1382,6 +1486,10 @@ const commands = {
|
|
|
1382
1486
|
out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
|
|
1383
1487
|
},
|
|
1384
1488
|
|
|
1489
|
+
async dialog(args) {
|
|
1490
|
+
out.log(await dialogCommand(args));
|
|
1491
|
+
},
|
|
1492
|
+
|
|
1385
1493
|
async modes(args) {
|
|
1386
1494
|
const arg = (args || '').trim();
|
|
1387
1495
|
if (!arg) return listModes();
|
|
@@ -1427,6 +1535,7 @@ function complete(line) {
|
|
|
1427
1535
|
if (command === 'tab') return match(['new', 'close']);
|
|
1428
1536
|
if (command === 'network') return match(['on', 'off']);
|
|
1429
1537
|
if (command === 'modes') return match(['off']);
|
|
1538
|
+
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
1430
1539
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
1431
1540
|
if (command === 'capture') return match(['on', 'off']);
|
|
1432
1541
|
if (command === 'route') return match(['off']);
|
|
@@ -1437,4 +1546,4 @@ function complete(line) {
|
|
|
1437
1546
|
return [[], current];
|
|
1438
1547
|
}
|
|
1439
1548
|
|
|
1440
|
-
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
|
|
|
@@ -72,9 +72,13 @@ const COMMANDS = {
|
|
|
72
72
|
tab: {
|
|
73
73
|
usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
|
|
74
74
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
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
|
|
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
|
+
},
|
|
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.',
|
|
76
81
|
},
|
|
77
|
-
goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
|
|
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' },
|
|
@@ -112,7 +116,7 @@ const COMMANDS = {
|
|
|
112
116
|
screenshot: {
|
|
113
117
|
usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
|
|
114
118
|
summary: 'save a PNG of the viewport (or --full page)',
|
|
115
|
-
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp).\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
|
|
119
|
+
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.',
|
|
116
120
|
},
|
|
117
121
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
118
122
|
wait: {
|
|
@@ -139,7 +143,7 @@ const COMMANDS = {
|
|
|
139
143
|
capture: {
|
|
140
144
|
usage: 'capture on [requests|console] [secs] | off',
|
|
141
145
|
summary: 'record requests and console together, in time order',
|
|
142
|
-
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
|
|
146
|
+
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.',
|
|
143
147
|
},
|
|
144
148
|
route: {
|
|
145
149
|
usage: 'route <glob> <status> <json> | off <glob>|--all',
|
|
@@ -166,7 +170,12 @@ const COMMANDS = {
|
|
|
166
170
|
modes: {
|
|
167
171
|
usage: 'modes [off]',
|
|
168
172
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
169
|
-
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, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
|
|
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.',
|
|
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.',
|
|
170
179
|
},
|
|
171
180
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
172
181
|
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'];
|
|
@@ -72,6 +72,8 @@ async function executeCommand(text) {
|
|
|
72
72
|
try {
|
|
73
73
|
await commands[cmd](args, all);
|
|
74
74
|
} catch (e) {
|
|
75
|
+
// Playwright colours its call log; the codes are noise anywhere but a terminal.
|
|
76
|
+
e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
75
77
|
out.error(`Error: ${e.message}`);
|
|
76
78
|
const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
|
|
77
79
|
if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
|
|
@@ -115,9 +117,10 @@ function prompt(preserveCursor) {
|
|
|
115
117
|
state.rl.prompt(preserveCursor);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
// 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.
|
|
119
122
|
function enqueue(line) {
|
|
120
|
-
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());
|
|
121
124
|
const run = () => handleLine(line);
|
|
122
125
|
if (direct) run().catch(e => out.error(`Error: ${e.message}`));
|
|
123
126
|
else queue = queue.then(run).catch(e => out.error(`Error: ${e.message}`));
|
|
@@ -126,6 +129,7 @@ function enqueue(line) {
|
|
|
126
129
|
// Server commands share the prompt's queue and are echoed to the pane, so
|
|
127
130
|
// someone watching sees everything an agent does.
|
|
128
131
|
function submit(text) {
|
|
132
|
+
if (/^dialog(?:\s|$)/.test(text)) return answerDialog(text);
|
|
129
133
|
return new Promise(resolve => {
|
|
130
134
|
queue = queue.then(async () => {
|
|
131
135
|
if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
|
|
@@ -154,6 +158,18 @@ function submit(text) {
|
|
|
154
158
|
});
|
|
155
159
|
}
|
|
156
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
|
+
|
|
157
173
|
function drained() {
|
|
158
174
|
return queue;
|
|
159
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)) {
|
|
@@ -122,10 +128,15 @@ async function where(options) {
|
|
|
122
128
|
if (way.explicit) return 64;
|
|
123
129
|
}
|
|
124
130
|
const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
|
|
125
|
-
if (!hasSession(session)) {
|
|
131
|
+
if (!hasSession(session)) {
|
|
132
|
+
console.error(`tmux: no session '${session}'`);
|
|
133
|
+
console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
|
|
134
|
+
return 64;
|
|
135
|
+
}
|
|
126
136
|
const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
|
|
127
137
|
if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
|
|
128
138
|
console.error(`tmux: session '${session}' is running '${running}', not the REPL`);
|
|
139
|
+
console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
|
|
129
140
|
return 64;
|
|
130
141
|
}
|
|
131
142
|
|
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/lib/syntax.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// How a command line's words are read, shared by the REPL and pw-repl send.
|
|
2
|
+
|
|
3
|
+
// Commands whose first word is a selector, quoted if it has spaces, and whose
|
|
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']);
|
|
6
|
+
|
|
7
|
+
// A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
|
|
8
|
+
const REF = /^(?:f\d+)?e\d+$/;
|
|
9
|
+
|
|
10
|
+
function toSelector(word) {
|
|
11
|
+
return REF.test(word) ? `aria-ref=${word}` : word;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Quotes around the whole of a value are removed; \" inside double quotes is a quote.
|
|
15
|
+
function unquote(text) {
|
|
16
|
+
const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
|
|
17
|
+
if (!match) return text;
|
|
18
|
+
return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The first word of args, or a quoted selector with spaces in it, and the rest of the line.
|
|
22
|
+
function splitSelector(args) {
|
|
23
|
+
const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
|
|
26
|
+
return { word, selector: toSelector(word), rest: match[4] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { SELECTOR_FIRST, toSelector, unquote, splitSelector };
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -11,6 +11,12 @@ shared session: a person can have their own tabs open in it and be using it whil
|
|
|
11
11
|
`pw-repl` below is the command the `pw-repl` npm package installs. Without a global install,
|
|
12
12
|
`npx pw-repl` works the same; from a clone, `<clone>/bin/pw-repl.js`.
|
|
13
13
|
|
|
14
|
+
## Arguments
|
|
15
|
+
|
|
16
|
+
Freeform args describe what to do in the browser: a page to look at, a flow to try, a failure to
|
|
17
|
+
reproduce (e.g. `pw-repl find out why the cart total shows 0 after adding an item`). They are the task;
|
|
18
|
+
the sections below are how to carry it out. With no args, get a REPL running and ask what to do.
|
|
19
|
+
|
|
14
20
|
## Start it
|
|
15
21
|
|
|
16
22
|
`pw-repl where` says whether a REPL is running and how `send` reaches it. There are three ways to run
|
|
@@ -29,8 +35,18 @@ one; each takes an optional start URL, which opens in a new tab.
|
|
|
29
35
|
tmux send-keys -t playwright-repl Enter
|
|
30
36
|
```
|
|
31
37
|
|
|
32
|
-
`serve
|
|
33
|
-
|
|
38
|
+
`serve` and `serve --background` take a socket path of your own instead of the default
|
|
39
|
+
(`pw-repl serve --background /tmp/mine.sock`); `send`, `attach`, `stop` and `where` then need
|
|
40
|
+
`-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`. The log of a background REPL is next to its socket
|
|
41
|
+
(`/tmp/mine.log`); `tail -f` on it follows along without a terminal to attach from. `serve <port>`
|
|
42
|
+
listens on TCP 127.0.0.1 instead of a socket, with no access control.
|
|
43
|
+
|
|
44
|
+
Several REPLs can run at once, each on its own socket, e.g. one per agent. Each has its own selected tab,
|
|
45
|
+
command queue and modes, so they do not wait on or select for each other. They share the browser,
|
|
46
|
+
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 network setting.
|
|
48
|
+
|
|
49
|
+
A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
|
|
34
50
|
|
|
35
51
|
## Send commands
|
|
36
52
|
|
|
@@ -40,12 +56,13 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
|
|
|
40
56
|
```
|
|
41
57
|
|
|
42
58
|
Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
|
|
43
|
-
are the command
|
|
44
|
-
`pw-repl send fill "text=Your name" Ada
|
|
59
|
+
are the command. For `fill`, `type`, `select` and `press`, a word quoted in your shell stays one word
|
|
60
|
+
(`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are
|
|
61
|
+
(`pw-repl send eval "document.title + ' x'"`). It works however the REPL was started:
|
|
45
62
|
|
|
46
63
|
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
47
64
|
It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|
|
48
|
-
of what
|
|
65
|
+
of what someone is typing; while a command is running, someone is typing, or the REPL is exiting, it
|
|
49
66
|
refuses (exit 64). Wait and retry.
|
|
50
67
|
- `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
|
|
51
68
|
as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
|
|
@@ -55,14 +72,13 @@ are the command, and a word quoted in your shell stays one word:
|
|
|
55
72
|
REPL), or why neither is reachable, without running anything.
|
|
56
73
|
|
|
57
74
|
Every command you run and its output show in the REPL's pane, or in `attach` and the log for a
|
|
58
|
-
background REPL (server commands as `[server]` lines), so
|
|
59
|
-
commands only, not what
|
|
60
|
-
at the browser itself: `tab` and `info` for where
|
|
61
|
-
made (`body <#>` for what one returned), `console` for console messages and page errors.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
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.
|
|
66
82
|
|
|
67
83
|
Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
|
|
68
84
|
blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
|
|
@@ -73,23 +89,26 @@ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their command
|
|
|
73
89
|
gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
|
|
74
90
|
help is the command reference; this file does not repeat it.
|
|
75
91
|
|
|
76
|
-
##
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
89
107
|
|
|
90
108
|
## Environment
|
|
91
109
|
|
|
92
|
-
- 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`.
|
|
93
112
|
- `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
|
|
94
113
|
- `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
|
|
95
114
|
so `rm /tmp/screenshot-*.png` cleans up.
|