#!/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, projectId: process.env.HWLAB_WEB_PROBE_PROJECT_ID ?? "prj_hwpod_workbench", conversationId: process.env.HWLAB_WEB_PROBE_CONVERSATION_ID ?? "", waitMessagesMs: 2500, waitAgentTerminalMs: Number(process.env.HWLAB_WEB_PROBE_WAIT_AGENT_TERMINAL_MS ?? "0"), 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 === "--project-id" || arg.startsWith("--project-id=")) args.projectId = readValue("--project-id"); else if (arg === "--conversation-id" || arg.startsWith("--conversation-id=")) args.conversationId = readValue("--conversation-id"); 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 === "--wait-messages-ms" || arg.startsWith("--wait-messages-ms=")) args.waitMessagesMs = positiveInteger(readValue("--wait-messages-ms"), "--wait-messages-ms"); else if (arg === "--wait-agent-terminal-ms" || arg.startsWith("--wait-agent-terminal-ms=")) args.waitAgentTerminalMs = positiveInteger(readValue("--wait-agent-terminal-ms"), "--wait-agent-terminal-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 waitForAuthBootstrap(page, args, actions); 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.conversationId) await selectConversationInBrowserSession(page, args, actions); if (args.freshSession) await createFreshSession(page, args, actions); if (args.message) await submitPrompt(page, args, actions); if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions); await waitForMessageCards(page, args, actions); await page.screenshot({ path: screenshotPath, fullPage: true }); const dom = await collectDomEvidence(page); const promptValidation = validatePromptFinalResponse(dom, args); result = { ok: true, status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList && promptValidation.ok ? "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, promptValidation, artifacts: { reportPath, screenshotPath }, safety: { repoOwnedLauncher: true, directChromiumLaunch: false, projectId: args.projectId, conversationSelected: Boolean(args.conversationId), 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; } function validatePromptFinalResponse(dom, args) { if (!args.message) return { ok: true, applicable: false, reason: "no-prompt-submitted" }; const messages = Array.isArray(dom.messages) ? dom.messages : []; const latestAgent = [...messages].reverse().find((message) => message.role === "agent"); const finalResponse = latestAgent?.finalResponse ?? null; const markdown = finalResponse?.markdown ?? null; const failures = []; if (!latestAgent) failures.push("agent-message-missing"); if (latestAgent?.status !== "completed") failures.push("agent-not-completed"); if (finalResponse?.present !== true) failures.push("final-response-missing"); if (!finalResponse || Number(finalResponse.textChars ?? 0) <= 0) failures.push("final-response-empty"); if (Number(markdown?.headings ?? 0) <= 0) failures.push("markdown-heading-missing"); if (Number(markdown?.lists ?? 0) <= 0 || Number(markdown?.listItems ?? 0) <= 0) failures.push("markdown-list-missing"); if (Number(markdown?.codeBlocks ?? 0) <= 0) failures.push("markdown-code-block-missing"); return { ok: failures.length === 0, applicable: true, failures, finalResponse: finalResponse ? { textChars: finalResponse.textChars ?? null, textPreview: finalResponse.textPreview ?? null, markdown: finalResponse.markdown ?? null } : null }; } 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, "--project-id", args.projectId, "--report", reportPath, "--screenshot", screenshotPath, "--timeout-ms", String(args.timeoutMs), "--wait-after-submit-ms", String(args.waitAfterSubmitMs), "--wait-agent-terminal-ms", String(args.waitAgentTerminalMs) ]; if (args.freshSession) childArgs.push("--fresh-session"); if (!args.cancelRunning) childArgs.push("--no-cancel-running"); if (args.conversationId) childArgs.push("--conversation-id", args.conversationId); 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, projectId: args.projectId, conversationId: args.conversationId || null, 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: Math.min(args.timeoutMs, 12000) }).catch(() => false))) { actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); 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 }); const finalUrl = new URL(page.url()); if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) { throw new Error("login credentials leaked into URL query"); } } async function waitForAuthBootstrap(page, args, actions) { const timeout = Math.min(args.timeoutMs, 12000); const settled = await page.waitForFunction(() => { const authState = document.body.getAttribute("data-auth-state"); const loginVisible = Boolean(document.querySelector("#login-username")); const workspaceVisible = Boolean(document.querySelector("#workspace")); return authState !== "checking" || loginVisible || workspaceVisible; }, null, { timeout }).catch(() => null); actions.push({ action: "auth-bootstrap", settled: Boolean(settled), authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) }); } 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 createFreshSession(page, args, actions) { const selector = "#session-create"; const locator = page.locator(selector).first(); if (!(await locator.isVisible({ timeout: 3000 }).catch(() => false))) { actions.push({ action: "fresh-session", selector, skipped: "not-visible" }); return false; } const shellReady = await waitForSessionShellReady(page, args, actions); const before = await collectSessionState(page); await locator.click(); const timeoutMs = Math.min(args.timeoutMs, 30000); const settled = await page.waitForFunction((initial) => { const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? null; const changed = Boolean(statusText && statusText !== initial.statusText && !/加载中|等待 workspace/u.test(statusText)); const messageCount = document.querySelectorAll(".message-card").length; const emptyVisible = Boolean(document.querySelector("[data-conversation-empty]")); const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); return changed && messageCount === 0 && emptyVisible && input && send && !input.disabled && !send.disabled; }, before, { timeout: timeoutMs }).catch(() => null); const after = await collectSessionState(page); actions.push({ action: "fresh-session", selector, settled: Boolean(settled), timeoutMs, shellReady, before, after }); if (!settled) throw new Error(`fresh session did not settle: ${JSON.stringify({ before, after, timeoutMs })}`); return true; } async function waitForSessionShellReady(page, args, actions) { const timeoutMs = Math.min(args.timeoutMs, 30000); const before = await collectSessionState(page); const ready = await page.waitForFunction(() => { const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? ""; const workspace = document.querySelector("#workspace"); const create = document.querySelector("#session-create"); return Boolean(workspace && create && !create.disabled && statusText && !/加载中/u.test(statusText)); }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectSessionState(page); const result = { action: "session-shell-ready", ready: Boolean(ready), timeoutMs, before, after }; actions.push(result); if (!ready) throw new Error(`session shell did not finish initial loading: ${JSON.stringify(result)}`); return after; } async function collectSessionState(page) { return page.evaluate(() => { const activeTab = document.querySelector(".session-tab[aria-selected='true']"); return { statusText: document.querySelector("#session-status")?.textContent?.trim() ?? null, activeTabTitle: activeTab?.getAttribute("title") ?? null, tabCount: document.querySelectorAll(".session-tab").length, messageCount: document.querySelectorAll(".message-card").length, emptyVisible: Boolean(document.querySelector("[data-conversation-empty]")), command: { inputDisabled: Boolean(document.querySelector("#command-input")?.disabled), sendDisabled: Boolean(document.querySelector("#command-send")?.disabled), sendText: document.querySelector("#command-send")?.textContent?.trim() ?? null, formTitle: document.querySelector("#command-form")?.getAttribute("title") ?? null } }; }); } async function submitPrompt(page, args, actions) { const readyBefore = await waitForCommandReady(page, args, actions); await page.locator("#command-input").fill(args.message); await page.waitForFunction((expected) => document.querySelector("#command-input")?.value === expected, args.message, { timeout: Math.min(args.timeoutMs, 5000) }); const afterFill = await collectCommandState(page); await page.locator("#command-send").click(); const afterClick = await collectCommandState(page); actions.push({ action: "submit-prompt", chars: args.message.length, readyBefore, afterFill, afterClick }); await page.waitForTimeout(args.waitAfterSubmitMs); if (args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message"); } async function waitForCommandReady(page, args, actions) { const timeoutMs = Math.min(args.timeoutMs, 30000); const before = await collectCommandState(page); const ready = await page.waitForFunction(() => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); return Boolean(input && send && !input.disabled && !send.disabled); }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectCommandState(page); const result = { action: "command-ready", ready: Boolean(ready), timeoutMs, before, after }; actions.push(result); if (!ready) throw new Error(`command bar is not ready for submit: ${JSON.stringify(result)}`); return after; } async function collectCommandState(page) { return page.evaluate(() => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); const form = document.querySelector("#command-form"); return { inputVisible: Boolean(input), inputDisabled: input ? Boolean(input.disabled) : null, inputLength: input && typeof input.value === "string" ? input.value.length : null, sendVisible: Boolean(send), sendDisabled: send ? Boolean(send.disabled) : null, sendText: send?.textContent?.trim() ?? null, formTitle: form?.getAttribute("title") ?? null, messageCount: document.querySelectorAll(".message-card").length, authState: document.body.getAttribute("data-auth-state") }; }); } async function selectConversationInBrowserSession(page, args, actions) { const result = await page.evaluate(async ({ projectId, conversationId }) => { const summarizeAuthSession = (body) => ({ authenticated: body?.authenticated ?? null, userId: body?.user?.id ?? body?.actor?.id ?? null, username: body?.user?.username ?? body?.actor?.username ?? null, role: body?.user?.role ?? body?.actor?.role ?? null, status: body?.user?.status ?? body?.actor?.status ?? null }); const summarizeWorkspace = (workspace) => workspace ? { workspaceId: workspace.workspaceId ?? null, ownerUserId: workspace.ownerUserId ?? null, projectId: workspace.projectId ?? null, selectedConversationId: workspace.selectedConversationId ?? null, selectedAgentSessionId: workspace.selectedAgentSessionId ?? null, selectedConversationStatus: workspace.selectedConversation?.status ?? null, selectedMessagesCount: workspace.selectedConversation?.messages?.length ?? workspace.selectedConversation?.messagesCount ?? null, revision: workspace.revision ?? null } : null; const sessionResponse = await fetch("/auth/session", { credentials: "same-origin" }); const sessionBody = await sessionResponse.json().catch(() => null); const workspaceResponse = await fetch(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" }); const workspaceBody = await workspaceResponse.json().catch(() => null); const workspaceId = workspaceBody?.workspace?.workspaceId; if (!workspaceId) return { ok: false, phase: "workspace", status: workspaceResponse.status, workspaceId: null, authSession: summarizeAuthSession(sessionBody), workspace: summarizeWorkspace(workspaceBody?.workspace), bodyStatus: workspaceBody?.status ?? null, error: workspaceBody?.error ?? null }; const selectResponse = await fetch(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, conversationId, updatedByClient: "web-live-dom-probe" }) }); const selectBody = await selectResponse.json().catch(() => null); return { ok: selectResponse.ok, phase: "select-conversation", status: selectResponse.status, workspaceId, authSession: summarizeAuthSession(sessionBody), workspace: summarizeWorkspace(workspaceBody?.workspace), responseStatus: selectBody?.status ?? null, error: selectBody?.error ?? null, conversationId: selectBody?.workspace?.selectedConversationId ?? conversationId, selectedMessageCount: selectBody?.workspace?.selectedConversation?.messages?.length ?? selectBody?.workspace?.selectedConversation?.messagesCount ?? null, sessionStatus: selectBody?.workspace?.selectedConversation?.status ?? selectBody?.workspace?.sessionStatus ?? null }; }, { projectId: args.projectId, conversationId: args.conversationId }); actions.push({ action: "select-conversation", ...result }); if (!result.ok) throw new Error(`browser session select-conversation failed: ${JSON.stringify(result)}`); await page.reload({ waitUntil: "domcontentloaded", timeout: args.timeoutMs }); await waitForAuthBootstrap(page, args, actions); await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs }); await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs }); } async function waitForMessageCards(page, args, actions) { if (!args.waitMessagesMs) return; const before = await page.locator(".message-card").count().catch(() => 0); const found = await page.waitForFunction(() => document.querySelectorAll(".message-card").length > 0, null, { timeout: args.waitMessagesMs }).catch(() => null); const after = await page.locator(".message-card").count().catch(() => 0); actions.push({ action: "wait-message-cards", before, after, found: Boolean(found), timeoutMs: args.waitMessagesMs }); } async function waitForAgentTerminal(page, args, actions) { const timeoutMs = Math.min(args.waitAgentTerminalMs, args.timeoutMs); const before = await collectLatestAgentMessageState(page); const terminal = await page.waitForFunction(() => { const cards = [...document.querySelectorAll(".message-card")]; const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent")); if (!latestAgent) return false; const status = latestAgent.getAttribute("data-message-status") ?? ""; const finalResponse = latestAgent.querySelector(".message-final-response"); const text = finalResponse?.textContent?.trim() ?? ""; return status && status !== "running" && text.length > 0; }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectLatestAgentMessageState(page); actions.push({ action: "wait-agent-terminal", terminal: Boolean(terminal), timeoutMs, before, after }); if (!terminal) throw new Error(`agent final response did not reach terminal DOM state: ${JSON.stringify({ before, after, timeoutMs })}`); } async function collectLatestAgentMessageState(page) { return page.evaluate(() => { const cards = [...document.querySelectorAll(".message-card")]; const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent")); const finalResponse = latestAgent?.querySelector(".message-final-response"); return { messageCount: cards.length, status: latestAgent?.getAttribute("data-message-status") ?? null, textChars: finalResponse?.textContent?.trim().length ?? 0, textPreview: (finalResponse?.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 240) }; }); } 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, authState: document.body.getAttribute("data-auth-state"), 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 === "message-user" || item === "message-agent" || item === "message-system") ?? "unknown"), messages: cards.map((card) => { const trace = card.querySelector(".message-trace"); const traceEvents = card.querySelector(".message-trace-events"); const finalResponse = card.querySelector(".message-final-response"); const traceStyle = traceEvents ? getComputedStyle(traceEvents) : null; const finalStyle = finalResponse ? getComputedStyle(finalResponse) : null; const traceRect = traceEvents?.getBoundingClientRect(); const finalRect = finalResponse?.getBoundingClientRect(); const finalBody = finalResponse?.querySelector(".message-body"); return { status: card.getAttribute("data-message-status") ?? null, role: card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system")?.replace(/^message-/, "") ?? null, className: card.className, trace: trace ? { present: true, open: trace instanceof HTMLDetailsElement ? trace.open : null, status: trace.getAttribute("data-trace-status"), mode: trace.getAttribute("data-trace-mode"), maxHeight: traceStyle?.maxHeight ?? null, overflowY: traceStyle?.overflowY ?? null, height: traceRect ? Math.round(traceRect.height) : null, scrollHeight: traceEvents ? Math.round(traceEvents.scrollHeight) : null } : { present: false }, finalResponse: finalResponse ? { present: true, maxHeight: finalStyle?.maxHeight ?? null, overflowY: finalStyle?.overflowY ?? null, height: finalRect ? Math.round(finalRect.height) : null, scrollHeight: Math.round(finalResponse.scrollHeight), textChars: finalResponse.textContent?.trim().length ?? 0, textPreview: (finalResponse.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 500), markdown: finalBody ? { paragraphs: finalBody.querySelectorAll("p").length, headings: finalBody.querySelectorAll("h1,h2,h3,h4,h5,h6").length, lists: finalBody.querySelectorAll("ul,ol").length, listItems: finalBody.querySelectorAll("li").length, codeBlocks: finalBody.querySelectorAll("pre code").length, inlineCode: finalBody.querySelectorAll("p code,li code").length, tables: finalBody.querySelectorAll("table").length } : null } : { present: false } }; }), 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 --message '...' --no-cancel-running --wait-agent-terminal-ms 180000", "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/#/workspace --conversation-id cnv_...", "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." }; }