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.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/connector.js +117 -13
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +51 -0
- package/src/core/page-trigger-manager.js +115 -30
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +2 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +28 -0
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- 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:
|
|
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)
|