fix: improve mobile cloud workbench reachability
This commit is contained in:
@@ -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."
|
||||
]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user