feat: 增加 YAML-first Cloud Console 验收命令

This commit is contained in:
Codex
2026-07-16 02:49:53 +02:00
parent 09bd068c9a
commit 8fad1ff10b
12 changed files with 1087 additions and 7 deletions
+7
View File
@@ -41,6 +41,13 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H
- `observe collect <id> --view turn-summary --command-id <cmd_...>` 必须按 commandId 精确返回单轮;不存在时保持结构化 `not-found`,禁止回退全量轮次。输出中 observer control 完成、turn submission 与异步 turn terminal 是三个独立证据字段,control completed 不等于业务 turn terminal。
- `workbench-triad` 的本地 `network.jsonl` 没有匹配响应时,结论必须写作 `artifact evidence missing`,并明确这不能证明业务 API 未发生;将 control artifact 中的 OTel trace reference 作为独立证据引用,再走 `$unidesk-otel` 受控诊断核验完整业务链路。
- `web-probe script` 结果首行固定返回 `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT <json>`,同时在结构化结果首字段返回 `commandPromotionHint`;一次性脚本在重复前必须沉淀为 repo-owned typed command。内置 generated command(例如 `web-probe opencode-smoke`)会明确返回可直接复用且不附加临时脚本 warning,禁止复制其生成脚本另起一次性入口。
- Cloud Console 全路由、多视口和通用产品交互收口:
- 使用 `web-probe console-verify --node <node> --lane <lane> --origin internal|public --profile <profile>`
- 路由、视口、selector、工作流、等待和证据上限只从 owning YAML profile 读取;
- 不再复制临时脚本;
- 只用于已部署页面的人工 closeout;
- 不进入 CI/CD gate
- 不替代 Workbench Kafka、Trace 或性能专项命令。
- 沉淀后的 typed command 必须复用 observer start 时选中的 semantic origin;不得在 command 内嵌 URL/IP,也不得为 internal/public 各复制一份 command。
- Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary``performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。
- Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible``reportFullscreenVisible`、报告 deep link 和截图 SHA。
@@ -4,6 +4,14 @@
- 交互流程使用 `observe start``observe command``observe collect``observe analyze`
- `run``script` 只用于有界探测和 smoke
- 收尾证据引用 observer id、command id、artifact/report SHA、screenshot SHA 和关键有界字段。
- Cloud Console 页面矩阵:
- 使用 `bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal|public --profile <profile>`
- 配置真相位于 `config/hwlab-node-lanes.yaml#templates.*.webProbeWorkbench.consoleVerificationProfiles`
- 配置拥有 route profile、viewports、稳定 selector、可选产品工作流、等待和证据上限;
- 命令复用既有认证、semantic origin、远程浏览器、`MemAvailable` 启动门禁、report recovery 和截图合同;
- 默认 profile 由同一 YAML 的 `defaultConsoleVerificationProfile` 选择,CLI `--profile` 只做显式 profile 选择;
- 该命令用于已部署页面人工 closeout,不作为 CI/CD gate,不替代 Workbench Kafka、Trace 和性能专项 validator
- 成功或失败均以有界 `issueEvidence`、report SHA、screenshot SHA 和失败摘要收口,不再重复创建 `.state/probes/*.mjs`
- Workbench 与 Performance 调查规则:
- 先使用专用 collect/analyze view
- 不要先检索原始 JSONL artifact。
+157
View File
@@ -691,6 +691,163 @@ templates:
requestTimeoutMs: 30000
initialDelayMs: 500
maxDelayMs: 10000
defaultConsoleVerificationProfile: cloud-console
consoleVerificationProfiles:
cloud-console:
navigationTimeoutMs: 30000
settleMs: 350
commandTimeoutSeconds: 900
minTextLength: 8
maxHorizontalOverflowPx: 2
outputLimits:
failures: 100
consoleErrors: 100
responseErrors: 100
screenshots: 24
textPreviewChars: 160
viewports:
- id: desktop
width: 1920
height: 1080
- id: compact
width: 960
height: 600
- id: mobile
width: 390
height: 844
routes:
- id: dashboard
path: /dashboard
readySelector: "#app"
screenshot: false
- id: projects
path: /projects
readySelector: "[data-testid='project-management-root']"
screenshot: true
- id: mdtodo
path: /projects/mdtodo
readySelector: "[data-testid='project-management-mdtodo']"
screenshot: true
- id: api-keys
path: /api-keys
readySelector: "#app"
screenshot: false
- id: usage
path: /usage
readySelector: "#app"
screenshot: false
- id: billing
path: /billing
readySelector: "#app"
screenshot: false
- id: performance
path: /performance
readySelector: "#app"
screenshot: false
- id: access
path: /admin/access
readySelector: "#app"
screenshot: true
- id: secrets
path: /admin/secrets
readySelector: "#app"
screenshot: false
- id: users
path: /admin/users
readySelector: "#app"
screenshot: false
- id: admin-billing
path: /admin/billing
readySelector: "#app"
screenshot: false
- id: hwpod-devices
path: /hwpods/devices
readySelector: "[data-testid='hwpod-console']"
screenshot: true
- id: hwpod-nodes
path: /hwpods/nodes
readySelector: "[data-testid='hwpod-console']"
screenshot: true
- id: hwpod-onboarding
path: /hwpods/onboarding
readySelector: "[data-testid='hwpod-console']"
screenshot: true
- id: provider-profiles
path: /admin/provider-profiles
readySelector: "#app"
screenshot: false
- id: skills
path: /skills
readySelector: "#app"
screenshot: false
- id: settings
path: /settings
readySelector: "#app"
screenshot: false
- id: gate
path: /gate
readySelector: "#app"
screenshot: false
- id: help
path: /help
readySelector: "#app"
screenshot: false
- id: agent-runs
path: /agents/runs?view=table
readySelector: ".agent-observer-page"
screenshot: true
workflows:
projects:
routeId: projects
listButtonName: 列表
viewQueryKey: view
hwpod:
devicesRouteId: hwpod-devices
nodesRouteId: hwpod-nodes
onboardingRouteId: hwpod-onboarding
listButtonName: 列表
cardButtonName: 电路板卡片
devicesListSelector: "[aria-label='HWPOD 设备列表']"
devicesCardSelector: "[aria-label='HWPOD 设备卡片']"
nodesListSelector: "[aria-label='HWPOD Node 列表']"
pcbTraceSelector: .pcb-trace
detailButtonName: 查看详情
detailPathPrefix: /hwpods/devices/
onboardingCommandSelector: .install-steps code
requiredCommandPrefixes:
- 'Get-FileHash .\'
- 'python .\'
downloadSelector: a.download-button
forbiddenDownloadSubstrings:
- hwlab.pikapython.com
forbiddenInternalSelector: .stage-action code
mdtodo:
routeId: mdtodo
taskSelector: "[data-task-ref]"
taskDeepLinkSegment: /tasks/
taskDetailSelector: "[data-testid='mdtodo-task-detail']"
reportLinkSelector: "[data-testid='mdtodo-report-link']:not([disabled])"
reportPreviewSelector: "[data-testid='mdtodo-report-preview']"
fullscreenButtonSelector: "[data-testid='mdtodo-report-fullscreen']"
fullscreenDialogSelector: "[data-testid='mdtodo-report-fullscreen-dialog']"
agentObserver:
routeId: agent-runs
eventSourcePath: /v1/agent-observer/events
phaseSelector: .agent-observer-stream strong
blockedPhases:
- 未连接
- 传输错误
streamSettleMs: 1200
views:
- buttonName: 卡片
queryValue: cards
screenshotName: agentrun-cards.png
- buttonName:
queryValue: tree
screenshotName: agentrun-tree.png
- buttonName: 表格
queryValue: table
screenshotName: agentrun-table.png
realtimeFanoutProfiles:
pure-kafka-live:
subscriberCount: 2
@@ -0,0 +1,44 @@
# R2 任务报告
## 结论
已完成 [UniDesk #2248](https://github.com/pikasTech/unidesk/issues/2248) 的通用工具改进:新增 `web-probe console-verify` typed command,将 Cloud Console 全路由、多视口、截图和可选 Projects/HWPOD/MDTODO/AgentRun 交互验收收敛为一个受控入口。
该命令不新增浏览器执行器,继续复用现有认证、semantic origin、远程 script runner、report recovery、截图、结构化输出和 `MemAvailable` 物理内存门禁。它只用于已部署页面人工 closeout,不进入 CI/CD gate,也不替代 Workbench Kafka、Trace 或性能专项 validator。
## 配置与实现
- owning YAML`config/hwlab-node-lanes.yaml#templates.*.webProbeWorkbench.consoleVerificationProfiles`
- CLI`bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal|public --profile <profile>`
- profile 拥有视口、路由、ready selector、截图预算、等待、输出上限和可选产品工作流。
- parser 对未知字段、重复 route/viewport、未知 route 引用、越界参数和截图预算不足 fail-fast。
- runner 一次创建一棵受控浏览器进程树,按路由×视口矩阵检查空白页、登录重定向、横向溢出和 HTTP 5xx,并输出有界 report/screenshot SHA。
- 产品工作流覆盖卡片/列表/树切换、URL view state、HWPOD reduced-motion 与深链刷新、Python 接入命令、MDTODO 任务深链与报告预览/全屏、AgentRun 单产品 EventSource。
- 生成命令标记为 repo-owned,最终 command governance 不再产生 ad-hoc script warning。
## 工具调用缩减
改进前需要先查 runner helper、临时维护 `.state/probes/*.mjs`、手工枚举路由/视口/selector,再执行 `web-probe script` 并人工抽取证据。
改进后同类任务只需一次 `web-probe console-verify` 调用;node、lane、origin 和 profile 是变化输入,其余验证矩阵由 owning YAML 维护。新页面或 DOM 合同变化只改 profile,不复制 runner。
## 验证
- `bun test scripts/src/hwlab-node-web-probe-console-verify.test.ts scripts/src/hwlab-node-web-probe-memory-guard.test.ts`17/17 通过。
- `bun scripts/cli.ts check --syntax-only`11/11 通过。
- `bun scripts/cli.ts web-probe --help`:默认输出未截断。
- `git diff --check`:通过。
- controlled scripts typecheck 的仓库全局 gate 仍有既有基线错误:
- 新增的三个 Console 文件匹配错误数为 0;
- `scripts/src/hwlab-node/web-probe.ts` 匹配错误数为 0
- `scripts/src/hwlab-node-lanes.ts` 的 2 条错误位于本任务未修改的既有代码,主分支同样存在。
- NC01 单命令 smoke
- `MemAvailable=2.62 GiB`
- 门禁阈值 `4 GiB`
- 返回 `status=blocked``decision=gc-required`
- 未创建 Chromium
- 输出 plan→run→status-until-terminal→memory-recheck 顺序。
## 未冒充完成的范围
本报告只完成通用 WebProbe 工具改进。HWLAB Web 产品体验 R2.4/R3 的真实页面验收仍需等待 NC01 `MemAvailable > 4 GiB` 后,分别对 internal 和 public semantic origin 执行该 typed command;当前 blocked smoke 不能作为页面通过证据。
@@ -19,3 +19,7 @@
### R1.3
测试与验收:https://github.com/pikasTech/unidesk/issues/1842;覆盖生命周期、stale/protected 分类和失败路径,提交独立 PR;合并后只等待自动 CI/CD 发布链,使用正式入口验证 observer/Chrome 退出、内存压力缓解且健康运行面不受扰动,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R1.3_Task_Report.md)。
## R2 [completed]
完成 [UniDesk #2248](https://github.com/pikasTech/unidesk/issues/2248):新增 YAML-first `web-probe console-verify` typed command,按 node/lane/origin/profile 复用现有认证、远程浏览器、4 GiB `MemAvailable` 门禁、报告恢复和截图合同,一条命令完成 Cloud Console 路由×视口矩阵及可选 Projects/HWPOD/MDTODO/AgentRun 交互验证;配置拥有路由、视口、DOM 合同和等待,禁止写死节点、lane、origin、Issue 或 artifact,不作为 CI/CD gate且禁止 Vitest,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R2_Task_Report.md)。
+3 -2
View File
@@ -70,7 +70,7 @@ export function rootHelp(): unknown {
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, issue/comment apply_patch body patching, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." },
{ command: "git github-push-fallback [--repo owner/name] [--branch branch] [--host-name host-or-ip] [--confirm]", description: "Plan or execute a one-shot GitHub push through ssh.github.com:443 without editing remotes; use only for reviewed DNS/port-22 push fallback." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." },
{ command: "web-probe run|script|observe|sentinel --node <node> --lane <lane>", description: "Run YAML-selected HWLAB Web probes, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel control through the single top-level web-probe entrypoint." },
{ command: "web-probe run|script|console-verify|observe|sentinel --node <node> --lane <lane>", description: "Run YAML-selected HWLAB Web probes, Cloud Console route matrices, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel control through the single top-level web-probe entrypoint." },
{ command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node <node> --lane <lane>", description: "Manage HWLAB node/lane runtime prerequisites, including NC01 YAML-declared k3s infra/tools-image/Argo bootstrap, HWPOD preinstall configRefs, redacted test-account preparation, and NC01 v0.3 runtime lane, with the node identity passed as data." },
{ command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node NC01 --lane v03", description: "Operate the NC01 HWLAB v0.3 development and deployment lane from YAML source truth." },
{ command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." },
@@ -942,10 +942,11 @@ function hwlabNodeHelpSummary(): unknown {
function webProbeHelpSummary(): unknown {
return {
command: "web-probe run|script|observe|sentinel --node <node> --lane <lane>",
command: "web-probe run|script|console-verify|observe|sentinel --node <node> --lane <lane>",
output: "json",
usage: [
"bun scripts/cli.ts web-probe run --node NC01 --lane v03 --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
"bun scripts/cli.ts web-probe observe start --node NC01 --lane v03 --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1",
+3 -3
View File
@@ -86,13 +86,11 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
description: "Run target node/lane HWLAB Cloud Web probes with YAML-selected semantic internal/public origins and one-shot bootstrap Web credentials.",
examples: [
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin internal --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin public --fresh-session --message 'public exposure closeout'",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --origin internal --message 'hi'",
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
"bun scripts/cli.ts web-probe script --node <node> --lane <lane> --origin internal --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --origin internal --path /workbench --viewport 1440x900",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --origin public --path /workbench --viewport 390x844 --name workbench-public-mobile.png",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin internal --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin public --target-path /projects/mdtodo --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateExistingSessionRefresh --profile pure-kafka-live --session-id ses_existing",
@@ -131,6 +129,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
actions: {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
"opencode-smoke": "Run the repo-owned OpenCode iframe/direct-host composer smoke and require DOM assistant text plus EventSource update/finish/idle evidence.",
"console-verify": "Run a YAML-profiled Cloud Console route and viewport matrix with optional Projects, HWPOD, MDTODO and AgentRun interaction contracts.",
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.",
@@ -143,6 +142,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"`web-probe script` is an ad-hoc exploration escape hatch; repeated/high-frequency workflows must become `web-probe observe command` types or repo-owned web-probe commands.",
"Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT <json>` guidance; repo-owned generated commands report reuse instead of ad-hoc promotion.",
"`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.",
"`web-probe console-verify` reads route, viewport, selector, workflow, wait and evidence bounds from the selected owning YAML profile and reuses the existing authenticated script runner.",
"observe is passive by default; user actions must be explicit observe command entries in control.jsonl.",
"observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.",
"After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
+18
View File
@@ -6,6 +6,7 @@
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH } from "./hwlab-node-control-plane-model";
import { parseWebProbeConsoleVerificationProfiles, type HwlabRuntimeWebProbeConsoleVerificationProfileSpec } from "./hwlab-node-web-probe-console-profile";
import { materializeYamlComposition } from "./yaml-composition";
export type HwlabRuntimeLane = string;
@@ -161,6 +162,8 @@ export interface HwlabRuntimeWebProbeSpec {
readonly alertThresholds?: HwlabRuntimeWebProbeAlertThresholdsSpec;
readonly browserFreezePolicy?: HwlabRuntimeWebProbeBrowserFreezePolicySpec;
readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec;
readonly defaultConsoleVerificationProfile?: string;
readonly consoleVerificationProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeConsoleVerificationProfileSpec>>;
readonly realtimeFanoutProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeRealtimeFanoutProfileSpec>>;
readonly workbenchKafkaDebugReplay?: HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec;
readonly workbenchTraceReadability?: HwlabRuntimeWebProbeWorkbenchTraceReadabilitySpec;
@@ -1514,6 +1517,19 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
if (defaultOrigin !== undefined && origins === undefined) {
throw new Error(`${path}.origins must declare internal and public when defaultOrigin is set`);
}
const consoleVerificationProfiles = raw.consoleVerificationProfiles === undefined
? undefined
: parseWebProbeConsoleVerificationProfiles(raw.consoleVerificationProfiles, `${path}.consoleVerificationProfiles`);
const defaultConsoleVerificationProfile = optionalStringField(raw, "defaultConsoleVerificationProfile", path);
if (defaultConsoleVerificationProfile !== undefined && consoleVerificationProfiles === undefined) {
throw new Error(`${path}.consoleVerificationProfiles must be declared when defaultConsoleVerificationProfile is set`);
}
if (consoleVerificationProfiles !== undefined && defaultConsoleVerificationProfile === undefined) {
throw new Error(`${path}.defaultConsoleVerificationProfile must select one consoleVerificationProfiles entry`);
}
if (defaultConsoleVerificationProfile !== undefined && consoleVerificationProfiles?.[defaultConsoleVerificationProfile] === undefined) {
throw new Error(`${path}.defaultConsoleVerificationProfile references unknown profile ${defaultConsoleVerificationProfile}`);
}
return {
...(browserProxyMode === undefined ? {} : { browserProxyMode }),
...(playwrightBrowsersPath === undefined ? {} : { playwrightBrowsersPath }),
@@ -1524,6 +1540,8 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
...(raw.alertThresholds === undefined ? {} : { alertThresholds: webProbeAlertThresholdsConfig(raw.alertThresholds, `${path}.alertThresholds`) }),
...(raw.browserFreezePolicy === undefined ? {} : { browserFreezePolicy: webProbeBrowserFreezePolicyConfig(raw.browserFreezePolicy, `${path}.browserFreezePolicy`) }),
...(raw.projectManagement === undefined ? {} : { projectManagement: webProbeProjectManagementConfig(raw.projectManagement, `${path}.projectManagement`) }),
...(defaultConsoleVerificationProfile === undefined ? {} : { defaultConsoleVerificationProfile }),
...(consoleVerificationProfiles === undefined ? {} : { consoleVerificationProfiles }),
...(raw.realtimeFanoutProfiles === undefined ? {} : { realtimeFanoutProfiles: webProbeRealtimeFanoutProfilesConfig(raw.realtimeFanoutProfiles, `${path}.realtimeFanoutProfiles`) }),
...(raw.workbenchKafkaDebugReplay === undefined
? {}
@@ -0,0 +1,335 @@
// Responsibility: Typed YAML profile parsing for repo-owned Cloud Console browser closeout.
export interface HwlabRuntimeWebProbeConsoleViewportSpec {
readonly id: string;
readonly width: number;
readonly height: number;
}
export interface HwlabRuntimeWebProbeConsoleRouteSpec {
readonly id: string;
readonly path: string;
readonly readySelector: string;
readonly screenshot: boolean;
}
export interface HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
readonly routeId: string;
readonly listButtonName: string;
readonly viewQueryKey: string;
}
export interface HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
readonly devicesRouteId: string;
readonly nodesRouteId: string;
readonly onboardingRouteId: string;
readonly listButtonName: string;
readonly cardButtonName: string;
readonly devicesListSelector: string;
readonly devicesCardSelector: string;
readonly nodesListSelector: string;
readonly pcbTraceSelector: string;
readonly detailButtonName: string;
readonly detailPathPrefix: string;
readonly onboardingCommandSelector: string;
readonly requiredCommandPrefixes: readonly string[];
readonly downloadSelector: string;
readonly forbiddenDownloadSubstrings: readonly string[];
readonly forbiddenInternalSelector: string;
}
export interface HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
readonly routeId: string;
readonly taskSelector: string;
readonly taskDeepLinkSegment: string;
readonly taskDetailSelector: string;
readonly reportLinkSelector: string;
readonly reportPreviewSelector: string;
readonly fullscreenButtonSelector: string;
readonly fullscreenDialogSelector: string;
}
export interface HwlabRuntimeWebProbeConsoleAgentViewSpec {
readonly buttonName: string;
readonly queryValue: string;
readonly screenshotName: string;
}
export interface HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
readonly routeId: string;
readonly eventSourcePath: string;
readonly phaseSelector: string;
readonly blockedPhases: readonly string[];
readonly streamSettleMs: number;
readonly views: readonly HwlabRuntimeWebProbeConsoleAgentViewSpec[];
}
export interface HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
readonly navigationTimeoutMs: number;
readonly settleMs: number;
readonly commandTimeoutSeconds: number;
readonly minTextLength: number;
readonly maxHorizontalOverflowPx: number;
readonly outputLimits: {
readonly failures: number;
readonly consoleErrors: number;
readonly responseErrors: number;
readonly screenshots: number;
readonly textPreviewChars: number;
};
readonly viewports: readonly HwlabRuntimeWebProbeConsoleViewportSpec[];
readonly routes: readonly HwlabRuntimeWebProbeConsoleRouteSpec[];
readonly workflows: {
readonly projects?: HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec;
readonly hwpod?: HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec;
readonly mdtodo?: HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec;
readonly agentObserver?: HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec;
};
}
export function parseWebProbeConsoleVerificationProfiles(
value: unknown,
path: string,
): Readonly<Record<string, HwlabRuntimeWebProbeConsoleVerificationProfileSpec>> {
const raw = record(value, path);
const entries = Object.entries(raw);
if (entries.length < 1 || entries.length > 20) throw new Error(`${path} must contain 1-20 profiles`);
return Object.fromEntries(entries.map(([name, profile]) => {
simpleId(name, `${path}.${name}`);
return [name, consoleProfile(profile, `${path}.${name}`)];
}));
}
function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
const raw = record(value, path);
onlyKeys(raw, [
"navigationTimeoutMs",
"settleMs",
"commandTimeoutSeconds",
"minTextLength",
"maxHorizontalOverflowPx",
"outputLimits",
"viewports",
"routes",
"workflows",
], path);
const viewports = array(raw.viewports, `${path}.viewports`, 1, 8).map((item, index) => viewport(item, `${path}.viewports[${index}]`));
const routes = array(raw.routes, `${path}.routes`, 1, 100).map((item, index) => route(item, `${path}.routes[${index}]`));
unique(viewports.map((item) => item.id), `${path}.viewports[].id`);
unique(routes.map((item) => item.id), `${path}.routes[].id`);
unique(routes.map((item) => item.path), `${path}.routes[].path`);
const routeIds = new Set(routes.map((item) => item.id));
const workflows = workflowConfig(raw.workflows, `${path}.workflows`, routeIds);
const output = record(raw.outputLimits, `${path}.outputLimits`);
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;
if (matrixScreenshots + workflowScreenshots > screenshotBudget) {
throw new Error(`${path}.outputLimits.screenshots=${screenshotBudget} is below the declared matrix/workflow screenshot count ${matrixScreenshots + workflowScreenshots}`);
}
return {
navigationTimeoutMs: integer(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`, 1000, 120_000),
settleMs: integer(raw.settleMs, `${path}.settleMs`, 0, 30_000),
commandTimeoutSeconds: integer(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`, 60, 3600),
minTextLength: integer(raw.minTextLength, `${path}.minTextLength`, 1, 100_000),
maxHorizontalOverflowPx: integer(raw.maxHorizontalOverflowPx, `${path}.maxHorizontalOverflowPx`, 0, 1000),
outputLimits: {
failures: integer(output.failures, `${path}.outputLimits.failures`, 1, 1000),
consoleErrors: integer(output.consoleErrors, `${path}.outputLimits.consoleErrors`, 1, 1000),
responseErrors: integer(output.responseErrors, `${path}.outputLimits.responseErrors`, 1, 1000),
screenshots: screenshotBudget,
textPreviewChars: integer(output.textPreviewChars, `${path}.outputLimits.textPreviewChars`, 20, 2000),
},
viewports,
routes,
workflows,
};
}
function viewport(value: unknown, path: string): HwlabRuntimeWebProbeConsoleViewportSpec {
const raw = record(value, path);
onlyKeys(raw, ["id", "width", "height"], path);
return {
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
width: integer(raw.width, `${path}.width`, 240, 7680),
height: integer(raw.height, `${path}.height`, 240, 4320),
};
}
function route(value: unknown, path: string): HwlabRuntimeWebProbeConsoleRouteSpec {
const raw = record(value, path);
onlyKeys(raw, ["id", "path", "readySelector", "screenshot"], path);
return {
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
path: absolutePath(text(raw.path, `${path}.path`), `${path}.path`),
readySelector: boundedText(raw.readySelector, `${path}.readySelector`, 500),
screenshot: boolean(raw.screenshot, `${path}.screenshot`),
};
}
function workflowConfig(
value: unknown,
path: string,
routeIds: ReadonlySet<string>,
): HwlabRuntimeWebProbeConsoleVerificationProfileSpec["workflows"] {
const raw = record(value, path);
onlyKeys(raw, ["projects", "hwpod", "mdtodo", "agentObserver"], path);
return {
...(raw.projects === undefined ? {} : { projects: projectsWorkflow(raw.projects, `${path}.projects`, routeIds) }),
...(raw.hwpod === undefined ? {} : { hwpod: hwpodWorkflow(raw.hwpod, `${path}.hwpod`, routeIds) }),
...(raw.mdtodo === undefined ? {} : { mdtodo: mdtodoWorkflow(raw.mdtodo, `${path}.mdtodo`, routeIds) }),
...(raw.agentObserver === undefined ? {} : { agentObserver: agentObserverWorkflow(raw.agentObserver, `${path}.agentObserver`, routeIds) }),
};
}
function projectsWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, ["routeId", "listButtonName", "viewQueryKey"], path);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
viewQueryKey: simpleId(text(raw.viewQueryKey, `${path}.viewQueryKey`), `${path}.viewQueryKey`),
};
}
function hwpodWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, [
"devicesRouteId", "nodesRouteId", "onboardingRouteId", "listButtonName", "cardButtonName",
"devicesListSelector", "devicesCardSelector", "nodesListSelector", "pcbTraceSelector", "detailButtonName",
"detailPathPrefix", "onboardingCommandSelector", "requiredCommandPrefixes", "downloadSelector",
"forbiddenDownloadSubstrings", "forbiddenInternalSelector",
], path);
return {
devicesRouteId: routeReference(raw.devicesRouteId, `${path}.devicesRouteId`, routeIds),
nodesRouteId: routeReference(raw.nodesRouteId, `${path}.nodesRouteId`, routeIds),
onboardingRouteId: routeReference(raw.onboardingRouteId, `${path}.onboardingRouteId`, routeIds),
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
cardButtonName: boundedText(raw.cardButtonName, `${path}.cardButtonName`, 100),
devicesListSelector: boundedText(raw.devicesListSelector, `${path}.devicesListSelector`, 500),
devicesCardSelector: boundedText(raw.devicesCardSelector, `${path}.devicesCardSelector`, 500),
nodesListSelector: boundedText(raw.nodesListSelector, `${path}.nodesListSelector`, 500),
pcbTraceSelector: boundedText(raw.pcbTraceSelector, `${path}.pcbTraceSelector`, 500),
detailButtonName: boundedText(raw.detailButtonName, `${path}.detailButtonName`, 100),
detailPathPrefix: absolutePath(text(raw.detailPathPrefix, `${path}.detailPathPrefix`), `${path}.detailPathPrefix`),
onboardingCommandSelector: boundedText(raw.onboardingCommandSelector, `${path}.onboardingCommandSelector`, 500),
requiredCommandPrefixes: textArray(raw.requiredCommandPrefixes, `${path}.requiredCommandPrefixes`, 1, 20, 300),
downloadSelector: boundedText(raw.downloadSelector, `${path}.downloadSelector`, 500),
forbiddenDownloadSubstrings: textArray(raw.forbiddenDownloadSubstrings, `${path}.forbiddenDownloadSubstrings`, 1, 20, 300),
forbiddenInternalSelector: boundedText(raw.forbiddenInternalSelector, `${path}.forbiddenInternalSelector`, 500),
};
}
function mdtodoWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, [
"routeId", "taskSelector", "taskDeepLinkSegment", "taskDetailSelector", "reportLinkSelector",
"reportPreviewSelector", "fullscreenButtonSelector", "fullscreenDialogSelector",
], path);
const segment = boundedText(raw.taskDeepLinkSegment, `${path}.taskDeepLinkSegment`, 100);
if (!segment.startsWith("/")) throw new Error(`${path}.taskDeepLinkSegment must start with /`);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
taskSelector: boundedText(raw.taskSelector, `${path}.taskSelector`, 500),
taskDeepLinkSegment: segment,
taskDetailSelector: boundedText(raw.taskDetailSelector, `${path}.taskDetailSelector`, 500),
reportLinkSelector: boundedText(raw.reportLinkSelector, `${path}.reportLinkSelector`, 500),
reportPreviewSelector: boundedText(raw.reportPreviewSelector, `${path}.reportPreviewSelector`, 500),
fullscreenButtonSelector: boundedText(raw.fullscreenButtonSelector, `${path}.fullscreenButtonSelector`, 500),
fullscreenDialogSelector: boundedText(raw.fullscreenDialogSelector, `${path}.fullscreenDialogSelector`, 500),
};
}
function agentObserverWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, ["routeId", "eventSourcePath", "phaseSelector", "blockedPhases", "streamSettleMs", "views"], path);
const views = array(raw.views, `${path}.views`, 1, 10).map((item, index) => agentView(item, `${path}.views[${index}]`));
unique(views.map((item) => item.queryValue), `${path}.views[].queryValue`);
unique(views.map((item) => item.screenshotName), `${path}.views[].screenshotName`);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
eventSourcePath: absolutePath(text(raw.eventSourcePath, `${path}.eventSourcePath`), `${path}.eventSourcePath`),
phaseSelector: boundedText(raw.phaseSelector, `${path}.phaseSelector`, 500),
blockedPhases: textArray(raw.blockedPhases, `${path}.blockedPhases`, 1, 20, 100),
streamSettleMs: integer(raw.streamSettleMs, `${path}.streamSettleMs`, 0, 120_000),
views,
};
}
function agentView(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentViewSpec {
const raw = record(value, path);
onlyKeys(raw, ["buttonName", "queryValue", "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 {
buttonName: boundedText(raw.buttonName, `${path}.buttonName`, 100),
queryValue: simpleId(text(raw.queryValue, `${path}.queryValue`), `${path}.queryValue`),
screenshotName,
};
}
function routeReference(value: unknown, path: string, routeIds: ReadonlySet<string>): string {
const id = simpleId(text(value, path), path);
if (!routeIds.has(id)) throw new Error(`${path} references unknown route ${id}`);
return id;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function array(value: unknown, path: string, min: number, max: number): unknown[] {
if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(`${path} must contain ${min}-${max} items`);
return value;
}
function text(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) throw new Error(`${path} must be a non-empty string`);
return value;
}
function boundedText(value: unknown, path: string, max: number): string {
const result = text(value, path);
if (result.length > max) throw new Error(`${path} must be at most ${max} chars`);
return result;
}
function textArray(value: unknown, path: string, min: number, max: number, maxChars: number): readonly string[] {
return array(value, path, min, max).map((item, index) => boundedText(item, `${path}[${index}]`, maxChars));
}
function simpleId(value: string, path: string): string {
if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 100) throw new Error(`${path} must be a simple id up to 100 chars`);
return value;
}
function absolutePath(value: string, path: string): string {
if (!value.startsWith("/") || value.length > 300) throw new Error(`${path} must be an absolute path up to 300 chars`);
return value;
}
function integer(value: unknown, path: string, min: number, max: number): number {
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new Error(`${path} must be an integer ${min}-${max}`);
return value as number;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be boolean`);
return value;
}
function onlyKeys(raw: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allow = new Set(allowed);
const unknown = Object.keys(raw).filter((key) => !allow.has(key));
if (unknown.length > 0) throw new Error(`${path} has unsupported fields: ${unknown.join(", ")}`);
}
function unique(values: readonly string[], path: string): void {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) throw new Error(`${path} contains duplicate ${value}`);
seen.add(value);
}
}
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "bun:test";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { parseWebProbeConsoleVerificationProfiles } from "./hwlab-node-web-probe-console-profile";
import { nodeWebProbeConsoleVerifyScript } from "./hwlab-node-web-probe-console-verify";
import { webProbeScriptCommandGovernance } from "./hwlab-node/web-observe-scripts";
import { parseNodeWebProbeOptions } from "./hwlab-node/web-probe";
function minimalProfile(): Record<string, unknown> {
return {
navigationTimeoutMs: 30000,
settleMs: 100,
commandTimeoutSeconds: 120,
minTextLength: 8,
maxHorizontalOverflowPx: 2,
outputLimits: {
failures: 20,
consoleErrors: 20,
responseErrors: 20,
screenshots: 1,
textPreviewChars: 120,
},
viewports: [{ id: "desktop", width: 1920, height: 1080 }],
routes: [{ id: "home", path: "/home", readySelector: "#app", screenshot: true }],
workflows: {},
};
}
test("Cloud Console verification profile is owned by lane YAML", () => {
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
const profileName = spec.webProbe?.defaultConsoleVerificationProfile;
const profile = profileName === undefined ? undefined : spec.webProbe?.consoleVerificationProfiles?.[profileName];
assert.equal(profileName, "cloud-console");
assert.ok(profile);
assert.deepEqual(profile.viewports.map((item) => [item.width, item.height]), [[1920, 1080], [960, 600], [390, 844]]);
assert.equal(profile.routes.length, 20);
assert.equal(profile.routes.filter((item) => item.screenshot).length * profile.viewports.length + (profile.workflows.agentObserver?.views.length ?? 0), 24);
assert.deepEqual(profile.workflows.hwpod?.requiredCommandPrefixes, ["Get-FileHash .\\", "python .\\"]);
});
test("Cloud Console profile parser rejects unknown fields and duplicate route ids", () => {
assert.throws(() => parseWebProbeConsoleVerificationProfiles({
fixture: { ...minimalProfile(), hiddenFallback: true },
}, "fixture.consoleVerificationProfiles"), /unsupported fields: hiddenFallback/u);
const duplicate = minimalProfile();
duplicate.routes = [
{ id: "same", path: "/one", readySelector: "#app", screenshot: false },
{ id: "same", path: "/two", readySelector: "#app", screenshot: false },
];
assert.throws(() => parseWebProbeConsoleVerificationProfiles({ fixture: duplicate }, "fixture.consoleVerificationProfiles"), /duplicate same/u);
});
test("generated Cloud Console runner is valid JavaScript and contains no node or lane authority", () => {
const profile = parseWebProbeConsoleVerificationProfiles({ fixture: minimalProfile() }, "fixture.consoleVerificationProfiles").fixture;
assert.ok(profile);
const script = nodeWebProbeConsoleVerifyScript("fixture", profile, "config/example.yaml#profiles.fixture");
assert.doesNotThrow(() => new Function(script.replace("export default ", "")));
const generatorSource = readFileSync(rootPath("scripts/src/hwlab-node-web-probe-console-verify.ts"), "utf8");
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);
});
test("console-verify parses into the existing managed script action", () => {
const options = parseNodeWebProbeOptions([
"console-verify",
"--node", "NC01",
"--lane", "v03",
"--origin", "internal",
"--profile", "cloud-console",
]);
assert.equal(options.action, "script");
if (options.action !== "script") return;
assert.equal(options.originName, "internal");
assert.equal(options.viewport, "1920x1080");
assert.equal(options.commandTimeoutSeconds, 900);
assert.equal(options.suppressAdHocWarning, true);
assert.equal(options.scriptSource.kind, "generated");
assert.equal(options.scriptSource.path, "builtin:console-verify/cloud-console");
assert.match(options.commandLabel ?? "", /web-probe console-verify/u);
const governance = webProbeScriptCommandGovernance(options);
assert.deepEqual(governance.warnings, []);
assert.equal(governance.commandPromotionHint.repoOwnedTypedCommand, true);
assert.equal(governance.commandPromotionHint.action, "reuse-repo-owned-typed-command");
});
test("console-verify rejects profiles not declared by owning YAML", () => {
assert.throws(() => parseNodeWebProbeOptions([
"console-verify",
"--node", "NC01",
"--lane", "v03",
"--origin", "internal",
"--profile", "not-configured",
]), /unknown --profile not-configured/u);
});
@@ -0,0 +1,347 @@
// Responsibility: Generate the repo-owned Cloud Console route-matrix browser command.
import type { HwlabRuntimeWebProbeConsoleVerificationProfileSpec } from "./hwlab-node-web-probe-console-profile";
export function nodeWebProbeConsoleVerifyScript(
profileName: string,
profile: HwlabRuntimeWebProbeConsoleVerificationProfileSpec,
configRef: string,
): string {
const config = { profileName, configRef, ...profile };
return String.raw`const config = ${JSON.stringify(config)};
export default async function consoleVerify({ page, context, goto, reloadStable, wait, screenshot, jsonArtifact, recordStep }) {
const failures = [];
const pages = [];
const screenshots = [];
const consoleErrors = [];
const responseErrors = [];
let totalFailureCount = 0;
let totalConsoleErrorCount = 0;
let totalResponseErrorCount = 0;
const addFailure = (message) => {
totalFailureCount += 1;
if (failures.length < config.outputLimits.failures) failures.push(String(message));
};
const addConsoleError = (message) => {
totalConsoleErrorCount += 1;
if (consoleErrors.length < config.outputLimits.consoleErrors) consoleErrors.push(String(message).slice(0, 300));
};
const addResponseError = (response) => {
totalResponseErrorCount += 1;
if (responseErrors.length < config.outputLimits.responseErrors) {
responseErrors.push({ status: response.status(), url: response.url().replace(/[?#].*$/u, "") });
}
};
await context.addInitScript(() => {
const OriginalEventSource = window.EventSource;
window.__unideskConsoleEventSources = [];
if (!OriginalEventSource || window.__unideskConsoleEventSourceWrapped === true) return;
window.__unideskConsoleEventSourceWrapped = true;
function TrackedEventSource(url, options) {
const instance = new OriginalEventSource(url, options);
window.__unideskConsoleEventSources.push(String(url));
return instance;
}
TrackedEventSource.prototype = OriginalEventSource.prototype;
TrackedEventSource.CONNECTING = OriginalEventSource.CONNECTING;
TrackedEventSource.OPEN = OriginalEventSource.OPEN;
TrackedEventSource.CLOSED = OriginalEventSource.CLOSED;
window.EventSource = TrackedEventSource;
});
page.on("console", (message) => {
if (message.type() === "error") addConsoleError(message.text());
});
page.on("response", (response) => {
if (response.status() >= 500) addResponseError(response);
});
const routeById = new Map(config.routes.map((route) => [route.id, route]));
const requiredRoute = (id) => {
const route = routeById.get(id);
if (!route) throw new Error("console verification profile references missing route " + id);
return route;
};
const gotoRoute = async (id) => {
const route = requiredRoute(id);
await goto(route.path, { selectors: [route.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
if (config.settleMs > 0) await wait(config.settleMs);
return route;
};
const captureScreenshot = async (name) => {
if (screenshots.length >= config.outputLimits.screenshots) {
addFailure("screenshot budget exceeded before " + name);
return null;
}
try {
const artifact = await screenshot(name);
screenshots.push(artifact);
return artifact;
} catch (error) {
addFailure("screenshot " + name + " failed: " + errorMessage(error));
return null;
}
};
for (const viewport of config.viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const beforeCount = pages.length;
for (const route of config.routes) {
await gotoRoute(route.id);
const metrics = await page.evaluate(({ requestedRoute, viewportId, readySelector, textPreviewChars }) => {
const html = document.documentElement;
const body = document.body;
const app = document.querySelector("#app");
const root = document.querySelector(readySelector);
const text = (app?.textContent || "").replace(/\s+/gu, " ").trim();
const rootRect = root?.getBoundingClientRect();
return {
requestedRoute,
viewportId,
finalPath: location.pathname + location.search,
title: document.title,
textLength: text.length,
textPreview: text.slice(0, textPreviewChars),
appVisible: Boolean(app && app.getBoundingClientRect().width > 0 && app.getBoundingClientRect().height > 0),
rootVisible: Boolean(rootRect && rootRect.width > 0 && rootRect.height > 0),
horizontalOverflowPx: Math.max(0, html.scrollWidth - innerWidth, body.scrollWidth - innerWidth),
documentHeight: Math.max(html.scrollHeight, body.scrollHeight),
};
}, {
requestedRoute: route.path,
viewportId: viewport.id,
readySelector: route.readySelector,
textPreviewChars: config.outputLimits.textPreviewChars,
});
pages.push(metrics);
if (!metrics.appVisible || !metrics.rootVisible || metrics.textLength < config.minTextLength) {
addFailure(viewport.id + " " + route.path + ": app/root blank");
}
if (metrics.finalPath.startsWith("/login")) addFailure(viewport.id + " " + route.path + ": redirected to login");
if (metrics.horizontalOverflowPx > config.maxHorizontalOverflowPx) {
addFailure(viewport.id + " " + route.path + ": horizontal overflow " + metrics.horizontalOverflowPx + "px");
}
if (route.screenshot) await captureScreenshot(viewport.id + "-" + route.id + ".png");
}
recordStep("console-matrix-" + viewport.id, {
ok: totalFailureCount === 0,
viewport,
routeSamples: pages.length - beforeCount,
totalFailureCount,
});
}
const workflowEvidence = {};
if (config.workflows.projects) workflowEvidence.projects = await verifyProjects(config.workflows.projects);
if (config.workflows.hwpod) workflowEvidence.hwpod = await verifyHwpod(config.workflows.hwpod);
if (config.workflows.mdtodo) workflowEvidence.mdtodo = await verifyMdtodo(config.workflows.mdtodo);
if (config.workflows.agentObserver) workflowEvidence.agentObserver = await verifyAgentObserver(config.workflows.agentObserver);
if (totalResponseErrorCount > 0) addFailure("HTTP 5xx responses: " + totalResponseErrorCount);
const evidence = {
profileName: config.profileName,
configRef: config.configRef,
pages,
failures,
failureCount: totalFailureCount,
consoleErrors,
consoleErrorCount: totalConsoleErrorCount,
responseErrors,
responseErrorCount: totalResponseErrorCount,
screenshots,
workflowEvidence,
valuesRedacted: true,
};
const report = await jsonArtifact("cloud-console-verification.json", evidence);
const ok = totalFailureCount === 0;
recordStep("console-verification-final", {
ok,
profileName: config.profileName,
routeSamples: pages.length,
failureCount: totalFailureCount,
screenshotCount: screenshots.length,
});
return {
ok,
status: ok ? "pass" : "blocked",
profileName: config.profileName,
configRef: config.configRef,
routeSamples: pages.length,
viewportCount: config.viewports.length,
failureCount: totalFailureCount,
failures,
consoleErrorCount: totalConsoleErrorCount,
responseErrorCount: totalResponseErrorCount,
screenshotCount: screenshots.length,
report,
workflowEvidence,
issueEvidence: {
profileName: config.profileName,
configRef: config.configRef,
reportSha256: typeof report?.sha256 === "string" ? report.sha256 : null,
screenshotSha256: screenshots.map((item) => item?.sha256).filter((item) => typeof item === "string"),
failureCount: totalFailureCount,
failures,
valuesRedacted: true,
},
valuesRedacted: true,
};
async function verifyProjects(workflow) {
await gotoRoute(workflow.routeId);
const button = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await button.count() < 1) {
addFailure("Projects: list view control missing");
return { listView: false };
}
await button.first().click();
await wait(config.settleMs);
const queryPresent = new URL(page.url()).searchParams.has(workflow.viewQueryKey);
if (!queryPresent) addFailure("Projects: list view did not enter URL state");
return { listView: queryPresent, finalPath: new URL(page.url()).pathname + new URL(page.url()).search };
}
async function verifyHwpod(workflow) {
await gotoRoute(workflow.devicesRouteId);
const listButton = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await listButton.count() < 1) {
addFailure("HWPOD devices: list view control missing");
} else {
await listButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.devicesListSelector).count() < 1) addFailure("HWPOD devices: list projection missing");
const cardButton = page.getByRole("button", { name: workflow.cardButtonName, exact: true });
if (await cardButton.count() < 1) addFailure("HWPOD devices: card view control missing");
else {
await cardButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.devicesCardSelector).count() < 1) addFailure("HWPOD devices: card projection missing");
}
}
await page.emulateMedia({ reducedMotion: "reduce" });
const reducedMotion = await page.evaluate((selector) => ({
matches: matchMedia("(prefers-reduced-motion: reduce)").matches,
animations: [...document.querySelectorAll(selector)].map((element) => getComputedStyle(element).animationName),
}), workflow.pcbTraceSelector);
if (!reducedMotion.matches || reducedMotion.animations.some((name) => name !== "none")) {
addFailure("HWPOD devices: reduced-motion does not stop PCB animation");
}
await page.emulateMedia({ reducedMotion: "no-preference" });
let deviceDeepLink = null;
const detail = page.getByRole("button", { name: workflow.detailButtonName, exact: true }).first();
if (await detail.count() > 0) {
await detail.click();
await wait(config.settleMs);
deviceDeepLink = new URL(page.url()).pathname;
const basePath = requiredRoute(workflow.devicesRouteId).path.split("?")[0];
if (!deviceDeepLink.startsWith(workflow.detailPathPrefix) || deviceDeepLink === basePath) {
addFailure("HWPOD devices: invalid detail deep link " + deviceDeepLink);
}
await reloadStable({ selectors: [requiredRoute(workflow.devicesRouteId).readySelector], readinessTimeoutMs: config.navigationTimeoutMs });
if (new URL(page.url()).pathname !== deviceDeepLink) addFailure("HWPOD devices: deep link did not survive refresh");
}
await gotoRoute(workflow.nodesRouteId);
const nodeListButton = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await nodeListButton.count() < 1) addFailure("HWPOD nodes: list view control missing");
else {
await nodeListButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.nodesListSelector).count() < 1) addFailure("HWPOD nodes: list projection missing");
}
await gotoRoute(workflow.onboardingRouteId);
const onboarding = await page.evaluate((workflowConfig) => ({
commands: [...document.querySelectorAll(workflowConfig.onboardingCommandSelector)].map((element) => element.textContent?.trim() || ""),
downloadHref: document.querySelector(workflowConfig.downloadSelector)?.getAttribute("href") || null,
internalVisibleCount: document.querySelectorAll(workflowConfig.forbiddenInternalSelector).length,
}), workflow);
for (const prefix of workflow.requiredCommandPrefixes) {
if (!onboarding.commands.some((command) => command.startsWith(prefix))) addFailure("HWPOD onboarding: required command prefix missing: " + prefix);
}
for (const forbidden of workflow.forbiddenDownloadSubstrings) {
if (onboarding.downloadHref?.includes(forbidden)) addFailure("HWPOD onboarding: forbidden download endpoint visible: " + forbidden);
}
if (onboarding.internalVisibleCount > 0) addFailure("HWPOD onboarding: internal entrypoint token visible");
return { deviceDeepLink, reducedMotion, onboarding };
}
async function verifyMdtodo(workflow) {
await gotoRoute(workflow.routeId);
const firstTask = page.locator(workflow.taskSelector).first();
if (await firstTask.count() < 1) {
addFailure("MDTODO: projected task tree is empty");
return { taskDeepLink: null, reportPreviewVisible: false, reportFullscreenVisible: false };
}
await firstTask.click();
await wait(config.settleMs);
const taskDeepLink = new URL(page.url()).pathname;
if (!taskDeepLink.includes(workflow.taskDeepLinkSegment)) addFailure("MDTODO: task deep link missing " + taskDeepLink);
await reloadStable({ selectors: [workflow.taskDetailSelector], readinessTimeoutMs: config.navigationTimeoutMs });
if (new URL(page.url()).pathname !== taskDeepLink) addFailure("MDTODO: task deep link did not survive refresh");
let reportPreviewVisible = false;
let reportFullscreenVisible = false;
const reportLink = page.locator(workflow.reportLinkSelector).first();
if (await reportLink.count() > 0) {
await reportLink.click();
await wait(config.settleMs);
reportPreviewVisible = await page.locator(workflow.reportPreviewSelector).isVisible().catch(() => false);
if (!reportPreviewVisible) addFailure("MDTODO: report preview did not become visible");
const fullscreen = page.locator(workflow.fullscreenButtonSelector).first();
if (await fullscreen.count() > 0) {
await fullscreen.click();
await wait(config.settleMs);
reportFullscreenVisible = await page.locator(workflow.fullscreenDialogSelector).isVisible().catch(() => false);
if (!reportFullscreenVisible) addFailure("MDTODO: report fullscreen did not become visible");
await page.keyboard.press("Escape");
}
}
return { taskDeepLink, reportPreviewVisible, reportFullscreenVisible };
}
async function verifyAgentObserver(workflow) {
await gotoRoute(workflow.routeId);
if (workflow.streamSettleMs > 0) await wait(workflow.streamSettleMs);
const before = await agentObserverState(workflow);
if (before.sourceCount !== 1) addFailure("AgentRun: expected one product EventSource, observed " + before.sourceCount);
const viewResults = [];
for (const view of workflow.views) {
const button = page.getByRole("button", { name: view.buttonName, exact: true });
if (await button.count() < 1) {
addFailure("AgentRun: missing view button " + view.buttonName);
continue;
}
await button.first().click();
await wait(config.settleMs);
const queryValue = new URL(page.url()).searchParams.get("view");
if (queryValue !== view.queryValue) addFailure("AgentRun: view " + view.buttonName + " URL state expected " + view.queryValue + ", got " + queryValue);
const artifact = await captureScreenshot(view.screenshotName);
viewResults.push({ buttonName: view.buttonName, queryValue, 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 };
}
async function agentObserverState(workflow) {
return await page.evaluate(({ eventSourcePath, phaseSelector }) => {
const sources = Array.isArray(window.__unideskConsoleEventSources) ? window.__unideskConsoleEventSources : [];
return {
sources,
sourceCount: sources.filter((url) => String(url).includes(eventSourcePath)).length,
phase: document.querySelector(phaseSelector)?.textContent?.trim() || null,
};
}, workflow);
}
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
`;
}
+62 -2
View File
@@ -13,7 +13,8 @@ import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure } from "../ssh";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneCiGitWorkspaceSecret, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeConsoleVerifyScript } from "../hwlab-node-web-probe-console-verify";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
@@ -2065,7 +2066,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke --node NODE --lane vNN [--origin internal|public]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify --node NODE --lane vNN [--origin internal|public]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
@@ -2100,6 +2101,65 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const browserProxyMode = browserProxyModeRaw === undefined ? undefined : parseWebProbeBrowserProxyMode(browserProxyModeRaw);
return resolveNodeWebProbeCliOrigin(spec, optionValue(args, "--origin"), optionValue(args, "--url"), browserProxyMode);
};
if (actionRaw === "console-verify") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--profile",
"--timeout-ms",
"--browser-proxy-mode",
"--command-timeout-seconds",
]), new Set([]));
const profiles = spec.webProbe?.consoleVerificationProfiles;
const profileName = optionValue(args, "--profile") ?? spec.webProbe?.defaultConsoleVerificationProfile;
if (profiles === undefined || profileName === undefined) {
throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare a default Cloud Console verification profile`);
}
const profile = profiles[profileName];
if (profile === undefined) {
throw new Error(`web-probe console-verify unknown --profile ${profileName}; configured profiles: ${Object.keys(profiles).sort().join(", ")}`);
}
const firstViewport = profile.viewports[0];
if (firstViewport === undefined) throw new Error(`web-probe console-verify profile ${profileName} has no viewport`);
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", profile.navigationTimeoutMs, 120_000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", profile.commandTimeoutSeconds, 3600);
const configRef = `${hwlabRuntimeLaneConfigPath()}#templates.*.webProbeWorkbench.consoleVerificationProfiles.${profileName}`;
const scriptText = nodeWebProbeConsoleVerifyScript(profileName, profile, configRef);
const selectedOrigin = resolveOrigin();
const commandLabel = `web-probe console-verify --node ${node} --lane ${lane}${selectedOrigin.originName === "custom" ? "" : ` --origin ${selectedOrigin.originName}`} --profile ${profileName}`;
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
timeoutMs,
viewport: `${firstViewport.width}x${firstViewport.height}`,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
commandLabel,
suppressAdHocWarning: true,
generatedHints: [
"Cloud Console verification is a repo-owned typed command; route, viewport, selector, workflow and evidence bounds come from the selected owning YAML profile.",
"The command reuses the managed semantic origin, authentication, remote browser, physical MemAvailable guard, report recovery and screenshot contracts.",
],
generatedPreferredCommands: {
rerun: commandLabel,
},
scriptSource: {
kind: "generated",
path: `builtin:console-verify/${profileName}`,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
if (actionRaw === "screenshot") {
assertKnownOptions(args.slice(1), new Set([
"--node",