fix: unify v02 browser launcher

This commit is contained in:
Codex Agent
2026-06-04 14:51:54 +08:00
parent 63b68914f7
commit 52af835500
7 changed files with 219 additions and 84 deletions
@@ -102,6 +102,10 @@
Cloud Web 的默认校验入口是 `bun run --cwd web/hwlab-cloud-web check`。该入口必须在 v0.2 CI 的 `hwlab-cloud-web` 镜像发布前执行,且保持秒级或低十秒级,不引入浏览器、Playwright、公网或真实 provider 依赖。 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 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 渲染变更而在浏览器运行期崩溃。 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 渲染变更而在浏览器运行期崩溃。
+1
View File
@@ -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": "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: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: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: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" "gateway:demo:edge-smoke": "node scripts/run-bun.mjs scripts/gateway-outbound-demo-smoke.mjs --edge"
}, },
@@ -10,6 +10,11 @@ import {
runDevCloudWorkbenchStaticSmoke, runDevCloudWorkbenchStaticSmoke,
sanitizeAgentChatBody sanitizeAgentChatBody
} from "./src/dev-cloud-workbench-smoke-lib.mjs"; } from "./src/dev-cloud-workbench-smoke-lib.mjs";
import {
browserLaunchRemediation,
classifyBrowserLaunchFailure,
chromiumLaunchPlan
} from "./src/browser-launcher.mjs";
import { import {
classifyCodeAgentBrowserFailure, classifyCodeAgentBrowserFailure,
classifyCodeAgentChatReadiness classifyCodeAgentChatReadiness
@@ -138,6 +143,15 @@ test("workbench smoke CLI treats skipped browser evidence as blocked and refuses
assert.equal(smokeCliExitCode(report, decision), 2); 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", () => { test("workbench smoke CLI refuses to overwrite live report with non-live fixture evidence", () => {
const report = { const report = {
status: "pass", status: "pass",
+172
View File
@@ -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 "";
}
}
+17 -79
View File
@@ -1,11 +1,17 @@
import fs from "node:fs"; import fs from "node:fs";
import { execFileSync, spawnSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import http from "node:http"; import http from "node:http";
import path from "node:path"; 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 { buildDevicePodRestPayload } from "../../internal/device-pod/fake-data.mjs";
import {
importPlaywright,
launchChromium,
playwrightDependencyCommand,
playwrightDependencySummary
} from "./browser-launcher.mjs";
import { import {
classifyCodeAgentBrowserJourney, classifyCodeAgentBrowserJourney,
classifyCodeAgentBrowserFailure, classifyCodeAgentBrowserFailure,
@@ -289,10 +295,6 @@ const requiredWebAssets = Object.freeze([
const liveIdentityWebAssets = Object.freeze(["index.html", "assets/index.css", "app.js"]); 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() { export function runDevCloudWorkbenchStaticSmoke() {
return runStaticSmoke(); return runStaticSmoke();
} }
@@ -319,69 +321,6 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
return runStaticSmoke(); 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) { export function parseSmokeArgs(argv) {
const args = { mode: "source", url: defaultLiveUrl, confirmDevLive: false }; const args = { mode: "source", url: defaultLiveUrl, confirmDevLive: false };
for (let index = 0; index < argv.length; index += 1) { for (let index = 0; index < argv.length; index += 1) {
@@ -3502,21 +3441,20 @@ async function inspectAuthFixtureViewport(browser, url, viewport) {
} }
async function loginWithDefaultCredentials(page) { async function loginWithDefaultCredentials(page) {
await page.waitForFunction( await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => null);
() => document.querySelector("#command-input") !== null || document.querySelector("#login-username") !== null, await page.waitForFunction(() => document.body.dataset.authState !== "checking", null, { timeout: 12000 }).catch(() => null);
null, const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
{ 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);
});
if (!loginVisible) return; 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-username").fill(defaultAuthCredentials.username);
await page.locator("#login-password").fill(defaultAuthCredentials.password); await page.locator("#login-password").fill(defaultAuthCredentials.password);
await page.locator("#login-submit").click(); await page.locator("#login-submit").click();
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); 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) { async function inspectLoginPage(page, viewport = null) {
+6
View File
@@ -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;
+5 -5
View File
@@ -15,9 +15,10 @@
// NOISE_USER=admin NOISE_PASS=hwlab2026 \ // NOISE_USER=admin NOISE_PASS=hwlab2026 \
// node tools/capture-issue-803-noise.mjs // node tools/capture-issue-803-noise.mjs
import { chromium } from "playwright";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { importPlaywright, launchChromium } from "../scripts/src/browser-launcher.mjs";
function parseArgs(argv) { function parseArgs(argv) {
const args = { const args = {
url: process.env.NOISE_URL ?? "http://74.48.78.17:17666/", 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 args = parseArgs(process.argv.slice(2));
const launchOptions = { headless: true }; const { chromium } = await importPlaywright();
if (process.env.CHROMIUM_PATH) launchOptions.executablePath = process.env.CHROMIUM_PATH; const browser = await launchChromium(chromium);
console.error(`[noise] browser ${JSON.stringify(browser.hwlabLaunchPlan ?? {})}`);
const browser = await chromium.launch(launchOptions);
const ctx = await browser.newContext({ viewport: { width: args.viewportWidth, height: args.viewportHeight } }); const ctx = await browser.newContext({ viewport: { width: args.viewportWidth, height: args.viewportHeight } });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(args.url, { waitUntil: "networkidle", timeout: 30000 }); await page.goto(args.url, { waitUntil: "networkidle", timeout: 30000 });