From 57c1d00a8ad63342da8849ce2c11da14ab50738a Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 16:59:10 +0000 Subject: [PATCH] fix: improve mobile cloud workbench reachability --- scripts/dev-cloud-workbench-smoke.mjs | 15 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 319 ++++++++++++++++++ web/hwlab-cloud-web/app.mjs | 35 +- web/hwlab-cloud-web/index.html | 4 +- web/hwlab-cloud-web/scripts/check.mjs | 30 +- web/hwlab-cloud-web/styles.css | 113 ++++++- 6 files changed, 501 insertions(+), 15 deletions(-) diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index 2ba62c22..4ca31f9b 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -1,14 +1,23 @@ #!/usr/bin/env node import { fileURLToPath } from "node:url"; -import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs"; +import { + parseSmokeArgs, + printSmokeHelp, + runDevCloudWorkbenchMobileSmoke, + runDevCloudWorkbenchSmoke +} from "./src/dev-cloud-workbench-smoke-lib.mjs"; if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { const args = parseSmokeArgs(process.argv.slice(2)); - const report = args.help ? printSmokeHelp() : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); + const report = args.help + ? printSmokeHelp() + : args.mobile + ? await runDevCloudWorkbenchMobileSmoke() + : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - process.exitCode = report.status === "pass" || report.status === "usage" ? 0 : 2; + process.exitCode = report.status === "pass" || report.status === "usage" || report.status === "skip" ? 0 : 2; } catch (error) { process.stderr.write(`[dev-cloud-workbench-smoke] ${error instanceof Error ? error.stack : String(error)}\n`); process.exitCode = 1; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index e65da0b7..0ed04fb7 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -157,6 +158,8 @@ export function parseSmokeArgs(argv) { args.mode = "static"; } else if (arg === "--live") { args.mode = "live"; + } else if (arg === "--mobile") { + args.mobile = true; } else if (arg === "--url") { index += 1; if (!argv[index]) throw new Error("--url requires a value"); @@ -243,6 +246,16 @@ function runStaticSmoke() { evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] }); + addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable and moves the resource tree into a controlled drawer.", { + blocker: "runtime_blocker", + evidence: [ + "syncMobileExplorer collapses resource tree at <=860px", + "mobile rail spans full height", + "center workspace and right sidebar share the content column", + "resource explorer opens as a full-height drawer" + ] + }); + addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { blocker: "safety_blocker", evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] @@ -584,6 +597,26 @@ function hasScrollLockContract(styles) { ); } +function hasMobileWorkbenchLayoutContract({ html, app, styles }) { + return ( + /id=["']resource-explorer["']/u.test(html) && + /aria-controls=["']resource-explorer["']/u.test(html) && + /aria-expanded=["']true["']/u.test(html) && + /function\s+syncMobileExplorer\s*\(/u.test(app) && + /window\.matchMedia\(["']\(max-width:\s*860px\)["']\)/u.test(app) && + /setExplorerCollapsed\(true\)/u.test(app) && + /setExplorerCollapsed\(false\)/u.test(app) && + /function\s+collapseExplorerAfterMobileAction\s*\(/u.test(app) && + /aria-expanded/u.test(functionBody(app, "setExplorerCollapsed")) && + /\.explorer-collapsed\s+\.explorer\s*\{[^\}]*pointer-events:\s*none;/su.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.activity-rail\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;/u.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;[\s\S]*?z-index:\s*4;/u.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.center-workspace\s*\{[\s\S]*?grid-row:\s*1;/u.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.right-sidebar\s*\{[\s\S]*?grid-row:\s*2;/u.test(styles) && + /@media\s*\(max-width:\s*520px\)[\s\S]*?\.command-bar\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto\s+auto;/u.test(styles) + ); +} + function hasSameOriginReadOnlyBoundary(app) { const rpcCalls = [...app.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]).sort(); return ( @@ -879,6 +912,291 @@ async function inspectLiveDom(url) { } } +export async function runDevCloudWorkbenchMobileSmoke() { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + status: "skip", + task: "DC-DCSN-P0-2026-003", + mode: "static-mobile-browser", + viewport: { width: 390, height: 844 }, + evidenceLevel: "SOURCE", + devLive: false, + summary: `Mobile browser smoke skipped because Playwright is unavailable: ${error.message}`, + checks: [], + blockers: [], + safety: staticSafety() + }; + } + + const server = await startStaticWebServer(); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true + }); + await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 }); + const closed = await inspectMobileWorkbench(page, { explorerOpen: false }); + await page.click("#explorer-toggle"); + const open = await inspectMobileWorkbench(page, { explorerOpen: true }); + await page.click('[data-side-tab-jump="wiring"]'); + const afterQuickAction = await inspectMobileWorkbench(page, { explorerOpen: false }); + const checks = [ + { + id: "mobile-default-workbench", + status: closed.defaultWorkbenchReachable ? "pass" : "blocked", + summary: "390x844 default route remains the user workbench with Gate/help as secondary entries.", + observations: { + title: closed.title, + workspaceHidden: closed.workspaceHidden, + gateHidden: closed.gateHidden, + helpHidden: closed.helpHidden, + explorerCollapsed: closed.explorerCollapsed, + visibleText: closed.visibleText + } + }, + { + id: "mobile-default-hit-test", + status: closed.primaryControlsReachable ? "pass" : "blocked", + summary: "Primary workbench controls are center-click reachable with the resource tree closed.", + observations: { + reachable: closed.reachable, + blocked: closed.blocked + } + }, + { + id: "mobile-resource-drawer-hit-test", + status: open.resourceControlsReachable ? "pass" : "blocked", + summary: "Opened resource drawer keeps quick actions and resource rows center-click reachable.", + observations: { + drawer: open.drawer, + reachable: open.reachable, + blocked: open.blocked + } + }, + { + id: "mobile-resource-action-collapse", + status: afterQuickAction.resourceActionCollapsed ? "pass" : "blocked", + summary: "Mobile resource quick actions collapse the drawer so the target workspace panel remains reachable.", + observations: { + explorerCollapsed: afterQuickAction.explorerCollapsed, + wiringSelected: afterQuickAction.wiringSelected, + tabHit: afterQuickAction.reachable.find((target) => target.label === "接线") + } + } + ]; + const blockers = checks + .filter((check) => check.status !== "pass") + .map((check) => ({ + type: "runtime_blocker", + scope: check.id, + status: "open", + summary: check.summary + })); + return { + status: blockers.length === 0 ? "pass" : "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "static-mobile-browser", + viewport: { width: 390, height: 844 }, + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: "Static local browser smoke only; deployment still requires post-deploy live smoke.", + checks, + blockers, + safety: staticSafety() + }; + } catch (error) { + return { + status: "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "static-mobile-browser", + viewport: { width: 390, height: 844 }, + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: `Mobile browser smoke failed: ${error.message}`, + checks: [], + blockers: [ + { + type: "runtime_blocker", + scope: "mobile-browser-smoke", + status: "open", + summary: error.message + } + ], + safety: staticSafety() + }; + } finally { + if (browser) await browser.close(); + await server.close(); + } +} + +async function startStaticWebServer() { + const server = http.createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + let pathname = decodeURIComponent(url.pathname); + if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate") { + pathname = "/index.html"; + } + const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, "")); + if (!filePath.startsWith(webRoot)) { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end("forbidden"); + return; + } + try { + const body = await fs.promises.readFile(filePath); + response.writeHead(200, { "content-type": contentTypeFor(filePath) }); + response.end(body); + } catch { + response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ ok: false, error: "not_found" })); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}/`, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + }; +} + +function contentTypeFor(filePath) { + if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; + if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; + if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8"; + return "application/octet-stream"; +} + +async function inspectMobileWorkbench(page, { explorerOpen }) { + return page.evaluate(async ({ explorerOpen }) => { + function visibleTextFor(selector) { + const element = document.querySelector(selector); + return element?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; + } + function boxFor(selector) { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + return { + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + width: box.width, + height: box.height, + display: getComputedStyle(element).display + }; + } + function ownsHit(element, hit) { + return hit === element || element.contains(hit); + } + function inspectTarget(element, label = element.id || element.textContent?.trim() || element.tagName) { + 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 hitLabel = hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; + return { + label: label.replace(/\s+/gu, " ").trim(), + ok: box.width > 0 && box.height > 0 && cx >= 0 && cx <= window.innerWidth && cy >= 0 && cy <= window.innerHeight && ownsHit(element, hit), + 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 + }; + } + function inspectSelector(selector, label) { + const element = document.querySelector(selector); + if (!element) { + return { label, ok: false, missing: true }; + } + return inspectTarget(element, label); + } + async function inspectScrollableTargets(selector) { + const targets = [...document.querySelectorAll(selector)]; + const results = []; + for (const target of targets) { + target.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + results.push(inspectTarget(target)); + } + return results; + } + + const defaultTargets = [ + inspectSelector('[data-route="workspace"]', "工作台"), + inspectSelector('[data-route="gate"]', "内部复核"), + inspectSelector('[data-route="help"]', "使用说明"), + inspectSelector("#explorer-toggle", "资源树"), + inspectSelector("#command-input", "Agent 输入"), + inspectSelector("#command-send", "发送"), + inspectSelector("#command-clear", "清空"), + inspectSelector("#tab-control", "控制"), + inspectSelector("#tab-wiring", "接线"), + inspectSelector("#tab-records", "可信记录") + ]; + const drawerTargets = explorerOpen + ? [ + ...[...document.querySelectorAll(".quick-actions button")].map((element) => inspectTarget(element)), + ...(await inspectScrollableTargets("#resource-tree summary, #resource-tree .tree-row")) + ] + : []; + const reachable = explorerOpen ? drawerTargets : defaultTargets; + const blocked = reachable.filter((target) => !target.ok); + const text = document.body.textContent ?? ""; + const workspace = document.querySelector('[data-view="workspace"]'); + const gate = document.querySelector('[data-view="gate"]'); + const help = document.querySelector('[data-view="help"]'); + const shell = document.querySelector("[data-app-shell]"); + const drawer = boxFor("#resource-explorer"); + return { + title: document.title, + workspaceHidden: workspace ? workspace.hidden : null, + gateHidden: gate ? gate.hidden : null, + helpHidden: help ? help.hidden : null, + explorerCollapsed: shell?.classList.contains("explorer-collapsed") ?? null, + visibleText: { + bodyHasWorkbench: text.includes("用户工作台") && text.includes("Agent 对话"), + gateIsSecondaryLabel: text.includes("内部复核"), + helpIsSecondaryLabel: text.includes("使用说明"), + visibleWorkspaceText: visibleTextFor(".center-workspace").slice(0, 160) + }, + drawer, + defaultWorkbenchReachable: + document.title === "HWLAB 云工作台" && + workspace?.hidden === false && + gate?.hidden === true && + help?.hidden === true && + text.includes("用户工作台") && + text.includes("Agent 对话") && + text.includes("内部复核") && + text.includes("使用说明"), + primaryControlsReachable: defaultTargets.every((target) => target.ok), + resourceControlsReachable: drawerTargets.length >= 8 && drawerTargets.every((target) => target.ok), + resourceActionCollapsed: + !explorerOpen && + shell?.classList.contains("explorer-collapsed") === true && + document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true", + wiringSelected: document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true", + reachable, + blocked + }; + }, { explorerOpen }); +} + function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } @@ -889,6 +1207,7 @@ export function printSmokeHelp() { command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/", notes: [ "Static mode reads repository files and emits SOURCE-level evidence only.", + "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", "Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable." ] }; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 35587c57..adc97bfb 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -98,6 +98,7 @@ const state = { initRoutes(); initExplorerToggle(); +syncMobileExplorer(); initSideTabs(); initQuickActions(); initCommandBar(); @@ -154,11 +155,29 @@ function showView(route) { function initExplorerToggle() { el.explorerToggle.addEventListener("click", () => { const collapsed = !el.shell.classList.contains("explorer-collapsed"); - el.shell.classList.toggle("explorer-collapsed", collapsed); - el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false"); + setExplorerCollapsed(collapsed); }); } +function setExplorerCollapsed(collapsed) { + el.shell.classList.toggle("explorer-collapsed", collapsed); + el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false"); + el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); +} + +function syncMobileExplorer() { + const media = window.matchMedia("(max-width: 860px)"); + const apply = () => { + if (media.matches) { + setExplorerCollapsed(true); + } else { + setExplorerCollapsed(false); + } + }; + apply(); + media.addEventListener("change", apply); +} + async function loadHelpSurface() { el.helpStatus.textContent = "加载中"; el.helpStatus.className = "state-tag tone-dry-run"; @@ -191,7 +210,10 @@ function initSideTabs() { tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab)); } for (const jump of document.querySelectorAll("[data-side-tab-jump]")) { - jump.addEventListener("click", () => selectSideTab(jump.dataset.sideTabJump)); + jump.addEventListener("click", () => { + selectSideTab(jump.dataset.sideTabJump); + collapseExplorerAfterMobileAction(); + }); } } @@ -199,10 +221,17 @@ function initQuickActions() { for (const button of document.querySelectorAll("[data-focus-command]")) { button.addEventListener("click", () => { el.commandInput.focus(); + collapseExplorerAfterMobileAction(); }); } } +function collapseExplorerAfterMobileAction() { + if (window.matchMedia("(max-width: 860px)").matches) { + setExplorerCollapsed(true); + } +} + function selectSideTab(tabId) { for (const tab of document.querySelectorAll("[data-side-tab]")) { const active = tab.dataset.sideTab === tabId; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index bbe74bed..ee98288c 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -14,10 +14,10 @@ - + -