pw-repl 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pw-repl.js +7 -3
- package/lib/commands.js +62 -30
- package/lib/help.js +4 -4
- package/lib/runner.js +2 -0
- package/lib/send.js +6 -1
- package/lib/syntax.js +29 -0
- package/package.json +1 -1
- package/skill/SKILL.md +21 -4
package/bin/pw-repl.js
CHANGED
|
@@ -67,9 +67,13 @@ 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
|
+
const quoted = requote ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
|
|
73
77
|
options.command = quoted.join(' ').trim();
|
|
74
78
|
return options;
|
|
75
79
|
}
|
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';
|
|
@@ -666,28 +667,6 @@ async function allModesOff() {
|
|
|
666
667
|
if (!any) out.log('No modes were on.');
|
|
667
668
|
}
|
|
668
669
|
|
|
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
670
|
// Commands that take only a selector take the whole line, spaces and all.
|
|
692
671
|
function soleSelector(args, usage) {
|
|
693
672
|
const text = unquote((args || '').trim());
|
|
@@ -703,14 +682,19 @@ function selectorAndValue(args, usage, example) {
|
|
|
703
682
|
return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
|
|
704
683
|
}
|
|
705
684
|
|
|
706
|
-
//
|
|
685
|
+
// An element that never appeared is said plainly, not as Playwright's call
|
|
686
|
+
// log. A ref that no longer matches usually means the page changed since the
|
|
687
|
+
// snapshot it came from.
|
|
707
688
|
async function onElement(selector, action) {
|
|
708
689
|
try {
|
|
709
690
|
return await action();
|
|
710
691
|
} catch (error) {
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
}
|
|
692
|
+
if (/resolved to/.test(error.message)) throw error;
|
|
693
|
+
const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
|
|
694
|
+
const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
|
|
695
|
+
const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
|
|
696
|
+
if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
|
|
697
|
+
error.message += staleRef;
|
|
714
698
|
throw error;
|
|
715
699
|
}
|
|
716
700
|
}
|
|
@@ -802,13 +786,56 @@ function discardCapture() {
|
|
|
802
786
|
cap = null;
|
|
803
787
|
}
|
|
804
788
|
|
|
805
|
-
// A tab of the REPL's own: closing it can go back to the tab before.
|
|
789
|
+
// A tab of the REPL's own: closing it can go back to the tab before. It opens
|
|
790
|
+
// in the background, so it does not take the front of the window from the
|
|
791
|
+
// person using the browser; Playwright's newPage would bring it to the front.
|
|
792
|
+
const OPEN_TIMEOUT = 10000;
|
|
793
|
+
|
|
806
794
|
async function openTab() {
|
|
807
|
-
const
|
|
795
|
+
const ctx = state.browser.contexts()[0];
|
|
796
|
+
let opened = null;
|
|
797
|
+
const session = await state.browser.newBrowserCDPSession().catch(() => null);
|
|
798
|
+
if (session) {
|
|
799
|
+
try {
|
|
800
|
+
const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
|
|
801
|
+
opened = await pageForTarget(ctx, targetId);
|
|
802
|
+
} catch {} finally {
|
|
803
|
+
await session.detach().catch(() => {});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
|
|
807
|
+
if (!opened) opened = await ctx.newPage();
|
|
808
808
|
openedTabs.add(opened);
|
|
809
809
|
return opened;
|
|
810
810
|
}
|
|
811
811
|
|
|
812
|
+
// page -> its CDP target id, asked for once per page.
|
|
813
|
+
const targetIds = new WeakMap();
|
|
814
|
+
|
|
815
|
+
async function targetIdOf(ctx, p) {
|
|
816
|
+
if (!targetIds.has(p)) {
|
|
817
|
+
const cdp = await ctx.newCDPSession(p).catch(() => null);
|
|
818
|
+
if (!cdp) return null;
|
|
819
|
+
const info = await cdp.send('Target.getTargetInfo').catch(() => null);
|
|
820
|
+
await cdp.detach().catch(() => {});
|
|
821
|
+
if (!info) return null;
|
|
822
|
+
targetIds.set(p, info.targetInfo.targetId);
|
|
823
|
+
}
|
|
824
|
+
return targetIds.get(p);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function pageForTarget(ctx, targetId) {
|
|
828
|
+
const deadline = Date.now() + OPEN_TIMEOUT;
|
|
829
|
+
while (Date.now() < deadline) {
|
|
830
|
+
for (const p of ctx.pages()) {
|
|
831
|
+
if (openedTabs.has(p)) continue;
|
|
832
|
+
if (await targetIdOf(ctx, p) === targetId) return p;
|
|
833
|
+
}
|
|
834
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
812
839
|
// Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
|
|
813
840
|
async function listTabs() {
|
|
814
841
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
@@ -1073,6 +1100,9 @@ const commands = {
|
|
|
1073
1100
|
}
|
|
1074
1101
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1075
1102
|
const filepath = nextScreenshotPath(name);
|
|
1103
|
+
// Chrome does not draw a tab that is not in front, and tabs open in the
|
|
1104
|
+
// background, so the tab is brought to the front for the shot.
|
|
1105
|
+
await state.page.bringToFront();
|
|
1076
1106
|
const image = await state.page.screenshot({ fullPage: full });
|
|
1077
1107
|
try {
|
|
1078
1108
|
fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
|
|
@@ -1226,7 +1256,8 @@ const commands = {
|
|
|
1226
1256
|
// The numbers skip what is hidden; say so, or they look like requests went missing.
|
|
1227
1257
|
const first = matches[0].id;
|
|
1228
1258
|
const last = matches[matches.length - 1].id;
|
|
1229
|
-
|
|
1259
|
+
// With a filter, the numbers skip what does not match too, so the count would mislead.
|
|
1260
|
+
const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
|
|
1230
1261
|
if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
|
|
1231
1262
|
},
|
|
1232
1263
|
|
|
@@ -1247,7 +1278,8 @@ const commands = {
|
|
|
1247
1278
|
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
1248
1279
|
let buffer;
|
|
1249
1280
|
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
1250
|
-
|
|
1281
|
+
// Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
|
|
1282
|
+
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
1283
|
finally { clearTimeout(timer); }
|
|
1252
1284
|
const type = entry.response.headers()['content-type'] || '';
|
|
1253
1285
|
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
package/lib/help.js
CHANGED
|
@@ -72,7 +72,7 @@ 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
76
|
},
|
|
77
77
|
goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
|
|
78
78
|
back: { usage: 'back', summary: 'go back one history entry' },
|
|
@@ -112,7 +112,7 @@ const COMMANDS = {
|
|
|
112
112
|
screenshot: {
|
|
113
113
|
usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
|
|
114
114
|
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.',
|
|
115
|
+
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
116
|
},
|
|
117
117
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
118
118
|
wait: {
|
|
@@ -139,7 +139,7 @@ const COMMANDS = {
|
|
|
139
139
|
capture: {
|
|
140
140
|
usage: 'capture on [requests|console] [secs] | off',
|
|
141
141
|
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
|
|
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. 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
143
|
},
|
|
144
144
|
route: {
|
|
145
145
|
usage: 'route <glob> <status> <json> | off <glob>|--all',
|
|
@@ -166,7 +166,7 @@ const COMMANDS = {
|
|
|
166
166
|
modes: {
|
|
167
167
|
usage: 'modes [off]',
|
|
168
168
|
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.',
|
|
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 <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
170
|
},
|
|
171
171
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
172
172
|
quit: {
|
package/lib/runner.js
CHANGED
|
@@ -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.');
|
package/lib/send.js
CHANGED
|
@@ -122,10 +122,15 @@ async function where(options) {
|
|
|
122
122
|
if (way.explicit) return 64;
|
|
123
123
|
}
|
|
124
124
|
const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
|
|
125
|
-
if (!hasSession(session)) {
|
|
125
|
+
if (!hasSession(session)) {
|
|
126
|
+
console.error(`tmux: no session '${session}'`);
|
|
127
|
+
console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
|
|
128
|
+
return 64;
|
|
129
|
+
}
|
|
126
130
|
const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
|
|
127
131
|
if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
|
|
128
132
|
console.error(`tmux: session '${session}' is running '${running}', not the REPL`);
|
|
133
|
+
console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
|
|
129
134
|
return 64;
|
|
130
135
|
}
|
|
131
136
|
|
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,8 +56,9 @@ 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
|