pw-repl 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -0
- package/README.md +16 -3
- package/bin/pw-repl.js +32 -8
- package/lib/background.js +187 -0
- package/lib/commands.js +145 -75
- package/lib/help.js +16 -11
- package/lib/runner.js +14 -16
- package/lib/send.js +9 -4
- package/lib/server.js +1 -3
- package/lib/start.js +23 -14
- package/package.json +1 -1
- package/skill/SKILL.md +38 -39
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,17 @@ function parseSendArgs(args, allowCommand) {
|
|
|
58
67
|
}
|
|
59
68
|
const words = args.slice(i);
|
|
60
69
|
if (!allowCommand && words.length) usage();
|
|
61
|
-
|
|
70
|
+
// A word the shell kept whole (fill "text=Your name" Ada) is quoted again, so
|
|
71
|
+
// it stays one word; a command given as a single word is sent as it is.
|
|
72
|
+
const quoted = words.length > 1 ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
|
|
73
|
+
options.command = quoted.join(' ').trim();
|
|
62
74
|
return options;
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
function startOptions(args, serve) {
|
|
66
|
-
const options = { serve, endpoint: null, startUrl: null };
|
|
78
|
+
const options = { serve, endpoint: null, startUrl: null, background: false, pidFile: process.env.PW_REPL_PID_FILE || null };
|
|
67
79
|
const rest = [...args];
|
|
80
|
+
if (serve && rest.includes('--background')) { options.background = true; rest.splice(rest.indexOf('--background'), 1); }
|
|
68
81
|
if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
|
|
69
82
|
if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
|
|
70
83
|
options.startUrl = rest[0] || null;
|
|
@@ -93,8 +106,19 @@ async function main() {
|
|
|
93
106
|
if (subcommand === undefined) return console.log(`${USAGE}\n\n${REPL_HELP}`);
|
|
94
107
|
switch (subcommand) {
|
|
95
108
|
case 'run':
|
|
96
|
-
case 'serve':
|
|
97
|
-
|
|
109
|
+
case 'serve': {
|
|
110
|
+
const options = startOptions(args, subcommand === 'serve');
|
|
111
|
+
if (options.background) process.exit(await require('../lib/background').start(options));
|
|
112
|
+
return require('../lib/start').start(options);
|
|
113
|
+
}
|
|
114
|
+
case 'attach':
|
|
115
|
+
case 'stop': {
|
|
116
|
+
const options = parseSendArgs(args, false);
|
|
117
|
+
if (options.session) usage();
|
|
118
|
+
const endpoint = options.endpoint || process.env.PW_ENDPOINT || null;
|
|
119
|
+
process.exit(await require('../lib/background')[subcommand]({ endpoint }));
|
|
120
|
+
}
|
|
121
|
+
// falls through never: process.exit above
|
|
98
122
|
case 'send': {
|
|
99
123
|
const options = parseSendArgs(args, true);
|
|
100
124
|
// 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
|
@@ -42,9 +42,9 @@ async function startCapture(tokens) {
|
|
|
42
42
|
};
|
|
43
43
|
const page = state.page;
|
|
44
44
|
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()}` }));
|
|
45
|
+
if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', req, text: `${req.method()} ${req.url()}` }));
|
|
46
46
|
if (label !== 'requests') {
|
|
47
|
-
listen('console', msg => record({ t: Date.now(), tag:
|
|
47
|
+
listen('console', msg => record({ t: Date.now(), tag: msg.type(), text: consoleText(msg) }));
|
|
48
48
|
listen('pageerror', error => record({ t: Date.now(), tag: 'pageerror', text: error.stack || error.message }));
|
|
49
49
|
}
|
|
50
50
|
// A capture must not outlive its tab: nothing could see or stop it.
|
|
@@ -201,6 +201,13 @@ function clipText(text) {
|
|
|
201
201
|
return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// The browser's own "Failed to load resource" message does not say which one.
|
|
205
|
+
function consoleText(msg) {
|
|
206
|
+
const text = msg.text();
|
|
207
|
+
const url = msg.location()?.url;
|
|
208
|
+
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
209
|
+
}
|
|
210
|
+
|
|
204
211
|
function keep(log, entry) {
|
|
205
212
|
log.push(entry);
|
|
206
213
|
if (log.length > RECENT_MAX) log.shift();
|
|
@@ -235,7 +242,7 @@ function ensureRecentLog(p) {
|
|
|
235
242
|
finish(req, fakedRequests.has(req) ? `${status} faked` : status, res);
|
|
236
243
|
});
|
|
237
244
|
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
|
|
245
|
+
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
239
246
|
// Uncaught exceptions never reach the console event.
|
|
240
247
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
241
248
|
}
|
|
@@ -531,6 +538,12 @@ async function startWatching(p, changes = false, live = false) {
|
|
|
531
538
|
// Only now: a failed first attempt must leave nothing half set up to retry against.
|
|
532
539
|
watches.set(p, created);
|
|
533
540
|
watch = created;
|
|
541
|
+
} else if (!watch.on) {
|
|
542
|
+
// A watch on after watch off is a new recording: the old steps would read as part of it.
|
|
543
|
+
watch.events.length = 0;
|
|
544
|
+
watch.shownSeq = watch.nextSeq - 1;
|
|
545
|
+
watch.shownRequestId = Math.max(0, ...(recentLogs.get(p) || []).map(r => r.id));
|
|
546
|
+
watch.liveStep = null;
|
|
534
547
|
}
|
|
535
548
|
await p.evaluate(WATCH_SCRIPT);
|
|
536
549
|
watch.changes = changes;
|
|
@@ -653,6 +666,55 @@ async function allModesOff() {
|
|
|
653
666
|
if (!any) out.log('No modes were on.');
|
|
654
667
|
}
|
|
655
668
|
|
|
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
|
+
// Commands that take only a selector take the whole line, spaces and all.
|
|
692
|
+
function soleSelector(args, usage) {
|
|
693
|
+
const text = unquote((args || '').trim());
|
|
694
|
+
if (!text) throw new Error(`Usage: ${usage}`);
|
|
695
|
+
return toSelector(text);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function selectorAndValue(args, usage, example) {
|
|
699
|
+
const parsed = splitSelector(args);
|
|
700
|
+
if (!parsed || parsed.rest === undefined) {
|
|
701
|
+
throw new Error(`Usage: ${usage}, e.g. ${example}; quote a selector with spaces: "text=Your name"`);
|
|
702
|
+
}
|
|
703
|
+
return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// A ref that no longer matches usually means the page changed since the snapshot.
|
|
707
|
+
async function onElement(selector, action) {
|
|
708
|
+
try {
|
|
709
|
+
return await action();
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (selector.startsWith('aria-ref=') && !/resolved to/.test(error.message)) {
|
|
712
|
+
error.message += `\n${selector.slice(9)} is a snapshot ref; if the page changed since that snapshot, take a new one.`;
|
|
713
|
+
}
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
656
718
|
const WAIT_DEFAULT = 10;
|
|
657
719
|
const WAIT_MAX = 120;
|
|
658
720
|
|
|
@@ -716,13 +778,17 @@ function clock(t) {
|
|
|
716
778
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
|
|
717
779
|
}
|
|
718
780
|
|
|
781
|
+
// One line per event, from the start of the capture: a request as requests
|
|
782
|
+
// shows it (its status is looked up now, so it is known if it has finished),
|
|
783
|
+
// a console message or page error as console shows it.
|
|
719
784
|
function printCapture(capture, all = false) {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
tag
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
785
|
+
const lines = capture.events.map(event => {
|
|
786
|
+
const at = `+${((event.t - capture.startedAt) / 1000).toFixed(3)}s`;
|
|
787
|
+
if (event.tag !== 'request') return `${at} [${event.tag}] ${event.text}`;
|
|
788
|
+
const entry = requestEntries.get(event.req);
|
|
789
|
+
return entry ? `${at} #${entry.id} ${entry.method} ${entry.status} ${entry.url}` : `${at} ${event.text}`;
|
|
790
|
+
});
|
|
791
|
+
if (lines.length) printOutput(lines.join('\n'), all);
|
|
726
792
|
else out.log('Nothing captured');
|
|
727
793
|
const notes = [];
|
|
728
794
|
if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
|
|
@@ -736,6 +802,13 @@ function discardCapture() {
|
|
|
736
802
|
cap = null;
|
|
737
803
|
}
|
|
738
804
|
|
|
805
|
+
// A tab of the REPL's own: closing it can go back to the tab before.
|
|
806
|
+
async function openTab() {
|
|
807
|
+
const opened = await state.browser.contexts()[0].newPage();
|
|
808
|
+
openedTabs.add(opened);
|
|
809
|
+
return opened;
|
|
810
|
+
}
|
|
811
|
+
|
|
739
812
|
// Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
|
|
740
813
|
async function listTabs() {
|
|
741
814
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
@@ -788,18 +861,19 @@ const commands = {
|
|
|
788
861
|
if (/^\d+$/.test(subcommand)) {
|
|
789
862
|
if (parts.length) throw new Error(usage);
|
|
790
863
|
const target = state.tabListing[Number(subcommand)];
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
864
|
+
if (target && all.includes(target)) { select(target); return commands.info(); }
|
|
865
|
+
// A number that is not a tab in the listing may be part of a URL (a port).
|
|
866
|
+
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
867
|
}
|
|
795
868
|
if (subcommand === 'new') {
|
|
796
|
-
|
|
797
|
-
const opened = await ctx.newPage();
|
|
798
|
-
openedTabs.add(opened);
|
|
799
|
-
select(opened);
|
|
869
|
+
select(await openTab());
|
|
800
870
|
const url = parts.join(' ');
|
|
801
|
-
if (url)
|
|
802
|
-
|
|
871
|
+
if (url) {
|
|
872
|
+
try { await commands.goto(url); }
|
|
873
|
+
catch (error) { throw new Error(`${error.message}\nThe new tab stays open and selected; tab close closes it.`); }
|
|
874
|
+
} else {
|
|
875
|
+
out.log('New tab created and selected');
|
|
876
|
+
}
|
|
803
877
|
return listTabs();
|
|
804
878
|
}
|
|
805
879
|
if (subcommand === 'close') {
|
|
@@ -852,104 +926,95 @@ const commands = {
|
|
|
852
926
|
},
|
|
853
927
|
|
|
854
928
|
async click(args) {
|
|
855
|
-
|
|
856
|
-
await state.page.click(
|
|
857
|
-
out.log(`Clicked: ${args}`);
|
|
929
|
+
const selector = soleSelector(args, 'click <selector>');
|
|
930
|
+
await onElement(selector, () => state.page.click(selector, { timeout: 5000 }));
|
|
931
|
+
out.log(`Clicked: ${args.trim()}`);
|
|
858
932
|
},
|
|
859
933
|
|
|
860
934
|
async dblclick(args) {
|
|
861
|
-
|
|
862
|
-
await state.page.dblclick(
|
|
863
|
-
out.log(`Double-clicked: ${args}`);
|
|
935
|
+
const selector = soleSelector(args, 'dblclick <selector>');
|
|
936
|
+
await onElement(selector, () => state.page.dblclick(selector, { timeout: 5000 }));
|
|
937
|
+
out.log(`Double-clicked: ${args.trim()}`);
|
|
864
938
|
},
|
|
865
939
|
|
|
866
940
|
async hover(args) {
|
|
867
|
-
|
|
868
|
-
await state.page.hover(
|
|
869
|
-
out.log(`Hovered: ${args}`);
|
|
941
|
+
const selector = soleSelector(args, 'hover <selector>');
|
|
942
|
+
await onElement(selector, () => state.page.hover(selector, { timeout: 5000 }));
|
|
943
|
+
out.log(`Hovered: ${args.trim()}`);
|
|
870
944
|
},
|
|
871
945
|
|
|
872
946
|
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()}`);
|
|
947
|
+
const { selector, value, shown } = selectorAndValue(args, 'fill <selector> <value>', 'fill #name Ada Lovelace');
|
|
948
|
+
await onElement(selector, () => state.page.fill(selector, value, { timeout: 5000 }));
|
|
949
|
+
out.log(`Filled: ${shown}`);
|
|
879
950
|
},
|
|
880
951
|
|
|
881
952
|
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()}`);
|
|
953
|
+
const { selector, value, shown } = selectorAndValue(args, 'type <selector> <text>', 'type #search garden hose');
|
|
954
|
+
await onElement(selector, () => state.page.type(selector, value, { timeout: 5000 }));
|
|
955
|
+
out.log(`Typed into: ${shown}`);
|
|
888
956
|
},
|
|
889
957
|
|
|
890
958
|
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()}`);
|
|
959
|
+
const parsed = splitSelector(args);
|
|
960
|
+
if (!parsed) throw new Error('Usage: press <key> | press <selector> <key>, e.g. press Enter or press #name Enter');
|
|
961
|
+
if (parsed.rest === undefined) {
|
|
962
|
+
await state.page.keyboard.press(parsed.word);
|
|
963
|
+
out.log(`Pressed: ${parsed.word}`);
|
|
964
|
+
return;
|
|
899
965
|
}
|
|
966
|
+
const key = unquote(parsed.rest);
|
|
967
|
+
await onElement(parsed.selector, () => state.page.press(parsed.selector, key, { timeout: 5000 }));
|
|
968
|
+
out.log(`Pressed ${key} on ${parsed.word}`);
|
|
900
969
|
},
|
|
901
970
|
|
|
902
|
-
|
|
903
971
|
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}`);
|
|
972
|
+
const { selector, value, shown } = selectorAndValue(args, 'select <selector> <value>', 'select #country Canada');
|
|
973
|
+
await onElement(selector, () => state.page.selectOption(selector, value, { timeout: 5000 }));
|
|
974
|
+
out.log(`Selected "${value}" in ${shown}`);
|
|
910
975
|
},
|
|
911
976
|
|
|
912
977
|
async check(args) {
|
|
913
|
-
|
|
914
|
-
await state.page.check(
|
|
915
|
-
out.log(`Checked: ${args}`);
|
|
978
|
+
const selector = soleSelector(args, 'check <selector>');
|
|
979
|
+
await onElement(selector, () => state.page.check(selector, { timeout: 5000 }));
|
|
980
|
+
out.log(`Checked: ${args.trim()}`);
|
|
916
981
|
},
|
|
917
982
|
|
|
918
983
|
async uncheck(args) {
|
|
919
|
-
|
|
920
|
-
await state.page.uncheck(
|
|
921
|
-
out.log(`Unchecked: ${args}`);
|
|
984
|
+
const selector = soleSelector(args, 'uncheck <selector>');
|
|
985
|
+
await onElement(selector, () => state.page.uncheck(selector, { timeout: 5000 }));
|
|
986
|
+
out.log(`Unchecked: ${args.trim()}`);
|
|
922
987
|
},
|
|
923
988
|
|
|
924
989
|
async text(args, all) {
|
|
925
|
-
|
|
926
|
-
printOutput(await state.page.innerText(
|
|
990
|
+
const selector = soleSelector(args, 'text <selector>');
|
|
991
|
+
printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
|
|
927
992
|
},
|
|
928
993
|
|
|
929
994
|
async html(args, all) {
|
|
930
|
-
|
|
931
|
-
printOutput(await state.page.$eval(
|
|
995
|
+
const selector = soleSelector(args, 'html <selector>');
|
|
996
|
+
printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
|
|
932
997
|
},
|
|
933
998
|
|
|
934
999
|
async attrs(args, all) {
|
|
935
|
-
|
|
936
|
-
const result = await state.page.$eval(
|
|
1000
|
+
const selector = soleSelector(args, 'attrs <selector>');
|
|
1001
|
+
const result = await onElement(selector, () => state.page.$eval(selector, el => {
|
|
937
1002
|
const out = {};
|
|
938
1003
|
for (const attr of el.attributes) out[attr.name] = attr.value;
|
|
939
1004
|
return out;
|
|
940
|
-
});
|
|
1005
|
+
}));
|
|
941
1006
|
printOutput(result, all);
|
|
942
1007
|
},
|
|
943
1008
|
|
|
944
1009
|
async count(args) {
|
|
945
|
-
|
|
946
|
-
const els = await state.page.$$(
|
|
1010
|
+
const selector = soleSelector(args, 'count <selector>');
|
|
1011
|
+
const els = await state.page.$$(selector);
|
|
947
1012
|
out.log(`${els.length} element(s)`);
|
|
948
1013
|
},
|
|
949
1014
|
|
|
950
1015
|
async visible(args) {
|
|
951
|
-
|
|
952
|
-
const el = await state.page.$(
|
|
1016
|
+
const selector = soleSelector(args, 'visible <selector>');
|
|
1017
|
+
const el = await state.page.$(selector);
|
|
953
1018
|
if (!el) { out.log('Not found'); return; }
|
|
954
1019
|
out.log(await el.isVisible() ? 'visible' : 'hidden');
|
|
955
1020
|
},
|
|
@@ -1029,7 +1094,7 @@ const commands = {
|
|
|
1029
1094
|
rest = '';
|
|
1030
1095
|
}
|
|
1031
1096
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
1032
|
-
const selector =
|
|
1097
|
+
const selector = toSelector(rest);
|
|
1033
1098
|
const target = selector ? state.page.locator(selector).first() : state.page;
|
|
1034
1099
|
const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1035
1100
|
const text = full ? raw : compactSnapshot(raw);
|
|
@@ -1072,9 +1137,9 @@ const commands = {
|
|
|
1072
1137
|
}
|
|
1073
1138
|
return waitForRequest(what, timeout);
|
|
1074
1139
|
}
|
|
1075
|
-
const selector = tokens.join(' ');
|
|
1140
|
+
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1076
1141
|
await state.page.waitForSelector(selector, { state: 'attached', timeout });
|
|
1077
|
-
out.log(`Found: ${
|
|
1142
|
+
out.log(`Found: ${tokens.join(' ')}`);
|
|
1078
1143
|
},
|
|
1079
1144
|
|
|
1080
1145
|
async sleep(args) {
|
|
@@ -1158,6 +1223,11 @@ const commands = {
|
|
|
1158
1223
|
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
1159
1224
|
out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.url}`);
|
|
1160
1225
|
}
|
|
1226
|
+
// The numbers skip what is hidden; say so, or they look like requests went missing.
|
|
1227
|
+
const first = matches[0].id;
|
|
1228
|
+
const last = matches[matches.length - 1].id;
|
|
1229
|
+
const hidden = everything ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
|
|
1230
|
+
if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
|
|
1161
1231
|
},
|
|
1162
1232
|
|
|
1163
1233
|
async body(args, all) {
|
|
@@ -1367,4 +1437,4 @@ function complete(line) {
|
|
|
1367
1437
|
return [[], current];
|
|
1368
1438
|
}
|
|
1369
1439
|
|
|
1370
|
-
module.exports = { commands, listTabs, activeModes, watchPage, complete, compactSnapshot, grepSnapshot, summarizeChanges, scrubEditable, clock };
|
|
1440
|
+
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
|
|
|
@@ -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' },
|
|
@@ -139,12 +144,12 @@ const COMMANDS = {
|
|
|
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>',
|
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;
|
|
@@ -70,19 +73,14 @@ async function executeCommand(text) {
|
|
|
70
73
|
await commands[cmd](args, all);
|
|
71
74
|
} catch (e) {
|
|
72
75
|
out.error(`Error: ${e.message}`);
|
|
73
|
-
|
|
76
|
+
const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
|
|
77
|
+
if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
|
|
78
|
+
return { status: 'error', cmd, uncertain };
|
|
74
79
|
}
|
|
75
80
|
if (cmd === 'quit' && state.shutdownFailed) return { status: 'error', cmd };
|
|
76
81
|
return { status: 'ok', cmd };
|
|
77
82
|
}
|
|
78
83
|
|
|
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
84
|
// A quit does not wait for the running server command, which may never finish
|
|
87
85
|
// once the browser is gone, so its sender is answered now. A read-only command
|
|
88
86
|
// changed nothing; any other may or may not have done what it was sent to do.
|
|
@@ -106,12 +104,12 @@ async function handleLine(line) {
|
|
|
106
104
|
const result = await execute(input.text);
|
|
107
105
|
done(result.status);
|
|
108
106
|
if (result.cmd === 'quit') { await beforeExit(); process.exit(process.exitCode || 0); }
|
|
109
|
-
if (result.uncertain) return giveUp();
|
|
110
107
|
if (!state.stopping) prompt();
|
|
111
108
|
}
|
|
112
109
|
|
|
113
110
|
// The prompt starts with the modes on in the selected tab: (watch network:off) pw>.
|
|
114
111
|
function prompt(preserveCursor) {
|
|
112
|
+
if (!state.rl) return;
|
|
115
113
|
const modes = state.page && !state.page.isClosed() ? activeModes(state.page) : [];
|
|
116
114
|
state.rl.setPrompt(`${modes.length ? `(${modes.join(' ')}) ` : ''}${state.promptBase}`);
|
|
117
115
|
state.rl.prompt(preserveCursor);
|
|
@@ -136,7 +134,7 @@ function submit(text) {
|
|
|
136
134
|
if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
|
|
137
135
|
out.log(`[server] ${text}`);
|
|
138
136
|
// So the person at the prompt can bring an agent's command back with up-arrow.
|
|
139
|
-
if (state.rl
|
|
137
|
+
if (state.rl?.history && state.rl.history[0] !== text) {
|
|
140
138
|
state.rl.history.unshift(text);
|
|
141
139
|
state.rl.history.length = Math.min(state.rl.history.length, 1000);
|
|
142
140
|
}
|
|
@@ -160,4 +158,4 @@ function drained() {
|
|
|
160
158
|
return queue;
|
|
161
159
|
}
|
|
162
160
|
|
|
163
|
-
module.exports = { enqueue, submit,
|
|
161
|
+
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,8 +112,13 @@ 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';
|
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/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -13,26 +13,24 @@ shared session: a person can have their own tabs open in it and be using it whil
|
|
|
13
13
|
|
|
14
14
|
## Start it
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
`pw-repl where` says whether a REPL is running and how `send` reaches it. There are three ways to run
|
|
17
|
+
one; each takes an optional start URL, which opens in a new tab.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
- `pw-repl serve --background` runs it detached, with a command server on `/tmp/playwright-repl.sock`
|
|
20
|
+
(owner-only) and its output in `/tmp/playwright-repl.log`. `pw-repl attach` shows everything it does
|
|
21
|
+
and takes commands; `pw-repl stop` stops it.
|
|
22
|
+
- `pw-repl serve` runs the same in a terminal, where the pane shows every command. Its prompt is
|
|
23
|
+
`pw[serve]>`.
|
|
24
|
+
- `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
|
|
25
|
+
the tmux session `playwright-repl`:
|
|
24
26
|
|
|
25
|
-
```bash
|
|
26
|
-
tmux send-keys -t playwright-repl -l 'pw-repl run'
|
|
27
|
-
tmux send-keys -t playwright-repl Enter
|
|
28
|
-
```
|
|
27
|
+
```bash
|
|
28
|
+
tmux send-keys -t playwright-repl -l 'pw-repl run'
|
|
29
|
+
tmux send-keys -t playwright-repl Enter
|
|
30
|
+
```
|
|
29
31
|
|
|
30
|
-
`
|
|
31
|
-
|
|
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.
|
|
32
|
+
`serve <port>` listens on TCP 127.0.0.1 instead of the socket, with no access control. A REPL in a
|
|
33
|
+
terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
|
|
36
34
|
|
|
37
35
|
## Send commands
|
|
38
36
|
|
|
@@ -41,29 +39,30 @@ pw-repl send tab
|
|
|
41
39
|
pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
|
|
42
40
|
```
|
|
43
41
|
|
|
44
|
-
Always send commands with `pw-repl send`; don't type into the pane yourself.
|
|
45
|
-
command
|
|
42
|
+
Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
|
|
43
|
+
are the command, and a word quoted in your shell stays one word:
|
|
44
|
+
`pw-repl send fill "text=Your name" Ada`. It works however the REPL was started:
|
|
46
45
|
|
|
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`.
|
|
46
|
+
- `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
|
|
47
|
+
It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
|
|
48
|
+
of what the user is typing; while a command is running, the user is typing, or the REPL is exiting, it
|
|
49
|
+
refuses (exit 64). Wait and retry.
|
|
50
|
+
- `serve`, in a terminal or in the background: `send` sends it over the socket and gets the output back
|
|
51
|
+
as JSON. Same commands, more reliable results: nothing is scraped, long output isn't cut off by
|
|
52
|
+
scrollback, and a command can never land in a shell.
|
|
55
53
|
|
|
56
54
|
`pw-repl where` says which one a command would reach (the server, or the tmux pane running the
|
|
57
55
|
REPL), or why neither is reachable, without running anything.
|
|
58
56
|
|
|
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
|
|
57
|
+
Every command you run and its output show in the REPL's pane, or in `attach` and the log for a
|
|
58
|
+
background REPL (server commands as `[server]` lines), so the user sees what you do. The pane shows REPL
|
|
59
|
+
commands only, not what the user clicked in the browser (unless `watch on --live` is on); for that, look
|
|
60
|
+
at the browser itself: `tab` and `info` for where they are, `requests` for the requests their clicks
|
|
61
|
+
made (`body <#>` for what one returned), `console` for console messages and page errors. When the user
|
|
62
|
+
wants to show you what they do, `watch on` on their tab records each step with the requests it caused
|
|
63
|
+
(`watch on --changes` adds what each step changed on the page); `watch` reads it back, and `watch new`
|
|
64
|
+
only what it has not shown yet. Watching and reading are fine on the user's tabs; the rule below is
|
|
65
|
+
about acting on them.
|
|
67
66
|
|
|
68
67
|
Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
|
|
69
68
|
blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
|
|
@@ -71,14 +70,14 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
|
|
|
71
70
|
## Learn the commands
|
|
72
71
|
|
|
73
72
|
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.
|
|
73
|
+
gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The
|
|
74
|
+
help is the command reference; this file does not repeat it.
|
|
76
75
|
|
|
77
76
|
## Shared-browser rules
|
|
78
77
|
|
|
79
78
|
- 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
|
-
|
|
79
|
+
their tab while they test); then say what you are doing and undo it the moment you are done. No tab
|
|
80
|
+
is selected when the REPL starts (unless it was given a start URL); `tab` lists them. Closing your
|
|
82
81
|
tab goes back only to a tab you opened; otherwise no tab is selected. Tab numbers change when tabs
|
|
83
82
|
open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by its URL and refuse if it is
|
|
84
83
|
ambiguous.
|