fix: improve mobile cloud workbench reachability

This commit is contained in:
Code Queue Review
2026-05-22 16:59:10 +00:00
parent a45e1bac91
commit 57c1d00a8a
6 changed files with 501 additions and 15 deletions
@@ -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."
]
};