From 80d384ad0cd7f32c9fec61fb988f93d4b3526dfb Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 13:46:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20AgentRun=20Inspect?= =?UTF-8?q?or=20=E6=88=AA=E5=9B=BE=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-webdev/references/web-probe.md | 7 +++- config/hwlab-node-lanes.yaml | 22 ++++++++-- .../hwlab-node-web-probe-console-profile.ts | 27 +++++++++++-- ...wlab-node-web-probe-console-verify.test.ts | 10 ++++- .../hwlab-node-web-probe-console-verify.ts | 40 ++++++++++++++++++- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/.agents/skills/unidesk-webdev/references/web-probe.md b/.agents/skills/unidesk-webdev/references/web-probe.md index 32ab1c51..8f09ebc7 100644 --- a/.agents/skills/unidesk-webdev/references/web-probe.md +++ b/.agents/skills/unidesk-webdev/references/web-probe.md @@ -12,7 +12,12 @@ - 默认 profile 由同一 YAML 的 `defaultConsoleVerificationProfile` 选择,CLI `--profile` 只做显式 profile 选择; - 该命令用于已部署页面人工 closeout,不作为 CI/CD gate,不替代 Workbench Kafka、Trace 和性能专项 validator; - 成功或失败均以有界 `issueEvidence`、report SHA、screenshot SHA 和失败摘要收口,不再重复创建 `.state/probes/*.mjs`。 - - AgentRun 思维导图的聚焦验收使用 `--profile agentrun-tree`,直接恢复 `/agents/runs?view=tree`、等待 `.agent-mind-map` 并采集 `1920x1080` 截图。 + - AgentRun 思维导图的聚焦验收使用 `--profile agentrun-tree`: + - 直接恢复 `/agents/runs?view=tree` 并等待 `.agent-mind-map`; + - 先采集 `1920x1080` 纯树图截图; + - 再按 YAML `selection` 合同用键盘选择首个 Run; + - 校验 SVG 连线、Inspector 与工作区边界、标题/页签/正文几何; + - 最后采集 Inspector 打开状态截图。 - WebProbe 远程命令错误可见性: - 默认文本输出必须在独立 `COMMAND_OUTPUT` 段完整展示已经脱敏并限长的 `stderrTail` 与 `stdoutTail`; - 摘要表可以继续提供单行预览,但不得用表格宽度裁剪替代完整的有界错误证据; diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 2b043c5a..9aeb3efe 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -858,7 +858,7 @@ templates: failures: 20 consoleErrors: 20 responseErrors: 20 - screenshots: 1 + screenshots: 2 textPreviewChars: 160 viewports: - id: desktop @@ -868,8 +868,24 @@ templates: - id: agent-runs-tree path: /agents/runs?view=tree readySelector: .agent-mind-map - screenshot: true - workflows: {} + screenshot: false + workflows: + agentObserver: + routeId: agent-runs-tree + eventSourcePath: /v1/agent-observer/events + phaseSelector: .agent-observer-stream strong + blockedPhases: + - 未连接 + - 传输错误 + streamSettleMs: 1200 + views: + - buttonName: 树 + queryValue: tree + screenshotName: agentrun-tree.png + selection: + selector: ".agent-mind-map-node[data-kind='run']" + inspectorSelector: .agent-observer-inspector + screenshotName: agentrun-tree-inspector.png realtimeFanoutProfiles: pure-kafka-live: subscriberCount: 2 diff --git a/scripts/src/hwlab-node-web-probe-console-profile.ts b/scripts/src/hwlab-node-web-probe-console-profile.ts index cbf0c081..6f658947 100644 --- a/scripts/src/hwlab-node-web-probe-console-profile.ts +++ b/scripts/src/hwlab-node-web-probe-console-profile.ts @@ -55,6 +55,12 @@ export interface HwlabRuntimeWebProbeConsoleAgentViewSpec { readonly screenshotName: string; } +export interface HwlabRuntimeWebProbeConsoleAgentSelectionSpec { + readonly selector: string; + readonly inspectorSelector: string; + readonly screenshotName: string; +} + export interface HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec { readonly routeId: string; readonly eventSourcePath: string; @@ -62,6 +68,7 @@ export interface HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec { readonly blockedPhases: readonly string[]; readonly streamSettleMs: number; readonly views: readonly HwlabRuntimeWebProbeConsoleAgentViewSpec[]; + readonly selection?: HwlabRuntimeWebProbeConsoleAgentSelectionSpec; } export interface HwlabRuntimeWebProbeConsoleVerificationProfileSpec { @@ -124,7 +131,7 @@ function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConso onlyKeys(output, ["failures", "consoleErrors", "responseErrors", "screenshots", "textPreviewChars"], `${path}.outputLimits`); const screenshotBudget = integer(output.screenshots, `${path}.outputLimits.screenshots`, 1, 1000); const matrixScreenshots = routes.filter((item) => item.screenshot).length * viewports.length; - const workflowScreenshots = workflows.agentObserver?.views.length ?? 0; + const workflowScreenshots = (workflows.agentObserver?.views.length ?? 0) + (workflows.agentObserver?.selection === undefined ? 0 : 1); if (matrixScreenshots + workflowScreenshots > screenshotBudget) { throw new Error(`${path}.outputLimits.screenshots=${screenshotBudget} is below the declared matrix/workflow screenshot count ${matrixScreenshots + workflowScreenshots}`); } @@ -243,10 +250,11 @@ function mdtodoWorkflow(value: unknown, path: string, routeIds: ReadonlySet): HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec { const raw = record(value, path); - onlyKeys(raw, ["routeId", "eventSourcePath", "phaseSelector", "blockedPhases", "streamSettleMs", "views"], path); + onlyKeys(raw, ["routeId", "eventSourcePath", "phaseSelector", "blockedPhases", "streamSettleMs", "views", "selection"], path); const views = array(raw.views, `${path}.views`, 1, 10).map((item, index) => agentView(item, `${path}.views[${index}]`)); + const selection = raw.selection === undefined ? undefined : agentSelection(raw.selection, `${path}.selection`); unique(views.map((item) => item.queryValue), `${path}.views[].queryValue`); - unique(views.map((item) => item.screenshotName), `${path}.views[].screenshotName`); + unique([...views.map((item) => item.screenshotName), ...(selection === undefined ? [] : [selection.screenshotName])], `${path}.screenshots`); return { routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds), eventSourcePath: absolutePath(text(raw.eventSourcePath, `${path}.eventSourcePath`), `${path}.eventSourcePath`), @@ -254,6 +262,19 @@ function agentObserverWorkflow(value: unknown, path: string, routeIds: ReadonlyS blockedPhases: textArray(raw.blockedPhases, `${path}.blockedPhases`, 1, 20, 100), streamSettleMs: integer(raw.streamSettleMs, `${path}.streamSettleMs`, 0, 120_000), views, + ...(selection === undefined ? {} : { selection }), + }; +} + +function agentSelection(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentSelectionSpec { + const raw = record(value, path); + onlyKeys(raw, ["selector", "inspectorSelector", "screenshotName"], path); + const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120); + if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`); + return { + selector: boundedText(raw.selector, `${path}.selector`, 500), + inspectorSelector: boundedText(raw.inspectorSelector, `${path}.inspectorSelector`, 500), + screenshotName, }; } diff --git a/scripts/src/hwlab-node-web-probe-console-verify.test.ts b/scripts/src/hwlab-node-web-probe-console-verify.test.ts index fd420bc1..37c41611 100644 --- a/scripts/src/hwlab-node-web-probe-console-verify.test.ts +++ b/scripts/src/hwlab-node-web-probe-console-verify.test.ts @@ -45,9 +45,15 @@ test("Cloud Console verification profile is owned by lane YAML", () => { id: "agent-runs-tree", path: "/agents/runs?view=tree", readySelector: ".agent-mind-map", - screenshot: true, + screenshot: false, }]); assert.deepEqual(agentrunTree.viewports.map((item) => [item.width, item.height]), [[1920, 1080]]); + assert.deepEqual(agentrunTree.workflows.agentObserver?.selection, { + selector: ".agent-mind-map-node[data-kind='run']", + inspectorSelector: ".agent-observer-inspector", + screenshotName: "agentrun-tree-inspector.png", + }); + assert.equal(agentrunTree.outputLimits.screenshots, 2); }); test("Cloud Console profile parser rejects unknown fields and duplicate route ids", () => { @@ -72,6 +78,8 @@ test("generated Cloud Console runner is valid JavaScript and contains no node or assert.doesNotMatch(generatorSource, /NC01|JD01|D601|\/dashboard|lab-dev\.hwpod\.com/u); assert.match(script, /config\/example\.yaml#profiles\.fixture/u); assert.match(script, /requestedRoute/u); + assert.match(script, /noInspectorChromeOverlap/u); + assert.match(script, /page\.keyboard\.press\("Enter"\)/u); }); test("console-verify parses into the existing managed script action", () => { diff --git a/scripts/src/hwlab-node-web-probe-console-verify.ts b/scripts/src/hwlab-node-web-probe-console-verify.ts index b1e58405..64d6ea3b 100644 --- a/scripts/src/hwlab-node-web-probe-console-verify.ts +++ b/scripts/src/hwlab-node-web-probe-console-verify.ts @@ -320,12 +320,50 @@ export default async function consoleVerify({ page, context, goto, reloadStable, const artifact = await captureScreenshot(view.screenshotName); viewResults.push({ buttonName: view.buttonName, queryValue, screenshotSha256: artifact?.sha256 || null }); } + let selection = null; + if (workflow.selection) { + const candidate = page.locator(workflow.selection.selector).first(); + if (await candidate.count() < 1) { + addFailure("AgentRun: selection target missing " + workflow.selection.selector); + } else { + await candidate.focus(); + await page.keyboard.press("Enter"); + await page.locator(workflow.selection.inspectorSelector).waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); + await wait(config.settleMs); + const layout = await page.evaluate(({ inspectorSelector }) => { + const bounds = (selector) => { + const rect = document.querySelector(selector)?.getBoundingClientRect(); + return rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height } : null; + }; + const edgeCount = Array.from(document.querySelectorAll(".agent-mind-map-flow .vue-flow__edge-path")) + .filter((edge) => typeof edge.getTotalLength === "function" && edge.getTotalLength() > 8).length; + const inspector = bounds(inspectorSelector); + const content = bounds(".agent-observer-content"); + const header = bounds(inspectorSelector + " .operations-inspector-header"); + const tabs = bounds(inspectorSelector + " .operations-inspector-tabs"); + const body = bounds(inspectorSelector + " .operations-inspector-body"); + return { + edgeCount, + inspector, + noWorkspaceOverlap: Boolean(content && inspector && content.right <= inspector.left + 1), + noInspectorChromeOverlap: Boolean(header && tabs && body && header.bottom <= tabs.top + 1 && tabs.bottom <= body.top + 1), + inspectorInsideViewport: Boolean(inspector && inspector.left >= 0 && inspector.right <= innerWidth + 1 && inspector.top >= 0 && inspector.bottom <= innerHeight + 1), + }; + }, workflow.selection); + if (layout.edgeCount < 1) addFailure("AgentRun: mind map has no connected SVG edges"); + if (!layout.noWorkspaceOverlap) addFailure("AgentRun: Inspector overlaps the workspace"); + if (!layout.noInspectorChromeOverlap) addFailure("AgentRun: Inspector header, tabs and body overlap"); + if (!layout.inspectorInsideViewport) addFailure("AgentRun: Inspector exceeds the viewport"); + const artifact = await captureScreenshot(workflow.selection.screenshotName); + selection = { layout, screenshotSha256: artifact?.sha256 || null }; + } + } const after = await agentObserverState(workflow); if (after.sourceCount !== before.sourceCount) { addFailure("AgentRun: view switch changed EventSource count " + before.sourceCount + " -> " + after.sourceCount); } if (workflow.blockedPhases.includes(after.phase)) addFailure("AgentRun: transport phase " + after.phase); - return { before, after, views: viewResults }; + return { before, after, views: viewResults, selection }; } async function agentObserverState(workflow) {