Merge pull request #1013 from pikasTech/fix/webprobe-screenshot-monitor-1012

fix: web-probe 远程截图命令化并收紧哨兵 monitor 布局
This commit is contained in:
Lyon
2026-06-26 19:07:21 +08:00
committed by GitHub
7 changed files with 526 additions and 30 deletions
@@ -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;
}
}
@@ -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 = '<div class="empty-state">暂无时间线</div>';
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 `<button class="timeline-node ${statusClass(run.status)}" type="button" data-run-id="${escapeAttr(runId)}" title="${escapeAttr(title)}">
@@ -350,7 +352,10 @@ function renderRuns() {
const runId = run.runId || run.id || "-";
const selected = state.selectedRunId === runId ? " selected-row" : "";
return `<tr class="${selected}" data-run-id="${escapeAttr(runId)}">
<td><div class="mono">${escapeHtml(runId)}</div><small>${escapeHtml(run.observerId || "-")}</small></td>
<td><div class="run-identity">
<div><span>run</span><code>${escapeHtml(runId)}</code></div>
<div><span>observer</span><code>${escapeHtml(run.observerId || "-")}</code></div>
</div></td>
<td><span class="status-pill ${statusClass(run.status)}">${escapeHtml(displayStatus(run.status))}</span></td>
<td>${escapeHtml(run.scenarioId || "-")}</td>
<td>${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(displaySeverity(run.maxSeverity))}</span>` : ""}</td>
@@ -369,7 +374,29 @@ function renderFindings() {
refs.findingsList.innerHTML = '<div class="empty-state">暂无发现项</div>';
return;
}
refs.findingsList.innerHTML = state.findings.map((item) => {
const groups = groupedFindingsBySeverity(state.findings);
refs.findingsList.innerHTML = groups.map((group, groupIndex) => {
const open = group.key === "red" || groupIndex === 0;
const visibleItems = group.items.slice(0, group.key === "red" ? 8 : 5);
const hiddenCount = Math.max(0, group.items.length - visibleItems.length);
return `<details class="finding-group finding-group-${escapeAttr(group.key)}" ${open ? "open" : ""}>
<summary>
<span>${escapeHtml(displaySeverity(group.key))}</span>
<strong>${formatNumber(group.totalCount)}</strong>
<small>${formatNumber(group.items.length)}${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组通过过滤查看` : ""}</small>
</summary>
<div class="finding-group-list">${visibleItems.map(renderFindingItem).join("")}</div>
</details>`;
}).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 renderFindingItem(item) {
const code = item.code || item.findingId || "finding";
const latestRunId = item.latestRunId || "-";
const hasLatestRun = latestRunId !== "-";
@@ -379,23 +406,38 @@ function renderFindings() {
<span class="finding-title">${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` <small class="mono">${escapeHtml(code)}</small>`}</span>
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span>
</div>
<div class="finding-summary">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}</div>
<div class="finding-actions" aria-label="发现项过滤">
<button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
<button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
</div>
<div class="finding-meta">次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
<div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
<div class="finding-meta">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}</div>
<div class="finding-meta">${escapeHtml(findingNextAction(code))}</div>
<button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>打开最近运行</button>
</article>`;
}).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() {
+3
View File
@@ -43,6 +43,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"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<string, unknown> {
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.",
},
@@ -63,7 +63,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<section class="metric-grid" aria-label="哨兵状态指标">
<article class="metric-card">
<span class="metric-label">总体状态</span>
<span class="metric-label">当前状态</span>
<strong id="metric-overall">-</strong>
<small id="metric-origin">-</small>
</article>
@@ -73,7 +73,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<small id="metric-latest-age">-</small>
</article>
<article class="metric-card">
<span class="metric-label">发现项</span>
<span class="metric-label">历史发现项</span>
<strong id="metric-findings">0</strong>
<small id="metric-findings-note">-</small>
</article>
+19 -1
View File
@@ -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;
+275 -1
View File
@@ -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<string, un
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, options.node);
if (options.action === "sentinel") return runWebProbeSentinelCommand(spec, options.sentinel);
if (options.action === "screenshot") return runNodeWebProbeScreenshot(options, spec);
if (options.action === "observe" && options.observeAction !== "start") return runNodeWebProbeObserve(options, spec, null, null, null);
const secretSpec = runtimeSecretSpec({ node: options.node, lane });
const material = readBootstrapAdminPasswordMaterial(secretSpec);
@@ -497,6 +498,279 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
});
}
export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown> | null): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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",
+72 -1
View File
@@ -1312,7 +1312,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|observe|sentinel --node NODE --lane vNN [--url URL]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel --node NODE --lane vNN [--url URL]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
@@ -1342,6 +1342,49 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
if (actionRaw === "screenshot") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--url",
"--path",
"--viewport",
"--local-dir",
"--name",
"--timeout-ms",
"--wait-until",
"--selector",
"--wait-timeout-ms",
"--command-timeout-seconds",
]), new Set([
"--full-page",
"--no-full-page",
"--keep-remote",
]));
const url = optionValue(args, "--url");
const targetPath = optionValue(args, "--path") ?? null;
if (url !== undefined && targetPath !== null) throw new Error("web-probe screenshot accepts --url or --path, not both");
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000);
const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.ceil(waitTimeoutMs / 1000) + 45, 900);
return {
action: "screenshot",
node,
lane,
url: url ?? resolveWebProbeScreenshotUrl(spec, targetPath ?? "/"),
path: targetPath,
viewport: parseWebProbeScreenshotViewport(optionValue(args, "--viewport") ?? "1440x900"),
localDir: optionValue(args, "--local-dir") ?? "/tmp",
name: parseWebProbeScreenshotName(optionValue(args, "--name") ?? `web-probe-${node.toLowerCase()}-${lane}.png`),
timeoutMs,
waitUntil: parseWebProbeScreenshotWaitUntil(optionValue(args, "--wait-until") ?? "networkidle"),
fullPage: !args.includes("--no-full-page"),
selector: optionValue(args, "--selector") ?? null,
keepRemote: args.includes("--keep-remote"),
waitTimeoutMs,
commandTimeoutSeconds,
};
}
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
@@ -1441,3 +1484,31 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
commandTimeoutUserProvided,
};
}
function resolveWebProbeScreenshotUrl(spec: HwlabRuntimeLaneSpec, targetPath: string): string {
const origin = nodeWebProbeDefaultUrl(spec);
try {
return new URL(targetPath || "/", origin).toString();
} catch {
throw new Error(`web-probe screenshot --path cannot be resolved against ${origin}: ${targetPath}`);
}
}
function parseWebProbeScreenshotViewport(value: string): string {
if (!/^[1-9][0-9]{1,4}x[1-9][0-9]{1,4}$/u.test(value)) throw new Error(`web-probe screenshot --viewport must look like 1440x900, got ${value}`);
const [widthRaw, heightRaw] = value.split("x");
const width = Number(widthRaw);
const height = Number(heightRaw);
if (width < 240 || width > 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}`);
}