6b417cc620
Co-authored-by: Codex Agent <codex@hwlab.local>
252 lines
8.1 KiB
JavaScript
252 lines
8.1 KiB
JavaScript
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,<title>hwlab-browser-doctor</title>", { 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: []
|
|
};
|
|
}
|
|
|
|
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 (/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()) {
|
|
if (plan.executablePath) return [`using ${plan.executablePath}`];
|
|
return [
|
|
"Set HWLAB_PLAYWRIGHT_CHROMIUM=/snap/bin/chromium when system Chromium exists.",
|
|
"Install Playwright browsers with npx playwright install when no system Chromium is available."
|
|
];
|
|
}
|
|
|
|
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)}...<truncated ${text.length - maxChars} chars>` : text;
|
|
}
|