pw-repl 0.12.6 → 0.12.7

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
@@ -104,7 +104,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `fe
104
104
 
105
105
  1. `npm test` passes and the working tree is clean.
106
106
  2. `npm outdated` and `npm audit` show nothing that needs doing first. The lockfile holds playwright-core
107
- at 1.63: on 1.64, a click on a link to a 204 waits out its timeout instead of returning (the test
107
+ at 1.63: on 1.64 (and 1.64.1), a click on a link to a 204 waits out its timeout instead of returning (the test
108
108
  "waits for the page to load, counting a navigation that just began" fails). Update it once a version
109
109
  passes `npm test`.
110
110
  3. `npm version <patch|minor|major> -m "chore: release %s"` sets the version in `package.json` and
package/README.md CHANGED
@@ -126,7 +126,7 @@ example the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
126
126
  it. `pw-repl where` says what is running and how `send` will reach it.
127
127
  - For `send` to reach `run`, start it in tmux: `tmux new -s pw-repl`, then `pw-repl run` there.
128
128
  - A URL after the command opens in a new tab: `pw-repl serve http://localhost:3000`.
129
- - `--launch` works with each of them. Flags after `--` go to that Chromium.
129
+ - `--launch` works with each of them. Flags after `--` go to that Chromium (flags only; a start URL goes before `--`).
130
130
  - A REPL of your own goes on a socket of its own: `-e /tmp/mine.sock` on every command, `serve`
131
131
  included, or set `PW_SOCKET`. `serve <port>` listens on TCP, on 127.0.0.1 only.
132
132
 
package/bin/pw-repl.js CHANGED
@@ -38,6 +38,9 @@ $PW_ENDPOINT, then $PW_SOCKET, then the default.
38
38
  send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
39
39
  several agents can share one REPL; pw-repl help session has the rest.
40
40
 
41
+ send's options can also follow the command, as in pw-repl send open <url> -t 30; after a -- before the
42
+ command, every word is the command's, -t 30 included.
43
+
41
44
  send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable. send -t is
42
45
  how long send waits for the answer (default 20s); it does not lengthen a command's own limits, such as
43
46
  the 5s an action waits for its element.
@@ -64,7 +67,7 @@ The browser must be running with --remote-debugging-port (default http://localho
64
67
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
65
68
  a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
66
69
  and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
67
- Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
70
+ Flags after -- go to it, and only flags; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
68
71
  kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
69
72
 
70
73
  In a tmux session named pw-repl, pw-repl send reaches pw-repl run without the server.`;
@@ -74,15 +77,49 @@ const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl s
74
77
  pw-repl help <topic | command [--all] | --all> shows one part of it; pw-repl help video and pw-repl
75
78
  help playwright-cli are guides.`;
76
79
 
77
- function usage() {
78
- console.error(USAGE);
80
+ const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'help', 'skill'];
81
+
82
+ // What is wrong, then only the usage of the subcommand it is about: the whole of it would bury the line
83
+ // that matters. With no message, the whole usage (-h after a subcommand).
84
+ function usage(message, subcommand) {
85
+ if (!message) {
86
+ console.error(USAGE);
87
+ process.exit(64);
88
+ }
89
+ console.error(`pw-repl: ${message}`);
90
+ const lines = subcommand && USAGE.split('\n\n').find(block => new RegExp(`^(?:Usage:\\n)? pw-repl ${subcommand}(?: |\\n|$)`).test(block));
91
+ if (lines) console.error(`Usage:\n${lines.replace(/^Usage:\n/, '')}`);
92
+ console.error(`pw-repl --help has the ${lines ? 'rest' : 'usage'}.`);
79
93
  process.exit(64);
80
94
  }
81
95
 
82
- // -e, -s, -c and -t, then the command words (unquoted words are one command).
83
- function parseSendArgs(args, allowCommand) {
96
+ // A command line to copy, quoted as a shell reads it.
97
+ function shellLine(words) {
98
+ return words.map(w => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, "'\\''")}'`)).join(' ');
99
+ }
100
+
101
+ // -e, -s, -c and -t, then the command words (unquoted words are one command), and send's options again
102
+ // at the end, as an agent types them (send open <url> -t 30); after --, every word is the command.
103
+ function parseSendArgs(args, subcommand) {
104
+ const allowCommand = subcommand === 'send';
84
105
  const options = { endpoint: null, session: null, client: null, timeout: 20, command: '', file: null };
106
+ const given = new Set();
107
+ const take = (arg, value, after) => {
108
+ if (value === undefined) usage(`${arg} needs a value`, subcommand);
109
+ // Given before the command and after it, neither silently wins.
110
+ if (after && given.has(arg)) usage(`${arg} is given twice, ${value === true ? arg : `${arg} ${value}`} after the command; give it once`, subcommand);
111
+ given.add(arg);
112
+ if (arg === '--no-vars') options.noVars = true;
113
+ if (arg === '-e') options.endpoint = value;
114
+ if (arg === '-s') options.session = value;
115
+ if (arg === '-c') options.client = value;
116
+ if (arg === '-t') {
117
+ if (!/^\d+$/.test(value) || Number(value) < 1) usage(`-t takes whole seconds, 1 or more (got ${value})`, subcommand);
118
+ options.timeout = Number(value);
119
+ }
120
+ };
85
121
  let i = 0;
122
+ let dashes = false;
86
123
  for (; i < args.length; i++) {
87
124
  const arg = args[i];
88
125
  // playwright-cli's --raw: the output here is only the command's already.
@@ -97,22 +134,22 @@ function parseSendArgs(args, allowCommand) {
97
134
  // A file of commands, one per line, as typed at the prompt.
98
135
  if (arg.startsWith('--file=') && arg.length > 7) { options.file = require('path').resolve(arg.slice(7)); continue; }
99
136
  if (arg === '--file') {
100
- if (args[i + 1] === undefined) usage();
137
+ if (args[i + 1] === undefined) usage('--file needs a file of commands', subcommand);
101
138
  options.file = require('path').resolve(args[++i]);
102
139
  continue;
103
140
  }
104
- if (arg === '--no-vars') { options.noVars = true; continue; }
105
- if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
106
- const value = args[++i];
107
- if (value === undefined) usage();
108
- if (arg === '-e') options.endpoint = value;
109
- if (arg === '-s') options.session = value;
110
- if (arg === '-c') options.client = value;
111
- if (arg === '-t') {
112
- if (!/^\d+$/.test(value) || Number(value) < 1) usage();
113
- options.timeout = Number(value);
114
- }
115
- } else if (arg === '--') { i++; break; } else break;
141
+ if (arg === '--no-vars') take(arg, true);
142
+ else if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') take(arg, args[++i]);
143
+ else if (arg === '--') { i++; dashes = true; break; } else break;
144
+ }
145
+ const words = args.slice(i);
146
+ // A word the shell kept whole ("a -t 3") is never one of these; an option with a bad value is still
147
+ // send's, refused, not the end of a URL or a selector.
148
+ while (allowCommand && !dashes) {
149
+ const last = words.length - 1;
150
+ if (last > 0 && words[last] === '--raw') words.pop();
151
+ else if (last > 0 && words[last] === '--no-vars') take(words.pop(), true, true);
152
+ else if (last > 1 && /^-[ecst]$/.test(words[last - 1])) { const value = words.pop(); take(words.pop(), value, true); } else break;
116
153
  }
117
154
  // An address that is refused (not loopback) is a usage error, said once, not a stack trace.
118
155
  const endpoint = options.endpoint || process.env.PW_ENDPOINT;
@@ -124,9 +161,9 @@ function parseSendArgs(args, allowCommand) {
124
161
  process.exit(64);
125
162
  }
126
163
  }
127
- const words = args.slice(i);
128
- if (!allowCommand && (words.length || options.file)) usage();
129
- if (options.file && words.length) usage();
164
+ if (!allowCommand && options.file) usage(`--file is send's; ${subcommand} runs no commands`, subcommand);
165
+ if (!allowCommand && words.length) usage(`${subcommand} takes no command (got ${shellLine(words)})`, subcommand);
166
+ if (options.file && words.length) usage(`a command or --file, not both (got --file and ${shellLine(words)})`, subcommand);
130
167
  options.command = require('../lib/send').commandLine(words);
131
168
  return options;
132
169
  }
@@ -141,17 +178,35 @@ function startOptions(args, serve) {
141
178
  if (serve) options.background = flag('--background');
142
179
  options.launch = flag('--launch');
143
180
  options.headed = flag('--headed');
144
- if ((options.headed || options.chromeArgs.length) && !options.launch) usage();
181
+ const subcommand = serve ? 'serve' : 'run';
182
+ if (options.headed && !options.launch) usage('--headed is for the Chromium --launch starts; add --launch', subcommand);
183
+ if (options.chromeArgs.length && !options.launch) usage('flags after -- go to the Chromium --launch starts; add --launch', subcommand);
184
+ // Chromium takes any word that is not a --switch as a page to open, and a headless one refuses a second.
185
+ const n = options.chromeArgs.findIndex(word => !word.startsWith('-'));
186
+ if (n !== -1) {
187
+ const [page, before] = [options.chromeArgs[n], options.chromeArgs[n - 1]];
188
+ // Most likely a switch's value given as a word of its own (--user-data-dir /tmp/p); a URL after a
189
+ // switch is a URL (--disable-gpu http://...).
190
+ if (before && /^--[^=]+$/.test(before) && !page.includes('://')) usage(`${page} after -- would go to Chromium as a page to open: it takes a switch's value as ${before}=${page}`, subcommand);
191
+ usage(`${page} after -- would go to Chromium as a page to open; only flags go after --. A start URL goes before --; for a browser already running, leave out --launch and set PW_CDP_URL`, subcommand);
192
+ }
145
193
  // -e <endpoint>, as send, attach, stop and where take it, or the endpoint as the first word.
146
194
  const e = rest.indexOf('-e');
147
195
  if (serve && e !== -1 && rest[e + 1] && looksLikeEndpoint(rest[e + 1])) {
148
196
  options.endpoint = rest.splice(e, 2)[1];
149
197
  // Two endpoints, one with -e: neither silently wins.
150
- if (rest[0] && looksLikeEndpoint(rest[0])) usage();
198
+ if (rest[0] && looksLikeEndpoint(rest[0])) usage(`two endpoints, -e ${options.endpoint} and ${rest[0]}: give one`, subcommand);
151
199
  } else if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
152
200
  // Otherwise the socket send would look for: PW_ENDPOINT, then PW_SOCKET, then the default.
153
201
  if (serve && !options.endpoint) options.endpoint = process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
154
- if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
202
+ const option = rest.find(word => word.startsWith('-'));
203
+ if (option === '-h' || option === '--help') usage();
204
+ if (option === '-e') {
205
+ usage(serve ? `-e takes an endpoint: a socket (/tmp/<name>.sock), a port, or 127.0.0.1:port (got ${rest[rest.indexOf('-e') + 1] ?? 'nothing'})` : 'run has no command server, so no endpoint; pw-repl serve has one', subcommand);
206
+ }
207
+ if (option === '--background') usage('--background is serve\'s: pw-repl serve --background', subcommand);
208
+ if (option) usage(`unknown option ${option}`, subcommand);
209
+ if (rest.length > 1) usage(`one start URL at most (got ${shellLine(rest)})`, subcommand);
155
210
  options.startUrl = rest[0] || null;
156
211
  return options;
157
212
  }
@@ -185,15 +240,15 @@ async function main() {
185
240
  }
186
241
  case 'attach':
187
242
  case 'stop': {
188
- const options = parseSendArgs(args, false);
189
- if (options.session) usage();
243
+ const options = parseSendArgs(args, subcommand);
244
+ if (options.session) usage(`-s is send's tmux session; ${subcommand} takes -e <endpoint>`, subcommand);
190
245
  // The same socket send would use when there is no -e: PW_ENDPOINT, then PW_SOCKET.
191
246
  const endpoint = options.endpoint || process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
192
247
  process.exit(await require('../lib/background')[subcommand]({ endpoint }));
193
248
  }
194
249
  // falls through never: process.exit above
195
250
  case 'send': {
196
- const options = parseSendArgs(args, true);
251
+ const options = parseSendArgs(args, 'send');
197
252
  // help is fixed text, so it needs no browser: answer it here.
198
253
  const asksHelp = /^help(?:\s+(.*))?$/.exec(options.command);
199
254
  if (asksHelp) return help((asksHelp[1] || '').trim());
@@ -201,7 +256,7 @@ async function main() {
201
256
  }
202
257
  // falls through never: process.exit above
203
258
  case 'where':
204
- process.exit(await require('../lib/send').where(parseSendArgs(args, false)));
259
+ process.exit(await require('../lib/send').where(parseSendArgs(args, 'where')));
205
260
  // falls through never
206
261
  case 'help': {
207
262
  const topic = args.join(' ').trim();
@@ -209,7 +264,7 @@ async function main() {
209
264
  return console.log(`${USAGE}\n\n${REPL_HELP}`);
210
265
  }
211
266
  case 'skill':
212
- if (args.length) usage();
267
+ if (args.length) usage(`skill takes no arguments (got ${shellLine(args)})`, 'skill');
213
268
  return skill();
214
269
  case '-v':
215
270
  case '--version':
@@ -217,8 +272,17 @@ async function main() {
217
272
  case '-h':
218
273
  case '--help':
219
274
  return console.log(`${USAGE}\n\n${REPL_HELP}`);
220
- default:
221
- return usage();
275
+ default: {
276
+ // An option before the subcommand (pw-repl -t 30 send ...): say where it goes, with the line fixed.
277
+ if (subcommand.startsWith('-')) {
278
+ const all = [subcommand, ...args];
279
+ const named = all.find(word => SUBCOMMANDS.includes(word));
280
+ const fixed = named ? shellLine(['pw-repl', named, ...all.filter((word, n) => n !== all.indexOf(named))]) : `pw-repl send ${shellLine(all)} <command>`;
281
+ return usage(`${subcommand} is an option of a subcommand, and goes after it: ${fixed}`, named || 'send');
282
+ }
283
+ const url = /^[a-z][\w+.-]*:/i.test(subcommand) && !looksLikeEndpoint(subcommand) ? `; a start URL goes after run or serve: pw-repl run ${subcommand}` : '';
284
+ return usage(`${subcommand} is not a command (they are ${SUBCOMMANDS.join(', ')})${url}`);
285
+ }
222
286
  }
223
287
  }
224
288
 
package/lib/emulation.js CHANGED
@@ -175,9 +175,10 @@ const commands = {
175
175
  out.log(`Viewport off: ${await viewportText()}`);
176
176
  return;
177
177
  }
178
- const match = /^(\d+)x(\d+)$/.exec(arg);
178
+ const match = /^(\d+)x(\d+)$/i.exec(arg);
179
179
  const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
180
- if (!w || !h) throw new Error('Usage: viewport [<width>x<height> | off]');
180
+ if (!match) throw new Error(`A size is <width>x<height>, e.g. viewport 1280x900 (got ${arg}). Usage: viewport [<width>x<height> | off]`);
181
+ if (!w || !h) throw new Error(`A width and a height are 1 or more (got ${arg})`);
181
182
  // Both set the screen size; whichever came last would win without saying so.
182
183
  if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
183
184
  await setViewport(state.page, { width: w, height: h });
package/lib/help.js CHANGED
@@ -610,7 +610,11 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
610
610
  ['off', 'back to the window\'s size.'],
611
611
  ['(none)', 'shows the size, and whether it was set or is the window\'s.'],
612
612
  ],
613
- detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.`,
613
+ detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.
614
+
615
+ The window's size here is the size of the page in it. Chromium's --window-size flag sizes the whole
616
+ window, frame included, so the page is smaller: --window-size=1280,900 gives 1280x757 in a headless one.
617
+ For a page of an exact size, use viewport <W>x<H>.`,
614
618
  },
615
619
  emulate: {
616
620
  usage: 'emulate [<what> [off] | off]',
package/lib/inspect.js CHANGED
@@ -442,7 +442,12 @@ const commands = {
442
442
  const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
443
443
  const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
444
444
  if (tookSeconds) seconds = Number(tokens.pop());
445
- if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
445
+ if (!tokens.length) throw new Error(`${usage} (1-${WAIT_MAX})`);
446
+ if (seconds < 1 || seconds > WAIT_MAX) {
447
+ // Most likely text that ends in a number, not seconds at all.
448
+ const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${seconds}"` : '';
449
+ throw new Error(`wait takes 1-${WAIT_MAX} seconds (got ${seconds})${text}`);
450
+ }
446
451
  const timeout = seconds * 1000;
447
452
  // Said plainly, not as Playwright's call log.
448
453
  const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
package/lib/start.js CHANGED
@@ -148,7 +148,8 @@ async function start(options) {
148
148
  rl.on('close', () => {
149
149
  if (state.stopping) return;
150
150
  endPromptLine();
151
- out.error('Input ended; disconnecting.');
151
+ // With no terminal (an agent's shell, a pipe), input ends at once: say what runs without one.
152
+ out.error(process.stdin.isTTY ? 'Input ended; disconnecting.' : 'Input ended (stdin is not a terminal); disconnecting. pw-repl serve --background runs it without one.');
152
153
  runner.drained().then(() => shutdown(), () => shutdown())
153
154
  .then(beforeExit).then(() => process.exit(process.exitCode || 0));
154
155
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
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
@@ -54,12 +54,12 @@ run one; each takes an optional start URL, which opens in a new tab.
54
54
  - Add `--launch` to `run` or `serve` (with or without `--background`) to have it start a Chromium of its
55
55
  own instead of connecting to one: headless unless `--headed`, in a temporary profile, on a free port
56
56
  of its own (never 9222). It stops with the REPL, Ctrl-C while it starts included, and `stop` returns
57
- once it has exited. It prints the command it ran; flags after `--` are passed to that Chromium, and a
58
- `--user-data-dir=<dir>` among them is used instead of the temporary profile, and kept. Started again
59
- in a kept profile, Chromium picks up where it left off, as it would for the user, headless too: its
60
- tabs come back, and session cookies with them, so a login stays. They start loading before the REPL is
61
- attached, so `requests` and `console` have only what came after, often nothing: `reload` for a full
62
- record.
57
+ once it has exited. It prints the command it ran; flags after `--` are passed to that Chromium (only
58
+ flags: a start URL goes before `--`), and a `--user-data-dir=<dir>` among them is used instead of the
59
+ temporary profile, and kept. Started again in a kept profile, Chromium picks up where it left off, as
60
+ it would for the user, headless too: its tabs come back, and session cookies with them, so a login
61
+ stays. They start loading before the REPL is attached, so `requests` and `console` have only what
62
+ came after, often nothing: `reload` for a full record.
63
63
  - `pw-repl run` runs it in a terminal with no server; `send` then reaches it through tmux, if it runs in
64
64
  the tmux session `pw-repl`:
65
65
 
@@ -112,8 +112,9 @@ of a REPL of your own, which `send` refuses. The words after `send` are the comm
112
112
  `select`, `press`, `upload` and `wait`, a word quoted in your shell stays one word
113
113
  (`pw-repl send fill "text=Your name" Ada`, `pw-repl send wait text "Showing 1"`); other commands get the words as they are, joined by
114
114
  spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the
115
- whole line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was
116
- started:
115
+ whole line, spaces and all: `pw-repl send click "text=Your name"`. `send`'s own options (`-t`, `-c`, `-e`,
116
+ `-s`, `--no-vars`) go before the command or after it (`pw-repl send open <url> -t 30`); after `--`, every
117
+ word is the command's. It works however the REPL was started:
117
118
 
118
119
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
119
120
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle