pw-repl 0.8.2 → 0.9.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
@@ -41,9 +41,14 @@ If you hit a limitation or write a workaround, consider adding the capability to
41
41
  command or behaviour. In `test/repl.test.js` each test starts in a fresh tab of its own, and every mode
42
42
  is turned off after it; a test whose REPL quits, times out or is killed goes in
43
43
  `test/repl-alone.test.js`, with a Chromium of its own.
44
- - Help entries (`lib/help.js`) are plain paragraphs: `wrap()` lays each out at 104 columns, and keeps as
45
- written a paragraph with a bullet, a table or an indented example. `test/fixtures` holds the rendered
46
- help; after an intended change to it, save it again (the command is in `test/units.test.js`).
44
+ - A command's help (`lib/help.js`) follows playwright-cli's layout: `usage` and `summary`; `args` and
45
+ `options`, one `[name, text]` each, with the default in its text (`Default: 750; 0 none.`); `detail`,
46
+ short paragraphs on what most callers need, a paragraph on one theme starting with a label (`Steps
47
+ (--steps):`); and `more`, with `moreAbout` naming what is in it, for what only goes wrong now and then,
48
+ shown by `help <command> --all` and `help --all`. An error about something in `more` ends by naming it
49
+ (`help record --all`). Paragraphs are plain: `wrap()` lays each out at 104 columns, and keeps as written
50
+ a paragraph with a bullet, a table or an indented example. `test/fixtures` holds the rendered help;
51
+ after an intended change to it, save it again (the command is in `test/units.test.js`).
47
52
  - Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`).
48
53
  Compare with the last commit that has one (`git log --grep='npm test:'`): a run that grew by more than
49
54
  a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
package/README.md CHANGED
@@ -78,8 +78,8 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
78
78
  - For `send` to reach `run`, start it in tmux: `tmux new -s playwright-repl`, then `pw-repl run` there.
79
79
  - A URL after the command opens in a new tab: `pw-repl run http://localhost:3000`.
80
80
  - `--launch` works with each of them; flags after `--` go to that Chromium.
81
- - `serve` takes a socket of its own (`pw-repl serve --background /tmp/mine.sock`; then `-e /tmp/mine.sock`
82
- for `send`, `attach`, `stop` and `where`), or `serve <port>` for TCP on 127.0.0.1.
81
+ - A socket of its own: `-e /tmp/mine.sock` on every command, `serve` included (`pw-repl serve --background
82
+ -e /tmp/mine.sock`), or `PW_SOCKET`; `serve <port>` listens on TCP on 127.0.0.1.
83
83
  - A background REPL cannot be brought back like a Ctrl-Z job; `attach` is how you get back to it, from
84
84
  any terminal.
85
85
 
@@ -98,7 +98,7 @@ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
98
98
  | show an agent what I do | `watch on`, click around in the browser, then `watch` |
99
99
  | see each step as I click | `watch on --live` |
100
100
  | record requests and console together | `capture on`, then `capture off` |
101
- | record a video of the page | `record on`, then `record off` (`help video` for the other ways) |
101
+ | record a video of the page | `record on`, then `record off` (`help video` for a take with a pointer) |
102
102
  | show a pointer where the agent acts | `cursor on`, then `cursor off` (in screenshots and videos) |
103
103
  | point something out on the page | `highlight <ref>` (`--style=<css>` to restyle it), then `highlight off` |
104
104
  | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
package/bin/pw-repl.js CHANGED
@@ -7,7 +7,7 @@ const USAGE = `Usage:
7
7
  pw-repl run [--launch [--headed]] [start-url] [-- <chromium flags>]
8
8
  connect to the browser and open the prompt
9
9
 
10
- pw-repl serve [--background] [--launch [--headed]] [endpoint] [start-url] [-- <chromium flags>]
10
+ pw-repl serve [--background] [--launch [--headed]] [[-e] endpoint] [start-url] [-- <chromium flags>]
11
11
  the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock);
12
12
  --background runs it detached, with its output in a log next to the socket
13
13
 
@@ -17,8 +17,8 @@ const USAGE = `Usage:
17
17
  pw-repl stop [-e endpoint]
18
18
  stop a background REPL, and say what became of each recording it ended
19
19
 
20
- pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
21
- run one command in a running REPL and print its output
20
+ pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...> | --file <file>
21
+ run one command in a running REPL and print its output; --file runs a file of them, one per line
22
22
 
23
23
  pw-repl where [-e endpoint | -s session]
24
24
  say which REPL send would reach
@@ -31,13 +31,20 @@ const USAGE = `Usage:
31
31
 
32
32
  send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/playwright-repl.sock)
33
33
  is there, and the tmux session (-s, $PW_TMUX_SESSION, default playwright-repl) otherwise. If the socket
34
- exists but nothing answers, send fails rather than fall back.
34
+ exists but nothing answers, send fails rather than fall back. serve without an endpoint serves on that
35
+ same one: $PW_ENDPOINT, then $PW_SOCKET, then the default.
35
36
 
36
37
  send -c <client> (or $PW_CLIENT) sends as a client of the REPL's with a selected tab of its own, so
37
38
  several agents can share one REPL; pw-repl help session has the rest.
38
39
 
39
40
  send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
40
41
 
42
+ send --file <file> runs each line of a file as a command, in order, as if typed at the REPL's prompt:
43
+ quoted as the prompt reads it (no shell), with a file it records, uploads or saves relative to this
44
+ folder, as on send's command line, and # comments and blank lines skipped. It stops at the first
45
+ command that fails, saying its line, with its exit status. -t is each command's own limit. Other
46
+ clients' commands can run between its lines, on their own tabs.
47
+
41
48
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
42
49
 
43
50
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
@@ -50,8 +57,8 @@ In a tmux session named playwright-repl, pw-repl send reaches pw-repl run withou
50
57
 
51
58
  const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
52
59
 
53
- pw-repl help <topic | command | --all> shows one part of it; pw-repl help video and pw-repl help
54
- playwright-cli are guides.`;
60
+ pw-repl help <topic | command [--all] | --all> shows one part of it; pw-repl help video and pw-repl
61
+ help playwright-cli are guides.`;
55
62
 
56
63
  function usage() {
57
64
  console.error(USAGE);
@@ -60,7 +67,7 @@ function usage() {
60
67
 
61
68
  // -e, -s, -c and -t, then the command words (unquoted words are one command).
62
69
  function parseSendArgs(args, allowCommand) {
63
- const options = { endpoint: null, session: null, client: null, timeout: 20, command: '' };
70
+ const options = { endpoint: null, session: null, client: null, timeout: 20, command: '', file: null };
64
71
  let i = 0;
65
72
  for (; i < args.length; i++) {
66
73
  const arg = args[i];
@@ -73,6 +80,13 @@ function parseSendArgs(args, allowCommand) {
73
80
  console.error('browser it shares, cookies, storage and modes and all.');
74
81
  process.exit(64);
75
82
  }
83
+ // A file of commands, one per line, as typed at the prompt.
84
+ if (arg.startsWith('--file=') && arg.length > 7) { options.file = require('path').resolve(arg.slice(7)); continue; }
85
+ if (arg === '--file') {
86
+ if (args[i + 1] === undefined) usage();
87
+ options.file = require('path').resolve(args[++i]);
88
+ continue;
89
+ }
76
90
  if (arg === '-e' || arg === '-s' || arg === '-c' || arg === '-t') {
77
91
  const value = args[++i];
78
92
  if (value === undefined) usage();
@@ -96,39 +110,9 @@ function parseSendArgs(args, allowCommand) {
96
110
  }
97
111
  }
98
112
  const words = args.slice(i);
99
- if (!allowCommand && words.length) usage();
100
- // For a command that reads a quoted selector (fill "text=Your name" Ada), a
101
- // word the shell kept whole is quoted again so it stays one word. Any other
102
- // command takes the rest of its line as it is (eval, route's JSON), so its
103
- // words are joined as they are.
104
- const { SELECTOR_FIRST } = require('../lib/syntax');
105
- // So does playwright-cli's storage key (localstorage-set "my key" v).
106
- // And record's file, which may have spaces in it.
107
- const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
108
- // An empty word (fill #name "") is the empty value.
109
- // upload's files are read by the REPL, whose folder may not be this one.
110
- // So are the files upload reads and screenshot --filename writes.
111
- const resolve = require('path').resolve;
112
- let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
113
- // record on <file> writes it too.
114
- if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
115
- resolved = words.map((w, n) => {
116
- if (n < (words[0] === 'record' ? 2 : 1)) return w;
117
- if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
118
- return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
119
- });
120
- }
121
- if (words[0] === 'screenshot') {
122
- resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
123
- }
124
- const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
125
- // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
126
- // Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
127
- for (const [n, w] of quoted.entries()) {
128
- const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
129
- if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
130
- }
131
- options.command = quoted.join(' ').trim();
113
+ if (!allowCommand && (words.length || options.file)) usage();
114
+ if (options.file && words.length) usage();
115
+ options.command = require('../lib/send').commandLine(words);
132
116
  return options;
133
117
  }
134
118
 
@@ -143,7 +127,15 @@ function startOptions(args, serve) {
143
127
  options.launch = flag('--launch');
144
128
  options.headed = flag('--headed');
145
129
  if ((options.headed || options.chromeArgs.length) && !options.launch) usage();
146
- if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
130
+ // -e <endpoint>, as send, attach, stop and where take it, or the endpoint as the first word.
131
+ const e = rest.indexOf('-e');
132
+ if (serve && e !== -1 && rest[e + 1] && looksLikeEndpoint(rest[e + 1])) {
133
+ options.endpoint = rest.splice(e, 2)[1];
134
+ // Two endpoints, one with -e: neither silently wins.
135
+ if (rest[0] && looksLikeEndpoint(rest[0])) usage();
136
+ } else if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
137
+ // Otherwise the socket send would look for: PW_ENDPOINT, then PW_SOCKET, then the default.
138
+ if (serve && !options.endpoint) options.endpoint = process.env.PW_ENDPOINT || process.env.PW_SOCKET || null;
147
139
  if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
148
140
  options.startUrl = rest[0] || null;
149
141
  return options;
package/lib/cli-names.js CHANGED
@@ -91,9 +91,10 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
91
91
  NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
92
92
  }
93
93
  for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
94
- for (const name of ['video-chapter', 'video-show-actions', 'video-hide-actions']) {
95
- NEAREST[name] = 'not here: record on|off records a video, without chapters or action labels';
96
- }
94
+ NEAREST['video-chapter'] = 'not here: record on|off records a video, without chapters';
95
+ // Theirs labels each action and pauses on it; the cursor is a pointer that moves to each action instead.
96
+ NEAREST['video-show-actions'] = 'not here; cursor on draws a pointer that moves to each action, without labels';
97
+ NEAREST['video-hide-actions'] = 'not here; cursor off hides the pointer cursor on draws';
97
98
  for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
98
99
 
99
100
  const NAME_LIST = Object.keys(NAMES);
package/lib/client.js CHANGED
@@ -69,6 +69,9 @@ function request(endpoint, command, timeoutMs, client = null) {
69
69
  ...target(endpoint),
70
70
  path: '/run',
71
71
  method: 'POST',
72
+ // A connection of its own each time: on one kept alive from an earlier command (send --file),
73
+ // 'connect' never fires, and a drop would be taken for a command never sent.
74
+ agent: false,
72
75
  headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
73
76
  }, res => {
74
77
  let text = '';
package/lib/cursor.js CHANGED
@@ -9,7 +9,7 @@ const { unquote, toSelector } = require('./syntax');
9
9
  const { recordingOf, stepTarget, cursorStep } = require('./record');
10
10
 
11
11
  // Scaled with the distance, so a short hop is quick and a long one does not drag. Slower while the tab
12
- // records, so a video (and a zoom that follows the cursor) can be followed.
12
+ // records, so a viewer can follow it.
13
13
  const DWELL_MS = 300;
14
14
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
15
 
@@ -80,8 +80,8 @@ function pageCursor({ x, y, action, ms, count, fade, box, items, index }) {
80
80
  cursor.y = y;
81
81
  place(cursor.arrow, x, y);
82
82
  if (!ms) return;
83
- // Smoothstep, exactly (x = t with these control points, and y = 3t² - 2t³): the easing a zoom that
84
- // follows the cursor uses, so it knows where the cursor is on every frame.
83
+ // Smoothstep, exactly (x = t with these control points, and y = 3t² - 2t³), so a tool editing the
84
+ // video can work out from the steps file where the cursor is on every frame.
85
85
  const glide = cursor.arrow.animate([{ transform: from }, { transform: to }], { duration: ms, easing: 'cubic-bezier(0.3333, 0, 0.6667, 1)' });
86
86
  // A hidden tab draws no frames, and its animation may never finish: not waited on past its time.
87
87
  return Promise.race([glide.finished, new Promise(resolve => setTimeout(resolve, ms + 300))]).then(() => {}, () => {});
@@ -158,19 +158,52 @@ async function glideTo(p, x, y) {
158
158
  const cursor = cursorOf(p);
159
159
  if (!cursor) return;
160
160
  const ms = glideTime(Math.hypot(x - cursor.x, y - cursor.y), !!recordingOf(p));
161
- // A recording notes where the pointer went and when, for a zoom to follow it.
161
+ // A recording with --steps notes where the pointer went and when, for whoever edits the video.
162
162
  if (ms) cursorStep(p, `(cursor from ${cursor.x} ${cursor.y})`, { x, y }, ms);
163
163
  cursor.x = x;
164
164
  cursor.y = y;
165
165
  await p.evaluate(pageCursor, { x, y, action: 'move', ms }).catch(() => {});
166
166
  }
167
167
 
168
- // Whether a command should find its element first: to glide the cursor there, or to note it in a
169
- // recording's steps.
168
+ // Whether a command should find its element first: to glide the cursor there, or, while recording, to
169
+ // scroll to it smoothly and note it in the steps (record on --steps).
170
170
  function aiming(p) {
171
171
  return !!(cursorOf(p) || recordingOf(p));
172
172
  }
173
173
 
174
+ // While recording, an element out of view is scrolled to smoothly, to the middle, rather than jumped to
175
+ // as Playwright's own scroll would. The page scrolls itself, so no input is sent and a scrollable box
176
+ // it is in scrolls too. One larger than the view is scrolled to only if its centre, where Playwright
177
+ // acts, is out of view. Done when it has held still for a few frames: a nested box ends its scroll on
178
+ // its own, and nothing ends one that never started.
179
+ async function scrollSmoothly(target) {
180
+ // Chrome slows a background tab's timers too, so the REPL bounds the wait as well.
181
+ await Promise.race([sleep(2000), target.evaluate(async (el, cap) => {
182
+ const r = el.getBoundingClientRect();
183
+ const cx = r.left + r.width / 2;
184
+ const cy = r.top + r.height / 2;
185
+ const inView = r.width > innerWidth || r.height > innerHeight
186
+ ? cx >= 0 && cy >= 0 && cx <= innerWidth && cy <= innerHeight
187
+ : r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight;
188
+ if (inView) return;
189
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
190
+ // A tab that is not drawing (behind another, minimised) has no frames: the timer keeps the cap.
191
+ const frame = () => new Promise(resolve => { requestAnimationFrame(resolve); setTimeout(resolve, 100); });
192
+ const started = performance.now();
193
+ const from = `${r.left},${r.top}`;
194
+ let last = from;
195
+ let still = 0;
196
+ // A smooth scroll can take a frame or two to start: longer to wait while nothing has moved yet.
197
+ while (performance.now() - started < cap && still < (last === from ? 10 : 3)) {
198
+ await frame();
199
+ const b = el.getBoundingClientRect();
200
+ const at = `${b.left},${b.top}`;
201
+ still = at === last ? still + 1 : 0;
202
+ last = at;
203
+ }
204
+ }, 1500).catch(() => {})]);
205
+ }
206
+
174
207
  // Finds the centre of an element (a locator or a handle), where Playwright clicks, after scrolling it
175
208
  // into view as Playwright would, and glides the cursor there if it is on: the pointer is there before
176
209
  // the click, not after. A recording notes the element in its steps.
@@ -182,6 +215,7 @@ async function aimAt(p, target, timeout) {
182
215
  if (target.waitFor) await target.waitFor({ state: 'attached', timeout });
183
216
  try {
184
217
  if (!await target.isVisible()) return null;
218
+ if (recordingOf(p)) await scrollSmoothly(target);
185
219
  await target.scrollIntoViewIfNeeded({ timeout: 2000 });
186
220
  const box = await target.boundingBox({ timeout: 1000 });
187
221
  if (!box) return null;
@@ -270,10 +304,11 @@ const commands = {
270
304
  const usage = 'Usage: cursor [on [<selector> | <x> <y>] | off]';
271
305
  const on = /^on(?:\s+([\s\S]+))?$/.exec(arg);
272
306
  if (!on) throw new Error(usage);
273
- if (cursor) { out.log(`The cursor is already on in the selected tab, at ${cursor.x}, ${cursor.y}; mousemove or a command on an element moves it`); return; }
274
307
  // Where it is asked to appear (a video's first element, say), or where the mouse last went, if the
275
308
  // REPL knows; otherwise the middle of the viewport.
276
309
  const where = (on[1] || '').trim();
310
+ if (cursor && !where) { out.log(`The cursor is already on in the selected tab, at ${cursor.x}, ${cursor.y}; cursor on <selector> or <x> <y> moves it`); return; }
311
+ const still = cursor ? 'the cursor stays where it was' : 'the cursor is still off';
277
312
  let at;
278
313
  if (/^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?$/.test(where)) {
279
314
  const [x, y] = where.split(/\s+/).map(n => Math.round(Number(n)));
@@ -283,14 +318,20 @@ const commands = {
283
318
  try {
284
319
  await target.scrollIntoViewIfNeeded({ timeout: 5000 });
285
320
  } catch {
286
- throw new Error(`No element matches ${where} that can be shown (waited 5s); the cursor is still off`);
321
+ throw new Error(`No element matches ${where} that can be shown (waited 5s); ${still}`);
287
322
  }
288
323
  const box = await target.boundingBox();
289
- if (!box) throw new Error(`${where} is not on the page to show the cursor at; the cursor is still off`);
324
+ if (!box) throw new Error(`${where} is not on the page to show the cursor at; ${still}`);
290
325
  at = { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
291
326
  } else {
292
327
  at = tabState(p).mouseAt || await p.evaluate(() => ({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) }));
293
328
  }
329
+ // Already on: the drawing glides there, and the real mouse stays put, as with fill.
330
+ if (cursor) {
331
+ await glideTo(p, at.x, at.y);
332
+ out.log(`The cursor moved to ${at.x}, ${at.y}`);
333
+ return;
334
+ }
294
335
  await p.evaluate(pageCursor, { x: at.x, y: at.y, action: 'draw', fade: true });
295
336
  tabState(p).cursor = { owner: state.client, x: at.x, y: at.y };
296
337
  cursorStep(p, '(cursor on)', at, 0);
package/lib/elements.js CHANGED
@@ -66,8 +66,9 @@ function selectorAndValue(args, usage, example) {
66
66
  // An element that never appeared is said plainly, not as Playwright's call
67
67
  // log, and so is one that timed out: its call log (dozens of lines of retries) goes
68
68
  // to the REPL's pane and log only. A ref that no longer matches usually means the page
69
- // changed since the snapshot it came from.
70
- async function onElement(selector, action) {
69
+ // changed since the snapshot it came from. It says the command's whole budget: Playwright's own
70
+ // timeout is what was left of it for the last call, after a glide say, and can read 4999ms for 5000.
71
+ async function onElement(selector, action, budget = 5000) {
71
72
  try {
72
73
  return await action();
73
74
  } catch (error) {
@@ -77,12 +78,12 @@ async function onElement(selector, action) {
77
78
  const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
78
79
  const timeout = /Timeout (\d+)ms exceeded/.exec(message);
79
80
  if (timeout && /waiting for locator/.test(message) && !/resolved to/.test(message)) {
80
- throw new Error(`No element matches ${ref || selector} (waited ${timeout[1] / 1000}s)${staleRef}`);
81
+ throw new Error(`No element matches ${ref || selector} (waited ${budget / 1000}s)${staleRef}`);
81
82
  }
82
83
  if (!timeout) { error.message += staleRef; throw error; }
83
84
  console.error(`Playwright's call log:\n${message}`);
84
85
  // Found, so the ref is not stale: no hint.
85
- const summary = new Error(`Timed out after ${timeout[1] / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
86
+ const summary = new Error(`Timed out after ${budget / 1000}s on ${ref || selector}: ${lastBlocker(message)} (its call log is in the REPL's pane or log)`);
86
87
  // Worked out from the whole log: the summary no longer says whether the element was found.
87
88
  summary.uncertain = !/waiting for locator/.test(message) || /resolved to/.test(message);
88
89
  throw summary;