browser-commander 0.20.0 → 0.21.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 (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,838 @@
1
+ /**
2
+ * Browser Commander environment probe.
3
+ *
4
+ * The whole file is one expression that evaluates to an async function. Callers
5
+ * wrap it as `(<file contents>)()` so the same bytes work through
6
+ * `page.evaluate`, `Runtime.evaluate` with `awaitPromise`, Selenium's
7
+ * `execute_async_script`, and a plain `<script>` tag in a page loaded by a
8
+ * browser that nothing is automating.
9
+ *
10
+ * The report must be deterministic for a given browser build and machine: no
11
+ * timings, no random values, no wall-clock dependent fields. Anything that
12
+ * legitimately differs between two runs belongs in the caller's ignore list,
13
+ * not in this file.
14
+ */
15
+ async function collectBrowserCommanderEnvironmentReport() {
16
+ const report = {};
17
+ const errors = {};
18
+
19
+ const fail = (section, error) => {
20
+ report[section] = null;
21
+ errors[section] = String((error && error.message) || error);
22
+ };
23
+
24
+ const record = (section, producer) => {
25
+ try {
26
+ report[section] = producer();
27
+ } catch (error) {
28
+ fail(section, error);
29
+ }
30
+ };
31
+
32
+ const recordAsync = async (section, producer) => {
33
+ try {
34
+ report[section] = await producer();
35
+ } catch (error) {
36
+ fail(section, error);
37
+ }
38
+ };
39
+
40
+ /** Stable, order-independent digest so large blobs stay comparable. */
41
+ const digest = (value) => {
42
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
43
+ let h1 = 0x811c9dc5;
44
+ let h2 = 0x01000193;
45
+ for (let index = 0; index < text.length; index += 1) {
46
+ const code = text.charCodeAt(index);
47
+ h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0;
48
+ h2 = Math.imul(h2 + code + index, 0x85ebca6b) >>> 0;
49
+ }
50
+ return `${h1.toString(16)}-${h2.toString(16)}`;
51
+ };
52
+
53
+ const isNativeFunction = (fn) => {
54
+ try {
55
+ return /\{\s*\[native code\]\s*\}/u.test(
56
+ Function.prototype.toString.call(fn)
57
+ );
58
+ } catch (error) {
59
+ return `error:${String((error && error.message) || error)}`;
60
+ }
61
+ };
62
+
63
+ const descriptorShape = (object, key) => {
64
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
65
+ if (!descriptor) {
66
+ return null;
67
+ }
68
+ return {
69
+ configurable: descriptor.configurable,
70
+ enumerable: descriptor.enumerable,
71
+ hasGet: typeof descriptor.get === 'function',
72
+ hasSet: typeof descriptor.set === 'function',
73
+ getIsNative: descriptor.get ? isNativeFunction(descriptor.get) : null,
74
+ isValue: 'value' in descriptor,
75
+ valueType: 'value' in descriptor ? typeof descriptor.value : null,
76
+ writable: descriptor.writable === undefined ? null : descriptor.writable,
77
+ };
78
+ };
79
+
80
+ record('navigator', () => ({
81
+ appCodeName: navigator.appCodeName,
82
+ appName: navigator.appName,
83
+ appVersion: navigator.appVersion,
84
+ cookieEnabled: navigator.cookieEnabled,
85
+ deviceMemory: navigator.deviceMemory ?? null,
86
+ doNotTrack: navigator.doNotTrack,
87
+ hardwareConcurrency: navigator.hardwareConcurrency,
88
+ language: navigator.language,
89
+ languages: Array.from(navigator.languages || []),
90
+ maxTouchPoints: navigator.maxTouchPoints,
91
+ onLine: navigator.onLine,
92
+ pdfViewerEnabled: navigator.pdfViewerEnabled ?? null,
93
+ platform: navigator.platform,
94
+ product: navigator.product,
95
+ productSub: navigator.productSub,
96
+ userAgent: navigator.userAgent,
97
+ vendor: navigator.vendor,
98
+ vendorSub: navigator.vendorSub,
99
+ webdriver: navigator.webdriver ?? null,
100
+ globalPrivacyControl: navigator.globalPrivacyControl ?? null,
101
+ hasUserActivation: typeof navigator.userActivation === 'object',
102
+ hasBluetooth: 'bluetooth' in navigator,
103
+ hasCredentials: 'credentials' in navigator,
104
+ hasHid: 'hid' in navigator,
105
+ hasKeyboard: 'keyboard' in navigator,
106
+ hasLocks: 'locks' in navigator,
107
+ hasSerial: 'serial' in navigator,
108
+ hasUsb: 'usb' in navigator,
109
+ hasWakeLock: 'wakeLock' in navigator,
110
+ hasWebGpu: 'gpu' in navigator,
111
+ hasXr: 'xr' in navigator,
112
+ keys: Object.getOwnPropertyNames(Object.getPrototypeOf(navigator)).sort(),
113
+ ownKeys: Object.getOwnPropertyNames(navigator).sort(),
114
+ }));
115
+
116
+ await recordAsync('userAgentData', async () => {
117
+ const data = navigator.userAgentData;
118
+ if (!data) {
119
+ return null;
120
+ }
121
+ const highEntropy = await data.getHighEntropyValues([
122
+ 'architecture',
123
+ 'bitness',
124
+ 'formFactors',
125
+ 'fullVersionList',
126
+ 'model',
127
+ 'platformVersion',
128
+ 'uaFullVersion',
129
+ 'wow64',
130
+ ]);
131
+ return {
132
+ brands: (data.brands || []).map((brand) => ({
133
+ brand: brand.brand,
134
+ version: brand.version,
135
+ })),
136
+ mobile: data.mobile,
137
+ platform: data.platform,
138
+ highEntropy,
139
+ };
140
+ });
141
+
142
+ record('webdriverDescriptor', () => ({
143
+ onInstance: descriptorShape(navigator, 'webdriver'),
144
+ onPrototype: descriptorShape(Navigator.prototype, 'webdriver'),
145
+ }));
146
+
147
+ /**
148
+ * Every property the fingerprint init script is allowed to touch, described
149
+ * the way a detector would look at it. A patched accessor that differs from
150
+ * the real one in configurability, enumerability, getter name or
151
+ * `[native code]` shape is itself the tell.
152
+ */
153
+ record('patchedDescriptors', () => {
154
+ const groups = [
155
+ [
156
+ 'Navigator.prototype',
157
+ Navigator.prototype,
158
+ [
159
+ 'webdriver',
160
+ 'deviceMemory',
161
+ 'vendor',
162
+ 'doNotTrack',
163
+ 'language',
164
+ 'languages',
165
+ 'hardwareConcurrency',
166
+ 'maxTouchPoints',
167
+ 'platform',
168
+ 'userAgent',
169
+ ],
170
+ ],
171
+ [
172
+ 'Screen.prototype',
173
+ Screen.prototype,
174
+ [
175
+ 'width',
176
+ 'height',
177
+ 'availWidth',
178
+ 'availHeight',
179
+ 'colorDepth',
180
+ 'pixelDepth',
181
+ ],
182
+ ],
183
+ [
184
+ 'WebGLRenderingContext.prototype',
185
+ WebGLRenderingContext.prototype,
186
+ ['getParameter'],
187
+ ],
188
+ ];
189
+ const result = {};
190
+ for (const [label, target, keys] of groups) {
191
+ for (const key of keys) {
192
+ const shape = descriptorShape(target, key);
193
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
194
+ const accessor = descriptor && (descriptor.get || descriptor.value);
195
+ result[`${label}.${key}`] = shape
196
+ ? { ...shape, name: accessor ? accessor.name : null }
197
+ : null;
198
+ }
199
+ }
200
+ return result;
201
+ });
202
+
203
+ const describeMimeType = ({ description, suffixes, type }) => ({
204
+ description,
205
+ suffixes,
206
+ type,
207
+ });
208
+ record('plugins', () => ({
209
+ length: navigator.plugins.length,
210
+ items: Array.from(navigator.plugins).map((plugin) => ({
211
+ description: plugin.description,
212
+ filename: plugin.filename,
213
+ name: plugin.name,
214
+ mimeTypes: Array.from(plugin).map(describeMimeType),
215
+ })),
216
+ mimeTypesLength: navigator.mimeTypes.length,
217
+ mimeTypes: Array.from(navigator.mimeTypes).map(describeMimeType),
218
+ pluginsIsPluginArray:
219
+ Object.prototype.toString.call(navigator.plugins) ===
220
+ '[object PluginArray]',
221
+ mimeTypesIsMimeTypeArray:
222
+ Object.prototype.toString.call(navigator.mimeTypes) ===
223
+ '[object MimeTypeArray]',
224
+ }));
225
+
226
+ record('screen', () => ({
227
+ availHeight: screen.availHeight,
228
+ availLeft: screen.availLeft ?? null,
229
+ availTop: screen.availTop ?? null,
230
+ availWidth: screen.availWidth,
231
+ colorDepth: screen.colorDepth,
232
+ height: screen.height,
233
+ isExtended: screen.isExtended ?? null,
234
+ orientationAngle: screen.orientation ? screen.orientation.angle : null,
235
+ orientationType: screen.orientation ? screen.orientation.type : null,
236
+ pixelDepth: screen.pixelDepth,
237
+ width: screen.width,
238
+ }));
239
+
240
+ record('window', () => ({
241
+ devicePixelRatio: window.devicePixelRatio,
242
+ innerHeight: window.innerHeight,
243
+ innerWidth: window.innerWidth,
244
+ outerHeight: window.outerHeight,
245
+ outerWidth: window.outerWidth,
246
+ screenLeft: window.screenLeft,
247
+ screenTop: window.screenTop,
248
+ screenX: window.screenX,
249
+ screenY: window.screenY,
250
+ chromeKeys: window.chrome ? Object.keys(window.chrome).sort() : null,
251
+ hasChrome: 'chrome' in window,
252
+ chromeRuntimeType: window.chrome ? typeof window.chrome.runtime : null,
253
+ chromeCsiType: window.chrome ? typeof window.chrome.csi : null,
254
+ chromeLoadTimesType: window.chrome ? typeof window.chrome.loadTimes : null,
255
+ chromeAppType: window.chrome ? typeof window.chrome.app : null,
256
+ barsVisible: {
257
+ locationbar: window.locationbar.visible,
258
+ menubar: window.menubar.visible,
259
+ personalbar: window.personalbar.visible,
260
+ scrollbars: window.scrollbars.visible,
261
+ statusbar: window.statusbar.visible,
262
+ toolbar: window.toolbar.visible,
263
+ },
264
+ /** Injected automation bindings show up as extra own properties here. */
265
+ suspiciousGlobals: Object.getOwnPropertyNames(window)
266
+ .filter((name) =>
267
+ /^(cdc_|\$cdc|__driver|__webdriver|__selenium|__fxdriver|__nightmare|_Selenium|_phantom|callPhantom|domAutomation|puppeteer|playwright|__playwright|__pw|__puppeteer)/iu.test(
268
+ name
269
+ )
270
+ )
271
+ .sort(),
272
+ documentKeys: Object.getOwnPropertyNames(document)
273
+ .filter((name) => /^(\$cdc|cdc_|__|_Selenium)/u.test(name))
274
+ .sort(),
275
+ }));
276
+
277
+ record('viewportRelation', () => ({
278
+ outerMinusInnerHeightIsZero: window.outerHeight - window.innerHeight === 0,
279
+ outerWidthIsZero: window.outerWidth === 0,
280
+ outerHeightIsZero: window.outerHeight === 0,
281
+ }));
282
+
283
+ record('intl', () => {
284
+ const dateTime = Intl.DateTimeFormat().resolvedOptions();
285
+ const number = Intl.NumberFormat().resolvedOptions();
286
+ const collator = Intl.Collator().resolvedOptions();
287
+ return {
288
+ dateTimeCalendar: dateTime.calendar,
289
+ dateTimeLocale: dateTime.locale,
290
+ dateTimeNumberingSystem: dateTime.numberingSystem,
291
+ dateTimeTimeZone: dateTime.timeZone,
292
+ collatorLocale: collator.locale,
293
+ numberLocale: number.locale,
294
+ numberNumberingSystem: number.numberingSystem,
295
+ timezoneOffsetJanuary: new Date(Date.UTC(2020, 0, 1)).getTimezoneOffset(),
296
+ timezoneOffsetJuly: new Date(Date.UTC(2020, 6, 1)).getTimezoneOffset(),
297
+ fixedDateString: new Date(Date.UTC(2020, 0, 1, 12)).toString(),
298
+ fixedDateLocaleString: new Date(
299
+ Date.UTC(2020, 0, 1, 12)
300
+ ).toLocaleString(),
301
+ supportedLocalesEnUs: Intl.DateTimeFormat.supportedLocalesOf(['en-US']),
302
+ };
303
+ });
304
+
305
+ record('mediaQueries', () => {
306
+ const queries = [
307
+ '(prefers-color-scheme: dark)',
308
+ '(prefers-color-scheme: light)',
309
+ '(prefers-reduced-motion: reduce)',
310
+ '(prefers-reduced-transparency: reduce)',
311
+ '(prefers-contrast: more)',
312
+ '(forced-colors: active)',
313
+ '(inverted-colors: inverted)',
314
+ '(any-hover: hover)',
315
+ '(any-pointer: fine)',
316
+ '(any-pointer: coarse)',
317
+ '(hover: hover)',
318
+ '(pointer: fine)',
319
+ '(pointer: coarse)',
320
+ '(display-mode: browser)',
321
+ '(color-gamut: srgb)',
322
+ '(dynamic-range: high)',
323
+ '(update: fast)',
324
+ '(scripting: enabled)',
325
+ ];
326
+ const result = {};
327
+ for (const query of queries) {
328
+ result[query] = window.matchMedia(query).matches;
329
+ }
330
+ return result;
331
+ });
332
+
333
+ record('css', () => {
334
+ const probeElement = document.createElement('div');
335
+ probeElement.style.cssText =
336
+ 'position:absolute;left:-9999px;font-family:monospace;font-size:16px;';
337
+ probeElement.textContent = 'BrowserCommander parity 0123456789';
338
+ document.documentElement.appendChild(probeElement);
339
+ const computed = window.getComputedStyle(probeElement);
340
+ const rect = probeElement.getBoundingClientRect();
341
+ const measurement = {
342
+ fontFamily: computed.fontFamily,
343
+ fontSize: computed.fontSize,
344
+ height: Math.round(rect.height * 100) / 100,
345
+ width: Math.round(rect.width * 100) / 100,
346
+ };
347
+ probeElement.remove();
348
+ return measurement;
349
+ });
350
+
351
+ record('fonts', () => {
352
+ const families = [
353
+ 'Arial',
354
+ 'Courier New',
355
+ 'DejaVu Sans',
356
+ 'Georgia',
357
+ 'Helvetica',
358
+ 'Liberation Sans',
359
+ 'Times New Roman',
360
+ 'Ubuntu',
361
+ 'Verdana',
362
+ ];
363
+ const canvas = document.createElement('canvas');
364
+ const context = canvas.getContext('2d');
365
+ const widths = {};
366
+ for (const family of families) {
367
+ context.font = `16px "${family}", monospace`;
368
+ widths[family] =
369
+ Math.round(context.measureText('mmmmmmmmmmlli').width * 100) / 100;
370
+ }
371
+ return {
372
+ widths,
373
+ checks: families.reduce((accumulator, family) => {
374
+ accumulator[family] = document.fonts.check(`16px "${family}"`);
375
+ return accumulator;
376
+ }, {}),
377
+ };
378
+ });
379
+
380
+ record('canvas2d', () => {
381
+ const canvas = document.createElement('canvas');
382
+ canvas.width = 240;
383
+ canvas.height = 60;
384
+ const context = canvas.getContext('2d');
385
+ context.textBaseline = 'top';
386
+ context.font = '14px "Arial"';
387
+ context.fillStyle = '#f60';
388
+ context.fillRect(0, 0, 100, 20);
389
+ context.fillStyle = '#069';
390
+ context.fillText('BrowserCommander 0123456789', 2, 15);
391
+ context.fillStyle = 'rgba(102, 204, 0, 0.7)';
392
+ context.fillText('BrowserCommander 0123456789', 4, 25);
393
+ context.globalCompositeOperation = 'multiply';
394
+ context.beginPath();
395
+ context.arc(50, 50, 20, 0, Math.PI * 2, true);
396
+ context.fill();
397
+ return {
398
+ dataUrlDigest: digest(canvas.toDataURL()),
399
+ textMetricsWidth:
400
+ Math.round(context.measureText('BrowserCommander').width * 100) / 100,
401
+ };
402
+ });
403
+
404
+ record('webgl', () => {
405
+ const canvas = document.createElement('canvas');
406
+ const collect = (contextName) => {
407
+ const gl = canvas.getContext(contextName, {
408
+ failIfMajorPerformanceCaveat: false,
409
+ });
410
+ if (!gl) {
411
+ return null;
412
+ }
413
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
414
+ const parameterNames = [
415
+ 'MAX_TEXTURE_SIZE',
416
+ 'MAX_VIEWPORT_DIMS',
417
+ 'MAX_RENDERBUFFER_SIZE',
418
+ 'MAX_VERTEX_ATTRIBS',
419
+ 'MAX_VARYING_VECTORS',
420
+ 'MAX_VERTEX_UNIFORM_VECTORS',
421
+ 'MAX_FRAGMENT_UNIFORM_VECTORS',
422
+ 'MAX_COMBINED_TEXTURE_IMAGE_UNITS',
423
+ 'MAX_CUBE_MAP_TEXTURE_SIZE',
424
+ 'ALIASED_LINE_WIDTH_RANGE',
425
+ 'ALIASED_POINT_SIZE_RANGE',
426
+ 'RED_BITS',
427
+ 'GREEN_BITS',
428
+ 'BLUE_BITS',
429
+ 'ALPHA_BITS',
430
+ 'DEPTH_BITS',
431
+ 'STENCIL_BITS',
432
+ ];
433
+ const parameters = {};
434
+ for (const name of parameterNames) {
435
+ if (gl[name] === undefined) {
436
+ continue;
437
+ }
438
+ const value = gl.getParameter(gl[name]);
439
+ parameters[name] = ArrayBuffer.isView(value)
440
+ ? Array.from(value)
441
+ : value;
442
+ }
443
+ return {
444
+ vendor: gl.getParameter(gl.VENDOR),
445
+ renderer: gl.getParameter(gl.RENDERER),
446
+ version: gl.getParameter(gl.VERSION),
447
+ shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
448
+ unmaskedVendor: debugInfo
449
+ ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
450
+ : null,
451
+ unmaskedRenderer: debugInfo
452
+ ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
453
+ : null,
454
+ extensions: (gl.getSupportedExtensions() || []).slice().sort(),
455
+ parameters,
456
+ contextAttributes: gl.getContextAttributes(),
457
+ };
458
+ };
459
+ return { webgl1: collect('webgl'), webgl2: collect('webgl2') };
460
+ });
461
+
462
+ await recordAsync('audio', async () => {
463
+ const OfflineContext =
464
+ window.OfflineAudioContext || window.webkitOfflineAudioContext;
465
+ if (!OfflineContext) {
466
+ return null;
467
+ }
468
+ const context = new OfflineContext(1, 5000, 44100);
469
+ const oscillator = context.createOscillator();
470
+ oscillator.type = 'triangle';
471
+ oscillator.frequency.value = 10000;
472
+ const compressor = context.createDynamicsCompressor();
473
+ compressor.threshold.value = -50;
474
+ compressor.knee.value = 40;
475
+ compressor.ratio.value = 12;
476
+ compressor.attack.value = 0;
477
+ compressor.release.value = 0.25;
478
+ oscillator.connect(compressor);
479
+ compressor.connect(context.destination);
480
+ oscillator.start(0);
481
+ const buffer = await context.startRendering();
482
+ const samples = buffer.getChannelData(0);
483
+ let sum = 0;
484
+ for (let index = 4500; index < 5000; index += 1) {
485
+ sum += Math.abs(samples[index]);
486
+ }
487
+ return {
488
+ sampleRate: context.sampleRate,
489
+ digest: digest(sum.toFixed(8)),
490
+ channelCount: buffer.numberOfChannels,
491
+ };
492
+ });
493
+
494
+ record('codecs', () => {
495
+ const video = document.createElement('video');
496
+ const audio = document.createElement('audio');
497
+ const videoTypes = [
498
+ 'video/mp4; codecs="avc1.42E01E"',
499
+ 'video/mp4; codecs="hev1.1.6.L93.B0"',
500
+ 'video/webm; codecs="vp8"',
501
+ 'video/webm; codecs="vp9"',
502
+ 'video/webm; codecs="av01.0.05M.08"',
503
+ 'video/ogg; codecs="theora"',
504
+ ];
505
+ const audioTypes = [
506
+ 'audio/mpeg',
507
+ 'audio/mp4; codecs="mp4a.40.2"',
508
+ 'audio/ogg; codecs="vorbis"',
509
+ 'audio/ogg; codecs="opus"',
510
+ 'audio/wav; codecs="1"',
511
+ 'audio/aac',
512
+ 'audio/flac',
513
+ ];
514
+ const result = { video: {}, audio: {} };
515
+ for (const type of videoTypes) {
516
+ result.video[type] = video.canPlayType(type);
517
+ }
518
+ for (const type of audioTypes) {
519
+ result.audio[type] = audio.canPlayType(type);
520
+ }
521
+ return result;
522
+ });
523
+
524
+ await recordAsync('permissions', async () => {
525
+ const names = [
526
+ 'accelerometer',
527
+ 'camera',
528
+ 'clipboard-read',
529
+ 'geolocation',
530
+ 'microphone',
531
+ 'midi',
532
+ 'notifications',
533
+ 'persistent-storage',
534
+ ];
535
+ const states = {};
536
+ for (const name of names) {
537
+ try {
538
+ const status = await navigator.permissions.query({ name });
539
+ states[name] = status.state;
540
+ } catch (error) {
541
+ states[name] = `error:${String((error && error.message) || error)}`;
542
+ }
543
+ }
544
+ return {
545
+ states,
546
+ notificationPermission:
547
+ typeof Notification === 'undefined' ? null : Notification.permission,
548
+ /** Headless Chrome historically reported denied here but default above. */
549
+ notificationsMismatch:
550
+ typeof Notification !== 'undefined' &&
551
+ states.notifications === 'prompt' &&
552
+ Notification.permission === 'denied',
553
+ };
554
+ });
555
+
556
+ await recordAsync('mediaDevices', async () => {
557
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
558
+ return null;
559
+ }
560
+ const devices = await navigator.mediaDevices.enumerateDevices();
561
+ const kinds = {};
562
+ for (const device of devices) {
563
+ kinds[device.kind] = (kinds[device.kind] || 0) + 1;
564
+ }
565
+ return { count: devices.length, kinds };
566
+ });
567
+
568
+ record('connection', () => {
569
+ const connection =
570
+ navigator.connection ||
571
+ navigator.mozConnection ||
572
+ navigator.webkitConnection;
573
+ if (!connection) {
574
+ return null;
575
+ }
576
+ return {
577
+ downlink: connection.downlink,
578
+ effectiveType: connection.effectiveType,
579
+ rtt: connection.rtt,
580
+ saveData: connection.saveData,
581
+ type: connection.type ?? null,
582
+ };
583
+ });
584
+
585
+ await recordAsync('battery', async () => {
586
+ if (typeof navigator.getBattery !== 'function') {
587
+ return null;
588
+ }
589
+ const battery = await navigator.getBattery();
590
+ return {
591
+ charging: battery.charging,
592
+ chargingTime: battery.chargingTime,
593
+ dischargingTime: battery.dischargingTime,
594
+ level: battery.level,
595
+ };
596
+ });
597
+
598
+ record('speech', () => {
599
+ if (typeof speechSynthesis === 'undefined') {
600
+ return null;
601
+ }
602
+ const voices = speechSynthesis.getVoices();
603
+ return {
604
+ count: voices.length,
605
+ names: voices
606
+ .map((voice) => `${voice.name}|${voice.lang}|${voice.default}`)
607
+ .sort(),
608
+ };
609
+ });
610
+
611
+ record('errors', () => {
612
+ const error = new Error('browser-commander-parity');
613
+ const stackLines = String(error.stack || '').split('\n').length;
614
+ let asyncStackLines = null;
615
+ try {
616
+ null.property();
617
+ } catch (typeError) {
618
+ asyncStackLines = String(typeError.stack || '').split('\n').length;
619
+ }
620
+ return {
621
+ stackLines,
622
+ asyncStackLines,
623
+ hasCaptureStackTrace: typeof Error.captureStackTrace === 'function',
624
+ stackTraceLimit: Error.stackTraceLimit,
625
+ prepareStackTraceType: typeof Error.prepareStackTrace,
626
+ messageFirstLine: String(error.stack || '').split('\n')[0],
627
+ };
628
+ });
629
+
630
+ record('nativeFunctions', () => {
631
+ const targets = {
632
+ 'navigator.permissions.query':
633
+ navigator.permissions && navigator.permissions.query,
634
+ 'navigator.plugins.item': navigator.plugins.item,
635
+ 'navigator.mediaDevices.enumerateDevices':
636
+ navigator.mediaDevices && navigator.mediaDevices.enumerateDevices,
637
+ 'HTMLCanvasElement.toDataURL': HTMLCanvasElement.prototype.toDataURL,
638
+ 'CanvasRenderingContext2D.getImageData':
639
+ CanvasRenderingContext2D.prototype.getImageData,
640
+ 'WebGLRenderingContext.getParameter':
641
+ WebGLRenderingContext.prototype.getParameter,
642
+ 'Function.prototype.toString': Function.prototype.toString,
643
+ 'Object.getOwnPropertyDescriptor': Object.getOwnPropertyDescriptor,
644
+ 'Reflect.get': Reflect.get,
645
+ 'Date.prototype.getTimezoneOffset': Date.prototype.getTimezoneOffset,
646
+ 'Intl.DateTimeFormat': Intl.DateTimeFormat,
647
+ };
648
+ const result = {};
649
+ for (const [name, fn] of Object.entries(targets)) {
650
+ result[name] = fn ? isNativeFunction(fn) : null;
651
+ }
652
+ return result;
653
+ });
654
+
655
+ record('iframe', () => {
656
+ const frame = document.createElement('iframe');
657
+ frame.style.display = 'none';
658
+ document.documentElement.appendChild(frame);
659
+ const contentWindow = frame.contentWindow;
660
+ const measurement = {
661
+ hasContentWindow: Boolean(contentWindow),
662
+ webdriver: contentWindow
663
+ ? (contentWindow.navigator.webdriver ?? null)
664
+ : null,
665
+ hardwareConcurrency: contentWindow
666
+ ? contentWindow.navigator.hardwareConcurrency
667
+ : null,
668
+ languages: contentWindow
669
+ ? Array.from(contentWindow.navigator.languages || [])
670
+ : null,
671
+ platform: contentWindow ? contentWindow.navigator.platform : null,
672
+ userAgent: contentWindow ? contentWindow.navigator.userAgent : null,
673
+ hasChrome: contentWindow ? 'chrome' in contentWindow : null,
674
+ pluginsLength: contentWindow
675
+ ? contentWindow.navigator.plugins.length
676
+ : null,
677
+ };
678
+ frame.remove();
679
+ return measurement;
680
+ });
681
+
682
+ record('document', () => ({
683
+ characterSet: document.characterSet,
684
+ compatMode: document.compatMode,
685
+ contentType: document.contentType,
686
+ designMode: document.designMode,
687
+ hidden: document.hidden,
688
+ referrer: document.referrer,
689
+ visibilityState: document.visibilityState,
690
+ hasFocus: document.hasFocus(),
691
+ bodyClientHeightIsPositive: document.body
692
+ ? document.body.clientHeight > 0
693
+ : null,
694
+ }));
695
+
696
+ await recordAsync('webgpu', async () => {
697
+ if (!navigator.gpu) {
698
+ return null;
699
+ }
700
+ const adapter = await navigator.gpu.requestAdapter();
701
+ if (!adapter) {
702
+ return { hasAdapter: false };
703
+ }
704
+ return {
705
+ hasAdapter: true,
706
+ features: Array.from(adapter.features || []).sort(),
707
+ isFallbackAdapter: adapter.isFallbackAdapter ?? null,
708
+ };
709
+ });
710
+
711
+ record('storage', () => ({
712
+ localStorageAvailable: (() => {
713
+ try {
714
+ window.localStorage.setItem('__bc_probe__', '1');
715
+ window.localStorage.removeItem('__bc_probe__');
716
+ return true;
717
+ } catch (error) {
718
+ return String((error && error.message) || error);
719
+ }
720
+ })(),
721
+ indexedDbAvailable: typeof indexedDB !== 'undefined',
722
+ hasStorageEstimate: Boolean(
723
+ navigator.storage && navigator.storage.estimate
724
+ ),
725
+ }));
726
+
727
+ /**
728
+ * A dedicated worker gets a fresh JavaScript realm that no page init script
729
+ * ever touches. Anything Chrome itself overrides is consistent here; anything
730
+ * that is only a property patch in the document is not, and the mismatch
731
+ * between the two is a stronger signal than either value alone.
732
+ */
733
+ await recordAsync('worker', async () => {
734
+ const source = `self.onmessage = () => {
735
+ const navigatorValues = {
736
+ userAgent: navigator.userAgent,
737
+ platform: navigator.platform,
738
+ language: navigator.language,
739
+ languages: Array.from(navigator.languages),
740
+ hardwareConcurrency: navigator.hardwareConcurrency,
741
+ deviceMemory: navigator.deviceMemory === undefined ? null : navigator.deviceMemory,
742
+ vendor: navigator.vendor,
743
+ webdriver: navigator.webdriver === undefined ? null : navigator.webdriver,
744
+ };
745
+ const resolved = Intl.DateTimeFormat().resolvedOptions();
746
+ self.postMessage({
747
+ navigator: navigatorValues,
748
+ timeZone: resolved.timeZone,
749
+ locale: resolved.locale,
750
+ });
751
+ };`;
752
+ const blob = new Blob([source], { type: 'text/javascript' });
753
+ const url = URL.createObjectURL(blob);
754
+ try {
755
+ const worker = new Worker(url);
756
+ const result = await new Promise((resolve, reject) => {
757
+ const timer = setTimeout(
758
+ () => reject(new Error('worker timeout')),
759
+ 5000
760
+ );
761
+ worker.onmessage = (event) => {
762
+ clearTimeout(timer);
763
+ resolve(event.data);
764
+ };
765
+ worker.onerror = (event) => {
766
+ clearTimeout(timer);
767
+ reject(new Error(String(event.message || 'worker error')));
768
+ };
769
+ worker.postMessage('go');
770
+ });
771
+ worker.terminate();
772
+ return result;
773
+ } finally {
774
+ URL.revokeObjectURL(url);
775
+ }
776
+ });
777
+
778
+ record('cdpSignals', () => {
779
+ // When a CDP client has called Runtime.enable for this execution context,
780
+ // the console API forwards its arguments to the inspector, which serialises
781
+ // them and therefore reads Error.prototype.stack. A real browser with no
782
+ // debugger attached never touches the getter.
783
+ let consoleDebugReadStack = false;
784
+ try {
785
+ const probeError = new Error('bc-probe');
786
+ Object.defineProperty(probeError, 'stack', {
787
+ configurable: true,
788
+ get() {
789
+ consoleDebugReadStack = true;
790
+ return '';
791
+ },
792
+ });
793
+ console.debug(probeError);
794
+ } catch (error) {
795
+ consoleDebugReadStack = String((error && error.message) || error);
796
+ }
797
+
798
+ // Same idea, but on an object preview rather than an error description:
799
+ // the inspector reads own properties to build the preview it sends with
800
+ // Runtime.consoleAPICalled.
801
+ const previewProbe = (emit) => {
802
+ let read = false;
803
+ try {
804
+ const element = document.createElement('div');
805
+ Object.defineProperty(element, 'id', {
806
+ configurable: true,
807
+ get() {
808
+ read = true;
809
+ return '';
810
+ },
811
+ });
812
+ emit(element);
813
+ } catch (error) {
814
+ return String((error && error.message) || error);
815
+ }
816
+ return read;
817
+ };
818
+
819
+ let stackTraceLimitIsDefault;
820
+ try {
821
+ stackTraceLimitIsDefault = Error.stackTraceLimit === 10;
822
+ } catch {
823
+ stackTraceLimitIsDefault = null;
824
+ }
825
+
826
+ return {
827
+ consoleDebugReadStack,
828
+ consoleDebugReadElementId: previewProbe((value) => console.debug(value)),
829
+ consoleLogReadElementId: previewProbe((value) => console.log(value)),
830
+ consoleTableReadElementId: previewProbe((value) => console.table(value)),
831
+ consoleDirReadElementId: previewProbe((value) => console.dir(value)),
832
+ stackTraceLimitIsDefault,
833
+ };
834
+ });
835
+
836
+ report.probeErrors = errors;
837
+ return report;
838
+ }