browser-commander 0.26.3 → 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 (115) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +6 -0
  4. package/eslint.config.js +11 -0
  5. package/package.json +9 -4
  6. package/src/bindings.js +19 -0
  7. package/src/browser/attach/snapshot-launch.js +8 -6
  8. package/src/browser/attach/snapshot.js +39 -2
  9. package/src/browser/browser-cookie-database.js +71 -2
  10. package/src/browser/browser-cookie-session.js +183 -0
  11. package/src/browser/browser-cookies.js +29 -0
  12. package/src/browser/connector.js +117 -13
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launch-options.js +2 -1
  17. package/src/browser/launch-restrictions.json +33 -2
  18. package/src/browser/launcher.js +54 -9
  19. package/src/browser/migration/sqlite-snapshot.js +5 -27
  20. package/src/browser/navigation.js +173 -98
  21. package/src/browser/persistent-session.js +360 -0
  22. package/src/browser/profile-directory.js +20 -0
  23. package/src/browser/real-browser.js +142 -70
  24. package/src/browser/restrictions.js +13 -4
  25. package/src/browser/session-cookies.js +78 -0
  26. package/src/browser/session-persistence.js +72 -0
  27. package/src/browser/session-watchdog.js +71 -0
  28. package/src/browser/session-worker.js +70 -0
  29. package/src/browser/storage-state.js +32 -6
  30. package/src/browser/webdriver.js +4 -0
  31. package/src/capture/encoding.js +327 -0
  32. package/src/capture/errors.js +12 -0
  33. package/src/capture/files.js +9 -0
  34. package/src/capture/index.js +5 -0
  35. package/src/capture/recording.js +136 -0
  36. package/src/capture/screenshot.js +222 -0
  37. package/src/capture/video.js +186 -0
  38. package/src/capture/viewport.js +68 -0
  39. package/src/capture/worker.js +22 -0
  40. package/src/cli/args.js +66 -1
  41. package/src/cli/commands.js +113 -2
  42. package/src/cli/high-level-methods.js +40 -3
  43. package/src/cli/sessions.js +11 -3
  44. package/src/core/commander-features.js +105 -0
  45. package/src/core/constants.js +1 -0
  46. package/src/core/dialog-manager.js +7 -0
  47. package/src/core/managed-navigation.js +101 -0
  48. package/src/core/navigation-manager.js +66 -80
  49. package/src/core/navigation-operation.js +25 -0
  50. package/src/core/navigation-readiness.js +68 -16
  51. package/src/core/navigation-safety.js +51 -0
  52. package/src/core/network-tracker.js +39 -92
  53. package/src/core/page-session.js +3 -2
  54. package/src/core/page-trigger-manager.js +130 -42
  55. package/src/core/readiness.js +76 -14
  56. package/src/core/subscriptions.js +20 -0
  57. package/src/core/webdriver-page.js +38 -0
  58. package/src/elements/locators.js +61 -37
  59. package/src/elements/reusable.js +92 -0
  60. package/src/elements/selectors.js +37 -81
  61. package/src/elements/visibility.js +38 -21
  62. package/src/exports.js +14 -0
  63. package/src/factory.js +17 -16
  64. package/src/high-level/universal-logic.js +57 -21
  65. package/src/index.js +2 -0
  66. package/src/interactions/click.js +47 -0
  67. package/src/interactions/overlays.js +28 -0
  68. package/src/interactions/scroll.js +8 -2
  69. package/src/traces/bundle.js +14 -5
  70. package/src/traces/checkpoints.js +99 -0
  71. package/src/traces/links.js +117 -1
  72. package/src/traces/mutation-stream.js +1 -0
  73. package/src/traces/network.js +257 -0
  74. package/src/traces/page-capture.js +143 -52
  75. package/src/traces/reader.js +8 -10
  76. package/src/traces/recorder.js +221 -245
  77. package/src/traces/render.js +168 -0
  78. package/src/traces/rolling.js +180 -0
  79. package/src/traces/start-cleanup.js +15 -0
  80. package/src/traces/stop.js +159 -0
  81. package/src/traces/storage.js +43 -0
  82. package/src/utilities/subprocess.js +47 -4
  83. package/tests/e2e/cli.e2e.test.js +20 -1
  84. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  85. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  86. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  87. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  88. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  89. package/tests/unit/browser/default-browser.test.js +18 -0
  90. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  91. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  92. package/tests/unit/browser/launcher.test.js +5 -1
  93. package/tests/unit/browser/persistent-session.test.js +59 -0
  94. package/tests/unit/browser/quiet-ui.test.js +16 -0
  95. package/tests/unit/browser/real-browser.test.js +1 -1
  96. package/tests/unit/browser/session-workflows.test.js +206 -0
  97. package/tests/unit/browser/storage-state.test.js +7 -2
  98. package/tests/unit/browser/tab-selection.test.js +38 -0
  99. package/tests/unit/capture/capture.test.js +52 -0
  100. package/tests/unit/capture/cleanup.test.js +24 -0
  101. package/tests/unit/capture/viewport.test.js +168 -0
  102. package/tests/unit/cli/dispatcher.test.js +25 -0
  103. package/tests/unit/core/navigation-budget.test.js +120 -0
  104. package/tests/unit/core/navigation-manager.test.js +23 -1
  105. package/tests/unit/core/readiness.test.js +21 -0
  106. package/tests/unit/core/trigger-readiness.test.js +39 -0
  107. package/tests/unit/core/webdriver-page.test.js +33 -0
  108. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  109. package/tests/unit/elements/visible-count.test.js +24 -0
  110. package/tests/unit/elements/wait-navigation.test.js +26 -0
  111. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  112. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  113. package/tests/unit/traces/bundle.test.js +16 -0
  114. package/tests/unit/traces/links.test.js +2 -2
  115. package/tests/unit/traces/network.test.js +97 -0
@@ -1,4 +1,6 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, writeFile, rename, rm } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { normalizeSessionCookies } from './session-cookies.js';
2
4
 
3
5
  /**
4
6
  * Load a Playwright-compatible storage state from a JSON path or object.
@@ -91,7 +93,9 @@ export async function restorePuppeteerStorageState({ page, storageState }) {
91
93
 
92
94
  const cookies = storageState.cookies ?? [];
93
95
  if (cookies.length > 0) {
94
- await page.setCookie(...cookies);
96
+ const context = page.browserContext?.();
97
+ const target = typeof context?.setCookie === 'function' ? context : page;
98
+ await target.setCookie(...normalizeSessionCookies(cookies, 'puppeteer'));
95
99
  }
96
100
 
97
101
  const origins = storageState.origins ?? [];
@@ -115,7 +119,7 @@ export async function restorePlaywrightStorageState({ context, storageState }) {
115
119
 
116
120
  const cookies = storageState.cookies ?? [];
117
121
  if (cookies.length > 0) {
118
- await context.addCookies(cookies);
122
+ await context.addCookies(normalizeSessionCookies(cookies, 'playwright'));
119
123
  }
120
124
 
121
125
  const origins = storageState.origins ?? [];
@@ -137,7 +141,11 @@ function readCurrentLocalStorage() {
137
141
  }
138
142
 
139
143
  async function collectPuppeteerStorageState(page) {
140
- const cookies = await page.cookies();
144
+ const context = page.browserContext?.();
145
+ const cookies =
146
+ typeof context?.cookies === 'function'
147
+ ? await context.cookies()
148
+ : await page.cookies();
141
149
  const pageUrl = page.url();
142
150
  const origin = new URL(pageUrl).origin;
143
151
  const origins = [];
@@ -161,7 +169,11 @@ async function collectPuppeteerStorageState(page) {
161
169
  export async function saveStorageState(page, filePath) {
162
170
  const context = typeof page.context === 'function' ? page.context() : null;
163
171
  if (typeof context?.storageState === 'function') {
164
- return context.storageState(filePath ? { path: filePath } : undefined);
172
+ const state = await context.storageState();
173
+ if (filePath) {
174
+ await writeRestrictedState(filePath, state);
175
+ }
176
+ return state;
165
177
  }
166
178
 
167
179
  if (
@@ -174,7 +186,21 @@ export async function saveStorageState(page, filePath) {
174
186
 
175
187
  const storageState = await collectPuppeteerStorageState(page);
176
188
  if (filePath) {
177
- await writeFile(filePath, `${JSON.stringify(storageState, null, 2)}\n`);
189
+ await writeRestrictedState(filePath, storageState);
178
190
  }
179
191
  return storageState;
180
192
  }
193
+
194
+ /** Replace state atomically with an owner-only file, without following a destination symlink. */
195
+ export async function writeRestrictedState(filePath, state) {
196
+ const temporary = `${filePath}.${randomUUID()}.tmp`;
197
+ try {
198
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
199
+ mode: 0o600,
200
+ flag: 'wx',
201
+ });
202
+ await rename(temporary, filePath);
203
+ } finally {
204
+ await rm(temporary, { force: true });
205
+ }
206
+ }
@@ -326,6 +326,7 @@ export function buildBrowserOptions({
326
326
  remoteDebuggingPort,
327
327
  headless,
328
328
  restrictions,
329
+ disableFeatures,
329
330
  args,
330
331
  automationParity,
331
332
  bidi,
@@ -358,6 +359,7 @@ export function buildBrowserOptions({
358
359
  remoteDebuggingPort,
359
360
  headless,
360
361
  restrictions,
362
+ disableFeatures,
361
363
  args,
362
364
  automationParity,
363
365
  });
@@ -556,6 +558,7 @@ async function launchWebDriverOnce(options, dependencies) {
556
558
  bidi = false,
557
559
  args = [],
558
560
  restrictions = [],
561
+ disableFeatures = [],
559
562
  automationParity = true,
560
563
  env,
561
564
  startupTimeout = 30_000,
@@ -627,6 +630,7 @@ async function launchWebDriverOnce(options, dependencies) {
627
630
  browser === 'chrome' ? await reservePort() : undefined,
628
631
  headless,
629
632
  restrictions,
633
+ disableFeatures,
630
634
  args,
631
635
  automationParity,
632
636
  bidi,
@@ -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
+ }