feat: 增加 YAML-first Cloud Console 验收命令
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user