browserless 13.4.1 → 13.5.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.
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.0",
6
6
  "types": "index.d.ts",
7
7
  "main": "src/index.js",
8
8
  "author": {
@@ -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.0",
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": "000c5ca9e84696666e3c652a9a0a2dab325e9576"
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,452 @@
1
+ 'use strict'
2
+
3
+ const { execFile } = require('child_process')
4
+ const { readFileSync, 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
+ // ANGLE hides the underlying Mesa version, so read it from the installed driver
229
+ // package. Best-effort: null off Debian/Ubuntu (e.g. macOS dev) or if dpkg fails.
230
+ const readMesaVersion = async () => {
231
+ try {
232
+ // Default output is `<name>\t<version>`; take the version field.
233
+ const { stdout } = await execFileAsync('dpkg-query', ['-W', 'libgl1-mesa-dri'], {
234
+ timeout: 1000
235
+ })
236
+ const version = stdout.trim().split(/\s+/).pop()
237
+ return matchOr(/(\d+\.\d+(?:\.\d+)?)/, version) || version || null
238
+ } catch {
239
+ return null
240
+ }
241
+ }
242
+
243
+ const readGpu = async page => {
244
+ const contexts = await page.evaluate(readBackend, CAPABILITY_ENUMS) // { v1, v2 }
245
+ const webgpu = await page.evaluate(readWebGPU)
246
+ const parsed = parseRenderer(contexts.v1.unmaskedRenderer || contexts.v2.unmaskedRenderer || null)
247
+ const mesa = parsed.vendor === 'Mesa' ? await readMesaVersion() : null
248
+ return {
249
+ vendor: parsed.vendor ?? null,
250
+ device: parsed.renderer ?? null, // "renderer" means different things per driver
251
+ type: parsed.renderer ? (parsed.software ? 'software' : 'hardware') : null,
252
+ graphics: {
253
+ translationLayer: parsed.translationLayer ?? null, // ANGLE (not a graphics driver)
254
+ name: parsed.api ?? null, // OpenGL / Vulkan / Metal / Direct3D11 / Direct3D12
255
+ version: parsed.apiVersion ?? null
256
+ },
257
+ ...(mesa ? { mesa } : {}),
258
+ ...(parsed.llvm ? { llvm: parsed.llvm } : {}),
259
+ ...(parsed.simdWidth ? { simdWidth: parsed.simdWidth } : {}),
260
+ webgl: contexts,
261
+ webgpu
262
+ }
263
+ }
264
+
265
+ // The full launch command line, via CDP; null if unavailable.
266
+ const readCommandLine = async browser => {
267
+ try {
268
+ const cdp = await browser.target().createCDPSession()
269
+ try {
270
+ const { arguments: argv = [] } = await cdp.send('Browser.getBrowserCommandLine')
271
+ return argv
272
+ } finally {
273
+ await cdp.detach().catch(() => {})
274
+ }
275
+ } catch {
276
+ return null
277
+ }
278
+ }
279
+
280
+ // Drop the executable path, positional URL and env-specific / sensitive flags
281
+ // (data dirs, extension paths, debug ports, logging), keeping the rendering-
282
+ // relevant switches useful for debugging regressions.
283
+ const OMIT_ARG =
284
+ /^--(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-)/
285
+
286
+ const sanitizeArgs = argv => argv.filter(arg => arg.startsWith('--') && !OMIT_ARG.test(arg))
287
+
288
+ const detectBuild = execPath => {
289
+ if (/chrome-headless-shell/i.test(execPath)) return 'chrome-headless-shell'
290
+ if (/chromium/i.test(execPath)) return 'Chromium'
291
+ // Chrome for Testing extracts under platform-arch dirs (chrome-linux64,
292
+ // chrome-mac-arm64, chrome-win64, ...). Match those specifically so branded
293
+ // Chrome paths (/opt/google/chrome/chrome, ...\Application\chrome.exe) are
294
+ // NOT misreported as a testing build.
295
+ if (/chrome-for-testing|chrome-(linux64|win64|win32|mac-(x64|arm64))/i.test(execPath)) {
296
+ return 'chrome-for-testing'
297
+ }
298
+ return null
299
+ }
300
+
301
+ const readBrowser = async page => {
302
+ try {
303
+ const browser = page.browser()
304
+ const raw = await browser.version() // e.g. "Chrome/139.0.7258.154"
305
+ const m = raw.match(/^(.*?)\/([\d.]+)/)
306
+ const product = m ? m[1] : raw
307
+ // "new" headless reports product "Chrome/x" (not "HeadlessChrome/x"), so
308
+ // detect headless from the launch command line, falling back to the string.
309
+ const argv = await readCommandLine(browser)
310
+ const headless = argv ? argv.some(arg => /^--headless/.test(arg)) : /headless/i.test(product)
311
+ const execPath = (typeof browser.process === 'function' && browser.process()?.spawnfile) || ''
312
+ const channel = matchOr(/\b(stable|beta|dev|canary|unstable)\b/i, execPath)
313
+ const build = detectBuild(execPath)
314
+ const args = argv ? sanitizeArgs(argv) : null
315
+ return {
316
+ name: product.replace(/headless/i, '').trim() || product,
317
+ version: m ? m[2] : null,
318
+ headless,
319
+ ...(channel ? { channel: channel.toLowerCase() } : {}),
320
+ ...(build ? { build } : {}),
321
+ // arguments: full sanitized command line; customArguments: only the flags
322
+ // this app intentionally adds (driver.defaultArgs), excluding Chromium/
323
+ // Puppeteer defaults — the ones to check when debugging config changes.
324
+ ...(args
325
+ ? { arguments: args, customArguments: args.filter(arg => DEFAULT_ARGS.has(arg)) }
326
+ : {})
327
+ }
328
+ } catch {
329
+ return null
330
+ }
331
+ }
332
+
333
+ // Distro pretty name (e.g. "Ubuntu 22.04.4 LTS"); null off Linux or if absent.
334
+ const readDistro = () => {
335
+ try {
336
+ const line = readFileSync('/etc/os-release', 'utf8')
337
+ .split('\n')
338
+ .find(l => l.startsWith('PRETTY_NAME='))
339
+ return line ? line.split('=')[1].replace(/^"|"$/g, '') || null : null
340
+ } catch {
341
+ return null
342
+ }
343
+ }
344
+
345
+ const readOsInfo = () => {
346
+ const distro = readDistro()
347
+ return {
348
+ platform: process.platform,
349
+ release: os.release(),
350
+ ...(distro ? { distro } : {})
351
+ }
352
+ }
353
+
354
+ const readCgroup = () => {
355
+ try {
356
+ return readFileSync('/proc/1/cgroup', 'utf8')
357
+ } catch {
358
+ return ''
359
+ }
360
+ }
361
+
362
+ const readEnvironment = flags => ({
363
+ // x86 sets the `hypervisor` CPUID flag under any VM/KVM guest.
364
+ virtualized: !!flags?.includes('hypervisor'),
365
+ container:
366
+ !!process.env.KUBERNETES_SERVICE_HOST ||
367
+ existsSync('/.dockerenv') ||
368
+ existsSync('/run/.containerenv') ||
369
+ /docker|kubepods|containerd|lxc|crio/i.test(readCgroup())
370
+ })
371
+
372
+ // All CPU feature flags (e.g. sse4_2, avx, avx2). Best-effort from /proc/cpuinfo
373
+ // (Linux only); undefined elsewhere.
374
+ const readFlags = () => {
375
+ try {
376
+ const line = readFileSync('/proc/cpuinfo', 'utf8')
377
+ .split('\n')
378
+ .find(l => /^(flags|Features)\b/.test(l))
379
+ if (!line) return undefined
380
+ const flags = line.split(':')[1].trim().split(/\s+/)
381
+ return flags.length ? flags : undefined
382
+ } catch {
383
+ return undefined
384
+ }
385
+ }
386
+
387
+ // Physical core count from /proc/cpuinfo: unique (physical id, core id) pairs.
388
+ // undefined off Linux or when topology is hidden (then we fall back to threads).
389
+ const readCoreCount = () => {
390
+ try {
391
+ const ids = new Set()
392
+ let phys = '0'
393
+ for (const line of readFileSync('/proc/cpuinfo', 'utf8').split('\n')) {
394
+ if (line.startsWith('physical id')) phys = line.split(':')[1].trim()
395
+ else if (line.startsWith('core id')) ids.add(`${phys}:${line.split(':')[1].trim()}`)
396
+ }
397
+ return ids.size || undefined
398
+ } catch {
399
+ return undefined
400
+ }
401
+ }
402
+
403
+ // os.cpus() reports speed 0 in many containers/VMs; fall back to /proc/cpuinfo.
404
+ const readMhz = () => {
405
+ try {
406
+ const line = readFileSync('/proc/cpuinfo', 'utf8')
407
+ .split('\n')
408
+ .find(l => l.startsWith('cpu MHz'))
409
+ const mhz = line ? Math.round(parseFloat(line.split(':')[1])) : NaN
410
+ return Number.isFinite(mhz) && mhz > 0 ? mhz : undefined
411
+ } catch {
412
+ return undefined
413
+ }
414
+ }
415
+
416
+ const readCpu = flags => {
417
+ const cpus = os.cpus() || []
418
+ const threads = cpus.length
419
+ const speed = cpus[0]?.speed || readMhz() // omit when unknown rather than report 0
420
+ return {
421
+ model: cpus[0]?.model,
422
+ cores: readCoreCount() ?? threads,
423
+ threads,
424
+ ...(speed ? { speed } : {}),
425
+ arch: process.arch,
426
+ ...(flags ? { flags } : {})
427
+ }
428
+ }
429
+
430
+ const report =
431
+ page =>
432
+ async ({ benchmark = false } = {}) => {
433
+ const flags = readFlags()
434
+ const [browser, gpu] = await Promise.all([readBrowser(page), readGpu(page)])
435
+ const result = {
436
+ browser,
437
+ environment: readEnvironment(flags),
438
+ os: readOsInfo(),
439
+ cpu: readCpu(flags),
440
+ memory: { total: os.totalmem() }, // bytes
441
+ gpu
442
+ }
443
+ if (benchmark) {
444
+ const performance = await page.evaluate(runBenchmark)
445
+ if (performance) result.performance = performance
446
+ }
447
+ return result
448
+ }
449
+
450
+ module.exports = report
451
+ module.exports.parseRenderer = parseRenderer
452
+ module.exports.detectBuild = detectBuild