browser-commander 0.27.0 → 0.28.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -0
@@ -0,0 +1,222 @@
1
+ import { writeCapture } from './files.js';
2
+ import path from 'node:path';
3
+ import webp from 'webp-wasm';
4
+ import { decodePng, encodePng, resizeFrame } from './encoding.js';
5
+ import { UnsupportedCaptureError } from './errors.js';
6
+ import { captureStableViewport, validateStableViewport } from './viewport.js';
7
+
8
+ /**
9
+ * @typedef {Object} ScreenshotOptions
10
+ * @property {string} [path]
11
+ * @property {boolean} [fullPage]
12
+ * @property {boolean} [stableViewport] Capture only the existing view without visual styling.
13
+ * @property {string} [selector]
14
+ * @property {Object} [element]
15
+ * @property {{x:number,y:number,width:number,height:number}} [clip]
16
+ * @property {string} [format] png, jpeg or webp
17
+ * @property {number} [quality]
18
+ * @property {string} [scale] css or device
19
+ * @property {boolean} [omitBackground]
20
+ * @property {string} [animations] allow or disabled
21
+ * @property {string} [caret] hide or initial
22
+ * @property {boolean} [hideScrollbars]
23
+ * @property {boolean} [hideCaret]
24
+ * @property {boolean} [disableAnimations]
25
+ * @property {boolean} [waitForFonts]
26
+ */
27
+ export async function screenshot(options = {}) {
28
+ const {
29
+ page,
30
+ engine = 'playwright',
31
+ selector,
32
+ element,
33
+ clip,
34
+ fullPage = false,
35
+ scale = 'device',
36
+ quality,
37
+ path: output,
38
+ } = options;
39
+ if (
40
+ !['playwright', 'puppeteer', 'selenium', 'chromiumoxide'].includes(
41
+ engine
42
+ ) ||
43
+ typeof page?.screenshot !== 'function'
44
+ ) {
45
+ throw new UnsupportedCaptureError('screenshot', engine);
46
+ }
47
+ const { format, animations, caret } = validateScreenshot(options);
48
+ const native = {
49
+ type: format === 'webp' ? 'png' : format,
50
+ fullPage,
51
+ ...(clip ? { clip } : {}),
52
+ ...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
53
+ };
54
+ if (options.omitBackground) {
55
+ if (engine === 'selenium') {
56
+ throw new UnsupportedCaptureError('omitBackground', engine);
57
+ }
58
+ native.omitBackground = true;
59
+ }
60
+ const css = captureStyle(options, animations, caret);
61
+ if (options.waitForFonts) {
62
+ await page.evaluate(() => document.fonts.ready.then(() => true));
63
+ }
64
+ if (options.stableViewport) {
65
+ if (engine === 'playwright') {
66
+ native.scale = scale;
67
+ native.caret = 'initial';
68
+ }
69
+ const captured = await captureStableViewport(page, engine, native);
70
+ return finishScreenshot(captured.bytes, {
71
+ page,
72
+ engine: captured.engine,
73
+ scale,
74
+ format,
75
+ quality,
76
+ output,
77
+ });
78
+ }
79
+ let styleId;
80
+ if (engine === 'playwright') {
81
+ Object.assign(native, {
82
+ scale,
83
+ animations,
84
+ caret: 'initial',
85
+ ...(css ? { style: css } : {}),
86
+ });
87
+ } else if (css) {
88
+ styleId = await page.evaluate((css) => {
89
+ const style = document.createElement('style');
90
+ style.id = `browser-commander-capture-${Math.random().toString(36).slice(2)}`;
91
+ style.textContent = css;
92
+ document.documentElement.appendChild(style);
93
+ return style.id;
94
+ }, css);
95
+ }
96
+ let target = page;
97
+ try {
98
+ if (selector || element) {
99
+ target =
100
+ element ??
101
+ (engine === 'playwright'
102
+ ? page.locator(selector).first()
103
+ : await page.$(selector));
104
+ if (!target) {
105
+ throw new Error(`No element matches ${selector}`);
106
+ }
107
+ if (typeof target.screenshot !== 'function') {
108
+ throw new UnsupportedCaptureError('element screenshot', engine);
109
+ }
110
+ delete native.fullPage;
111
+ }
112
+ return await finishScreenshot(
113
+ Buffer.from(await target.screenshot(native)),
114
+ { page, engine, scale, format, quality, output }
115
+ );
116
+ } finally {
117
+ if (styleId) {
118
+ await page.evaluate(
119
+ (id) => document.getElementById(id)?.remove(),
120
+ styleId
121
+ );
122
+ }
123
+ }
124
+ }
125
+
126
+ async function finishScreenshot(
127
+ bytes,
128
+ { page, engine, scale, format, quality, output }
129
+ ) {
130
+ if (scale === 'css' && engine !== 'playwright') {
131
+ if (format === 'jpeg') {
132
+ throw new UnsupportedCaptureError('JPEG CSS scale', engine);
133
+ }
134
+ const ratio = await page.evaluate(() => window.devicePixelRatio);
135
+ if (ratio !== 1) {
136
+ bytes = encodePng(resizeFrame(decodePng(bytes), 1 / ratio));
137
+ }
138
+ }
139
+ if (format === 'webp') {
140
+ bytes = await webp.encode(decodePng(bytes), { quality: quality ?? 75 });
141
+ }
142
+ return writeCapture(bytes, output);
143
+ }
144
+
145
+ function validateScreenshot(options) {
146
+ const engine = options.engine ?? 'playwright';
147
+ const {
148
+ path: output,
149
+ quality,
150
+ scale = 'device',
151
+ selector,
152
+ element,
153
+ clip,
154
+ fullPage,
155
+ } = options;
156
+ const extension = output ? path.extname(output).slice(1).toLowerCase() : '';
157
+ const format =
158
+ options.format ??
159
+ options.type ??
160
+ (extension === 'jpg'
161
+ ? 'jpeg'
162
+ : ['png', 'jpeg', 'webp'].includes(extension)
163
+ ? extension
164
+ : 'png');
165
+ if (!['png', 'jpeg', 'webp'].includes(format)) {
166
+ throw new UnsupportedCaptureError(format, engine);
167
+ }
168
+ if (
169
+ quality !== undefined &&
170
+ (!Number.isInteger(quality) ||
171
+ quality < 0 ||
172
+ quality > 100 ||
173
+ format === 'png')
174
+ ) {
175
+ throw new RangeError('quality must be an integer 0–100 for JPEG/WebP');
176
+ }
177
+ if (!['css', 'device'].includes(scale)) {
178
+ throw new RangeError('scale must be css or device');
179
+ }
180
+ if ([selector || element, clip, fullPage].filter(Boolean).length > 1) {
181
+ throw new RangeError(
182
+ 'selector/element, clip and fullPage are mutually exclusive'
183
+ );
184
+ }
185
+ if (
186
+ clip &&
187
+ (!['x', 'y', 'width', 'height'].every((key) =>
188
+ Number.isFinite(clip[key])
189
+ ) ||
190
+ clip.x < 0 ||
191
+ clip.y < 0 ||
192
+ clip.width <= 0 ||
193
+ clip.height <= 0)
194
+ ) {
195
+ throw new RangeError(
196
+ 'clip must have nonnegative coordinates and positive dimensions'
197
+ );
198
+ }
199
+ const animations =
200
+ options.animations ?? (options.disableAnimations ? 'disabled' : 'allow');
201
+ const caret = options.caret ?? (options.hideCaret ? 'hide' : 'initial');
202
+ validateStableViewport(options, animations, caret);
203
+ if (
204
+ !['allow', 'disabled'].includes(animations) ||
205
+ !['hide', 'initial'].includes(caret)
206
+ ) {
207
+ throw new RangeError('invalid animations/caret option');
208
+ }
209
+ return { format, animations, caret };
210
+ }
211
+
212
+ function captureStyle(options, animations, caret) {
213
+ return [
214
+ options.hideScrollbars
215
+ ? '*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important}'
216
+ : '',
217
+ caret === 'hide' ? '*{caret-color:transparent!important}' : '',
218
+ animations === 'disabled'
219
+ ? '*,*::before,*::after{animation:none!important;transition:none!important}'
220
+ : '',
221
+ ].join('');
222
+ }
@@ -0,0 +1,186 @@
1
+ import { writeCapture } from './files.js';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runCommand } from '../utilities/subprocess.js';
6
+ import { decodePng } from './encoding.js';
7
+ import { UnsupportedCaptureError } from './errors.js';
8
+
9
+ /** Optional binary backend; never inferred from PATH or invoked by default. */
10
+ export async function encodeVideo(frames, options = {}) {
11
+ if (!Array.isArray(frames) || frames.length < 1 || frames.length > 1000) {
12
+ throw new RangeError('video requires 1–1000 frames');
13
+ }
14
+ if (
15
+ !Number.isFinite(options.fps ?? 10) ||
16
+ (options.fps ?? 10) < 1 ||
17
+ (options.fps ?? 10) > 60
18
+ ) {
19
+ throw new RangeError('fps must be 1–60');
20
+ }
21
+ if (
22
+ options.quality !== undefined &&
23
+ (!Number.isInteger(options.quality) ||
24
+ options.quality < 0 ||
25
+ options.quality > 100)
26
+ ) {
27
+ throw new RangeError('quality must be 0–100');
28
+ }
29
+ if (frames.reduce((sum, frame) => sum + frame.length, 0) > 64 * 1024 * 1024) {
30
+ throw new RangeError('video frames exceed 64 MiB');
31
+ }
32
+ for (const frame of frames) {
33
+ decodePng(frame);
34
+ }
35
+ if (!options.ffmpeg) {
36
+ if (!options.page?.evaluate) {
37
+ throw new UnsupportedCaptureError(
38
+ options.format ?? 'webm',
39
+ 'video encoder (provide a page or set ffmpeg explicitly)'
40
+ );
41
+ }
42
+ const result = await options.page.evaluate(encodeVideoInPage, {
43
+ frames: frames.map((frame) => frame.toString('base64')),
44
+ format: options.format ?? 'webm',
45
+ fps: options.fps ?? 10,
46
+ quality: options.quality,
47
+ size: options.size,
48
+ });
49
+ if (result.unsupported) {
50
+ throw new UnsupportedCaptureError(
51
+ options.format ?? 'webm',
52
+ options.engine ?? 'MediaRecorder'
53
+ );
54
+ }
55
+ const bytes = Buffer.from(result.data);
56
+ return writeCapture(bytes, options.path);
57
+ }
58
+ return encodeFfmpeg(frames, options);
59
+ }
60
+
61
+ async function encodeFfmpeg(frames, options) {
62
+ const directory = await fs.mkdtemp(
63
+ path.join(os.tmpdir(), 'commander-video-')
64
+ );
65
+ try {
66
+ for (let i = 0; i < frames.length; i++) {
67
+ await fs.writeFile(
68
+ path.join(directory, `${String(i).padStart(6, '0')}.png`),
69
+ frames[i]
70
+ );
71
+ }
72
+ const output = path.join(directory, `output.${options.format ?? 'webm'}`);
73
+ const codec = { mp4: 'libx264', webm: 'libvpx-vp9', mov: 'libx264' }[
74
+ options.format ?? 'webm'
75
+ ];
76
+ if (!codec) {
77
+ throw new UnsupportedCaptureError(options.format, 'ffmpeg');
78
+ }
79
+ const args = [
80
+ '-y',
81
+ '-framerate',
82
+ String(options.fps ?? 10),
83
+ '-i',
84
+ path.join(directory, '%06d.png'),
85
+ '-c:v',
86
+ codec,
87
+ '-pix_fmt',
88
+ 'yuv420p',
89
+ '-crf',
90
+ String(Math.round(51 * (1 - (options.quality ?? 75) / 100))),
91
+ ...(options.size
92
+ ? ['-vf', `scale=${options.size.width}:${options.size.height}`]
93
+ : []),
94
+ output,
95
+ ];
96
+ try {
97
+ await runCommand(
98
+ options.ffmpeg === true ? 'ffmpeg' : options.ffmpeg,
99
+ args
100
+ );
101
+ } catch (cause) {
102
+ throw new UnsupportedCaptureError('ffmpeg', 'video encoder', cause);
103
+ }
104
+ const bytes = await fs.readFile(output);
105
+ return writeCapture(bytes, options.path);
106
+ } finally {
107
+ await fs.rm(directory, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ /** Browser-native encoding on a detached canvas; no live DOM markers. */
112
+ export async function encodeVideoInPage({
113
+ frames,
114
+ format,
115
+ fps,
116
+ quality,
117
+ size,
118
+ }) {
119
+ const mimeType = { webm: 'video/webm', mp4: 'video/mp4' }[format];
120
+ if (!mimeType || !globalThis.MediaRecorder?.isTypeSupported(mimeType)) {
121
+ return { unsupported: true };
122
+ }
123
+ const image = new globalThis.Image();
124
+ image.src = `data:image/png;base64,${frames[0]}`;
125
+ await image.decode();
126
+ const canvas = document.createElement('canvas');
127
+ canvas.width = size?.width ?? image.width;
128
+ canvas.height = size?.height ?? image.height;
129
+ if (
130
+ !Number.isInteger(canvas.width) ||
131
+ canvas.width < 1 ||
132
+ !Number.isInteger(canvas.height) ||
133
+ canvas.height < 1 ||
134
+ canvas.width * canvas.height > 16_777_216
135
+ ) {
136
+ throw new RangeError('video canvas exceeds 16 megapixels');
137
+ }
138
+ const context = canvas.getContext('2d');
139
+ const stream = canvas.captureStream(fps);
140
+ const chunks = [];
141
+ let encodedBytes = 0;
142
+ const recorder = new globalThis.MediaRecorder(stream, {
143
+ mimeType,
144
+ videoBitsPerSecond: Math.max(
145
+ 100_000,
146
+ Math.round((quality ?? 75) * 100_000)
147
+ ),
148
+ });
149
+ const done = new Promise((resolve, reject) => {
150
+ recorder.ondataavailable = (event) => {
151
+ encodedBytes += event.data.size;
152
+ if (encodedBytes > 64 * 1024 * 1024) {
153
+ reject(new RangeError('encoded video exceeds 64 MiB'));
154
+ } else {
155
+ chunks.push(event.data);
156
+ }
157
+ };
158
+ recorder.onerror = (event) =>
159
+ reject(event.error ?? new Error('Video encoding failed'));
160
+ recorder.onstop = resolve;
161
+ });
162
+ // Keep early codec failures handled while frames are still being decoded.
163
+ done.catch(() => {});
164
+ try {
165
+ recorder.start();
166
+ for (const bytes of frames) {
167
+ image.src = `data:image/png;base64,${bytes}`;
168
+ await image.decode();
169
+ context.clearRect(0, 0, canvas.width, canvas.height);
170
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
171
+ await new Promise((resolve) => setTimeout(resolve, 1000 / fps));
172
+ }
173
+ recorder.stop();
174
+ await done;
175
+ return {
176
+ data: Array.from(
177
+ new Uint8Array(await new Blob(chunks, { type: mimeType }).arrayBuffer())
178
+ ),
179
+ };
180
+ } finally {
181
+ if (recorder.state !== 'inactive') {
182
+ recorder.stop();
183
+ }
184
+ stream.getTracks().forEach((track) => track.stop());
185
+ }
186
+ }
@@ -0,0 +1,68 @@
1
+ import { createCdpSession } from '../browser/cdp-session.js';
2
+
3
+ export function validateStableViewport(options, animations, caret) {
4
+ const changes = [
5
+ options.selector,
6
+ options.element,
7
+ options.clip,
8
+ options.fullPage,
9
+ options.omitBackground,
10
+ options.hideScrollbars,
11
+ animations !== 'allow',
12
+ caret !== 'initial',
13
+ ];
14
+ if (options.stableViewport && changes.some(Boolean)) {
15
+ throw new RangeError(
16
+ 'stableViewport requires the unmodified viewport: no region, background or visual styling options'
17
+ );
18
+ }
19
+ }
20
+
21
+ /** Capture the existing view; never scroll, resize, activate, or capture beyond it. */
22
+ export async function captureStableViewport(page, engine, native) {
23
+ // Native-view capture bypasses emulated pixel density. Keep the engine's viewport path.
24
+ const viewport =
25
+ engine === 'playwright' ? page.viewportSize?.() : page.viewport?.();
26
+ if (viewport) {
27
+ return { bytes: Buffer.from(await page.screenshot(native)), engine };
28
+ }
29
+ let session;
30
+ if (
31
+ (engine === 'playwright' && typeof page.context === 'function') ||
32
+ (engine === 'puppeteer' &&
33
+ (typeof page.createCDPSession === 'function' ||
34
+ typeof page.target === 'function'))
35
+ ) {
36
+ try {
37
+ session = await createCdpSession(page, { engine });
38
+ } catch (error) {
39
+ if (
40
+ error.name !== 'UnsupportedOperation' &&
41
+ !/only supported by Chromium|CDP.*not supported|CDP.*unsupported|CDP support is required|does not support CDP/i.test(
42
+ error.message
43
+ )
44
+ ) {
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+ if (session) {
50
+ try {
51
+ const { data } = await session.send('Page.captureScreenshot', {
52
+ format: native.type,
53
+ fromSurface: false,
54
+ captureBeyondViewport: false,
55
+ ...(native.quality === undefined ? {} : { quality: native.quality }),
56
+ });
57
+ return { bytes: Buffer.from(data, 'base64'), engine: 'cdp' };
58
+ } catch (error) {
59
+ // Some headless builds cannot capture a native view. Preserve every other error.
60
+ if (!/Unable to capture screenshot/i.test(error.message)) {
61
+ throw error;
62
+ }
63
+ } finally {
64
+ await session.detach();
65
+ }
66
+ }
67
+ return { bytes: Buffer.from(await page.screenshot(native)), engine };
68
+ }
@@ -0,0 +1,22 @@
1
+ /** Companion codec worker for Rust. Uses the same bounded portable encoders. */
2
+ import { encodeAnimation, encodeVideo } from './index.js';
3
+ let input = '';
4
+ for await (const chunk of process.stdin) {
5
+ input += chunk;
6
+ if (input.length > 96 * 1024 * 1024) {
7
+ throw new RangeError('codec input exceeds 96 MiB');
8
+ }
9
+ }
10
+ try {
11
+ const { frames, options } = JSON.parse(input);
12
+ const data = frames.map((frame) => Buffer.from(frame, 'base64'));
13
+ const result = ['webm', 'mp4', 'mov'].includes(options.format)
14
+ ? await encodeVideo(data, options)
15
+ : await encodeAnimation(data, options);
16
+ process.stdout.write(JSON.stringify({ data: result.toString('base64') }));
17
+ } catch (error) {
18
+ process.stdout.write(
19
+ JSON.stringify({ error: { code: error.code, message: error.message } })
20
+ );
21
+ process.exitCode = 1;
22
+ }
package/src/cli/args.js CHANGED
@@ -33,6 +33,8 @@ const BROWSER_OPTIONS = Object.freeze({
33
33
  'first-run': FLAG,
34
34
  'driver-path': STRING,
35
35
  bidi: FLAG,
36
+ 'remote-debugging-port': STRING,
37
+ 'idle-timeout-ms': STRING,
36
38
  });
37
39
 
38
40
  /** Options accepted by every page command. */
@@ -41,8 +43,40 @@ const PAGE_OPTIONS = Object.freeze({
41
43
  'cdp-endpoint': STRING,
42
44
  'server-url': STRING,
43
45
  url: STRING,
46
+ 'target-id': STRING,
47
+ 'single-tab': FLAG,
44
48
  });
45
49
 
50
+ const CAPTURE_OPTIONS = {
51
+ format: STRING,
52
+ quality: STRING,
53
+ fps: STRING,
54
+ scale: STRING,
55
+ loop: STRING,
56
+ palette: STRING,
57
+ dither: FLAG,
58
+ optimize: FLAG,
59
+ ffmpeg: STRING,
60
+ size: STRING,
61
+ };
62
+ const SCREENSHOT_OPTIONS = {
63
+ ...PAGE_OPTIONS,
64
+ 'full-page': FLAG,
65
+ 'stable-viewport': FLAG,
66
+ selector: STRING,
67
+ clip: STRING,
68
+ format: STRING,
69
+ quality: STRING,
70
+ scale: STRING,
71
+ 'omit-background': FLAG,
72
+ animations: STRING,
73
+ caret: STRING,
74
+ 'hide-scrollbars': FLAG,
75
+ 'hide-caret': FLAG,
76
+ 'disable-animations': FLAG,
77
+ 'wait-for-fonts': FLAG,
78
+ };
79
+
46
80
  /**
47
81
  * Every command: its positional argument names and its options.
48
82
  * Two-word commands (`trace start`) are keyed by both words.
@@ -66,12 +100,36 @@ export const COMMANDS = Object.freeze({
66
100
  eval: { positionals: ['expression'], options: PAGE_OPTIONS },
67
101
  screenshot: {
68
102
  positionals: ['path'],
69
- options: { ...PAGE_OPTIONS, 'full-page': FLAG },
103
+ options: SCREENSHOT_OPTIONS,
70
104
  },
71
105
  pdf: { positionals: ['path'], options: PAGE_OPTIONS },
72
106
  'trace start': { positionals: [], options: { ...PAGE_OPTIONS, out: STRING } },
73
107
  'trace stop': { positionals: [], options: { out: STRING } },
74
108
  'trace view': { positionals: ['dir'], options: { out: STRING } },
109
+ 'trace summarize': {
110
+ positionals: ['dir'],
111
+ options: { from: STRING, to: STRING, grep: STRING },
112
+ },
113
+ 'trace render': {
114
+ positionals: ['dir'],
115
+ options: { ...CAPTURE_OPTIONS, out: STRING, from: STRING, to: STRING },
116
+ },
117
+ 'record start': {
118
+ positionals: [],
119
+ options: {
120
+ ...PAGE_OPTIONS,
121
+ ...CAPTURE_OPTIONS,
122
+ out: STRING,
123
+ 'max-frames': STRING,
124
+ 'max-bytes': STRING,
125
+ 'max-duration-ms': STRING,
126
+ },
127
+ },
128
+ 'record stop': { positionals: [], options: { out: STRING } },
129
+ gif: {
130
+ positionals: [],
131
+ options: { ...CAPTURE_OPTIONS, frame: STRINGS, out: STRING },
132
+ },
75
133
  'cookies import': {
76
134
  positionals: [],
77
135
  options: {
@@ -255,6 +313,13 @@ export function launchParams(options) {
255
313
  firstRun: options.firstRun,
256
314
  driverPath: options.driverPath,
257
315
  bidi: options.bidi,
316
+ keepOpen: options.keepOpen,
317
+ remoteDebuggingPort: integerOption(
318
+ options,
319
+ 'remoteDebuggingPort',
320
+ 'launch'
321
+ ),
322
+ idleTimeoutMs: integerOption(options, 'idleTimeoutMs', 'launch'),
258
323
  };
259
324
  return Object.fromEntries(
260
325
  Object.entries(params).filter(([, value]) => value !== undefined)