Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/sidebar-resize.test.mjs
T
2026-05-28 01:39:03 +08:00

335 lines
15 KiB
JavaScript

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("#device-pod-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",
"#device-pod-select",
'#device-pod-summary [data-device-detail="pod"]',
"#device-pod-interfaces .summary-tile",
"#device-event-scroll",
"#device-event-follow"
];
const keyTargets = targetSelectors.map((selector) => inspectTargetVisibility(selector));
const disabledTargets = ["#command-form", "#device-pod-summary"].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";
}