pw-repl 0.7.0 → 0.7.2

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/bin/pw-repl.js CHANGED
@@ -15,7 +15,7 @@ const USAGE = `Usage:
15
15
  see everything a background REPL does, and type commands to it; Ctrl-C leaves it running
16
16
 
17
17
  pw-repl stop [-e endpoint]
18
- stop a background REPL
18
+ stop a background REPL, and say what became of each recording it ended
19
19
 
20
20
  pw-repl send [-e endpoint | -s session] [-c client] [-t seconds] <command...>
21
21
  run one command in a running REPL and print its output
@@ -41,10 +41,10 @@ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or
41
41
  The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).
42
42
 
43
43
  --launch is the quick start instead: it starts a Chromium of the REPL's own (headless unless --headed, in
44
- a temporary profile, on a free port of its own), prints the command it ran, and stops it with the REPL
45
- (Ctrl-C while it starts too); stop returns once that Chromium has exited. Flags after -- go to it; a
46
- --user-data-dir=<dir> among them is used instead of the temporary profile, and kept. PW_CHROME picks
47
- which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
44
+ a temporary profile, on a free port of its own, which pw-repl where names), prints the command it ran,
45
+ and stops it with the REPL (Ctrl-C while it starts too); stop returns once that Chromium has exited.
46
+ Flags after -- go to it; a --user-data-dir=<dir> among them is used instead of the temporary profile, and
47
+ kept. PW_CHROME picks which one. To set a browser up your own way, start it yourself and use PW_CDP_URL.
48
48
 
49
49
  In a tmux session named playwright-repl, pw-repl send reaches pw-repl run without the server.`;
50
50
 
@@ -103,7 +103,8 @@ function parseSendArgs(args, allowCommand) {
103
103
  // words are joined as they are.
104
104
  const { SELECTOR_FIRST } = require('../lib/syntax');
105
105
  // So does playwright-cli's storage key (localstorage-set "my key" v).
106
- const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]));
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]));
107
108
  // An empty word (fill #name "") is the empty value.
108
109
  // upload's files are read by the REPL, whose folder may not be this one.
109
110
  // So are the files upload reads and screenshot --filename writes.
package/lib/background.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const { spawn } = require('child_process');
7
7
  const client = require('./client');
8
+ const { STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
8
9
 
9
10
  const BIN = path.join(__dirname, '..', 'bin', 'pw-repl.js');
10
11
  const START_TIMEOUT = 20000;
@@ -132,12 +133,26 @@ async function stop(options) {
132
133
  const { pidFile } = filesFor(endpoint);
133
134
  const pid = runningPid(pidFile);
134
135
  if (!pid) return fail(`no background REPL is serving on ${client.describe(endpoint)}`);
136
+ // What it logs as it stops, from here on, holds what became of each recording it ended.
137
+ const { logFile } = filesFor(endpoint);
138
+ let logFrom = 0;
139
+ try { logFrom = fs.statSync(logFile).size; } catch {}
135
140
  // Like Ctrl-C: a command still running is answered, and the socket is removed.
136
141
  process.kill(pid, 'SIGTERM');
137
142
  const deadline = Date.now() + STOP_TIMEOUT;
138
143
  while (alive(pid) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
139
144
  if (alive(pid)) return fail(`the background REPL (pid ${pid}) has not stopped after ${STOP_TIMEOUT / 1000}s`);
140
- console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${filesFor(endpoint).logFile}`);
145
+ console.log(`Stopped the background REPL (pid ${pid}) on ${client.describe(endpoint)}; its log stays at ${logFile}`);
146
+ // The REPL is gone, so record can no longer say where a recording it ended went, or that it was lost.
147
+ let logged = '';
148
+ try {
149
+ const fd = fs.openSync(logFile, 'r');
150
+ const buffer = Buffer.alloc(Math.max(0, fs.fstatSync(fd).size - logFrom));
151
+ fs.readSync(fd, buffer, 0, buffer.length, logFrom);
152
+ fs.closeSync(fd);
153
+ logged = buffer.toString('utf8');
154
+ } catch {}
155
+ for (const line of logged.split('\n')) if (line.startsWith(STOPPED_RECORDING) || line.startsWith(ENDED_UNSEEN)) console.log(line);
141
156
  return 0;
142
157
  }
143
158
 
package/lib/cli-names.js CHANGED
@@ -69,7 +69,7 @@ const NEAREST = {
69
69
  list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
70
70
  attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
71
71
  detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
72
- drag: 'not here yet', drop: 'not here yet', highlight: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
72
+ drag: 'not here yet', drop: 'not here yet', 'webmcp-list': 'not here yet', 'webmcp-call': 'not here yet',
73
73
  keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
74
74
  pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
75
75
  'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
package/lib/dialogs.js CHANGED
@@ -5,11 +5,17 @@ const { OUTPUT_LIMIT } = out;
5
5
  const { unquote } = require('./syntax');
6
6
  const { hints } = require('./util');
7
7
  const { tabState } = require('./tabstate');
8
+ const { keptSession } = require('./cdp');
8
9
 
9
10
  // page -> its open dialog. The page, and every command that reads it, waits
10
11
  // until the dialog is answered, in the browser or with the dialog command.
11
12
  const openDialogs = new Map();
12
13
 
14
+ // cdp's session is new, and Chrome lets only one that saw a dialog open answer it: it would wait out its
15
+ // timeout. Refused ahead of the queue too, which the dialog it means holds up.
16
+ const CDP_ANSWER = /^cdp(?:\s+--all)?\s+Page\.handleJavaScriptDialog(?:\s|$)/;
17
+ const CDP_REFUSED = 'cdp cannot answer a dialog, which Chrome lets only a session that saw it open do; dialog accept or dialog dismiss answers it';
18
+
13
19
  function ensureDialogHandler(p) {
14
20
  if ('dialogHandled' in tabState(p)) return;
15
21
  tabState(p).dialogHandled = true;
@@ -19,6 +25,12 @@ function ensureDialogHandler(p) {
19
25
  out.notice('It waits to be answered in the browser, or with dialog accept [text] | dialog dismiss; until then the page, and commands that read it, wait too.', p);
20
26
  });
21
27
  p.on('close', () => openDialogs.delete(p));
28
+ // One answered in the browser is gone too, which Playwright does not report. The tab's own session
29
+ // shares Playwright's connection, so a dialog's closing comes in before the next one opening.
30
+ keptSession(p).then(async session => {
31
+ session.on('Page.javascriptDialogClosed', () => openDialogs.delete(p));
32
+ await session.send('Page.enable');
33
+ }).catch(() => {});
22
34
  }
23
35
 
24
36
  // Runs ahead of the command queue (see runner.js), so it still works while
@@ -30,7 +42,9 @@ async function dialogCommand(args, selected = state.page) {
30
42
  const describe = (p, d) => `[${pages.indexOf(p)}] ${p.url()}: ${d.type()} ${JSON.stringify(String(d.message()).slice(0, 200))}`;
31
43
  if (!action) {
32
44
  if (!openDialogs.size) return 'No dialog is open.';
33
- return [...[...openDialogs].map(([p, d]) => describe(p, d)), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
45
+ // With several, the one accept and dismiss answer is marked, as tab marks the selected tab.
46
+ const mark = p => (openDialogs.size > 1 ? (p === selected ? '* ' : ' ') : '');
47
+ return [...[...openDialogs].map(([p, d]) => `${mark(p)}${describe(p, d)}`), hints([['dialog accept [text]', 'accept it (text answers a prompt)'], ['dialog dismiss', 'dismiss it']])].join('\n');
34
48
  }
35
49
  if (action !== 'accept' && action !== 'dismiss') throw new Error('Usage: dialog [accept [text] | dismiss]');
36
50
  // The selected tab's dialog, or the only one open.
@@ -40,7 +54,8 @@ async function dialogCommand(args, selected = state.page) {
40
54
  openDialogs.delete(page);
41
55
  const line = describe(page, dialog);
42
56
  try {
43
- if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : undefined);
57
+ // Without text, as OK in the browser: a prompt's default stays its answer.
58
+ if (action === 'accept') await dialog.accept(rest.length ? unquote(rest.join(' ')) : dialog.defaultValue());
44
59
  else await dialog.dismiss();
45
60
  } catch (error) {
46
61
  // Answered in the browser meanwhile.
@@ -55,4 +70,4 @@ const commands = {
55
70
  },
56
71
  };
57
72
 
58
- module.exports = { commands, ensureDialogHandler, dialogCommand };
73
+ module.exports = { commands, ensureDialogHandler, dialogCommand, CDP_ANSWER, CDP_REFUSED };
package/lib/elements.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // Elements and the mouse: finding the match to act on, and the commands that click, type and choose.
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
- const { state } = require('./state');
4
+ const { state, onShutdown } = require('./state');
5
5
  const out = require('./output');
6
6
  const { takeOptions } = require('./cli-names');
7
7
  const { unquote, toSelector, keyName, splitSelector, REF } = require('./syntax');
8
- const { numbers } = require('./util');
8
+ const { numbers, hints } = require('./util');
9
+ const { tabState } = require('./tabstate');
9
10
 
10
11
  // The type a page sees for a file it is given, from its extension.
11
12
  const MIME_TYPES = {
@@ -198,6 +199,34 @@ async function pointerAction(selector, act, options = {}) {
198
199
  }
199
200
  }
200
201
 
202
+ // Highlights: Playwright's own overlay, as playwright-cli's highlight draws it. Each is a mode of its
203
+ // tab, so modes shows it and modes off hides it, and none is left on the page when the REPL stops.
204
+ function highlightsOf(p) {
205
+ if (!tabState(p).highlights) {
206
+ tabState(p).highlights = new Map();
207
+ // A new document drops the overlay; a page that routes on the client keeps it.
208
+ p.on('domcontentloaded', () => tabState(p).highlights?.clear());
209
+ }
210
+ return tabState(p).highlights;
211
+ }
212
+
213
+ // Hides a tab's highlights, or only the running client's (mine).
214
+ async function hideHighlights(p, mine = false) {
215
+ const highlights = tabState(p).highlights;
216
+ if (!highlights?.size) return 0;
217
+ const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
218
+ if (hidden.length === highlights.size) await p.hideHighlight();
219
+ else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
220
+ for (const [selector] of hidden) highlights.delete(selector);
221
+ return hidden.length;
222
+ }
223
+
224
+ onShutdown(async () => {
225
+ if (!state.browser || state.connectionLost) return;
226
+ const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
227
+ await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
228
+ });
229
+
201
230
  const commands = {
202
231
  async click(args) {
203
232
  const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
@@ -217,6 +246,42 @@ const commands = {
217
246
  out.log(`Hovered: ${args.trim()}${which}`);
218
247
  },
219
248
 
249
+ async highlight(args) {
250
+ const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
251
+ const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
252
+ const style = found.find(o => o.name === 'style');
253
+ const hide = found.some(o => o.name === 'hide');
254
+ const highlights = highlightsOf(state.page);
255
+ if (!rest && !found.length) {
256
+ if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
257
+ for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
258
+ out.log(hints([['highlight --hide [<selector>]', 'hide one, or all']]));
259
+ return;
260
+ }
261
+ if (rest === 'off' && !found.length || hide && !rest) {
262
+ const count = await hideHighlights(state.page);
263
+ out.log(count ? `Hid ${count} highlight${count === 1 ? '' : 's'}` : 'Nothing was highlighted in the selected tab.');
264
+ return;
265
+ }
266
+ if (!rest || style && (hide || !style.value)) throw new Error(usage);
267
+ const selector = soleSelector(rest, usage);
268
+ const locator = state.page.locator(selector);
269
+ if (hide) {
270
+ if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
271
+ await locator.hideHighlight();
272
+ highlights.delete(selector);
273
+ out.log(`Hid the highlight on ${rest}`);
274
+ return;
275
+ }
276
+ const count = await locator.count();
277
+ if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
278
+ // Drawn again, with its new style, rather than twice.
279
+ if (highlights.has(selector)) await locator.hideHighlight();
280
+ await locator.highlight(style ? { style: style.value } : {});
281
+ highlights.set(selector, { owner: state.client, style: style?.value || null });
282
+ out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
283
+ },
284
+
220
285
  // At a point, in CSS pixels from the top left of the tab's viewport, as playwright-cli's are.
221
286
  async mousemove(args) {
222
287
  const [x, y, extra] = numbers(args, 'mousemove <x> <y>');
@@ -357,4 +422,4 @@ const commands = {
357
422
  },
358
423
  };
359
424
 
360
- module.exports = { commands, soleSelector, onElement };
425
+ module.exports = { commands, soleSelector, onElement, hideHighlights };
package/lib/help.js CHANGED
@@ -74,7 +74,7 @@ fill for selectors with spaces. mousemove and the other mouse commands act at a
74
74
  },
75
75
  inspect: {
76
76
  intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
77
- commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
77
+ commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'highlight', 'record', 'viewport', 'emulate', 'wait', 'sleep'],
78
78
  },
79
79
  network: {
80
80
  intro: `requests and console record all the time; a capture records only while it runs.`,
@@ -105,7 +105,7 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
105
105
 
106
106
  Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
107
107
 
108
- Dialogs are reported and never answered on their own; dialog answers one.`,
108
+ Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.`,
109
109
  commands: ['modes', 'dialog', 'help', 'quit'],
110
110
  },
111
111
  };
@@ -287,6 +287,27 @@ says how many.`,
287
287
  visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
288
288
  links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
289
289
  inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
290
+ highlight: {
291
+ usage: 'highlight [<selector> [--style=<css>] | off]',
292
+ summary: 'draw a box over elements, for a person or a screenshot',
293
+ detail: `highlight <selector> draws Playwright's highlight over every match, as playwright-cli's does: a
294
+ tinted box, labelled with its locator, that follows the element as the page scrolls or changes, and is
295
+ in screenshots. --style=<css> adds CSS to the box, quoted if it has spaces; this circles e5 in blue:
296
+ highlight e5 --style="outline: 3px solid blue; border-radius: 50%"
297
+
298
+ highlight on its own lists the selected tab's highlights. highlight --hide <selector> hides one, and
299
+ highlight --hide or highlight off hides them all. A highlight is a mode of its tab: modes shows it,
300
+ modes off hides it, and the REPL hides every one as it stops. Loading a new page drops them; a page that
301
+ changes its route without loading keeps them.
302
+
303
+ The box is drawn over the page, in an element of Playwright's own added to it (x-pw-glass); the page's
304
+ own elements are not changed, and clicks and typing reach them as before. Playwright always labels the
305
+ box, and the label can cover what is just below it. For a screenshot without one, eval can outline
306
+ the element itself (with border-radius: 50% for a circle). That changes the page, so put it back
307
+ afterwards, as it was if it had a style of its own:
308
+ eval document.querySelector("#rate-3").style.outline = "3px solid blue"
309
+ eval document.querySelector("#rate-3").style.outline = ""`,
310
+ },
290
311
  screenshot: {
291
312
  usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
292
313
  summary: 'save a PNG of the viewport, --full page or an element',
@@ -308,25 +329,24 @@ phone shows: a page laid out wider than the phone (no viewport meta tag) comes o
308
329
  detail: `record on starts recording the selected tab, and record off stops it and saves the file: its
309
330
  path, length, size and bytes. record on its own, or record off when nothing is recording, says whether the
310
331
  selected tab is recording, and how your last recording ended (record off, its seconds, its tab closing
311
- or the REPL stopping) with what record off would have said.
332
+ or the REPL stopping) with what record off would have said. tab close says what became of a recording it ended. A REPL stopped with pw-repl stop cannot be asked: stop prints what became of each recording it ended, and of one that ended by itself that record has not shown since.
312
333
 
313
- record off <file> saves it under that name instead. --filename=<file> is file too, as playwright-cli
334
+ record off <file> saves it under that name instead. The format is set by record on: a name that does not end as its file does, or cannot be used, is refused, and it goes on recording. --filename=<file> is file too, as playwright-cli
314
335
  writes it.
315
336
 
316
- The file is a .webm, or an .mp4 if its name ends so, absolute or relative to the folder pw-repl send runs in, and
337
+ The file's ending picks the format: record on clip.mp4 records an MP4, and record on clip.webm, or record on with no file, a WebM (there is no --mp4 or --webm). The file is absolute or relative to the folder pw-repl send runs in, quoted if it has spaces, and
317
338
  owner-only, as a screenshot is; it never replaces a file. Without one, it is recording-<timestamp>.webm where screenshot saves. With seconds,
318
- it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab.
339
+ it stops by itself after them (at most 3600, which is also the default); commands run meanwhile. One recording per tab. Another client with the tab selected can stop it too, as it can any mode; both are then told whose it was.
319
340
 
320
341
  It brings the tab to the front of its window first, as screenshot does. It records the page only,
321
- from inside the browser, at the page's size in CSS pixels, made even (an
342
+ from inside the browser, at the viewport's size in CSS pixels, scrollbars included, made even (an
322
343
  odd width or height loses a pixel), a phone's too under emulate mobile: a Pixel 7's is 412 wide, less
323
344
  sharp than the phone draws it. It has no pointer and no browser toolbars,
324
345
  and a tab behind another, hidden or headless keeps recording. Set the size first with viewport
325
346
  (1280x720 fills a 16:9 player); a page that changes size while recording is fitted into the first size.
326
347
  Chrome sends a frame as the page draws, and a still page keeps its real length.
327
348
 
328
- It needs ffmpeg: $PW_FFMPEG, then Playwright's own (npx playwright-core install ffmpeg), then one on the
329
- PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. WebM plays in browsers,
349
+ It needs ffmpeg: $PW_FFMPEG, and only it when it is set (the REPL's, not send's); otherwise Playwright's own (npx playwright-core install ffmpeg, in $PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright), then one on the PATH. Playwright's writes only WebM; an .mp4 needs a system ffmpeg, with H.264. record on fails at once, recording nothing, without an ffmpeg that can write its format. If ffmpeg exits while recording, the recording ends then, the pane says what was saved, and the next record off fails with it. ffmpeg writes in blocks, so what it has not written yet is lost: the last seconds, or at first all of it. WebM plays in browsers,
330
350
  GitHub, GitLab and Slack; MP4 plays everywhere. It takes about 15 MB a minute.
331
351
 
332
352
  For a video with the pointer, or of the person's screen, help video compares the other ways.`,
@@ -493,16 +513,17 @@ already happened (requests lists those).`,
493
513
  summary: 'send one CDP command through a temporary session',
494
514
  detail: `The session is detached afterwards, so subscriptions and settings do not persist.
495
515
 
496
- Browser.close and Target.closeTarget are refused.`,
516
+ Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog answers one.`,
497
517
  },
498
518
  cookies: { usage: 'cookies [--all]', summary: 'cookie names, domains and flags; values are omitted' },
499
519
  storage: { usage: 'storage [--all]', summary: 'localStorage keys; values are omitted' },
500
520
  modes: {
501
521
  usage: 'modes [off [--mine]]',
502
522
  summary: 'the modes on in every tab; modes off turns them all off',
503
- detail: `The modes are watch, network off or slow, route, capture, record, emulate and viewport. Each is
504
- turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... | route off
505
- <glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> | viewport off.
523
+ detail: `The modes are watch, network off or slow, route, capture, record, emulate, viewport and highlight.
524
+ Each is turned on and off with its own command: watch on|off, network off|slow|on, route <glob> ... |
525
+ route off <glob>|--all, capture on|off, record on|off, emulate ... | emulate off, viewport <WxH> |
526
+ viewport off, highlight <selector> | highlight off.
506
527
 
507
528
  modes off turns off every one in every tab; a capture it stops is kept for capture to show, and a
508
529
  recording it stops is saved, as record off would. With
@@ -512,10 +533,11 @@ the sender's own.`,
512
533
  dialog: {
513
534
  usage: 'dialog [accept [text] | dismiss]',
514
535
  summary: 'show, accept or dismiss an open alert, confirm or prompt',
515
- detail: `While a dialog is open, its page and the commands that read it wait. dialog runs at once, ahead
516
- of them; it answers the selected tab's dialog, or the only one open. accept text answers a prompt.
536
+ detail: `While a dialog is open, its page and the commands that read it wait, a click that opened it included, until it is answered. Commands run one at a time, so every client's commands behind that one wait too, in any tab: in a shared REPL, answer a dialog you open. dialog runs at once, ahead
537
+ of them; it answers the selected tab's dialog (marked *), or the only one open. accept text answers a prompt, and accept alone keeps its default, as OK in the browser does.
517
538
 
518
- With nobody at the browser (e.g. headless), it is the only way to answer one.`,
539
+ An agent can answer any dialog, one it opened included; one the person answers in the browser is gone
540
+ from dialog then. cdp cannot answer one: Chrome lets only a session that saw it open do so. With nobody at the browser (e.g. headless), it is the only way to answer one.`,
519
541
  },
520
542
  help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
521
543
  quit: {
package/lib/inspect.js CHANGED
@@ -9,6 +9,7 @@ const { toSelector, REF, unquote } = require('./syntax');
9
9
  const { regexOf, COMMAND_TIMEOUT } = require('./util');
10
10
  const { tabState } = require('./tabstate');
11
11
  const { keptSession } = require('./cdp');
12
+ const { CDP_REFUSED } = require('./dialogs');
12
13
  const { onElement, soleSelector } = require('./elements');
13
14
  const { waitForLoad, waitForRequest } = require('./requestlog');
14
15
  const { deviceMetrics, viewportMetrics } = require('./emulation');
@@ -429,6 +430,7 @@ const commands = {
429
430
  const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
430
431
  if (!match) throw new Error('Usage: cdp <method> <JSON object>');
431
432
  if (['Browser.close', 'Target.closeTarget'].includes(match[1])) throw new Error('Browser lifecycle commands are reserved; use quit or tab close.');
433
+ if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
432
434
  const params = JSON.parse(match[2]);
433
435
  if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
434
436
  const operation = async () => {
package/lib/modes.js CHANGED
@@ -9,6 +9,7 @@ const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
9
9
  const { recordingOf, finishRecording } = require('./record');
10
10
  const { currentCapture, endCapture } = require('./capture');
11
11
  const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
12
+ const { hideHighlights } = require('./elements');
12
13
 
13
14
  // The modes turned on in a tab, in the order the prompt shows them.
14
15
  function activeModes(p) {
@@ -22,6 +23,8 @@ function activeModes(p) {
22
23
  if (viewport) modes.push(`viewport:${viewport.width}x${viewport.height}`);
23
24
  const routes = tabState(p).routes?.size;
24
25
  if (routes) modes.push(`routes:${routes}`);
26
+ const highlights = tabState(p).highlights?.size;
27
+ if (highlights) modes.push(`highlight:${highlights}`);
25
28
  if (currentCapture()?.page === p) modes.push('capture');
26
29
  if (recordingOf(p)) modes.push('record');
27
30
  return modes;
@@ -70,6 +73,7 @@ function modeOwners(p) {
70
73
  const viewport = tabState(p).viewport;
71
74
  if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
72
75
  for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
76
+ for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
73
77
  const capture = currentCapture();
74
78
  if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
75
79
  const recording = recordingOf(p);
@@ -104,6 +108,8 @@ async function allModesOff(mine = false) {
104
108
  const globs = [...(tabState(p).routes || [])].filter(([, route]) => ours(route.owner)).map(([glob]) => glob);
105
109
  if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
106
110
  if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
111
+ const hidden = await hideHighlights(p, mine);
112
+ if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
107
113
  // Before the page's size is reset, which the recording would otherwise end with.
108
114
  const recording = recordingOf(p);
109
115
  if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
package/lib/record.js CHANGED
@@ -6,7 +6,8 @@ const path = require('path');
6
6
  const { state, onShutdown } = require('./state');
7
7
  const out = require('./output');
8
8
  const { tabState } = require('./tabstate');
9
- const { clock, NO_TAB } = require('./util');
9
+ const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
10
+ const { shellWords } = require('./syntax');
10
11
 
11
12
  const FPS = 25;
12
13
  const MAX_SECONDS = 3600;
@@ -27,10 +28,11 @@ const FORMATS = {
27
28
  mp4: { encoder: 'libx264', args: ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-threads', '1', '-g', String(FPS * 2), '-movflags', '+frag_keyframe+empty_moov+default_base_moof'] },
28
29
  };
29
30
 
30
- // PW_FFMPEG, then Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
31
+ const FORMAT_NAMES = { webm: 'WebM (VP8)', mp4: 'MP4 (H.264)' };
32
+
33
+ // Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
31
34
  function ffmpegCandidates() {
32
35
  const found = [];
33
- if (process.env.PW_FFMPEG) found.push(process.env.PW_FFMPEG);
34
36
  const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
35
37
  let names = [];
36
38
  try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
@@ -49,20 +51,43 @@ function ffmpegCandidates() {
49
51
 
50
52
  const encoderLists = new Map();
51
53
 
52
- function hasEncoder(exe, encoder) {
53
- if (!encoderLists.has(exe)) {
54
- let list = '';
55
- try { list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }); } catch {}
54
+ // What an ffmpeg's -encoders lists, or why it could not say. Only a list is kept: a probe that
55
+ // failed, as one slowed past its timeout on a busy machine can, is tried again the next time.
56
+ function encoderList(exe) {
57
+ if (encoderLists.has(exe)) return { list: encoderLists.get(exe) };
58
+ try {
59
+ const list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
56
60
  encoderLists.set(exe, list);
61
+ return { list };
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT') return { error: 'not found' };
64
+ if (error.code === 'EACCES') return { error: 'not executable' };
65
+ if (error.code === 'ETIMEDOUT') return { error: '-encoders did not answer within 5s' };
66
+ return { error: `-encoders failed (${error.status !== null && error.status !== undefined ? `exit ${error.status}` : error.signal || error.message})` };
57
67
  }
58
- return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(encoderLists.get(exe));
59
68
  }
60
69
 
61
- // The first ffmpeg that can write the format.
70
+ function hasEncoder(list, encoder) {
71
+ return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(list || '');
72
+ }
73
+
74
+ // PW_FFMPEG when it is set, and never another in its place; otherwise the first ffmpeg that can
75
+ // write the format.
62
76
  function findFfmpeg(format) {
77
+ const { encoder } = FORMATS[format];
78
+ const named = process.env.PW_FFMPEG;
79
+ if (named) {
80
+ const { list, error } = encoderList(named);
81
+ if (error) throw new Error(`PW_FFMPEG=${named} cannot be used: ${error}`);
82
+ if (!hasEncoder(list, encoder)) throw new Error(`PW_FFMPEG=${named} cannot write ${FORMAT_NAMES[format]}: its -encoders lists no ${encoder}`);
83
+ return named;
84
+ }
63
85
  const candidates = ffmpegCandidates();
64
- if (!candidates.length) throw new Error(`record needs ffmpeg, and none was found: ${INSTALL}; PW_FFMPEG names one`);
65
- const exe = candidates.find(c => hasEncoder(c, FORMATS[format].encoder));
86
+ if (!candidates.length) {
87
+ const how = format === 'mp4' ? 'an .mp4 needs ffmpeg from your system\'s packages, with H.264 (Playwright\'s writes only WebM)' : INSTALL;
88
+ throw new Error(`record needs ffmpeg, and none was found: ${how}; PW_FFMPEG names one`);
89
+ }
90
+ const exe = candidates.find(c => hasEncoder(encoderList(c).list, encoder));
66
91
  if (exe) return exe;
67
92
  if (format === 'mp4') throw new Error(`No ffmpeg found can write MP4 (H.264): Playwright's writes only WebM. Install ffmpeg from your system's packages, or name one with PW_FFMPEG; or record to a .webm file`);
68
93
  throw new Error(`No ffmpeg found can write WebM (VP8): ${INSTALL}; PW_FFMPEG names one`);
@@ -117,15 +142,18 @@ async function startRecording(p, file, seconds, format) {
117
142
  const rec = {
118
143
  page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
119
144
  size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
120
- ffmpegError: '',
145
+ ffmpegError: '', starting: true,
121
146
  };
122
147
  // The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
123
148
  // its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
124
- // reload or a change of emulation, that can still be the window's.
149
+ // reload or a change of emulation, that can still be the window's. Its scrollbars too, as the
150
+ // frames have them (innerWidth has them; the viewport's clientWidth does not).
125
151
  const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
126
152
  try {
127
153
  const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
128
- rec.size = { width: even(vv.clientWidth * (vv.scale || 1)), height: even(vv.clientHeight * (vv.scale || 1)) };
154
+ const { result } = await session.send('Runtime.evaluate', { expression: '[innerWidth, innerHeight]', returnByValue: true });
155
+ const [width, height] = result.value;
156
+ rec.size = { width: even(width * (vv.scale || 1)), height: even(height * (vv.scale || 1)) };
129
157
  } catch (error) {
130
158
  session.detach().catch(() => {});
131
159
  throw error;
@@ -134,7 +162,7 @@ async function startRecording(p, file, seconds, format) {
134
162
  let firstFrame;
135
163
  const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
136
164
  const tick = () => {
137
- if (!rec.latest || !rec.ff || rec.finishing) return;
165
+ if (!rec.latest || !rec.ff || rec.ffExited || rec.finishing) return;
138
166
  // The video keeps the wall clock: Chrome sends a frame only when the page redraws,
139
167
  // so the latest one is repeated while the page is still.
140
168
  const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
@@ -178,7 +206,7 @@ async function startRecording(p, file, seconds, format) {
178
206
  rec.limit = setTimeout(() => {
179
207
  finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
180
208
  }, seconds * 1000);
181
- rec.onClose = () => finishRecording(rec, 'Its tab closed, which ended it').then(text => out.notice(text, p));
209
+ rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
182
210
  p.once('close', rec.onClose);
183
211
  return rec;
184
212
  }
@@ -195,10 +223,24 @@ function spawnFfmpeg(rec) {
195
223
  // A write after ffmpeg has gone is reported when the recording finishes, not thrown.
196
224
  ff.stdin.on('error', () => {});
197
225
  ff.on('error', error => { rec.ffmpegError += error.message; });
198
- rec.exited = new Promise(resolve => ff.once('close', code => resolve(code)));
226
+ rec.exited = new Promise(resolve => ff.once('close', (code, signal) => {
227
+ rec.ffExited = true;
228
+ resolve(code ?? signal);
229
+ // ffmpeg gone before record off ends the recording now, so no one goes on thinking it records;
230
+ // record on, still starting it, says so itself.
231
+ if (!rec.finishing) {
232
+ rec.exitedEarly = true;
233
+ finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
234
+ }
235
+ }));
199
236
  return ff;
200
237
  }
201
238
 
239
+ // An exit code, or the signal that ended ffmpeg.
240
+ function exitText(code) {
241
+ return typeof code === 'string' ? `was killed by ${code}` : `exited with ${code}`;
242
+ }
243
+
202
244
  // Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
203
245
  function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
204
246
  if (rec.finishing) return rec.finishing;
@@ -207,10 +249,13 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
207
249
  rec.finishing = (async () => {
208
250
  const result = await finishFile(rec, shuttingDown);
209
251
  const text = typeof result === 'string' ? result : result.text;
210
- // A recording that saved nothing makes record off fail, rather than report it as done.
211
- rec.failed = typeof result !== 'string';
252
+ // A recording that saved nothing, or that ffmpeg cut short, makes record off fail rather than
253
+ // report it as done.
254
+ rec.failed = typeof result !== 'string' || !!rec.exitedEarly;
212
255
  const said = why ? `${why}. ${text}` : text;
213
- lastEnded.set(owner, { text: said, at: stoppedAt });
256
+ // A failure is kept unreported until record off (or record on) has failed with it. One that
257
+ // ended by itself is unseen until record shows it, and the REPL stopping logs it for stop.
258
+ lastEnded.set(owner, { text: said, at: stoppedAt, failed: rec.failed, reported: false, seen: !why || shuttingDown });
214
259
  return said;
215
260
  })();
216
261
  const pending = saving.get(owner) || new Set();
@@ -224,7 +269,7 @@ async function finishFile(rec, shuttingDown) {
224
269
  if (rec.onClose) rec.page.off('close', rec.onClose);
225
270
  if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
226
271
  // The last frame runs to now, and a recording has one frame at least.
227
- if (rec.ff && rec.latest) {
272
+ if (rec.ff && rec.latest && !rec.ffExited) {
228
273
  const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
229
274
  for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
230
275
  }
@@ -256,20 +301,38 @@ async function finishFile(rec, shuttingDown) {
256
301
  const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
257
302
  if (!saved) {
258
303
  fs.rmSync(rec.file, { force: true });
259
- const why = cut ? 'it stopped making progress and was stopped' : `it exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
304
+ const why = cut ? 'it stopped making progress and was stopped' : `it ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
260
305
  return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
261
306
  }
262
307
  const notes = [];
263
308
  if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
264
309
  if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
265
- if (cut) notes.push(`ffmpeg ${shuttingDown ? `did not finish within ${SHUTDOWN_FINISH_TIMEOUT / 1000}s as the REPL stopped` : 'stopped making progress'} and was stopped, so the file is likely cut short of the ${seconds.toFixed(1)}s recorded`);
266
- else if (code !== 0) notes.push(`ffmpeg exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
267
- return `Saved: ${rec.file} (${seconds.toFixed(1)}s, ${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
310
+ // An ffmpeg stopped before it finished leaves only what it had written, which it writes in
311
+ // blocks: seconds of video short of the frames it was sent, and no length in the file's header.
312
+ const short = `so the file holds only what it had written, short of the ${seconds.toFixed(1)}s recorded, and may not show its length or seek`;
313
+ if (cut) notes.push(`ffmpeg ${shuttingDown ? `did not finish within ${SHUTDOWN_FINISH_TIMEOUT / 1000}s as the REPL stopped` : 'stopped making progress'} and was stopped, ${short}`);
314
+ else if (rec.exitedEarly) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? ` (${rec.ffmpegError.trim()})` : ''}, ${short}`);
315
+ else if (code !== 0) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
316
+ const length = cut || rec.exitedEarly ? '' : `${seconds.toFixed(1)}s, `;
317
+ return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
318
+ }
319
+
320
+ const TAB_CLOSED = 'Its tab closed, which ended it';
321
+
322
+ // What became of a recording that tab close ended by closing its tab, for it to say.
323
+ async function endedByTabClose(rec) {
324
+ const text = await finishRecording(rec, TAB_CLOSED);
325
+ const last = lastEnded.get(rec.owner ?? null);
326
+ if (last?.text === text) last.seen = true;
327
+ return text;
268
328
  }
269
329
 
270
- // Ends every recording, as the REPL stops, and says in the pane and the log what each saved.
330
+ // Ends every recording, as the REPL stops, and says in the pane and the log what each saved, and
331
+ // what became of any that ended by itself that record has not shown since.
271
332
  async function finishAll() {
272
- await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), 'The REPL stopped, which ended a recording', { shuttingDown: true }).then(text => out.log(text), () => {})));
333
+ for (const last of lastEnded.values()) if (!last.seen) out.log(`${ENDED_UNSEEN}: ${last.text.replace(/\n/g, '; ')}`);
334
+ // One line each, for stop to find in the log.
335
+ await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), STOPPED_RECORDING, { shuttingDown: true }).then(text => out.log(text.replace(/\n/g, '; ')), () => {})));
273
336
  }
274
337
 
275
338
  onShutdown(finishAll);
@@ -278,6 +341,7 @@ function statusText(p) {
278
341
  const rec = recordingOf(p);
279
342
  if (!rec) {
280
343
  const last = lastEnded.get(state.client ?? null);
344
+ if (last) last.seen = true;
281
345
  return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
282
346
  }
283
347
  const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
@@ -295,11 +359,14 @@ function fileWords(words, usage) {
295
359
  const word = words[i];
296
360
  if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
297
361
  else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
362
+ else if (/^--(?:mp4|webm)$/.test(word)) throw new Error(`record takes no ${word}: the file's ending picks the format, as in record on clip.${word.slice(2)}`);
363
+ // playwright-cli's video-start --size: here the video is the page's size.
364
+ else if (/^--size(?:=|$)/.test(word)) throw new Error(`record takes no --size: the video is the page's size; set it first with viewport <width>x<height>`);
298
365
  else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
299
366
  else if (/^\d+$/.test(word)) rest.push(word);
300
367
  else files.push(word);
301
368
  }
302
- if (files.length > 1) throw new Error(usage);
369
+ if (files.length > 1) throw new Error(`record takes one file, and ${files.map(f => `"${f}"`).join(' and ')} are two: quote a file name with spaces in it. ${usage}`);
303
370
  return { file: files.length ? path.resolve(files[0]) : null, rest };
304
371
  }
305
372
 
@@ -309,6 +376,7 @@ function checkFile(file, format) {
309
376
  if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
310
377
  if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
311
378
  if (!fs.existsSync(path.dirname(file))) throw new Error(`No folder ${path.dirname(file)} to save ${path.basename(file)} in`);
379
+ try { fs.accessSync(path.dirname(file), fs.constants.W_OK); } catch { throw new Error(`Cannot write in ${path.dirname(file)} to save ${path.basename(file)} there`); }
312
380
  return ext;
313
381
  }
314
382
 
@@ -326,7 +394,8 @@ function moveRecording(from, to) {
326
394
 
327
395
  const commands = {
328
396
  async record(args) {
329
- const words = (args || '').trim().split(/\s+/).filter(Boolean);
397
+ // A file may be quoted, with spaces in it.
398
+ const words = shellWords(args);
330
399
  const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
331
400
  if (!words.length) {
332
401
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
@@ -334,17 +403,23 @@ const commands = {
334
403
  return;
335
404
  }
336
405
  const [what, ...rest] = words;
337
- // record on its own works with no tab selected, as after the recorded tab closed.
338
- if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
339
406
  if (what !== 'on' && what !== 'off') throw new Error(usage);
407
+ // record on its own, and record off, work with no tab selected, as after the recorded tab closed.
408
+ const noTab = !state.page || state.page.isClosed();
409
+ if (noTab && what === 'on') throw new Error(NO_TAB);
340
410
  // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
341
411
  const { file: named, rest: numbers } = fileWords(rest, usage);
342
412
  if (what === 'off') {
343
413
  if (numbers.length) throw new Error(usage);
344
- const rec = recordingOf(state.page);
414
+ const rec = noTab ? null : recordingOf(state.page);
345
415
  if (!rec) {
346
416
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
347
- out.log(statusText(state.page));
417
+ // A recording that ended by itself and saved nothing fails record off, once, as it would
418
+ // have had record off ended it: its exit status is what an agent checks.
419
+ const last = lastEnded.get(state.client ?? null);
420
+ const text = statusText(state.page);
421
+ if (last?.failed && !last.reported) { last.reported = true; throw new Error(text); }
422
+ out.log(text);
348
423
  return;
349
424
  }
350
425
  // Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
@@ -353,10 +428,17 @@ const commands = {
353
428
  if (rename) {
354
429
  try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
355
430
  }
356
- const text = await finishRecording(rec);
357
- if (rec.failed) throw new Error(text);
431
+ // Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
432
+ const other = (rec.owner ?? null) !== (state.client ?? null);
433
+ const clientName = c => c ?? 'the unnamed client';
434
+ const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
435
+ if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
358
436
  if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
359
- moveRecording(rec.file, rename);
437
+ try {
438
+ moveRecording(rec.file, rename);
439
+ } catch (error) {
440
+ throw new Error(`${text}\nIt could not be renamed to ${rename} (${error.message}), so it stays where it was saved`);
441
+ }
360
442
  const last = lastEnded.get(rec.owner ?? null);
361
443
  if (last) last.text = last.text.split(rec.file).join(rename);
362
444
  out.log(text.split(rec.file).join(rename));
@@ -378,15 +460,25 @@ const commands = {
378
460
  if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
379
461
  throw error;
380
462
  }
463
+ let rec;
381
464
  try {
382
- await startRecording(state.page, file, seconds, format);
465
+ rec = await startRecording(state.page, file, seconds, format);
383
466
  } catch (error) {
384
467
  fs.rmSync(file, { force: true });
385
468
  throw error;
386
469
  }
470
+ // An ffmpeg that stopped as it started (bad arguments, an encoder that failed) has ended it
471
+ // already, and record on says so instead of a notice.
472
+ if (rec.finishing) {
473
+ const text = await rec.finishing;
474
+ if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
475
+ out.log(text);
476
+ return;
477
+ }
478
+ rec.starting = false;
387
479
  const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
388
480
  out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
389
481
  },
390
482
  };
391
483
 
392
- module.exports = { commands, recordingOf, finishRecording, findFfmpeg, jpegSize };
484
+ module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize };
package/lib/runner.js CHANGED
@@ -4,7 +4,7 @@ const readline = require('readline');
4
4
  const { state, clientRecord, asClient, beforeExit } = require('./state');
5
5
  const out = require('./output');
6
6
  const { commands, cliCommands, needsTab } = require('./commands');
7
- const { dialogCommand } = require('./dialogs');
7
+ const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
8
8
  const { activeModes } = require('./modes');
9
9
  const { NO_TAB } = require('./util');
10
10
  const cliNames = require('./cli-names');
@@ -172,7 +172,8 @@ function prompt(preserveCursor) {
172
172
  function enqueue(line) {
173
173
  const text = parseInput(line).text;
174
174
  const direct = /^quit(?:\s|$)/.test(text) || DIALOG.test(text);
175
- if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
175
+ if (CDP_ANSWER.test(text)) out.error(`Error: ${CDP_REFUSED}`);
176
+ else if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
176
177
  else queue = queue.then(() => handleLine(line)).catch(e => out.error(`Error: ${e.message}`));
177
178
  }
178
179
 
@@ -181,6 +182,10 @@ function enqueue(line) {
181
182
  // client names the sender: its commands show as [server:<client>] and use its own selected tab.
182
183
  function submit(text, client = null) {
183
184
  if (DIALOG.test(text)) return answerDialog(text, client);
185
+ if (CDP_ANSWER.test(text)) {
186
+ console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
187
+ return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
188
+ }
184
189
  return new Promise(resolve => {
185
190
  queue = queue.then(async () => {
186
191
  if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
package/lib/syntax.js CHANGED
@@ -18,6 +18,13 @@ function unquote(text) {
18
18
  return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
19
19
  }
20
20
 
21
+ // The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
22
+ // (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
23
+ function shellWords(text) {
24
+ return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
25
+ .map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double.replace(/\\(.)/g, '$1') : single)));
26
+ }
27
+
21
28
  // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
22
29
  function splitSelector(args) {
23
30
  const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
@@ -47,4 +54,4 @@ function keyName(key) {
47
54
  return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
48
55
  }
49
56
 
50
- module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, splitSelector, keyName };
57
+ module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName };
package/lib/tabs.js CHANGED
@@ -7,6 +7,7 @@ const { keepDrawing } = require('./cdp');
7
7
  const { ensureDialogHandler } = require('./dialogs');
8
8
  const { notLoaded, stayedPut } = require('./requestlog');
9
9
  const { viewportText } = require('./emulation');
10
+ const { recordingOf, endedByTabClose } = require('./record');
10
11
 
11
12
  // A navigation that does not load is said as wait load says it, from its request, rather than as
12
13
  // Playwright's net:: error and call log.
@@ -184,6 +185,7 @@ const commands = {
184
185
  if (all.length <= 1) throw new Error('Refusing to close the last tab');
185
186
  const url = target.url();
186
187
  const wasSelected = target === state.page;
188
+ const recording = recordingOf(target);
187
189
  await target.close();
188
190
  if (wasSelected) {
189
191
  const back = state.previousPage;
@@ -191,6 +193,7 @@ const commands = {
191
193
  state.previousPage = null;
192
194
  }
193
195
  out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
196
+ if (recording) out.log(await endedByTabClose(recording));
194
197
  return listTabs();
195
198
  }
196
199
  select(byUrl(trimmed));
package/lib/util.js CHANGED
@@ -46,4 +46,9 @@ function clock(t) {
46
46
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
47
47
  }
48
48
 
49
- module.exports = { COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };
49
+ // How the REPL, as it stops, begins what it says of each recording it ended, and of one that ended
50
+ // by itself unseen: stop prints those lines.
51
+ const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
52
+ const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
53
+
54
+ module.exports = { STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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
@@ -195,12 +195,16 @@ pw-repl send -c <name> close # your modes off; tab close
195
195
  one too; otherwise no tab is selected.
196
196
  Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
197
197
  its URL and refuse if it is ambiguous.
198
- - Dialogs are never answered on their own. While one is open, its page and the commands that read it
199
- wait; `dialog` shows it, and `dialog accept` or `dialog dismiss` answers it.
198
+ - You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept`
199
+ or `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and
200
+ the commands that read it wait. One answered in the browser is gone from `dialog` too.
200
201
  - Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
201
202
  lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
202
203
  `modes off` turns off every mode in every tab.
203
204
  - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
205
+ - To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
206
+ without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
207
+ cover what is below it; `help highlight` says how to mark a screenshot without one.
204
208
  - For a video, `help video` says which way suits what you want: `record on` records a tab's page, and a
205
209
  screen recorder on the person's machine records what they see.
206
210
 
@@ -209,7 +213,10 @@ pw-repl send -c <name> close # your modes off; tab close
209
213
  - Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
210
214
  one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
211
215
  - `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
212
- - `PW_FFMPEG` — the ffmpeg `record` uses (default: Playwright's own, then one on the `PATH`).
216
+ - `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
217
+ Playwright's own, then one on the `PATH`).
218
+ - Playwright's own Chromium and ffmpeg are found where Playwright installs them: `PLAYWRIGHT_BROWSERS_PATH`,
219
+ or `~/.cache/ms-playwright`.
213
220
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
214
221
  - `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
215
222
  a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`