fix: add repo-owned playwright probe guard (#850)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 20:35:58 +08:00
committed by GitHub
parent b896db85d4
commit 6b417cc620
8 changed files with 561 additions and 10 deletions
+79
View File
@@ -1,5 +1,6 @@
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";
@@ -68,12 +69,15 @@ export async function browserDoctor() {
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();
@@ -81,6 +85,9 @@ export async function browserDoctor() {
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)
@@ -99,11 +106,60 @@ export async function browserDoctor() {
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) {
@@ -170,3 +226,26 @@ function npmGlobalRoot() {
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;
}
+5
View File
@@ -99,6 +99,11 @@ export const checkProfiles = Object.freeze({
{ id: "check-080-smoke-run-bun", group: "smoke", command: ["node","scripts/run-bun.mjs","build","scripts/src/m3-io-control-e2e.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-081-smoke-m3-io-control-e2e-test", group: "smoke", command: ["node","--check","scripts/m3-io-control-e2e.test.mjs"] },
{ id: "check-082-cloud-web-dev-cloud-workbench-smoke-test", group: "cloud-web", command: ["node","--check","scripts/dev-cloud-workbench-smoke.test.mjs"] },
{ id: "check-083-cloud-web-browser-launcher", group: "cloud-web", command: ["node","--check","scripts/src/browser-launcher.mjs"] },
{ id: "check-084-cloud-web-playwright-launch-guard", group: "cloud-web", command: ["node","--check","scripts/src/playwright-launch-guard.mjs"] },
{ id: "check-085-cloud-web-playwright-launch-guard-cli", group: "cloud-web", command: ["node","--check","scripts/playwright-launch-guard.mjs"] },
{ id: "check-086-cloud-web-playwright-launch-guard-run", group: "cloud-web", command: ["node","scripts/playwright-launch-guard.mjs"] },
{ id: "check-087-cloud-web-live-dom-probe-cli", group: "cloud-web", command: ["node","--check","scripts/web-live-dom-probe.mjs"] },
{ id: "check-089-deploy-validate-artifact-catalog", group: "deploy", command: ["node","--check","scripts/validate-artifact-catalog.mjs"] },
{ id: "check-090-deploy-refresh-artifact-catalog", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.mjs"] },
{ id: "check-091-deploy-refresh-artifact-catalog-test", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.test.mjs"] },
+10 -8
View File
@@ -261,7 +261,9 @@ const markdownRendererPackages = Object.freeze([
"marked",
"markdown-it",
"micromark",
"react-markdown",
"remark",
"remark-gfm",
"remark-parse",
"unified",
"commonmark"
@@ -284,9 +286,9 @@ const requiredWebAssets = Object.freeze([
"src/components/device-pod/DevicePodSidebar.tsx",
"src/components/settings/SettingsView.tsx",
"src/components/skills/SkillsView.tsx",
"src/components/shared/MessageMarkdown.tsx",
"src/hooks/useAuth.ts",
"src/services/api/client.ts",
"src/services/markdown/render.ts",
"src/state/workbench.ts",
"src/state/conversation.ts",
"src/types/domain.ts",
@@ -427,9 +429,9 @@ function runStaticSmoke() {
evidence: ["data-app-shell", "#command-input", "#conversation-list", "#device-pod-sidebar", "#logout-button"]
});
addCheck(checks, blockers, "react-api-state-separation", reactApiStateSeparation(files), "API, workspace/session state, conversation mapping, markdown rendering, and UI components are separated.", {
addCheck(checks, blockers, "react-api-state-separation", reactApiStateSeparation(files), "API, workspace/session state, conversation mapping, shared markdown rendering, and UI components are separated.", {
blocker: "contract_blocker",
evidence: ["services/api/client.ts", "state/workbench.ts", "state/conversation.ts", "services/markdown/render.ts"]
evidence: ["services/api/client.ts", "state/workbench.ts", "state/conversation.ts", "components/shared/MessageMarkdown.tsx"]
});
addCheck(checks, blockers, "vite-build-and-dist-contract", viteBuildAndDistContract(files), "Vite build, route aliases, dist freshness, and root app.js output are the only deployable Cloud Web asset path.", {
@@ -1550,11 +1552,11 @@ function reactApiStateSeparation({ app }) {
"services/api/client.ts",
"state/workbench.ts",
"state/conversation.ts",
"services/markdown/render.ts"
"components/shared/MessageMarkdown.tsx"
].every((file) => fs.existsSync(path.join(webRoot, "src", file))) &&
/fetchJson/u.test(app) &&
/useWorkbenchStore/u.test(app) &&
/renderMessageMarkdown/u.test(app) &&
/MessageMarkdown/u.test(app) &&
/selectConversation/u.test(app);
}
@@ -2613,17 +2615,17 @@ function findHelpMarkdownFiles() {
}
function findMarkdownRenderers() {
const renderService = readText("web/hwlab-cloud-web/src/services/markdown/render.ts");
const messageMarkdown = readText("web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx");
const packages = ["package.json", "web/hwlab-cloud-web/package.json"]
.filter((file) => fs.existsSync(path.join(repoRoot, file)))
.flatMap((file) => {
const json = JSON.parse(readText(file));
return Object.keys({ ...(json.dependencies ?? {}), ...(json.devDependencies ?? {}) });
});
const source = `${readCloudWebAppSource()}\n${readText("web/hwlab-cloud-web/index.html")}`;
const source = `${readCloudWebAppSource()}\n${messageMarkdown}\n${readText("web/hwlab-cloud-web/index.html")}`;
const imported = markdownRendererPackages.filter((name) => new RegExp(`from\\s+["']${escapeRegExp(name)}["']|import\\(["']${escapeRegExp(name)}["']\\)`, "u").test(source));
const renderers = [...packages, ...imported].filter((name) => markdownRendererPackages.includes(name));
if (/export function renderMessageMarkdown/u.test(renderService) && /escapeHtml/u.test(renderService)) renderers.push("react-render-service");
if (/export function MessageMarkdown/u.test(messageMarkdown) && /ReactMarkdown/u.test(messageMarkdown)) renderers.push("react-message-markdown");
return [...new Set(renderers)].sort();
}
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
export const defaultPlaywrightLaunchScanRoots = Object.freeze(["scripts", "tools", "web"]);
export const defaultPlaywrightLaunchAllowlist = Object.freeze([
"scripts/src/browser-launcher.mjs"
]);
const sourceExtensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
const skippedDirectories = new Set([".git", ".state", ".worktree", "dist", "node_modules"]);
const directChromiumLaunchPattern = new RegExp("\\bchromium\\s*\\.\\s*launch\\s*\\(", "u");
export function findDirectChromiumLaunches(options = {}) {
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
const roots = options.roots ?? defaultPlaywrightLaunchScanRoots;
const allowlist = new Set(options.allowlist ?? defaultPlaywrightLaunchAllowlist);
const scannedFiles = [];
const allowed = [];
const violations = [];
for (const root of roots) {
const absoluteRoot = path.resolve(repoRoot, root);
if (!fs.existsSync(absoluteRoot)) continue;
for (const file of collectSourceFiles(absoluteRoot)) {
const relativePath = normalizeRelative(repoRoot, file);
scannedFiles.push(relativePath);
const matches = findMatches(file, relativePath);
if (matches.length === 0) continue;
if (allowlist.has(relativePath)) allowed.push(...matches);
else violations.push(...matches);
}
}
return {
status: violations.length === 0 ? "pass" : "fail",
repoRoot,
roots,
allowlist: [...allowlist].sort(),
scannedFileCount: scannedFiles.length,
allowed,
violations
};
}
function collectSourceFiles(directory) {
const out = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!skippedDirectories.has(entry.name)) out.push(...collectSourceFiles(path.join(directory, entry.name)));
continue;
}
if (!entry.isFile()) continue;
const full = path.join(directory, entry.name);
if (sourceExtensions.has(path.extname(entry.name))) out.push(full);
}
return out;
}
function findMatches(file, relativePath) {
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/u);
const matches = [];
lines.forEach((line, index) => {
if (directChromiumLaunchPattern.test(line)) {
matches.push({ file: relativePath, line: index + 1, text: line.trim().slice(0, 200) });
}
});
return matches;
}
function normalizeRelative(root, file) {
return path.relative(root, file).split(path.sep).join("/");
}