Files
pikasTech-HWLAB/scripts/web-live-dom-probe.mjs
T
2026-06-17 23:48:45 +08:00

1409 lines
71 KiB
JavaScript

#!/usr/bin/env node
// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针
import { createHash } from "node:crypto";
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 ?? "",
credentials: {
userSource: process.env.HWLAB_WEB_USER ? "env:HWLAB_WEB_USER" : "default",
passSource: process.env.HWLAB_WEB_PASS ? "env:HWLAB_WEB_PASS" : "unresolved",
valuesRedacted: true
},
credentialError: null,
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"),
traceSampleCount: envNonNegativeInteger("HWLAB_WEB_PROBE_TRACE_SAMPLE_COUNT", 0),
traceSampleIntervalMs: envNonNegativeInteger("HWLAB_WEB_PROBE_TRACE_SAMPLE_INTERVAL_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");
args.credentials.userSource = "cli:--user";
}
else if (arg === "--pass" || arg.startsWith("--pass=")) {
args.pass = readValue("--pass");
args.credentials.passSource = "cli:--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 === "--trace-sample-count" || arg.startsWith("--trace-sample-count=")) args.traceSampleCount = nonNegativeInteger(readValue("--trace-sample-count"), "--trace-sample-count");
else if (arg === "--trace-sample-interval-ms" || arg.startsWith("--trace-sample-interval-ms=")) args.traceSampleIntervalMs = nonNegativeInteger(readValue("--trace-sample-interval-ms"), "--trace-sample-interval-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}`);
}
resolveProbeCredentials(args);
return args;
}
function resolveProbeCredentials(args) {
if (args.command === "help" || args.command === "status") return;
if (args.pass) return;
const target = inferProbeCredentialTarget(args.url);
if (!target) {
args.pass = "hwlab2026";
args.credentials = { ...args.credentials, passSource: "legacy-default", valuesRedacted: true };
return;
}
if (args.credentials.userSource === "default") {
args.user = target.username;
args.credentials.userSource = "target-default";
}
const material = readProbeSecretSource(target.sourceRef, target.sourceKey);
args.credentials = {
...args.credentials,
node: target.node,
lane: target.lane,
sourceRef: target.sourceRef,
sourceKey: target.sourceKey,
sourcePath: material.sourcePath ? displayProbeSecretPath(material.sourcePath) : null,
sourcePresent: material.present,
sourceFingerprint: material.fingerprint,
passSource: material.ok ? "secret-source" : "missing-secret-source",
valuesRedacted: true
};
if (material.ok) {
args.pass = material.value;
return;
}
args.credentialError = {
code: "web_login_secret_missing",
message: `Web login secret source is missing for ${target.node}/${target.lane}`,
reason: material.reason
};
}
function inferProbeCredentialTarget(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.hostname === "hwlab.pikapython.com") {
return {
node: "D601",
lane: "v03",
username: "admin",
sourceRef: "hwlab/d601-v03-bootstrap-admin.env",
sourceKey: "HWLAB_BOOTSTRAP_ADMIN_PASSWORD"
};
}
if (parsed.hostname === "74.48.78.17" && (parsed.port === "20666" || parsed.port === "20667")) {
return {
node: "G14",
lane: "v03",
username: "admin",
sourceRef: "hwlab/g14-v03-bootstrap-admin.env",
sourceKey: "HWLAB_BOOTSTRAP_ADMIN_PASSWORD"
};
}
return null;
}
function readProbeSecretSource(sourceRef, sourceKey) {
const sourcePath = probeSecretSourcePaths(sourceRef).find((candidate) => fs.existsSync(candidate));
if (!sourcePath) return { ok: false, present: false, sourcePath: probeSecretSourcePaths(sourceRef)[0] ?? null, fingerprint: null, reason: "secret-source-missing" };
const values = parseEnvFile(fs.readFileSync(sourcePath, "utf8"));
const value = values[sourceKey];
if (!value) return { ok: false, present: true, sourcePath, fingerprint: null, reason: "secret-key-missing" };
return { ok: true, present: true, sourcePath, value, fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`, reason: null };
}
function probeSecretSourcePaths(sourceRef) {
const roots = [
process.env.HWLAB_WEB_SECRET_SOURCE_ROOT,
process.env.UNIDESK_SECRET_SOURCE_ROOT,
path.join(repoRoot, ".state", "secrets"),
repoRoot.includes("/.worktree/") ? path.join(repoRoot.slice(0, repoRoot.indexOf("/.worktree/")), ".state", "secrets") : null,
"/root/unidesk/.state/secrets"
].filter(Boolean);
return [...new Set(roots.map((root) => path.join(root, sourceRef)))];
}
function parseEnvFile(text) {
const values = {};
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const rawValue = line.slice(eq + 1).trim();
values[key] = rawValue.startsWith("'") && rawValue.endsWith("'")
? rawValue.slice(1, -1).replace(/'"'"'/gu, "'")
: rawValue.startsWith('"') && rawValue.endsWith('"')
? rawValue.slice(1, -1)
: rawValue;
}
return values;
}
function displayProbeSecretPath(sourcePath) {
if (sourcePath.startsWith(`${repoRoot}/`)) return sourcePath.slice(repoRoot.length + 1);
const unideskRoot = "/root/unidesk/";
if (sourcePath.startsWith(unideskRoot)) return sourcePath.slice(unideskRoot.length);
return sourcePath;
}
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 page;
let result;
let traceSamples = [];
let sessionSummary = null;
let traceSummary = null;
let performanceSummary = null;
if (args.credentialError) {
result = {
ok: false,
status: "blocked",
command: "web-live-dom-probe run",
generatedAt: new Date().toISOString(),
startedAt,
url: args.url,
actions,
credentials: args.credentials,
error: args.credentialError.code,
credentialError: args.credentialError,
artifacts: { reportPath, screenshotPath }
};
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
result.artifacts = {
...(result.artifacts ?? {}),
reportPath,
reportSha256: await fileSha256(reportPath),
screenshotPath,
screenshotSha256: await fileSha256(screenshotPath)
};
return result;
}
try {
const { chromium } = await importPlaywright();
browser = await launchChromium(chromium);
const context = await browser.newContext({ viewport: { width, height } });
page = await context.newPage();
const bootstrapRoutes = observeBootstrapRoutes(page);
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.message && args.traceSampleCount > 0) traceSamples = await sampleTraceTimeline(page, args, actions);
if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions);
if (args.message && args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message");
await waitForMessageCards(page, args, actions);
await page.screenshot({ path: screenshotPath, fullPage: true });
const dom = await collectDomEvidence(page, args);
const promptValidation = validatePromptFinalResponse(dom, args);
sessionSummary = summarizeSessionRun(actions, dom);
traceSummary = summarizeTraceRun(traceSamples, dom, sessionSummary);
performanceSummary = summarizeProbePerformance(actions, startedAt, traceSamples, traceSummary);
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,
credentials: args.credentials,
bootstrapRoutes: summarizeBootstrapRoutes(bootstrapRoutes),
session: sessionSummary,
trace: traceSummary,
performance: performanceSummary,
traceSamples,
dom,
promptValidation,
degradedReason: runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation }),
artifacts: { reportPath, screenshotPath },
safety: {
repoOwnedLauncher: true,
directChromiumLaunch: false,
projectId: args.projectId,
conversationSelected: Boolean(args.conversationId),
freshSessionRequested: args.freshSession,
promptSubmitted: Boolean(args.message),
traceSamplingRequested: args.traceSampleCount > 0,
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,
credentials: args.credentials,
error: error instanceof Error ? error.message : String(error),
degradedReason: classifyRunProbeError(error),
finalUrl: page ? page.url() : null,
failureDom: page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null,
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");
result.artifacts = {
...(result.artifacts ?? {}),
reportPath,
reportSha256: await fileSha256(reportPath),
screenshotPath,
screenshotSha256: await fileSha256(screenshotPath)
};
return result;
}
async function collectFailureDomEvidence(page) {
return page.evaluate(() => {
const text = (selector) => document.querySelector(selector)?.textContent?.trim() ?? null;
const bodyText = document.body?.innerText?.replace(/\s+/gu, " ").trim() ?? "";
return {
authState: document.body?.getAttribute("data-auth-state") ?? null,
title: document.title || null,
locationPath: window.location.pathname,
login: {
cardVisible: Boolean(document.querySelector(".login-card")),
usernameVisible: Boolean(document.querySelector('#login-username, input[autocomplete="username"]')),
passwordVisible: Boolean(document.querySelector('#login-password, input[autocomplete="current-password"], input[type="password"]')),
errorText: text(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")
},
requiredSelectors: {
workspace: Boolean(document.querySelector("#workspace")),
commandInput: Boolean(document.querySelector("#command-input")),
conversationList: Boolean(document.querySelector("#conversation-list"))
},
bodyTextPreview: bodyText.slice(0, 500)
};
});
}
function validatePromptFinalResponse(dom, args) {
if (!args.message) return { ok: true, applicable: false, reason: "no-prompt-submitted" };
if (args.traceSampleCount > 0 && !args.waitAgentTerminalMs) return { ok: true, applicable: false, reason: "trace-sampling-without-terminal-wait" };
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) {
if (args.credentialError) {
return {
ok: false,
status: "blocked",
command: "web-live-dom-probe start",
error: args.credentialError.code,
credentialError: args.credentialError,
credentials: args.credentials
};
}
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,
"--viewport", args.viewport,
"--project-id", args.projectId,
"--report", reportPath,
"--screenshot", screenshotPath,
"--timeout-ms", String(args.timeoutMs),
"--wait-after-submit-ms", String(args.waitAfterSubmitMs),
"--wait-messages-ms", String(args.waitMessagesMs)
];
if (args.waitAgentTerminalMs > 0) childArgs.push("--wait-agent-terminal-ms", String(args.waitAgentTerminalMs));
if (args.traceSampleCount > 0) childArgs.push("--trace-sample-count", String(args.traceSampleCount), "--trace-sample-interval-ms", String(args.traceSampleIntervalMs));
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,
env: { ...process.env, HWLAB_WEB_USER: args.user, HWLAB_WEB_PASS: args.pass },
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,
traceSampling: args.traceSampleCount > 0 ? { count: args.traceSampleCount, intervalMs: args.traceSampleIntervalMs } : 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)) {
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, selectorMode: "legacy-id" });
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");
}
return;
}
const loginSurface = await waitForLoginSurface(page, args);
if (loginSurface === "workspace") {
actions.push({ action: "login", skipped: "already-authenticated", selectorMode: "workspace", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
if (loginSurface === "legacy-id") {
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, selectorMode: "legacy-id-delayed" });
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");
}
return;
}
if (loginSurface !== "login-card") {
actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
await submitCurrentLoginForm(page, args, actions, "current-autocomplete");
}
async function waitForLoginSurface(page, args) {
const handle = await page.waitForFunction(() => {
if (document.querySelector("#workspace")) return "workspace";
if (document.querySelector("#login-username")) return "legacy-id";
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
if (document.querySelector(".login-card input")) return "login-card";
return "";
}, null, { timeout: args.timeoutMs }).catch(() => null);
if (!handle) return null;
return handle.jsonValue().catch(() => null);
}
async function submitCurrentLoginForm(page, args, actions, selectorMode) {
const username = page.locator('input[autocomplete="username"], .login-card input').first();
const password = page.locator('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
const submit = page.locator('.login-card button[type="submit"], button[type="submit"]').first();
const inputCount = await page.locator(".login-card input, input[autocomplete=\"username\"], input[autocomplete=\"current-password\"]").count().catch(() => 0);
if (!(await username.isVisible({ timeout: 5000 }).catch(() => false)) || !(await password.isVisible({ timeout: 5000 }).catch(() => false))) {
actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode, inputCount, authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
return;
}
await username.fill(args.user);
await password.fill(args.pass);
const valuesReady = await waitForCurrentLoginValues(page, args);
await Promise.allSettled([
page.waitForURL(/\/workbench/u, { timeout: Math.min(args.timeoutMs, 15000) }),
submit.click()
]);
actions.push({ action: "login", user: args.user, selectorMode, valuesReady });
const outcome = await waitForLoginOutcome(page, args);
if (outcome?.kind === "error") throw new Error(`login failed: ${outcome.errorText || "unknown login error"}`);
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
assertNoCredentialLeak(page.url());
}
async function waitForCurrentLoginValues(page, args) {
const ready = await page.waitForFunction((expected) => {
const username = document.querySelector('input[autocomplete="username"], .login-card input');
const password = document.querySelector('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]');
const submit = document.querySelector('.login-card button[type="submit"], button[type="submit"]');
return Boolean(username && password && username.value === expected.user && password.value.length === expected.passwordLength && !submit?.disabled);
}, { user: args.user, passwordLength: args.pass.length }, { timeout: Math.min(args.timeoutMs, 5000) }).catch(() => null);
return Boolean(ready);
}
async function waitForLoginOutcome(page, args) {
const handle = await page.waitForFunction(() => {
if (document.querySelector("#command-input")) return { kind: "workspace" };
const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim();
if (errorText) return { kind: "error", errorText };
return null;
}, null, { timeout: Math.min(args.timeoutMs, 15000) }).catch(() => null);
if (!handle) return null;
return handle.jsonValue().catch(() => null);
}
function assertNoCredentialLeak(value) {
const finalUrl = new URL(value);
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") || document.querySelector('input[autocomplete="username"]') || document.querySelector(".login-card input"));
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" });
throw new Error("fresh session create button is not visible");
}
const shellReady = await waitForSessionShellReady(page, args, actions);
const before = await collectSessionState(page, args);
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 statusReady = Boolean(statusText && !/加载中|等待 workspace/u.test(statusText));
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const activeTitle = activeTab?.querySelector("strong")?.textContent?.trim() ?? activeTab?.getAttribute("title") ?? null;
const activeChanged = Boolean(activeTitle && activeTitle !== initial.activeTabTitle);
const statusChanged = Boolean(statusText && statusText !== initial.statusText);
const messageCount = document.querySelectorAll(".message-card").length;
const input = document.querySelector("#command-input");
return statusReady && (activeChanged || statusChanged) && messageCount === 0 && input && !input.disabled;
}, before, { timeout: timeoutMs }).catch(() => null);
const alignment = await waitForFreshSessionAlignment(page, args, before, timeoutMs);
const after = alignment.after;
actions.push({ action: "fresh-session", selector, settled: Boolean(settled), aligned: alignment.ok, timeoutMs, shellReady, before, after, alignment });
if (!settled || !alignment.ok) throw new Error(`fresh session did not settle: ${JSON.stringify({ settled: Boolean(settled), alignment, timeoutMs })}`);
return true;
}
async function waitForFreshSessionAlignment(page, args, before, timeoutMs) {
const startedAt = Date.now();
const attempts = [];
let after = await collectSessionState(page, args);
let bestCandidate = null;
while (Date.now() - startedAt < timeoutMs) {
const check = freshSessionAlignment(before, after);
bestCandidate = bestCandidate ?? freshSessionCandidate(before, after);
attempts.push({ elapsedMs: Date.now() - startedAt, ok: check.ok, reason: check.reason, conversationId: check.conversationId, routeConversationId: check.routeConversationId, selectedConversationId: check.selectedConversationId, apiConversationPresent: check.apiConversationPresent });
if (check.ok) return { ok: true, reason: null, attempts, after, check };
await page.waitForTimeout(500);
after = await collectSessionState(page, args);
}
const check = freshSessionAlignment(before, after);
bestCandidate = bestCandidate ?? freshSessionCandidate(before, after);
attempts.push({ elapsedMs: Date.now() - startedAt, ok: check.ok, reason: check.reason, conversationId: check.conversationId, routeConversationId: check.routeConversationId, selectedConversationId: check.selectedConversationId, apiConversationPresent: check.apiConversationPresent });
if (!check.ok && bestCandidate?.conversationId) {
const repair = await realignFreshSession(page, args, bestCandidate.conversationId);
after = await collectSessionState(page, args);
const repaired = freshSessionAlignment(before, after);
return { ok: repaired.ok, reason: repaired.reason, attempts, after, check: repaired, repair };
}
return { ok: false, reason: check.reason, attempts, after, check };
}
function freshSessionCandidate(before, after) {
const previousConversationId = firstNonEmpty(before?.routeConversationId, before?.api?.workspace?.selectedConversationId, before?.activeTab?.conversationId);
const items = Array.isArray(after?.api?.conversations?.items) ? after.api.conversations.items : [];
return items.find((item) => item.conversationId && item.conversationId !== previousConversationId && Number(item.messagesCount ?? 0) === 0) ?? null;
}
async function realignFreshSession(page, args, conversationId) {
const selected = await selectConversationViaApi(page, args, conversationId, "web-live-dom-probe-fresh-realign");
const target = new URL(`/workbench/sessions/${encodeURIComponent(conversationId)}`, args.url).toString();
await page.goto(target, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs });
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
return { action: "fresh-session-realign", conversationId, target, selected };
}
async function selectConversationViaApi(page, args, conversationId, updatedByClient) {
return page.evaluate(async ({ projectId, conversationId, updatedByClient }) => {
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, 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 })
});
const selectBody = await selectResponse.json().catch(() => null);
return {
ok: selectResponse.ok,
phase: "select-conversation",
status: selectResponse.status,
workspaceId,
conversationId: selectBody?.workspace?.selectedConversationId ?? conversationId,
revision: selectBody?.workspace?.revision ?? null,
error: selectBody?.error ?? null
};
}, { projectId: args.projectId, conversationId, updatedByClient });
}
function freshSessionAlignment(before, after) {
const previousConversationId = firstNonEmpty(before?.routeConversationId, before?.api?.workspace?.selectedConversationId, before?.activeTab?.conversationId);
const conversationId = firstNonEmpty(after?.routeConversationId, after?.api?.workspace?.selectedConversationId, after?.activeTab?.conversationId);
const selectedConversationId = after?.api?.workspace?.selectedConversationId ?? null;
const routeConversationId = after?.routeConversationId ?? null;
const apiConversation = conversationId ? after?.api?.conversations?.items?.find((item) => item.conversationId === conversationId) ?? null : null;
const routeMatches = Boolean(routeConversationId && conversationId && routeConversationId === conversationId);
const workspaceMatches = Boolean(selectedConversationId && conversationId && selectedConversationId === conversationId);
const apiConversationPresent = Boolean(apiConversation);
const activeTabMatches = !after?.activeTab?.conversationId || after.activeTab.conversationId === conversationId;
const changed = Boolean(conversationId && conversationId !== previousConversationId);
const commandReady = after?.command?.inputDisabled === false;
const empty = after?.messageCount === 0;
const ok = changed && routeMatches && workspaceMatches && apiConversationPresent && activeTabMatches && commandReady && empty;
const reason = ok ? null
: !changed ? "fresh-session-conversation-not-changed"
: !routeMatches ? "fresh-session-route-not-aligned"
: !workspaceMatches ? "fresh-session-workspace-not-aligned"
: !apiConversationPresent ? "fresh-session-conversation-api-missing"
: !activeTabMatches ? "fresh-session-active-tab-not-aligned"
: !commandReady ? "fresh-session-command-not-ready"
: "fresh-session-not-empty";
return { ok, reason, previousConversationId, conversationId, routeConversationId, selectedConversationId, apiConversationPresent, activeTabConversationId: after?.activeTab?.conversationId ?? null, commandReady, command: after?.command ?? null, messageCount: after?.messageCount ?? null };
}
async function waitForSessionShellReady(page, args, actions) {
const timeoutMs = Math.min(args.timeoutMs, 30000);
const before = await collectSessionState(page, args);
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 && !/加载中|等待 workspace/u.test(statusText));
}, null, { timeout: timeoutMs }).catch(() => null);
const after = await collectSessionState(page, args);
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, args = null) {
const dom = await page.evaluate(() => {
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) ?? window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
return {
finalUrl: window.location.href,
locationPath: window.location.pathname,
routeConversationId: routeMatch ? decodeURIComponent(routeMatch[1]) : null,
statusText: document.querySelector("#session-status")?.textContent?.trim() ?? null,
activeTabTitle: activeTab?.querySelector("strong")?.textContent?.trim() ?? activeTab?.getAttribute("title") ?? null,
activeTab: activeTab ? {
title: activeTab.querySelector("strong")?.textContent?.trim() ?? activeTab.getAttribute("title") ?? null,
conversationId: activeTab.getAttribute("data-conversation-id") ?? null,
sessionId: activeTab.getAttribute("data-session-id") ?? null,
status: activeTab.getAttribute("data-status") ?? null,
running: activeTab.getAttribute("data-running") ?? null
} : 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
}
};
});
if (!args) return dom;
const workspace = await fetchJsonFromPage(page, `/v1/workbench/workspace?projectId=${encodeURIComponent(args.projectId)}`, { timeoutMs: 8000 });
const conversations = await fetchJsonFromPage(page, `/v1/agent/conversations?projectId=${encodeURIComponent(args.projectId)}`, { timeoutMs: 8000 });
return {
...dom,
api: {
workspace: summarizeWorkspaceFetch(workspace),
conversations: summarizeConversationsFetch(conversations)
}
};
}
async function fetchJsonFromPage(page, requestPath, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000;
return page.evaluate(async ({ requestPath: path, timeoutMs: fetchTimeoutMs }) => {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(path, { credentials: "same-origin", headers: { accept: "application/json" }, signal: controller.signal });
const text = await response.text();
let json = null;
let parseError = null;
try {
json = text ? JSON.parse(text) : null;
} catch (error) {
parseError = error instanceof Error ? error.message : String(error);
}
return {
ok: response.ok && parseError === null,
path,
status: response.status,
statusText: response.statusText,
elapsedMs: Date.now() - startedAt,
contentType: response.headers.get("content-type"),
bodyPreview: text.slice(0, 500),
json,
parseError,
error: parseError
};
} catch (error) {
return {
ok: false,
path,
status: null,
statusText: null,
elapsedMs: Date.now() - startedAt,
contentType: null,
bodyPreview: null,
json: null,
parseError: null,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : null
};
} finally {
clearTimeout(timer);
}
}, { requestPath, timeoutMs });
}
function summarizeWorkspaceFetch(fetchResult) {
const workspace = fetchResult?.json?.workspace ?? fetchResult?.json?.data?.workspace ?? null;
const selected = workspace?.selectedConversation ?? null;
return {
ok: fetchResult?.ok === true,
path: fetchResult?.path ?? null,
status: fetchResult?.status ?? null,
error: fetchResult?.error ?? null,
bodyPreview: fetchResult?.ok === true ? null : fetchResult?.bodyPreview ?? null,
workspaceId: workspace?.workspaceId ?? null,
revision: workspace?.revision ?? null,
selectedConversationId: workspace?.selectedConversationId ?? selected?.conversationId ?? null,
selectedAgentSessionId: workspace?.selectedAgentSessionId ?? selected?.sessionId ?? null,
activeTraceId: workspace?.activeTraceId ?? selected?.lastTraceId ?? null,
selectedConversationStatus: selected?.status ?? workspace?.sessionStatus ?? null,
selectedMessagesCount: Array.isArray(selected?.messages) ? selected.messages.length : selected?.messagesCount ?? null
};
}
function summarizeConversationsFetch(fetchResult) {
const conversations = Array.isArray(fetchResult?.json?.conversations)
? fetchResult.json.conversations
: Array.isArray(fetchResult?.json?.data?.conversations)
? fetchResult.json.data.conversations
: [];
return {
ok: fetchResult?.ok === true,
path: fetchResult?.path ?? null,
status: fetchResult?.status ?? null,
error: fetchResult?.error ?? null,
bodyPreview: fetchResult?.ok === true ? null : fetchResult?.bodyPreview ?? null,
count: conversations.length,
items: conversations.slice(0, 16).map((item) => ({
conversationId: item?.conversationId ?? null,
sessionId: item?.sessionId ?? null,
threadId: item?.threadId ?? null,
status: item?.status ?? null,
messagesCount: Array.isArray(item?.messages) ? item.messages.length : item?.messagesCount ?? null,
lastTraceId: item?.lastTraceId ?? null,
updatedAt: item?.updatedAt ?? 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) => {
const input = document.querySelector("#command-input");
const send = document.querySelector("#command-send");
return input?.value === expected && send && !send.disabled;
}, args.message, { timeout: Math.min(args.timeoutMs, 5000) });
const afterFill = await collectCommandState(page);
const submittedAt = new Date().toISOString();
await page.locator("#command-send").click();
const afterClick = await collectCommandState(page);
actions.push({ action: "submit-prompt", chars: args.message.length, submittedAt, readyBefore, afterFill, afterClick });
await page.waitForTimeout(args.waitAfterSubmitMs);
}
async function sampleTraceTimeline(page, args, actions) {
const samples = [];
for (let index = 0; index < args.traceSampleCount; index += 1) {
if (index > 0 && args.traceSampleIntervalMs > 0) await page.waitForTimeout(args.traceSampleIntervalMs);
samples.push(await collectTraceTimelineSample(page, args, index));
}
actions.push({ action: "trace-interval-samples", count: samples.length, intervalMs: args.traceSampleIntervalMs, samples });
return samples;
}
async function collectTraceTimelineSample(page, args, index) {
const dom = await page.evaluate((sampleIndex) => {
const cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-agent" || item === "message-user" || item === "message-system")?.replace(/^message-/, "") ?? null;
const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null;
const cards = [...document.querySelectorAll(".message-card")];
const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent"));
const trace = latestAgent?.querySelector(".trace-timeline") ?? null;
const details = trace?.querySelector("details") ?? null;
const rows = latestAgent ? [...latestAgent.querySelectorAll(".trace-render-row")] : [];
const latestRow = rows.at(-1) ?? null;
const empty = latestAgent?.querySelector(".trace-empty") ?? null;
const finalResponse = latestAgent?.querySelector(".message-final-response") ?? null;
return {
index: sampleIndex,
sampledAt: new Date().toISOString(),
messageCount: cards.length,
agentStatus: latestAgent ? cardStatus(latestAgent) : null,
tracePresent: Boolean(trace),
traceStatus: trace?.getAttribute("data-status") ?? null,
traceOpen: details instanceof HTMLDetailsElement ? details.open : null,
rowCount: rows.length,
emptyLabel: empty?.textContent?.replace(/\s+/gu, " ").trim() ?? null,
latestRowPreview: latestRow?.textContent?.replace(/\s+/gu, " ").trim().slice(0, 240) ?? null,
finalTextChars: finalResponse?.textContent?.trim().length ?? 0
};
}, index);
const session = await collectSessionState(page, args);
const conversationId = firstNonEmpty(session.routeConversationId, session.api?.workspace?.selectedConversationId, session.activeTab?.conversationId);
const conversation = conversationId ? session.api?.conversations?.items?.find((item) => item.conversationId === conversationId) ?? null : null;
const traceId = firstNonEmpty(conversation?.lastTraceId, session.api?.workspace?.activeTraceId);
const traceFetch = traceId
? await fetchTraceSummaryWithRetry(page, args, traceId)
: { ok: false, traceId: null, degradedReason: "trace-id-missing", attempts: [] };
return { ...dom, conversationId, sessionId: conversation?.sessionId ?? session.activeTab?.sessionId ?? null, traceId, traceFetch };
}
async function fetchTraceSummaryWithRetry(page, args, traceId) {
const path = `/v1/agent/traces/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(args.projectId)}&sinceSeq=0&limit=20`;
const attempts = [];
for (let attempt = 1; attempt <= 3; attempt += 1) {
const fetched = await fetchJsonFromPage(page, path, { timeoutMs: 8000 });
const summary = summarizeTraceFetch(fetched, traceId, attempt);
attempts.push(summary);
if (summary.ok) return { ...summary, attempts };
if (attempt < 3) await page.waitForTimeout(500 * attempt);
}
const last = attempts.at(-1) ?? { ok: false, traceId, degradedReason: "trace-fetch-failed" };
return { ...last, attempts, degradedReason: last.degradedReason ?? "trace-fetch-failed" };
}
function summarizeTraceFetch(fetchResult, expectedTraceId, attempt) {
const body = fetchResult?.json?.data ?? fetchResult?.json ?? null;
const trace = body?.trace ?? body?.runnerTrace ?? body;
const events = Array.isArray(trace?.events) ? trace.events : Array.isArray(body?.events) ? body.events : [];
const latest = events.at(-1) ?? null;
const ok = fetchResult?.ok === true;
return {
ok,
attempt,
path: fetchResult?.path ?? null,
httpStatus: fetchResult?.status ?? null,
elapsedMs: fetchResult?.elapsedMs ?? null,
traceId: trace?.traceId ?? body?.traceId ?? expectedTraceId,
traceStatus: trace?.status ?? body?.status ?? null,
running: trace?.running ?? body?.running ?? null,
terminal: trace?.terminal ?? body?.terminal ?? null,
eventCount: events.length,
firstEventAt: events[0]?.createdAt ?? events[0]?.at ?? null,
latestEventAt: latest?.createdAt ?? latest?.at ?? null,
latestEventPreview: latest ? JSON.stringify(latest).replace(/\s+/gu, " ").slice(0, 300) : null,
error: fetchResult?.error ?? null,
bodyPreview: ok ? null : fetchResult?.bodyPreview ?? null,
degradedReason: ok ? null : fetchResult?.status ? "trace-fetch-http-status" : "trace-fetch-failed"
};
}
function summarizeSessionRun(actions, dom) {
const fresh = [...actions].reverse().find((item) => item.action === "fresh-session") ?? null;
const alignment = fresh?.alignment?.check ?? null;
const sessionRail = dom?.sessionRail ?? null;
const active = sessionRail?.active ?? null;
return {
freshSessionRequested: Boolean(fresh),
freshSessionAligned: fresh ? fresh.aligned === true : null,
conversationId: firstNonEmpty(alignment?.conversationId, active?.conversationId, dom?.routeConversationId),
routeConversationId: alignment?.routeConversationId ?? dom?.routeConversationId ?? null,
selectedConversationId: alignment?.selectedConversationId ?? null,
sessionId: active?.sessionId ?? null,
revision: fresh?.after?.api?.workspace?.revision ?? null,
messageCount: dom?.messageCount ?? null,
degradedReason: fresh && fresh.aligned !== true ? fresh.alignment?.reason ?? "fresh-session-not-aligned" : null,
freshSession: fresh ? {
settled: fresh.settled === true,
aligned: fresh.aligned === true,
reason: fresh.alignment?.reason ?? null,
before: compactSessionState(fresh.before),
after: compactSessionState(fresh.after)
} : null
};
}
function compactSessionState(state) {
if (!state) return null;
return {
finalUrl: state.finalUrl ?? null,
routeConversationId: state.routeConversationId ?? null,
selectedConversationId: state.api?.workspace?.selectedConversationId ?? null,
revision: state.api?.workspace?.revision ?? null,
activeTab: state.activeTab ?? null,
messageCount: state.messageCount ?? null,
command: state.command ?? null,
conversationsCount: state.api?.conversations?.count ?? null
};
}
function summarizeTraceRun(samples, dom, sessionSummary) {
const firstDomRow = samples.find((sample) => Number(sample.rowCount ?? 0) > 0) ?? null;
const firstRestEvent = samples.find((sample) => Number(sample.traceFetch?.eventCount ?? 0) > 0) ?? null;
const last = samples.at(-1) ?? null;
const failedFetches = samples.filter((sample) => sample.traceFetch && sample.traceFetch.ok !== true);
return {
requested: samples.length > 0,
sampleCount: samples.length,
conversationId: firstNonEmpty(last?.conversationId, sessionSummary?.conversationId),
sessionId: firstNonEmpty(last?.sessionId, sessionSummary?.sessionId),
traceId: firstNonEmpty(last?.traceId, firstRestEvent?.traceId, dom?.sessionRail?.traceIds?.[0]),
finalAgentStatus: last?.agentStatus ?? null,
finalTraceStatus: last?.traceStatus ?? null,
finalDomRowCount: last?.rowCount ?? null,
firstDomRowVisibleAt: firstDomRow?.sampledAt ?? null,
firstDomRowPreview: firstDomRow?.latestRowPreview ?? null,
firstRestEventVisibleAt: firstRestEvent?.sampledAt ?? null,
firstRestEventCount: firstRestEvent?.traceFetch?.eventCount ?? null,
restTraceOk: samples.some((sample) => sample.traceFetch?.ok === true),
failedFetchCount: failedFetches.length,
latestFetch: last?.traceFetch ?? null,
degradedReason: samples.length === 0 ? null : failedFetches.length === samples.length ? failedFetches.at(-1)?.traceFetch?.degradedReason ?? "trace-fetch-failed" : null
};
}
function summarizeProbePerformance(actions, startedAt, samples, traceSummary) {
const submit = [...actions].reverse().find((item) => item.action === "submit-prompt") ?? null;
const submittedAt = submit?.submittedAt ?? null;
return {
startedAt,
submittedAt,
firstDomTraceRowAt: traceSummary?.firstDomRowVisibleAt ?? null,
firstRestTraceEventAt: traceSummary?.firstRestEventVisibleAt ?? null,
submitToFirstDomTraceRowMs: submittedAt && traceSummary?.firstDomRowVisibleAt ? Math.max(0, new Date(traceSummary.firstDomRowVisibleAt).getTime() - new Date(submittedAt).getTime()) : null,
submitToFirstRestTraceEventMs: submittedAt && traceSummary?.firstRestEventVisibleAt ? Math.max(0, new Date(traceSummary.firstRestEventVisibleAt).getTime() - new Date(submittedAt).getTime()) : null,
totalSampleWindowMs: samples.length >= 2 ? Math.max(0, new Date(samples.at(-1).sampledAt).getTime() - new Date(samples[0].sampledAt).getTime()) : 0
};
}
function runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation }) {
if (sessionSummary?.degradedReason) return sessionSummary.degradedReason;
if (traceSummary?.degradedReason) return traceSummary.degradedReason;
if (promptValidation?.ok === false) return "prompt-validation-failed";
return null;
}
function classifyRunProbeError(error) {
const message = error instanceof Error ? error.message : String(error);
if (/fresh session/u.test(message)) return "fresh-session-not-aligned";
if (/login|auth/u.test(message)) return "auth-login-failed";
if (/Timeout|timed out|timeout/u.test(message)) return "browser-timeout";
if (/fetch/u.test(message)) return "trace-fetch-failed";
return "web-probe-failed";
}
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
}
return null;
}
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);
}, 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 cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-agent" || item === "message-user" || item === "message-system")?.replace(/^message-/, "") ?? null;
const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null;
const cards = [...document.querySelectorAll(".message-card")];
const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent"));
if (!latestAgent) return false;
const status = cardStatus(latestAgent) ?? "";
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 cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-agent" || item === "message-user" || item === "message-system")?.replace(/^message-/, "") ?? null;
const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null;
const cards = [...document.querySelectorAll(".message-card")];
const latestAgent = [...cards].reverse().find((card) => cardRole(card) === "agent" || card.classList.contains("message-agent"));
const finalResponse = latestAgent?.querySelector(".message-final-response");
return {
messageCount: cards.length,
status: latestAgent ? cardStatus(latestAgent) : null,
textChars: finalResponse?.textContent?.trim().length ?? 0,
textPreview: (finalResponse?.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 240)
};
});
}
async function collectDomEvidence(page, args) {
return page.evaluate(async ({ projectId }) => {
const cardRole = (card) => card.getAttribute("data-role") ?? card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system")?.replace(/^message-/, "") ?? null;
const cardStatus = (card) => card.getAttribute("data-status") ?? card.getAttribute("data-message-status") ?? null;
const workspace = document.querySelector("#workspace");
const list = document.querySelector("#conversation-list");
const cards = [...document.querySelectorAll(".message-card")];
const conversationResponse = await fetch(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" }).catch(() => null);
const conversationBody = conversationResponse ? await conversationResponse.json().catch(() => null) : null;
const conversations = Array.isArray(conversationBody?.conversations) ? conversationBody.conversations : [];
const sessionTabs = [...document.querySelectorAll("#session-tabs .session-tab")];
const activeSessionTab = sessionTabs.find((item) => item.getAttribute("data-active") === "true") ?? null;
const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) ?? window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u);
const sessionIds = conversations.map((item) => item?.sessionId).filter((value) => typeof value === "string" && value).slice(0, 8);
const traceIds = conversations.map((item) => item?.lastTraceId).filter((value) => typeof value === "string" && value).slice(0, 8);
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"),
routeConversationId: routeMatch ? decodeURIComponent(routeMatch[1]) : null,
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"))
},
sessionRail: {
tabCount: sessionTabs.length,
activeTitle: activeSessionTab?.querySelector("strong")?.textContent?.trim() ?? null,
active: activeSessionTab ? {
title: activeSessionTab.querySelector("strong")?.textContent?.trim() ?? activeSessionTab.getAttribute("title") ?? null,
conversationId: activeSessionTab.getAttribute("data-conversation-id") ?? null,
sessionId: activeSessionTab.getAttribute("data-session-id") ?? null,
status: activeSessionTab.getAttribute("data-status") ?? null,
running: activeSessionTab.getAttribute("data-running") ?? null
} : null,
objectTitleCount: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").filter((text) => text.includes("[object Object]")).length,
titles: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").slice(0, 8),
sessionIds,
traceIds,
conversationsStatus: conversationResponse?.status ?? null,
conversationsCount: conversations.length
},
messageCount: cards.length,
messageRoles: cards.map((card) => cardRole(card) ?? "unknown"),
messages: cards.map((card) => {
const trace = card.querySelector(".trace-timeline, .message-trace");
const traceEvents = card.querySelector(".trace-disclosure-body ol, .message-trace-events");
const finalResponse = card.querySelector(".message-final-response, .message-text");
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: cardStatus(card),
role: cardRole(card),
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)
};
}, { projectId: args.projectId });
}
function observeBootstrapRoutes(page) {
const routes = [];
page.on("request", (request) => {
try {
const url = new URL(request.url());
if (!["/auth/bootstrap", "/auth/session", "/auth/workspace-bootstrap", "/v1/workbench/workspace"].includes(url.pathname)) return;
routes.push({ method: request.method(), path: url.pathname });
} catch {
// Ignore malformed browser-internal URLs.
}
});
return routes;
}
function summarizeBootstrapRoutes(routes) {
const paths = routes.map((route) => route.path);
return {
authBootstrapRequested: paths.includes("/auth/bootstrap"),
authSessionRequested: paths.includes("/auth/session"),
workbenchWorkspaceRequested: paths.includes("/v1/workbench/workspace"),
legacyWorkspaceBootstrapRequested: paths.includes("/auth/workspace-bootstrap"),
routes: routes.slice(0, 20)
};
}
async function fileSha256(file) {
if (!file || !fs.existsSync(file)) return null;
return createHash("sha256").update(await readFile(file)).digest("hex");
}
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 nonNegativeInteger(value, label) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative integer`);
return parsed;
}
function envNonNegativeInteger(name, fallback) {
const parsed = Number(process.env[name] ?? fallback);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
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 https://hwlab.pikapython.com/ --fresh-session --message '...' --no-cancel-running --trace-sample-count 6 --trace-sample-interval-ms 5000",
"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 <jobId>"
],
output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata."
};
}