diff --git a/AGENTS.md b/AGENTS.md index acba6672..9c8f108a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Cloud Web 静态检查:`npm run web:check` - Cloud Web 构建:`npm run web:build` - Cloud Web M3 只读护栏:`npm run web:m3-readonly` -- Cloud Workbench 布局/遮挡 smoke:`node scripts/dev-cloud-workbench-layout-smoke.mjs --static` +- Cloud Workbench 布局/遮挡 smoke:`npm run web:layout`;local-build 用 `npm run web:layout:build`;DEV deploy 后用 `npm run web:layout:live`。 - DEV artifact 发布预检:`npm run dev-artifact:preflight` - DEV CD 单事务发布/应用/验证:`npm run dev-cd:apply` - runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight` diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index f6b2488d..9bf33404 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -90,10 +90,16 @@ node scripts/dev-cloud-workbench-layout-smoke.mjs --static node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ ``` +`npm run web:check` 是 Cloud Workbench 的 repo-owned 前端检查入口。它会 +运行源码合同、移动视口 smoke、本地 Code Agent fixture,以及 #273 +SOURCE/static layout smoke;默认不访问公网 DEV,不部署、不重启服务,也不调用 +M3 IO。 + The static smoke checks both sides of the route contract: `/` remains the user-facing workbench, while `/gate` and `/diagnostics/gate` remain available for internal diagnostics. This PR-class check is intentionally source-only and -does not deploy, restart services, or run browser e2e. +does not deploy, restart services, call public DEV, or run the full live Code +Agent browser journey. Live checks against `http://74.48.78.17:16666/` are read-only and must report blocked/skip instead of false green when the browser or network path is not @@ -118,17 +124,33 @@ dispatch 绕过用户命中。 常用命令: ```sh -node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json -node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json -node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json +npm run web:check +npm run web:layout +npm run web:layout:build +npm run web:layout:live ``` +三种模式分工: + +| Mode | Command | When | +| --- | --- | --- | +| SOURCE/static | `npm run web:check` 或 `npm run web:layout` | PR/source gate 默认运行,不依赖公网 DEV。 | +| local-build | `npm run web:layout:build` | Cloud Web build/publish 前运行;会先刷新本地 `dist`。 | +| DEV live | `npm run web:layout:live` | DEV deploy 后对 `http://74.48.78.17:16666/` 运行,只证明 UI layout/clickability。 | + 报告字段必须能定位 `status=pass|blocked|skip`、`viewport`、`selector`、 `failureType` 和 artifact 路径。当前允许的 `failureType` 包括 `overlap`、 `covered-hit-target`、`overflow`、`outer-scroll-regression`、`screenshot-diff` 以及未上线覆盖项的 `skip`。脚本会保存右侧面板、M3 控制表单、Code Agent 输入区和 `/gate` 当前页局部截图,但不做整页像素 diff 门禁。 +默认 `web:check` 失败时会把完整 JSON 报告写入 +`tmp/dev-cloud-workbench-layout-web-check.json`,终端断言只输出 compact +blockers/failures:`viewport`、`selector`、`failureType`、summary 和 artifact +路径。`reports/dev-gate/dev-cloud-workbench-layout*.json` 是显式 `web:layout*` +命令的报告路径;它们属于 #273 UI layout blocker 证据,不属于 M3 trusted green +证据。 + #287 的硬件状态标签化和 #288 的 `/gate` 单一大表如果尚未上线,layout smoke 只能把对应覆盖项记录为 `skip`,同时继续检查当前可见容器的溢出、遮挡和外层 滚动。该 UI smoke 只能证明布局/可点击性护栏通过,不等于 M3 DEV-LIVE 硬件 diff --git a/package.json b/package.json index 194113de..01a3dc07 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -36,7 +36,10 @@ "runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs", "report:lifecycle:test": "node scripts/report-lifecycle.test.mjs", "dev-runtime:migration": "node scripts/dev-runtime-migration.mjs", - "m3:io:e2e": "node scripts/m3-io-control-e2e.mjs" + "m3:io:e2e": "node scripts/m3-io-control-e2e.mjs", + "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json", + "web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json", + "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json" }, "dependencies": { "pg": "^8.21.0", diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs index 71b7fc82..7219840e 100644 --- a/scripts/dev-cloud-workbench-layout-smoke.mjs +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -45,6 +45,53 @@ export function layoutSmokeExitCode(report) { return report.status === "pass" || report.status === "usage" ? 0 : report.status === "skip" ? 2 : 2; } +export function compactLayoutSmokeCliOutput(report) { + if (report.status === "usage") return report; + return { + status: report.status, + task: report.task, + issue: report.issue, + mode: report.mode, + sourceMode: report.sourceMode, + url: report.url, + generatedAt: report.generatedAt, + evidenceLevel: report.evidenceLevel, + devLive: report.devLive, + summary: report.summary, + failures: (report.failures ?? []).map(compactLayoutFailure), + blockers: (report.blockers ?? []).map(compactLayoutFailure), + skipped: (report.skipped ?? []).map((item) => ({ + checkId: item.checkId, + failureType: item.failureType ?? "skip", + summary: item.summary + })), + artifacts: { + reportPath: report.artifacts?.reportPath ?? null, + screenshotDir: report.artifacts?.screenshotDir ?? null, + screenshotCount: Array.isArray(report.artifacts?.screenshots) ? report.artifacts.screenshots.length : 0 + }, + validationCommands: report.validationCommands, + safety: { + layoutOnly: report.safety?.layoutOnly === true, + codeAgentPostSent: report.safety?.codeAgentPostSent === true, + hardwareWriteApis: report.safety?.hardwareWriteApis === true, + hitTestMethod: report.safety?.hitTestMethod ?? null, + statement: report.safety?.statement ?? null + } + }; +} + +function compactLayoutFailure(failure) { + return { + checkId: failure.checkId ?? failure.scope ?? null, + viewport: failure.viewport ?? null, + selector: failure.selector ?? null, + failureType: failure.failureType ?? "blocked", + summary: failure.summary ?? null, + artifact: failure.artifact ?? null + }; +} + if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { const args = parseLayoutSmokeArgs(process.argv.slice(2)); @@ -66,7 +113,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { await mkdir(path.dirname(reportPath), { recursive: true }); await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); } - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(compactLayoutSmokeCliOutput(report), null, 2)}\n`); process.exitCode = layoutSmokeExitCode(report); } catch (error) { const failure = { diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 61c09a74..779642a2 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { @@ -20,6 +21,7 @@ import { smokeCliExitCode } from "./dev-cloud-workbench-smoke.mjs"; import { + compactLayoutSmokeCliOutput, parseLayoutSmokeArgs } from "./dev-cloud-workbench-layout-smoke.mjs"; @@ -40,6 +42,9 @@ const expectedRuntimeIdentity = Object.freeze({ imageTag: "7de6edd", image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:7de6edd" }); +const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8")); +const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.mjs", import.meta.url), "utf8"); test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => { const defaultArgs = parseSmokeArgs([]); @@ -480,8 +485,22 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async } assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.issue, "pikasTech/HWLAB#273"); + assert.equal(report.taskId, "dev-cloud-workbench-layout"); + assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout"); + assert.equal(report.sourceMode, "source-static"); assert.equal(report.evidenceLevel, "SOURCE"); assert.equal(report.devLive, false); + assert.equal(report.devPreconditions.status, "not_applicable"); + assert.equal(report.localSmoke.commands.includes("npm run web:check"), true); + for (const command of [ + "node --check scripts/dev-cloud-workbench-layout-smoke.mjs", + "npm run web:layout", + "npm run web:layout:build", + "npm run web:layout:live" + ]) { + assert.equal(report.validationCommands.includes(command), true, `missing ${command}`); + } for (const id of [ "layout-desktop-expanded", "layout-desktop-collapsed", @@ -504,7 +523,11 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async ); assert.equal(Array.isArray(report.failures), true); assert.equal(Array.isArray(report.skipped), true); + assert.equal(report.artifacts.reportPath, null); + assert.match(report.reportLifecycle.summary, /cannot be used as DEV-LIVE evidence/u); + assert.match(report.devPreconditions.summary, /not required for SOURCE\/static gates/u); assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true); + assert.match(report.safety.statement, /does not send Code Agent chat, call M3 IO/u); assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass"); assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip"); @@ -528,7 +551,42 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(mobileCollapsed.wiring.noHorizontalScroll, true); const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations; + assert.equal(Object.hasOwn(desktopExpanded.boxes.shell, "text"), false); + assert.equal(Object.hasOwn(desktopExpanded.semanticOverlapChecks[0].boxes["#m3-control-form"], "text"), false); assert.equal(desktopExpanded.failures.length, 0); assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true); assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true); + + const compact = compactLayoutSmokeCliOutput(report); + assert.equal(compact.status, "pass"); + assert.equal(compact.artifacts.screenshotCount, 21); + assert.equal(compact.failures.length, 0); + assert.equal(compact.blockers.length, 0); + assert.equal(JSON.stringify(compact).includes('"checks"'), false); + assert.equal(JSON.stringify(compact).includes('"boxes"'), false); + assert.equal(JSON.stringify(compact).includes('"text"'), false); +}); + +test("repo-owned web checks expose source build and DEV live layout smoke gates", () => { + assert.equal(rootPackage.scripts["web:check"], "node web/hwlab-cloud-web/scripts/check.mjs"); + assert.equal( + rootPackage.scripts["web:layout"], + "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json" + ); + assert.equal( + rootPackage.scripts["web:layout:build"], + "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json" + ); + assert.equal( + rootPackage.scripts["web:layout:live"], + "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json" + ); + assert.match(rootPackage.scripts.check, /node --check scripts\/dev-cloud-workbench-layout-smoke\.mjs/u); + assert.match(rootPackage.scripts.check, /node web\/hwlab-cloud-web\/scripts\/check\.mjs/u); + assert.equal(cloudWebPackage.scripts.layout.includes("--static"), true); + assert.equal(cloudWebPackage.scripts["layout:build"].includes("--build"), true); + assert.equal(cloudWebPackage.scripts["layout:live"].includes("--live --url http://74.48.78.17:16666/"), true); + assert.match(cloudWebCheckSource, /runDevCloudWorkbenchLayoutSmoke/u); + assert.match(cloudWebCheckSource, /tmp\/dev-cloud-workbench-layout-web-check\.json/u); + assert.match(cloudWebCheckSource, /compactLayoutSmokeFailures/u); }); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 6756cf9f..8d3a8eda 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -843,19 +843,38 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { repoRoot, args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) ); + const layoutViewports = workbenchLayoutViewports(); let chromium; try { ({ chromium } = await importPlaywright()); } catch (error) { const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`; - return { + return sanitizeLayoutReport({ + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: reportModeId("layout"), + reportVersion: "v1", status: "skip", + issue: "pikasTech/HWLAB#273", + taskId: "dev-cloud-workbench-layout", + commitId: observeSourceIdentity().reportCommitId, + acceptanceLevel: "dev_cloud_workbench_layout", + devOnly: true, + prodDisabled: true, + reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", + sourceMode: layoutSourceMode, url: useLiveUrl ? args.url : null, + generatedAt: new Date().toISOString(), + sourceContract: layoutSourceContract(), + validationCommands: layoutValidationCommands(), + localSmoke: layoutLocalSmoke("skip"), + dryRun: layoutDryRun(), + devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", devLive: false, summary, + viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks: [ { id: "layout-browser-dependency", @@ -881,8 +900,9 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { artifacts: { screenshotDir: artifactRoot, reportPath: args.reportPath ?? null - } - }; + }, + safety: layoutSafety() + }); } if (!useLiveUrl && args.build === true) { @@ -902,11 +922,6 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { let browser; try { browser = await chromium.launch({ headless: true }); - const layoutViewports = [ - { id: "desktop", width: 1366, height: 768, isMobile: false }, - { id: "narrow-desktop", width: 1024, height: 768, isMobile: false }, - { id: "mobile", width: 390, height: 844, isMobile: true } - ]; const viewportResults = {}; for (const viewport of layoutViewports) { viewportResults[viewport.id] = await inspectWorkbenchLayoutViewport(browser, url, viewport, { artifactRoot }); @@ -1037,13 +1052,28 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { summary: check.summary, observations: check.observations })); - return { + return sanitizeLayoutReport({ + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: reportModeId("layout"), + reportVersion: "v1", status: blockers.length === 0 ? "pass" : "blocked", + issue: "pikasTech/HWLAB#273", + taskId: "dev-cloud-workbench-layout", + commitId: observeSourceIdentity().reportCommitId, + acceptanceLevel: "dev_cloud_workbench_layout", + devOnly: true, + prodDisabled: true, + reportLifecycle: layoutReportLifecycle(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), + sourceContract: layoutSourceContract(), + validationCommands: layoutValidationCommands(), + localSmoke: layoutLocalSmoke(blockers.length === 0 ? "pass" : "blocked"), + dryRun: layoutDryRun(), + devPreconditions: layoutDevPreconditions(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"), evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", devLive: false, summary: useLiveUrl @@ -1063,27 +1093,35 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { ]), reportPath: args.reportPath ?? null }, - safety: { - ...staticSafety(), - sourceIsDevLive: false, - layoutOnly: true, - codeAgentPostSent: false, - hardwareWriteApis: false, - hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks", - statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance." - } - }; + safety: layoutSafety() + }); } catch (error) { - return { + return sanitizeLayoutReport({ + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: reportModeId("layout"), + reportVersion: "v1", status: "blocked", + issue: "pikasTech/HWLAB#273", + taskId: "dev-cloud-workbench-layout", + commitId: observeSourceIdentity().reportCommitId, + acceptanceLevel: "dev_cloud_workbench_layout", + devOnly: true, + prodDisabled: true, + reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), + sourceContract: layoutSourceContract(), + validationCommands: layoutValidationCommands(), + localSmoke: layoutLocalSmoke("blocked"), + dryRun: layoutDryRun(), + devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", devLive: false, summary: `Workbench layout smoke failed: ${error.message}`, + viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks: [], blockers: [ { @@ -1109,14 +1147,56 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { screenshots: [], reportPath: args.reportPath ?? null }, - safety: staticSafety() - }; + safety: layoutSafety() + }); } finally { if (browser) await browser.close(); if (server) await server.close(); } } +function workbenchLayoutViewports() { + return [ + { id: "desktop", width: 1366, height: 768, isMobile: false }, + { id: "narrow-desktop", width: 1024, height: 768, isMobile: false }, + { id: "mobile", width: 390, height: 844, isMobile: true } + ]; +} + +function layoutSafety() { + return { + ...staticSafety(), + sourceIsDevLive: false, + layoutOnly: true, + codeAgentPostSent: false, + hardwareWriteApis: false, + hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks", + statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance; it must not claim M3 DEV-LIVE hardware-loop acceptance." + }; +} + +function sanitizeLayoutReport(report) { + return stripLayoutObservationNoise(report); +} + +function stripLayoutObservationNoise(value) { + if (Array.isArray(value)) return value.map(stripLayoutObservationNoise); + if (!value || typeof value !== "object") return value; + const cleaned = {}; + const looksLikeElementBox = + Number.isFinite(value.left) && + Number.isFinite(value.top) && + Number.isFinite(value.right) && + Number.isFinite(value.bottom) && + Number.isFinite(value.width) && + Number.isFinite(value.height); + for (const [key, item] of Object.entries(value)) { + if (looksLikeElementBox && key === "text") continue; + cleaned[key] = stripLayoutObservationNoise(item); + } + return cleaned; +} + function addDomOnlyCodeAgentCheck(checks) { checks.push({ id: "live-code-agent-browser-journey", @@ -1152,6 +1232,85 @@ function addDomOnlyCodeAgentCheck(checks) { }); } +function layoutReportLifecycle(useLiveUrl, status) { + return { + version: "v1", + state: "active", + activeEndpoint: runtime.endpoints.api, + activeBrowserEndpoint: runtime.endpoints.frontend, + deprecatedEndpoint: null, + summary: useLiveUrl + ? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not M3 hardware-loop acceptance.` + : `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.` + }; +} + +function layoutSourceContract() { + return { + status: "pass", + documents: [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" + ], + summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance." + }; +} + +function layoutValidationCommands() { + return [ + "node --check scripts/dev-cloud-workbench-layout-smoke.mjs", + "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", + "npm run web:check", + "npm run web:layout", + "npm run web:layout:build", + "npm run web:layout:live" + ]; +} + +function layoutLocalSmoke(status) { + return { + status, + commands: [ + "npm run web:check", + "npm run web:layout", + "npm run web:layout:build" + ], + evidence: [ + "web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.", + "web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.", + "web:layout:build rebuilds local dist and checks the built Cloud Web assets." + ], + summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers." + }; +} + +function layoutDryRun() { + return { + status: "not_applicable", + commands: ["npm run web:layout"], + evidence: ["Layout smoke is browser evidence, not a dry-run substitute."], + summary: "No dry-run mode is accepted for layout/clickability evidence." + }; +} + +function layoutDevPreconditions(useLiveUrl, status) { + return { + status: useLiveUrl ? status : "not_applicable", + requirements: [ + "SOURCE/static PR checks must pass without public DEV dependency.", + "local-build mode must rebuild and inspect Cloud Web dist before publish/apply.", + "DEV live mode must target http://74.48.78.17:16666/ after a controlled DEV deployment or confirmed live revision.", + "The report must include viewport, selector, failureType, and artifact/report paths for failures.", + "Layout pass is not M3 DEV-LIVE trusted closure and does not replace #227 DO/DI functional evidence." + ], + commands: ["npm run web:layout:live"], + summary: useLiveUrl + ? "DEV live layout smoke was executed as UI layout/clickability evidence only." + : "DEV live layout smoke is a post-deploy check and is intentionally not required for SOURCE/static gates." + }; +} + function baseReport({ mode, status, diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 42ea299c..9e988a99 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -31,7 +31,8 @@ const issueFamily = Object.freeze({ DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40", DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41", DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58", - DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7" + DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7", + DEV_CLOUD_WORKBENCH_LAYOUT: "pikasTech/HWLAB#273" }); const allowedIssues = new Set([ contractIssue, @@ -138,6 +139,14 @@ const requiredDevCloudWorkbenchLiveValidationCommands = [ "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json" ]; +const requiredDevCloudWorkbenchLayoutValidationCommands = [ + "node --check scripts/dev-cloud-workbench-layout-smoke.mjs", + "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", + "npm run web:check", + "npm run web:layout", + "npm run web:layout:build", + "npm run web:layout:live" +]; const requiredDevCloudWorkbenchDocs = [ "docs/cloud-web-workbench.md", "docs/reference/cloud-workbench.md", @@ -276,7 +285,7 @@ const reportFamilyTemplates = new Map([ ] ]); -const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "not_sent", "failed"]); +const statusValues = new Set(["pass", "blocked", "degraded", "skip", "not_run", "not_applicable", "not_sent", "failed"]); const blockerTypes = new Set([ "contract_blocker", "environment_blocker", @@ -527,6 +536,13 @@ async function validateReport(relativePath) { await validateDevCloudWorkbenchLiveReport(report, label, raw); return; } + if ( + report.issue === issueFamily.DEV_CLOUD_WORKBENCH_LAYOUT || + report.taskId === "dev-cloud-workbench-layout" + ) { + await validateDevCloudWorkbenchLayoutReport(report, label); + return; + } const template = reportFamilyTemplates.get(report.taskId); assert.ok( @@ -1926,6 +1942,228 @@ function assertCodeAgentRetainedApiFields(value, label) { } } +async function validateDevCloudWorkbenchLayoutReport(report, label) { + for (const field of [ + "$schema", + "$id", + "reportVersion", + "issue", + "taskId", + "commitId", + "acceptanceLevel", + "devOnly", + "prodDisabled", + "status", + "mode", + "sourceMode", + "url", + "evidenceLevel", + "devLive", + "sourceContract", + "validationCommands", + "localSmoke", + "dryRun", + "devPreconditions", + "checks", + "blockers", + "artifacts", + "safety" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assertString(report.$schema, `${label}.$schema`); + assertString(report.$id, `${label}.$id`); + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.issue, issueFamily.DEV_CLOUD_WORKBENCH_LAYOUT, `${label}.issue`); + assert.equal(report.taskId, "dev-cloud-workbench-layout", `${label}.taskId`); + assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`); + assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout", `${label}.acceptanceLevel`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assertStatus(report.status, `${label}.status`); + assert.ok(["pass", "blocked", "skip"].includes(report.status), `${label}.status`); + assert.equal(report.mode, "layout-browser", `${label}.mode`); + assert.ok(["source-static", "local-build", "dev-live"].includes(report.sourceMode), `${label}.sourceMode`); + assert.equal(report.devLive, false, `${label}.devLive`); + assert.equal( + report.evidenceLevel, + report.sourceMode === "dev-live" ? report.status === "pass" ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", + `${label}.evidenceLevel` + ); + if (report.sourceMode === "dev-live") { + assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`); + } + + assertObject(report.sourceContract, `${label}.sourceContract`); + assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); + await assertDocumentSet( + report.sourceContract.documents, + `${label}.sourceContract.documents`, + requiredDevCloudWorkbenchDocs + ); + assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); + + assertStringArray(report.validationCommands, `${label}.validationCommands`, { + minLength: requiredDevCloudWorkbenchLayoutValidationCommands.length + }); + assertUnique(report.validationCommands, `${label}.validationCommands`); + for (const requiredCommand of requiredDevCloudWorkbenchLayoutValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + for (const section of ["localSmoke", "dryRun", "devPreconditions"]) { + assertObject(report[section], `${label}.${section}`); + assertStatus(report[section].status, `${label}.${section}.status`); + assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 }); + assertStringArray(report[section].evidence ?? [], `${label}.${section}.evidence`, { + minLength: section === "devPreconditions" ? 0 : 1 + }); + assertString(report[section].summary, `${label}.${section}.summary`); + } + assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, { + minLength: 4 + }); + + assertArray(report.viewports, `${label}.viewports`); + assert.deepEqual( + report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`), + ["1366x768", "1024x768", "390x844"], + `${label}.viewports` + ); + for (const [index, viewport] of report.viewports.entries()) { + assertObject(viewport, `${label}.viewports[${index}]`); + assertString(viewport.id, `${label}.viewports[${index}].id`); + assert.equal(Number.isInteger(viewport.width), true, `${label}.viewports[${index}].width`); + assert.equal(Number.isInteger(viewport.height), true, `${label}.viewports[${index}].height`); + } + + assertArray(report.checks, `${label}.checks`); + if (report.status === "skip") { + assert.ok(report.checks.length >= 1, `${label}.checks must explain the skipped layout dependency`); + } else { + assert.ok(report.checks.length >= 11, `${label}.checks must cover desktop, mobile, and gate layout checks`); + } + assertUnique(report.checks.map((check) => check.id), `${label}.checks ids`); + const checks = new Map(report.checks.map((check, index) => { + const checkLabel = `${label}.checks[${index}]`; + assertObject(check, checkLabel); + for (const field of ["id", "status", "summary"]) { + assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`); + } + assertString(check.id, `${checkLabel}.id`); + assertStatus(check.status, `${checkLabel}.status`); + assertString(check.summary, `${checkLabel}.summary`); + if (Object.hasOwn(check, "viewport")) { + assertViewportObject(check.viewport, `${checkLabel}.viewport`); + } + if (Object.hasOwn(check, "observations")) { + assertObject(check.observations, `${checkLabel}.observations`); + } + return [check.id, check]; + })); + + if (report.status !== "skip") { + for (const requiredCheck of [ + "layout-desktop-expanded", + "layout-desktop-collapsed", + "layout-desktop-restored", + "layout-narrow-desktop-expanded", + "layout-narrow-desktop-collapsed", + "layout-narrow-desktop-restored", + "layout-mobile-collapsed", + "layout-mobile-drawer", + "layout-gate-desktop", + "layout-gate-narrow-desktop", + "layout-gate-mobile" + ]) { + assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`); + } + } + + assertBlockers(report.blockers, `${label}.blockers`); + assertArray(report.failures ?? [], `${label}.failures`); + for (const [index, failure] of (report.failures ?? []).entries()) { + assertLayoutFailure(failure, `${label}.failures[${index}]`); + } + if (report.status === "pass") { + assert.equal(report.blockers.length, 0, `${label}.blockers`); + assert.equal((report.failures ?? []).length, 0, `${label}.failures`); + } else { + assert.ok(report.blockers.length > 0 || (report.failures ?? []).length > 0, `${label} blocked/skip status must carry blockers or failures`); + } + + if (Object.hasOwn(report, "skipped")) { + assertArray(report.skipped, `${label}.skipped`); + for (const [index, skipped] of report.skipped.entries()) { + assertObject(skipped, `${label}.skipped[${index}]`); + assertString(skipped.checkId, `${label}.skipped[${index}].checkId`); + assert.equal(skipped.failureType, "skip", `${label}.skipped[${index}].failureType`); + assertString(skipped.summary, `${label}.skipped[${index}].summary`); + } + } + + assertObject(report.artifacts, `${label}.artifacts`); + assertString(report.artifacts.screenshotDir, `${label}.artifacts.screenshotDir`); + if (Object.hasOwn(report.artifacts, "reportPath") && report.artifacts.reportPath !== null) { + assertString(report.artifacts.reportPath, `${label}.artifacts.reportPath`); + } + assertArray(report.artifacts.screenshots ?? [], `${label}.artifacts.screenshots`); + for (const [index, screenshot] of (report.artifacts.screenshots ?? []).entries()) { + assertObject(screenshot, `${label}.artifacts.screenshots[${index}]`); + assertString(screenshot.selector, `${label}.artifacts.screenshots[${index}].selector`); + assertViewportObject(screenshot.viewport, `${label}.artifacts.screenshots[${index}].viewport`); + assertString(screenshot.path, `${label}.artifacts.screenshots[${index}].path`); + } + + assertObject(report.safety, `${label}.safety`); + assert.equal(report.safety.layoutOnly, true, `${label}.safety.layoutOnly`); + assert.equal(report.safety.codeAgentPostSent, false, `${label}.safety.codeAgentPostSent`); + assert.equal(report.safety.hardwareWriteApis, false, `${label}.safety.hardwareWriteApis`); + assert.equal(report.safety.sourceIsDevLive, false, `${label}.safety.sourceIsDevLive`); + assert.match(report.safety.hitTestMethod, /elementsFromPoint/u, `${label}.safety.hitTestMethod`); + assert.match(report.safety.statement, /not claim M3 DEV-LIVE hardware-loop acceptance/u, `${label}.safety.statement`); +} + +function assertViewportObject(value, label) { + assertObject(value, label); + assert.equal(Number.isInteger(value.width), true, `${label}.width`); + assert.equal(Number.isInteger(value.height), true, `${label}.height`); +} + +function assertLayoutFailure(value, label) { + assertObject(value, label); + if (Object.hasOwn(value, "checkId")) { + assertString(value.checkId, `${label}.checkId`); + } + assert.ok(Object.hasOwn(value, "viewport"), `${label} missing viewport`); + if (value.viewport !== null) { + assertViewportObject(value.viewport, `${label}.viewport`); + } + assert.ok(Object.hasOwn(value, "selector"), `${label} missing selector`); + if (value.selector !== null) { + assertString(value.selector, `${label}.selector`); + } + assertString(value.failureType, `${label}.failureType`); + assert.ok( + [ + "overlap", + "covered-hit-target", + "overflow", + "outer-scroll-regression", + "screenshot-diff", + "blocked", + "blocked/skip", + "skip" + ].includes(value.failureType), + `${label}.failureType` + ); + assertString(value.summary, `${label}.summary`); +} + function assertDevCloudWorkbenchDeploymentPreflight(report, label, checks) { for (const requiredCheck of [ "live-runtime-current-main", diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 24ff0923..4a71b412 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -6,6 +6,9 @@ "scripts": { "m3-readonly": "node scripts/m3-readonly-contract.mjs", "check": "node scripts/check.mjs", - "build": "node scripts/build.mjs" + "build": "node scripts/build.mjs", + "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report ../../reports/dev-gate/dev-cloud-workbench-layout.json", + "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report ../../reports/dev-gate/dev-cloud-workbench-layout-build.json", + "layout:live": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report ../../reports/dev-gate/dev-cloud-workbench-layout-live.json" } } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index b8d2d3ff..554e77f1 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; import { runDevCloudWorkbenchLocalAgentFixtureSmoke, + runDevCloudWorkbenchLayoutSmoke, runDevCloudWorkbenchMobileSmoke, runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs"; @@ -70,6 +71,11 @@ const mobileOuterScrollSmokeCheck = mobileWorkbenchSmoke.checks.find((check) => const mobileHelpSmokeCheck = mobileWorkbenchSmoke.checks.find((check) => check.id === "mobile-help-markdown-route"); const localAgentFixtureSmoke = await runDevCloudWorkbenchLocalAgentFixtureSmoke(); const localAgentNo4500Check = localAgentFixtureSmoke.checks.find((check) => check.id === "local-agent-fixture-no-4500ms-permanent-failure"); +const layoutSmokeReportPath = path.resolve(repoRoot, "tmp/dev-cloud-workbench-layout-web-check.json"); +const layoutSmoke = await runDevCloudWorkbenchLayoutSmoke({ reportPath: layoutSmokeReportPath }); +fs.mkdirSync(path.dirname(layoutSmokeReportPath), { recursive: true }); +layoutSmoke.artifacts = { ...(layoutSmoke.artifacts ?? {}), reportPath: layoutSmokeReportPath }; +fs.writeFileSync(layoutSmokeReportPath, `${JSON.stringify(layoutSmoke, null, 2)}\n`, "utf8"); assert.notEqual( mobileWorkbenchSmoke.status, @@ -79,6 +85,14 @@ assert.notEqual( "Install repository dependencies and run `node scripts/dev-cloud-workbench-smoke.mjs --mobile` to generate the required mobile outer-scroll evidence." ].filter(Boolean).join(" ") ); +assert.notEqual( + layoutSmoke.status, + "skip", + [ + layoutSmoke.summary, + "Install repository dependencies and run `node scripts/dev-cloud-workbench-layout-smoke.mjs --static` to generate the required layout evidence." + ].filter(Boolean).join(" ") +); assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2)); assert.equal(workbenchSmoke.mode, "source"); @@ -107,6 +121,44 @@ assert.equal(localAgentFixtureSmoke.status, "pass", JSON.stringify(localAgentFix assert.equal(localAgentFixtureSmoke.evidenceLevel, "SOURCE"); assert.equal(localAgentFixtureSmoke.devLive, false); assert.equal(localAgentNo4500Check?.status, "pass", "local Code Agent fixture must prove the 4500ms browser timeout path is not permanent"); +assert.equal( + layoutSmoke.status, + "pass", + JSON.stringify(compactLayoutSmokeFailures(layoutSmoke), null, 2) +); +assert.equal(layoutSmoke.sourceMode, "source-static"); +assert.equal(layoutSmoke.evidenceLevel, "SOURCE"); +assert.equal(layoutSmoke.devLive, false); +assert.equal(layoutSmoke.safety.layoutOnly, true); +assert.equal(layoutSmoke.safety.codeAgentPostSent, false); +assert.equal(layoutSmoke.safety.hardwareWriteApis, false); +assert.equal(layoutSmoke.failures.length, 0); +assert.equal(layoutSmoke.viewports.map((viewport) => `${viewport.width}x${viewport.height}`).join(","), "1366x768,1024x768,390x844"); +assert.equal( + layoutSmoke.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, + "pass", + "#287 hardware status tabs must remain covered by layout smoke" +); +assert.equal( + layoutSmoke.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, + "skip", + "#288 single-table gate is future coverage; current /gate route remains covered by layout smoke" +); +for (const layoutCheckId of [ + "layout-desktop-expanded", + "layout-desktop-collapsed", + "layout-desktop-restored", + "layout-narrow-desktop-expanded", + "layout-narrow-desktop-collapsed", + "layout-narrow-desktop-restored", + "layout-mobile-collapsed", + "layout-mobile-drawer", + "layout-gate-desktop", + "layout-gate-narrow-desktop", + "layout-gate-mobile" +]) { + assert.equal(layoutSmoke.checks.find((check) => check.id === layoutCheckId)?.status, "pass", `${layoutCheckId} must pass in web:check layout gate`); +} if (mobileWorkbenchSmoke.status === "pass") { assert.equal(mobileWorkbenchSmoke.viewport.width, 390); assert.equal(mobileWorkbenchSmoke.viewport.height, 844); @@ -627,6 +679,27 @@ function textHasForbiddenTerm(text, term) { return text.includes(term); } +function compactLayoutSmokeFailures(report) { + return { + status: report.status, + sourceMode: report.sourceMode, + artifacts: report.artifacts, + blockers: (report.blockers ?? []).map(compactLayoutFailure), + failures: (report.failures ?? []).map(compactLayoutFailure) + }; +} + +function compactLayoutFailure(failure) { + return { + checkId: failure.checkId ?? failure.scope ?? null, + viewport: failure.viewport ?? null, + selector: failure.selector ?? null, + failureType: failure.failureType ?? "blocked", + summary: failure.summary ?? null, + artifact: failure.artifact ?? null + }; +} + function functionBody(source, functionName) { const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u")); if (!match) return "";