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 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 aria-ref=e3 # click by label (or any Playwright selector)
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 navigate tab [0] to a URL
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
- options.command = words.join(' ').trim();
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
- return require('../lib/start').start(startOptions(args, subcommand === 'serve'));
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: `console:${msg.type()}`, text: msg.text() }));
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.text()) }));
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 events = capture.events.map(event => ({
721
- elapsed: Number(((event.t - capture.startedAt) / 1000).toFixed(3)),
722
- tag: event.tag,
723
- text: event.text,
724
- }));
725
- if (events.length) printOutput(events, all);
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 (!target || !all.includes(target)) throw new Error('That tab is unavailable. Run tab again.');
792
- select(target);
793
- return commands.info();
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
- const ctx = state.browser.contexts()[0];
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) await commands.goto(url);
802
- else out.log('New tab created and selected');
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
- if (!args) throw new Error('Usage: click <selector>');
856
- await state.page.click(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: dblclick <selector>');
862
- await state.page.dblclick(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: hover <selector>');
868
- await state.page.hover(args, { timeout: 5000 });
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
- if (!args || !args.includes('=>')) {
874
- throw new Error('Usage: fill <selector> => <value>');
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
- if (!args || !args.includes('=>')) {
883
- throw new Error('Usage: type <selector> => <text>');
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
- if (!args) throw new Error('Usage: press <key> or press <selector> => <key>');
892
- if (args.includes('=>')) {
893
- const [selector, key] = args.split('=>').map(s => s.trim());
894
- await state.page.press(selector, key, { timeout: 5000 });
895
- out.log(`Pressed ${key} on ${selector}`);
896
- } else {
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
- if (!args || !args.includes('=>')) {
905
- throw new Error('Usage: select <selector> => <value>');
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
- if (!args) throw new Error('Usage: check <selector>');
914
- await state.page.check(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: uncheck <selector>');
920
- await state.page.uncheck(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: text <selector>');
926
- printOutput(await state.page.innerText(args, { timeout: 5000 }), all);
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
- if (!args) throw new Error('Usage: html <selector>');
931
- printOutput(await state.page.$eval(args, el => el.outerHTML), all);
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
- if (!args) throw new Error('Usage: attrs <selector>');
936
- const result = await state.page.$eval(args, el => {
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
- if (!args) throw new Error('Usage: count <selector>');
946
- const els = await state.page.$$(args);
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
- if (!args) throw new Error('Usage: visible <selector>');
952
- const el = await state.page.$(args);
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 = /^(?:f\d+)?e\d+$/.test(rest) ? `aria-ref=${rest}` : rest;
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: ${selector}`);
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
- catch (error) { throw new Error(`The body of #${id} is not available: ${error.message}`); }
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: 'The REPL selects tab [0] when it starts; tab on its own lists them all. After tab close, see help tab.',
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=...). Commands use the first match.',
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-out command may still be running, so the REPL disconnects instead of continuing.
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 and selects a tab. 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
+ 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: { usage: 'fill <selector> => <value>', summary: 'clear an input and fill it' },
85
- type: { usage: 'type <selector> => <text>', summary: 'type into an input key by key' },
86
- press: { usage: 'press <key> | press <selector> => <key>', summary: 'press a key, optionally on an element' },
87
- select: { usage: 'select <selector> => <value>', summary: 'choose an option in a select' },
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 label (e3, or f1e3 in newer Playwright) works as a selector:\nclick aria-ref=e3. Labels can change when the page navigates (e102 may become f4e98), so take a new\nsnapshot after one.\n\nOutput over 60 lines ends with a line count.',
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 it pauses), Enter and Escape, form changes, submits\nand navigations, each described by role and name like snapshot, with up to 5 of the requests it\ncaused underneath (their numbers in requests; body <#> for one), leaving out scripts and what\nrequests 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.',
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; the REPL waits meanwhile.\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.',
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> <METHOD> <url> -> <status>", with #<n> as in\nrequests. If fulfilling fails it prints "Fake failed" and aborts the request, so it never reaches\nthe 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"}}',
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, shutdown, beforeExit } = require('./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 reported as an error
9
- // and the REPL carries on. Any other command that times out may still be
10
- // acting on the page, so the REPL disconnects rather than continue.
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
- return /timed out|timeout|connection (?:lost|closed)/i.test(error.message || '');
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
- return { status: 'error', cmd, uncertain: !READ_ONLY.has(cmd) && isUncertain(e) };
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.history && state.rl.history[0] !== text) {
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, giveUp, answerInterrupted, drained, busy };
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}'; ask the user before creating one (pw-repl skill: Start it)`;
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)) { console.log(`server: ${name} (the REPL was started with pw-repl serve)`); return 0; }
116
- console.error(`server: ${name} is not answering`);
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)) { console.error(`tmux: no session '${session}'`); return 64; }
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
- if (result.interrupted) return send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
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
- state.page = pages[0];
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
- if (!state.page) {
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(`Navigated to: ${state.page.url()}`);
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; the shell's prompt
102
- // belongs on a line of its own.
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
- if (process.stdout.isTTY) process.stdout.write('\n');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
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
- The REPL runs in the tmux session `playwright-repl`. Check it first:
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
- ```bash
19
- tmux capture-pane -t playwright-repl -p -S -5
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
- If the pane is at a shell prompt, start the REPL there. If there is no session, ask the user before
23
- creating one.
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
- ```bash
26
- tmux send-keys -t playwright-repl -l 'pw-repl run'
27
- tmux send-keys -t playwright-repl Enter
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
- `pw-repl serve` is the same plus a command server on `/tmp/playwright-repl.sock` (owner-only); its
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. Unquoted words are one
45
- command. It works however the REPL was started:
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`: `send` types the command into the tmux pane and reads the result back off the screen. It
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
- it refuses (exit 64). Wait and retry.
51
- - `serve`: `send` sends it over the socket and gets the output back as JSON. Same commands, more
52
- reliable results: nothing is scraped, long output isn't cut off by scrollback, and a command can
53
- never land in a shell. If you'll send many commands or read long output, ask the user whether to
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
- Either way, every command you run and its output show in the pane (server commands as `[server]` lines),
60
- so the user sees what you do. The pane shows REPL commands only, not what the user clicked in the
61
- browser (unless `watch on --live` is on); for that, look at the browser itself: `tab` and `info` for
62
- where they are, `requests` for the requests their clicks made (`body <#>` for what one returned),
63
- `console` for console messages and page errors. When the user wants to show you what they do, `watch on`
64
- on their tab records each step with the requests it caused (`watch on --changes` adds what each step
65
- changed on the page); `watch` reads it back, and `watch new` only what it has not shown yet. Watching
66
- and reading are fine on the user's tabs; the rule below is about acting on them.
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 help is
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. The REPL
81
- selects tab [0] at startup, and that tab may be the user's: run `tab` before acting. Closing your
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.