diff --git a/scripts/web-live-dom-probe.mjs b/scripts/web-live-dom-probe.mjs index 0e0ad289..9c2fb590 100644 --- a/scripts/web-live-dom-probe.mjs +++ b/scripts/web-live-dom-probe.mjs @@ -45,6 +45,9 @@ export function parseProbeArgs(argv) { 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, reportPath: tempReportPath("web-live-dom-probe.json"), screenshotPath: tempReportPath("web-live-dom-probe.png"), jobId: "" @@ -65,10 +68,13 @@ export function parseProbeArgs(argv) { 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 === "--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; @@ -92,11 +98,14 @@ export async function runProbe(args) { 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 maybeClick(page, "#session-create", actions, "fresh-session"); if (args.message) await submitPrompt(page, args, actions); + await waitForMessageCards(page, args, actions); await page.screenshot({ path: screenshotPath, fullPage: true }); const dom = await collectDomEvidence(page); result = { @@ -115,6 +124,8 @@ export async function runProbe(args) { safety: { repoOwnedLauncher: true, directChromiumLaunch: false, + projectId: args.projectId, + conversationSelected: Boolean(args.conversationId), freshSessionRequested: args.freshSession, promptSubmitted: Boolean(args.message), cancelRunningRequested: args.cancelRunning @@ -156,6 +167,7 @@ async function startProbe(args) { "--user", args.user, "--pass", args.pass, "--viewport", args.viewport, + "--project-id", args.projectId, "--report", reportPath, "--screenshot", screenshotPath, "--timeout-ms", String(args.timeoutMs), @@ -163,6 +175,7 @@ async function startProbe(args) { ]; 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"); @@ -180,6 +193,8 @@ async function startProbe(args) { pid: child.pid, startedAt: new Date().toISOString(), url: args.url, + projectId: args.projectId, + conversationId: args.conversationId || null, reportPath, screenshotPath, stdoutPath, @@ -216,12 +231,34 @@ async function probeStatus(args) { async function maybeLogin(page, args, actions) { const username = page.locator("#login-username"); - if (!(await username.isVisible({ timeout: 2500 }).catch(() => false))) return; + 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) { @@ -244,6 +281,77 @@ async function submitPrompt(page, args, actions) { if (args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message"); } +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 collectDomEvidence(page) { return page.evaluate(() => { const workspace = document.querySelector("#workspace"); @@ -258,6 +366,7 @@ async function collectDomEvidence(page) { } : null; return { title: document.title, + authState: document.body.getAttribute("data-auth-state"), requiredSelectors: { workspace: Boolean(workspace), commandInput: Boolean(document.querySelector("#command-input")), @@ -266,7 +375,38 @@ async function collectDomEvidence(page) { 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"), + 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(); + 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) + } : { present: false } + }; + }), scroll, textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800) }; @@ -307,6 +447,7 @@ function helpPayload() { 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 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 " ],