335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
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";
|
|
}
|