diff --git a/package.json b/package.json index e0ba88eb..a6fa370a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 7fdc0b1e..c91cf91a 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -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", () => { diff --git a/scripts/playwright-launch-guard.mjs b/scripts/playwright-launch-guard.mjs new file mode 100644 index 00000000..36daa6c4 --- /dev/null +++ b/scripts/playwright-launch-guard.mjs @@ -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." + }; +} diff --git a/scripts/src/browser-launcher.mjs b/scripts/src/browser-launcher.mjs index 3f77fa11..984cc68a 100644 --- a/scripts/src/browser-launcher.mjs +++ b/scripts/src/browser-launcher.mjs @@ -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)}...` : text; +} diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 017d5f94..82bc276a 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -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"] }, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 0cc0a827..c97b90b7 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.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(); } diff --git a/scripts/src/playwright-launch-guard.mjs b/scripts/src/playwright-launch-guard.mjs new file mode 100644 index 00000000..c1c30a6f --- /dev/null +++ b/scripts/src/playwright-launch-guard.mjs @@ -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("/"); +} diff --git a/scripts/web-live-dom-probe.mjs b/scripts/web-live-dom-probe.mjs new file mode 100644 index 00000000..0e0ad289 --- /dev/null +++ b/scripts/web-live-dom-probe.mjs @@ -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 " + ], + output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata." + }; +}