codeplay-common 4.3.9 → 4.4.1

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.
@@ -1,29 +1,29 @@
1
- const fs = require('node:fs');
2
- const path = require('node:path');
3
- const readline = require('node:readline/promises');
4
- const { spawn, spawnSync } = require('node:child_process');
5
-
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const readline = require('node:readline/promises');
4
+ const { spawn, spawnSync } = require('node:child_process');
5
+
6
6
  const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
7
7
  const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
8
8
  const amazonAssetsDirectory = path.join(__dirname, 'Auto-Screenshot', 'Amazon-assets');
9
9
  const samsungAssetsDirectory = path.join(__dirname, 'Auto-Screenshot', 'Samsung-assets');
10
10
  const capacitorConfigPath = path.join(__dirname, 'capacitor.config.json');
11
11
  const amazonIconPath = path.join(__dirname, 'resources', 'icon-only.png');
12
- const temporaryDirectory = path.join(__dirname, 'agent-temp');
13
- const browserExecutablePaths = [
14
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
15
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
16
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
17
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
18
- ];
19
-
20
- const deviceProfiles = [
21
- {
22
- name: 'android-phone',
23
- label: 'Android phone',
24
- outputDirectory: 'Android',
25
- frameType: 'android-phone',
26
- width: 360,
12
+ const temporaryDirectory = path.join(__dirname, 'agent-temp');
13
+ const browserExecutablePaths = [
14
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
15
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
16
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
17
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
18
+ ];
19
+
20
+ const deviceProfiles = [
21
+ {
22
+ name: 'android-phone',
23
+ label: 'Android phone',
24
+ outputDirectory: 'Android',
25
+ frameType: 'android-phone',
26
+ width: 360,
27
27
  height: 800,
28
28
  deviceScaleFactor: 3,
29
29
  orientationSensitive: true,
@@ -58,24 +58,24 @@ const deviceProfiles = [
58
58
  },
59
59
  {
60
60
  name: 'iphone',
61
- label: 'iPhone 6.9-inch App Store viewport',
62
- outputDirectory: 'iPhone',
63
- frameType: 'iphone',
64
- width: 440,
65
- height: 956,
61
+ label: 'iPhone 6.9-inch App Store viewport',
62
+ outputDirectory: 'iPhone',
63
+ frameType: 'iphone',
64
+ width: 440,
65
+ height: 956,
66
66
  deviceScaleFactor: 3,
67
67
  framedWidth: 1284,
68
68
  framedHeight: 2778,
69
69
  orientationSensitive: true,
70
70
  mobile: true,
71
- },
72
- {
73
- name: 'ipad',
74
- label: 'iPad 13-inch App Store viewport',
75
- outputDirectory: 'iPad',
76
- frameType: 'tablet',
77
- width: 1032,
78
- height: 1376,
71
+ },
72
+ {
73
+ name: 'ipad',
74
+ label: 'iPad 13-inch App Store viewport',
75
+ outputDirectory: 'iPad',
76
+ frameType: 'tablet',
77
+ width: 1032,
78
+ height: 1376,
79
79
  deviceScaleFactor: 2,
80
80
  framedWidth: 2048,
81
81
  framedHeight: 2732,
@@ -118,48 +118,48 @@ const deviceProfiles = [
118
118
  framedHeight: 514,
119
119
  mobile: true,
120
120
  },
121
- {
122
- name: 'amazon-mobile',
123
- label: 'Amazon mobile store viewport',
124
- outputDirectory: 'Amazon-Phone',
125
- frameType: 'android-phone',
126
- width: 360,
121
+ {
122
+ name: 'amazon-mobile',
123
+ label: 'Amazon mobile store viewport',
124
+ outputDirectory: 'Amazon-Phone',
125
+ frameType: 'android-phone',
126
+ width: 360,
127
127
  height: 640,
128
128
  deviceScaleFactor: 3,
129
129
  orientationSensitive: true,
130
130
  mobile: true,
131
- },
132
- {
133
- name: 'amazon-fire-tablet',
134
- label: 'Amazon Fire tablet store viewport',
135
- outputDirectory: 'Amazon-Tab',
136
- frameType: 'tablet',
137
- width: 800,
131
+ },
132
+ {
133
+ name: 'amazon-fire-tablet',
134
+ label: 'Amazon Fire tablet store viewport',
135
+ outputDirectory: 'Amazon-Tab',
136
+ frameType: 'tablet',
137
+ width: 800,
138
138
  height: 1280,
139
139
  deviceScaleFactor: 2,
140
140
  orientationSensitive: true,
141
141
  mobile: true,
142
- },
143
- {
144
- name: 'amazon-fire-tv',
145
- label: 'Amazon Fire TV store viewport',
146
- outputDirectory: 'Amazon-TV',
147
- frameType: 'tv',
148
- width: 1920,
149
- height: 1080,
150
- deviceScaleFactor: 1,
151
- mobile: false,
152
- },
153
- {
154
- name: 'android-tv',
155
- label: 'Android TV',
156
- outputDirectory: 'Android-TV',
157
- frameType: 'tv',
158
- width: 1920,
159
- height: 1080,
160
- deviceScaleFactor: 1,
161
- mobile: false,
162
- },
142
+ },
143
+ {
144
+ name: 'amazon-fire-tv',
145
+ label: 'Amazon Fire TV store viewport',
146
+ outputDirectory: 'Amazon-TV',
147
+ frameType: 'tv',
148
+ width: 1920,
149
+ height: 1080,
150
+ deviceScaleFactor: 1,
151
+ mobile: false,
152
+ },
153
+ {
154
+ name: 'android-tv',
155
+ label: 'Android TV',
156
+ outputDirectory: 'Android-TV',
157
+ frameType: 'tv',
158
+ width: 1920,
159
+ height: 1080,
160
+ deviceScaleFactor: 1,
161
+ mobile: false,
162
+ },
163
163
  {
164
164
  name: 'chromebook',
165
165
  label: 'Chromebook 4K Play Store viewport',
@@ -172,26 +172,26 @@ const deviceProfiles = [
172
172
  framedHeight: 2160,
173
173
  mobile: false,
174
174
  },
175
- {
176
- name: 'android-xr',
177
- label: 'Android XR 2D panel approximation',
178
- outputDirectory: 'Android-XR',
179
- frameType: 'xr',
180
- width: 1920,
181
- height: 1080,
182
- deviceScaleFactor: 1,
183
- mobile: false,
184
- },
185
- {
186
- name: 'mac',
187
- label: 'Mac-sized viewport',
188
- outputDirectory: 'Mac',
189
- frameType: 'laptop',
190
- width: 1440,
191
- height: 900,
192
- deviceScaleFactor: 2,
193
- mobile: false,
194
- },
175
+ {
176
+ name: 'android-xr',
177
+ label: 'Android XR 2D panel approximation',
178
+ outputDirectory: 'Android-XR',
179
+ frameType: 'xr',
180
+ width: 1920,
181
+ height: 1080,
182
+ deviceScaleFactor: 1,
183
+ mobile: false,
184
+ },
185
+ {
186
+ name: 'mac',
187
+ label: 'Mac-sized viewport',
188
+ outputDirectory: 'Mac',
189
+ frameType: 'laptop',
190
+ width: 1440,
191
+ height: 900,
192
+ deviceScaleFactor: 2,
193
+ mobile: false,
194
+ },
195
195
  ];
196
196
 
197
197
  function getConfiguredOrientation() {
@@ -214,192 +214,192 @@ function orientProfile(profile, orientation) {
214
214
  framedHeight: profile.framedWidth,
215
215
  };
216
216
  }
217
-
218
- function runAdb(args, options = {}) {
219
- const result = spawnSync('adb', args, {
220
- encoding: 'utf8',
221
- timeout: 30000,
222
- windowsHide: true,
223
- ...options,
224
- });
225
-
226
- if (result.error) {
227
- if (result.error.code === 'ENOENT') {
228
- throw new Error('ADB was not found. Add Android platform-tools to your PATH.');
229
- }
230
-
231
- if (result.error.code === 'ETIMEDOUT') {
232
- throw new Error('ADB did not respond within 30 seconds. Check the device connection.');
233
- }
234
-
235
- throw result.error;
236
- }
237
-
238
- if (result.status !== 0) {
239
- throw new Error(result.stderr.trim() || `ADB exited with code ${result.status}.`);
240
- }
241
-
242
- return result.stdout.trim();
243
- }
244
-
245
- function getConnectedDevice() {
246
- const devices = runAdb(['devices'])
247
- .split(/\r?\n/)
248
- .map((line) => line.match(/^(\S+)\s+device$/))
249
- .filter(Boolean)
250
- .map((match) => match[1]);
251
-
252
- if (!devices.length) {
253
- throw new Error('no_connected_device');
254
- }
255
-
256
- return devices[0];
257
- }
258
-
259
- function getAppId() {
260
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
261
-
262
- if (!config.appId) {
263
- throw new Error('capacitor.config.json does not contain an appId.');
264
- }
265
-
266
- return config.appId;
267
- }
268
-
269
- function getWebViewSocket(deviceId, appId) {
270
- const processIds = runAdb(['-s', deviceId, 'shell', 'pidof', appId])
271
- .split(/\s+/)
272
- .filter(Boolean);
273
-
274
- if (!processIds.length) {
275
- throw new Error(`The app process is not running: ${appId}`);
276
- }
277
-
278
- const sockets = runAdb(['-s', deviceId, 'shell', 'cat', '/proc/net/unix']);
279
-
280
- for (const processId of processIds) {
281
- const socketName = `webview_devtools_remote_${processId}`;
282
-
283
- if (sockets.includes(`@${socketName}`)) {
284
- return socketName;
285
- }
286
- }
287
-
288
- throw new Error(
289
- 'The running app does not expose a debuggable WebView. Use a debug/live-reload build with WebView debugging enabled.',
290
- );
291
- }
292
-
293
- function sanitizeName(input, fallback) {
294
- const sanitized = input
295
- .trim()
296
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
297
- .replace(/[. ]+$/g, '');
298
-
299
- return sanitized || fallback;
300
- }
301
-
302
- function getSelectedOutputDirectories(selectedProfiles) {
303
- return [...new Set(selectedProfiles.flatMap((profile) => ([
304
- path.join(screenshotDirectory, profile.outputDirectory),
305
- path.join(inFrameDirectory, profile.outputDirectory),
306
- ])))];
307
- }
308
-
309
- function getNextNumberedFileName(selectedProfiles) {
310
- const highestNumber = getSelectedOutputDirectories(selectedProfiles)
311
- .filter((directory) => fs.existsSync(directory))
312
- .reduce((highestAcrossDirectories, directory) => {
313
- const highestInDirectory = fs.readdirSync(directory, { withFileTypes: true })
314
- .filter((entry) => entry.isFile())
315
- .reduce((highest, entry) => {
316
- const match = entry.name.match(/^(\d+)\.png$/i);
317
- return match ? Math.max(highest, Number(match[1])) : highest;
318
- }, 0);
319
-
320
- return Math.max(highestAcrossDirectories, highestInDirectory);
321
- }, 0);
322
-
323
- return `${highestNumber + 1}.png`;
324
- }
325
-
326
- function getUniqueFileName(prefix, selectedProfiles) {
327
- const outputDirectories = getSelectedOutputDirectories(selectedProfiles);
328
- let fileName = `${prefix}.png`;
329
- let duplicateNumber = 2;
330
-
331
- while (outputDirectories.some((directory) => (
332
- fs.existsSync(path.join(directory, fileName))
333
- ))) {
334
- fileName = `${prefix}-${duplicateNumber}.png`;
335
- duplicateNumber += 1;
336
- }
337
-
338
- return fileName;
339
- }
340
-
341
- function getScreenshotFileName(fileNameInput, selectedProfiles) {
342
- const inputWithoutExtension = fileNameInput.trim().replace(/\.png$/i, '');
343
-
344
- if (!inputWithoutExtension) {
345
- return getNextNumberedFileName(selectedProfiles);
346
- }
347
-
348
- let prefix = sanitizeName(inputWithoutExtension, '');
349
-
350
- if (!prefix) {
351
- return getNextNumberedFileName(selectedProfiles);
352
- }
353
-
354
- if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(prefix)) {
355
- prefix = `${prefix}_`;
356
- }
357
-
358
- return getUniqueFileName(prefix, selectedProfiles);
359
- }
360
-
361
- function parseArguments() {
362
- const args = process.argv.slice(2);
217
+
218
+ function runAdb(args, options = {}) {
219
+ const result = spawnSync('adb', args, {
220
+ encoding: 'utf8',
221
+ timeout: 30000,
222
+ windowsHide: true,
223
+ ...options,
224
+ });
225
+
226
+ if (result.error) {
227
+ if (result.error.code === 'ENOENT') {
228
+ throw new Error('ADB was not found. Add Android platform-tools to your PATH.');
229
+ }
230
+
231
+ if (result.error.code === 'ETIMEDOUT') {
232
+ throw new Error('ADB did not respond within 30 seconds. Check the device connection.');
233
+ }
234
+
235
+ throw result.error;
236
+ }
237
+
238
+ if (result.status !== 0) {
239
+ throw new Error(result.stderr.trim() || `ADB exited with code ${result.status}.`);
240
+ }
241
+
242
+ return result.stdout.trim();
243
+ }
244
+
245
+ function getConnectedDevice() {
246
+ const devices = runAdb(['devices'])
247
+ .split(/\r?\n/)
248
+ .map((line) => line.match(/^(\S+)\s+device$/))
249
+ .filter(Boolean)
250
+ .map((match) => match[1]);
251
+
252
+ if (!devices.length) {
253
+ throw new Error('no_connected_device');
254
+ }
255
+
256
+ return devices[0];
257
+ }
258
+
259
+ function getAppId() {
260
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
261
+
262
+ if (!config.appId) {
263
+ throw new Error('capacitor.config.json does not contain an appId.');
264
+ }
265
+
266
+ return config.appId;
267
+ }
268
+
269
+ function getWebViewSocket(deviceId, appId) {
270
+ const processIds = runAdb(['-s', deviceId, 'shell', 'pidof', appId])
271
+ .split(/\s+/)
272
+ .filter(Boolean);
273
+
274
+ if (!processIds.length) {
275
+ throw new Error(`The app process is not running: ${appId}`);
276
+ }
277
+
278
+ const sockets = runAdb(['-s', deviceId, 'shell', 'cat', '/proc/net/unix']);
279
+
280
+ for (const processId of processIds) {
281
+ const socketName = `webview_devtools_remote_${processId}`;
282
+
283
+ if (sockets.includes(`@${socketName}`)) {
284
+ return socketName;
285
+ }
286
+ }
287
+
288
+ throw new Error(
289
+ 'The running app does not expose a debuggable WebView. Use a debug/live-reload build with WebView debugging enabled.',
290
+ );
291
+ }
292
+
293
+ function sanitizeName(input, fallback) {
294
+ const sanitized = input
295
+ .trim()
296
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
297
+ .replace(/[. ]+$/g, '');
298
+
299
+ return sanitized || fallback;
300
+ }
301
+
302
+ function getSelectedOutputDirectories(selectedProfiles) {
303
+ return [...new Set(selectedProfiles.flatMap((profile) => ([
304
+ path.join(screenshotDirectory, profile.outputDirectory),
305
+ path.join(inFrameDirectory, profile.outputDirectory),
306
+ ])))];
307
+ }
308
+
309
+ function getNextNumberedFileName(selectedProfiles) {
310
+ const highestNumber = getSelectedOutputDirectories(selectedProfiles)
311
+ .filter((directory) => fs.existsSync(directory))
312
+ .reduce((highestAcrossDirectories, directory) => {
313
+ const highestInDirectory = fs.readdirSync(directory, { withFileTypes: true })
314
+ .filter((entry) => entry.isFile())
315
+ .reduce((highest, entry) => {
316
+ const match = entry.name.match(/^(\d+)\.png$/i);
317
+ return match ? Math.max(highest, Number(match[1])) : highest;
318
+ }, 0);
319
+
320
+ return Math.max(highestAcrossDirectories, highestInDirectory);
321
+ }, 0);
322
+
323
+ return `${highestNumber + 1}.png`;
324
+ }
325
+
326
+ function getUniqueFileName(prefix, selectedProfiles) {
327
+ const outputDirectories = getSelectedOutputDirectories(selectedProfiles);
328
+ let fileName = `${prefix}.png`;
329
+ let duplicateNumber = 2;
330
+
331
+ while (outputDirectories.some((directory) => (
332
+ fs.existsSync(path.join(directory, fileName))
333
+ ))) {
334
+ fileName = `${prefix}-${duplicateNumber}.png`;
335
+ duplicateNumber += 1;
336
+ }
337
+
338
+ return fileName;
339
+ }
340
+
341
+ function getScreenshotFileName(fileNameInput, selectedProfiles) {
342
+ const inputWithoutExtension = fileNameInput.trim().replace(/\.png$/i, '');
343
+
344
+ if (!inputWithoutExtension) {
345
+ return getNextNumberedFileName(selectedProfiles);
346
+ }
347
+
348
+ let prefix = sanitizeName(inputWithoutExtension, '');
349
+
350
+ if (!prefix) {
351
+ return getNextNumberedFileName(selectedProfiles);
352
+ }
353
+
354
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(prefix)) {
355
+ prefix = `${prefix}_`;
356
+ }
357
+
358
+ return getUniqueFileName(prefix, selectedProfiles);
359
+ }
360
+
361
+ function parseArguments() {
362
+ const args = process.argv.slice(2);
363
363
  const options = {
364
364
  amazonAssets: false,
365
365
  samsungAssets: false,
366
- dryRun: false,
367
- frameExisting: false,
368
- help: false,
369
- list: false,
370
- prefix: '',
371
- profiles: '',
372
- };
373
-
374
- for (let index = 0; index < args.length; index += 1) {
375
- const argument = args[index];
376
-
366
+ dryRun: false,
367
+ frameExisting: false,
368
+ help: false,
369
+ list: false,
370
+ prefix: '',
371
+ profiles: '',
372
+ };
373
+
374
+ for (let index = 0; index < args.length; index += 1) {
375
+ const argument = args[index];
376
+
377
377
  if (argument === '--amazon-assets') {
378
378
  options.amazonAssets = true;
379
379
  } else if (argument === '--samsung-assets') {
380
380
  options.samsungAssets = true;
381
381
  } else if (argument === '--dry-run') {
382
- options.dryRun = true;
383
- } else if (argument === '--frame-existing') {
384
- options.frameExisting = true;
385
- } else if (argument === '--help' || argument === '-h') {
386
- options.help = true;
387
- } else if (argument === '--list') {
388
- options.list = true;
389
- } else if (argument === '--prefix') {
390
- options.prefix = args[index + 1] || '';
391
- index += 1;
392
- } else if (argument === '--profiles') {
393
- options.profiles = args[index + 1] || '';
394
- index += 1;
395
- } else {
396
- throw new Error(`Unknown argument: ${argument}`);
397
- }
398
- }
399
-
400
- return options;
401
- }
402
-
382
+ options.dryRun = true;
383
+ } else if (argument === '--frame-existing') {
384
+ options.frameExisting = true;
385
+ } else if (argument === '--help' || argument === '-h') {
386
+ options.help = true;
387
+ } else if (argument === '--list') {
388
+ options.list = true;
389
+ } else if (argument === '--prefix') {
390
+ options.prefix = args[index + 1] || '';
391
+ index += 1;
392
+ } else if (argument === '--profiles') {
393
+ options.profiles = args[index + 1] || '';
394
+ index += 1;
395
+ } else {
396
+ throw new Error(`Unknown argument: ${argument}`);
397
+ }
398
+ }
399
+
400
+ return options;
401
+ }
402
+
403
403
  function printProfiles() {
404
404
  const orientation = getConfiguredOrientation();
405
405
 
@@ -417,11 +417,11 @@ function printProfiles() {
417
417
  );
418
418
  }
419
419
  }
420
-
421
- function printHelp() {
422
- console.log('Capture the current live Android WebView at multiple responsive viewport sizes.');
423
- console.log('');
424
- console.log('Usage:');
420
+
421
+ function printHelp() {
422
+ console.log('Capture the current live Android WebView at multiple responsive viewport sizes.');
423
+ console.log('');
424
+ console.log('Usage:');
425
425
  console.log(' node take-screen-image.js');
426
426
  console.log(' node take-screen-image.js --profiles iphone,ipad');
427
427
  console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
@@ -430,464 +430,470 @@ function printHelp() {
430
430
  console.log(' node take-screen-image.js --samsung-assets');
431
431
  console.log(' node take-screen-image.js --dry-run');
432
432
  console.log(' node take-screen-image.js --list');
433
- }
434
-
433
+ }
434
+
435
435
  function selectProfiles(input) {
436
436
  const orientation = getConfiguredOrientation();
437
437
  const names = input
438
- .split(',')
439
- .map((name) => name.trim().toLowerCase())
440
- .filter(Boolean);
441
-
438
+ .split(',')
439
+ .map((name) => name.trim().toLowerCase())
440
+ .filter(Boolean);
441
+
442
442
  if (!names.length || names.includes('all')) {
443
443
  return deviceProfiles.map((profile) => orientProfile(profile, orientation));
444
- }
445
-
446
- const selectedProfiles = names.map((name) => {
444
+ }
445
+
446
+ const selectedProfiles = names.map((name) => {
447
447
  const profile = deviceProfiles.find((candidate) => (
448
448
  candidate.name === name || candidate.aliases?.includes(name)
449
449
  ));
450
-
451
- if (!profile) {
452
- throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
453
- }
454
-
455
- return profile;
456
- });
457
-
450
+
451
+ if (!profile) {
452
+ throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
453
+ }
454
+
455
+ return profile;
456
+ });
457
+
458
458
  return [...new Map(selectedProfiles.map((profile) => [profile.name, profile])).values()]
459
459
  .map((profile) => orientProfile(profile, orientation));
460
460
  }
461
-
462
- class CdpClient {
463
- constructor(webSocketUrl) {
464
- this.nextId = 1;
465
- this.pendingCommands = new Map();
466
- this.socket = new WebSocket(webSocketUrl);
467
- }
468
-
469
- connect() {
470
- return new Promise((resolve, reject) => {
471
- const timeout = setTimeout(() => {
472
- reject(new Error('Timed out while connecting to the app WebView.'));
473
- }, 10000);
474
-
475
- this.socket.addEventListener('open', () => {
476
- clearTimeout(timeout);
477
- resolve();
478
- }, { once: true });
479
-
480
- this.socket.addEventListener('error', () => {
481
- clearTimeout(timeout);
482
- reject(new Error('Could not connect to the app WebView debugging socket.'));
483
- }, { once: true });
484
-
485
- this.socket.addEventListener('message', (event) => {
486
- const message = JSON.parse(event.data);
487
-
488
- if (!message.id || !this.pendingCommands.has(message.id)) {
489
- return;
490
- }
491
-
492
- const pendingCommand = this.pendingCommands.get(message.id);
493
- this.pendingCommands.delete(message.id);
494
-
495
- if (message.error) {
496
- pendingCommand.reject(new Error(message.error.message));
497
- } else {
498
- pendingCommand.resolve(message.result);
499
- }
500
- });
501
-
502
- this.socket.addEventListener('close', () => {
503
- for (const pendingCommand of this.pendingCommands.values()) {
504
- pendingCommand.reject(new Error('The app WebView debugging connection closed.'));
505
- }
506
-
507
- this.pendingCommands.clear();
508
- });
509
- });
510
- }
511
-
512
- send(method, params = {}) {
513
- return new Promise((resolve, reject) => {
514
- const id = this.nextId;
515
- this.nextId += 1;
516
- this.pendingCommands.set(id, { resolve, reject });
517
- this.socket.send(JSON.stringify({ id, method, params }));
518
- });
519
- }
520
-
521
- close() {
522
- this.socket.close();
523
- }
524
- }
525
-
526
- async function getWebViewTarget(localPort) {
527
- const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
528
-
529
- if (!response.ok) {
530
- throw new Error(`WebView target discovery failed with HTTP ${response.status}.`);
531
- }
532
-
533
- const targets = await response.json();
534
- const pageTargets = targets.filter((target) => (
535
- target.type === 'page'
536
- && target.webSocketDebuggerUrl
537
- && target.url !== 'about:blank'
538
- ));
539
-
540
- const visibleTarget = pageTargets.find((target) => {
541
- try {
542
- return JSON.parse(target.description || '{}').visible !== false;
543
- } catch {
544
- return true;
545
- }
546
- });
547
-
548
- const target = visibleTarget || pageTargets[0];
549
-
550
- if (!target) {
551
- throw new Error('No rendered page was found in the app WebView.');
552
- }
553
-
554
- return target;
555
- }
556
-
557
- async function getPageDetails(client) {
558
- const evaluation = await client.send('Runtime.evaluate', {
559
- expression: `JSON.stringify({
560
- title: document.title,
561
- url: location.href,
562
- width: innerWidth,
563
- height: innerHeight,
564
- devicePixelRatio: devicePixelRatio,
565
- readyState: document.readyState
566
- })`,
567
- returnByValue: true,
568
- });
569
-
570
- return JSON.parse(evaluation.result.value);
571
- }
572
-
573
- async function capturePageSnapshot(client) {
574
- const evaluation = await client.send('Runtime.evaluate', {
575
- expression: `(async () => {
576
- const clonedRoot = document.documentElement.cloneNode(true);
577
- const originalElements = [document.documentElement, ...document.querySelectorAll('*')];
578
- const clonedElements = [clonedRoot, ...clonedRoot.querySelectorAll('*')];
579
-
580
- const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
581
- const reader = new FileReader();
582
- reader.onload = () => resolve(reader.result);
583
- reader.onerror = () => reject(reader.error);
584
- reader.readAsDataURL(blob);
585
- });
586
-
587
- const imageToDataUrl = (element) => {
588
- const width = element.naturalWidth || element.clientWidth;
589
- const height = element.naturalHeight || element.clientHeight;
590
-
591
- if (!width || !height) return '';
592
-
593
- const canvas = document.createElement('canvas');
594
- canvas.width = width;
595
- canvas.height = height;
596
- canvas.getContext('2d').drawImage(element, 0, 0, width, height);
597
- return canvas.toDataURL('image/png');
598
- };
599
-
600
- for (let index = 0; index < originalElements.length; index += 1) {
601
- const original = originalElements[index];
602
- const clone = clonedElements[index];
603
-
604
- if (!clone) continue;
605
-
606
- if (original.scrollTop || original.scrollLeft) {
607
- clone.setAttribute('data-responsive-scroll-top', String(original.scrollTop));
608
- clone.setAttribute('data-responsive-scroll-left', String(original.scrollLeft));
609
- }
610
-
611
- if (original instanceof HTMLInputElement) {
612
- clone.setAttribute('value', original.value);
613
- clone.toggleAttribute('checked', original.checked);
614
- } else if (original instanceof HTMLTextAreaElement) {
615
- clone.textContent = original.value;
616
- } else if (original instanceof HTMLSelectElement) {
617
- for (let optionIndex = 0; optionIndex < original.options.length; optionIndex += 1) {
618
- clone.options[optionIndex].selected = original.options[optionIndex].selected;
619
- }
620
- } else if (original instanceof HTMLCanvasElement) {
621
- try {
622
- const replacement = document.createElement('img');
623
- replacement.src = original.toDataURL('image/png');
624
- replacement.alt = original.getAttribute('aria-label') || '';
625
- replacement.setAttribute('style', original.getAttribute('style') || '');
626
- clone.replaceWith(replacement);
627
- } catch {
628
- // Keep the canvas element when its pixels cannot be exported.
629
- }
630
- } else if (original instanceof HTMLVideoElement && original.readyState >= 2) {
631
- try {
632
- const replacement = document.createElement('img');
633
- replacement.src = imageToDataUrl(original);
634
- replacement.alt = original.getAttribute('aria-label') || '';
635
- replacement.setAttribute('style', original.getAttribute('style') || '');
636
- clone.replaceWith(replacement);
637
- } catch {
638
- clone.setAttribute('poster', original.poster || '');
639
- }
640
- } else if (original instanceof HTMLImageElement && original.complete) {
641
- try {
642
- clone.setAttribute('src', imageToDataUrl(original) || original.currentSrc || original.src);
643
- clone.removeAttribute('srcset');
644
- } catch {
645
- clone.setAttribute('src', original.currentSrc || original.src);
646
- }
647
- }
648
- }
649
-
650
- const stylesheetLinks = [...document.querySelectorAll('link[rel="stylesheet"]')];
651
- const clonedStylesheetLinks = [...clonedRoot.querySelectorAll('link[rel="stylesheet"]')];
652
-
653
- await Promise.all(stylesheetLinks.map(async (link, index) => {
654
- try {
655
- const response = await fetch(link.href);
656
- if (!response.ok) return;
657
-
658
- const cssText = (await response.text()).replace(
659
- /url\\(\\s*(['"]?)(?!data:|blob:|#)([^'"\\)]+)\\1\\s*\\)/gi,
660
- (match, quote, resourceUrl) => {
661
- try {
662
- return 'url("' + new URL(resourceUrl.trim(), link.href).href + '")';
663
- } catch {
664
- return match;
665
- }
666
- },
667
- );
668
- const style = document.createElement('style');
669
- style.textContent = cssText;
670
- clonedStylesheetLinks[index].replaceWith(style);
671
- } catch {
672
- clonedStylesheetLinks[index].href = link.href;
673
- }
674
- }));
675
-
676
- clonedRoot.querySelectorAll('script, link[rel="modulepreload"]').forEach((element) => element.remove());
677
- clonedRoot.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(
678
- (element) => element.remove(),
679
- );
680
-
681
- const base = document.createElement('base');
682
- base.href = location.href;
683
- clonedRoot.querySelector('head').prepend(base);
684
-
685
- const replayStyle = document.createElement('style');
686
- replayStyle.textContent = [
687
- '*,*::before,*::after{animation-play-state:paused!important;}',
688
- '*{scrollbar-width:none!important;}',
689
- '*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important;}',
690
- ].join('');
691
- clonedRoot.querySelector('head').append(replayStyle);
692
-
693
- return {
694
- html: '<!doctype html>' + clonedRoot.outerHTML,
695
- scrollX,
696
- scrollY,
697
- };
698
- })()`,
699
- awaitPromise: true,
700
- returnByValue: true,
701
- });
702
-
703
- if (evaluation.exceptionDetails) {
704
- throw new Error('The current WebView page could not be serialized.');
705
- }
706
-
707
- return evaluation.result.value;
708
- }
709
-
710
- function findBrowserExecutable() {
711
- const executablePath = browserExecutablePaths.find((candidate) => (
712
- fs.existsSync(candidate)
713
- ));
714
-
715
- if (!executablePath) {
716
- throw new Error('Google Chrome or Microsoft Edge was not found on this computer.');
717
- }
718
-
719
- return executablePath;
720
- }
721
-
722
- function delay(milliseconds) {
723
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
724
- }
725
-
726
- async function launchReplayBrowser() {
727
- fs.mkdirSync(temporaryDirectory, { recursive: true });
728
-
729
- const profileDirectory = path.join(
730
- temporaryDirectory,
731
- `responsive-screenshot-chrome-${process.pid}-${Date.now()}`,
732
- );
733
- fs.mkdirSync(profileDirectory, { recursive: true });
734
-
735
- const browserProcess = spawn(findBrowserExecutable(), [
736
- '--headless=new',
737
- '--remote-debugging-port=0',
738
- `--user-data-dir=${profileDirectory}`,
739
- '--no-first-run',
740
- '--no-default-browser-check',
741
- '--disable-background-networking',
742
- '--remote-allow-origins=*',
743
- 'about:blank',
744
- ], {
745
- stdio: 'ignore',
746
- windowsHide: true,
747
- });
748
-
749
- const activePortPath = path.join(profileDirectory, 'DevToolsActivePort');
750
- const deadline = Date.now() + 15000;
751
-
752
- while (!fs.existsSync(activePortPath)) {
753
- if (browserProcess.exitCode !== null) {
754
- throw new Error('The local replay browser closed before it was ready.');
755
- }
756
-
757
- if (Date.now() >= deadline) {
758
- browserProcess.kill();
759
- throw new Error('Timed out while starting the local replay browser.');
760
- }
761
-
762
- await delay(100);
763
- }
764
-
765
- const [port] = fs.readFileSync(activePortPath, 'utf8').trim().split(/\r?\n/);
766
- const versionResponse = await fetch(`http://127.0.0.1:${port}/json/version`);
767
- const version = await versionResponse.json();
768
- const client = new CdpClient(version.webSocketDebuggerUrl);
769
- await client.connect();
770
-
771
- return {
772
- browserProcess,
773
- client,
774
- port,
775
- profileDirectory,
776
- };
777
- }
778
-
779
- async function createReplayPage(browser) {
780
- const createdTarget = await browser.client.send('Target.createTarget', {
781
- url: 'about:blank',
782
- });
783
- const deadline = Date.now() + 10000;
784
-
785
- while (Date.now() < deadline) {
786
- const response = await fetch(`http://127.0.0.1:${browser.port}/json/list`);
787
- const targets = await response.json();
788
- const target = targets.find((candidate) => candidate.id === createdTarget.targetId);
789
-
790
- if (target && target.webSocketDebuggerUrl) {
791
- const client = new CdpClient(target.webSocketDebuggerUrl);
792
- await client.connect();
793
- await client.send('Page.enable');
794
- await client.send('Runtime.enable');
795
- return client;
796
- }
797
-
798
- await delay(100);
799
- }
800
-
801
- throw new Error('The local replay page did not become available.');
802
- }
803
-
804
- function removeTemporaryBrowserProfile(profileDirectory) {
805
- const resolvedTemporaryRoot = path.resolve(temporaryDirectory);
806
- const resolvedProfileDirectory = path.resolve(profileDirectory);
807
-
808
- if (!resolvedProfileDirectory.startsWith(`${resolvedTemporaryRoot}${path.sep}`)) {
809
- throw new Error('Refusing to remove a browser profile outside agent-temp.');
810
- }
811
-
812
- fs.rmSync(resolvedProfileDirectory, { recursive: true, force: true });
813
- }
814
-
815
- async function closeReplayBrowser(browser) {
816
- try {
817
- await browser.client.send('Browser.close');
818
- } catch {
819
- // Closing the browser can close the protocol connection before it acknowledges.
820
- }
821
-
822
- const deadline = Date.now() + 3000;
823
-
824
- while (browser.browserProcess.exitCode === null && Date.now() < deadline) {
825
- await delay(100);
826
- }
827
-
828
- if (browser.browserProcess.exitCode === null) {
829
- browser.browserProcess.kill();
830
- await delay(200);
831
- }
832
-
833
- browser.client.close();
834
-
835
- for (let attempt = 1; attempt <= 5; attempt += 1) {
836
- try {
837
- removeTemporaryBrowserProfile(browser.profileDirectory);
838
- return;
839
- } catch (error) {
840
- if (attempt === 5) {
841
- throw error;
842
- }
843
-
844
- await delay(200);
845
- }
846
- }
847
- }
848
-
849
- async function waitForReplayLayout(client, snapshot) {
850
- await client.send('Runtime.evaluate', {
851
- expression: `(async () => {
852
- const assetWait = Promise.all([...document.images].map((image) => {
853
- if (image.complete) return Promise.resolve();
854
- return new Promise((resolve) => {
855
- image.addEventListener('load', resolve, { once: true });
856
- image.addEventListener('error', resolve, { once: true });
857
- });
858
- }));
859
-
860
- await Promise.race([
861
- Promise.all([document.fonts.ready, assetWait]),
862
- new Promise((resolve) => setTimeout(resolve, 5000)),
863
- ]);
864
-
865
- document.querySelectorAll('[data-responsive-scroll-top]').forEach((element) => {
866
- element.scrollTop = Number(element.dataset.responsiveScrollTop);
867
- element.scrollLeft = Number(element.dataset.responsiveScrollLeft);
868
- });
869
- scrollTo(${Number(snapshot.scrollX) || 0}, ${Number(snapshot.scrollY) || 0});
870
-
871
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
872
- })()`,
873
- awaitPromise: true,
874
- returnByValue: true,
875
- });
876
- }
877
-
461
+
462
+ class CdpClient {
463
+ constructor(webSocketUrl) {
464
+ this.nextId = 1;
465
+ this.pendingCommands = new Map();
466
+ this.socket = new WebSocket(webSocketUrl);
467
+ }
468
+
469
+ connect() {
470
+ return new Promise((resolve, reject) => {
471
+ const timeout = setTimeout(() => {
472
+ reject(new Error('Timed out while connecting to the app WebView.'));
473
+ }, 10000);
474
+
475
+ this.socket.addEventListener('open', () => {
476
+ clearTimeout(timeout);
477
+ resolve();
478
+ }, { once: true });
479
+
480
+ this.socket.addEventListener('error', () => {
481
+ clearTimeout(timeout);
482
+ reject(new Error('Could not connect to the app WebView debugging socket.'));
483
+ }, { once: true });
484
+
485
+ this.socket.addEventListener('message', (event) => {
486
+ const message = JSON.parse(event.data);
487
+
488
+ if (!message.id || !this.pendingCommands.has(message.id)) {
489
+ return;
490
+ }
491
+
492
+ const pendingCommand = this.pendingCommands.get(message.id);
493
+ this.pendingCommands.delete(message.id);
494
+
495
+ if (message.error) {
496
+ pendingCommand.reject(new Error(message.error.message));
497
+ } else {
498
+ pendingCommand.resolve(message.result);
499
+ }
500
+ });
501
+
502
+ this.socket.addEventListener('close', () => {
503
+ for (const pendingCommand of this.pendingCommands.values()) {
504
+ pendingCommand.reject(new Error('The app WebView debugging connection closed.'));
505
+ }
506
+
507
+ this.pendingCommands.clear();
508
+ });
509
+ });
510
+ }
511
+
512
+ send(method, params = {}) {
513
+ return new Promise((resolve, reject) => {
514
+ const id = this.nextId;
515
+ this.nextId += 1;
516
+ this.pendingCommands.set(id, { resolve, reject });
517
+ this.socket.send(JSON.stringify({ id, method, params }));
518
+ });
519
+ }
520
+
521
+ close() {
522
+ this.socket.close();
523
+ }
524
+ }
525
+
526
+ async function getWebViewTarget(localPort) {
527
+ const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
528
+
529
+ if (!response.ok) {
530
+ throw new Error(`WebView target discovery failed with HTTP ${response.status}.`);
531
+ }
532
+
533
+ const targets = await response.json();
534
+ const pageTargets = targets.filter((target) => (
535
+ target.type === 'page'
536
+ && target.webSocketDebuggerUrl
537
+ && target.url !== 'about:blank'
538
+ ));
539
+
540
+ const visibleTarget = pageTargets.find((target) => {
541
+ try {
542
+ return JSON.parse(target.description || '{}').visible !== false;
543
+ } catch {
544
+ return true;
545
+ }
546
+ });
547
+
548
+ const target = visibleTarget || pageTargets[0];
549
+
550
+ if (!target) {
551
+ throw new Error('No rendered page was found in the app WebView.');
552
+ }
553
+
554
+ return target;
555
+ }
556
+
557
+ async function getPageDetails(client) {
558
+ const evaluation = await client.send('Runtime.evaluate', {
559
+ expression: `JSON.stringify({
560
+ title: document.title,
561
+ url: location.href,
562
+ width: innerWidth,
563
+ height: innerHeight,
564
+ devicePixelRatio: devicePixelRatio,
565
+ readyState: document.readyState
566
+ })`,
567
+ returnByValue: true,
568
+ });
569
+
570
+ return JSON.parse(evaluation.result.value);
571
+ }
572
+
573
+ async function capturePageSnapshot(client) {
574
+ const evaluation = await client.send('Runtime.evaluate', {
575
+ expression: `(async () => {
576
+ const clonedRoot = document.documentElement.cloneNode(true);
577
+ const originalElements = [
578
+ document.documentElement,
579
+ ...document.documentElement.querySelectorAll('*'),
580
+ ];
581
+ const clonedElements = [clonedRoot, ...clonedRoot.querySelectorAll('*')];
582
+
583
+ const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
584
+ const reader = new FileReader();
585
+ reader.onload = () => resolve(reader.result);
586
+ reader.onerror = () => reject(reader.error);
587
+ reader.readAsDataURL(blob);
588
+ });
589
+
590
+ const imageToDataUrl = (element) => {
591
+ const width = element.naturalWidth || element.clientWidth;
592
+ const height = element.naturalHeight || element.clientHeight;
593
+
594
+ if (!width || !height) return '';
595
+
596
+ const canvas = document.createElement('canvas');
597
+ canvas.width = width;
598
+ canvas.height = height;
599
+ canvas.getContext('2d').drawImage(element, 0, 0, width, height);
600
+ return canvas.toDataURL('image/png');
601
+ };
602
+
603
+ for (let index = 0; index < originalElements.length; index += 1) {
604
+ const original = originalElements[index];
605
+ const clone = clonedElements[index];
606
+
607
+ if (!clone) continue;
608
+
609
+ if (original.scrollTop || original.scrollLeft) {
610
+ clone.setAttribute('data-responsive-scroll-top', String(original.scrollTop));
611
+ clone.setAttribute('data-responsive-scroll-left', String(original.scrollLeft));
612
+ }
613
+
614
+ if (original instanceof HTMLInputElement) {
615
+ clone.setAttribute('value', original.value);
616
+ clone.toggleAttribute('checked', original.checked);
617
+ } else if (original instanceof HTMLTextAreaElement) {
618
+ clone.textContent = original.value;
619
+ } else if (original instanceof HTMLSelectElement) {
620
+ for (let optionIndex = 0; optionIndex < original.options.length; optionIndex += 1) {
621
+ clone.options[optionIndex].selected = original.options[optionIndex].selected;
622
+ }
623
+ } else if (original instanceof HTMLCanvasElement) {
624
+ try {
625
+ clone.style.setProperty(
626
+ 'background-image',
627
+ 'url("' + original.toDataURL('image/png') + '")',
628
+ 'important',
629
+ );
630
+ clone.style.setProperty('background-position', 'center', 'important');
631
+ clone.style.setProperty('background-repeat', 'no-repeat', 'important');
632
+ clone.style.setProperty('background-size', '100% 100%', 'important');
633
+ } catch {
634
+ // Keep the canvas element when its pixels cannot be exported.
635
+ }
636
+ } else if (original instanceof HTMLVideoElement && original.readyState >= 2) {
637
+ try {
638
+ const replacement = document.createElement('img');
639
+ replacement.src = imageToDataUrl(original);
640
+ replacement.alt = original.getAttribute('aria-label') || '';
641
+ replacement.setAttribute('style', original.getAttribute('style') || '');
642
+ clone.replaceWith(replacement);
643
+ } catch {
644
+ clone.setAttribute('poster', original.poster || '');
645
+ }
646
+ } else if (original instanceof HTMLImageElement && original.complete) {
647
+ try {
648
+ clone.setAttribute('src', imageToDataUrl(original) || original.currentSrc || original.src);
649
+ clone.removeAttribute('srcset');
650
+ } catch {
651
+ clone.setAttribute('src', original.currentSrc || original.src);
652
+ }
653
+ }
654
+ }
655
+
656
+ const stylesheetLinks = [...document.querySelectorAll('link[rel="stylesheet"]')];
657
+ const clonedStylesheetLinks = [...clonedRoot.querySelectorAll('link[rel="stylesheet"]')];
658
+
659
+ await Promise.all(stylesheetLinks.map(async (link, index) => {
660
+ try {
661
+ const response = await fetch(link.href);
662
+ if (!response.ok) return;
663
+
664
+ const cssText = (await response.text()).replace(
665
+ /url\\(\\s*(['"]?)(?!data:|blob:|#)([^'"\\)]+)\\1\\s*\\)/gi,
666
+ (match, quote, resourceUrl) => {
667
+ try {
668
+ return 'url("' + new URL(resourceUrl.trim(), link.href).href + '")';
669
+ } catch {
670
+ return match;
671
+ }
672
+ },
673
+ );
674
+ const style = document.createElement('style');
675
+ style.textContent = cssText;
676
+ clonedStylesheetLinks[index].replaceWith(style);
677
+ } catch {
678
+ clonedStylesheetLinks[index].href = link.href;
679
+ }
680
+ }));
681
+
682
+ clonedRoot.querySelectorAll('script, link[rel="modulepreload"]').forEach((element) => element.remove());
683
+ clonedRoot.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(
684
+ (element) => element.remove(),
685
+ );
686
+
687
+ const base = document.createElement('base');
688
+ base.href = location.href;
689
+ clonedRoot.querySelector('head').prepend(base);
690
+
691
+ const replayStyle = document.createElement('style');
692
+ replayStyle.textContent = [
693
+ '*,*::before,*::after{animation-play-state:paused!important;}',
694
+ '*{scrollbar-width:none!important;}',
695
+ '*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important;}',
696
+ ].join('');
697
+ clonedRoot.querySelector('head').append(replayStyle);
698
+
699
+ return {
700
+ html: '<!doctype html>' + clonedRoot.outerHTML,
701
+ scrollX,
702
+ scrollY,
703
+ };
704
+ })()`,
705
+ awaitPromise: true,
706
+ returnByValue: true,
707
+ });
708
+
709
+ if (evaluation.exceptionDetails) {
710
+ throw new Error('The current WebView page could not be serialized.');
711
+ }
712
+
713
+ return evaluation.result.value;
714
+ }
715
+
716
+ function findBrowserExecutable() {
717
+ const executablePath = browserExecutablePaths.find((candidate) => (
718
+ fs.existsSync(candidate)
719
+ ));
720
+
721
+ if (!executablePath) {
722
+ throw new Error('Google Chrome or Microsoft Edge was not found on this computer.');
723
+ }
724
+
725
+ return executablePath;
726
+ }
727
+
728
+ function delay(milliseconds) {
729
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
730
+ }
731
+
732
+ async function launchReplayBrowser() {
733
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
734
+
735
+ const profileDirectory = path.join(
736
+ temporaryDirectory,
737
+ `responsive-screenshot-chrome-${process.pid}-${Date.now()}`,
738
+ );
739
+ fs.mkdirSync(profileDirectory, { recursive: true });
740
+
741
+ const browserProcess = spawn(findBrowserExecutable(), [
742
+ '--headless=new',
743
+ '--remote-debugging-port=0',
744
+ `--user-data-dir=${profileDirectory}`,
745
+ '--no-first-run',
746
+ '--no-default-browser-check',
747
+ '--disable-background-networking',
748
+ '--remote-allow-origins=*',
749
+ 'about:blank',
750
+ ], {
751
+ stdio: 'ignore',
752
+ windowsHide: true,
753
+ });
754
+
755
+ const activePortPath = path.join(profileDirectory, 'DevToolsActivePort');
756
+ const deadline = Date.now() + 15000;
757
+
758
+ while (!fs.existsSync(activePortPath)) {
759
+ if (browserProcess.exitCode !== null) {
760
+ throw new Error('The local replay browser closed before it was ready.');
761
+ }
762
+
763
+ if (Date.now() >= deadline) {
764
+ browserProcess.kill();
765
+ throw new Error('Timed out while starting the local replay browser.');
766
+ }
767
+
768
+ await delay(100);
769
+ }
770
+
771
+ const [port] = fs.readFileSync(activePortPath, 'utf8').trim().split(/\r?\n/);
772
+ const versionResponse = await fetch(`http://127.0.0.1:${port}/json/version`);
773
+ const version = await versionResponse.json();
774
+ const client = new CdpClient(version.webSocketDebuggerUrl);
775
+ await client.connect();
776
+
777
+ return {
778
+ browserProcess,
779
+ client,
780
+ port,
781
+ profileDirectory,
782
+ };
783
+ }
784
+
785
+ async function createReplayPage(browser) {
786
+ const createdTarget = await browser.client.send('Target.createTarget', {
787
+ url: 'about:blank',
788
+ });
789
+ const deadline = Date.now() + 10000;
790
+
791
+ while (Date.now() < deadline) {
792
+ const response = await fetch(`http://127.0.0.1:${browser.port}/json/list`);
793
+ const targets = await response.json();
794
+ const target = targets.find((candidate) => candidate.id === createdTarget.targetId);
795
+
796
+ if (target && target.webSocketDebuggerUrl) {
797
+ const client = new CdpClient(target.webSocketDebuggerUrl);
798
+ await client.connect();
799
+ await client.send('Page.enable');
800
+ await client.send('Runtime.enable');
801
+ return client;
802
+ }
803
+
804
+ await delay(100);
805
+ }
806
+
807
+ throw new Error('The local replay page did not become available.');
808
+ }
809
+
810
+ function removeTemporaryBrowserProfile(profileDirectory) {
811
+ const resolvedTemporaryRoot = path.resolve(temporaryDirectory);
812
+ const resolvedProfileDirectory = path.resolve(profileDirectory);
813
+
814
+ if (!resolvedProfileDirectory.startsWith(`${resolvedTemporaryRoot}${path.sep}`)) {
815
+ throw new Error('Refusing to remove a browser profile outside agent-temp.');
816
+ }
817
+
818
+ fs.rmSync(resolvedProfileDirectory, { recursive: true, force: true });
819
+ }
820
+
821
+ async function closeReplayBrowser(browser) {
822
+ try {
823
+ await browser.client.send('Browser.close');
824
+ } catch {
825
+ // Closing the browser can close the protocol connection before it acknowledges.
826
+ }
827
+
828
+ const deadline = Date.now() + 3000;
829
+
830
+ while (browser.browserProcess.exitCode === null && Date.now() < deadline) {
831
+ await delay(100);
832
+ }
833
+
834
+ if (browser.browserProcess.exitCode === null) {
835
+ browser.browserProcess.kill();
836
+ await delay(200);
837
+ }
838
+
839
+ browser.client.close();
840
+
841
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
842
+ try {
843
+ removeTemporaryBrowserProfile(browser.profileDirectory);
844
+ return;
845
+ } catch (error) {
846
+ if (attempt === 5) {
847
+ throw error;
848
+ }
849
+
850
+ await delay(200);
851
+ }
852
+ }
853
+ }
854
+
855
+ async function waitForReplayLayout(client, snapshot) {
856
+ await client.send('Runtime.evaluate', {
857
+ expression: `(async () => {
858
+ const assetWait = Promise.all([...document.images].map((image) => {
859
+ if (image.complete) return Promise.resolve();
860
+ return new Promise((resolve) => {
861
+ image.addEventListener('load', resolve, { once: true });
862
+ image.addEventListener('error', resolve, { once: true });
863
+ });
864
+ }));
865
+
866
+ await Promise.race([
867
+ Promise.all([document.fonts.ready, assetWait]),
868
+ new Promise((resolve) => setTimeout(resolve, 5000)),
869
+ ]);
870
+
871
+ document.querySelectorAll('[data-responsive-scroll-top]').forEach((element) => {
872
+ element.scrollTop = Number(element.dataset.responsiveScrollTop);
873
+ element.scrollLeft = Number(element.dataset.responsiveScrollLeft);
874
+ });
875
+ scrollTo(${Number(snapshot.scrollX) || 0}, ${Number(snapshot.scrollY) || 0});
876
+
877
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
878
+ })()`,
879
+ awaitPromise: true,
880
+ returnByValue: true,
881
+ });
882
+ }
883
+
878
884
  function getFrameAdornment(frameType) {
879
- if (frameType === 'iphone') {
880
- return '<div class="dynamic-island"></div>';
881
- }
882
-
883
- if (frameType === 'android-phone') {
884
- return '<div class="camera-dot"></div>';
885
- }
886
-
887
- if (frameType === 'tablet') {
888
- return '<div class="tablet-camera"></div>';
889
- }
890
-
885
+ if (frameType === 'iphone') {
886
+ return '<div class="dynamic-island"></div>';
887
+ }
888
+
889
+ if (frameType === 'android-phone') {
890
+ return '<div class="camera-dot"></div>';
891
+ }
892
+
893
+ if (frameType === 'tablet') {
894
+ return '<div class="tablet-camera"></div>';
895
+ }
896
+
891
897
  if (frameType === 'tv' || frameType === 'apple-tv') {
892
898
  return '<div class="tv-stand"></div>';
893
899
  }
@@ -899,110 +905,110 @@ function getFrameAdornment(frameType) {
899
905
  if (frameType === 'apple-vision-pro') {
900
906
  return '<div class="vision-sensor vision-sensor-left"></div><div class="vision-sensor vision-sensor-right"></div>';
901
907
  }
902
-
903
- if (frameType === 'laptop') {
904
- return '<div class="laptop-camera"></div><div class="laptop-base"></div>';
905
- }
906
-
907
- return '<div class="xr-status"></div>';
908
- }
909
-
908
+
909
+ if (frameType === 'laptop') {
910
+ return '<div class="laptop-camera"></div><div class="laptop-base"></div>';
911
+ }
912
+
913
+ return '<div class="xr-status"></div>';
914
+ }
915
+
910
916
  function createDeviceFrameHtml(profile, screenshotBuffer) {
911
- const screenshotDataUrl = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
912
- const aspectRatio = `${profile.width} / ${profile.height}`;
913
-
914
- return `<!doctype html>
915
- <html>
916
- <head>
917
- <meta charset="utf-8" />
918
- <style>
919
- * { box-sizing: border-box; }
920
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
921
- body {
922
- display: grid;
923
- place-items: center;
924
- position: relative;
925
- background:
926
- radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
927
- radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
928
- linear-gradient(145deg, #111827 0%, #070b12 52%, #020409 100%);
929
- font-family: Arial, sans-serif;
930
- }
931
- body::before {
932
- content: '';
933
- position: absolute;
934
- inset: 0;
935
- opacity: 0.18;
936
- background-image:
937
- linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
938
- linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
939
- background-size: 7vw 7vw;
940
- mask-image: linear-gradient(to bottom, transparent, black 28%, black 72%, transparent);
941
- }
942
- .device {
943
- position: relative;
944
- z-index: 1;
945
- aspect-ratio: ${aspectRatio};
946
- background: linear-gradient(145deg, #566171 0%, #171c25 18%, #05070b 55%, #384252 100%);
947
- border: clamp(2px, 0.45vw, 9px) solid #727d8d;
948
- box-shadow:
949
- 0 4vh 9vh rgba(0, 0, 0, 0.68),
950
- 0 0 0 1px rgba(255, 255, 255, 0.18) inset,
951
- 0 0 5vh rgba(45, 213, 255, 0.12);
952
- }
953
- .screen {
954
- position: relative;
955
- width: 100%;
956
- height: 100%;
957
- overflow: hidden;
958
- background: #05070b;
959
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
960
- }
961
- .screen img {
962
- display: block;
963
- width: 100%;
964
- height: 100%;
965
- object-fit: cover;
966
- }
967
- .device.android-phone,
968
- .device.iphone {
969
- height: 87%;
970
- padding: 1.25%;
971
- border-radius: clamp(24px, 8vw, 70px);
972
- }
973
- .device.android-phone .screen,
974
- .device.iphone .screen {
975
- border-radius: clamp(19px, 6.7vw, 60px);
976
- }
977
- .camera-dot {
978
- position: absolute;
979
- z-index: 3;
980
- top: 2.1%;
981
- left: 50%;
982
- width: clamp(5px, 1.8vw, 16px);
983
- aspect-ratio: 1;
984
- transform: translateX(-50%);
985
- border-radius: 50%;
986
- background: radial-gradient(circle at 36% 34%, #406891, #080b10 46%, #000 72%);
987
- box-shadow: 0 0 0 2px #111827;
988
- }
989
- .dynamic-island {
990
- position: absolute;
991
- z-index: 3;
992
- top: 2.2%;
993
- left: 50%;
994
- width: 29%;
995
- height: 3.3%;
996
- transform: translateX(-50%);
997
- border-radius: 999px;
998
- background: #000;
999
- box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
1000
- }
1001
- .device.tablet {
1002
- height: 86%;
1003
- padding: 1.05%;
1004
- border-radius: clamp(18px, 3.2vw, 48px);
1005
- }
917
+ const screenshotDataUrl = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
918
+ const aspectRatio = `${profile.width} / ${profile.height}`;
919
+
920
+ return `<!doctype html>
921
+ <html>
922
+ <head>
923
+ <meta charset="utf-8" />
924
+ <style>
925
+ * { box-sizing: border-box; }
926
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
927
+ body {
928
+ display: grid;
929
+ place-items: center;
930
+ position: relative;
931
+ background:
932
+ radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
933
+ radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
934
+ linear-gradient(145deg, #111827 0%, #070b12 52%, #020409 100%);
935
+ font-family: Arial, sans-serif;
936
+ }
937
+ body::before {
938
+ content: '';
939
+ position: absolute;
940
+ inset: 0;
941
+ opacity: 0.18;
942
+ background-image:
943
+ linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
944
+ linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
945
+ background-size: 7vw 7vw;
946
+ mask-image: linear-gradient(to bottom, transparent, black 28%, black 72%, transparent);
947
+ }
948
+ .device {
949
+ position: relative;
950
+ z-index: 1;
951
+ aspect-ratio: ${aspectRatio};
952
+ background: linear-gradient(145deg, #566171 0%, #171c25 18%, #05070b 55%, #384252 100%);
953
+ border: clamp(2px, 0.45vw, 9px) solid #727d8d;
954
+ box-shadow:
955
+ 0 4vh 9vh rgba(0, 0, 0, 0.68),
956
+ 0 0 0 1px rgba(255, 255, 255, 0.18) inset,
957
+ 0 0 5vh rgba(45, 213, 255, 0.12);
958
+ }
959
+ .screen {
960
+ position: relative;
961
+ width: 100%;
962
+ height: 100%;
963
+ overflow: hidden;
964
+ background: #05070b;
965
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
966
+ }
967
+ .screen img {
968
+ display: block;
969
+ width: 100%;
970
+ height: 100%;
971
+ object-fit: cover;
972
+ }
973
+ .device.android-phone,
974
+ .device.iphone {
975
+ height: 87%;
976
+ padding: 1.25%;
977
+ border-radius: clamp(24px, 8vw, 70px);
978
+ }
979
+ .device.android-phone .screen,
980
+ .device.iphone .screen {
981
+ border-radius: clamp(19px, 6.7vw, 60px);
982
+ }
983
+ .camera-dot {
984
+ position: absolute;
985
+ z-index: 3;
986
+ top: 2.1%;
987
+ left: 50%;
988
+ width: clamp(5px, 1.8vw, 16px);
989
+ aspect-ratio: 1;
990
+ transform: translateX(-50%);
991
+ border-radius: 50%;
992
+ background: radial-gradient(circle at 36% 34%, #406891, #080b10 46%, #000 72%);
993
+ box-shadow: 0 0 0 2px #111827;
994
+ }
995
+ .dynamic-island {
996
+ position: absolute;
997
+ z-index: 3;
998
+ top: 2.2%;
999
+ left: 50%;
1000
+ width: 29%;
1001
+ height: 3.3%;
1002
+ transform: translateX(-50%);
1003
+ border-radius: 999px;
1004
+ background: #000;
1005
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
1006
+ }
1007
+ .device.tablet {
1008
+ height: 86%;
1009
+ padding: 1.05%;
1010
+ border-radius: clamp(18px, 3.2vw, 48px);
1011
+ }
1006
1012
  .device.tablet .screen {
1007
1013
  border-radius: clamp(13px, 2.5vw, 38px);
1008
1014
  }
@@ -1061,90 +1067,90 @@ function createDeviceFrameHtml(profile, screenshotBuffer) {
1061
1067
  }
1062
1068
  .vision-sensor-left { left: 3.2%; }
1063
1069
  .vision-sensor-right { right: 3.2%; }
1064
- .tablet-camera,
1065
- .laptop-camera {
1066
- position: absolute;
1067
- z-index: 3;
1068
- top: 0.48%;
1069
- left: 50%;
1070
- width: clamp(3px, 0.55vw, 10px);
1071
- aspect-ratio: 1;
1072
- transform: translateX(-50%);
1073
- border-radius: 50%;
1074
- background: #080b10;
1075
- box-shadow: 0 0 0 1px #526071;
1076
- }
1070
+ .tablet-camera,
1071
+ .laptop-camera {
1072
+ position: absolute;
1073
+ z-index: 3;
1074
+ top: 0.48%;
1075
+ left: 50%;
1076
+ width: clamp(3px, 0.55vw, 10px);
1077
+ aspect-ratio: 1;
1078
+ transform: translateX(-50%);
1079
+ border-radius: 50%;
1080
+ background: #080b10;
1081
+ box-shadow: 0 0 0 1px #526071;
1082
+ }
1077
1083
  .device.tv,
1078
1084
  .device.apple-tv {
1079
1085
  width: 82%;
1080
- padding: 0.62%;
1081
- border-radius: clamp(8px, 1.1vw, 24px);
1082
- transform: translateY(-4%);
1083
- }
1086
+ padding: 0.62%;
1087
+ border-radius: clamp(8px, 1.1vw, 24px);
1088
+ transform: translateY(-4%);
1089
+ }
1084
1090
  .device.tv .screen,
1085
1091
  .device.apple-tv .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1086
- .tv-stand {
1087
- position: absolute;
1088
- z-index: -1;
1089
- left: 50%;
1090
- bottom: -13%;
1091
- width: 24%;
1092
- height: 13%;
1093
- transform: translateX(-50%);
1094
- background: linear-gradient(90deg, #11161f, #697383 50%, #11161f);
1095
- clip-path: polygon(42% 0, 58% 0, 67% 76%, 94% 86%, 100% 100%, 0 100%, 6% 86%, 33% 76%);
1096
- filter: drop-shadow(0 1.5vh 1.5vh rgba(0,0,0,0.55));
1097
- }
1098
- .device.laptop {
1099
- width: 82%;
1100
- padding: 0.75% 0.75% 1.15%;
1101
- border-radius: clamp(8px, 1.2vw, 24px);
1102
- transform: translateY(-3%);
1103
- }
1104
- .device.laptop .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1105
- .laptop-base {
1106
- position: absolute;
1107
- z-index: -1;
1108
- left: 50%;
1109
- bottom: -7.5%;
1110
- width: 112%;
1111
- height: 8%;
1112
- transform: translateX(-50%);
1113
- border-radius: 0 0 50% 50% / 0 0 70% 70%;
1114
- background: linear-gradient(#697383, #202631 45%, #090c11);
1115
- box-shadow: 0 1.5vh 2vh rgba(0,0,0,0.48);
1116
- }
1117
- .device.xr {
1118
- width: 82%;
1119
- padding: 0.58%;
1120
- border: clamp(2px, 0.32vw, 7px) solid rgba(128, 230, 255, 0.9);
1121
- border-radius: clamp(18px, 2.5vw, 48px);
1122
- background: rgba(13, 22, 34, 0.82);
1123
- box-shadow:
1124
- 0 4vh 10vh rgba(0,0,0,0.65),
1125
- 0 0 7vh rgba(45,213,255,0.34),
1126
- 0 0 0 1px rgba(255,255,255,0.22) inset;
1127
- }
1128
- .device.xr .screen { border-radius: clamp(13px, 2vw, 38px); }
1129
- .xr-status {
1130
- position: absolute;
1131
- top: -2.4%;
1132
- left: 50%;
1133
- width: 13%;
1134
- height: 1.1%;
1135
- transform: translateX(-50%);
1136
- border-radius: 999px;
1137
- background: #6fe8ff;
1138
- box-shadow: 0 0 1.8vh #2dd5ff;
1139
- }
1140
- </style>
1141
- </head>
1142
- <body>
1143
- <main class="device ${profile.frameType}">
1144
- <div class="screen"><img src="${screenshotDataUrl}" /></div>
1145
- ${getFrameAdornment(profile.frameType)}
1146
- </main>
1147
- </body>
1092
+ .tv-stand {
1093
+ position: absolute;
1094
+ z-index: -1;
1095
+ left: 50%;
1096
+ bottom: -13%;
1097
+ width: 24%;
1098
+ height: 13%;
1099
+ transform: translateX(-50%);
1100
+ background: linear-gradient(90deg, #11161f, #697383 50%, #11161f);
1101
+ clip-path: polygon(42% 0, 58% 0, 67% 76%, 94% 86%, 100% 100%, 0 100%, 6% 86%, 33% 76%);
1102
+ filter: drop-shadow(0 1.5vh 1.5vh rgba(0,0,0,0.55));
1103
+ }
1104
+ .device.laptop {
1105
+ width: 82%;
1106
+ padding: 0.75% 0.75% 1.15%;
1107
+ border-radius: clamp(8px, 1.2vw, 24px);
1108
+ transform: translateY(-3%);
1109
+ }
1110
+ .device.laptop .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1111
+ .laptop-base {
1112
+ position: absolute;
1113
+ z-index: -1;
1114
+ left: 50%;
1115
+ bottom: -7.5%;
1116
+ width: 112%;
1117
+ height: 8%;
1118
+ transform: translateX(-50%);
1119
+ border-radius: 0 0 50% 50% / 0 0 70% 70%;
1120
+ background: linear-gradient(#697383, #202631 45%, #090c11);
1121
+ box-shadow: 0 1.5vh 2vh rgba(0,0,0,0.48);
1122
+ }
1123
+ .device.xr {
1124
+ width: 82%;
1125
+ padding: 0.58%;
1126
+ border: clamp(2px, 0.32vw, 7px) solid rgba(128, 230, 255, 0.9);
1127
+ border-radius: clamp(18px, 2.5vw, 48px);
1128
+ background: rgba(13, 22, 34, 0.82);
1129
+ box-shadow:
1130
+ 0 4vh 10vh rgba(0,0,0,0.65),
1131
+ 0 0 7vh rgba(45,213,255,0.34),
1132
+ 0 0 0 1px rgba(255,255,255,0.22) inset;
1133
+ }
1134
+ .device.xr .screen { border-radius: clamp(13px, 2vw, 38px); }
1135
+ .xr-status {
1136
+ position: absolute;
1137
+ top: -2.4%;
1138
+ left: 50%;
1139
+ width: 13%;
1140
+ height: 1.1%;
1141
+ transform: translateX(-50%);
1142
+ border-radius: 999px;
1143
+ background: #6fe8ff;
1144
+ box-shadow: 0 0 1.8vh #2dd5ff;
1145
+ }
1146
+ </style>
1147
+ </head>
1148
+ <body>
1149
+ <main class="device ${profile.frameType}">
1150
+ <div class="screen"><img src="${screenshotDataUrl}" /></div>
1151
+ ${getFrameAdornment(profile.frameType)}
1152
+ </main>
1153
+ </body>
1148
1154
  </html>`;
1149
1155
  }
1150
1156
 
@@ -1662,20 +1668,20 @@ async function createSamsungAssets(client) {
1662
1668
  }
1663
1669
 
1664
1670
  async function captureFramedScreenshot(
1665
- client,
1666
- screenshotBuffer,
1667
- profile,
1668
- screenshotFileName,
1669
- skipExisting = false,
1671
+ client,
1672
+ screenshotBuffer,
1673
+ profile,
1674
+ screenshotFileName,
1675
+ skipExisting = false,
1670
1676
  ) {
1671
- const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
1672
- const framedScreenshotPath = path.join(framedProfileDirectory, screenshotFileName);
1673
-
1674
- if (skipExisting && fs.existsSync(framedScreenshotPath)) {
1675
- console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
1676
- return false;
1677
- }
1678
-
1677
+ const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
1678
+ const framedScreenshotPath = path.join(framedProfileDirectory, screenshotFileName);
1679
+
1680
+ if (skipExisting && fs.existsSync(framedScreenshotPath)) {
1681
+ console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
1682
+ return false;
1683
+ }
1684
+
1679
1685
  const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
1680
1686
  const frameWidth = profile.framedWidth || profile.width;
1681
1687
  const frameHeight = profile.framedHeight || profile.height;
@@ -1688,194 +1694,194 @@ async function captureFramedScreenshot(
1688
1694
  mobile: false,
1689
1695
  screenWidth: frameWidth,
1690
1696
  screenHeight: frameHeight,
1691
- positionX: 0,
1692
- positionY: 0,
1693
- });
1694
-
1695
- const frameTree = await client.send('Page.getFrameTree');
1696
- await client.send('Page.setDocumentContent', {
1697
- frameId: frameTree.frameTree.frame.id,
1698
- html: createDeviceFrameHtml(profile, screenshotBuffer),
1699
- });
1700
- await client.send('Runtime.evaluate', {
1701
- expression: `new Promise((resolve) => {
1702
- const image = document.querySelector('img');
1703
- const ready = () => requestAnimationFrame(() => requestAnimationFrame(resolve));
1704
- if (image.complete) ready();
1705
- else {
1706
- image.addEventListener('load', ready, { once: true });
1707
- image.addEventListener('error', ready, { once: true });
1708
- }
1709
- })`,
1710
- awaitPromise: true,
1711
- returnByValue: true,
1712
- });
1713
-
1714
- const framedScreenshot = await client.send('Page.captureScreenshot', {
1715
- format: 'png',
1716
- fromSurface: true,
1717
- captureBeyondViewport: false,
1718
- });
1719
-
1720
- fs.mkdirSync(framedProfileDirectory, { recursive: true });
1721
- fs.writeFileSync(
1722
- framedScreenshotPath,
1723
- Buffer.from(framedScreenshot.data, 'base64'),
1724
- { flag: 'wx' },
1725
- );
1726
- console.log(`Saved framed ${profile.label}: ${framedScreenshotPath}`);
1727
- return true;
1728
- }
1729
-
1730
- async function captureProfile(client, snapshot, profile, screenshotFileName) {
1731
- await client.send('Emulation.setDeviceMetricsOverride', {
1732
- width: profile.width,
1733
- height: profile.height,
1734
- deviceScaleFactor: profile.deviceScaleFactor,
1735
- mobile: profile.mobile,
1736
- screenWidth: profile.width,
1737
- screenHeight: profile.height,
1738
- positionX: 0,
1739
- positionY: 0,
1740
- });
1741
-
1742
- const frameTree = await client.send('Page.getFrameTree');
1743
- await client.send('Page.setDocumentContent', {
1744
- frameId: frameTree.frameTree.frame.id,
1745
- html: snapshot.html,
1746
- });
1747
- await waitForReplayLayout(client, snapshot);
1748
-
1749
- const screenshot = await client.send('Page.captureScreenshot', {
1750
- format: 'png',
1751
- fromSurface: true,
1752
- captureBeyondViewport: false,
1753
- });
1754
-
1755
- const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1756
- const screenshotPath = path.join(profileDirectory, screenshotFileName);
1757
- const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
1758
-
1759
- fs.mkdirSync(profileDirectory, { recursive: true });
1760
- fs.writeFileSync(screenshotPath, screenshotBuffer, { flag: 'wx' });
1761
- console.log(`Saved ${profile.label}: ${screenshotPath}`);
1762
- await captureFramedScreenshot(
1763
- client,
1764
- screenshotBuffer,
1765
- profile,
1766
- screenshotFileName,
1767
- );
1768
- }
1769
-
1770
- async function captureScreenshotSet(
1771
- deviceClient,
1772
- replayClient,
1773
- selectedProfiles,
1774
- fileNameInput,
1775
- ) {
1776
- console.log('Capturing the current rendered page and resolved native data...');
1777
- const snapshot = await capturePageSnapshot(deviceClient);
1778
- const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
1779
-
1780
- for (const profile of selectedProfiles) {
1781
- await captureProfile(replayClient, snapshot, profile, screenshotFileName);
1782
- }
1783
-
1784
- console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
1785
- }
1786
-
1787
- async function frameExistingScreenshots(selectedProfiles) {
1788
- let replayBrowser;
1789
- let replayClient;
1790
- let framedCount = 0;
1791
- let failedCount = 0;
1792
-
1793
- try {
1794
- replayBrowser = await launchReplayBrowser();
1795
- replayClient = await createReplayPage(replayBrowser);
1796
-
1797
- for (const profile of selectedProfiles) {
1798
- const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1799
-
1800
- if (!fs.existsSync(profileDirectory)) {
1801
- continue;
1802
- }
1803
-
1804
- const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
1805
- .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
1806
- .map((entry) => entry.name)
1807
- .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
1808
-
1809
- for (const screenshotFileName of screenshotFiles) {
1810
- const screenshotPath = path.join(profileDirectory, screenshotFileName);
1811
-
1812
- try {
1813
- const wasCreated = await captureFramedScreenshot(
1814
- replayClient,
1815
- fs.readFileSync(screenshotPath),
1816
- profile,
1817
- screenshotFileName,
1818
- true,
1819
- );
1820
- if (wasCreated) framedCount += 1;
1821
- } catch (error) {
1822
- failedCount += 1;
1823
- console.error(`Failed to frame ${screenshotPath}: ${error.message}`);
1824
- }
1825
- }
1826
- }
1827
- } finally {
1828
- if (replayClient) {
1829
- replayClient.close();
1830
- }
1831
-
1832
- if (replayBrowser) {
1833
- await closeReplayBrowser(replayBrowser);
1834
- }
1835
- }
1836
-
1837
- console.log(`Existing screenshot framing completed: ${framedCount} created.`);
1838
-
1839
- if (failedCount) {
1840
- throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
1841
- }
1842
- }
1843
-
1844
- async function runInteractiveCaptureLoop(
1845
- deviceClient,
1846
- replayClient,
1847
- selectedProfiles,
1848
- ) {
1849
- const terminal = readline.createInterface({
1850
- input: process.stdin,
1851
- output: process.stdout,
1852
- prompt: 'Enter a filename, or press Enter for the next number: ',
1853
- });
1854
-
1855
- terminal.on('SIGINT', () => terminal.close());
1856
-
1857
- console.log(`Screenshots will be saved in: ${screenshotDirectory}`);
1858
- console.log('Press Ctrl+C to exit.');
1859
- terminal.prompt();
1860
-
1861
- for await (const fileNameInput of terminal) {
1862
- try {
1863
- await captureScreenshotSet(
1864
- deviceClient,
1865
- replayClient,
1866
- selectedProfiles,
1867
- fileNameInput,
1868
- );
1869
- } catch (error) {
1870
- console.error(`Responsive screenshot failed: ${error.message}`);
1871
- }
1872
-
1873
- terminal.prompt();
1874
- }
1875
-
1876
- console.log('\nResponsive screenshot tool closed.');
1877
- }
1878
-
1697
+ positionX: 0,
1698
+ positionY: 0,
1699
+ });
1700
+
1701
+ const frameTree = await client.send('Page.getFrameTree');
1702
+ await client.send('Page.setDocumentContent', {
1703
+ frameId: frameTree.frameTree.frame.id,
1704
+ html: createDeviceFrameHtml(profile, screenshotBuffer),
1705
+ });
1706
+ await client.send('Runtime.evaluate', {
1707
+ expression: `new Promise((resolve) => {
1708
+ const image = document.querySelector('img');
1709
+ const ready = () => requestAnimationFrame(() => requestAnimationFrame(resolve));
1710
+ if (image.complete) ready();
1711
+ else {
1712
+ image.addEventListener('load', ready, { once: true });
1713
+ image.addEventListener('error', ready, { once: true });
1714
+ }
1715
+ })`,
1716
+ awaitPromise: true,
1717
+ returnByValue: true,
1718
+ });
1719
+
1720
+ const framedScreenshot = await client.send('Page.captureScreenshot', {
1721
+ format: 'png',
1722
+ fromSurface: true,
1723
+ captureBeyondViewport: false,
1724
+ });
1725
+
1726
+ fs.mkdirSync(framedProfileDirectory, { recursive: true });
1727
+ fs.writeFileSync(
1728
+ framedScreenshotPath,
1729
+ Buffer.from(framedScreenshot.data, 'base64'),
1730
+ { flag: 'wx' },
1731
+ );
1732
+ console.log(`Saved framed ${profile.label}: ${framedScreenshotPath}`);
1733
+ return true;
1734
+ }
1735
+
1736
+ async function captureProfile(client, snapshot, profile, screenshotFileName) {
1737
+ await client.send('Emulation.setDeviceMetricsOverride', {
1738
+ width: profile.width,
1739
+ height: profile.height,
1740
+ deviceScaleFactor: profile.deviceScaleFactor,
1741
+ mobile: profile.mobile,
1742
+ screenWidth: profile.width,
1743
+ screenHeight: profile.height,
1744
+ positionX: 0,
1745
+ positionY: 0,
1746
+ });
1747
+
1748
+ const frameTree = await client.send('Page.getFrameTree');
1749
+ await client.send('Page.setDocumentContent', {
1750
+ frameId: frameTree.frameTree.frame.id,
1751
+ html: snapshot.html,
1752
+ });
1753
+ await waitForReplayLayout(client, snapshot);
1754
+
1755
+ const screenshot = await client.send('Page.captureScreenshot', {
1756
+ format: 'png',
1757
+ fromSurface: true,
1758
+ captureBeyondViewport: false,
1759
+ });
1760
+
1761
+ const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1762
+ const screenshotPath = path.join(profileDirectory, screenshotFileName);
1763
+ const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
1764
+
1765
+ fs.mkdirSync(profileDirectory, { recursive: true });
1766
+ fs.writeFileSync(screenshotPath, screenshotBuffer, { flag: 'wx' });
1767
+ console.log(`Saved ${profile.label}: ${screenshotPath}`);
1768
+ await captureFramedScreenshot(
1769
+ client,
1770
+ screenshotBuffer,
1771
+ profile,
1772
+ screenshotFileName,
1773
+ );
1774
+ }
1775
+
1776
+ async function captureScreenshotSet(
1777
+ deviceClient,
1778
+ replayClient,
1779
+ selectedProfiles,
1780
+ fileNameInput,
1781
+ ) {
1782
+ console.log('Capturing the current rendered page and resolved native data...');
1783
+ const snapshot = await capturePageSnapshot(deviceClient);
1784
+ const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
1785
+
1786
+ for (const profile of selectedProfiles) {
1787
+ await captureProfile(replayClient, snapshot, profile, screenshotFileName);
1788
+ }
1789
+
1790
+ console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
1791
+ }
1792
+
1793
+ async function frameExistingScreenshots(selectedProfiles) {
1794
+ let replayBrowser;
1795
+ let replayClient;
1796
+ let framedCount = 0;
1797
+ let failedCount = 0;
1798
+
1799
+ try {
1800
+ replayBrowser = await launchReplayBrowser();
1801
+ replayClient = await createReplayPage(replayBrowser);
1802
+
1803
+ for (const profile of selectedProfiles) {
1804
+ const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1805
+
1806
+ if (!fs.existsSync(profileDirectory)) {
1807
+ continue;
1808
+ }
1809
+
1810
+ const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
1811
+ .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
1812
+ .map((entry) => entry.name)
1813
+ .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
1814
+
1815
+ for (const screenshotFileName of screenshotFiles) {
1816
+ const screenshotPath = path.join(profileDirectory, screenshotFileName);
1817
+
1818
+ try {
1819
+ const wasCreated = await captureFramedScreenshot(
1820
+ replayClient,
1821
+ fs.readFileSync(screenshotPath),
1822
+ profile,
1823
+ screenshotFileName,
1824
+ true,
1825
+ );
1826
+ if (wasCreated) framedCount += 1;
1827
+ } catch (error) {
1828
+ failedCount += 1;
1829
+ console.error(`Failed to frame ${screenshotPath}: ${error.message}`);
1830
+ }
1831
+ }
1832
+ }
1833
+ } finally {
1834
+ if (replayClient) {
1835
+ replayClient.close();
1836
+ }
1837
+
1838
+ if (replayBrowser) {
1839
+ await closeReplayBrowser(replayBrowser);
1840
+ }
1841
+ }
1842
+
1843
+ console.log(`Existing screenshot framing completed: ${framedCount} created.`);
1844
+
1845
+ if (failedCount) {
1846
+ throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
1847
+ }
1848
+ }
1849
+
1850
+ async function runInteractiveCaptureLoop(
1851
+ deviceClient,
1852
+ replayClient,
1853
+ selectedProfiles,
1854
+ ) {
1855
+ const terminal = readline.createInterface({
1856
+ input: process.stdin,
1857
+ output: process.stdout,
1858
+ prompt: 'Enter a filename, or press Enter for the next number: ',
1859
+ });
1860
+
1861
+ terminal.on('SIGINT', () => terminal.close());
1862
+
1863
+ console.log(`Screenshots will be saved in: ${screenshotDirectory}`);
1864
+ console.log('Press Ctrl+C to exit.');
1865
+ terminal.prompt();
1866
+
1867
+ for await (const fileNameInput of terminal) {
1868
+ try {
1869
+ await captureScreenshotSet(
1870
+ deviceClient,
1871
+ replayClient,
1872
+ selectedProfiles,
1873
+ fileNameInput,
1874
+ );
1875
+ } catch (error) {
1876
+ console.error(`Responsive screenshot failed: ${error.message}`);
1877
+ }
1878
+
1879
+ terminal.prompt();
1880
+ }
1881
+
1882
+ console.log('\nResponsive screenshot tool closed.');
1883
+ }
1884
+
1879
1885
  async function getInteractiveOptions(options) {
1880
1886
  if (
1881
1887
  !process.stdin.isTTY
@@ -1883,42 +1889,42 @@ async function getInteractiveOptions(options) {
1883
1889
  || options.amazonAssets
1884
1890
  || options.samsungAssets
1885
1891
  ) {
1886
- return options;
1887
- }
1888
-
1889
- const terminal = readline.createInterface({
1890
- input: process.stdin,
1891
- output: process.stdout,
1892
- });
1893
-
1894
- try {
1895
- if (!options.profiles) {
1896
- console.log('Available profiles:');
1897
- printProfiles();
1898
- options.profiles = await terminal.question(
1899
- 'Profiles separated by commas, or press Enter for all: ',
1900
- );
1901
- }
1902
- } finally {
1903
- terminal.close();
1904
- }
1905
-
1906
- return options;
1907
- }
1908
-
1909
- async function main() {
1910
- let options = parseArguments();
1911
-
1912
- if (options.help) {
1913
- printHelp();
1914
- return;
1915
- }
1916
-
1917
- if (options.list) {
1918
- printProfiles();
1919
- return;
1920
- }
1921
-
1892
+ return options;
1893
+ }
1894
+
1895
+ const terminal = readline.createInterface({
1896
+ input: process.stdin,
1897
+ output: process.stdout,
1898
+ });
1899
+
1900
+ try {
1901
+ if (!options.profiles) {
1902
+ console.log('Available profiles:');
1903
+ printProfiles();
1904
+ options.profiles = await terminal.question(
1905
+ 'Profiles separated by commas, or press Enter for all: ',
1906
+ );
1907
+ }
1908
+ } finally {
1909
+ terminal.close();
1910
+ }
1911
+
1912
+ return options;
1913
+ }
1914
+
1915
+ async function main() {
1916
+ let options = parseArguments();
1917
+
1918
+ if (options.help) {
1919
+ printHelp();
1920
+ return;
1921
+ }
1922
+
1923
+ if (options.list) {
1924
+ printProfiles();
1925
+ return;
1926
+ }
1927
+
1922
1928
  options = await getInteractiveOptions(options);
1923
1929
 
1924
1930
  if (options.amazonAssets) {
@@ -1956,96 +1962,96 @@ async function main() {
1956
1962
  }
1957
1963
 
1958
1964
  const selectedProfiles = selectProfiles(options.profiles);
1959
-
1960
- if (options.frameExisting) {
1961
- await frameExistingScreenshots(selectedProfiles);
1962
- return;
1963
- }
1964
-
1965
- const interactiveMode = process.stdin.isTTY && !options.prefix && !options.dryRun;
1966
- const deviceId = getConnectedDevice();
1967
- const appId = getAppId();
1968
- const socketName = getWebViewSocket(deviceId, appId);
1969
- const localPort = runAdb([
1970
- '-s',
1971
- deviceId,
1972
- 'forward',
1973
- 'tcp:0',
1974
- `localabstract:${socketName}`,
1975
- ]);
1976
-
1977
- let deviceClient;
1978
- let replayBrowser;
1979
- let replayClient;
1980
-
1981
- try {
1982
- const target = await getWebViewTarget(localPort);
1983
- deviceClient = new CdpClient(target.webSocketDebuggerUrl);
1984
- await deviceClient.connect();
1985
- await deviceClient.send('Page.enable');
1986
- await deviceClient.send('Runtime.enable');
1987
-
1988
- const pageDetails = await getPageDetails(deviceClient);
1989
- console.log(`Connected device: ${deviceId}`);
1990
- console.log(`App: ${appId}`);
1991
- console.log(`Current page: ${pageDetails.title || '(untitled)'}`);
1992
- console.log(`Current URL: ${pageDetails.url}`);
1993
- console.log(
1994
- `Current viewport: ${pageDetails.width}x${pageDetails.height} `
1995
- + `at ${pageDetails.devicePixelRatio}x (${pageDetails.readyState})`,
1996
- );
1997
-
1998
- if (options.dryRun) {
1999
- console.log('Dry run completed. No screenshots were created.');
2000
- return;
2001
- }
2002
-
2003
- fs.mkdirSync(screenshotDirectory, { recursive: true });
2004
- replayBrowser = await launchReplayBrowser();
2005
- replayClient = await createReplayPage(replayBrowser);
2006
-
2007
- console.log(
2008
- 'Apple, TV, Chromebook, Mac, and XR profiles are viewport approximations of the live Android WebView.',
2009
- );
2010
-
2011
- if (interactiveMode) {
2012
- await runInteractiveCaptureLoop(
2013
- deviceClient,
2014
- replayClient,
2015
- selectedProfiles,
2016
- );
2017
- } else {
2018
- await captureScreenshotSet(
2019
- deviceClient,
2020
- replayClient,
2021
- selectedProfiles,
2022
- options.prefix,
2023
- );
2024
- }
2025
- } finally {
2026
- if (replayClient) {
2027
- replayClient.close();
2028
- }
2029
-
2030
- if (replayBrowser) {
2031
- await closeReplayBrowser(replayBrowser);
2032
- }
2033
-
2034
- if (deviceClient) {
2035
- deviceClient.close();
2036
- }
2037
-
2038
- if (localPort) {
2039
- try {
2040
- runAdb(['-s', deviceId, 'forward', '--remove', `tcp:${localPort}`]);
2041
- } catch {
2042
- // ADB may already have removed the temporary forwarding rule.
2043
- }
2044
- }
2045
- }
2046
- }
2047
-
2048
- main().catch((error) => {
2049
- console.error(`Responsive screenshot failed: ${error.message}`);
2050
- process.exitCode = 1;
2051
- });
1965
+
1966
+ if (options.frameExisting) {
1967
+ await frameExistingScreenshots(selectedProfiles);
1968
+ return;
1969
+ }
1970
+
1971
+ const interactiveMode = process.stdin.isTTY && !options.prefix && !options.dryRun;
1972
+ const deviceId = getConnectedDevice();
1973
+ const appId = getAppId();
1974
+ const socketName = getWebViewSocket(deviceId, appId);
1975
+ const localPort = runAdb([
1976
+ '-s',
1977
+ deviceId,
1978
+ 'forward',
1979
+ 'tcp:0',
1980
+ `localabstract:${socketName}`,
1981
+ ]);
1982
+
1983
+ let deviceClient;
1984
+ let replayBrowser;
1985
+ let replayClient;
1986
+
1987
+ try {
1988
+ const target = await getWebViewTarget(localPort);
1989
+ deviceClient = new CdpClient(target.webSocketDebuggerUrl);
1990
+ await deviceClient.connect();
1991
+ await deviceClient.send('Page.enable');
1992
+ await deviceClient.send('Runtime.enable');
1993
+
1994
+ const pageDetails = await getPageDetails(deviceClient);
1995
+ console.log(`Connected device: ${deviceId}`);
1996
+ console.log(`App: ${appId}`);
1997
+ console.log(`Current page: ${pageDetails.title || '(untitled)'}`);
1998
+ console.log(`Current URL: ${pageDetails.url}`);
1999
+ console.log(
2000
+ `Current viewport: ${pageDetails.width}x${pageDetails.height} `
2001
+ + `at ${pageDetails.devicePixelRatio}x (${pageDetails.readyState})`,
2002
+ );
2003
+
2004
+ if (options.dryRun) {
2005
+ console.log('Dry run completed. No screenshots were created.');
2006
+ return;
2007
+ }
2008
+
2009
+ fs.mkdirSync(screenshotDirectory, { recursive: true });
2010
+ replayBrowser = await launchReplayBrowser();
2011
+ replayClient = await createReplayPage(replayBrowser);
2012
+
2013
+ console.log(
2014
+ 'Apple, TV, Chromebook, Mac, and XR profiles are viewport approximations of the live Android WebView.',
2015
+ );
2016
+
2017
+ if (interactiveMode) {
2018
+ await runInteractiveCaptureLoop(
2019
+ deviceClient,
2020
+ replayClient,
2021
+ selectedProfiles,
2022
+ );
2023
+ } else {
2024
+ await captureScreenshotSet(
2025
+ deviceClient,
2026
+ replayClient,
2027
+ selectedProfiles,
2028
+ options.prefix,
2029
+ );
2030
+ }
2031
+ } finally {
2032
+ if (replayClient) {
2033
+ replayClient.close();
2034
+ }
2035
+
2036
+ if (replayBrowser) {
2037
+ await closeReplayBrowser(replayBrowser);
2038
+ }
2039
+
2040
+ if (deviceClient) {
2041
+ deviceClient.close();
2042
+ }
2043
+
2044
+ if (localPort) {
2045
+ try {
2046
+ runAdb(['-s', deviceId, 'forward', '--remove', `tcp:${localPort}`]);
2047
+ } catch {
2048
+ // ADB may already have removed the temporary forwarding rule.
2049
+ }
2050
+ }
2051
+ }
2052
+ }
2053
+
2054
+ main().catch((error) => {
2055
+ console.error(`Responsive screenshot failed: ${error.message}`);
2056
+ process.exitCode = 1;
2057
+ });