diff --git a/AGENTS.md b/AGENTS.md index 48bab2d1..1bd6cbee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +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` - DEV artifact 发布预检:`npm run dev-artifact:preflight` - runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight` - D601 k3s 只读观测:`npm run d601:k3s:readonly` diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 273eccce..e22bf389 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -97,10 +97,40 @@ when identity drift blocks the full journey. It never sends `POST must not be used as a DEV-LIVE reply, Secret, mutation, or M3/M4/M5 acceptance claim. +## Layout Smoke + +`scripts/dev-cloud-workbench-layout-smoke.mjs` 是 #273 前端布局/遮挡护栏的 +runner 入口。它使用 Playwright 覆盖 `1366x768`、`1024x768` 和 `390x844` +三档视口,检查 M3 控制区、Code Agent 输入区、右侧硬件/可信记录容器、当前 +`/gate` 内部页、外层滚动锁和内部面板滚动。命中测试使用真实 Playwright +点击与 `document.elementsFromPoint()`;不得使用 `force: true` 或纯 DOM +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 +``` + +报告字段必须能定位 `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 门禁。 + +#287 的硬件状态标签化和 #288 的 `/gate` 单一大表如果尚未上线,layout smoke +只能把对应覆盖项记录为 `skip`,同时继续检查当前可见容器的溢出、遮挡和外层 +滚动。该 UI smoke 只能证明布局/可点击性护栏通过,不等于 M3 DEV-LIVE 硬件 +闭环通过,也不能替代 #227 的 DO/DI 功能验收。 + ## Stable Sources - [pikasTech/HWLAB#99](https://github.com/pikasTech/HWLAB/issues/99): default Cloud Workbench direction. +- [pikasTech/HWLAB#273](https://github.com/pikasTech/HWLAB/issues/273): + Playwright layout/overlap smoke guardrail. - [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108): no outer scroll, Chinese UI, internal Markdown help. - [code-agent-chat-readiness.md](code-agent-chat-readiness.md): Code Agent diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs new file mode 100644 index 00000000..71b7fc82 --- /dev/null +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + parseSmokeArgs, + printSmokeHelp, + runDevCloudWorkbenchLayoutSmoke +} from "./src/dev-cloud-workbench-smoke-lib.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function parseLayoutSmokeArgs(argv) { + if (argv.includes("--help")) return { help: true }; + const normalized = ["--layout"]; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--static" || arg === "--source") { + continue; + } + if (arg === "--live") { + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + normalized.push("--url"); + normalized.push(next); + index += 1; + } else { + normalized.push("--url"); + normalized.push("http://74.48.78.17:16666/"); + } + continue; + } + normalized.push(arg); + if (["--url", "--report"].includes(arg)) { + index += 1; + if (!argv[index]) throw new Error(`${arg} requires a value`); + normalized.push(argv[index]); + } + } + return parseSmokeArgs(normalized); +} + +export function layoutSmokeExitCode(report) { + return report.status === "pass" || report.status === "usage" ? 0 : report.status === "skip" ? 2 : 2; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const args = parseLayoutSmokeArgs(process.argv.slice(2)); + const report = args.help + ? { + ...printSmokeHelp(), + command: "node scripts/dev-cloud-workbench-layout-smoke.mjs [--static|--build|--live --url http://74.48.78.17:16666/] [--report reports/dev-gate/dev-cloud-workbench-layout.json]", + layoutNotes: [ + "默认 --static 使用 source/static 本地静态服务。", + "--build 会先运行 web/hwlab-cloud-web/scripts/build.mjs,再检查本地 dist 构建产物。", + "--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 M3 IO,不等于 M3 DEV-LIVE 功能通过。", + "结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。" + ] + } + : await runDevCloudWorkbenchLayoutSmoke(args); + if (args.reportPath && report.status !== "usage") { + const reportPath = path.resolve(process.cwd(), args.reportPath); + report.artifacts = { ...(report.artifacts ?? {}), reportPath }; + 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.exitCode = layoutSmokeExitCode(report); + } catch (error) { + const failure = { + status: "blocked", + mode: "layout-browser", + generatedAt: new Date().toISOString(), + failureType: "blocked", + selector: null, + viewport: null, + artifact: null, + reportPath: null, + summary: error instanceof Error ? error.message : String(error) + }; + process.stderr.write(`[dev-cloud-workbench-layout-smoke] ${error instanceof Error ? error.stack : String(error)}\n`); + process.stdout.write(`${JSON.stringify(failure, null, 2)}\n`); + process.exitCode = 2; + } +} + +export { repoRoot }; diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 91db6250..9c0c9d6d 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -19,6 +19,9 @@ import { decideSmokeReportWrite, smokeCliExitCode } from "./dev-cloud-workbench-smoke.mjs"; +import { + parseLayoutSmokeArgs +} from "./dev-cloud-workbench-layout-smoke.mjs"; const sourceIdentity = Object.freeze({ status: "observed", @@ -161,12 +164,37 @@ test("smoke args include reusable layout-only browser mode", () => { assert.equal(local.mode, "layout"); assert.equal(local.urlExplicit, undefined); + const build = parseSmokeArgs(["--layout", "--build"]); + assert.equal(build.mode, "layout"); + assert.equal(build.build, true); + const live = parseSmokeArgs(["--layout", "--url", "http://74.48.78.17:16666/"]); assert.equal(live.mode, "layout"); assert.equal(live.url, "http://74.48.78.17:16666/"); assert.equal(live.urlExplicit, true); }); +test("dedicated layout smoke CLI supports static build and DEV live shorthand", () => { + const local = parseLayoutSmokeArgs(["--static"]); + assert.equal(local.mode, "layout"); + assert.equal(local.urlExplicit, undefined); + + const build = parseLayoutSmokeArgs(["--build", "--report", "reports/dev-gate/dev-cloud-workbench-layout.json"]); + assert.equal(build.mode, "layout"); + assert.equal(build.build, true); + assert.equal(build.reportPath, "reports/dev-gate/dev-cloud-workbench-layout.json"); + + const liveDefault = parseLayoutSmokeArgs(["--live"]); + assert.equal(liveDefault.mode, "layout"); + assert.equal(liveDefault.url, "http://74.48.78.17:16666/"); + assert.equal(liveDefault.urlExplicit, true); + + const liveExplicit = parseLayoutSmokeArgs(["--live", "--url", "http://74.48.78.17:16666/"]); + assert.equal(liveExplicit.mode, "layout"); + assert.equal(liveExplicit.url, "http://74.48.78.17:16666/"); + assert.equal(liveExplicit.urlExplicit, true); +}); + test("source/default smoke covers #278 sidebar collapse static contract", () => { const report = runDevCloudWorkbenchStaticSmoke(); assert.equal(report.checks.find((check) => check.id === "feedback-278-sidebar-collapse-contract")?.status, "pass"); @@ -447,12 +475,28 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async "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-mobile-drawer", + "layout-gate-desktop", + "layout-gate-narrow-desktop", + "layout-gate-mobile" ]) { assert.equal(report.checks.find((check) => check.id === id)?.status, "pass", id); } + assert.deepEqual( + report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`), + ["1366x768", "1024x768", "390x844"] + ); + assert.equal(Array.isArray(report.failures), true); + assert.equal(Array.isArray(report.skipped), true); + assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true); + assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "skip"); + assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip"); + const desktopCollapsed = report.checks.find((check) => check.id === "layout-desktop-collapsed")?.observations; assert.equal(desktopCollapsed.explorerCollapsed, true); assert.equal(desktopCollapsed.toggle.ariaLabel, "展开左侧资源树"); @@ -464,4 +508,9 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(mobileCollapsed.explorerCollapsed, true); assert.equal(mobileCollapsed.defaultCollapsedMobile, true); assert.equal(mobileCollapsed.keyTargetsReachable, true); + + const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations; + assert.equal(desktopExpanded.failures.length, 0); + assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true); + assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true); }); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 94c3c793..2469d8b4 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -283,6 +283,9 @@ export function parseSmokeArgs(argv) { args.mode = "dom-only"; } else if (arg === "--layout") { args.mode = "layout"; + } else if (arg === "--build") { + args.mode = "layout"; + args.build = true; } else if (arg === "--mobile") { args.mode = "mobile"; args.mobile = true; @@ -819,7 +822,12 @@ async function runLiveDomOnlySmoke(args) { } export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { - const useLiveUrl = args.urlExplicit === true; + const useLiveUrl = args.live === true || args.urlExplicit === true; + const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static"; + const artifactRoot = path.resolve( + repoRoot, + args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) + ); let chromium; try { ({ chromium } = await importPlaywright()); @@ -854,87 +862,200 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { package: "playwright", declaredIn: "package.json", installCommand: playwrightDependencyCommand + }, + artifacts: { + screenshotDir: artifactRoot, + reportPath: args.reportPath ?? null } }; } - const server = useLiveUrl ? null : await startStaticWebServer(); + if (!useLiveUrl && args.build === true) { + execFileSync("node", ["web/hwlab-cloud-web/scripts/build.mjs"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000 + }); + } + await fs.promises.mkdir(artifactRoot, { recursive: true }); + + const server = useLiveUrl + ? null + : await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot }); const url = useLiveUrl ? args.url : server.url; let browser; try { browser = await chromium.launch({ headless: true }); - const desktop = await inspectWorkbenchLayoutViewport(browser, url, { - width: 1366, - height: 768, - isMobile: false - }); - const narrow = await inspectWorkbenchLayoutViewport(browser, url, { - width: 390, - height: 844, - isMobile: 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 }); + } + const desktop = viewportResults.desktop; + const narrowDesktop = viewportResults["narrow-desktop"]; + const mobile = viewportResults.mobile; const checks = [ { id: "layout-desktop-expanded", status: desktop.expanded.pass ? "pass" : "blocked", - summary: "Desktop expanded layout keeps navigation/resources visible, key controls reachable, and no outer scroll.", + viewport: desktop.expanded.viewport, + summary: "Desktop 1366x768 expanded layout keeps M3 controls, Code Agent input, hardware panels, hit targets, overflow, and outer scroll in contract.", observations: desktop.expanded }, { id: "layout-desktop-collapsed", status: desktop.collapsed.pass ? "pass" : "blocked", - summary: "Desktop collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.", + viewport: desktop.collapsed.viewport, + summary: "Desktop 1366x768 collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.", observations: desktop.collapsed }, { id: "layout-desktop-restored", status: desktop.restored.pass ? "pass" : "blocked", + viewport: desktop.restored.viewport, summary: "Desktop expand action restores the left navigation/resource tree without layout overlap.", observations: desktop.restored }, + { + id: "layout-narrow-desktop-expanded", + status: narrowDesktop.expanded.pass ? "pass" : "blocked", + viewport: narrowDesktop.expanded.viewport, + summary: "Narrow desktop 1024x768 layout keeps the right hardware area below the workbench without covered controls or outer scroll.", + observations: narrowDesktop.expanded + }, + { + id: "layout-narrow-desktop-collapsed", + status: narrowDesktop.collapsed.pass ? "pass" : "blocked", + viewport: narrowDesktop.collapsed.viewport, + summary: "Narrow desktop 1024x768 collapsed layout releases left-sidebar width without hiding M3 or Code Agent controls.", + observations: narrowDesktop.collapsed + }, + { + id: "layout-narrow-desktop-restored", + status: narrowDesktop.restored.pass ? "pass" : "blocked", + viewport: narrowDesktop.restored.viewport, + summary: "Narrow desktop 1024x768 restored layout returns the resource tree without incoherent overlap.", + observations: narrowDesktop.restored + }, { id: "layout-mobile-collapsed", - status: narrow.collapsed.pass ? "pass" : "blocked", + status: mobile.collapsed.pass ? "pass" : "blocked", + viewport: mobile.collapsed.viewport, summary: "Mobile default layout keeps the resource tree collapsed and preserves Agent input plus M3 tabs/controls.", - observations: narrow.collapsed + observations: mobile.collapsed }, { id: "layout-mobile-drawer", - status: narrow.drawer.pass ? "pass" : "blocked", + status: mobile.drawer.pass ? "pass" : "blocked", + viewport: mobile.drawer.viewport, summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.", - observations: narrow.drawer + observations: mobile.drawer + }, + { + id: "layout-gate-desktop", + status: desktop.gate.currentRoute.pass ? "pass" : "blocked", + viewport: desktop.gate.currentRoute.viewport, + summary: "Desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: desktop.gate.currentRoute + }, + { + id: "layout-gate-narrow-desktop", + status: narrowDesktop.gate.currentRoute.pass ? "pass" : "blocked", + viewport: narrowDesktop.gate.currentRoute.viewport, + summary: "Narrow desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: narrowDesktop.gate.currentRoute + }, + { + id: "layout-gate-mobile", + status: mobile.gate.currentRoute.pass ? "pass" : "blocked", + viewport: mobile.gate.currentRoute.viewport, + summary: "Mobile /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: mobile.gate.currentRoute + }, + { + id: "layout-issue-287-future-hardware-status-tabs", + status: allViewportCoverage(viewportResults, "issue287HardwareTabsReady") ? "pass" : "skip", + summary: "#287 hardware status tab set is not required for this #273 smoke until the wider live hardware workspace is implemented; current visible right-side containers are still checked for overflow.", + observations: summarizeCoverageGap(viewportResults, "issue287") + }, + { + id: "layout-issue-288-future-single-table-gate", + status: allViewportCoverage(viewportResults, "issue288SingleTableReady") ? "pass" : "skip", + summary: "#288 single-table internal review page is not required for this #273 smoke until that UI lands; current /gate route is checked only for outer-scroll and visible-layout regressions.", + observations: summarizeCoverageGap(viewportResults, "issue288") } ]; const blockers = checks .filter((check) => check.status !== "pass") + .filter((check) => check.status !== "skip") .map((check) => ({ type: "runtime_blocker", scope: check.id, status: "open", - summary: check.summary + summary: check.summary, + viewport: check.viewport ?? null, + selector: firstFailure(check)?.selector ?? null, + failureType: firstFailure(check)?.failureType ?? "blocked" + })); + const failures = checks.flatMap((check) => + (check.observations?.failures ?? []).map((failure) => ({ + checkId: check.id, + viewport: check.viewport ?? failure.viewport ?? null, + selector: failure.selector ?? null, + failureType: failure.failureType ?? "blocked", + summary: failure.summary ?? check.summary, + ...failure + })) + ); + const skipped = checks + .filter((check) => check.status === "skip") + .map((check) => ({ + checkId: check.id, + status: "skip", + failureType: "skip", + summary: check.summary, + observations: check.observations })); return { status: blockers.length === 0 ? "pass" : "blocked", task: "DC-DCSN-P0-2026-003", mode: "layout-browser", + sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", devLive: false, summary: useLiveUrl - ? "Live browser layout smoke verifies sidebar collapse/expand usability only; it is not M3 hardware acceptance." - : "Static local browser layout smoke verifies sidebar collapse/expand usability only; deployment still requires DEV live verification.", - refs: ["pikasTech/HWLAB#278", "pikasTech/HWLAB#273", "pikasTech/HWLAB#99"], + ? "Live browser layout smoke verifies workbench layout, hit targets, overflow, and /gate usability only; it is not M3 hardware acceptance." + : "Static local browser layout smoke verifies workbench layout, hit targets, overflow, and /gate usability only; deployment still requires DEV live verification.", + refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"], + viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks, blockers, + failures, + skipped, + artifacts: { + screenshotDir: artifactRoot, + screenshots: Object.values(viewportResults).flatMap((result) => [ + ...(result.artifacts?.screenshots ?? []), + ...(result.gate?.currentRoute?.artifacts?.screenshots ?? []) + ]), + reportPath: args.reportPath ?? null + }, safety: { ...staticSafety(), sourceIsDevLive: false, layoutOnly: true, codeAgentPostSent: false, hardwareWriteApis: false, - statement: "Layout smoke opens the workbench and clicks sidebar controls only. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance." + 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." } }; } catch (error) { @@ -942,6 +1063,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { status: "blocked", task: "DC-DCSN-P0-2026-003", mode: "layout-browser", + sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", @@ -953,9 +1075,25 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { type: "runtime_blocker", scope: "layout-browser-smoke", status: "open", + summary: error.message, + selector: null, + failureType: "blocked", + viewport: null + } + ], + failures: [ + { + selector: null, + failureType: "blocked", + viewport: null, summary: error.message } ], + artifacts: { + screenshotDir: artifactRoot, + screenshots: [], + reportPath: args.reportPath ?? null + }, safety: staticSafety() }; } finally { @@ -3402,6 +3540,7 @@ export async function runDevCloudWorkbenchMobileSmoke() { } async function startStaticWebServer(options = {}) { + const rootDir = path.resolve(options.rootDir ?? webRoot); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { @@ -3411,8 +3550,8 @@ async function startStaticWebServer(options = {}) { if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate" || pathname === "/help") { pathname = "/index.html"; } - const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, "")); - if (!filePath.startsWith(webRoot)) { + const filePath = path.resolve(rootDir, pathname.replace(/^\/+/, "")); + if (!filePath.startsWith(rootDir)) { response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); response.end("forbidden"); return; @@ -3733,12 +3872,13 @@ async function inspectMobileWorkbench(page, { explorerOpen }) { }, { explorerOpen }); } -async function inspectWorkbenchLayoutViewport(browser, url, viewport) { +async function inspectWorkbenchLayoutViewport(browser, url, viewport, options = {}) { const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, isMobile: viewport.isMobile === true }); + const artifacts = { screenshots: [] }; try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 }); @@ -3749,6 +3889,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: null }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-collapsed")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const drawer = await inspectLayoutState(page, { @@ -3756,6 +3897,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: collapsed }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-drawer")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const recollapsed = await inspectLayoutState(page, { @@ -3770,7 +3912,8 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { keyTargetsReachable: recollapsed.keyTargetsReachable }; drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable; - return { collapsed, drawer }; + const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); + return { collapsed, drawer, gate, artifacts }; } const expanded = await inspectLayoutState(page, { @@ -3778,6 +3921,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: null }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-expanded")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const collapsed = await inspectLayoutState(page, { @@ -3785,6 +3929,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: expanded }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-collapsed")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const restored = await inspectLayoutState(page, { @@ -3792,7 +3937,28 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: expanded }); - return { expanded, collapsed, restored }; + const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); + return { expanded, collapsed, restored, gate, artifacts }; + } finally { + await page.close(); + } +} + +async function inspectWorkbenchGateLayout(browser, url, viewport, options = {}) { + const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + isMobile: viewport.isMobile === true + }); + try { + await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.locator('[data-view="gate"]').waitFor({ state: "visible", timeout: 12000 }); + await page.waitForTimeout(150); + const currentRoute = await inspectGateLayoutState(page, { viewport, mode: "gate-current" }); + currentRoute.artifacts = { + screenshots: await captureGateScreenshots(page, options.artifactRoot, viewport) + }; + return { currentRoute }; } finally { await page.close(); } @@ -3800,16 +3966,44 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { async function inspectLayoutState(page, { mode, viewport, compareTo }) { return page.evaluate(async ({ mode, viewport, compareTo }) => { + const failures = []; + const skip = []; const selectorLabels = [ ["#explorer-toggle", "资源树折叠按钮"], ["#command-input", "Code Agent 输入"], ["#command-send", "发送"], + ["#m3-gateway-select", "M3 Gateway select"], + ["#m3-box-select", "M3 BOX select"], + ["#m3-port-select", "M3 port select"], + ["#m3-value-select", "M3 value select"], ["#tab-control", "M3 控制标签"], ["#tab-wiring", "M3 接线标签"], ["#tab-records", "可信记录标签"], ["#m3-write-do", "写入 DO1"], ["#m3-read-di", "读取 DI1"] ]; + const overflowSelectors = [ + "body", + "[data-app-shell]", + ".workbench-shell", + ".center-workspace", + "#command-form", + ".input-shell", + ".right-sidebar", + ".hardware-status", + "#hardware-list", + "#panel-control", + "#m3-control-form", + ".m3-control-actions" + ]; + const overlapPairs = [ + [".right-sidebar article.info-card", "#m3-control-form", "M3 control info-card vs DO/DI form"], + [".right-sidebar article.info-card", ".m3-control-actions", "M3 control info-card vs actions"], + ["#m3-control-form", "#control-list", "M3 form vs control info list"], + [".m3-control-actions", "#control-list", "M3 actions vs control info list"], + [".hardware-status", ".side-workspace", "hardware status vs side workspace"], + ["#command-form", ".right-sidebar", "Code Agent command form vs right sidebar"] + ]; const drawerSelectorLabels = [ [".quick-actions button", "资源树快捷操作"], ["#resource-tree summary", "资源树分组"], @@ -3830,7 +4024,9 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { visibility: style.visibility, overflow: style.overflow, overflowY: style.overflowY, + clientWidth: element.clientWidth, clientHeight: element.clientHeight, + scrollWidth: element.scrollWidth, scrollHeight: element.scrollHeight, text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? "" }; @@ -3842,34 +4038,98 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { }; const ownsHit = (element, hit) => hit === element || element.contains(hit); const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; - const inspectHitTarget = (selector, label) => { + const nearestScrollableAncestor = (element) => { + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); + if (scrollable) return current; + current = current.parentElement; + } + return null; + }; + const scrollIntoNearestContainer = async (element) => { + const container = nearestScrollableAncestor(element); + if (!container) { + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + return null; + } + const elementBox = element.getBoundingClientRect(); + const containerBox = container.getBoundingClientRect(); + const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); + container.scrollTop += elementBox.top - containerBox.top - inset; + await new Promise((resolve) => requestAnimationFrame(resolve)); + return container; + }; + const clipBoxForElement = (element) => { + let visible = { + left: 0, + top: 0, + right: window.innerWidth, + bottom: window.innerHeight + }; + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { + const box = current.getBoundingClientRect(); + visible = { + left: Math.max(visible.left, box.left), + top: Math.max(visible.top, box.top), + right: Math.min(visible.right, box.right), + bottom: Math.min(visible.bottom, box.bottom) + }; + } + current = current.parentElement; + } + return visible; + }; + const inspectHitTarget = async (selector, label) => { const element = document.querySelector(selector); if (!element) return { selector, label, ok: false, missing: true }; - element.scrollIntoView({ block: "center", inline: "nearest" }); + const scrollContainer = await scrollIntoNearestContainer(element); const box = element.getBoundingClientRect(); - const cx = box.left + box.width / 2; - const cy = box.top + box.height / 2; - const hit = document.elementFromPoint(cx, cy); + const clipBox = clipBoxForElement(element); + const visibleBox = { + left: Math.max(box.left, clipBox.left), + top: Math.max(box.top, clipBox.top), + right: Math.min(box.right, clipBox.right), + bottom: Math.min(box.bottom, clipBox.bottom) + }; + const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); + const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); + const cx = visibleBox.left + visibleWidth / 2; + const cy = visibleBox.top + visibleHeight / 2; + const stack = document.elementsFromPoint(cx, cy); + const hit = stack[0] ?? null; const style = getComputedStyle(element); + const ok = + style.display !== "none" && + style.visibility !== "hidden" && + box.width > 0 && + box.height > 0 && + visibleWidth > 0 && + visibleHeight > 0 && + cx >= 0 && + cx <= window.innerWidth && + cy >= 0 && + cy <= window.innerHeight && + stack.some((candidate) => ownsHit(element, candidate)); return { selector, label, - ok: - style.display !== "none" && - style.visibility !== "hidden" && - box.width > 0 && - box.height > 0 && - cx >= 0 && - cx <= window.innerWidth && - cy >= 0 && - cy <= window.innerHeight && - ownsHit(element, hit), + ok, center: { x: cx, y: cy }, box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - hit: hitLabel(hit) + visibleBox, + clipping: clipBox, + scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null, + hit: hitLabel(hit), + hitStack: stack.slice(0, 5).map(hitLabel) }; }; - const inspectFirstHit = (selector, label) => { + const inspectFirstHit = async (selector, label) => { const element = document.querySelector(selector); if (!element) return { selector, label, ok: false, missing: true }; return inspectHitTarget(selector, label); @@ -3908,6 +4168,10 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { center: boxFor(".center-workspace"), right: boxFor(".right-sidebar"), commandBar: boxFor("#command-form"), + commandInput: boxFor("#command-input"), + commandSend: boxFor("#command-send"), + hardwareStatus: boxFor(".hardware-status"), + hardwareList: boxFor("#hardware-list"), controlPanel: boxFor("#panel-control"), m3Form: boxFor("#m3-control-form"), m3Actions: boxFor(".m3-control-actions"), @@ -3927,8 +4191,14 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { rootAfterScrollAttempt.windowScrollY === 0 && rootAfterScrollAttempt.htmlScrollTop === 0 && rootAfterScrollAttempt.bodyScrollTop === 0; - const keyTargets = selectorLabels.map(([selector, label]) => inspectHitTarget(selector, label)); - const drawerTargets = drawerSelectorLabels.map(([selector, label]) => inspectFirstHit(selector, label)); + const keyTargets = []; + for (const [selector, label] of selectorLabels) { + keyTargets.push(await inspectHitTarget(selector, label)); + } + const drawerTargets = []; + for (const [selector, label] of drawerSelectorLabels) { + drawerTargets.push(await inspectFirstHit(selector, label)); + } const blockedKeyTargets = keyTargets.filter((target) => !target.ok); const blockedDrawerTargets = drawerTargets.filter((target) => !target.ok); const explorerCollapsed = shell?.classList.contains("explorer-collapsed") === true; @@ -3941,6 +4211,18 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { ); const isDesktop = viewport.width >= 861; const isMobile = !isDesktop; + const requireKeyTargets = !(isMobile && mode === "mobile-drawer"); + if (requireKeyTargets) { + for (const target of blockedKeyTargets) { + failures.push({ + failureType: target.missing ? "blocked/skip" : "covered-hit-target", + selector: target.selector, + viewport, + summary: `${target.label} is not center-click reachable; hit=${target.hit ?? "none"}`, + target + }); + } + } const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center ? boxes.center.width - compareTo.boxes.center.width : null; @@ -3954,7 +4236,77 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { m3ActionsControlList: boxesOverlap(boxes.m3Actions, boxes.controlList, -1), topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1) }; + const semanticOverlapChecks = overlapPairs.map(([aSelector, bSelector, label]) => { + const a = boxFor(aSelector); + const b = boxFor(bSelector); + const overlaps = boxesOverlap(a, b, -1); + if (overlaps) { + failures.push({ + failureType: "overlap", + selector: `${aSelector} <-> ${bSelector}`, + viewport, + summary: `${label} overlap`, + boxes: { [aSelector]: a, [bSelector]: b } + }); + } + return { + label, + selectors: [aSelector, bSelector], + overlaps, + boxes: { [aSelector]: a, [bSelector]: b } + }; + }); const noIncoherentOverlap = Object.values(overlapChecks).every((value) => value === false); + if (!noIncoherentOverlap) { + for (const [key, value] of Object.entries(overlapChecks)) { + if (value) { + failures.push({ + failureType: "overlap", + selector: key, + viewport, + summary: `layout region overlap detected: ${key}` + }); + } + } + } + const overflowChecks = overflowSelectors.map((selector) => { + const element = selector === "body" ? document.body : document.querySelector(selector); + if (!element) { + return { selector, exists: false, ok: true, skipped: true }; + } + const style = getComputedStyle(element); + const allowHorizontalScroll = selector === ".activity-rail" || selector === ".table-wrap"; + const horizontalOverflow = element.scrollWidth > element.clientWidth + 2; + const verticalOverflow = element.scrollHeight > element.clientHeight + 2; + const isRootSelector = selector === "body" || selector === "[data-app-shell]" || selector === ".workbench-shell"; + const ok = isRootSelector + ? !horizontalOverflow && !verticalOverflow + : !horizontalOverflow || allowHorizontalScroll || ["auto", "scroll"].includes(style.overflowX); + const result = { + selector, + exists: true, + ok, + horizontalOverflow, + verticalOverflow, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + overflowX: style.overflowX, + overflowY: style.overflowY + }; + if (!ok) { + failures.push({ + failureType: "overflow", + selector, + viewport, + summary: `${selector} overflow regression: scrollWidth=${element.scrollWidth}, clientWidth=${element.clientWidth}, scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}`, + overflow: result + }); + } + return result; + }); + const overflowOk = overflowChecks.every((check) => check.ok); const sidePanelScroll = await scrollableProbe("#panel-control"); const sidePanelOverflowUsable = sidePanelScroll.exists && (sidePanelScroll.scrolls || sidePanelScroll.overflowY !== "hidden"); const toggle = { @@ -3973,6 +4325,15 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { const drawerReachable = isMobile && mode === "mobile-drawer" ? drawerTargets.length >= 3 && blockedDrawerTargets.length === 0 : true; + for (const target of isMobile && mode === "mobile-drawer" ? blockedDrawerTargets : []) { + failures.push({ + failureType: target.missing ? "blocked/skip" : "covered-hit-target", + selector: target.selector, + viewport, + summary: `${target.label} drawer target is not reachable; hit=${target.hit ?? "none"}`, + target + }); + } const collapsedExpected = mode === "desktop-collapsed" || mode === "mobile-collapsed" || mode === "mobile-recollapsed"; const expandedExpected = mode === "desktop-expanded" || mode === "desktop-restored" || mode === "mobile-drawer"; const collapsedStateMatches = collapsedExpected @@ -3989,10 +4350,117 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { ? explorerCollapsed === true : true; const keyTargetsReachable = blockedKeyTargets.length === 0; - const keyTargetsRequirement = mode === "mobile-drawer" ? true : keyTargetsReachable; + const keyTargetsRequirement = requireKeyTargets ? keyTargetsReachable : true; + if (!collapsedStateMatches) { + failures.push({ + failureType: "blocked/skip", + selector: "#explorer-toggle", + viewport, + summary: `Explorer toggle state/Chinese label mismatch in ${mode}: text=${toggle.text}, ariaLabel=${toggle.ariaLabel}, ariaExpanded=${toggle.ariaExpanded}, ariaPressed=${toggle.ariaPressed}.`, + toggle, + expected: collapsedExpected + ? { + explorerCollapsed: true, + text: "展开资源树", + ariaLabel: "展开左侧资源树", + ariaExpanded: "false", + ariaPressed: "true" + } + : { + explorerCollapsed: false, + text: "收起资源树", + ariaLabel: "收起左侧资源树", + ariaExpanded: "true", + ariaPressed: "false" + } + }); + } + if (!explorerStateMatches) { + failures.push({ + failureType: "blocked/skip", + selector: "#resource-explorer", + viewport, + summary: `Explorer visibility did not match expected ${collapsedExpected ? "collapsed" : "expanded"} state in ${mode}.`, + explorerCollapsed, + explorerVisible, + explorerBox: boxes.explorer + }); + } + if (!collapsedWidthGain) { + failures.push({ + failureType: "overflow", + selector: ".center-workspace", + viewport, + summary: `Collapsed desktop layout did not release enough width to the center workspace; centerWidthDelta=${centerWidthDelta}.`, + centerWidthDelta, + centerBox: boxes.center + }); + } + if (!restoredWidth) { + failures.push({ + failureType: "overflow", + selector: ".center-workspace", + viewport, + summary: `Restored desktop layout did not return center width near the expanded baseline; centerWidthDelta=${centerWidthDelta}.`, + centerWidthDelta, + centerBox: boxes.center + }); + } + if (!drawerReachable) { + failures.push({ + failureType: "covered-hit-target", + selector: "#resource-explorer", + viewport, + summary: "Mobile resource drawer opened but its quick actions/resource rows were not reachable.", + blockedDrawerTargets + }); + } + if (!defaultCollapsedMobile) { + failures.push({ + failureType: "blocked/skip", + selector: "[data-app-shell]", + viewport, + summary: "Mobile default workbench did not start with the resource explorer collapsed.", + explorerCollapsed + }); + } + if (!rootScrollLocked) { + failures.push({ + failureType: "outer-scroll-regression", + selector: "html/body/.workbench-shell", + viewport, + summary: "Outer html/body/workbench shell scrolled or exceeded viewport after scroll attempt.", + rootAfterScrollAttempt, + html: boxForElement(document.documentElement), + body: boxForElement(document.body), + shell: boxes.shell + }); + } + if (!sidePanelOverflowUsable) { + failures.push({ + failureType: "overflow", + selector: "#panel-control", + viewport, + summary: "M3 control panel does not expose usable internal scrolling when needed.", + sidePanelScroll + }); + } + const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) => + Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label))) + ); + if (!issue287HardwareTabsReady) { + skip.push({ + failureType: "skip", + selector: ".hardware-status", + viewport, + summary: "#287 hardware status tabs are not visible in the current DOM; current container overflow is still covered." + }); + } const pass = rootScrollLocked && noIncoherentOverlap && + semanticOverlapChecks.every((check) => !check.overlaps) && + overflowOk && keyTargetsRequirement && collapsedStateMatches && explorerStateMatches && @@ -4024,11 +4492,303 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { blockedDrawerTargets, sidePanelScroll, sidePanelOverflowUsable, - defaultCollapsedMobile + semanticOverlapChecks, + overflowChecks, + defaultCollapsedMobile, + issue287HardwareTabsReady, + issue288SingleTableReady: false, + skip, + failures }; }, { mode, viewport, compareTo }); } +async function inspectGateLayoutState(page, { mode, viewport }) { + return page.evaluate(async ({ mode, viewport }) => { + const failures = []; + const skip = []; + const boxForElement = (element) => { + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + width: box.width, + height: box.height, + display: style.display, + visibility: style.visibility, + overflowX: style.overflowX, + overflowY: style.overflowY, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 160) ?? "" + }; + }; + const boxFor = (selector) => boxForElement(document.querySelector(selector)); + const boxesOverlap = (a, b, gap = 0) => { + if (!a || !b || a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false; + return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top); + }; + const ownsHit = (element, hit) => hit === element || element.contains(hit); + const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; + const hitTarget = (selector, label) => { + const element = document.querySelector(selector); + if (!element) { + const missing = { selector, label, ok: false, missing: true }; + failures.push({ + failureType: "blocked/skip", + selector, + viewport, + summary: `${label} is missing on /gate`, + target: missing + }); + return missing; + } + const box = element.getBoundingClientRect(); + const cx = box.left + box.width / 2; + const cy = box.top + Math.min(box.height / 2, 24); + const stack = document.elementsFromPoint(cx, cy); + const ok = + box.width > 0 && + box.height > 0 && + cx >= 0 && + cx <= window.innerWidth && + cy >= 0 && + cy <= window.innerHeight && + stack.some((candidate) => ownsHit(element, candidate)); + const target = { + selector, + label, + ok, + center: { x: cx, y: cy }, + hit: hitLabel(stack[0] ?? null), + hitStack: stack.slice(0, 5).map(hitLabel), + box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height } + }; + if (!ok) { + failures.push({ + failureType: "covered-hit-target", + selector, + viewport, + summary: `${label} is not user-reachable on /gate; hit=${target.hit}`, + target + }); + } + return target; + }; + + window.scrollTo(0, 240); + document.documentElement.scrollTop = 240; + document.body.scrollTop = 240; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const gate = document.querySelector('[data-view="gate"]'); + const workspace = document.querySelector('[data-view="workspace"]'); + const shell = document.querySelector("[data-app-shell]"); + const html = boxForElement(document.documentElement); + const body = boxForElement(document.body); + const shellBox = boxFor("[data-app-shell]"); + const gateBox = boxFor('[data-view="gate"]'); + const rootAfterScrollAttempt = { + windowScrollY: window.scrollY, + htmlScrollTop: document.documentElement.scrollTop, + bodyScrollTop: document.body.scrollTop + }; + const rootScrollLocked = + getComputedStyle(document.documentElement).overflow === "hidden" && + getComputedStyle(document.body).overflow === "hidden" && + document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && + document.body.scrollHeight <= document.body.clientHeight + 2 && + rootAfterScrollAttempt.windowScrollY === 0 && + rootAfterScrollAttempt.htmlScrollTop === 0 && + rootAfterScrollAttempt.bodyScrollTop === 0; + if (!rootScrollLocked) { + failures.push({ + failureType: "outer-scroll-regression", + selector: "html/body/.workbench-shell", + viewport, + summary: "/gate route allowed page-level scrolling.", + rootAfterScrollAttempt, + html, + body, + shell: shellBox + }); + } + const hitTargets = [ + hitTarget('[data-route="workspace"]', "工作台"), + hitTarget('[data-route="gate"]', "内部复核"), + hitTarget('[data-route="help"]', "使用说明") + ]; + const gateChildren = [...document.querySelectorAll('[data-view="gate"] > *')].map((element, index) => ({ + selector: `[data-view="gate"] > :nth-child(${index + 1})`, + box: boxForElement(element) + })); + const visibleOverlapPairs = []; + for (let i = 0; i < gateChildren.length; i += 1) { + for (let j = i + 1; j < gateChildren.length; j += 1) { + const a = gateChildren[i]; + const b = gateChildren[j]; + if (boxesOverlap(a.box, b.box, -1)) { + visibleOverlapPairs.push([a.selector, b.selector]); + failures.push({ + failureType: "overlap", + selector: `${a.selector} <-> ${b.selector}`, + viewport, + summary: "/gate visible sections overlap.", + boxes: { [a.selector]: a.box, [b.selector]: b.box } + }); + } + } + } + const gateHorizontalOverflow = Boolean(gate && gate.scrollWidth > gate.clientWidth + 2); + if (gateHorizontalOverflow) { + failures.push({ + failureType: "overflow", + selector: '[data-view="gate"]', + viewport, + summary: `/gate horizontal overflow: scrollWidth=${gate.scrollWidth}, clientWidth=${gate.clientWidth}`, + overflow: { + scrollWidth: gate.scrollWidth, + clientWidth: gate.clientWidth, + overflowX: getComputedStyle(gate).overflowX + } + }); + } + const tableCount = document.querySelectorAll('[data-view="gate"] table').length; + const workspacePanels = document.querySelectorAll('[data-view="gate"] .workspace-panel').length; + const complexLayoutCount = document.querySelectorAll('[data-view="gate"] .gate-grid, [data-view="gate"] .gate-split, [data-view="gate"] .milestone-grid').length; + const issue288SingleTableReady = tableCount === 1 && workspacePanels <= 1 && complexLayoutCount === 0; + if (!issue288SingleTableReady) { + skip.push({ + failureType: "skip", + selector: '[data-view="gate"]', + viewport, + summary: `#288 single-table gate page not yet implemented: tableCount=${tableCount}, workspacePanels=${workspacePanels}, complexLayoutCount=${complexLayoutCount}` + }); + } + const pass = + window.location.pathname.replace(/\/+$/, "") === "/gate" && + gate?.hidden === false && + workspace?.hidden === true && + shell !== null && + rootScrollLocked && + hitTargets.every((target) => target.ok) && + !gateHorizontalOverflow && + visibleOverlapPairs.length === 0; + return { + mode, + viewport, + pass, + pathname: window.location.pathname, + workspaceHidden: workspace ? workspace.hidden : null, + gateHidden: gate ? gate.hidden : null, + rootScrollLocked, + rootAfterScrollAttempt, + boxes: { + html, + body, + shell: shellBox, + gate: gateBox + }, + hitTargets, + visibleOverlapPairs, + gateHorizontalOverflow, + tableCount, + workspacePanels, + complexLayoutCount, + issue288SingleTableReady, + issue287HardwareTabsReady: false, + failures, + skip + }; + }, { mode, viewport }); +} + +async function captureLayoutScreenshots(page, artifactRoot, viewport, stateId) { + if (!artifactRoot) return []; + return [ + await captureElementScreenshot(page, ".right-sidebar", artifactRoot, viewport, `${stateId}-right-sidebar`), + await captureElementScreenshot(page, "#m3-control-form", artifactRoot, viewport, `${stateId}-m3-control-form`), + await captureElementScreenshot(page, "#command-form", artifactRoot, viewport, `${stateId}-code-agent-input`) + ].filter(Boolean); +} + +async function captureGateScreenshots(page, artifactRoot, viewport) { + if (!artifactRoot) return []; + return [ + await captureElementScreenshot(page, '[data-view="gate"]', artifactRoot, viewport, "gate-current") + ].filter(Boolean); +} + +async function captureElementScreenshot(page, selector, artifactRoot, viewport, label) { + const locator = page.locator(selector).first(); + const count = await locator.count(); + if (count === 0) return null; + const safeLabel = label.replace(/[^a-z0-9_-]+/giu, "-").replace(/^-|-$/gu, "").toLowerCase(); + const filePath = path.join(artifactRoot, `${viewport.width}x${viewport.height}-${safeLabel}.png`); + try { + await locator.screenshot({ path: filePath, timeout: 5000 }); + return { + selector, + viewport: { width: viewport.width, height: viewport.height }, + label, + path: filePath + }; + } catch (error) { + return { + selector, + viewport: { width: viewport.width, height: viewport.height }, + label, + path: filePath, + status: "blocked", + failureType: "screenshot-diff", + summary: `screenshot capture failed: ${error.message}` + }; + } +} + +function firstFailure(check) { + return check?.observations?.failures?.[0] ?? null; +} + +function allViewportCoverage(viewportResults, fieldName) { + return Object.values(viewportResults).every((result) => + Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName]) + ); +} + +function summarizeCoverageGap(viewportResults, issueKey) { + const fieldName = issueKey === "issue288" ? "issue288SingleTableReady" : "issue287HardwareTabsReady"; + return { + status: allViewportCoverage(viewportResults, fieldName) ? "pass" : "skip", + coverage: Object.fromEntries( + Object.entries(viewportResults).map(([id, result]) => [ + id, + { + viewport: result.expanded?.viewport ?? result.collapsed?.viewport ?? result.gate?.currentRoute?.viewport ?? null, + covered: Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName]), + currentVisibleContainersChecked: true, + skip: [ + ...(result.expanded?.skip ?? []), + ...(result.collapsed?.skip ?? []), + ...(result.gate?.currentRoute?.skip ?? []) + ].filter((item) => issueKey === "issue288" + ? item.summary?.includes("#288") + : item.summary?.includes("#287")) + } + ]) + ) + }; +} + +function layoutArtifactTimestamp(date = new Date()) { + return date.toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z"); +} + async function inspectWorkbenchScrollContract(page, { panelSelector }) { return page.evaluate(async ({ panelSelector }) => { function metrics(selector) {