browserless 13.4.1 → 13.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -39,6 +39,7 @@ export interface Context {
39
39
  screenshot: (page: Page, opts?: ScreenshotOptions) => Promise<Buffer>
40
40
  text: (url: string, opts?: GotoOptions) => Promise<string>
41
41
  getDevice: (deviceName: string) => Viewport | undefined
42
+ report: (opts?: { benchmark?: boolean }) => Promise<HardwareInfo>
42
43
  destroyContext: (opts?: { force?: boolean }) => Promise<void>
43
44
  withPage: <T>(fn: (page: Page, goto: unknown) => Promise<T>, opts?: { timeout?: number }) => Promise<T>
44
45
  }
@@ -69,6 +70,103 @@ export interface ScreenshotOptions {
69
70
  encoding?: 'binary' | 'base64'
70
71
  }
71
72
 
73
+ export interface WebGLContextInfo {
74
+ supported: boolean
75
+ /** From WEBGL_debug_renderer_info; null if the extension is unavailable. */
76
+ unmaskedVendor?: string | null
77
+ unmaskedRenderer?: string | null
78
+ version?: string
79
+ shadingLanguageVersion?: string
80
+ /** GL MAX_* parameters (e.g. maxTextureSize). maxViewportDims is a [w, h] pair. */
81
+ capabilities?: Record<string, number | number[]>
82
+ extensions?: string[]
83
+ }
84
+
85
+ export interface WebGPUInfo {
86
+ supported: boolean
87
+ adapter?: {
88
+ vendor: string | null
89
+ architecture: string | null
90
+ device: string | null
91
+ description: string | null
92
+ }
93
+ }
94
+
95
+ export interface HardwareInfo {
96
+ browser: {
97
+ name: string
98
+ version: string | null
99
+ headless: boolean
100
+ /** Release channel ("stable" | "beta" | "dev" | "canary"), when detectable. */
101
+ channel?: string
102
+ /** Build flavor, e.g. "Chromium" | "chrome-for-testing" | "chrome-headless-shell". */
103
+ build?: string
104
+ /** Full sanitized command line; env-specific/sensitive args are omitted. */
105
+ arguments?: string[]
106
+ /** Only the flags this app intentionally adds (excludes Chromium/Puppeteer defaults). */
107
+ customArguments?: string[]
108
+ } | null
109
+ environment: {
110
+ virtualized: boolean
111
+ container: boolean
112
+ }
113
+ os: {
114
+ platform: string
115
+ release: string
116
+ distro?: string
117
+ }
118
+ cpu: {
119
+ model?: string
120
+ /** Physical cores. */
121
+ cores: number
122
+ /** Logical processors. */
123
+ threads: number
124
+ /** MHz; omitted when the platform does not report it. */
125
+ speed?: number
126
+ arch: string
127
+ flags?: string[]
128
+ }
129
+ memory: {
130
+ /** Total physical memory, in bytes. */
131
+ total: number
132
+ }
133
+ gpu: {
134
+ vendor: string | null
135
+ device: string | null
136
+ type: 'hardware' | 'software' | null
137
+ /** The graphics stack ANGLE translates to. */
138
+ graphics: {
139
+ /** Translation layer, e.g. "ANGLE"; null when the renderer is not wrapped. */
140
+ translationLayer: string | null
141
+ /** Graphics API: "OpenGL" | "Vulkan" | "Metal" | "Direct3D11" | "Direct3D12" | ... */
142
+ name: string | null
143
+ version: string | null
144
+ }
145
+ /** Mesa version (software path); read from the host package. */
146
+ mesa?: string
147
+ /** LLVM version backing llvmpipe (software path). */
148
+ llvm?: string
149
+ /** llvmpipe SIMD JIT width in bits (software path). */
150
+ simdWidth?: number
151
+ webgl: {
152
+ v1: WebGLContextInfo
153
+ v2: WebGLContextInfo
154
+ }
155
+ webgpu: WebGPUInfo
156
+ }
157
+ /** Present only when report({ benchmark: true }) is requested. */
158
+ performance?: {
159
+ webgl: {
160
+ frames: number
161
+ /** Total wall time for all frames, in milliseconds. */
162
+ totalMs: number
163
+ /** Mean per-frame time, in milliseconds. */
164
+ frameTimeMs: number
165
+ fps: number
166
+ }
167
+ }
168
+ }
169
+
72
170
  export interface Browserless {
73
171
  createContext: (opts?: ContextOptions) => Promise<Context>
74
172
  respawn: () => void
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "browserless",
3
3
  "description": "The headless Chrome/Chromium driver on top of Puppeteer. Take screenshots, generate PDFs, extract text and HTML with a production-ready API.",
4
4
  "homepage": "https://browserless.js.org",
5
- "version": "13.4.1",
5
+ "version": "13.5.3",
6
6
  "types": "index.d.ts",
7
7
  "main": "src/index.js",
8
8
  "author": {
@@ -36,9 +36,9 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "@browserless/errors": "13.1.7",
39
- "@browserless/goto": "13.4.1",
40
- "@browserless/pdf": "13.4.1",
41
- "@browserless/screenshot": "13.4.1",
39
+ "@browserless/goto": "13.5.3",
40
+ "@browserless/pdf": "13.5.3",
41
+ "@browserless/screenshot": "13.5.3",
42
42
  "debug-logfmt": "~1.4.10",
43
43
  "kill-process-group": "~1.0.13",
44
44
  "p-reflect": "~2.1.0",
@@ -48,7 +48,7 @@
48
48
  "superlock": "~1.2.7"
49
49
  },
50
50
  "devDependencies": {
51
- "@browserless/test": "13.4.1",
51
+ "@browserless/test": "13.5.3",
52
52
  "ava": "5",
53
53
  "ps-list": "7",
54
54
  "puppeteer": "25",
@@ -70,5 +70,5 @@
70
70
  "timeout": "2m",
71
71
  "workerThreads": false
72
72
  },
73
- "gitHead": "a3b2bf486af21f948fdb62f8bcf88daaf67f39b9"
73
+ "gitHead": "3aed0ceefb700a38debc5be98241a7985818c88d"
74
74
  }
package/src/index.js CHANGED
@@ -198,6 +198,7 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
198
198
  browser: getBrowser,
199
199
  evaluate,
200
200
  goto,
201
+ report: withPage(require('./report')),
201
202
  html: evaluate(page => page.content(), { flattenShadowDOM: true }),
202
203
  page: createPage,
203
204
  pdf: withPage(createPdf({ goto })),
package/src/report.js ADDED
@@ -0,0 +1,482 @@
1
+ 'use strict'
2
+
3
+ const { execFile } = require('child_process')
4
+ const { readFileSync, readdirSync, existsSync } = require('fs')
5
+ const { promisify } = require('util')
6
+ const os = require('os')
7
+
8
+ const driver = require('./driver')
9
+
10
+ const execFileAsync = promisify(execFile)
11
+
12
+ // Inspect the hardware the browser actually renders on: the browser build, the
13
+ // GPU/WebGL/WebGPU backends (resolved at runtime, in-page), and the host
14
+ // environment / OS / CPU / memory (from Node). Optionally runs a small
15
+ // deterministic WebGL benchmark (`report({ benchmark: true })`).
16
+ //
17
+ // GPU: the ANGLE renderer string (e.g. "ANGLE (Mesa, llvmpipe (LLVM 15.0.7 256
18
+ // bits), OpenGL 4.5)") is parsed into normalized fields:
19
+ // - vendor / device the GL vendor and renderer device.
20
+ // - type 'software' (llvmpipe/swiftshader CPU path) or 'hardware';
21
+ // a swiftshader device is the slow (~4x) fallback we
22
+ // must never silently hit.
23
+ // - graphics { translationLayer, name, version } — ANGLE translates
24
+ // to a graphics API (OpenGL/Vulkan/Metal/Direct3D11/12).
25
+ // Structured so new APIs need no schema change.
26
+ // - mesa / llvm / simdWidth software-stack detail; `mesa` is NOT in the string
27
+ // (ANGLE drops it), so it is read from the host package.
28
+ // Per-version `webgl.v1`/`webgl.v2` keep the raw UNMASKED strings, the renderer
29
+ // `capabilities` (GL MAX_* parameters) and the supported-extension list.
30
+ //
31
+ // memory.total is in BYTES. OS distro gates the available Mesa/LLVM (e.g. Ubuntu
32
+ // 22.04 caps at Mesa 23.2.1 / LLVM 15). See ./driver.js and the HardwareInfo type
33
+ // in ../../index.d.ts.
34
+
35
+ const DEFAULT_ARGS = new Set(driver.defaultArgs || [])
36
+
37
+ const CAPABILITY_ENUMS = [
38
+ 'MAX_TEXTURE_SIZE',
39
+ 'MAX_CUBE_MAP_TEXTURE_SIZE',
40
+ 'MAX_RENDERBUFFER_SIZE',
41
+ 'MAX_VERTEX_ATTRIBS',
42
+ 'MAX_TEXTURE_IMAGE_UNITS',
43
+ 'MAX_COMBINED_TEXTURE_IMAGE_UNITS',
44
+ 'MAX_VERTEX_TEXTURE_IMAGE_UNITS',
45
+ 'MAX_SAMPLES',
46
+ 'MAX_VIEWPORT_DIMS'
47
+ ]
48
+
49
+ const readBackend = enums => {
50
+ const read = type => {
51
+ let gl
52
+ try {
53
+ gl = document.createElement('canvas').getContext(type)
54
+ } catch {
55
+ gl = null
56
+ }
57
+ if (!gl) return { supported: false }
58
+ const dbg = gl.getExtension('WEBGL_debug_renderer_info')
59
+ const capabilities = {}
60
+ for (const name of enums) {
61
+ const pname = gl[name] // undefined where the enum doesn't exist (e.g. MAX_SAMPLES on webgl1)
62
+ if (pname === undefined) continue
63
+ let value = gl.getParameter(pname)
64
+ if (value == null) continue
65
+ if (typeof value === 'object' && 'length' in value) value = Array.from(value)
66
+ capabilities[name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value
67
+ }
68
+ return {
69
+ supported: true,
70
+ unmaskedVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null,
71
+ unmaskedRenderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
72
+ version: gl.getParameter(gl.VERSION),
73
+ shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
74
+ capabilities,
75
+ extensions: gl.getSupportedExtensions() || []
76
+ }
77
+ }
78
+ return { v1: read('webgl'), v2: read('webgl2') }
79
+ }
80
+
81
+ const readWebGPU = async () => {
82
+ if (typeof navigator === 'undefined' || !navigator.gpu) return { supported: false }
83
+ try {
84
+ const adapter = await navigator.gpu.requestAdapter()
85
+ if (!adapter) return { supported: false }
86
+ let info = null
87
+ try {
88
+ info =
89
+ adapter.info ||
90
+ (typeof adapter.requestAdapterInfo === 'function'
91
+ ? await adapter.requestAdapterInfo()
92
+ : null)
93
+ } catch {
94
+ info = null
95
+ }
96
+ if (!info) return { supported: true }
97
+ return {
98
+ supported: true,
99
+ adapter: {
100
+ vendor: info.vendor || null,
101
+ architecture: info.architecture || null,
102
+ device: info.device || null,
103
+ description: info.description || null
104
+ }
105
+ }
106
+ } catch {
107
+ return { supported: false }
108
+ }
109
+ }
110
+
111
+ // Small deterministic fragment-bound WebGL benchmark: render N frames of a
112
+ // fixed sin/cos shader, forcing each via readPixels (so the software pipeline
113
+ // actually rasterizes), and report the timing. Same renderer as production;
114
+ // ~300ms on llvmpipe. For comparing environments / catching render regressions.
115
+ const runBenchmark = () => {
116
+ const SIZE = 512
117
+ const FRAMES = 60
118
+ const canvas = document.createElement('canvas')
119
+ canvas.width = canvas.height = SIZE
120
+ const gl = canvas.getContext('webgl')
121
+ if (!gl) return null
122
+ const compile = (type, src) => {
123
+ const s = gl.createShader(type)
124
+ gl.shaderSource(s, src)
125
+ gl.compileShader(s)
126
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s))
127
+ return s
128
+ }
129
+ try {
130
+ const vs = compile(
131
+ gl.VERTEX_SHADER,
132
+ 'attribute vec2 p;void main(){gl_Position=vec4(p,0.0,1.0);}'
133
+ )
134
+ const fs = compile(
135
+ gl.FRAGMENT_SHADER,
136
+ 'precision highp float;uniform float t;void main(){vec2 u=gl_FragCoord.xy/512.0;float v=0.0;for(int i=0;i<24;i++){v+=sin(u.x*float(i)+t)*cos(u.y*float(i)-t);}gl_FragColor=vec4(fract(v),u,1.0);}'
137
+ )
138
+ const prog = gl.createProgram()
139
+ gl.attachShader(prog, vs)
140
+ gl.attachShader(prog, fs)
141
+ gl.linkProgram(prog)
142
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog))
143
+ gl.useProgram(prog)
144
+ gl.viewport(0, 0, SIZE, SIZE)
145
+ const buf = gl.createBuffer()
146
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf)
147
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
148
+ const loc = gl.getAttribLocation(prog, 'p')
149
+ gl.enableVertexAttribArray(loc)
150
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
151
+ const tl = gl.getUniformLocation(prog, 't')
152
+ const px = new Uint8Array(4)
153
+ const frame = i => {
154
+ gl.uniform1f(tl, i * 0.01)
155
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
156
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px) // force the frame to complete
157
+ }
158
+ for (let i = 0; i < 3; i++) frame(i) // warmup
159
+ const start = performance.now()
160
+ for (let i = 0; i < FRAMES; i++) frame(i)
161
+ const totalMs = performance.now() - start
162
+ const round = n => Math.round(n * 100) / 100
163
+ return {
164
+ webgl: {
165
+ frames: FRAMES,
166
+ totalMs: round(totalMs),
167
+ frameTimeMs: round(totalMs / FRAMES),
168
+ fps: Math.round(1000 / (totalMs / FRAMES))
169
+ }
170
+ }
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ const matchOr = (re, str) => {
177
+ const m = typeof str === 'string' ? str.match(re) : null
178
+ return m ? m[1] : null
179
+ }
180
+
181
+ // Split on top-level commas only (ignore those nested in parentheses).
182
+ const splitTop = str => {
183
+ const out = []
184
+ let depth = 0
185
+ let cur = ''
186
+ for (const ch of str) {
187
+ if (ch === '(') depth++
188
+ else if (ch === ')') depth--
189
+ if (ch === ',' && depth === 0) {
190
+ out.push(cur.trim())
191
+ cur = ''
192
+ } else cur += ch
193
+ }
194
+ if (cur.trim()) out.push(cur.trim())
195
+ return out
196
+ }
197
+
198
+ // "ANGLE (Mesa, llvmpipe (LLVM 15.0.7 256 bits), OpenGL 4.5)" ->
199
+ // { translationLayer: 'ANGLE', vendor: 'Mesa', renderer: 'llvmpipe',
200
+ // api: 'OpenGL', apiVersion: '4.5', llvm: '15.0.7', simdWidth: 256, software: true }
201
+ const parseRenderer = renderer => {
202
+ if (!renderer) return {}
203
+ const wrapped = renderer.match(/^ANGLE \((.*)\)$/)
204
+ const parts = splitTop(wrapped ? wrapped[1] : renderer)
205
+
206
+ let vendor = null
207
+ let rendererPart = wrapped ? wrapped[1] : renderer
208
+ let apiPart = null
209
+ if (wrapped && parts.length >= 3) {
210
+ vendor = parts[0]
211
+ rendererPart = parts.slice(1, -1).join(', ')
212
+ apiPart = parts[parts.length - 1] // "OpenGL 4.5" / "Vulkan 1.3.0" / "SwiftShader driver-5.0.0"
213
+ }
214
+
215
+ const bits = matchOr(/(\d+) bits/, rendererPart)
216
+ return {
217
+ translationLayer: wrapped ? 'ANGLE' : null,
218
+ vendor,
219
+ renderer: rendererPart.replace(/\s*\(LLVM[^)]*\)/, '').trim() || null,
220
+ api: apiPart ? apiPart.split(/\s+/)[0] : null, // OpenGL / Vulkan / Metal / Direct3D11 / SwiftShader
221
+ apiVersion: matchOr(/(\d+(?:\.\d+)+)/, apiPart),
222
+ llvm: matchOr(/LLVM ([\d.]+)/, rendererPart),
223
+ simdWidth: bits ? Number(bits) : null,
224
+ software: /\b(llvmpipe|swiftshader|softpipe)\b/i.test(renderer)
225
+ }
226
+ }
227
+
228
+ // Directories where the loaded Mesa Gallium driver may live. The multiarch path
229
+ // is resolved at runtime so this works on amd64 and arm64 without a hardcode.
230
+ const GALLIUM_LIB_DIRS = [
231
+ `/usr/lib/${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-linux-gnu`,
232
+ '/usr/lib',
233
+ '/usr/local/lib'
234
+ ]
235
+
236
+ // Modern Mesa ships the version in the shared-object filename, e.g.
237
+ // `libgallium-26.1.3.so`. Pure so it can be unit-tested without a filesystem.
238
+ const parseGalliumVersion = filenames => {
239
+ for (const name of filenames) {
240
+ const m = /^libgallium-(\d+\.\d+(?:\.\d+)?)\.so$/.exec(name)
241
+ if (m) return m[1]
242
+ }
243
+ return null
244
+ }
245
+
246
+ // ANGLE hides the underlying Mesa version. Read it from the ACTUALLY loaded
247
+ // Gallium driver (`libgallium-<ver>.so`) first — when Mesa is side-loaded over
248
+ // the distro package (our Docker image COPYs a source build over apt's), dpkg
249
+ // still reports the stale apt version while this reflects what Chromium loads.
250
+ // Fall back to the dpkg package version, then null (e.g. macOS dev, no Mesa).
251
+ const readMesaVersion = async () => {
252
+ for (const dir of GALLIUM_LIB_DIRS) {
253
+ try {
254
+ const version = parseGalliumVersion(readdirSync(dir))
255
+ if (version) return version
256
+ } catch {
257
+ // dir absent / unreadable — try the next candidate.
258
+ }
259
+ }
260
+ try {
261
+ // Default output is `<name>\t<version>`; take the version field.
262
+ const { stdout } = await execFileAsync('dpkg-query', ['-W', 'libgl1-mesa-dri'], {
263
+ timeout: 1000
264
+ })
265
+ const version = stdout.trim().split(/\s+/).pop()
266
+ return matchOr(/(\d+\.\d+(?:\.\d+)?)/, version) || version || null
267
+ } catch {
268
+ return null
269
+ }
270
+ }
271
+
272
+ const readGpu = async page => {
273
+ const contexts = await page.evaluate(readBackend, CAPABILITY_ENUMS) // { v1, v2 }
274
+ const webgpu = await page.evaluate(readWebGPU)
275
+ const parsed = parseRenderer(contexts.v1.unmaskedRenderer || contexts.v2.unmaskedRenderer || null)
276
+ const mesa = parsed.vendor === 'Mesa' ? await readMesaVersion() : null
277
+ return {
278
+ vendor: parsed.vendor ?? null,
279
+ device: parsed.renderer ?? null, // "renderer" means different things per driver
280
+ type: parsed.renderer ? (parsed.software ? 'software' : 'hardware') : null,
281
+ graphics: {
282
+ translationLayer: parsed.translationLayer ?? null, // ANGLE (not a graphics driver)
283
+ name: parsed.api ?? null, // OpenGL / Vulkan / Metal / Direct3D11 / Direct3D12
284
+ version: parsed.apiVersion ?? null
285
+ },
286
+ ...(mesa ? { mesa } : {}),
287
+ ...(parsed.llvm ? { llvm: parsed.llvm } : {}),
288
+ ...(parsed.simdWidth ? { simdWidth: parsed.simdWidth } : {}),
289
+ webgl: contexts,
290
+ webgpu
291
+ }
292
+ }
293
+
294
+ // The full launch command line, via CDP; null if unavailable.
295
+ const readCommandLine = async browser => {
296
+ try {
297
+ const cdp = await browser.target().createCDPSession()
298
+ try {
299
+ const { arguments: argv = [] } = await cdp.send('Browser.getBrowserCommandLine')
300
+ return argv
301
+ } finally {
302
+ await cdp.detach().catch(() => {})
303
+ }
304
+ } catch {
305
+ return null
306
+ }
307
+ }
308
+
309
+ // Drop the executable path, positional URL and env-specific / sensitive flags
310
+ // (data dirs, extension paths, debug ports, logging), keeping the rendering-
311
+ // relevant switches useful for debugging regressions.
312
+ const OMIT_ARG =
313
+ /^--(user-data-dir|data-path|disk-cache-dir|load-extension|disable-extensions-except|allowlisted-extension-id|remote-debugging-port|remote-debugging-pipe|crash-dumps-dir|log-file|enable-logging|flag-switches-begin|flag-switches-end|field-trial-handle|variations-)/
314
+
315
+ const sanitizeArgs = argv => argv.filter(arg => arg.startsWith('--') && !OMIT_ARG.test(arg))
316
+
317
+ const detectBuild = execPath => {
318
+ if (/chrome-headless-shell/i.test(execPath)) return 'chrome-headless-shell'
319
+ if (/chromium/i.test(execPath)) return 'Chromium'
320
+ // Chrome for Testing extracts under platform-arch dirs (chrome-linux64,
321
+ // chrome-mac-arm64, chrome-win64, ...). Match those specifically so branded
322
+ // Chrome paths (/opt/google/chrome/chrome, ...\Application\chrome.exe) are
323
+ // NOT misreported as a testing build.
324
+ if (/chrome-for-testing|chrome-(linux64|win64|win32|mac-(x64|arm64))/i.test(execPath)) {
325
+ return 'chrome-for-testing'
326
+ }
327
+ return null
328
+ }
329
+
330
+ const readBrowser = async page => {
331
+ try {
332
+ const browser = page.browser()
333
+ const raw = await browser.version() // e.g. "Chrome/139.0.7258.154"
334
+ const m = raw.match(/^(.*?)\/([\d.]+)/)
335
+ const product = m ? m[1] : raw
336
+ // "new" headless reports product "Chrome/x" (not "HeadlessChrome/x"), so
337
+ // detect headless from the launch command line, falling back to the string.
338
+ const argv = await readCommandLine(browser)
339
+ const headless = argv ? argv.some(arg => /^--headless/.test(arg)) : /headless/i.test(product)
340
+ const execPath = (typeof browser.process === 'function' && browser.process()?.spawnfile) || ''
341
+ const channel = matchOr(/\b(stable|beta|dev|canary|unstable)\b/i, execPath)
342
+ const build = detectBuild(execPath)
343
+ const args = argv ? sanitizeArgs(argv) : null
344
+ return {
345
+ name: product.replace(/headless/i, '').trim() || product,
346
+ version: m ? m[2] : null,
347
+ headless,
348
+ ...(channel ? { channel: channel.toLowerCase() } : {}),
349
+ ...(build ? { build } : {}),
350
+ // arguments: full sanitized command line; customArguments: only the flags
351
+ // this app intentionally adds (driver.defaultArgs), excluding Chromium/
352
+ // Puppeteer defaults — the ones to check when debugging config changes.
353
+ ...(args
354
+ ? { arguments: args, customArguments: args.filter(arg => DEFAULT_ARGS.has(arg)) }
355
+ : {})
356
+ }
357
+ } catch {
358
+ return null
359
+ }
360
+ }
361
+
362
+ // Distro pretty name (e.g. "Ubuntu 22.04.4 LTS"); null off Linux or if absent.
363
+ const readDistro = () => {
364
+ try {
365
+ const line = readFileSync('/etc/os-release', 'utf8')
366
+ .split('\n')
367
+ .find(l => l.startsWith('PRETTY_NAME='))
368
+ return line ? line.split('=')[1].replace(/^"|"$/g, '') || null : null
369
+ } catch {
370
+ return null
371
+ }
372
+ }
373
+
374
+ const readOsInfo = () => {
375
+ const distro = readDistro()
376
+ return {
377
+ platform: process.platform,
378
+ release: os.release(),
379
+ ...(distro ? { distro } : {})
380
+ }
381
+ }
382
+
383
+ const readCgroup = () => {
384
+ try {
385
+ return readFileSync('/proc/1/cgroup', 'utf8')
386
+ } catch {
387
+ return ''
388
+ }
389
+ }
390
+
391
+ const readEnvironment = flags => ({
392
+ // x86 sets the `hypervisor` CPUID flag under any VM/KVM guest.
393
+ virtualized: !!flags?.includes('hypervisor'),
394
+ container:
395
+ !!process.env.KUBERNETES_SERVICE_HOST ||
396
+ existsSync('/.dockerenv') ||
397
+ existsSync('/run/.containerenv') ||
398
+ /docker|kubepods|containerd|lxc|crio/i.test(readCgroup())
399
+ })
400
+
401
+ // All CPU feature flags (e.g. sse4_2, avx, avx2). Best-effort from /proc/cpuinfo
402
+ // (Linux only); undefined elsewhere.
403
+ const readFlags = () => {
404
+ try {
405
+ const line = readFileSync('/proc/cpuinfo', 'utf8')
406
+ .split('\n')
407
+ .find(l => /^(flags|Features)\b/.test(l))
408
+ if (!line) return undefined
409
+ const flags = line.split(':')[1].trim().split(/\s+/)
410
+ return flags.length ? flags : undefined
411
+ } catch {
412
+ return undefined
413
+ }
414
+ }
415
+
416
+ // Physical core count from /proc/cpuinfo: unique (physical id, core id) pairs.
417
+ // undefined off Linux or when topology is hidden (then we fall back to threads).
418
+ const readCoreCount = () => {
419
+ try {
420
+ const ids = new Set()
421
+ let phys = '0'
422
+ for (const line of readFileSync('/proc/cpuinfo', 'utf8').split('\n')) {
423
+ if (line.startsWith('physical id')) phys = line.split(':')[1].trim()
424
+ else if (line.startsWith('core id')) ids.add(`${phys}:${line.split(':')[1].trim()}`)
425
+ }
426
+ return ids.size || undefined
427
+ } catch {
428
+ return undefined
429
+ }
430
+ }
431
+
432
+ // os.cpus() reports speed 0 in many containers/VMs; fall back to /proc/cpuinfo.
433
+ const readMhz = () => {
434
+ try {
435
+ const line = readFileSync('/proc/cpuinfo', 'utf8')
436
+ .split('\n')
437
+ .find(l => l.startsWith('cpu MHz'))
438
+ const mhz = line ? Math.round(parseFloat(line.split(':')[1])) : NaN
439
+ return Number.isFinite(mhz) && mhz > 0 ? mhz : undefined
440
+ } catch {
441
+ return undefined
442
+ }
443
+ }
444
+
445
+ const readCpu = flags => {
446
+ const cpus = os.cpus() || []
447
+ const threads = cpus.length
448
+ const speed = cpus[0]?.speed || readMhz() // omit when unknown rather than report 0
449
+ return {
450
+ model: cpus[0]?.model,
451
+ cores: readCoreCount() ?? threads,
452
+ threads,
453
+ ...(speed ? { speed } : {}),
454
+ arch: process.arch,
455
+ ...(flags ? { flags } : {})
456
+ }
457
+ }
458
+
459
+ const report =
460
+ page =>
461
+ async ({ benchmark = false } = {}) => {
462
+ const flags = readFlags()
463
+ const [browser, gpu] = await Promise.all([readBrowser(page), readGpu(page)])
464
+ const result = {
465
+ browser,
466
+ environment: readEnvironment(flags),
467
+ os: readOsInfo(),
468
+ cpu: readCpu(flags),
469
+ memory: { total: os.totalmem() }, // bytes
470
+ gpu
471
+ }
472
+ if (benchmark) {
473
+ const performance = await page.evaluate(runBenchmark)
474
+ if (performance) result.performance = performance
475
+ }
476
+ return result
477
+ }
478
+
479
+ module.exports = report
480
+ module.exports.parseRenderer = parseRenderer
481
+ module.exports.parseGalliumVersion = parseGalliumVersion
482
+ module.exports.detectBuild = detectBuild