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,327 @@
1
+ import { writeCapture } from './files.js';
2
+ import fs from 'node:fs/promises';
3
+ import { deflateSync } from 'node:zlib';
4
+ import UPNG from 'upng-js';
5
+ import webp from 'webp-wasm';
6
+ import gifenc from 'gifenc';
7
+ import { UnsupportedCaptureError } from './errors.js';
8
+ const { GIFEncoder, quantize, applyPalette } = gifenc;
9
+
10
+ export function resizeFrame(frame, scale = 1, size) {
11
+ const width = size?.width ?? Math.max(1, Math.round(frame.width * scale));
12
+ const height = size?.height ?? Math.max(1, Math.round(frame.height * scale));
13
+ if (
14
+ !Number.isSafeInteger(width) ||
15
+ !Number.isSafeInteger(height) ||
16
+ width < 1 ||
17
+ height < 1 ||
18
+ width * height > 16_777_216
19
+ ) {
20
+ throw new RangeError(
21
+ 'capture dimensions must be positive integers within 16 megapixels'
22
+ );
23
+ }
24
+ const data = new Uint8Array(width * height * 4);
25
+ for (let y = 0; y < height; y++) {
26
+ for (let x = 0; x < width; x++) {
27
+ const source =
28
+ (Math.min(frame.height - 1, Math.floor((y * frame.height) / height)) *
29
+ frame.width +
30
+ Math.min(frame.width - 1, Math.floor((x * frame.width) / width))) *
31
+ 4;
32
+ data.set(frame.data.subarray(source, source + 4), (y * width + x) * 4);
33
+ }
34
+ }
35
+ return { width, height, data };
36
+ }
37
+ export function decodePng(bytes) {
38
+ const data = Buffer.from(bytes);
39
+ if (
40
+ data.length < 33 ||
41
+ !data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
42
+ ) {
43
+ throw new RangeError('frame must be a PNG');
44
+ }
45
+ const pixels = data.readUInt32BE(16) * data.readUInt32BE(20);
46
+ if (pixels < 1 || pixels > 16_777_216) {
47
+ throw new RangeError('PNG exceeds 16 megapixels');
48
+ }
49
+ const decoded = UPNG.decode(
50
+ data.buffer.slice(data.byteOffset, data.byteOffset + data.length)
51
+ );
52
+ return {
53
+ width: decoded.width,
54
+ height: decoded.height,
55
+ data: new Uint8Array(UPNG.toRGBA8(decoded)[0]),
56
+ };
57
+ }
58
+ export function encodePng(frame) {
59
+ return Buffer.from(
60
+ UPNG.encode(
61
+ [
62
+ frame.data.buffer.slice(
63
+ frame.data.byteOffset,
64
+ frame.data.byteOffset + frame.data.length
65
+ ),
66
+ ],
67
+ frame.width,
68
+ frame.height,
69
+ 0,
70
+ undefined,
71
+ true
72
+ )
73
+ );
74
+ }
75
+
76
+ function chunk(tag, data) {
77
+ const header = Buffer.alloc(8);
78
+ header.write(tag);
79
+ header.writeUInt32LE(data.length, 4);
80
+ return Buffer.concat([
81
+ header,
82
+ data,
83
+ ...(data.length % 2 ? [Buffer.alloc(1)] : []),
84
+ ]);
85
+ }
86
+ function write24(buffer, value, offset) {
87
+ buffer.writeUIntLE(value, offset, 3);
88
+ }
89
+ async function animatedWebp(frames, { fps, quality = 75, loop = 0 }) {
90
+ const { width, height } = frames[0];
91
+ const extended = Buffer.alloc(10);
92
+ extended[0] = 0x12;
93
+ write24(extended, width - 1, 4);
94
+ write24(extended, height - 1, 7);
95
+ const animation = Buffer.alloc(6);
96
+ animation.writeUInt16LE(loop, 4);
97
+ const chunks = [chunk('VP8X', extended), chunk('ANIM', animation)];
98
+ for (const frame of frames) {
99
+ const encoded = await webp.encode(frame, { quality });
100
+ const imageChunks = [];
101
+ for (let offset = 12; offset < encoded.length;) {
102
+ const length = encoded.readUInt32LE(offset + 4);
103
+ const tag = encoded.subarray(offset, offset + 4).toString();
104
+ if (['ALPH', 'VP8 ', 'VP8L'].includes(tag)) {
105
+ imageChunks.push(
106
+ encoded.subarray(offset, offset + 8 + length + (length % 2))
107
+ );
108
+ }
109
+ offset += 8 + length + (length % 2);
110
+ }
111
+ const header = Buffer.alloc(16);
112
+ write24(header, width - 1, 6);
113
+ write24(header, height - 1, 9);
114
+ write24(header, Math.round(1000 / fps), 12);
115
+ header[15] = 2;
116
+ chunks.push(chunk('ANMF', Buffer.concat([header, ...imageChunks])));
117
+ }
118
+ const payload = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
119
+ const header = Buffer.alloc(8);
120
+ header.write('RIFF');
121
+ header.writeUInt32LE(payload.length, 4);
122
+ return Buffer.concat([header, payload]);
123
+ }
124
+
125
+ function pngChunk(tag, data) {
126
+ const bytes = Buffer.alloc(data.length + 12);
127
+ bytes.writeUInt32BE(data.length);
128
+ bytes.write(tag, 4);
129
+ data.copy(bytes, 8);
130
+ bytes.writeUInt32BE(
131
+ UPNG.crc.crc(bytes, 4, data.length + 4) >>> 0,
132
+ data.length + 8
133
+ );
134
+ return bytes;
135
+ }
136
+ function animatedPng(frames, fps, loop, optimize) {
137
+ const { width, height } = frames[0];
138
+ const ihdr = Buffer.alloc(13);
139
+ ihdr.writeUInt32BE(width);
140
+ ihdr.writeUInt32BE(height, 4);
141
+ ihdr[8] = 8;
142
+ ihdr[9] = 6;
143
+ const animation = Buffer.alloc(8);
144
+ animation.writeUInt32BE(frames.length);
145
+ animation.writeUInt32BE(loop, 4);
146
+ const chunks = [
147
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
148
+ pngChunk('IHDR', ihdr),
149
+ pngChunk('acTL', animation),
150
+ ];
151
+ let sequence = 0;
152
+ for (const [index, frame] of frames.entries()) {
153
+ const control = Buffer.alloc(26);
154
+ control.writeUInt32BE(sequence++);
155
+ control.writeUInt32BE(width, 4);
156
+ control.writeUInt32BE(height, 8);
157
+ control.writeUInt16BE(Math.round(1000 / fps), 20);
158
+ control.writeUInt16BE(1000, 22);
159
+ chunks.push(pngChunk('fcTL', control));
160
+ const scanlines = Buffer.alloc(height * (width * 4 + 1));
161
+ for (let y = 0; y < height; y++) {
162
+ scanlines.set(
163
+ frame.data.subarray(y * width * 4, (y + 1) * width * 4),
164
+ y * (width * 4 + 1) + 1
165
+ );
166
+ }
167
+ const compressed = deflateSync(scanlines, { level: optimize ? 9 : 1 });
168
+ if (index === 0) {
169
+ chunks.push(pngChunk('IDAT', compressed));
170
+ } else {
171
+ const number = Buffer.alloc(4);
172
+ number.writeUInt32BE(sequence++);
173
+ chunks.push(pngChunk('fdAT', Buffer.concat([number, compressed])));
174
+ }
175
+ }
176
+ chunks.push(pngChunk('IEND', Buffer.alloc(0)));
177
+ return Buffer.concat(chunks);
178
+ }
179
+ function ditherPalette(frame, palette) {
180
+ const data = Float32Array.from(frame.data);
181
+ const pixels = new Uint8Array(frame.width * frame.height);
182
+ const transparent = palette.findIndex((color) => color[3] === 0);
183
+ const add = (x, y, channel, error, weight) => {
184
+ if (x >= 0 && x < frame.width && y < frame.height) {
185
+ data[(y * frame.width + x) * 4 + channel] += error * weight;
186
+ }
187
+ };
188
+ for (let y = 0; y < frame.height; y++) {
189
+ for (let x = 0; x < frame.width; x++) {
190
+ const offset = (y * frame.width + x) * 4;
191
+ if (data[offset + 3] < 128 && transparent >= 0) {
192
+ pixels[y * frame.width + x] = transparent;
193
+ continue;
194
+ }
195
+ let best = 0,
196
+ distance = Infinity;
197
+ for (let i = 0; i < palette.length; i++) {
198
+ if (i === transparent) {
199
+ continue;
200
+ }
201
+ const delta = palette[i]
202
+ .slice(0, 3)
203
+ .reduce((sum, value, c) => sum + (data[offset + c] - value) ** 2, 0);
204
+ if (delta < distance) {
205
+ distance = delta;
206
+ best = i;
207
+ }
208
+ }
209
+ pixels[y * frame.width + x] = best;
210
+ for (let c = 0; c < 3; c++) {
211
+ const error = data[offset + c] - palette[best][c];
212
+ add(x + 1, y, c, error, 7 / 16);
213
+ add(x - 1, y + 1, c, error, 3 / 16);
214
+ add(x, y + 1, c, error, 5 / 16);
215
+ add(x + 1, y + 1, c, error, 1 / 16);
216
+ }
217
+ }
218
+ }
219
+ return pixels;
220
+ }
221
+
222
+ /** Portable GIF/APNG/WebP animation from PNG bytes or paths. */
223
+ export async function encodeAnimation(input, options = {}) {
224
+ const {
225
+ format = 'gif',
226
+ fps = 10,
227
+ scale = 1,
228
+ loop = 0,
229
+ palette = 256,
230
+ dither = false,
231
+ optimize = true,
232
+ path: output,
233
+ } = options;
234
+ validateAnimation(input, { fps, scale, loop, dither, format });
235
+ const frames = [];
236
+ let decodedBytes = 0;
237
+ for (const value of input) {
238
+ const png = typeof value === 'string' ? await fs.readFile(value) : value;
239
+ const data = Buffer.from(png);
240
+ if (data.length < 33) {
241
+ throw new RangeError('frame must be a PNG');
242
+ }
243
+ const sourceBytes = data.readUInt32BE(16) * data.readUInt32BE(20) * 4;
244
+ const targetBytes = options.size
245
+ ? options.size.width * options.size.height * 4
246
+ : sourceBytes * Math.max(1, scale * scale);
247
+ if (decodedBytes + sourceBytes + targetBytes > 256 * 1024 * 1024) {
248
+ throw new RangeError('decoded animation exceeds 256 MiB');
249
+ }
250
+ const frame = resizeFrame(decodePng(data), scale, options.size);
251
+ decodedBytes += frame.data.length;
252
+ frames.push(frame);
253
+ }
254
+ const { width, height } = frames[0];
255
+ if (
256
+ frames.some((frame) => frame.width !== width || frame.height !== height)
257
+ ) {
258
+ throw new RangeError('animation frames must have equal dimensions');
259
+ }
260
+ if (frames.length * width * height * 4 > 256 * 1024 * 1024) {
261
+ throw new RangeError('decoded animation exceeds 256 MiB');
262
+ }
263
+ let bytes;
264
+ if (format === 'gif') {
265
+ bytes = encodeGif(frames, { width, height, palette, dither, fps, loop });
266
+ } else if (format === 'apng') {
267
+ bytes = animatedPng(frames, fps, loop, optimize);
268
+ } else if (format === 'webp') {
269
+ bytes = await animatedWebp(frames, { ...options, fps, loop });
270
+ } else {
271
+ throw new UnsupportedCaptureError(format, 'animation encoder');
272
+ }
273
+ if (bytes.length > 64 * 1024 * 1024) {
274
+ throw new RangeError('encoded animation exceeds 64 MiB');
275
+ }
276
+ return writeCapture(bytes, output);
277
+ }
278
+
279
+ function validateAnimation(input, { fps, scale, loop, dither, format }) {
280
+ if (
281
+ !Number.isFinite(fps) ||
282
+ fps < 1 ||
283
+ fps > 60 ||
284
+ !Number.isFinite(scale) ||
285
+ scale <= 0 ||
286
+ scale > 8
287
+ ) {
288
+ throw new RangeError('fps must be 1–60 and scale must be >0 and <=8');
289
+ }
290
+ if (!Number.isInteger(loop) || loop < 0 || loop > 65535) {
291
+ throw new RangeError('loop must be 0–65535');
292
+ }
293
+ if (dither && format !== 'gif') {
294
+ throw new UnsupportedCaptureError('dither', format);
295
+ }
296
+ if (!Array.isArray(input) || input.length === 0 || input.length > 1000) {
297
+ throw new RangeError('animation requires 1–1000 frames');
298
+ }
299
+ }
300
+ function encodeGif(frames, { width, height, palette, dither, fps, loop }) {
301
+ if (
302
+ !Array.isArray(palette) &&
303
+ (!Number.isInteger(palette) || palette < 2 || palette > 256)
304
+ ) {
305
+ throw new RangeError('palette must be 2–256 colors or a palette array');
306
+ }
307
+ const gif = GIFEncoder();
308
+ for (const frame of frames) {
309
+ const colors = Array.isArray(palette)
310
+ ? palette
311
+ : quantize(frame.data, palette, { format: 'rgba4444' });
312
+ const pixels = dither
313
+ ? ditherPalette(frame, colors)
314
+ : applyPalette(frame.data, colors, 'rgba4444');
315
+ const transparentIndex = colors.findIndex((color) => color[3] === 0);
316
+ gif.writeFrame(pixels, width, height, {
317
+ palette: colors,
318
+ delay: 1000 / fps,
319
+ repeat: loop,
320
+ dispose: 2,
321
+ transparent: transparentIndex >= 0,
322
+ transparentIndex: Math.max(0, transparentIndex),
323
+ });
324
+ }
325
+ gif.finish();
326
+ return Buffer.from(gif.bytes());
327
+ }
@@ -0,0 +1,12 @@
1
+ /** A requested capture capability is unavailable on this engine or codec. */
2
+ export class UnsupportedCaptureError extends Error {
3
+ constructor(capability, engine, cause) {
4
+ super(`Capture capability "${capability}" is unavailable on ${engine}`, {
5
+ cause,
6
+ });
7
+ this.name = 'UnsupportedCaptureError';
8
+ this.code = 'UNSUPPORTED_CAPTURE';
9
+ this.capability = capability;
10
+ this.engine = engine;
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ /** Return capture bytes and optionally persist them with private permissions. */
4
+ export async function writeCapture(bytes, output) {
5
+ if (output) {
6
+ await fs.writeFile(output, bytes, { mode: 0o600 });
7
+ }
8
+ return bytes;
9
+ }
@@ -0,0 +1,5 @@
1
+ export { UnsupportedCaptureError } from './errors.js';
2
+ export { screenshot } from './screenshot.js';
3
+ export { encodeAnimation } from './encoding.js';
4
+ export { startRecording } from './recording.js';
5
+ export { encodeVideo } from './video.js';
@@ -0,0 +1,136 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { screenshot } from './screenshot.js';
3
+ import {
4
+ encodeAnimation,
5
+ decodePng,
6
+ encodePng,
7
+ resizeFrame,
8
+ } from './encoding.js';
9
+ import { encodeVideo } from './video.js';
10
+ import { UnsupportedCaptureError } from './errors.js';
11
+
12
+ /** A bounded, sequential screenshot recorder, usable on attached sessions. */
13
+ export async function startRecording(options = {}) {
14
+ options = { ...options, path: options.path ?? options.output };
15
+ const {
16
+ fps = 10,
17
+ maxFrames = 1000,
18
+ maxBytes = 64 * 1024 * 1024,
19
+ maxDurationMs = 60_000,
20
+ format = 'webm',
21
+ } = options;
22
+ if (
23
+ !Number.isFinite(fps) ||
24
+ fps < 1 ||
25
+ fps > 60 ||
26
+ !Number.isSafeInteger(maxFrames) ||
27
+ maxFrames < 1 ||
28
+ maxFrames > 1000 ||
29
+ !Number.isFinite(maxDurationMs) ||
30
+ maxDurationMs <= 0 ||
31
+ !Number.isSafeInteger(maxBytes) ||
32
+ maxBytes < 1
33
+ ) {
34
+ throw new RangeError('invalid recording bounds');
35
+ }
36
+ if (
37
+ !['frames', 'gif', 'apng', 'webp', 'webm', 'mp4', 'mov'].includes(format)
38
+ ) {
39
+ throw new UnsupportedCaptureError(format, options.engine);
40
+ }
41
+ if (['webm', 'mp4', 'mov'].includes(format) && !options.ffmpeg) {
42
+ const supported = await options.page.evaluate(
43
+ (format) =>
44
+ globalThis.MediaRecorder?.isTypeSupported(
45
+ { webm: 'video/webm', mp4: 'video/mp4' }[format] ?? ''
46
+ ) ?? false,
47
+ format
48
+ );
49
+ if (!supported) {
50
+ throw new UnsupportedCaptureError(
51
+ format,
52
+ options.engine ?? 'MediaRecorder'
53
+ );
54
+ }
55
+ }
56
+ const frames = [];
57
+ const controller = new AbortController();
58
+ let bytes = 0,
59
+ failure,
60
+ stopping,
61
+ truncated = false;
62
+ const started = performance.now();
63
+ const grab = async () => {
64
+ let data = await screenshot({
65
+ page: options.page,
66
+ engine: options.engine,
67
+ ...(options.screenshot ?? {}),
68
+ format: 'png',
69
+ });
70
+ if (options.size) {
71
+ data = encodePng(resizeFrame(decodePng(data), 1, options.size));
72
+ }
73
+ if (bytes + data.length > maxBytes) {
74
+ truncated = true;
75
+ return false;
76
+ }
77
+ frames.push(data);
78
+ bytes += data.length;
79
+ return true;
80
+ };
81
+ if (!(await grab())) {
82
+ throw new RangeError('The first recording frame exceeds maxBytes');
83
+ }
84
+ const task = (async () => {
85
+ while (!controller.signal.aborted) {
86
+ try {
87
+ await delay(Math.max(1, 1000 / fps), undefined, {
88
+ signal: controller.signal,
89
+ });
90
+ } catch {
91
+ break;
92
+ }
93
+ if (
94
+ frames.length >= maxFrames ||
95
+ performance.now() - started >= maxDurationMs
96
+ ) {
97
+ truncated = true;
98
+ break;
99
+ }
100
+ try {
101
+ if (!(await grab())) {
102
+ break;
103
+ }
104
+ } catch (error) {
105
+ failure = error;
106
+ break;
107
+ }
108
+ }
109
+ })();
110
+ const stop = (stopOptions = {}) => {
111
+ stopping ??= (async () => {
112
+ controller.abort();
113
+ await task;
114
+ if (failure) {
115
+ throw failure;
116
+ }
117
+ const encoding = { ...options, format, ...stopOptions, fps };
118
+ const data =
119
+ encoding.format === 'frames'
120
+ ? null
121
+ : ['gif', 'apng', 'webp'].includes(encoding.format)
122
+ ? await encodeAnimation(frames, encoding)
123
+ : await encodeVideo(frames, encoding);
124
+ return {
125
+ frames,
126
+ bytes: data,
127
+ path: encoding.path ?? null,
128
+ format: encoding.format,
129
+ fps,
130
+ truncated,
131
+ };
132
+ })();
133
+ return stopping;
134
+ };
135
+ return { stop, frames, fps };
136
+ }