pw-repl 0.6.1 → 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/AGENTS.md CHANGED
@@ -15,8 +15,8 @@ If you hit a limitation or write a workaround, consider adding the capability to
15
15
  - The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
16
16
  `lib/elements.js` (choosing a match; the interact and mouse commands), `lib/inspect.js` (reading the
17
17
  page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
18
- `lib/routes.js`, `lib/network.js`, `lib/emulation.js`, `lib/capture.js`, `lib/dialogs.js` and
19
- `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
18
+ `lib/routes.js`, `lib/network.js`, `lib/emulation.js` (and viewport), `lib/capture.js`, `lib/record.js`
19
+ (video, through ffmpeg), `lib/dialogs.js` and `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
20
20
  session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
21
21
  `lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
22
22
  - `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
package/README.md CHANGED
@@ -98,6 +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
102
  | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `route <glob> abort`, `network off` |
102
103
  | change or slow an API response | `route <glob> patch <json>`, `route <glob> delay <secs>` |
103
104
  | slow the whole network | `network slow` |
@@ -130,8 +131,8 @@ pw-repl differs, it is because someone else may be using the browser:
130
131
  - `watch` records what a person does as steps with their requests, not as generated code.
131
132
 
132
133
  For a browser of its own per agent, isolated sessions, traces, video, PDFs or generated test code,
133
- playwright-cli is the better fit. pw-repl does not record video; for one of the shared browser as you
134
- see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
134
+ playwright-cli is the better fit. `record on` records a tab's page, without the pointer; for the shared
135
+ browser as you see it, use a screen recorder (`ffmpeg -f x11grab`, macOS `screencapture -v`, OBS).
135
136
 
136
137
  ## From scripts and agents
137
138
 
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,16 +41,17 @@ 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
 
51
51
  const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
52
52
 
53
- pw-repl help <topic | command | --all> shows one part of it.`;
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.`;
54
55
 
55
56
  function usage() {
56
57
  console.error(USAGE);
@@ -102,12 +103,21 @@ function parseSendArgs(args, allowCommand) {
102
103
  // words are joined as they are.
103
104
  const { SELECTOR_FIRST } = require('../lib/syntax');
104
105
  // So does playwright-cli's storage key (localstorage-set "my key" v).
105
- 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]));
106
108
  // An empty word (fill #name "") is the empty value.
107
109
  // upload's files are read by the REPL, whose folder may not be this one.
108
110
  // So are the files upload reads and screenshot --filename writes.
109
111
  const resolve = require('path').resolve;
110
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
+ }
111
121
  if (words[0] === 'screenshot') {
112
122
  resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
113
123
  }
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
@@ -31,6 +31,9 @@ const NAMES = {
31
31
  unroute: [a => (a ? `route off ${a}` : 'route off --all'), 'route off <glob>|--all'],
32
32
  request: [a => `body ${a}`, 'body <#>, the response body only'],
33
33
  'response-body': [a => `body ${a}`, 'body <#>'],
34
+ // The file is named when recording starts here.
35
+ 'video-start': [a => `record on ${a}`.trim(), 'record on [file.webm|file.mp4]; --filename=<file> is the file'],
36
+ 'video-stop': [a => `record off ${a}`.trim(), 'record off [file]'],
34
37
  };
35
38
 
36
39
  // Storage by eval: a key is the first word, quoted if it has spaces, and a value the rest.
@@ -66,7 +69,7 @@ const NEAREST = {
66
69
  list: 'pw-repl where says which REPL is running; one REPL serves everyone using its browser',
67
70
  attach: 'pw-repl serve connects to the browser on PW_CDP_URL; pw-repl skill has the rest',
68
71
  detach: 'pw-repl stop, or quit at the prompt, disconnects and leaves the browser running',
69
- 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',
70
73
  keydown: 'not here yet; press <key> presses and releases one', keyup: 'not here yet; press <key> presses and releases one',
71
74
  pdf: 'not here yet; screenshot --full saves the whole page as a PNG',
72
75
  'generate-locator': 'not here yet; a snapshot ref (e5) works as a selector in any command',
@@ -88,8 +91,8 @@ for (const what of ['reduced-motion', 'forced-colors', 'contrast', 'media']) {
88
91
  NEAREST[`clear-${what}`] = 'not here yet; emulate dark|light sets the color scheme';
89
92
  }
90
93
  for (const name of ['request-headers', 'response-headers', 'request-body']) NEAREST[name] = 'not here yet; body <#> shows the response body';
91
- for (const name of ['video-start', 'video-stop', 'video-chapter', 'video-show-actions', 'video-hide-actions']) {
92
- NEAREST[name] = 'not here: pw-repl does not record video (playwright-cli itself does, in a browser of its own); screenshot saves an image, and watch on records each step';
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';
93
96
  }
94
97
  for (const name of ['pause-at', 'resume', 'step-over']) NEAREST[name] = 'not here: they step through a Playwright test';
95
98
 
@@ -277,4 +280,11 @@ function table() {
277
280
  return [...lines, '', 'Not here as such:', ...nearest].join('\n');
278
281
  }
279
282
 
280
- module.exports = { translate, table, takeOptions, NAME_LIST };
283
+ // What help says for a playwright-cli name: what it is here, or why it is not.
284
+ function describe(name) {
285
+ if (Object.hasOwn(NAMES, name)) return { as: NAMES[name][1] };
286
+ if (Object.hasOwn(NEAREST, name)) return { nearest: NEAREST[name] };
287
+ return null;
288
+ }
289
+
290
+ module.exports = { translate, table, takeOptions, describe, NAME_LIST };
package/lib/commands.js CHANGED
@@ -11,6 +11,7 @@ const { commands: routesCommands, routesFor } = require('./routes');
11
11
  const { commands: networkCommands } = require('./network');
12
12
  const { commands: emulationCommands, EMULATE_KINDS } = require('./emulation');
13
13
  const { commands: captureCommands } = require('./capture');
14
+ const { commands: recordCommands } = require('./record');
14
15
  const { commands: inspectCommands } = require('./inspect');
15
16
  const { commands: watchCommands, nextTabWanted, waitingForMe, watchNewTab } = require('./watch');
16
17
  const { commands: tabsCommands, isOpening } = require('./tabs');
@@ -27,6 +28,7 @@ const commands = {
27
28
  ...networkCommands,
28
29
  ...emulationCommands,
29
30
  ...captureCommands,
31
+ ...recordCommands,
30
32
  ...dialogsCommands,
31
33
  ...modesCommands,
32
34
 
@@ -110,6 +112,8 @@ function complete(line) {
110
112
  if (command === 'dialog') return match(['accept', 'dismiss']);
111
113
  if (command === 'watch') return match(['on', 'off', 'new', '--all']);
112
114
  if (command === 'capture') return match(['on', 'off']);
115
+ if (command === 'record') return match(['on', 'off']);
116
+ if (command === 'viewport') return match(['off']);
113
117
  if (command === 'route') return match(['off']);
114
118
  }
115
119
  if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live', '--next-tab'].filter(f => !words.slice(2, -1).includes(f)));
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/emulation.js CHANGED
@@ -17,7 +17,7 @@ async function viewportText() {
17
17
  const wider = laidOut && laidOut > width ? `; the page lays out ${laidOut} wide, shown shrunk: it has no viewport meta tag` : '';
18
18
  return `${width}x${height} (emulate mobile: ${device}${wider})`;
19
19
  }
20
- const set = state.page.viewportSize();
20
+ const set = tabState(state.page).viewport;
21
21
  if (set) return `${set.width}x${set.height} (set with viewport)`;
22
22
  const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
23
23
  return size ? `${size} (the window's size)` : 'unknown';
@@ -49,6 +49,35 @@ function deviceText(name) {
49
49
  return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
50
50
  }
51
51
 
52
+ // A viewport set with viewport, on the kept session, as a phone's size is, so that viewport off can
53
+ // undo it: Playwright's setViewportSize has no undo. As Playwright sets it, at one device pixel per
54
+ // CSS pixel, and the window is left as it is.
55
+ function viewportMetrics(session, { width, height }) {
56
+ return session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height });
57
+ }
58
+
59
+ // Chrome answers a change of screen size before the page has it (a clear, often), so the size read
60
+ // next, a screenshot or a recording would be the old one: two frames later it has it. Bounded, as a
61
+ // dialog holds the page.
62
+ function settled(p) {
63
+ return Promise.race([
64
+ p.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))).catch(() => {}),
65
+ new Promise(resolve => setTimeout(resolve, 1000)),
66
+ ]);
67
+ }
68
+
69
+ async function setViewport(p, size) {
70
+ const session = await keptSession(p);
71
+ if (size) {
72
+ await viewportMetrics(session, size);
73
+ tabState(p).viewport = { ...size, owner: state.client };
74
+ } else {
75
+ await session.send('Emulation.clearDeviceMetricsOverride');
76
+ delete tabState(p).viewport;
77
+ }
78
+ await settled(p);
79
+ }
80
+
52
81
  // Sends only what changed. Chrome takes the user agent and the languages
53
82
  // together, so a change to either sends both.
54
83
  function deviceMetrics(session, name) {
@@ -71,6 +100,7 @@ async function setEmulation(p, next) {
71
100
  await send('Emulation.clearDeviceMetricsOverride');
72
101
  }
73
102
  await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
103
+ await settled(p);
74
104
  }
75
105
  if (next.device !== previous.device || next.locale !== previous.locale) {
76
106
  if (next.device || next.locale) {
@@ -111,23 +141,34 @@ function showEmulation() {
111
141
  out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
112
142
  }
113
143
 
114
- // Resets every tab's emulation, which detaching would only partly undo.
144
+ // Resets every tab's emulation and viewport, which detaching would only partly undo.
115
145
  async function resetEmulations() {
116
146
  if (!state.browser || state.connectionLost) return;
117
- const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => 'emulation' in tabState(p) && !p.isClosed());
118
- await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
147
+ const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
148
+ await Promise.all(pages.map(async p => {
149
+ if ('emulation' in tabState(p)) await setEmulation(p, {}).catch(() => {});
150
+ if ('viewport' in tabState(p)) await setViewport(p, null).catch(() => {});
151
+ }));
119
152
  }
120
153
 
121
154
  onShutdown(resetEmulations);
122
155
 
123
156
  const commands = {
124
157
  async viewport(args) {
125
- if (!args) { out.log(await viewportText()); return; }
126
- const [w, h] = args.split('x').map(Number);
127
- if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
158
+ const arg = (args || '').trim();
159
+ if (!arg) { out.log(await viewportText()); return; }
160
+ if (arg === 'off') {
161
+ if (!tabState(state.page).viewport) { out.log(`No viewport is set in the selected tab: ${await viewportText()}`); return; }
162
+ await setViewport(state.page, null);
163
+ out.log(`Viewport off: ${await viewportText()}`);
164
+ return;
165
+ }
166
+ const match = /^(\d+)x(\d+)$/.exec(arg);
167
+ const [w, h] = match ? [Number(match[1]), Number(match[2])] : [];
168
+ if (!w || !h) throw new Error('Usage: viewport [<width>x<height> | off]');
128
169
  // Both set the screen size; whichever came last would win without saying so.
129
170
  if (tabState(state.page).emulation?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
130
- await state.page.setViewportSize({ width: w, height: h });
171
+ await setViewport(state.page, { width: w, height: h });
131
172
  out.log(`Viewport set to ${w}x${h}`);
132
173
  },
133
174
 
@@ -156,6 +197,8 @@ const commands = {
156
197
  }
157
198
  let next;
158
199
  if (what === 'mobile') {
200
+ // One screen size at a time, as viewport refuses while a phone's is set.
201
+ if (tabState(state.page).viewport) throw new Error('viewport sets the size of the selected tab; viewport off first');
159
202
  next = deviceNamed(value || DEFAULT_DEVICE);
160
203
  } else if (what === 'dark' || what === 'light') {
161
204
  if (value) throw new Error(usage);
@@ -177,4 +220,4 @@ const commands = {
177
220
  },
178
221
  };
179
222
 
180
- module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, setEmulation };
223
+ module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport };