import fs from "node:fs"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; export const playwrightDependencyCommand = "npm install"; export const playwrightDependencySummary = "Install repository dependencies before running browser smokes: npm install provides the declared playwright package; the Code Queue image may also provide the same package globally."; const chromiumExecutableEnvKeys = Object.freeze([ "HWLAB_PLAYWRIGHT_CHROMIUM", "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "CHROMIUM_PATH" ]); const chromiumExecutableCandidates = Object.freeze([ "/snap/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable" ]); export async function importPlaywright() { try { return normalizePlaywrightModule(await import("playwright")); } catch (localError) { const globalRoot = npmGlobalRoot(); if (globalRoot) { try { return normalizePlaywrightModule(await import(pathToFileURL(path.join(globalRoot, "playwright/index.js")).href)); } catch { // Keep the local resolution error so remediation points at this repository's package.json. } } throw localError; } } export async function launchChromium(chromium, options = {}) { const plan = chromiumLaunchPlan(); const launchOptions = { ...options, headless: options.headless ?? true, args: ["--no-sandbox", ...(options.args ?? [])], ...(plan.executablePath ? { executablePath: plan.executablePath } : {}) }; try { const browser = await chromium.launch(launchOptions); browser.hwlabLaunchPlan = { ...plan, failureCode: null }; return browser; } catch (error) { error.hwlabBrowserLaunch = { ...plan, failureCode: classifyBrowserLaunchFailure(error), remediation: browserLaunchRemediation(plan) }; throw error; } } export async function browserDoctor() { let playwright; try { playwright = await importPlaywright(); } catch (error) { return { status: "fail", failureCode: "playwright-import-failed", playwrightPackageSource: "missing", platform: browserRuntimePlatform(), browser: chromiumLaunchPlan(), remediation: [playwrightDependencyCommand], error: error instanceof Error ? error.message : String(error) }; } const managedBrowser = playwrightManagedBrowserPlan(playwright.chromium); const installCheck = managedBrowser.cacheStatus === "missing" ? playwrightInstallDryRun() : null; const browser = await launchChromium(playwright.chromium).catch((error) => ({ error })); if (browser?.error) { const plan = browser.error.hwlabBrowserLaunch ?? chromiumLaunchPlan(); return { status: "fail", failureCode: plan.failureCode ?? classifyBrowserLaunchFailure(browser.error), playwrightPackageSource: "playwright", platform: browserRuntimePlatform(), managedBrowser, installCheck, browser: plan, remediation: browserLaunchRemediation(plan), error: browser.error instanceof Error ? browser.error.message : String(browser.error) }; } try { const page = await browser.newPage(); await page.goto("data:text/html,hwlab-browser-doctor", { waitUntil: "domcontentloaded", timeout: 5000 }); await page.close(); } finally { await browser.close(); } return { status: "pass", failureCode: null, playwrightPackageSource: "playwright", platform: browserRuntimePlatform(), managedBrowser, installCheck, browser: browser.hwlabLaunchPlan ?? chromiumLaunchPlan(), remediation: browserLaunchRemediation(browser.hwlabLaunchPlan ?? chromiumLaunchPlan(), { managedBrowser, installCheck }) }; } export function playwrightManagedBrowserPlan(chromium) { const executablePath = typeof chromium?.executablePath === "function" ? chromium.executablePath() : ""; const exists = executablePath ? fs.existsSync(executablePath) : false; return { browserSource: "playwright-cache", executablePath, exists, cacheStatus: exists ? "available" : "missing" }; } export function playwrightInstallDryRun() { const command = ["npx", "playwright", "install", "--dry-run", "chromium"]; try { const output = execFileSync(command[0], command.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).trim(); return { status: "dry-run-available", command: command.join(" "), note: "Dry-run reports the Playwright download plan only; this doctor uses system Chromium fallback when the managed cache is missing.", output: boundedText(output) }; } catch (error) { const output = `${error?.stdout ?? ""}${error?.stderr ?? ""}`.trim(); const message = error instanceof Error ? error.message : String(error); return { status: /does not support|unsupported platform|unsupported/iu.test(`${output}\n${message}`) ? "unsupported" : "unknown", command: command.join(" "), output: boundedText(output || message), exitCode: typeof error?.status === "number" ? error.status : null }; } } export function browserRuntimePlatform() { return { platform: process.platform, arch: process.arch, osRelease: readOsReleaseSummary(), glibcVersionRuntime: process.report?.getReport?.().header?.glibcVersionRuntime ?? null }; } export function chromiumLaunchPlan(env = process.env) { const candidatePaths = chromiumExecutableCandidates.filter((candidate) => fs.existsSync(candidate)); for (const key of chromiumExecutableEnvKeys) { const value = env[key]; if (value && fs.existsSync(value)) { return { browserSource: "env", executablePath: value, executablePathEnvKey: key, fallbackUsed: key !== "HWLAB_PLAYWRIGHT_CHROMIUM", candidatePaths }; } } for (const candidate of chromiumExecutableCandidates) { if (fs.existsSync(candidate)) { return { browserSource: "system-chromium", executablePath: candidate, executablePathEnvKey: null, fallbackUsed: true, candidatePaths }; } } return { browserSource: "playwright-cache", executablePath: "", executablePathEnvKey: null, fallbackUsed: false, candidatePaths }; } export function classifyBrowserLaunchFailure(error) { const message = error instanceof Error ? error.message : String(error); if (/does not support chromium on/iu.test(message)) return "browser-install-unsupported-platform"; if (/Executable doesn't exist|download new browsers|playwright install/iu.test(message)) return "browser-cache-missing"; if (/spawn|ENOENT|permission denied|EACCES/iu.test(message)) return "browser-executable-unavailable"; return "browser-launch-failed"; } export function browserLaunchRemediation(plan = chromiumLaunchPlan(), context = {}) { const remediation = []; if (plan.executablePath) { remediation.push(`using ${plan.executablePath}`); if (context.managedBrowser?.cacheStatus === "missing") { remediation.push("Playwright-managed browser cache is missing; the repo launcher selected system Chromium fallback."); } if (context.installCheck?.status) { remediation.push(`Playwright install dry-run status=${context.installCheck.status}; keep using the repo launcher/system Chromium fallback when managed browser install is unavailable or unnecessary.`); } return remediation; } remediation.push( "Set HWLAB_PLAYWRIGHT_CHROMIUM=/snap/bin/chromium when system Chromium exists.", context.installCheck?.status === "unsupported" ? "Playwright install is unsupported on this platform; install a system Chromium package or use a runner image that already contains Chromium." : "Install Playwright browsers with npx playwright install when no system Chromium is available." ); return remediation; } function normalizePlaywrightModule(module) { if (module?.chromium) return module; if (module?.default?.chromium) return module.default; return module; } function npmGlobalRoot() { try { return execFileSync("npm", ["root", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { return ""; } } function readOsReleaseSummary() { try { const fields = Object.fromEntries(fs.readFileSync("/etc/os-release", "utf8") .split(/\r?\n/u) .map((line) => line.match(/^([A-Z_]+)=(.*)$/u)) .filter(Boolean) .map((match) => [match[1], match[2].replace(/^"|"$/gu, "")])); return { name: fields.NAME ?? os.type(), versionId: fields.VERSION_ID ?? "", prettyName: fields.PRETTY_NAME ?? "", codename: fields.VERSION_CODENAME ?? fields.UBUNTU_CODENAME ?? "" }; } catch { return { name: os.type(), versionId: os.release(), prettyName: os.platform(), codename: "" }; } } function boundedText(value, maxChars = 2000) { const text = String(value ?? ""); return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; }