From 49ce6c41be1a6fac7768eaf26a1b503fbb0a3b94 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 11:06:09 +0000 Subject: [PATCH] fix: add web-probe screenshot and tighten sentinel dashboard --- .../dashboard.css | 106 ++++++- .../web-probe-sentinel-dashboard/dashboard.js | 74 ++++- scripts/src/hwlab-node-help.ts | 3 + ...wlab-node-web-sentinel-dashboard-assets.ts | 4 +- scripts/src/hwlab-node/entry.ts | 20 +- scripts/src/hwlab-node/web-probe-observe.ts | 276 +++++++++++++++++- scripts/src/hwlab-node/web-probe.ts | 73 ++++- 7 files changed, 526 insertions(+), 30 deletions(-) diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index 573aba95..8dbcd969 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -263,20 +263,20 @@ select { } .overview-checks { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin: 0 0 14px; } .check-chip { - display: inline-flex; + display: flex; align-items: center; min-height: 30px; max-width: 100%; padding: 5px 10px; border: 1px solid #d8e0ea; - border-radius: 999px; + border-radius: 8px; background: #ffffff; color: #475467; font-size: 12px; @@ -301,11 +301,12 @@ select { } .run-timeline { - display: flex; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 8px; min-height: 68px; padding: 14px 16px; - overflow-x: auto; + overflow: visible; } .timeline-node { @@ -313,8 +314,9 @@ select { grid-template-rows: 18px auto; justify-items: center; gap: 4px; - min-width: 76px; - max-width: 120px; + width: 100%; + min-width: 0; + max-width: none; border: 0; background: transparent; color: #475467; @@ -342,6 +344,7 @@ select { .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr); + align-items: start; gap: 14px; } @@ -462,6 +465,33 @@ select { overflow-wrap: anywhere; } +.run-identity { + display: grid; + gap: 5px; + min-width: 0; +} + +.run-identity div { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + align-items: baseline; + gap: 8px; +} + +.run-identity span { + color: var(--muted); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.run-identity code { + min-width: 0; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + .mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; @@ -470,10 +500,49 @@ select { .finding-list { display: grid; - gap: 10px; + gap: 12px; padding: 12px; } +.finding-group { + min-width: 0; + border: 1px solid #e3e8ef; + border-radius: 8px; + background: #ffffff; +} + +.finding-group summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + align-items: center; + min-height: 46px; + padding: 10px 12px; + cursor: pointer; +} + +.finding-group summary span { + font-size: 13px; + font-weight: 800; +} + +.finding-group summary strong { + font-size: 20px; + line-height: 1; +} + +.finding-group summary small { + grid-column: 1 / -1; + color: var(--muted); + font-size: 12px; +} + +.finding-group-list { + display: grid; + gap: 10px; + padding: 0 10px 10px; +} + .finding-aggregation { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -519,6 +588,13 @@ select { overflow-wrap: anywhere; } +.finding-summary { + color: #1f2937; + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; +} + .finding-actions { display: flex; flex-wrap: wrap; @@ -827,6 +903,10 @@ select { .metric-card { min-height: 96px; } + + .run-timeline { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 560px) { @@ -862,4 +942,12 @@ select { .findings-filter { grid-template-columns: 1fr; } + + .run-timeline { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .finding-group:not([open]) summary { + min-height: 42px; + } } diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 9a427625..4941cf60 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -292,12 +292,13 @@ function renderDashboard() { function renderOverview() { const overview = state.overview || {}; const status = overview.status || (overview.ok ? "healthy" : "degraded"); - refs.statusPill.textContent = displayStatus(status); - refs.statusPill.className = `status-pill ${statusClass(status)}`; - refs.overall.textContent = displayStatus(status); - refs.origin.textContent = overview.publicOrigin || root.dataset.publicOrigin || "-"; - const latest = overview.latestRun || null; + const latestStatus = latest?.status || status; + refs.statusPill.textContent = displayStatus(latestStatus); + refs.statusPill.className = `status-pill ${statusClass(latestStatus)}`; + refs.overall.textContent = displayStatus(latestStatus); + refs.origin.textContent = `${overview.publicOrigin || root.dataset.publicOrigin || "-"} · 历史 ${displayStatus(status)}`; + refs.latestRun.textContent = latest?.runId || latest?.id || "-"; refs.latestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} 更新` : "-"; @@ -322,12 +323,13 @@ function renderOverview() { } function renderTimeline() { - refs.timelineCount.textContent = `最近 ${formatNumber(state.runs.length)} 次`; + const visibleRuns = state.runs.slice(0, 12); + refs.timelineCount.textContent = `最近 ${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 次`; if (state.runs.length === 0) { refs.timeline.innerHTML = '
暂无时间线
'; return; } - refs.timeline.innerHTML = state.runs.slice(0, 20).map((run) => { + refs.timeline.innerHTML = visibleRuns.map((run) => { const runId = run.runId || run.id || "-"; const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${run.updatedAt ? formatRelative(run.updatedAt) : "-"}`; return `
次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}
run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}
-
${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}
${escapeHtml(findingNextAction(code))}
`; - }).join(""); - for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) { - button.addEventListener("click", () => selectRun(button.dataset.openFindingRun)); - } - for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) { - button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || "")); +} + +function groupedFindingsBySeverity(findings) { + const severityOrder = ["red", "critical", "error", "warning", "amber", "info", "unknown"]; + const groups = new Map(); + for (const item of findings) { + const key = String(item.severity || "unknown").toLowerCase(); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(item); } + return Array.from(groups.entries()) + .map(([key, items]) => ({ + key, + items: items.sort((a, b) => Number(b.count || 0) - Number(a.count || 0)), + totalCount: items.reduce((sum, item) => sum + Number(item.count || 0), 0), + })) + .sort((a, b) => severityRank(a.key, severityOrder) - severityRank(b.key, severityOrder)); +} + +function severityRank(key, order) { + const index = order.indexOf(key); + return index >= 0 ? index : order.length; } function renderFindingAggregation() { diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 8e670889..6187eb4d 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -43,6 +43,8 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe run --node D601 --lane v03 --wait-messages-ms 1000", "bun scripts/cli.ts web-probe run --node D601 --lane v03 --fresh-session --message 'ping'", "bun scripts/cli.ts web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs", + "bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 1440x900", + "bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 390x844 --name monitor-mobile.png", "bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'", @@ -58,6 +60,7 @@ export function hwlabNodeWebProbeHelp(): Record { actions: { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.", + screenshot: "Capture a no-auth or public page through the selected node/lane remote browser and download PNG artifacts to the caller /tmp by default.", observe: "Start, inspect, control, stop, collect, and analyze a long-running observer that writes JSONL artifacts.", sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, maintenance and report views.", }, diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts index 4b8c2b92..f7a39141 100644 --- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts +++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts @@ -63,7 +63,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
- 总体状态 + 当前状态 - -
@@ -73,7 +73,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig -
- 发现项 + 历史发现项 0 -
diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 7564bc66..85c54faa 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -91,6 +91,24 @@ export interface NodeWebProbeScriptOptions { }; } +export interface NodeWebProbeScreenshotOptions { + action: "screenshot"; + node: string; + lane: string; + url: string; + path: string | null; + viewport: string; + localDir: string; + name: string; + timeoutMs: number; + waitUntil: "load" | "domcontentloaded" | "networkidle" | "commit"; + fullPage: boolean; + selector: string | null; + keepRemote: boolean; + waitTimeoutMs: number; + commandTimeoutSeconds: number; +} + export type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop" | "collect" | "analyze"; export type NodeWebProbeObserveCommandType = @@ -188,7 +206,7 @@ export interface NodeWebProbeSentinelOptions { lane: string; } -export type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions | NodeWebProbeSentinelOptions; +export type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeScreenshotOptions | NodeWebProbeObserveOptions | NodeWebProbeSentinelOptions; export interface WebObserveIndexEntry { id: string; diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 74b04288..49dd06bc 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -28,7 +28,7 @@ import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRul import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport"; import type { RenderedCliResult } from "../output"; -import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry"; +import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeScreenshotOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry"; import { runTransWorkspaceStdinScript, runtimeSecretSpec } from "./public-exposure"; import { transPath } from "./runtime-common"; import { assertLane, assertNodeId, compactCommandResult, compactCommandResultRedacted, compactCommandResultWithStdoutTail, nullableRecord, optionValue, parseJsonObject, positiveIntegerOption, record, requiredOption, shellQuote } from "./utils"; @@ -434,6 +434,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record { + const route = `${options.node}:${spec.workspace}`; + const script = webProbeScreenshotRemoteScript(options); + const result = runCommand([ + transPath(), + route, + "playwright", + "--local-dir", + options.localDir, + "--wait-timeout-ms", + String(options.waitTimeoutMs), + "--inactivity-timeout-ms", + "30000", + ...(options.keepRemote ? ["--keep-remote"] : []), + ], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 }); + const transport = parseJsonObject(result.stdout); + const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record) : []; + const screenshot = artifacts.find((artifact) => { + const remotePath = typeof artifact.remotePath === "string" ? artifact.remotePath : ""; + const localPath = typeof artifact.localPath === "string" ? artifact.localPath : ""; + return remotePath.endsWith(".png") || localPath.endsWith(".png"); + }) ?? null; + const remoteSummary = parseWebProbeScreenshotSummary(record(transport.remote).stdoutTail); + const compactScreenshot = screenshot === null ? null : compactWebProbeScreenshotArtifact(screenshot); + const compactArtifacts = artifacts.map(compactWebProbeScreenshotArtifact); + const remoteRecord = record(transport.remote); + const ok = result.exitCode === 0 && transport.ok === true && screenshot !== null && screenshot.verified !== false; + const degradedReason = ok + ? null + : result.timedOut + ? "web-probe-screenshot-command-timeout" + : transport.ok === false + ? "web-probe-screenshot-remote-failed" + : screenshot === null + ? "web-probe-screenshot-artifact-missing" + : "web-probe-screenshot-download-unverified"; + return { + ok, + status: ok ? "pass" : "blocked", + command: `web-probe screenshot --node ${options.node} --lane ${options.lane}`, + node: options.node, + lane: options.lane, + workspace: spec.workspace, + route, + url: options.url, + viewport: options.viewport, + localDir: options.localDir, + screenshot: compactScreenshot, + artifacts: compactArtifacts, + artifactCount: artifacts.length, + remote: { + exitCode: remoteRecord.exitCode ?? null, + remoteDir: remoteRecord.remoteDir ?? null, + defaultScreenshot: remoteRecord.defaultScreenshot ?? null, + stdoutTail: ok ? "" : typeof remoteRecord.stdoutTail === "string" ? remoteRecord.stdoutTail.slice(-1200) : "", + stderrTail: ok ? "" : typeof remoteRecord.stderrTail === "string" ? remoteRecord.stderrTail.slice(-1200) : "", + }, + page: compactWebProbeScreenshotPageSummary(remoteSummary), + transport: { + runId: transport.runId ?? null, + artifactCount: transport.artifactCount ?? null, + expectedArtifactCount: transport.expectedArtifactCount ?? null, + cleanup: compactWebProbeScreenshotCleanup(transport.cleanup), + downloadFailure: transport.downloadFailure ?? null, + }, + result: compactCommandResult(result), + degradedReason, + valuesRedacted: true, + }; +} + +function compactWebProbeScreenshotArtifact(artifact: Record): Record { + const transfer = record(artifact.transfer); + return { + remotePath: typeof artifact.remotePath === "string" ? artifact.remotePath : null, + localPath: typeof artifact.localPath === "string" ? artifact.localPath : null, + bytes: Number.isFinite(Number(artifact.bytes)) ? Number(artifact.bytes) : null, + sha256: typeof artifact.sha256 === "string" ? artifact.sha256 : null, + verified: artifact.verified === true, + transfer: Object.keys(transfer).length === 0 ? null : { + strategy: transfer.strategy ?? null, + transport: transfer.transport ?? null, + chunks: transfer.chunks ?? null, + elapsedMs: transfer.elapsedMs ?? null, + throughputBytesPerSecond: transfer.throughputBytesPerSecond ?? null, + }, + }; +} + +function compactWebProbeScreenshotPageSummary(value: Record | null): Record | null { + if (value === null) return null; + const layout = record(value.layout); + return { + ok: value.ok === true, + status: value.status ?? null, + title: value.title ?? null, + finalUrl: value.finalUrl ?? null, + executablePath: value.executablePath ?? null, + viewport: value.viewport ?? null, + fullPage: value.fullPage ?? null, + selector: value.selector ?? null, + layout: { + viewport: record(layout.viewport), + documentSize: record(layout.documentSize), + horizontalOverflow: layout.horizontalOverflow === true, + overflowCount: layout.overflowCount ?? null, + overflow: Array.isArray(layout.overflow) ? layout.overflow.slice(0, 5).map(record) : [], + }, + consoleCount: value.consoleCount ?? null, + requestFailureCount: value.requestFailureCount ?? null, + }; +} + +function compactWebProbeScreenshotCleanup(value: unknown): Record | null { + const cleanup = record(value); + if (Object.keys(cleanup).length === 0) return null; + return { + attempted: cleanup.attempted ?? null, + kept: cleanup.kept ?? null, + remoteDir: cleanup.remoteDir ?? null, + exitCode: cleanup.exitCode ?? null, + ok: cleanup.ok ?? null, + }; +} + +function webProbeScreenshotRemoteScript(options: NodeWebProbeScreenshotOptions): string { + const [widthRaw, heightRaw] = options.viewport.split("x"); + return [ + "set -eu", + `export UNIDESK_WEB_PROBE_SCREENSHOT_URL=${shellQuote(options.url)}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_PATH="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(options.name)}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH=${shellQuote(widthRaw ?? "1440")}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT=${shellQuote(heightRaw ?? "900")}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL=${shellQuote(options.waitUntil)}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`, + `export UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR=${shellQuote(options.selector ?? "")}`, + "if command -v chromium >/dev/null 2>&1; then", + " export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v chromium)", + "elif command -v chromium-browser >/dev/null 2>&1; then", + " export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v chromium-browser)", + "elif command -v google-chrome >/dev/null 2>&1; then", + " export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v google-chrome)", + "else", + " export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=", + "fi", + "cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\" <<'WEB_PROBE_SCREENSHOT_JS'", + webProbeScreenshotRemoteModule(), + "WEB_PROBE_SCREENSHOT_JS", + "bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\"", + ].join("\n"); +} + +function webProbeScreenshotRemoteModule(): string { + return String.raw`import { chromium } from "playwright"; + +const url = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_URL; +const screenshotPath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_PATH; +const width = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH || 1440); +const height = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT || 900); +const timeout = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS || 30000); +const waitUntil = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL || "networkidle"; +const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0"; +const selector = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR || ""; +const executablePath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH || ""; + +if (!url || !screenshotPath) throw new Error("missing screenshot URL or path"); + +const consoleMessages = []; +const requestFailures = []; +const launchOptions = { + headless: true, + args: ["--disable-gpu", "--no-sandbox"], + ...(executablePath ? { executablePath } : {}), +}; +const browser = await chromium.launch(launchOptions); +const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, isMobile: width <= 560 }); +const page = await context.newPage(); +page.on("console", (message) => { + if (consoleMessages.length < 20) consoleMessages.push({ type: message.type(), text: message.text().slice(0, 240) }); +}); +page.on("requestfailed", (request) => { + if (requestFailures.length < 20) requestFailures.push({ url: request.url().slice(0, 240), method: request.method(), failure: request.failure()?.errorText || null }); +}); + +let status = null; +try { + const response = await page.goto(url, { timeout, waitUntil }); + status = response?.status() ?? null; + await page.waitForTimeout(350); + if (selector) await page.locator(selector).screenshot({ path: screenshotPath, timeout }); + else await page.screenshot({ path: screenshotPath, fullPage, animations: "disabled" }); + const layout = await page.evaluate(() => { + const doc = document.documentElement; + const body = document.body; + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const documentSize = { + width: Math.max(doc.scrollWidth, body?.scrollWidth || 0), + height: Math.max(doc.scrollHeight, body?.scrollHeight || 0), + }; + const overflow = []; + let overflowCount = 0; + for (const element of Array.from(document.querySelectorAll("body *"))) { + const rect = element.getBoundingClientRect(); + const right = rect.right; + const bottom = rect.bottom; + const overflowRight = right - viewport.width; + const overflowLeft = -rect.left; + if (overflowRight > 1 || overflowLeft > 1) { + overflowCount += 1; + if (overflow.length < 20) { + overflow.push({ + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 120), + text: String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120), + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + overflowRight: Math.max(0, Math.round(overflowRight)), + overflowLeft: Math.max(0, Math.round(overflowLeft)), + bottom: Math.round(bottom), + }); + } + } + } + return { + title: document.title, + finalUrl: window.location.href, + viewport, + documentSize, + horizontalOverflow: documentSize.width > viewport.width + 1, + overflowCount, + overflow, + }; + }); + console.log("__WEB_PROBE_SCREENSHOT_JSON__" + JSON.stringify({ + ok: true, + url, + finalUrl: page.url(), + status, + title: await page.title(), + screenshotPath, + executablePath: executablePath || null, + viewport: { width, height }, + fullPage, + selector: selector || null, + layout, + consoleCount: consoleMessages.length, + requestFailureCount: requestFailures.length, + consoleMessages, + requestFailures, + valuesRedacted: true, + })); +} finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); +} +`; +} + +function parseWebProbeScreenshotSummary(value: unknown): Record | null { + if (typeof value !== "string" || value.length === 0) return null; + const marker = "__WEB_PROBE_SCREENSHOT_JSON__"; + const index = value.lastIndexOf(marker); + if (index < 0) return null; + try { + return record(JSON.parse(value.slice(index + marker.length).trim())); + } catch { + return null; + } +} + export function nodeWebProbeRunArgs(options: NodeWebProbeRunOptions, command: "run" | "start"): string[] { const probeArgs = [ "node", diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 2e163b46..5b1b2f92 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -1312,7 +1312,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType 7680 || height < 240 || height > 4320) throw new Error(`web-probe screenshot --viewport out of range: ${value}`); + return value; +} + +function parseWebProbeScreenshotName(value: string): string { + if (!/^[A-Za-z0-9._-]{1,120}$/u.test(value)) throw new Error("web-probe screenshot --name must contain only letters, numbers, dot, underscore, or dash, max 120 chars"); + return value.endsWith(".png") ? value : `${value}.png`; +} + +function parseWebProbeScreenshotWaitUntil(value: string): "load" | "domcontentloaded" | "networkidle" | "commit" { + if (value === "load" || value === "domcontentloaded" || value === "networkidle" || value === "commit") return value; + throw new Error(`web-probe screenshot --wait-until must be load, domcontentloaded, networkidle, or commit; got ${value}`); +}