pw-repl 0.1.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/AGENTS.md +15 -0
- package/README.md +16 -3
- package/bin/pw-repl.js +36 -8
- package/lib/background.js +187 -0
- package/lib/commands.js +178 -76
- package/lib/help.js +20 -15
- package/lib/runner.js +16 -16
- package/lib/send.js +15 -5
- package/lib/server.js +1 -3
- package/lib/start.js +23 -14
- package/lib/syntax.js +29 -0
- package/package.json +1 -1
- package/skill/SKILL.md +54 -38
package/AGENTS.md
CHANGED
|
@@ -22,3 +22,18 @@ If you hit a limitation or write a workaround, consider adding the capability to
|
|
|
22
22
|
and a new tab, not the user's.
|
|
23
23
|
- `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
|
|
24
24
|
used, and leave its Custom rules section empty.
|
|
25
|
+
|
|
26
|
+
## Releasing
|
|
27
|
+
|
|
28
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`,
|
|
29
|
+
`test:`, `chore:` and so on, with an optional scope (`fix(watch): ...`). Versions follow semver; before
|
|
30
|
+
1.0, a change that breaks how the REPL is used is a minor bump (0.2.0 to 0.3.0), anything else a patch.
|
|
31
|
+
|
|
32
|
+
1. `npm test` passes and the working tree is clean.
|
|
33
|
+
2. `npm outdated` and `npm audit` show nothing that needs doing first.
|
|
34
|
+
3. `npm version <patch|minor|major> -m "chore: release %s"` sets the version in `package.json` and
|
|
35
|
+
`package-lock.json`, commits it as `chore: release X.Y.Z`, and tags that commit `vX.Y.Z`.
|
|
36
|
+
4. `git push --follow-tags` pushes the commits and the tag.
|
|
37
|
+
5. `npm publish` publishes it; `prepublishOnly` runs the tests again first.
|
|
38
|
+
|
|
39
|
+
Pushing and publishing need the maintainer's GitHub and npm credentials.
|
package/README.md
CHANGED
|
@@ -33,7 +33,8 @@ From a clone: `npm install`, then `bin/pw-repl.js run` (or `npm link` to get `pw
|
|
|
33
33
|
|
|
34
34
|
It lists the open tabs and shows a `pw>` prompt. To send it commands from scripts or agents later
|
|
35
35
|
(`pw-repl send`), run it in a tmux session named `playwright-repl` (`tmux new -s playwright-repl`), or
|
|
36
|
-
start it with `pw-repl serve` instead, which needs no tmux
|
|
36
|
+
start it with `pw-repl serve` instead, which needs no tmux (`pw-repl serve --background` runs it without
|
|
37
|
+
a terminal; see [In the background](#in-the-background)).
|
|
37
38
|
|
|
38
39
|
### 3. Try it
|
|
39
40
|
|
|
@@ -41,7 +42,7 @@ start it with `pw-repl serve` instead, which needs no tmux.
|
|
|
41
42
|
pw> help # common tasks and topics; help <topic>, help <command>, help --all
|
|
42
43
|
pw> tab new https://example.com # open your own tab to work in
|
|
43
44
|
pw> snapshot # the page by role and name, with [ref=eN] labels
|
|
44
|
-
pw> click
|
|
45
|
+
pw> click e3 # click by snapshot ref (or any Playwright selector)
|
|
45
46
|
pw> requests # requests the tab made
|
|
46
47
|
pw> tab close
|
|
47
48
|
```
|
|
@@ -78,11 +79,23 @@ run next.
|
|
|
78
79
|
## Options
|
|
79
80
|
|
|
80
81
|
```bash
|
|
81
|
-
pw-repl run http://localhost:3000 # connect and
|
|
82
|
+
pw-repl run http://localhost:3000 # connect and open a URL in a new tab
|
|
82
83
|
pw-repl serve # also accept commands on /tmp/playwright-repl.sock (prompt: pw[serve]>)
|
|
83
84
|
PW_CDP_URL=http://host:9222 pw-repl run # a browser elsewhere
|
|
84
85
|
```
|
|
85
86
|
|
|
87
|
+
### In the background
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pw-repl serve --background # detached, serving /tmp/playwright-repl.sock; output in /tmp/playwright-repl.log
|
|
91
|
+
pw-repl attach # see everything it does and type commands to it; Ctrl-C leaves it running
|
|
92
|
+
pw-repl stop # stop it
|
|
93
|
+
pw-repl where # is one running, and where
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A background process cannot be brought back to the foreground like a Ctrl-Z job; `attach` is how you
|
|
97
|
+
get back to it, from any terminal.
|
|
98
|
+
|
|
86
99
|
**Browser on the host, REPL in a container:** with host networking, `localhost:9222` reaches the host's
|
|
87
100
|
browser directly. Otherwise set `PW_CDP_URL` to the host's address as seen from the container (for example
|
|
88
101
|
the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
|
package/bin/pw-repl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The one command: pw-repl run | serve | send | where | help | skill.
|
|
2
|
+
// The one command: pw-repl run | serve | attach | stop | send | where | help | skill.
|
|
3
3
|
|
|
4
4
|
const { looksLikeEndpoint } = require('../lib/client');
|
|
5
5
|
|
|
@@ -7,8 +7,15 @@ const USAGE = `Usage:
|
|
|
7
7
|
pw-repl run [start-url]
|
|
8
8
|
connect to the browser and open the prompt
|
|
9
9
|
|
|
10
|
-
pw-repl serve [endpoint] [start-url]
|
|
11
|
-
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock)
|
|
10
|
+
pw-repl serve [--background] [endpoint] [start-url]
|
|
11
|
+
the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock);
|
|
12
|
+
--background runs it detached, with its output in a log next to the socket
|
|
13
|
+
|
|
14
|
+
pw-repl attach [-e endpoint]
|
|
15
|
+
see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
|
|
16
|
+
|
|
17
|
+
pw-repl stop [-e endpoint]
|
|
18
|
+
stop a background REPL
|
|
12
19
|
|
|
13
20
|
pw-repl send [-e endpoint | -s session] [-t seconds] <command...>
|
|
14
21
|
run one command in a running REPL and print its output
|
|
@@ -28,7 +35,9 @@ exists but nothing answers, send fails rather than fall back.
|
|
|
28
35
|
|
|
29
36
|
send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
|
|
30
37
|
|
|
31
|
-
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL)
|
|
38
|
+
The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
|
|
39
|
+
|
|
40
|
+
In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
|
|
32
41
|
|
|
33
42
|
const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
|
|
34
43
|
|
|
@@ -58,13 +67,21 @@ function parseSendArgs(args, allowCommand) {
|
|
|
58
67
|
}
|
|
59
68
|
const words = args.slice(i);
|
|
60
69
|
if (!allowCommand && words.length) usage();
|
|
61
|
-
|
|
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;
|
|
77
|
+
options.command = quoted.join(' ').trim();
|
|
62
78
|
return options;
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
function startOptions(args, serve) {
|
|
66
|
-
const options = { serve, endpoint: null, startUrl: null };
|
|
82
|
+
const options = { serve, endpoint: null, startUrl: null, background: false, pidFile: process.env.PW_REPL_PID_FILE || null };
|
|
67
83
|
const rest = [...args];
|
|
84
|
+
if (serve && rest.includes('--background')) { options.background = true; rest.splice(rest.indexOf('--background'), 1); }
|
|
68
85
|
if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
|
|
69
86
|
if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
|
|
70
87
|
options.startUrl = rest[0] || null;
|
|
@@ -93,8 +110,19 @@ async function main() {
|
|
|
93
110
|
if (subcommand === undefined) return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
94
111
|
switch (subcommand) {
|
|
95
112
|
case 'run':
|
|
96
|
-
case 'serve':
|
|
97
|
-
|
|
113
|
+
case 'serve': {
|
|
114
|
+
const options = startOptions(args, subcommand === 'serve');
|
|
115
|
+
if (options.background) process.exit(await require('../lib/background').start(options));
|
|
116
|
+
return require('../lib/start').start(options);
|
|
117
|
+
}
|
|
118
|
+
case 'attach':
|
|
119
|
+
case 'stop': {
|
|
120
|
+
const options = parseSendArgs(args, false);
|
|
121
|
+
if (options.session) usage();
|
|
122
|
+
const endpoint = options.endpoint || process.env.PW_ENDPOINT || null;
|
|
123
|
+
process.exit(await require('../lib/background')[subcommand]({ endpoint }));
|
|
124
|
+
}
|
|
125
|
+
// falls through never: process.exit above
|
|
98
126
|
case 'send': {
|
|
99
127
|
const options = parseSendArgs(args, true);
|
|
100
128
|
// help is fixed text, so it needs no browser: answer it here.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// pw-repl serve --background, attach and stop: a REPL that runs detached,
|
|
2
|
+
// found through the files it keeps next to its socket.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
const client = require('./client');
|
|
8
|
+
|
|
9
|
+
const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
|
|
10
|
+
const START_TIMEOUT = 20000;
|
|
11
|
+
const STOP_TIMEOUT = 10000;
|
|
12
|
+
const FOLLOW_INTERVAL = 150;
|
|
13
|
+
const ATTACH_BACKLOG = 20;
|
|
14
|
+
const ATTACH_COMMAND_TIMEOUT = 3600000;
|
|
15
|
+
|
|
16
|
+
// /tmp/playwright-repl.sock keeps /tmp/playwright-repl.pid and .log; a port, /tmp/playwright-repl-<port>.*.
|
|
17
|
+
function filesFor(endpoint) {
|
|
18
|
+
const base = endpoint.socket ? endpoint.socket.replace(/\.sock$/, '') : path.join('/tmp', `playwright-repl-${endpoint.port}`);
|
|
19
|
+
return { pidFile: `${base}.pid`, logFile: `${base}.log` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function alive(pid) {
|
|
23
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The pid of the background REPL, or null; a pid file left by one that died is removed.
|
|
27
|
+
function runningPid(pidFile) {
|
|
28
|
+
let pid;
|
|
29
|
+
try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); } catch { return null; }
|
|
30
|
+
if (pid > 0 && alive(pid)) return pid;
|
|
31
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// What attach and stop need to find a REPL that is not on the default socket.
|
|
36
|
+
function endpointFlag(endpointArg) {
|
|
37
|
+
const endpoint = client.parseEndpoint(endpointArg);
|
|
38
|
+
return endpoint.socket === client.DEFAULT_SOCKET ? '' : ` -e ${client.describe(endpoint)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(message) {
|
|
42
|
+
console.error(`pw-repl: ${message}`);
|
|
43
|
+
return 64;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tail(file, lines) {
|
|
47
|
+
try { return fs.readFileSync(file, 'utf8').trimEnd().split('\n').slice(-lines).join('\n'); } catch { return ''; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Called in the background REPL once it serves: its pid file says it runs, and goes when it exits.
|
|
51
|
+
function claimPidFile(pidFile) {
|
|
52
|
+
fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
|
|
53
|
+
process.on('exit', () => {
|
|
54
|
+
try { if (fs.readFileSync(pidFile, 'utf8').trim() === String(process.pid)) fs.unlinkSync(pidFile); } catch {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function start(options) {
|
|
59
|
+
const endpoint = client.parseEndpoint(options.endpoint);
|
|
60
|
+
const name = client.describe(endpoint);
|
|
61
|
+
const { pidFile, logFile } = filesFor(endpoint);
|
|
62
|
+
const running = runningPid(pidFile);
|
|
63
|
+
if (running) return fail(`a background REPL is already serving on ${name} (pid ${running}); pw-repl attach uses it, pw-repl stop stops it`);
|
|
64
|
+
if (await client.health(endpoint, 2000)) return fail(`a REPL is already serving on ${name}, in a terminal; pw-repl where says more`);
|
|
65
|
+
const log = fs.openSync(logFile, 'w', 0o600);
|
|
66
|
+
fs.fchmodSync(log, 0o600);
|
|
67
|
+
const args = [BIN, 'serve', ...(options.endpoint ? [options.endpoint] : []), ...(options.startUrl ? [options.startUrl] : [])];
|
|
68
|
+
const child = spawn(process.execPath, args, {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: ['ignore', log, log],
|
|
71
|
+
env: { ...process.env, PW_REPL_PID_FILE: pidFile },
|
|
72
|
+
});
|
|
73
|
+
fs.closeSync(log);
|
|
74
|
+
let exited = false;
|
|
75
|
+
child.on('exit', () => { exited = true; });
|
|
76
|
+
const deadline = Date.now() + START_TIMEOUT;
|
|
77
|
+
while (!exited && Date.now() < deadline && !(await client.health(endpoint, 1000) && runningPid(pidFile))) {
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
79
|
+
}
|
|
80
|
+
if (exited || !runningPid(pidFile)) {
|
|
81
|
+
if (!exited) child.kill();
|
|
82
|
+
const last = tail(logFile, 10);
|
|
83
|
+
return fail(`the background REPL did not start${last ? `; the end of ${logFile}:\n${last}` : ''}`);
|
|
84
|
+
}
|
|
85
|
+
child.unref();
|
|
86
|
+
const e = endpointFlag(options.endpoint);
|
|
87
|
+
console.log(`Serving in the background (pid ${child.pid}) on ${name}`);
|
|
88
|
+
console.log(`Log: ${logFile}`);
|
|
89
|
+
console.log('');
|
|
90
|
+
const attachCommand = `pw-repl attach${e}`;
|
|
91
|
+
console.log(` ${attachCommand} see everything it does, and type commands to it`);
|
|
92
|
+
console.log(` ${`pw-repl stop${e}`.padEnd(attachCommand.length)} stop it`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function stop(options) {
|
|
97
|
+
const endpoint = client.parseEndpoint(options.endpoint);
|
|
98
|
+
const { pidFile } = filesFor(endpoint);
|
|
99
|
+
const pid = runningPid(pidFile);
|
|
100
|
+
if (!pid) return fail(`no background REPL is serving on ${client.describe(endpoint)}`);
|
|
101
|
+
// Like Ctrl-C: a command still running is answered, and the socket is removed.
|
|
102
|
+
process.kill(pid, 'SIGTERM');
|
|
103
|
+
const deadline = Date.now() + STOP_TIMEOUT;
|
|
104
|
+
while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
|
|
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}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Follows the background REPL's log, which holds everything it prints, and
|
|
111
|
+
// sends what is typed as commands; their output then shows in the log too.
|
|
112
|
+
async function attach(options) {
|
|
113
|
+
const endpoint = client.parseEndpoint(options.endpoint);
|
|
114
|
+
const name = client.describe(endpoint);
|
|
115
|
+
const { pidFile, logFile } = filesFor(endpoint);
|
|
116
|
+
const pid = runningPid(pidFile);
|
|
117
|
+
if (!pid) {
|
|
118
|
+
const served = await client.health(endpoint, 2000);
|
|
119
|
+
return fail(served ? `the REPL on ${name} runs in a terminal, not in the background; use it there` : `no background REPL is serving on ${name}; pw-repl serve --background starts one`);
|
|
120
|
+
}
|
|
121
|
+
const fd = fs.openSync(logFile, 'r');
|
|
122
|
+
const backlog = tail(logFile, ATTACH_BACKLOG);
|
|
123
|
+
let position = fs.fstatSync(fd).size;
|
|
124
|
+
console.log(`Attached to the background REPL (pid ${pid}) on ${name}. Ctrl-C or Ctrl-D leaves it running.`);
|
|
125
|
+
if (backlog) console.log(`\n${backlog}`);
|
|
126
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'pw[attach]> ' });
|
|
127
|
+
let inputEnded = false;
|
|
128
|
+
// New log lines go above what is being typed, which is then redrawn.
|
|
129
|
+
const follow = () => {
|
|
130
|
+
const size = fs.fstatSync(fd).size;
|
|
131
|
+
if (size < position) position = 0;
|
|
132
|
+
if (size === position) return;
|
|
133
|
+
const buffer = Buffer.alloc(size - position);
|
|
134
|
+
fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
135
|
+
position = size;
|
|
136
|
+
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
137
|
+
process.stdout.write(buffer.toString('utf8'));
|
|
138
|
+
if (!inputEnded) rl.prompt(true);
|
|
139
|
+
};
|
|
140
|
+
let leaving = false;
|
|
141
|
+
const leave = message => {
|
|
142
|
+
if (leaving) return;
|
|
143
|
+
leaving = true;
|
|
144
|
+
clearInterval(timer);
|
|
145
|
+
follow();
|
|
146
|
+
process.stdout.write(`\n${message}\n`);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
};
|
|
149
|
+
const timer = setInterval(() => {
|
|
150
|
+
follow();
|
|
151
|
+
if (!runningPid(pidFile)) leave('The background REPL stopped.');
|
|
152
|
+
}, FOLLOW_INTERVAL);
|
|
153
|
+
let queue = Promise.resolve();
|
|
154
|
+
rl.on('line', line => {
|
|
155
|
+
const command = line.trim();
|
|
156
|
+
if (!command) { if (!inputEnded) rl.prompt(); return; }
|
|
157
|
+
if (command === 'quit' || command === 'exit') { leave('Left; the background REPL keeps running (pw-repl stop stops it).'); return; }
|
|
158
|
+
queue = queue.then(async () => {
|
|
159
|
+
const answer = await client.request(endpoint, command, ATTACH_COMMAND_TIMEOUT);
|
|
160
|
+
if (answer.unreachable || answer.dropped) console.error(`pw-repl: the background REPL did not answer (${answer.unreachable || answer.dropped})`);
|
|
161
|
+
// Let the log catch up, so the prompt comes back after the output.
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, FOLLOW_INTERVAL));
|
|
163
|
+
follow();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
// Input can end before the commands typed ahead of it have run (e.g. piped in).
|
|
167
|
+
rl.on('close', () => {
|
|
168
|
+
inputEnded = true;
|
|
169
|
+
queue.then(() => leave('Left; the background REPL keeps running (pw-repl stop stops it).'));
|
|
170
|
+
});
|
|
171
|
+
rl.on('SIGINT', () => leave('Left; the background REPL keeps running (pw-repl stop stops it).'));
|
|
172
|
+
rl.prompt();
|
|
173
|
+
return new Promise(() => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// For pw-repl where: the background REPL on an endpoint, if there is one.
|
|
177
|
+
function describeBackground(endpoint) {
|
|
178
|
+
const { pidFile, logFile } = filesFor(endpoint);
|
|
179
|
+
const pid = runningPid(pidFile);
|
|
180
|
+
if (!pid) return null;
|
|
181
|
+
let since = '';
|
|
182
|
+
try { since = `, since ${fs.statSync(pidFile).mtime.toLocaleString()}`; } catch {}
|
|
183
|
+
const e = endpoint.socket === client.DEFAULT_SOCKET ? '' : ` -e ${client.describe(endpoint)}`;
|
|
184
|
+
return `background: pid ${pid}${since}; log ${logFile} (pw-repl attach${e}, pw-repl stop${e})`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { filesFor, endpointFlag, claimPidFile, start, stop, attach, describeBackground };
|
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';
|
|
@@ -42,9 +43,9 @@ async function startCapture(tokens) {
|
|
|
42
43
|
};
|
|
43
44
|
const page = state.page;
|
|
44
45
|
const listen = (event, handler) => { page.on(event, handler); handlers.push([event, handler]); };
|
|
45
|
-
if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', text: `${req.method()} ${req.url()}` }));
|
|
46
|
+
if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', req, text: `${req.method()} ${req.url()}` }));
|
|
46
47
|
if (label !== 'requests') {
|
|
47
|
-
listen('console', msg => record({ t: Date.now(), tag:
|
|
48
|
+
listen('console', msg => record({ t: Date.now(), tag: msg.type(), text: consoleText(msg) }));
|
|
48
49
|
listen('pageerror', error => record({ t: Date.now(), tag: 'pageerror', text: error.stack || error.message }));
|
|
49
50
|
}
|
|
50
51
|
// A capture must not outlive its tab: nothing could see or stop it.
|
|
@@ -201,6 +202,13 @@ function clipText(text) {
|
|
|
201
202
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// The browser's own "Failed to load resource" message does not say which one.
|
|
206
|
+
function consoleText(msg) {
|
|
207
|
+
const text = msg.text();
|
|
208
|
+
const url = msg.location()?.url;
|
|
209
|
+
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
210
|
+
}
|
|
211
|
+
|
|
204
212
|
function keep(log, entry) {
|
|
205
213
|
log.push(entry);
|
|
206
214
|
if (log.length > RECENT_MAX) log.shift();
|
|
@@ -235,7 +243,7 @@ function ensureRecentLog(p) {
|
|
|
235
243
|
finish(req, fakedRequests.has(req) ? `${status} faked` : status, res);
|
|
236
244
|
});
|
|
237
245
|
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
238
|
-
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(msg
|
|
246
|
+
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
239
247
|
// Uncaught exceptions never reach the console event.
|
|
240
248
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
241
249
|
}
|
|
@@ -531,6 +539,12 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
531
539
|
// Only now: a failed first attempt must leave nothing half set up to retry against.
|
|
532
540
|
watches.set(p, created);
|
|
533
541
|
watch = created;
|
|
542
|
+
} else if (!watch.on) {
|
|
543
|
+
// A watch on after watch off is a new recording: the old steps would read as part of it.
|
|
544
|
+
watch.events.length = 0;
|
|
545
|
+
watch.shownSeq = watch.nextSeq - 1;
|
|
546
|
+
watch.shownRequestId = Math.max(0, ...(recentLogs.get(p) || []).map(r => r.id));
|
|
547
|
+
watch.liveStep = null;
|
|
534
548
|
}
|
|
535
549
|
await p.evaluate(WATCH_SCRIPT);
|
|
536
550
|
watch.changes = changes;
|
|
@@ -653,6 +667,38 @@ async function allModesOff() {
|
|
|
653
667
|
if (!any) out.log('No modes were on.');
|
|
654
668
|
}
|
|
655
669
|
|
|
670
|
+
// Commands that take only a selector take the whole line, spaces and all.
|
|
671
|
+
function soleSelector(args, usage) {
|
|
672
|
+
const text = unquote((args || '').trim());
|
|
673
|
+
if (!text) throw new Error(`Usage: ${usage}`);
|
|
674
|
+
return toSelector(text);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function selectorAndValue(args, usage, example) {
|
|
678
|
+
const parsed = splitSelector(args);
|
|
679
|
+
if (!parsed || parsed.rest === undefined) {
|
|
680
|
+
throw new Error(`Usage: ${usage}, e.g. ${example}; quote a selector with spaces: "text=Your name"`);
|
|
681
|
+
}
|
|
682
|
+
return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
|
|
683
|
+
}
|
|
684
|
+
|
|
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.
|
|
688
|
+
async function onElement(selector, action) {
|
|
689
|
+
try {
|
|
690
|
+
return await action();
|
|
691
|
+
} catch (error) {
|
|
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;
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
656
702
|
const WAIT_DEFAULT = 10;
|
|
657
703
|
const WAIT_MAX = 120;
|
|
658
704
|
|
|
@@ -716,13 +762,17 @@ function clock(t) {
|
|
|
716
762
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
|
|
717
763
|
}
|
|
718
764
|
|
|
765
|
+
// One line per event, from the start of the capture: a request as requests
|
|
766
|
+
// shows it (its status is looked up now, so it is known if it has finished),
|
|
767
|
+
// a console message or page error as console shows it.
|
|
719
768
|
function printCapture(capture, all = false) {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
tag
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
769
|
+
const lines = capture.events.map(event => {
|
|
770
|
+
const at = `+${((event.t - capture.startedAt) / 1000).toFixed(3)}s`;
|
|
771
|
+
if (event.tag !== 'request') return `${at} [${event.tag}] ${event.text}`;
|
|
772
|
+
const entry = requestEntries.get(event.req);
|
|
773
|
+
return entry ? `${at} #${entry.id} ${entry.method} ${entry.status} ${entry.url}` : `${at} ${event.text}`;
|
|
774
|
+
});
|
|
775
|
+
if (lines.length) printOutput(lines.join('\n'), all);
|
|
726
776
|
else out.log('Nothing captured');
|
|
727
777
|
const notes = [];
|
|
728
778
|
if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
|
|
@@ -736,6 +786,56 @@ function discardCapture() {
|
|
|
736
786
|
cap = null;
|
|
737
787
|
}
|
|
738
788
|
|
|
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
|
+
|
|
794
|
+
async function openTab() {
|
|
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
|
+
openedTabs.add(opened);
|
|
809
|
+
return opened;
|
|
810
|
+
}
|
|
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
|
+
|
|
739
839
|
// Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
|
|
740
840
|
async function listTabs() {
|
|
741
841
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
@@ -788,18 +888,19 @@ const commands = {
|
|
|
788
888
|
if (/^\d+$/.test(subcommand)) {
|
|
789
889
|
if (parts.length) throw new Error(usage);
|
|
790
890
|
const target = state.tabListing[Number(subcommand)];
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
891
|
+
if (target && all.includes(target)) { select(target); return commands.info(); }
|
|
892
|
+
// A number that is not a tab in the listing may be part of a URL (a port).
|
|
893
|
+
if (!all.some(p => p.url().includes(subcommand))) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
|
|
794
894
|
}
|
|
795
895
|
if (subcommand === 'new') {
|
|
796
|
-
|
|
797
|
-
const opened = await ctx.newPage();
|
|
798
|
-
openedTabs.add(opened);
|
|
799
|
-
select(opened);
|
|
896
|
+
select(await openTab());
|
|
800
897
|
const url = parts.join(' ');
|
|
801
|
-
if (url)
|
|
802
|
-
|
|
898
|
+
if (url) {
|
|
899
|
+
try { await commands.goto(url); }
|
|
900
|
+
catch (error) { throw new Error(`${error.message}\nThe new tab stays open and selected; tab close closes it.`); }
|
|
901
|
+
} else {
|
|
902
|
+
out.log('New tab created and selected');
|
|
903
|
+
}
|
|
803
904
|
return listTabs();
|
|
804
905
|
}
|
|
805
906
|
if (subcommand === 'close') {
|
|
@@ -852,104 +953,95 @@ const commands = {
|
|
|
852
953
|
},
|
|
853
954
|
|
|
854
955
|
async click(args) {
|
|
855
|
-
|
|
856
|
-
await state.page.click(
|
|
857
|
-
out.log(`Clicked: ${args}`);
|
|
956
|
+
const selector = soleSelector(args, 'click <selector>');
|
|
957
|
+
await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
|
|
958
|
+
out.log(`Clicked: ${args.trim()}`);
|
|
858
959
|
},
|
|
859
960
|
|
|
860
961
|
async dblclick(args) {
|
|
861
|
-
|
|
862
|
-
await state.page.dblclick(
|
|
863
|
-
out.log(`Double-clicked: ${args}`);
|
|
962
|
+
const selector = soleSelector(args, 'dblclick <selector>');
|
|
963
|
+
await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
|
|
964
|
+
out.log(`Double-clicked: ${args.trim()}`);
|
|
864
965
|
},
|
|
865
966
|
|
|
866
967
|
async hover(args) {
|
|
867
|
-
|
|
868
|
-
await state.page.hover(
|
|
869
|
-
out.log(`Hovered: ${args}`);
|
|
968
|
+
const selector = soleSelector(args, 'hover <selector>');
|
|
969
|
+
await onElement(selector, () => state.page.hover(selector, { timeout: 5000 }));
|
|
970
|
+
out.log(`Hovered: ${args.trim()}`);
|
|
870
971
|
},
|
|
871
972
|
|
|
872
973
|
async fill(args) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
}
|
|
876
|
-
const [selector, ...rest] = args.split('=>');
|
|
877
|
-
await state.page.fill(selector.trim(), rest.join('=>').trim(), { timeout: 5000 });
|
|
878
|
-
out.log(`Filled: ${selector.trim()}`);
|
|
974
|
+
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value>', 'fill #name Ada Lovelace');
|
|
975
|
+
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
976
|
+
out.log(`Filled: ${shown}`);
|
|
879
977
|
},
|
|
880
978
|
|
|
881
979
|
async type(args) {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
}
|
|
885
|
-
const [selector, ...rest] = args.split('=>');
|
|
886
|
-
await state.page.type(selector.trim(), rest.join('=>').trim(), { timeout: 5000 });
|
|
887
|
-
out.log(`Typed into: ${selector.trim()}`);
|
|
980
|
+
const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
|
|
981
|
+
await onElement(selector, () => state.page.type(selector, value, { timeout: 5000 }));
|
|
982
|
+
out.log(`Typed into: ${shown}`);
|
|
888
983
|
},
|
|
889
984
|
|
|
890
985
|
async press(args) {
|
|
891
|
-
|
|
892
|
-
if (
|
|
893
|
-
|
|
894
|
-
await state.page.press(
|
|
895
|
-
out.log(`Pressed ${
|
|
896
|
-
|
|
897
|
-
await state.page.keyboard.press(args.trim());
|
|
898
|
-
out.log(`Pressed: ${args.trim()}`);
|
|
986
|
+
const parsed = splitSelector(args);
|
|
987
|
+
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
988
|
+
if (parsed.rest === undefined) {
|
|
989
|
+
await state.page.keyboard.press(parsed.word);
|
|
990
|
+
out.log(`Pressed: ${parsed.word}`);
|
|
991
|
+
return;
|
|
899
992
|
}
|
|
993
|
+
const key = unquote(parsed.rest);
|
|
994
|
+
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
995
|
+
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
900
996
|
},
|
|
901
997
|
|
|
902
|
-
|
|
903
998
|
async select(args) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
}
|
|
907
|
-
const [selector, value] = args.split('=>').map(s => s.trim());
|
|
908
|
-
await state.page.selectOption(selector, value, { timeout: 5000 });
|
|
909
|
-
out.log(`Selected "${value}" in ${selector}`);
|
|
999
|
+
const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
|
|
1000
|
+
await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
|
|
1001
|
+
out.log(`Selected "${value}" in ${shown}`);
|
|
910
1002
|
},
|
|
911
1003
|
|
|
912
1004
|
async check(args) {
|
|
913
|
-
|
|
914
|
-
await state.page.check(
|
|
915
|
-
out.log(`Checked: ${args}`);
|
|
1005
|
+
const selector = soleSelector(args, 'check <selector>');
|
|
1006
|
+
await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
|
|
1007
|
+
out.log(`Checked: ${args.trim()}`);
|
|
916
1008
|
},
|
|
917
1009
|
|
|
918
1010
|
async uncheck(args) {
|
|
919
|
-
|
|
920
|
-
await state.page.uncheck(
|
|
921
|
-
out.log(`Unchecked: ${args}`);
|
|
1011
|
+
const selector = soleSelector(args, 'uncheck <selector>');
|
|
1012
|
+
await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
|
|
1013
|
+
out.log(`Unchecked: ${args.trim()}`);
|
|
922
1014
|
},
|
|
923
1015
|
|
|
924
1016
|
async text(args, all) {
|
|
925
|
-
|
|
926
|
-
printOutput(await state.page.innerText(
|
|
1017
|
+
const selector = soleSelector(args, 'text <selector>');
|
|
1018
|
+
printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
|
|
927
1019
|
},
|
|
928
1020
|
|
|
929
1021
|
async html(args, all) {
|
|
930
|
-
|
|
931
|
-
printOutput(await state.page.$eval(
|
|
1022
|
+
const selector = soleSelector(args, 'html <selector>');
|
|
1023
|
+
printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
|
|
932
1024
|
},
|
|
933
1025
|
|
|
934
1026
|
async attrs(args, all) {
|
|
935
|
-
|
|
936
|
-
const result = await state.page.$eval(
|
|
1027
|
+
const selector = soleSelector(args, 'attrs <selector>');
|
|
1028
|
+
const result = await onElement(selector, () => state.page.$eval(selector, el => {
|
|
937
1029
|
const out = {};
|
|
938
1030
|
for (const attr of el.attributes) out[attr.name] = attr.value;
|
|
939
1031
|
return out;
|
|
940
|
-
});
|
|
1032
|
+
}));
|
|
941
1033
|
printOutput(result, all);
|
|
942
1034
|
},
|
|
943
1035
|
|
|
944
1036
|
async count(args) {
|
|
945
|
-
|
|
946
|
-
const els = await state.page.$$(
|
|
1037
|
+
const selector = soleSelector(args, 'count <selector>');
|
|
1038
|
+
const els = await state.page.$$(selector);
|
|
947
1039
|
out.log(`${els.length} element(s)`);
|
|
948
1040
|
},
|
|
949
1041
|
|
|
950
1042
|
async visible(args) {
|
|
951
|
-
|
|
952
|
-
const el = await state.page.$(
|
|
1043
|
+
const selector = soleSelector(args, 'visible <selector>');
|
|
1044
|
+
const el = await state.page.$(selector);
|
|
953
1045
|
if (!el) { out.log('Not found'); return; }
|
|
954
1046
|
out.log(await el.isVisible() ? 'visible' : 'hidden');
|
|
955
1047
|
},
|
|
@@ -1008,6 +1100,9 @@ const commands = {
|
|
|
1008
1100
|
}
|
|
1009
1101
|
// Named after the countdown so a default filename timestamps the capture.
|
|
1010
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();
|
|
1011
1106
|
const image = await state.page.screenshot({ fullPage: full });
|
|
1012
1107
|
try {
|
|
1013
1108
|
fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
|
|
@@ -1029,7 +1124,7 @@ const commands = {
|
|
|
1029
1124
|
rest = '';
|
|
1030
1125
|
}
|
|
1031
1126
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
1032
|
-
const selector =
|
|
1127
|
+
const selector = toSelector(rest);
|
|
1033
1128
|
const target = selector ? state.page.locator(selector).first() : state.page;
|
|
1034
1129
|
const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1035
1130
|
const text = full ? raw : compactSnapshot(raw);
|
|
@@ -1072,9 +1167,9 @@ const commands = {
|
|
|
1072
1167
|
}
|
|
1073
1168
|
return waitForRequest(what, timeout);
|
|
1074
1169
|
}
|
|
1075
|
-
const selector = tokens.join(' ');
|
|
1170
|
+
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1076
1171
|
await state.page.waitForSelector(selector, { state: 'attached', timeout });
|
|
1077
|
-
out.log(`Found: ${
|
|
1172
|
+
out.log(`Found: ${tokens.join(' ')}`);
|
|
1078
1173
|
},
|
|
1079
1174
|
|
|
1080
1175
|
async sleep(args) {
|
|
@@ -1158,6 +1253,12 @@ const commands = {
|
|
|
1158
1253
|
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
1159
1254
|
out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.url}`);
|
|
1160
1255
|
}
|
|
1256
|
+
// The numbers skip what is hidden; say so, or they look like requests went missing.
|
|
1257
|
+
const first = matches[0].id;
|
|
1258
|
+
const last = matches[matches.length - 1].id;
|
|
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;
|
|
1261
|
+
if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
|
|
1161
1262
|
},
|
|
1162
1263
|
|
|
1163
1264
|
async body(args, all) {
|
|
@@ -1177,7 +1278,8 @@ const commands = {
|
|
|
1177
1278
|
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
1178
1279
|
let buffer;
|
|
1179
1280
|
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
1180
|
-
|
|
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)`); }
|
|
1181
1283
|
finally { clearTimeout(timer); }
|
|
1182
1284
|
const type = entry.response.headers()['content-type'] || '';
|
|
1183
1285
|
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
|
@@ -1367,4 +1469,4 @@ function complete(line) {
|
|
|
1367
1469
|
return [[], current];
|
|
1368
1470
|
}
|
|
1369
1471
|
|
|
1370
|
-
module.exports = { commands, listTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
|
1472
|
+
module.exports = { commands, listTabs, openTab, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
package/lib/help.js
CHANGED
|
@@ -31,11 +31,11 @@ help <command> for usage and caveats (e.g. help route); help --all for everythin
|
|
|
31
31
|
|
|
32
32
|
const TOPICS = {
|
|
33
33
|
tabs: {
|
|
34
|
-
intro: '
|
|
34
|
+
intro: 'No tab is selected when the REPL starts: tab new opens one, tab <index|url-part> selects one.\ntab on its own lists them all.',
|
|
35
35
|
commands: ['tab', 'goto', 'back', 'forward', 'reload', 'info'],
|
|
36
36
|
},
|
|
37
37
|
interact: {
|
|
38
|
-
intro: 'Selectors are Playwright selectors (CSS, text=..., role=...)
|
|
38
|
+
intro: 'Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.\nCommands use the first match; see help fill for selectors with spaces.',
|
|
39
39
|
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck'],
|
|
40
40
|
},
|
|
41
41
|
inspect: {
|
|
@@ -53,7 +53,8 @@ const TOPICS = {
|
|
|
53
53
|
session: {
|
|
54
54
|
intro: `Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.
|
|
55
55
|
|
|
56
|
-
Unknown outcome: a timed
|
|
56
|
+
Unknown outcome: a command that timed out after it began acting on the page may or may not have done
|
|
57
|
+
it. It says so (pw-repl send exits 2), and the REPL carries on.
|
|
57
58
|
|
|
58
59
|
Server: pw-repl serve also takes commands on /tmp/playwright-repl.sock; they show here as [server] lines.
|
|
59
60
|
|
|
@@ -71,7 +72,7 @@ const COMMANDS = {
|
|
|
71
72
|
tab: {
|
|
72
73
|
usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
|
|
73
74
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
74
|
-
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.',
|
|
75
76
|
},
|
|
76
77
|
goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
|
|
77
78
|
back: { usage: 'back', summary: 'go back one history entry' },
|
|
@@ -81,21 +82,25 @@ const COMMANDS = {
|
|
|
81
82
|
click: { usage: 'click <selector>', summary: 'click the first match' },
|
|
82
83
|
dblclick: { usage: 'dblclick <selector>', summary: 'double-click the first match' },
|
|
83
84
|
hover: { usage: 'hover <selector>', summary: 'hover the first match' },
|
|
84
|
-
fill: {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
fill: {
|
|
86
|
+
usage: 'fill <selector> <value>',
|
|
87
|
+
summary: 'clear an input and fill it',
|
|
88
|
+
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
|
+
},
|
|
90
|
+
type: { usage: 'type <selector> <text>', summary: 'type into an input key by key; the text is as for fill' },
|
|
91
|
+
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A), optionally on an element' },
|
|
92
|
+
select: { usage: 'select <selector> <value>', summary: 'choose an option in a select (the value is the rest of the line)' },
|
|
88
93
|
check: { usage: 'check <selector>', summary: 'check a checkbox' },
|
|
89
94
|
uncheck: { usage: 'uncheck <selector>', summary: 'uncheck a checkbox' },
|
|
90
95
|
snapshot: {
|
|
91
96
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
92
97
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
93
|
-
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path.\n\nsnapshot e3 outlines one element. A
|
|
98
|
+
detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
|
|
94
99
|
},
|
|
95
100
|
watch: {
|
|
96
101
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
97
102
|
summary: 'record what happens in the tab; show the last n steps',
|
|
98
|
-
detail: 'Off until watch on. Records clicks, typing (once
|
|
103
|
+
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath (their numbers in requests; body\n<#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded as type.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
99
104
|
},
|
|
100
105
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
101
106
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
@@ -107,7 +112,7 @@ const COMMANDS = {
|
|
|
107
112
|
screenshot: {
|
|
108
113
|
usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
|
|
109
114
|
summary: 'save a PNG of the viewport (or --full page)',
|
|
110
|
-
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.',
|
|
111
116
|
},
|
|
112
117
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
113
118
|
wait: {
|
|
@@ -134,17 +139,17 @@ const COMMANDS = {
|
|
|
134
139
|
capture: {
|
|
135
140
|
usage: 'capture on [requests|console] [secs] | off',
|
|
136
141
|
summary: 'record requests and console together, in time order',
|
|
137
|
-
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.',
|
|
138
143
|
},
|
|
139
144
|
route: {
|
|
140
145
|
usage: 'route <glob> <status> <json> | off <glob>|--all',
|
|
141
146
|
summary: 'answer the selected tab\'s matching requests with fake JSON',
|
|
142
|
-
detail: 'The fake is fulfilled inside the browser, so the page handles it as a real response and the request\nnever reaches the network. Each one prints "Faked: #<n
|
|
147
|
+
detail: 'The fake is fulfilled inside the browser, so the page handles it as a real response and the request\nnever reaches the network; it still answers while the network is off. Each one prints "Faked: #<n>\n<METHOD> <url> -> <status>" in the REPL window (and in the answer to a command running then), with\n#<n> as in requests. If fulfilling fails it prints "Fake failed" and aborts the request, so it never\nreaches the network.\n\nStatus: 200-599. wait request <glob> after the page loads prints the request with its status, so a\nfake shows as <status> faked.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes.\n\nExample: route **/api/health_check 503 {"detail":{"code":"service_unavailable"}}',
|
|
143
148
|
},
|
|
144
149
|
network: {
|
|
145
150
|
usage: 'network [on|off]',
|
|
146
151
|
summary: 'cut or restore the tab\'s network, like dropped wifi',
|
|
147
|
-
detail: 'Stopping a service is not the same: a dev proxy in front of it usually holds the request open, so\nthe page spins instead of failing.\n\nPer tab; lasts until network on or the REPL exits.',
|
|
152
|
+
detail: 'Stopping a service is not the same: a dev proxy in front of it usually holds the request open, so\nthe page spins instead of failing.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off.',
|
|
148
153
|
},
|
|
149
154
|
eval: {
|
|
150
155
|
usage: 'eval [--all] <JavaScript>',
|
|
@@ -161,7 +166,7 @@ const COMMANDS = {
|
|
|
161
166
|
modes: {
|
|
162
167
|
usage: 'modes [off]',
|
|
163
168
|
summary: 'the modes on in every tab; modes off turns them all off',
|
|
164
|
-
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.',
|
|
165
170
|
},
|
|
166
171
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
167
172
|
quit: {
|
package/lib/runner.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Runs commands one at a time, whether they come from the prompt or the server.
|
|
2
2
|
// A prompt command tagged @<id> reports completion with a marker the caller can wait for.
|
|
3
3
|
const readline = require('readline');
|
|
4
|
-
const { state,
|
|
4
|
+
const { state, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
6
|
const { commands, activeModes } = require('./commands');
|
|
7
7
|
|
|
8
|
-
// A timeout here cannot have changed anything, so it is
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// A timeout here cannot have changed anything, so it is an ordinary error. Any
|
|
9
|
+
// other command that times out may or may not have done what it was sent to
|
|
10
|
+
// do; that is reported, and the REPL carries on.
|
|
11
11
|
const READ_ONLY = new Set(['info', 'text', 'html', 'attrs', 'count', 'visible', 'links', 'inputs',
|
|
12
12
|
'snapshot', 'screenshot', 'wait', 'sleep', 'requests', 'body', 'console', 'cookies', 'storage', 'capture', 'help']);
|
|
13
13
|
|
|
@@ -28,7 +28,10 @@ function parseInput(line) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function isUncertain(error) {
|
|
31
|
-
|
|
31
|
+
const message = error.message || '';
|
|
32
|
+
if (!/timed out|timeout|connection (?:lost|closed)/i.test(message)) return false;
|
|
33
|
+
// Playwright's call log shows whether the element was ever found; if not, nothing was done.
|
|
34
|
+
return !(/waiting for locator/.test(message) && !/resolved to/.test(message));
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
let running = 0;
|
|
@@ -69,20 +72,17 @@ async function executeCommand(text) {
|
|
|
69
72
|
try {
|
|
70
73
|
await commands[cmd](args, all);
|
|
71
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, '');
|
|
72
77
|
out.error(`Error: ${e.message}`);
|
|
73
|
-
|
|
78
|
+
const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
|
|
79
|
+
if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
|
|
80
|
+
return { status: 'error', cmd, uncertain };
|
|
74
81
|
}
|
|
75
82
|
if (cmd === 'quit' && state.shutdownFailed) return { status: 'error', cmd };
|
|
76
83
|
return { status: 'ok', cmd };
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
// A timed-out browser command may still be running, so nothing after it can
|
|
80
|
-
// be trusted to act on the page the caller thinks it is acting on.
|
|
81
|
-
function giveUp() {
|
|
82
|
-
out.error('Browser command outcome is unknown; disconnecting instead of continuing.');
|
|
83
|
-
void shutdown().then(beforeExit).then(() => process.exit(process.exitCode || 1));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
86
|
// A quit does not wait for the running server command, which may never finish
|
|
87
87
|
// once the browser is gone, so its sender is answered now. A read-only command
|
|
88
88
|
// changed nothing; any other may or may not have done what it was sent to do.
|
|
@@ -106,12 +106,12 @@ async function handleLine(line) {
|
|
|
106
106
|
const result = await execute(input.text);
|
|
107
107
|
done(result.status);
|
|
108
108
|
if (result.cmd === 'quit') { await beforeExit(); process.exit(process.exitCode || 0); }
|
|
109
|
-
if (result.uncertain) return giveUp();
|
|
110
109
|
if (!state.stopping) prompt();
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
// The prompt starts with the modes on in the selected tab: (watch network:off) pw>.
|
|
114
113
|
function prompt(preserveCursor) {
|
|
114
|
+
if (!state.rl) return;
|
|
115
115
|
const modes = state.page && !state.page.isClosed() ? activeModes(state.page) : [];
|
|
116
116
|
state.rl.setPrompt(`${modes.length ? `(${modes.join(' ')}) ` : ''}${state.promptBase}`);
|
|
117
117
|
state.rl.prompt(preserveCursor);
|
|
@@ -136,7 +136,7 @@ function submit(text) {
|
|
|
136
136
|
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
137
137
|
out.log(`[server] ${text}`);
|
|
138
138
|
// So the person at the prompt can bring an agent's command back with up-arrow.
|
|
139
|
-
if (state.rl
|
|
139
|
+
if (state.rl?.history && state.rl.history[0] !== text) {
|
|
140
140
|
state.rl.history.unshift(text);
|
|
141
141
|
state.rl.history.length = Math.min(state.rl.history.length, 1000);
|
|
142
142
|
}
|
|
@@ -160,4 +160,4 @@ function drained() {
|
|
|
160
160
|
return queue;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
module.exports = { enqueue, submit,
|
|
163
|
+
module.exports = { enqueue, submit, answerInterrupted, drained, busy };
|
package/lib/send.js
CHANGED
|
@@ -41,7 +41,7 @@ function fail(message) {
|
|
|
41
41
|
// is unknown; ..."), so only a bare prompt counts. That also refuses while a
|
|
42
42
|
// command runs or someone is typing.
|
|
43
43
|
function paneProblem(session) {
|
|
44
|
-
if (!hasSession(session)) return `no tmux session '${session}';
|
|
44
|
+
if (!hasSession(session)) return `no tmux session '${session}' and no REPL server; pw-repl serve --background starts one (pw-repl skill: Start it)`;
|
|
45
45
|
const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
|
|
46
46
|
if (running !== 'node') return `the REPL is not running in '${session}' (pane is running '${running}'); start it there with pw-repl run (pw-repl skill: Start it)`;
|
|
47
47
|
const lines = tmux('capture-pane', '-t', session, '-p').split('\n').filter(line => line.trim());
|
|
@@ -101,7 +101,7 @@ async function send(options) {
|
|
|
101
101
|
}
|
|
102
102
|
if (answer.timeout) { console.error('completion not confirmed'); return 2; }
|
|
103
103
|
if (answer.dropped) { console.error(`completion not confirmed: the REPL closed the connection (${answer.dropped})`); return 2; }
|
|
104
|
-
if (way.explicit) return fail(`cannot reach the REPL server at ${client.describe(way.endpoint)} (${answer.unreachable})`);
|
|
104
|
+
if (way.explicit) return fail(answer.unreachable === 'ENOENT' ? `no REPL is serving on ${client.describe(way.endpoint)} (there is no socket)` : `cannot reach the REPL server at ${client.describe(way.endpoint)} (${answer.unreachable})`);
|
|
105
105
|
// The server was expected, so the REPL has most likely just exited or is
|
|
106
106
|
// exiting. Falling back to tmux now could type into the shell it leaves behind.
|
|
107
107
|
return fail(`the REPL server at ${way.socket} is not answering (${answer.unreachable}); not falling back to tmux. Check with pw-repl where; if the REPL now runs without the server, use -s or remove ${way.socket}`);
|
|
@@ -112,15 +112,25 @@ async function where(options) {
|
|
|
112
112
|
const way = route(options);
|
|
113
113
|
if (way.kind === 'server') {
|
|
114
114
|
const name = client.describe(way.endpoint);
|
|
115
|
-
if (await client.health(way.endpoint, 5000)) {
|
|
116
|
-
|
|
115
|
+
if (await client.health(way.endpoint, 5000)) {
|
|
116
|
+
const background = require('./background').describeBackground(way.endpoint);
|
|
117
|
+
console.log(`server: ${name} (the REPL was started with pw-repl serve${background ? ' --background' : ''})`);
|
|
118
|
+
if (background) console.log(background);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
console.error(way.endpoint.socket && !fs.existsSync(way.endpoint.socket) ? `server: no REPL is serving on ${name} (there is no socket)` : `server: ${name} is not answering`);
|
|
117
122
|
if (way.explicit) return 64;
|
|
118
123
|
}
|
|
119
124
|
const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
|
|
120
|
-
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
|
+
}
|
|
121
130
|
const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
|
|
122
131
|
if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
|
|
123
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.');
|
|
124
134
|
return 64;
|
|
125
135
|
}
|
|
126
136
|
|
package/lib/server.js
CHANGED
|
@@ -47,9 +47,7 @@ function handle(req, res) {
|
|
|
47
47
|
const result = await runner.submit(command.trim());
|
|
48
48
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
49
49
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
50
|
-
|
|
51
|
-
send(res, 200, { status: result.status, output: `${result.output}\nBrowser command outcome is unknown; the REPL is disconnecting.`, unconfirmed: true });
|
|
52
|
-
runner.giveUp();
|
|
50
|
+
send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
|
|
53
51
|
});
|
|
54
52
|
}
|
|
55
53
|
|
package/lib/start.js
CHANGED
|
@@ -3,7 +3,7 @@ const { chromium } = require('playwright-core');
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { state, withTimeout, shutdown, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const { listTabs, watchPage, complete } = require('./commands');
|
|
6
|
+
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';
|
|
@@ -30,7 +30,8 @@ async function start(options) {
|
|
|
30
30
|
const contexts = state.browser.contexts();
|
|
31
31
|
const pages = contexts.flatMap(c => c.pages());
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Nothing is selected to begin with: the first tab may be someone else's.
|
|
34
|
+
state.page = null;
|
|
34
35
|
state.tabListing = pages.slice();
|
|
35
36
|
|
|
36
37
|
// Hooks first, so the start URL's own requests, logs and dialogs are recorded.
|
|
@@ -40,22 +41,27 @@ async function start(options) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
if (START_URL) {
|
|
43
|
-
|
|
44
|
-
state.page = await contexts[0].newPage();
|
|
45
|
-
}
|
|
44
|
+
state.page = await openTab();
|
|
46
45
|
await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
|
47
|
-
out.log(`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!state.page) {
|
|
51
|
-
state.page = await (contexts[0] || await state.browser.newContext()).newPage();
|
|
52
|
-
out.log('Created new page');
|
|
46
|
+
out.log(`Opened ${state.page.url()} in a new tab`);
|
|
53
47
|
}
|
|
54
48
|
|
|
55
49
|
out.log('');
|
|
56
50
|
await listTabs();
|
|
57
51
|
out.log('');
|
|
52
|
+
if (!state.page) out.log('No tab is selected: tab new [url] opens one of your own, tab <index|url-part> selects one.');
|
|
58
53
|
if (options.serve) await require('./server').serve(options.endpoint);
|
|
54
|
+
// In the background there is no prompt: commands come only through the server.
|
|
55
|
+
if (options.pidFile) {
|
|
56
|
+
require('./background').claimPidFile(options.pidFile);
|
|
57
|
+
out.onIdlePrint({ idle: () => !runner.busy() && !state.stopping, print: text => console.log(text) });
|
|
58
|
+
const e = require('./background').endpointFlag(options.endpoint);
|
|
59
|
+
out.log(`Running in the background: pw-repl attach${e} to use it, pw-repl stop${e} to stop it.`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!options.serve && !process.env.TMUX) {
|
|
63
|
+
out.log('Tip: run it in a tmux session named playwright-repl so pw-repl send can reach it, or use pw-repl serve.');
|
|
64
|
+
}
|
|
59
65
|
out.log("Run 'help' to see the commands. They act on the selected tab (*).");
|
|
60
66
|
out.log('');
|
|
61
67
|
|
|
@@ -89,19 +95,22 @@ async function start(options) {
|
|
|
89
95
|
}
|
|
90
96
|
runner.enqueue(line);
|
|
91
97
|
});
|
|
98
|
+
// Input ends with Ctrl-D, or at once when nothing is attached to it (e.g.
|
|
99
|
+
// started in the background), so it says why it stops.
|
|
92
100
|
rl.on('close', () => {
|
|
93
101
|
if (state.stopping) return;
|
|
94
102
|
endPromptLine();
|
|
103
|
+
out.error('Input ended; disconnecting.');
|
|
95
104
|
runner.drained().then(() => shutdown(), () => shutdown())
|
|
96
105
|
.then(() => process.exit(process.exitCode || 0));
|
|
97
106
|
});
|
|
98
107
|
rl.on('SIGINT', stop);
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
// Ctrl-C and Ctrl-D leave the cursor after the prompt;
|
|
102
|
-
//
|
|
110
|
+
// Ctrl-C and Ctrl-D leave the cursor after the prompt; what follows belongs
|
|
111
|
+
// on a line of its own.
|
|
103
112
|
function endPromptLine() {
|
|
104
|
-
|
|
113
|
+
process.stdout.write('\n');
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
// Like quit: a server command still running is answered before the REPL exits.
|
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,28 +11,42 @@ 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
|
|
23
|
+
one; each takes an optional start URL, which opens in a new tab.
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
- `pw-repl serve --background` runs it detached, with a command server on `/tmp/playwright-repl.sock`
|
|
26
|
+
(owner-only) and its output in `/tmp/playwright-repl.log`. `pw-repl attach` shows everything it does
|
|
27
|
+
and takes commands; `pw-repl stop` stops it.
|
|
28
|
+
- `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
|
|
29
|
+
`pw[serve]>`.
|
|
30
|
+
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
31
|
+
the tmux session `playwright-repl`:
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
```bash
|
|
34
|
+
tmux send-keys -t playwright-repl -l 'pw-repl run'
|
|
35
|
+
tmux send-keys -t playwright-repl Enter
|
|
36
|
+
```
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
29
48
|
|
|
30
|
-
|
|
31
|
-
prompt is `pw[serve]>` instead of `pw>`. It is opt-in: use it when the user asks for it or agrees.
|
|
32
|
-
Prefer the socket: `pw-repl serve <port>` (TCP on 127.0.0.1) has no authentication, so any local
|
|
33
|
-
process could drive the browser; use it only for a client that cannot reach the socket. Both take an
|
|
34
|
-
optional start URL, which navigates tab [0]. Stop either at the pane (`quit`, or Ctrl-C); `send quit` is
|
|
35
|
-
refused so an agent cannot close a REPL the user is using.
|
|
49
|
+
A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
|
|
36
50
|
|
|
37
51
|
## Send commands
|
|
38
52
|
|
|
@@ -41,29 +55,31 @@ pw-repl send tab
|
|
|
41
55
|
pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
|
|
42
56
|
```
|
|
43
57
|
|
|
44
|
-
Always send commands with `pw-repl send`; don't type into the pane yourself.
|
|
45
|
-
command.
|
|
58
|
+
Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
|
|
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:
|
|
46
62
|
|
|
47
|
-
- `run
|
|
48
|
-
types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|
|
49
|
-
of what the user is typing; while a command is running, the user is typing, or the REPL is exiting,
|
|
50
|
-
|
|
51
|
-
- `serve
|
|
52
|
-
reliable results: nothing is scraped, long output isn't cut off by
|
|
53
|
-
never land in a shell.
|
|
54
|
-
restart the REPL with `pw-repl serve`.
|
|
63
|
+
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
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 the user is typing; while a command is running, the user is typing, or the REPL is exiting, it
|
|
66
|
+
refuses (exit 64). Wait and retry.
|
|
67
|
+
- `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
|
|
68
|
+
as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
|
|
69
|
+
scrollback, and a command can never land in a shell.
|
|
55
70
|
|
|
56
71
|
`pw-repl where` says which one a command would reach (the server, or the tmux pane running the
|
|
57
72
|
REPL), or why neither is reachable, without running anything.
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
so the user sees what you do. The pane shows REPL
|
|
61
|
-
browser (unless `watch on --live` is on); for that, look
|
|
62
|
-
where they are, `requests` for the requests their clicks
|
|
63
|
-
`console` for console messages and page errors. When the user
|
|
64
|
-
on their tab records each step with the requests it caused
|
|
65
|
-
changed on the page); `watch` reads it back, and `watch new`
|
|
66
|
-
and reading are fine on the user's tabs; the rule below is
|
|
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 the user sees what you do. The pane shows REPL
|
|
76
|
+
commands only, not what the user clicked in the browser (unless `watch on --live` is on); for that, look
|
|
77
|
+
at the browser itself: `tab` and `info` for where they are, `requests` for the requests their clicks
|
|
78
|
+
made (`body <#>` for what one returned), `console` for console messages and page errors. When the user
|
|
79
|
+
wants to show you what they do, `watch on` on their tab records each step with the requests it caused
|
|
80
|
+
(`watch on --changes` adds what each step changed on the page); `watch` reads it back, and `watch new`
|
|
81
|
+
only what it has not shown yet. Watching and reading are fine on the user's tabs; the rule below is
|
|
82
|
+
about acting on them.
|
|
67
83
|
|
|
68
84
|
Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
|
|
69
85
|
blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
|
|
@@ -71,14 +87,14 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
71
87
|
## Learn the commands
|
|
72
88
|
|
|
73
89
|
Run `pw-repl send help`. It lists six topics; `help <topic>` lists their commands, `help <command>`
|
|
74
|
-
gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
|
|
75
|
-
the command reference; this file does not repeat it.
|
|
90
|
+
gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
|
|
91
|
+
help is the command reference; this file does not repeat it.
|
|
76
92
|
|
|
77
93
|
## Shared-browser rules
|
|
78
94
|
|
|
79
95
|
- Act only on tabs you opened (`tab new`), unless the user asks you to act on theirs (e.g. a `route` in
|
|
80
|
-
their tab while they test); then say what you are doing and undo it the moment you are done.
|
|
81
|
-
|
|
96
|
+
their tab while they test); then say what you are doing and undo it the moment you are done. No tab
|
|
97
|
+
is selected when the REPL starts (unless it was given a start URL); `tab` lists them. Closing your
|
|
82
98
|
tab goes back only to a tab you opened; otherwise no tab is selected. Tab numbers change when tabs
|
|
83
99
|
open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by its URL and refuse if it is
|
|
84
100
|
ambiguous.
|