fix: add repo-owned playwright probe guard (#850)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -40,6 +40,8 @@
|
||||
"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",
|
||||
"web:browser:guard": "node scripts/playwright-launch-guard.mjs",
|
||||
"web:dom-probe": "node scripts/web-live-dom-probe.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"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
@@ -12,9 +14,12 @@ import {
|
||||
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
|
||||
import {
|
||||
browserLaunchRemediation,
|
||||
browserRuntimePlatform,
|
||||
classifyBrowserLaunchFailure,
|
||||
chromiumLaunchPlan
|
||||
chromiumLaunchPlan,
|
||||
playwrightManagedBrowserPlan
|
||||
} from "./src/browser-launcher.mjs";
|
||||
import { findDirectChromiumLaunches } from "./src/playwright-launch-guard.mjs";
|
||||
import {
|
||||
classifyCodeAgentBrowserFailure,
|
||||
classifyCodeAgentChatReadiness
|
||||
@@ -153,6 +158,30 @@ test("repo-owned browser launcher classifies cache misses and exposes system Chr
|
||||
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");
|
||||
const managed = playwrightManagedBrowserPlan({ executablePath: () => "/definitely/missing/playwright-cache" });
|
||||
assert.equal(managed.cacheStatus, "missing");
|
||||
assert.equal(managed.exists, false);
|
||||
const platform = browserRuntimePlatform();
|
||||
assert.equal(platform.platform, process.platform);
|
||||
assert.equal(platform.arch, process.arch);
|
||||
});
|
||||
|
||||
test("repo-owned guard blocks direct chromium launch outside the launcher", () => {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "hwlab-playwright-guard-"));
|
||||
try {
|
||||
mkdirSync(path.join(tempRoot, "scripts", "src"), { recursive: true });
|
||||
mkdirSync(path.join(tempRoot, "tools"), { recursive: true });
|
||||
const directLaunch = "await chromium." + "launch({ headless: true });\n";
|
||||
writeFileSync(path.join(tempRoot, "scripts", "src", "browser-launcher.mjs"), directLaunch, "utf8");
|
||||
writeFileSync(path.join(tempRoot, "tools", "ad-hoc-probe.mjs"), directLaunch, "utf8");
|
||||
const report = findDirectChromiumLaunches({ repoRoot: tempRoot, roots: ["scripts", "tools"] });
|
||||
assert.equal(report.status, "fail");
|
||||
assert.equal(report.allowed.length, 1);
|
||||
assert.equal(report.violations.length, 1);
|
||||
assert.equal(report.violations[0].file, "tools/ad-hoc-probe.mjs");
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench smoke CLI refuses to overwrite live report with non-live fixture evidence", () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { findDirectChromiumLaunches } from "./src/playwright-launch-guard.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
process.stdout.write(`${JSON.stringify(helpPayload(), null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const report = findDirectChromiumLaunches({
|
||||
repoRoot: args.repoRoot,
|
||||
roots: args.roots.length > 0 ? args.roots : undefined
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exitCode = report.status === "pass" ? 0 : 2;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { help: false, repoRoot: process.cwd(), roots: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u"));
|
||||
if (inline) return inline[1];
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
if (arg === "--help" || arg === "-h") out.help = true;
|
||||
else if (arg === "--root" || arg.startsWith("--root=")) out.repoRoot = readValue("--root");
|
||||
else if (arg === "--path" || arg.startsWith("--path=")) out.roots.push(...readValue("--path").split(",").filter(Boolean));
|
||||
else throw new Error(`unknown playwright-launch-guard argument: ${arg}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function helpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
command: "playwright-launch-guard",
|
||||
usage: [
|
||||
"node scripts/playwright-launch-guard.mjs",
|
||||
"node scripts/playwright-launch-guard.mjs --path scripts,tools,web"
|
||||
],
|
||||
summary: "Fails when repo scripts bypass scripts/src/browser-launcher.mjs with direct chromium launch calls."
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { importPlaywright, launchChromium } from "./src/browser-launcher.mjs";
|
||||
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const stateRoot = path.join(repoRoot, ".state", "web-live-dom-probe");
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
const args = parseProbeArgs(process.argv.slice(2));
|
||||
const result = args.command === "start"
|
||||
? await startProbe(args)
|
||||
: args.command === "status"
|
||||
? await probeStatus(args)
|
||||
: args.command === "help"
|
||||
? helpPayload()
|
||||
: await runProbe(args);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
process.exitCode = ["pass", "running", "usage"].includes(result.status) ? 0 : 2;
|
||||
} catch (error) {
|
||||
process.stderr.write(`[web-live-dom-probe] ${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: false, command: "web-live-dom-probe", status: "blocked", error: error instanceof Error ? error.message : String(error) }, null, 2)}\n`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProbeArgs(argv) {
|
||||
const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run";
|
||||
const startIndex = command === "run" ? (argv[0] === "run" ? 1 : 0) : 1;
|
||||
const args = {
|
||||
command,
|
||||
url: process.env.HWLAB_WEB_PROBE_URL ?? process.env.HWLAB_WEB_URL ?? "http://74.48.78.17:19666/",
|
||||
user: process.env.HWLAB_WEB_USER ?? "admin",
|
||||
pass: process.env.HWLAB_WEB_PASS ?? "hwlab2026",
|
||||
viewport: process.env.HWLAB_WEB_PROBE_VIEWPORT ?? "1440x900",
|
||||
message: process.env.HWLAB_WEB_PROBE_MESSAGE ?? "",
|
||||
freshSession: false,
|
||||
cancelRunning: true,
|
||||
waitAfterSubmitMs: 1500,
|
||||
timeoutMs: 30000,
|
||||
reportPath: tempReportPath("web-live-dom-probe.json"),
|
||||
screenshotPath: tempReportPath("web-live-dom-probe.png"),
|
||||
jobId: ""
|
||||
};
|
||||
if (command === "help" || argv.includes("--help") || argv.includes("-h")) return { ...args, command: "help" };
|
||||
for (let index = startIndex; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u"));
|
||||
if (inline) return inline[1];
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
if (arg === "--url" || arg.startsWith("--url=")) args.url = readValue("--url");
|
||||
else if (arg === "--user" || arg.startsWith("--user=")) args.user = readValue("--user");
|
||||
else if (arg === "--pass" || arg.startsWith("--pass=")) args.pass = readValue("--pass");
|
||||
else if (arg === "--viewport" || arg.startsWith("--viewport=")) args.viewport = readValue("--viewport");
|
||||
else if (arg === "--message" || arg.startsWith("--message=")) args.message = readValue("--message");
|
||||
else if (arg === "--report" || arg.startsWith("--report=")) args.reportPath = readValue("--report");
|
||||
else if (arg === "--screenshot" || arg.startsWith("--screenshot=")) args.screenshotPath = readValue("--screenshot");
|
||||
else if (arg === "--job-id" || arg.startsWith("--job-id=")) args.jobId = readValue("--job-id");
|
||||
else if (arg === "--wait-after-submit-ms" || arg.startsWith("--wait-after-submit-ms=")) args.waitAfterSubmitMs = positiveInteger(readValue("--wait-after-submit-ms"), "--wait-after-submit-ms");
|
||||
else if (arg === "--timeout-ms" || arg.startsWith("--timeout-ms=")) args.timeoutMs = positiveInteger(readValue("--timeout-ms"), "--timeout-ms");
|
||||
else if (arg === "--fresh-session") args.freshSession = true;
|
||||
else if (arg === "--no-cancel-running") args.cancelRunning = false;
|
||||
else if (command === "status" && !args.jobId) args.jobId = arg;
|
||||
else throw new Error(`unknown web-live-dom-probe argument: ${arg}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function runProbe(args) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const reportPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.reportPath), "--report");
|
||||
const screenshotPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.screenshotPath), "--screenshot");
|
||||
const { width, height } = parseViewport(args.viewport);
|
||||
const actions = [];
|
||||
let browser;
|
||||
let result;
|
||||
try {
|
||||
const { chromium } = await importPlaywright();
|
||||
browser = await launchChromium(chromium);
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
|
||||
await maybeLogin(page, args, actions);
|
||||
await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs });
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
||||
if (args.freshSession) await maybeClick(page, "#session-create", actions, "fresh-session");
|
||||
if (args.message) await submitPrompt(page, args, actions);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const dom = await collectDomEvidence(page);
|
||||
result = {
|
||||
ok: true,
|
||||
status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList ? "pass" : "blocked",
|
||||
command: "web-live-dom-probe run",
|
||||
generatedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
url: args.url,
|
||||
finalUrl: page.url(),
|
||||
viewport: { width, height },
|
||||
browser: browser.hwlabLaunchPlan ?? null,
|
||||
actions,
|
||||
dom,
|
||||
artifacts: { reportPath, screenshotPath },
|
||||
safety: {
|
||||
repoOwnedLauncher: true,
|
||||
directChromiumLaunch: false,
|
||||
freshSessionRequested: args.freshSession,
|
||||
promptSubmitted: Boolean(args.message),
|
||||
cancelRunningRequested: args.cancelRunning
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
command: "web-live-dom-probe run",
|
||||
generatedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
url: args.url,
|
||||
browser: browser?.hwlabLaunchPlan ?? error?.hwlabBrowserLaunch ?? null,
|
||||
actions,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
artifacts: { reportPath, screenshotPath }
|
||||
};
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
}
|
||||
await mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function startProbe(args) {
|
||||
await mkdir(stateRoot, { recursive: true });
|
||||
const jobId = args.jobId || `web-live-dom-probe-${Date.now()}-${process.pid}`;
|
||||
const reportPath = path.join(stateRoot, `${jobId}.result.json`);
|
||||
const screenshotPath = path.join(stateRoot, `${jobId}.png`);
|
||||
const stdoutPath = path.join(stateRoot, `${jobId}.stdout.log`);
|
||||
const stderrPath = path.join(stateRoot, `${jobId}.stderr.log`);
|
||||
const metaPath = path.join(stateRoot, `${jobId}.json`);
|
||||
const childArgs = [
|
||||
scriptPath,
|
||||
"run",
|
||||
"--url", args.url,
|
||||
"--user", args.user,
|
||||
"--pass", args.pass,
|
||||
"--viewport", args.viewport,
|
||||
"--report", reportPath,
|
||||
"--screenshot", screenshotPath,
|
||||
"--timeout-ms", String(args.timeoutMs),
|
||||
"--wait-after-submit-ms", String(args.waitAfterSubmitMs)
|
||||
];
|
||||
if (args.freshSession) childArgs.push("--fresh-session");
|
||||
if (!args.cancelRunning) childArgs.push("--no-cancel-running");
|
||||
if (args.message) childArgs.push("--message", args.message);
|
||||
const stdout = fs.openSync(stdoutPath, "a");
|
||||
const stderr = fs.openSync(stderrPath, "a");
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", stdout, stderr]
|
||||
});
|
||||
child.unref();
|
||||
const meta = {
|
||||
ok: true,
|
||||
status: "running",
|
||||
command: "web-live-dom-probe start",
|
||||
jobId,
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
url: args.url,
|
||||
reportPath,
|
||||
screenshotPath,
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
statusCommand: `node scripts/web-live-dom-probe.mjs status ${jobId}`
|
||||
};
|
||||
await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function probeStatus(args) {
|
||||
if (!args.jobId) throw new Error("status requires a job id");
|
||||
const metaPath = path.join(stateRoot, `${args.jobId}.json`);
|
||||
const meta = JSON.parse(await readFile(metaPath, "utf8"));
|
||||
const report = fs.existsSync(meta.reportPath) ? JSON.parse(await readFile(meta.reportPath, "utf8")) : null;
|
||||
const running = !report && typeof meta.pid === "number" && processAlive(meta.pid);
|
||||
return {
|
||||
ok: report ? report.ok === true : running,
|
||||
status: report ? report.status : running ? "running" : "blocked",
|
||||
command: "web-live-dom-probe status",
|
||||
jobId: args.jobId,
|
||||
pid: meta.pid,
|
||||
startedAt: meta.startedAt,
|
||||
reportPath: meta.reportPath,
|
||||
screenshotPath: meta.screenshotPath,
|
||||
stdoutPath: meta.stdoutPath,
|
||||
stderrPath: meta.stderrPath,
|
||||
running,
|
||||
reportSummary: report ? { status: report.status, url: report.url, finalUrl: report.finalUrl, browser: report.browser, dom: report.dom } : null,
|
||||
stdoutTail: await readTail(meta.stdoutPath),
|
||||
stderrTail: await readTail(meta.stderrPath)
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeLogin(page, args, actions) {
|
||||
const username = page.locator("#login-username");
|
||||
if (!(await username.isVisible({ timeout: 2500 }).catch(() => false))) return;
|
||||
await username.fill(args.user);
|
||||
await page.locator("#login-password").fill(args.pass);
|
||||
await page.locator("#login-submit").click();
|
||||
actions.push({ action: "login", user: args.user });
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
||||
}
|
||||
|
||||
async function maybeClick(page, selector, actions, action) {
|
||||
const locator = page.locator(selector).first();
|
||||
if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await locator.click();
|
||||
actions.push({ action, selector });
|
||||
await page.waitForTimeout(1000);
|
||||
return true;
|
||||
}
|
||||
actions.push({ action, selector, skipped: "not-visible" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function submitPrompt(page, args, actions) {
|
||||
await page.locator("#command-input").fill(args.message);
|
||||
await page.locator("#command-send").click();
|
||||
actions.push({ action: "submit-prompt", chars: args.message.length });
|
||||
await page.waitForTimeout(args.waitAfterSubmitMs);
|
||||
if (args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message");
|
||||
}
|
||||
|
||||
async function collectDomEvidence(page) {
|
||||
return page.evaluate(() => {
|
||||
const workspace = document.querySelector("#workspace");
|
||||
const list = document.querySelector("#conversation-list");
|
||||
const cards = [...document.querySelectorAll(".message-card")];
|
||||
const scroll = workspace ? {
|
||||
scrollTop: Math.round(workspace.scrollTop),
|
||||
scrollHeight: Math.round(workspace.scrollHeight),
|
||||
clientHeight: Math.round(workspace.clientHeight),
|
||||
overflowY: getComputedStyle(workspace).overflowY,
|
||||
followState: workspace.getAttribute("data-scroll-follow")
|
||||
} : null;
|
||||
return {
|
||||
title: document.title,
|
||||
requiredSelectors: {
|
||||
workspace: Boolean(workspace),
|
||||
commandInput: Boolean(document.querySelector("#command-input")),
|
||||
commandSend: Boolean(document.querySelector("#command-send")),
|
||||
conversationList: Boolean(list),
|
||||
sessionCreate: Boolean(document.querySelector("#session-create"))
|
||||
},
|
||||
messageCount: cards.length,
|
||||
messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item.startsWith("message-")) ?? "unknown"),
|
||||
scroll,
|
||||
textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseViewport(value) {
|
||||
const [width, height] = String(value).split("x").map(Number);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error(`invalid --viewport: ${value}`);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function positiveInteger(value, label) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function processAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTail(file, maxChars = 4000) {
|
||||
if (!file || !fs.existsSync(file)) return "";
|
||||
const text = await readFile(file, "utf8");
|
||||
return text.length > maxChars ? text.slice(-maxChars) : text;
|
||||
}
|
||||
|
||||
function helpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
status: "usage",
|
||||
command: "web-live-dom-probe",
|
||||
usage: [
|
||||
"node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/ --fresh-session --report /tmp/hwlab-dev-gate/web-live-dom-probe.json",
|
||||
"node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session",
|
||||
"node scripts/web-live-dom-probe.mjs status <jobId>"
|
||||
],
|
||||
output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata."
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user