import assert from "node:assert/strict"; import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultCredentials = Object.freeze({ username: "admin", password: "hwlab2026" }); test("left activity rail resize is controlled, clamped, persisted, and disabled while collapsed", async () => { const server = await startStaticServer(webRoot); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1366, height: 768 }, deviceScaleFactor: 1 }); await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); await login(page); await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); const before = await sidebarMetrics(page); assert.equal(before.handle.role, "separator"); assert.equal(before.handle.ariaLabel, "拖拽调整左侧导航宽度"); assert.match(before.handle.title, /左右方向键/); assert.equal(before.handle.tabIndex, 0); assert.equal(before.handle.ariaDisabled, "false"); assert.equal(before.handle.ariaHidden, "false"); assert.equal(before.railWidth, 92); assert.equal(before.handle.min, 72); assert.equal(before.handle.max, 180); assert.equal(before.keyTargetsReachable, true); await dragLeftResize(page, before.railWidth + 48); const afterDrag = await sidebarMetrics(page); assert.ok(afterDrag.railWidth >= before.railWidth + 40, `rail width did not grow: ${afterDrag.railWidth}`); assert.ok(afterDrag.centerWidth <= before.centerWidth - 40, `center width did not shrink: ${afterDrag.centerWidth}`); assert.equal(afterDrag.storage.leftSidebarWidth, afterDrag.railWidth); assert.equal(afterDrag.keyTargetsReachable, true); assert.equal(afterDrag.noHorizontalOverflow, true); assert.equal(afterDrag.rootScrollLocked, true); await page.locator("#left-sidebar-resize").press("End"); const afterEnd = await sidebarMetrics(page); assert.equal(afterEnd.railWidth, afterEnd.handle.max); assert.equal(afterEnd.keyTargetsReachable, true); await dragLeftResize(page, 999); const afterOverMaxDrag = await sidebarMetrics(page); assert.equal(afterOverMaxDrag.railWidth, afterOverMaxDrag.handle.max); await page.locator("#left-sidebar-resize").press("Home"); const afterHome = await sidebarMetrics(page); assert.equal(afterHome.railWidth, afterHome.handle.min); assert.equal(afterHome.keyTargetsReachable, true); await dragLeftResize(page, 10); const afterUnderMinDrag = await sidebarMetrics(page); assert.equal(afterUnderMinDrag.railWidth, afterUnderMinDrag.handle.min); await page.locator("#left-sidebar-resize").press("ArrowRight"); const afterArrowRight = await sidebarMetrics(page); assert.equal(afterArrowRight.railWidth, afterUnderMinDrag.railWidth + 8); await page.locator("#left-sidebar-resize").press("ArrowLeft"); const afterArrowLeft = await sidebarMetrics(page); assert.equal(afterArrowLeft.railWidth, afterUnderMinDrag.railWidth); await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); const restored = await sidebarMetrics(page); assert.equal(restored.railWidth, afterArrowLeft.railWidth); assert.equal(restored.storage.leftSidebarWidth, afterArrowLeft.railWidth); await page.locator("#left-sidebar-toggle").click(); const collapsed = await sidebarMetrics(page); assert.equal(collapsed.collapsed, true); assert.equal(collapsed.railWidth, 44); assert.equal(collapsed.handle.display, "none"); assert.equal(collapsed.handle.tabIndex, -1); assert.equal(collapsed.handle.ariaDisabled, "true"); assert.equal(collapsed.handle.ariaHidden, "true"); assert.equal(collapsed.keyTargetsReachable, true); await page.locator("#left-sidebar-toggle").click(); const expandedAgain = await sidebarMetrics(page); assert.equal(expandedAgain.collapsed, false); assert.equal(expandedAgain.railWidth, afterArrowLeft.railWidth); assert.equal(expandedAgain.handle.tabIndex, 0); assert.equal(expandedAgain.keyTargetsReachable, true); await page.close(); } finally { await browser.close(); await server.close(); } }); test("left resize handle is disabled on narrow and mobile workbench layouts", async () => { const server = await startStaticServer(webRoot); const browser = await chromium.launch({ headless: true }); try { for (const viewport of [ { width: 1024, height: 768, isMobile: false }, { width: 390, height: 844, isMobile: true } ]) { const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, isMobile: viewport.isMobile }); await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); await login(page); const metrics = await sidebarMetrics(page); assert.equal(metrics.handle.display, "none", `${viewport.width}x${viewport.height} handle display`); assert.equal(metrics.handle.tabIndex, -1, `${viewport.width}x${viewport.height} tabindex`); assert.equal(metrics.handle.ariaDisabled, "true", `${viewport.width}x${viewport.height} aria-disabled`); assert.equal(metrics.handle.ariaHidden, "true", `${viewport.width}x${viewport.height} aria-hidden`); assert.equal(metrics.disabledTargetsReachable, true, `${viewport.width}x${viewport.height} workbench areas`); assert.equal(metrics.noHorizontalOverflow, true, `${viewport.width}x${viewport.height} overflow`); await page.close(); } } finally { await browser.close(); await server.close(); } }); async function login(page) { await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 }); const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false); if (!loginVisible) { await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); return; } await page.locator("#login-username").fill(defaultCredentials.username); await page.locator("#login-password").fill(defaultCredentials.password); await page.locator("#login-submit").click(); await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); } async function dragLeftResize(page, targetWidth) { const start = await page.evaluate(() => { const rail = document.querySelector("#activity-rail"); const handle = document.querySelector("#left-sidebar-resize"); const railBox = rail.getBoundingClientRect(); const handleBox = handle.getBoundingClientRect(); return { currentWidth: railBox.width, x: handleBox.left + handleBox.width / 2, y: handleBox.top + Math.min(80, handleBox.height / 2) }; }); await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + targetWidth - start.currentWidth, start.y, { steps: 8 }); await page.mouse.up(); } async function sidebarMetrics(page) { return page.evaluate(() => { const shell = document.querySelector("[data-app-shell]"); const rail = document.querySelector("#activity-rail"); const center = document.querySelector(".center-workspace"); const right = document.querySelector("#hardware-sidebar"); const handle = document.querySelector("#left-sidebar-resize"); const handleStyle = getComputedStyle(handle); const handleBox = handle.getBoundingClientRect(); const storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); const targetSelectors = [ "#command-input", "#command-send", ".hardware-status", "#hardware-list", "#m3-write-do", "#m3-read-di", "[data-hardware-tab='overview']", "[data-hardware-tab='gateways']", "[data-hardware-tab='box1']", "[data-hardware-tab='box2']", "[data-hardware-tab='patch']" ]; const keyTargets = targetSelectors.map((selector) => inspectTargetVisibility(selector)); const disabledTargets = ["#command-form", ".hardware-status"].map((selector) => inspectTargetVisibility(selector)); return { collapsed: shell.classList.contains("is-left-sidebar-collapsed"), railWidth: Math.round(rail.getBoundingClientRect().width), centerWidth: Math.round(center.getBoundingClientRect().width), rightWidth: Math.round(right.getBoundingClientRect().width), storage, handle: { display: handleStyle.display, width: handleBox.width, role: handle.getAttribute("role"), ariaLabel: handle.getAttribute("aria-label"), title: handle.getAttribute("title"), tabIndex: handle.tabIndex, ariaDisabled: handle.getAttribute("aria-disabled"), ariaHidden: handle.getAttribute("aria-hidden"), min: Number(handle.getAttribute("aria-valuemin")), max: Number(handle.getAttribute("aria-valuemax")), now: Number(handle.getAttribute("aria-valuenow")), valueText: handle.getAttribute("aria-valuetext") }, keyTargets, keyTargetsReachable: keyTargets.every((target) => target.ok), disabledTargets, disabledTargetsReachable: disabledTargets.every((target) => target.ok), noHorizontalOverflow: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && document.body.scrollWidth <= document.body.clientWidth + 2 && shell.scrollWidth <= shell.clientWidth + 2 && right.scrollWidth <= right.clientWidth + 2, 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 }; function inspectTargetVisibility(selector) { const element = document.querySelector(selector); if (!element) return { selector, ok: false, missing: true }; scrollIntoNearestContainer(element); const box = element.getBoundingClientRect(); const clip = clipBoxFor(element); const visibleBox = { left: Math.max(box.left, clip.left), top: Math.max(box.top, clip.top), right: Math.min(box.right, clip.right), bottom: Math.min(box.bottom, clip.bottom) }; const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); const x = Math.min(Math.max(visibleBox.left + visibleWidth / 2, 0), window.innerWidth - 1); const y = Math.min(Math.max(visibleBox.top + Math.min(visibleHeight / 2, 32), 0), window.innerHeight - 1); const stack = document.elementsFromPoint(x, y); const visible = box.width > 0 && box.height > 0 && visibleWidth > 0 && visibleHeight > 0; return { selector, ok: visible && stack.some((candidate) => candidate === element || element.contains(candidate)), box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, visibleBox, hit: stack[0]?.id || stack[0]?.className || stack[0]?.tagName || "none" }; } function scrollIntoNearestContainer(element) { const container = nearestScrollableAncestor(element); if (!container) { element.scrollIntoView({ block: "center", inline: "nearest" }); return; } 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; } function 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; } function clipBoxFor(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; } }); } async function startStaticServer(rootDir) { const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); const pathname = url.pathname === "/" ? "/index.html" : url.pathname; const resolved = path.resolve(rootDir, `.${pathname}`); if (!resolved.startsWith(rootDir)) { response.writeHead(403).end("forbidden"); return; } const body = await readFile(resolved); response.writeHead(200, { "content-type": contentType(resolved) }); response.end(body); } catch { response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); response.end("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 contentType(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"; }