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 +98 -0
- package/package.json +6 -6
- package/src/index.js +1 -0
- package/src/report.js +482 -0
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.
|
|
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.
|
|
40
|
-
"@browserless/pdf": "13.
|
|
41
|
-
"@browserless/screenshot": "13.
|
|
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.
|
|
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": "
|
|
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
|