fix: unify v02 browser launcher
This commit is contained in:
@@ -10,6 +10,11 @@ import {
|
||||
runDevCloudWorkbenchStaticSmoke,
|
||||
sanitizeAgentChatBody
|
||||
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
|
||||
import {
|
||||
browserLaunchRemediation,
|
||||
classifyBrowserLaunchFailure,
|
||||
chromiumLaunchPlan
|
||||
} from "./src/browser-launcher.mjs";
|
||||
import {
|
||||
classifyCodeAgentBrowserFailure,
|
||||
classifyCodeAgentChatReadiness
|
||||
@@ -138,6 +143,15 @@ test("workbench smoke CLI treats skipped browser evidence as blocked and refuses
|
||||
assert.equal(smokeCliExitCode(report, decision), 2);
|
||||
});
|
||||
|
||||
test("repo-owned browser launcher classifies cache misses and exposes system Chromium fallback metadata", () => {
|
||||
assert.equal(classifyBrowserLaunchFailure(new Error("Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1217")), "browser-cache-missing");
|
||||
assert.equal(classifyBrowserLaunchFailure(new Error("spawn /missing/chromium ENOENT")), "browser-executable-unavailable");
|
||||
const plan = chromiumLaunchPlan({ HWLAB_PLAYWRIGHT_CHROMIUM: "/definitely/missing/chromium" });
|
||||
assert.ok(["system-chromium", "playwright-cache"].includes(plan.browserSource));
|
||||
assert.equal(Array.isArray(plan.candidatePaths), true);
|
||||
assert.equal(browserLaunchRemediation({ executablePath: "/snap/bin/chromium" })[0], "using /snap/bin/chromium");
|
||||
});
|
||||
|
||||
test("workbench smoke CLI refuses to overwrite live report with non-live fixture evidence", () => {
|
||||
const report = {
|
||||
status: "pass",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
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",
|
||||
browser: chromiumLaunchPlan(),
|
||||
remediation: [playwrightDependencyCommand],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
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",
|
||||
browser: browser.hwlabLaunchPlan ?? chromiumLaunchPlan(),
|
||||
remediation: []
|
||||
};
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildDevicePodRestPayload } from "../../internal/device-pod/fake-data.mjs";
|
||||
import {
|
||||
importPlaywright,
|
||||
launchChromium,
|
||||
playwrightDependencyCommand,
|
||||
playwrightDependencySummary
|
||||
} from "./browser-launcher.mjs";
|
||||
import {
|
||||
classifyCodeAgentBrowserJourney,
|
||||
classifyCodeAgentBrowserFailure,
|
||||
@@ -289,10 +295,6 @@ const requiredWebAssets = Object.freeze([
|
||||
|
||||
const liveIdentityWebAssets = Object.freeze(["index.html", "assets/index.css", "app.js"]);
|
||||
|
||||
const playwrightDependencyCommand = "npm install";
|
||||
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.";
|
||||
|
||||
export function runDevCloudWorkbenchStaticSmoke() {
|
||||
return runStaticSmoke();
|
||||
}
|
||||
@@ -319,69 +321,6 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
|
||||
return runStaticSmoke();
|
||||
}
|
||||
|
||||
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 {
|
||||
// Fall through to the original local resolution error so the remediation points at package.json.
|
||||
}
|
||||
}
|
||||
throw localError;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaywrightModule(module) {
|
||||
if (module?.chromium) return module;
|
||||
if (module?.default?.chromium) return module.default;
|
||||
return module;
|
||||
}
|
||||
|
||||
async function launchChromium(chromium) {
|
||||
try {
|
||||
return await chromium.launch({ headless: true });
|
||||
} catch (error) {
|
||||
const executablePath = systemChromiumExecutablePath();
|
||||
if (!executablePath || !isMissingPlaywrightBrowser(error)) throw error;
|
||||
return chromium.launch({ headless: true, executablePath });
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPlaywrightBrowser(error) {
|
||||
return /Executable doesn't exist|Please run the following command to download new browsers|playwright install/iu.test(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
function systemChromiumExecutablePath() {
|
||||
for (const candidate of ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]) {
|
||||
const found = spawnSync("command", ["-v", candidate], {
|
||||
encoding: "utf8",
|
||||
shell: true,
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
});
|
||||
const executablePath = found.stdout.trim().split("\n")[0];
|
||||
if (found.status === 0 && executablePath) return executablePath;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function npmGlobalRoot() {
|
||||
try {
|
||||
return execFileSync("npm", ["root", "-g"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSmokeArgs(argv) {
|
||||
const args = { mode: "source", url: defaultLiveUrl, confirmDevLive: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -3502,21 +3441,20 @@ async function inspectAuthFixtureViewport(browser, url, viewport) {
|
||||
}
|
||||
|
||||
async function loginWithDefaultCredentials(page) {
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#command-input") !== null || document.querySelector("#login-username") !== null,
|
||||
null,
|
||||
{ timeout: 12000 }
|
||||
).catch(() => {});
|
||||
const loginVisible = await page.evaluate(() => {
|
||||
const login = document.querySelector("#login-shell");
|
||||
const username = document.querySelector("#login-username");
|
||||
return Boolean(login && username && login.hidden === false);
|
||||
});
|
||||
await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => null);
|
||||
await page.waitForFunction(() => document.body.dataset.authState !== "checking", null, { timeout: 12000 }).catch(() => null);
|
||||
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
|
||||
if (!loginVisible) return;
|
||||
await page.locator("#login-submit").waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForFunction(() => document.querySelector("#login-submit")?.disabled === false, null, { timeout: 12000 }).catch(() => null);
|
||||
await page.locator("#login-username").fill(defaultAuthCredentials.username);
|
||||
await page.locator("#login-password").fill(defaultAuthCredentials.password);
|
||||
await page.locator("#login-submit").click();
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
const currentUrl = new URL(page.url());
|
||||
if (currentUrl.searchParams.has("username") || currentUrl.searchParams.has("password")) {
|
||||
throw new Error("auth-bootstrap-query-leak: username/password query params appeared after login");
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectLoginPage(page, viewport = null) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { browserDoctor } from "./src/browser-launcher.mjs";
|
||||
|
||||
const report = await browserDoctor();
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exitCode = report.status === "pass" ? 0 : 2;
|
||||
Reference in New Issue
Block a user