From 8fad1ff10b9536da312ec6913bcaa8f72a5b9327 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 02:49:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20YAML-first=20Cloud?= =?UTF-8?q?=20Console=20=E9=AA=8C=E6=94=B6=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-webdev/SKILL.md | 7 + .../unidesk-webdev/references/web-probe.md | 8 + config/hwlab-node-lanes.yaml | 157 ++++++++ .../R2_Task_Report.md | 44 +++ .../MDTODO/web-observe-runtime-reliability.md | 4 + scripts/src/help.ts | 5 +- scripts/src/hwlab-node-help.ts | 6 +- scripts/src/hwlab-node-lanes.ts | 18 + .../hwlab-node-web-probe-console-profile.ts | 335 +++++++++++++++++ ...wlab-node-web-probe-console-verify.test.ts | 99 +++++ .../hwlab-node-web-probe-console-verify.ts | 347 ++++++++++++++++++ scripts/src/hwlab-node/web-probe.ts | 64 +++- 12 files changed, 1087 insertions(+), 7 deletions(-) create mode 100644 docs/MDTODO/details/web-observe-runtime-reliability/R2_Task_Report.md create mode 100644 scripts/src/hwlab-node-web-probe-console-profile.ts create mode 100644 scripts/src/hwlab-node-web-probe-console-verify.test.ts create mode 100644 scripts/src/hwlab-node-web-probe-console-verify.ts diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 86f7c4d2..96a45482 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -41,6 +41,13 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - `observe collect --view turn-summary --command-id ` 必须按 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 `,同时在结构化结果首字段返回 `commandPromotionHint`;一次性脚本在重复前必须沉淀为 repo-owned typed command。内置 generated command(例如 `web-probe opencode-smoke`)会明确返回可直接复用且不附加临时脚本 warning,禁止复制其生成脚本另起一次性入口。 +- Cloud Console 全路由、多视口和通用产品交互收口: + - 使用 `web-probe console-verify --node --lane --origin internal|public --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。 diff --git a/.agents/skills/unidesk-webdev/references/web-probe.md b/.agents/skills/unidesk-webdev/references/web-probe.md index abb5c36c..73d354a8 100644 --- a/.agents/skills/unidesk-webdev/references/web-probe.md +++ b/.agents/skills/unidesk-webdev/references/web-probe.md @@ -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 --lane --origin internal|public --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。 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index d7d52a18..aaa20225 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -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 diff --git a/docs/MDTODO/details/web-observe-runtime-reliability/R2_Task_Report.md b/docs/MDTODO/details/web-observe-runtime-reliability/R2_Task_Report.md new file mode 100644 index 00000000..7749a952 --- /dev/null +++ b/docs/MDTODO/details/web-observe-runtime-reliability/R2_Task_Report.md @@ -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 --lane --origin internal|public --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 不能作为页面通过证据。 diff --git a/docs/MDTODO/web-observe-runtime-reliability.md b/docs/MDTODO/web-observe-runtime-reliability.md index 873c5aa0..2c06329a 100644 --- a/docs/MDTODO/web-observe-runtime-reliability.md +++ b/docs/MDTODO/web-observe-runtime-reliability.md @@ -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)。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 49d8d071..62fc4b72 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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 --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 --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 --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 --lane ", + command: "web-probe run|script|console-verify|observe|sentinel --node --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 --lane --origin internal --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", diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 6537b607..9c721358 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -86,13 +86,11 @@ export function hwlabNodeWebProbeHelp(): Record { 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 --lane --origin internal --wait-messages-ms 1000", - "bun scripts/cli.ts web-probe run --node --lane --origin public --fresh-session --message 'public exposure closeout'", "bun scripts/cli.ts web-probe opencode-smoke --node --lane --origin internal --message 'hi'", + "bun scripts/cli.ts web-probe console-verify --node --lane --origin internal --profile ", "bun scripts/cli.ts web-probe script --node --lane --origin internal --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", - "bun scripts/cli.ts web-probe screenshot --node --lane --origin public --path /workbench --viewport 390x844 --name workbench-public-mobile.png", "bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000", - "bun scripts/cli.ts web-probe observe start --node --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 '' --second-text ''", "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 { 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 { "`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 ` 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 instead of repeating --node/--lane/--state-dir.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index ba3f1907..63c045fa 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -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>; readonly realtimeFanoutProfiles?: Readonly>; 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 ? {} diff --git a/scripts/src/hwlab-node-web-probe-console-profile.ts b/scripts/src/hwlab-node-web-probe-console-profile.ts new file mode 100644 index 00000000..cbf0c081 --- /dev/null +++ b/scripts/src/hwlab-node-web-probe-console-profile.ts @@ -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> { + 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, +): 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): 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): 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): 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): 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 { + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +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, 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(); + for (const value of values) { + if (seen.has(value)) throw new Error(`${path} contains duplicate ${value}`); + seen.add(value); + } +} diff --git a/scripts/src/hwlab-node-web-probe-console-verify.test.ts b/scripts/src/hwlab-node-web-probe-console-verify.test.ts new file mode 100644 index 00000000..5d24464f --- /dev/null +++ b/scripts/src/hwlab-node-web-probe-console-verify.test.ts @@ -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 { + 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); +}); diff --git a/scripts/src/hwlab-node-web-probe-console-verify.ts b/scripts/src/hwlab-node-web-probe-console-verify.ts new file mode 100644 index 00000000..b1e58405 --- /dev/null +++ b/scripts/src/hwlab-node-web-probe-console-verify.ts @@ -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); +} +`; +} diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 65c9bcc4..460abc4a 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -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