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 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,17 @@ 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
+ // 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
- return require('../lib/start').start(startOptions(args, subcommand === 'serve'));
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: `console:${msg.type()}`, text: msg.text() }));
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.text()) }));
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 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);
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 (!target || !all.includes(target)) throw new Error('That tab is unavailable. Run tab again.');
792
- select(target);
793
- return commands.info();
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
- const ctx = state.browser.contexts()[0];
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) await commands.goto(url);
802
- else out.log('New tab created and selected');
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
- if (!args) throw new Error('Usage: click <selector>');
856
- await state.page.click(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: dblclick <selector>');
862
- await state.page.dblclick(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: hover <selector>');
868
- await state.page.hover(args, { timeout: 5000 });
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
- 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()}`);
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
- 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()}`);
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
- 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()}`);
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
- 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}`);
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
- if (!args) throw new Error('Usage: check <selector>');
914
- await state.page.check(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: uncheck <selector>');
920
- await state.page.uncheck(args, { timeout: 5000 });
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
- if (!args) throw new Error('Usage: text <selector>');
926
- printOutput(await state.page.innerText(args, { timeout: 5000 }), all);
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
- if (!args) throw new Error('Usage: html <selector>');
931
- printOutput(await state.page.$eval(args, el => el.outerHTML), all);
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
- if (!args) throw new Error('Usage: attrs <selector>');
936
- const result = await state.page.$eval(args, el => {
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
- if (!args) throw new Error('Usage: count <selector>');
946
- const els = await state.page.$$(args);
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
- if (!args) throw new Error('Usage: visible <selector>');
952
- const el = await state.page.$(args);
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 = /^(?:f\d+)?e\d+$/.test(rest) ? `aria-ref=${rest}` : rest;
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: ${selector}`);
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: '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
 
@@ -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' },
@@ -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> <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>',
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;
@@ -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
- return { status: 'error', cmd, uncertain: !READ_ONLY.has(cmd) && isUncertain(e) };
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.history && state.rl.history[0] !== text) {
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, giveUp, answerInterrupted, drained, busy };
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}'; 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,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)) { 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';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
@@ -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
- The REPL runs in the tmux session `playwright-repl`. Check it first:
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
- ```bash
19
- tmux capture-pane -t playwright-repl -p -S -5
20
- ```
21
-
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.
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
- `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.
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. Unquoted words are one
45
- command. It works however the REPL was started:
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`: `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`.
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
- 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.
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 help is
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. The REPL
81
- selects tab [0] at startup, and that tab may be the user's: run `tab` before acting. Closing your
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.