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 });