From 52af835500f5857f48d8c8f01c910cfc745e9854 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 14:51:54 +0800 Subject: [PATCH] fix: unify v02 browser launcher --- docs/reference/spec-v02-hwlab-cloud-web.md | 4 + package.json | 1 + scripts/dev-cloud-workbench-smoke.test.mjs | 14 ++ scripts/src/browser-launcher.mjs | 172 ++++++++++++++++++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 96 ++-------- scripts/web-browser-doctor.mjs | 6 + tools/capture-issue-803-noise.mjs | 10 +- 7 files changed, 219 insertions(+), 84 deletions(-) create mode 100644 scripts/src/browser-launcher.mjs create mode 100644 scripts/web-browser-doctor.mjs diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 258d68dd..d9d15622 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -102,6 +102,10 @@ Cloud Web 的默认校验入口是 `bun run --cwd web/hwlab-cloud-web check`。该入口必须在 v0.2 CI 的 `hwlab-cloud-web` 镜像发布前执行,且保持秒级或低十秒级,不引入浏览器、Playwright、公网或真实 provider 依赖。 +Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web check。所有 repo-owned Playwright smoke、layout smoke 和专项截图脚本必须通过 `scripts/src/browser-launcher.mjs` 启动 Chromium;不得直接 `chromium.launch()` 绑定到偶然存在的 Playwright browser cache。launcher 的浏览器来源判定顺序是显式 env(`HWLAB_PLAYWRIGHT_CHROMIUM` / `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` / `CHROMIUM_PATH`)、G14 系统 Chromium(优先 `/snap/bin/chromium`)、最后才是 Playwright cache。诊断入口是 `npm run web:browser:doctor`,输出必须包含 `playwrightPackageSource`、`browser.browserSource`、`browser.executablePath`、`browser.fallbackUsed`、`failureCode` 和 remediation;浏览器不可用、auth bootstrap 未完成和真实 layout 失败不得再统一表现成 `#command-input hidden` 超时。 + +Live smoke 登录前必须等待前端 auth bootstrap 结束(`body[data-auth-state]` 不再是 `checking`,且 login submit 已可见/可用)再填表;登录后必须断言 URL query 不含 `username` 或 `password`,防止原生 form submit 泄漏凭据并伪装成 layout 超时。 + Cloud Web check 必须先对实际 bundle 输入集合运行 TypeScript 语义检查。语法检查和 Bun build 只能证明源码可解析或可打包,不能稳定发现未绑定标识符;`isRequestTraceEvent is not defined` 这类错误必须由 semantic check 在发布前拦截。实现上可以生成与 dist build 相同顺序的临时 app entry,再执行 `tsc --noEmit` 或等价 TS checker;只跑 `node --check`、`bun build` 或源码字符串断言不满足本规格。 Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`,不允许只维护硬编码文件清单。`app-trace` 的 trace row/render helper 必须有纯逻辑单测,直接构造 request、setup、commandExecution、assistant markdown 和 completion events,证明 trace 展示路径不会因为漏定义 helper、事件分类漂移或 markdown body 渲染变更而在浏览器运行期崩溃。 diff --git a/package.json b/package.json index a7efa80e..e0ba88eb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json", "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json", + "web:browser:doctor": "node scripts/web-browser-doctor.mjs", "gateway:demo:smoke": "node scripts/run-bun.mjs scripts/gateway-outbound-demo-smoke.mjs", "gateway:demo:edge-smoke": "node scripts/run-bun.mjs scripts/gateway-outbound-demo-smoke.mjs --edge" }, diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 823ac3b2..892b8db8 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -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", diff --git a/scripts/src/browser-launcher.mjs b/scripts/src/browser-launcher.mjs new file mode 100644 index 00000000..3f77fa11 --- /dev/null +++ b/scripts/src/browser-launcher.mjs @@ -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,hwlab-browser-doctor", { 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 ""; + } +} diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 2cd714ae..1a878917 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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) { diff --git a/scripts/web-browser-doctor.mjs b/scripts/web-browser-doctor.mjs new file mode 100644 index 00000000..f9e421ae --- /dev/null +++ b/scripts/web-browser-doctor.mjs @@ -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; diff --git a/tools/capture-issue-803-noise.mjs b/tools/capture-issue-803-noise.mjs index d07552e8..cb267f82 100644 --- a/tools/capture-issue-803-noise.mjs +++ b/tools/capture-issue-803-noise.mjs @@ -15,9 +15,10 @@ // NOISE_USER=admin NOISE_PASS=hwlab2026 \ // node tools/capture-issue-803-noise.mjs -import { chromium } from "playwright"; import { setTimeout as sleep } from "node:timers/promises"; +import { importPlaywright, launchChromium } from "../scripts/src/browser-launcher.mjs"; + function parseArgs(argv) { const args = { url: process.env.NOISE_URL ?? "http://74.48.78.17:17666/", @@ -43,10 +44,9 @@ function parseArgs(argv) { } const args = parseArgs(process.argv.slice(2)); -const launchOptions = { headless: true }; -if (process.env.CHROMIUM_PATH) launchOptions.executablePath = process.env.CHROMIUM_PATH; - -const browser = await chromium.launch(launchOptions); +const { chromium } = await importPlaywright(); +const browser = await launchChromium(chromium); +console.error(`[noise] browser ${JSON.stringify(browser.hwlabLaunchPlan ?? {})}`); const ctx = await browser.newContext({ viewport: { width: args.viewportWidth, height: args.viewportHeight } }); const page = await ctx.newPage(); await page.goto(args.url, { waitUntil: "networkidle", timeout: 30000 });