From f297dfd8562815714a37f596c0fad62ade982fa3 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Fri, 26 Jun 2026 17:26:09 +0200 Subject: [PATCH 1/2] feat(browserless): add report() hardware & GPU diagnostics Add a `report()` context method that returns a normalized snapshot of the environment a page renders in: browser build (name/version/headless/channel/ build + full and app-only launch args), virtualization/container detection, OS/CPU/memory, and the GPU stack parsed from the ANGLE renderer string (vendor/device/type, graphics API, mesa/llvm/simdWidth) plus per-version WebGL capabilities/extensions and WebGPU support. Optionally runs a small deterministic WebGL benchmark (`report({ benchmark: true })`) for comparing environments and catching render regressions after Chrome/Mesa/LLVM/flag/infra changes. Includes TypeScript types (HardwareInfo) and tests. Co-Authored-By: Claude Opus 4.8 --- packages/browserless/index.d.ts | 98 +++++++ packages/browserless/src/index.js | 1 + packages/browserless/src/report.js | 445 +++++++++++++++++++++++++++++ packages/browserless/test/index.js | 50 ++++ 4 files changed, 594 insertions(+) create mode 100644 packages/browserless/src/report.js diff --git a/packages/browserless/index.d.ts b/packages/browserless/index.d.ts index bc09b3792..a8d976033 100644 --- a/packages/browserless/index.d.ts +++ b/packages/browserless/index.d.ts @@ -39,6 +39,7 @@ export interface Context { screenshot: (page: Page, opts?: ScreenshotOptions) => Promise text: (url: string, opts?: GotoOptions) => Promise getDevice: (deviceName: string) => Viewport | undefined + report: (opts?: { benchmark?: boolean }) => Promise destroyContext: (opts?: { force?: boolean }) => Promise withPage: (fn: (page: Page, goto: unknown) => Promise, opts?: { timeout?: number }) => Promise } @@ -69,6 +70,103 @@ export interface ScreenshotOptions { encoding?: 'binary' | 'base64' } +export interface WebGLContextInfo { + supported: boolean + /** From WEBGL_debug_renderer_info; null if the extension is unavailable. */ + unmaskedVendor?: string | null + unmaskedRenderer?: string | null + version?: string + shadingLanguageVersion?: string + /** GL MAX_* parameters (e.g. maxTextureSize). maxViewportDims is a [w, h] pair. */ + capabilities?: Record + extensions?: string[] +} + +export interface WebGPUInfo { + supported: boolean + adapter?: { + vendor: string | null + architecture: string | null + device: string | null + description: string | null + } +} + +export interface HardwareInfo { + browser: { + name: string + version: string | null + headless: boolean + /** Release channel ("stable" | "beta" | "dev" | "canary"), when detectable. */ + channel?: string + /** Build flavor, e.g. "Chromium" | "chrome-for-testing" | "chrome-headless-shell". */ + build?: string + /** Full sanitized command line; env-specific/sensitive args are omitted. */ + arguments?: string[] + /** Only the flags this app intentionally adds (excludes Chromium/Puppeteer defaults). */ + customArguments?: string[] + } | null + environment: { + virtualized: boolean + container: boolean + } + os: { + platform: string + release: string + distro?: string + } + cpu: { + model?: string + /** Physical cores. */ + cores: number + /** Logical processors. */ + threads: number + /** MHz; omitted when the platform does not report it. */ + speed?: number + arch: string + flags?: string[] + } + memory: { + /** Total physical memory, in bytes. */ + total: number + } + gpu: { + vendor: string | null + device: string | null + type: 'hardware' | 'software' | null + /** The graphics stack ANGLE translates to. */ + graphics: { + /** Translation layer, e.g. "ANGLE"; null when the renderer is not wrapped. */ + translationLayer: string | null + /** Graphics API: "OpenGL" | "Vulkan" | "Metal" | "Direct3D11" | "Direct3D12" | ... */ + name: string | null + version: string | null + } + /** Mesa version (software path); read from the host package. */ + mesa?: string + /** LLVM version backing llvmpipe (software path). */ + llvm?: string + /** llvmpipe SIMD JIT width in bits (software path). */ + simdWidth?: number + webgl: { + v1: WebGLContextInfo + v2: WebGLContextInfo + } + webgpu: WebGPUInfo + } + /** Present only when report({ benchmark: true }) is requested. */ + performance?: { + webgl: { + frames: number + /** Total wall time for all frames, in milliseconds. */ + totalMs: number + /** Mean per-frame time, in milliseconds. */ + frameTimeMs: number + fps: number + } + } +} + export interface Browserless { createContext: (opts?: ContextOptions) => Promise respawn: () => void diff --git a/packages/browserless/src/index.js b/packages/browserless/src/index.js index beb435241..38b5ef59f 100644 --- a/packages/browserless/src/index.js +++ b/packages/browserless/src/index.js @@ -198,6 +198,7 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => { browser: getBrowser, evaluate, goto, + report: withPage(require('./report')), html: evaluate(page => page.content(), { flattenShadowDOM: true }), page: createPage, pdf: withPage(createPdf({ goto })), diff --git a/packages/browserless/src/report.js b/packages/browserless/src/report.js new file mode 100644 index 000000000..1758dffcc --- /dev/null +++ b/packages/browserless/src/report.js @@ -0,0 +1,445 @@ +'use strict' + +const { execFile } = require('child_process') +const { readFileSync, existsSync } = require('fs') +const { promisify } = require('util') +const os = require('os') + +const driver = require('./driver') + +const execFileAsync = promisify(execFile) + +// Inspect the hardware the browser actually renders on: the browser build, the +// GPU/WebGL/WebGPU backends (resolved at runtime, in-page), and the host +// environment / OS / CPU / memory (from Node). Optionally runs a small +// deterministic WebGL benchmark (`report({ benchmark: true })`). +// +// GPU: the ANGLE renderer string (e.g. "ANGLE (Mesa, llvmpipe (LLVM 15.0.7 256 +// bits), OpenGL 4.5)") is parsed into normalized fields: +// - vendor / device the GL vendor and renderer device. +// - type 'software' (llvmpipe/swiftshader CPU path) or 'hardware'; +// a swiftshader device is the slow (~4x) fallback we +// must never silently hit. +// - graphics { translationLayer, name, version } — ANGLE translates +// to a graphics API (OpenGL/Vulkan/Metal/Direct3D11/12). +// Structured so new APIs need no schema change. +// - mesa / llvm / simdWidth software-stack detail; `mesa` is NOT in the string +// (ANGLE drops it), so it is read from the host package. +// Per-version `webgl.v1`/`webgl.v2` keep the raw UNMASKED strings, the renderer +// `capabilities` (GL MAX_* parameters) and the supported-extension list. +// +// memory.total is in BYTES. OS distro gates the available Mesa/LLVM (e.g. Ubuntu +// 22.04 caps at Mesa 23.2.1 / LLVM 15). See ./driver.js and the HardwareInfo type +// in ../../index.d.ts. + +const DEFAULT_ARGS = new Set(driver.defaultArgs || []) + +const CAPABILITY_ENUMS = [ + 'MAX_TEXTURE_SIZE', + 'MAX_CUBE_MAP_TEXTURE_SIZE', + 'MAX_RENDERBUFFER_SIZE', + 'MAX_VERTEX_ATTRIBS', + 'MAX_TEXTURE_IMAGE_UNITS', + 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', + 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', + 'MAX_SAMPLES', + 'MAX_VIEWPORT_DIMS' +] + +const readBackend = enums => { + const read = type => { + let gl + try { + gl = document.createElement('canvas').getContext(type) + } catch { + gl = null + } + if (!gl) return { supported: false } + const dbg = gl.getExtension('WEBGL_debug_renderer_info') + const capabilities = {} + for (const name of enums) { + const pname = gl[name] // undefined where the enum doesn't exist (e.g. MAX_SAMPLES on webgl1) + if (pname === undefined) continue + let value = gl.getParameter(pname) + if (value == null) continue + if (typeof value === 'object' && 'length' in value) value = Array.from(value) + capabilities[name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value + } + return { + supported: true, + unmaskedVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null, + unmaskedRenderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null, + version: gl.getParameter(gl.VERSION), + shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION), + capabilities, + extensions: gl.getSupportedExtensions() || [] + } + } + return { v1: read('webgl'), v2: read('webgl2') } +} + +const readWebGPU = async () => { + if (typeof navigator === 'undefined' || !navigator.gpu) return { supported: false } + try { + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return { supported: false } + let info = null + try { + info = + adapter.info || + (typeof adapter.requestAdapterInfo === 'function' + ? await adapter.requestAdapterInfo() + : null) + } catch { + info = null + } + if (!info) return { supported: true } + return { + supported: true, + adapter: { + vendor: info.vendor || null, + architecture: info.architecture || null, + device: info.device || null, + description: info.description || null + } + } + } catch { + return { supported: false } + } +} + +// Small deterministic fragment-bound WebGL benchmark: render N frames of a +// fixed sin/cos shader, forcing each via readPixels (so the software pipeline +// actually rasterizes), and report the timing. Same renderer as production; +// ~300ms on llvmpipe. For comparing environments / catching render regressions. +const runBenchmark = () => { + const SIZE = 512 + const FRAMES = 60 + const canvas = document.createElement('canvas') + canvas.width = canvas.height = SIZE + const gl = canvas.getContext('webgl') + if (!gl) return null + const compile = (type, src) => { + const s = gl.createShader(type) + gl.shaderSource(s, src) + gl.compileShader(s) + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)) + return s + } + try { + const vs = compile( + gl.VERTEX_SHADER, + 'attribute vec2 p;void main(){gl_Position=vec4(p,0.0,1.0);}' + ) + const fs = compile( + gl.FRAGMENT_SHADER, + '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);}' + ) + const prog = gl.createProgram() + gl.attachShader(prog, vs) + gl.attachShader(prog, fs) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog)) + gl.useProgram(prog) + gl.viewport(0, 0, SIZE, SIZE) + const buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW) + const loc = gl.getAttribLocation(prog, 'p') + gl.enableVertexAttribArray(loc) + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) + const tl = gl.getUniformLocation(prog, 't') + const px = new Uint8Array(4) + const frame = i => { + gl.uniform1f(tl, i * 0.01) + gl.drawArrays(gl.TRIANGLES, 0, 3) + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px) // force the frame to complete + } + for (let i = 0; i < 3; i++) frame(i) // warmup + const start = performance.now() + for (let i = 0; i < FRAMES; i++) frame(i) + const totalMs = performance.now() - start + const round = n => Math.round(n * 100) / 100 + return { + webgl: { + frames: FRAMES, + totalMs: round(totalMs), + frameTimeMs: round(totalMs / FRAMES), + fps: Math.round(1000 / (totalMs / FRAMES)) + } + } + } catch { + return null + } +} + +const matchOr = (re, str) => { + const m = typeof str === 'string' ? str.match(re) : null + return m ? m[1] : null +} + +// Split on top-level commas only (ignore those nested in parentheses). +const splitTop = str => { + const out = [] + let depth = 0 + let cur = '' + for (const ch of str) { + if (ch === '(') depth++ + else if (ch === ')') depth-- + if (ch === ',' && depth === 0) { + out.push(cur.trim()) + cur = '' + } else cur += ch + } + if (cur.trim()) out.push(cur.trim()) + return out +} + +// "ANGLE (Mesa, llvmpipe (LLVM 15.0.7 256 bits), OpenGL 4.5)" -> +// { translationLayer: 'ANGLE', vendor: 'Mesa', renderer: 'llvmpipe', +// api: 'OpenGL', apiVersion: '4.5', llvm: '15.0.7', simdWidth: 256, software: true } +const parseRenderer = renderer => { + if (!renderer) return {} + const wrapped = renderer.match(/^ANGLE \((.*)\)$/) + const parts = splitTop(wrapped ? wrapped[1] : renderer) + + let vendor = null + let rendererPart = wrapped ? wrapped[1] : renderer + let apiPart = null + if (wrapped && parts.length >= 3) { + vendor = parts[0] + rendererPart = parts.slice(1, -1).join(', ') + apiPart = parts[parts.length - 1] // "OpenGL 4.5" / "Vulkan 1.3.0" / "SwiftShader driver-5.0.0" + } + + const bits = matchOr(/(\d+) bits/, rendererPart) + return { + translationLayer: wrapped ? 'ANGLE' : null, + vendor, + renderer: rendererPart.replace(/\s*\(LLVM[^)]*\)/, '').trim() || null, + api: apiPart ? apiPart.split(/\s+/)[0] : null, // OpenGL / Vulkan / Metal / Direct3D11 / SwiftShader + apiVersion: matchOr(/(\d+(?:\.\d+)+)/, apiPart), + llvm: matchOr(/LLVM ([\d.]+)/, rendererPart), + simdWidth: bits ? Number(bits) : null, + software: /\b(llvmpipe|swiftshader|softpipe)\b/i.test(renderer) + } +} + +// ANGLE hides the underlying Mesa version, so read it from the installed driver +// package. Best-effort: null off Debian/Ubuntu (e.g. macOS dev) or if dpkg fails. +const readMesaVersion = async () => { + try { + // Default output is `\t`; take the version field. + const { stdout } = await execFileAsync('dpkg-query', ['-W', 'libgl1-mesa-dri'], { + timeout: 1000 + }) + const version = stdout.trim().split(/\s+/).pop() + return matchOr(/(\d+\.\d+(?:\.\d+)?)/, version) || version || null + } catch { + return null + } +} + +const readGpu = async page => { + const contexts = await page.evaluate(readBackend, CAPABILITY_ENUMS) // { v1, v2 } + const webgpu = await page.evaluate(readWebGPU) + const parsed = parseRenderer(contexts.v1.unmaskedRenderer || contexts.v2.unmaskedRenderer || null) + const mesa = parsed.vendor === 'Mesa' ? await readMesaVersion() : null + return { + vendor: parsed.vendor ?? null, + device: parsed.renderer ?? null, // "renderer" means different things per driver + type: parsed.renderer ? (parsed.software ? 'software' : 'hardware') : null, + graphics: { + translationLayer: parsed.translationLayer ?? null, // ANGLE (not a graphics driver) + name: parsed.api ?? null, // OpenGL / Vulkan / Metal / Direct3D11 / Direct3D12 + version: parsed.apiVersion ?? null + }, + ...(mesa ? { mesa } : {}), + ...(parsed.llvm ? { llvm: parsed.llvm } : {}), + ...(parsed.simdWidth ? { simdWidth: parsed.simdWidth } : {}), + webgl: contexts, + webgpu + } +} + +// The full launch command line, via CDP; null if unavailable. +const readCommandLine = async browser => { + try { + const cdp = await browser.target().createCDPSession() + try { + const { arguments: argv = [] } = await cdp.send('Browser.getBrowserCommandLine') + return argv + } finally { + await cdp.detach().catch(() => {}) + } + } catch { + return null + } +} + +// Drop the executable path, positional URL and env-specific / sensitive flags +// (data dirs, extension paths, debug ports, logging), keeping the rendering- +// relevant switches useful for debugging regressions. +const OMIT_ARG = + /^--(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-)/ + +const sanitizeArgs = argv => argv.filter(arg => arg.startsWith('--') && !OMIT_ARG.test(arg)) + +const detectBuild = execPath => { + if (/chrome-headless-shell/i.test(execPath)) return 'chrome-headless-shell' + if (/chromium/i.test(execPath)) return 'Chromium' + if (/chrome-for-testing|chrome-linux|chrome-mac|chrome-win|[/\\]chrome[/\\]/i.test(execPath)) { return 'chrome-for-testing' } + return null +} + +const readBrowser = async page => { + try { + const browser = page.browser() + const raw = await browser.version() // e.g. "Chrome/139.0.7258.154" + const m = raw.match(/^(.*?)\/([\d.]+)/) + const product = m ? m[1] : raw + // "new" headless reports product "Chrome/x" (not "HeadlessChrome/x"), so + // detect headless from the launch command line, falling back to the string. + const argv = await readCommandLine(browser) + const headless = argv ? argv.some(arg => /^--headless/.test(arg)) : /headless/i.test(product) + const execPath = (typeof browser.process === 'function' && browser.process()?.spawnfile) || '' + const channel = matchOr(/\b(stable|beta|dev|canary|unstable)\b/i, execPath) + const build = detectBuild(execPath) + const args = argv ? sanitizeArgs(argv) : null + return { + name: product.replace(/headless/i, '').trim() || product, + version: m ? m[2] : null, + headless, + ...(channel ? { channel: channel.toLowerCase() } : {}), + ...(build ? { build } : {}), + // arguments: full sanitized command line; customArguments: only the flags + // this app intentionally adds (driver.defaultArgs), excluding Chromium/ + // Puppeteer defaults — the ones to check when debugging config changes. + ...(args + ? { arguments: args, customArguments: args.filter(arg => DEFAULT_ARGS.has(arg)) } + : {}) + } + } catch { + return null + } +} + +// Distro pretty name (e.g. "Ubuntu 22.04.4 LTS"); null off Linux or if absent. +const readDistro = () => { + try { + const line = readFileSync('/etc/os-release', 'utf8') + .split('\n') + .find(l => l.startsWith('PRETTY_NAME=')) + return line ? line.split('=')[1].replace(/^"|"$/g, '') || null : null + } catch { + return null + } +} + +const readOsInfo = () => { + const distro = readDistro() + return { + platform: process.platform, + release: os.release(), + ...(distro ? { distro } : {}) + } +} + +const readCgroup = () => { + try { + return readFileSync('/proc/1/cgroup', 'utf8') + } catch { + return '' + } +} + +const readEnvironment = flags => ({ + // x86 sets the `hypervisor` CPUID flag under any VM/KVM guest. + virtualized: !!flags?.includes('hypervisor'), + container: + !!process.env.KUBERNETES_SERVICE_HOST || + existsSync('/.dockerenv') || + existsSync('/run/.containerenv') || + /docker|kubepods|containerd|lxc|crio/i.test(readCgroup()) +}) + +// All CPU feature flags (e.g. sse4_2, avx, avx2). Best-effort from /proc/cpuinfo +// (Linux only); undefined elsewhere. +const readFlags = () => { + try { + const line = readFileSync('/proc/cpuinfo', 'utf8') + .split('\n') + .find(l => /^(flags|Features)\b/.test(l)) + if (!line) return undefined + const flags = line.split(':')[1].trim().split(/\s+/) + return flags.length ? flags : undefined + } catch { + return undefined + } +} + +// Physical core count from /proc/cpuinfo: unique (physical id, core id) pairs. +// undefined off Linux or when topology is hidden (then we fall back to threads). +const readCoreCount = () => { + try { + const ids = new Set() + let phys = '0' + for (const line of readFileSync('/proc/cpuinfo', 'utf8').split('\n')) { + if (line.startsWith('physical id')) phys = line.split(':')[1].trim() + else if (line.startsWith('core id')) ids.add(`${phys}:${line.split(':')[1].trim()}`) + } + return ids.size || undefined + } catch { + return undefined + } +} + +// os.cpus() reports speed 0 in many containers/VMs; fall back to /proc/cpuinfo. +const readMhz = () => { + try { + const line = readFileSync('/proc/cpuinfo', 'utf8') + .split('\n') + .find(l => l.startsWith('cpu MHz')) + const mhz = line ? Math.round(parseFloat(line.split(':')[1])) : NaN + return Number.isFinite(mhz) && mhz > 0 ? mhz : undefined + } catch { + return undefined + } +} + +const readCpu = flags => { + const cpus = os.cpus() || [] + const threads = cpus.length + const speed = cpus[0]?.speed || readMhz() // omit when unknown rather than report 0 + return { + model: cpus[0]?.model, + cores: readCoreCount() ?? threads, + threads, + ...(speed ? { speed } : {}), + arch: process.arch, + ...(flags ? { flags } : {}) + } +} + +const report = + page => + async ({ benchmark = false } = {}) => { + const flags = readFlags() + const [browser, gpu] = await Promise.all([readBrowser(page), readGpu(page)]) + const result = { + browser, + environment: readEnvironment(flags), + os: readOsInfo(), + cpu: readCpu(flags), + memory: { total: os.totalmem() }, // bytes + gpu + } + if (benchmark) { + const performance = await page.evaluate(runBenchmark) + if (performance) result.performance = performance + } + return result + } + +module.exports = report +module.exports.parseRenderer = parseRenderer diff --git a/packages/browserless/test/index.js b/packages/browserless/test/index.js index bb3263f9a..c3bed8a14 100644 --- a/packages/browserless/test/index.js +++ b/packages/browserless/test/index.js @@ -54,6 +54,56 @@ test('pass specific options to a context', async t => { ]) }) +test('report() returns browser, GPU backend and host CPU', async t => { + const browserless = await getBrowserContext(t) + const { browser, environment, os, gpu, cpu, memory } = await browserless.report() + + t.is(typeof browser.name, 'string') + t.is(typeof browser.headless, 'boolean') + t.true(browser.arguments === undefined || Array.isArray(browser.arguments)) + t.true(browser.customArguments === undefined || Array.isArray(browser.customArguments)) + + t.is(typeof environment.virtualized, 'boolean') + t.is(typeof environment.container, 'boolean') + + t.true(gpu.webgl.v1.supported) + t.true(gpu.webgl.v2.supported) + t.is(typeof gpu.graphics.name, 'string') // OpenGL / Vulkan / Metal / Direct3D11 + t.true(['hardware', 'software'].includes(gpu.type)) + t.is(typeof gpu.webgpu.supported, 'boolean') + // never a silent SwiftShader / 2D fallback (~4x slower on the GPU-less fleet). + t.false(/swiftshader/i.test(gpu.webgl.v1.unmaskedRenderer), gpu.webgl.v1.unmaskedRenderer) + t.true(Array.isArray(gpu.webgl.v1.extensions)) + t.true(gpu.webgl.v1.capabilities.maxTextureSize > 0) + // --use-angle=gl resolves to Mesa llvmpipe on the GPU-less Linux target (CI + // under Xvfb); native GL elsewhere, so pin the software path only on CI. + if (process.env.CI) { + t.is(gpu.vendor, 'Mesa', gpu.webgl.v1.unmaskedRenderer) + t.is(gpu.device, 'llvmpipe', gpu.webgl.v1.unmaskedRenderer) + t.is(gpu.type, 'software') + t.is(gpu.graphics.translationLayer, 'ANGLE') + t.is(gpu.graphics.name, 'OpenGL') + } + + t.is(typeof os.platform, 'string') + t.is(typeof os.release, 'string') + t.true(cpu.cores > 0) + t.true(cpu.threads > 0) + t.is(typeof cpu.model, 'string') + if (process.platform === 'linux') t.true(Array.isArray(cpu.flags)) + t.true(memory.total > 0) +}) + +test('report({ benchmark: true }) includes a WebGL performance benchmark', async t => { + const browserless = await getBrowserContext(t) + const { performance } = await browserless.report({ benchmark: true }) + + t.truthy(performance) + t.true(performance.webgl.frames > 0) + t.true(performance.webgl.totalMs > 0) + t.true(performance.webgl.fps > 0) +}) + test('ensure to destroy browser contexts', async t => { const browserlessFactory = createBrowser() t.teardown(browserlessFactory.close) From 8280af5b4d4c6b58bbdbe4aa6eaedfa8ba8b21ac Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Fri, 26 Jun 2026 17:36:36 +0200 Subject: [PATCH 2/2] fix(browserless): don't mislabel branded Chrome as chrome-for-testing detectBuild matched broad patterns (chrome-linux/mac/win, /chrome/) that also hit stable Chrome install paths (/opt/google/chrome/chrome, Application/ chrome.exe). Match only the Chrome for Testing platform-arch dirs (chrome-linux64, chrome-mac-arm64, chrome-win64, ...). Adds detectBuild unit test. Addresses Cursor Bugbot review on #810. Co-Authored-By: Claude Opus 4.8 --- packages/browserless/src/report.js | 9 ++++++++- packages/browserless/test/index.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/browserless/src/report.js b/packages/browserless/src/report.js index 1758dffcc..82fc025c1 100644 --- a/packages/browserless/src/report.js +++ b/packages/browserless/src/report.js @@ -288,7 +288,13 @@ const sanitizeArgs = argv => argv.filter(arg => arg.startsWith('--') && !OMIT_AR const detectBuild = execPath => { if (/chrome-headless-shell/i.test(execPath)) return 'chrome-headless-shell' if (/chromium/i.test(execPath)) return 'Chromium' - if (/chrome-for-testing|chrome-linux|chrome-mac|chrome-win|[/\\]chrome[/\\]/i.test(execPath)) { return 'chrome-for-testing' } + // Chrome for Testing extracts under platform-arch dirs (chrome-linux64, + // chrome-mac-arm64, chrome-win64, ...). Match those specifically so branded + // Chrome paths (/opt/google/chrome/chrome, ...\Application\chrome.exe) are + // NOT misreported as a testing build. + if (/chrome-for-testing|chrome-(linux64|win64|win32|mac-(x64|arm64))/i.test(execPath)) { + return 'chrome-for-testing' + } return null } @@ -443,3 +449,4 @@ const report = module.exports = report module.exports.parseRenderer = parseRenderer +module.exports.detectBuild = detectBuild diff --git a/packages/browserless/test/index.js b/packages/browserless/test/index.js index c3bed8a14..0e53b9a32 100644 --- a/packages/browserless/test/index.js +++ b/packages/browserless/test/index.js @@ -7,9 +7,39 @@ const path = require('path') const ava = require('ava') const { runServer, createBrowser, getBrowserContext, getBrowser } = require('@browserless/test') +const { detectBuild } = require('../src/report') const test = process.env.CI ? ava.serial : ava +test('detectBuild distinguishes testing builds from branded Chrome', t => { + // Chrome for Testing (puppeteer cache) -> 'chrome-for-testing' + t.is( + detectBuild('/root/.cache/chrome/linux-150.0.7871.24/chrome-linux64/chrome'), + 'chrome-for-testing' + ) + t.is( + detectBuild( + '/Users/x/.cache/puppeteer/chrome/mac_arm-150/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' + ), + 'chrome-for-testing' + ) + t.is( + detectBuild('C:\\Users\\x\\.cache\\chrome\\win64-150\\chrome-win64\\chrome.exe'), + 'chrome-for-testing' + ) + // chrome-headless-shell / Chromium + t.is( + detectBuild('/root/.cache/chrome-headless-shell/linux-150/chrome-headless-shell'), + 'chrome-headless-shell' + ) + t.is(detectBuild('/usr/lib/chromium/chromium'), 'Chromium') + // Branded Google Chrome must NOT be misreported as a testing build. + t.is(detectBuild('/opt/google/chrome/chrome'), null) + t.is(detectBuild('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'), null) + t.is(detectBuild('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'), null) + t.is(detectBuild('/usr/bin/google-chrome-stable'), null) +}) + require('@browserless/test/suite')(getBrowser()) test('pass specific options to a context', async t => { const proxiedRequestUrls = []