148 lines
8.0 KiB
JavaScript
148 lines
8.0 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { chromium } from "playwright-core";
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
|
?? "/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
|
|
headless: true,
|
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
});
|
|
|
|
async function traceNavigation(page, toggles) {
|
|
return page.evaluate(async (times) => {
|
|
const menu = document.querySelector(".gallery-sidebar-toggle");
|
|
const toolbar = document.querySelector(".topbar");
|
|
const sidebar = () => document.querySelector(innerWidth <= 720 ? ".mobile-sidebar" : ".desktop-sidebar");
|
|
const right = () => sidebar()?.getBoundingClientRect().right ?? 0;
|
|
const controls = [...toolbar.querySelectorAll("button")].filter(button => !button.closest("[inert]"));
|
|
const menuRect = menu.getBoundingClientRect();
|
|
const result = { frames: 0, reversals: [], leaks: [], coveredSidebar: 0, coveredControls: 0, menuDrift: 0, partialFrames: 0 };
|
|
const started = performance.now();
|
|
let index = 0;
|
|
while (performance.now() - started < times.at(-1) + 440) {
|
|
await new Promise(requestAnimationFrame);
|
|
if (index < times.length && performance.now() - started >= times[index]) {
|
|
const before = right();
|
|
menu.click();
|
|
// Flush the component update without advancing the animation timeline.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
result.reversals.push(Math.abs(right() - before));
|
|
index++;
|
|
}
|
|
result.frames++;
|
|
const panel = sidebar();
|
|
const panelRect = panel?.getBoundingClientRect();
|
|
const topbarRect = toolbar.getBoundingClientRect();
|
|
const rect = menu.getBoundingClientRect();
|
|
result.menuDrift = Math.max(result.menuDrift, Math.hypot(rect.x - menuRect.x, rect.y - menuRect.y));
|
|
if (panelRect && panelRect.right > 1 && panelRect.right < panelRect.width - 1) result.partialFrames++;
|
|
for (const button of controls) {
|
|
const box = button.getBoundingClientRect();
|
|
if (!button.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2))) result.coveredControls++;
|
|
}
|
|
for (const y of [topbarRect.top + 1, topbarRect.top + topbarRect.height / 2, topbarRect.bottom - 1]) {
|
|
for (let x = 2; x < innerWidth; x += 16) {
|
|
const hit = document.elementFromPoint(x, y);
|
|
if (!hit || hit.closest(".content")) result.leaks.push({ x, y });
|
|
if (
|
|
panelRect && x < panelRect.right - 1 && !panel.closest("[inert]")
|
|
&& !panel.contains(hit) && !toolbar.contains(hit)
|
|
) result.coveredSidebar++;
|
|
}
|
|
}
|
|
}
|
|
result.open = menu.getAttribute("aria-expanded");
|
|
result.drawerMounted = Boolean(document.querySelector(".mobile-nav-layer"));
|
|
result.bodyLocked = document.body.style.overflow === "hidden";
|
|
result.overflow = document.documentElement.scrollWidth > innerWidth;
|
|
return result;
|
|
}, toggles);
|
|
}
|
|
|
|
function checkTrace(trace, reduced = false) {
|
|
assert.deepEqual(trace.leaks, [], "the header lets clicks reach gallery cards during a transition");
|
|
assert.equal(trace.coveredSidebar, 0, "the header surface covers the visible sidebar");
|
|
assert.equal(trace.coveredControls, 0, "navigation covers a toolbar control");
|
|
assert.ok(trace.menuDrift < 1, `the menu button moved during navigation: ${trace.menuDrift}px`);
|
|
assert.equal(trace.overflow, false);
|
|
if (!reduced) {
|
|
assert.ok(trace.partialFrames > 1, "the sidebar did not animate");
|
|
assert.ok(Math.max(...trace.reversals) < 2, `navigation jumped when reversed: ${trace.reversals}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const width of [1440, 1000, 721, 720, 390, 320]) {
|
|
const mobile = width <= 720;
|
|
const page = await browser.newPage({ viewport: { width, height: 844 }, hasTouch: mobile });
|
|
const errors = [];
|
|
page.on("pageerror", error => errors.push(error.message));
|
|
await page.addInitScript(() => localStorage.setItem("pixhelf.sidebar-collapsed", "true"));
|
|
await page.goto(process.env.PIXHELF_URL ?? "http://127.0.0.1:3002");
|
|
await page.locator(".image-card").nth(24).waitFor();
|
|
// Put real, clickable cards behind the header, including its left edge.
|
|
await page.evaluate(() => window.scrollTo(0, 320));
|
|
await page.waitForTimeout(250);
|
|
const opened = await traceNavigation(page, [0]);
|
|
checkTrace(opened);
|
|
assert.equal(opened.open, "true");
|
|
const panel = mobile ? ".mobile-sidebar" : ".desktop-sidebar";
|
|
const brandClickable = await page.locator(`${panel} .brand-lockup`).evaluate(brand =>
|
|
[...brand.querySelectorAll(".brand-mark, .brand-name, .brand-version")].every(element => {
|
|
const rect = element.getBoundingClientRect();
|
|
return element.closest("a").contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
|
|
}),
|
|
);
|
|
assert.equal(brandClickable, true, "the sidebar brand is obscured by the header");
|
|
await page.screenshot({ path: `/tmp/pixhelf-navigation-${width}.png` });
|
|
const closed = await traceNavigation(page, [0]);
|
|
checkTrace(closed);
|
|
assert.equal(closed.open, "false");
|
|
assert.equal(closed.drawerMounted || closed.bodyLocked, false);
|
|
const rapid = await traceNavigation(page, [0, 70, 140, 210, 280, 350]);
|
|
checkTrace(rapid);
|
|
assert.equal(rapid.drawerMounted || rapid.bodyLocked, false);
|
|
// A genuine pointer click in the blank header must not open the card below it.
|
|
await page.mouse.click(Math.min(100, width / 2), 25);
|
|
assert.equal(await page.locator(".image-viewer").count(), 0);
|
|
await page.locator(".gallery-sidebar-toggle").click();
|
|
await page.locator(`${panel} .brand-name`).click();
|
|
await page.waitForFunction(() => document.querySelector(".content").getAttribute("aria-busy") === "false");
|
|
await page.locator(".search-toggle").click();
|
|
await page.locator(".search-popover-panel input").fill("navigation-check");
|
|
assert.equal(await page.locator(".search-popover-panel input").inputValue(), "navigation-check");
|
|
await page.locator(".clear-search").click();
|
|
await page.locator(".search-toggle").click();
|
|
if (!mobile) await page.locator(".gallery-sidebar-toggle").click();
|
|
await page.waitForTimeout(360);
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
const reduced = await traceNavigation(page, [0, 90]);
|
|
checkTrace(reduced, true);
|
|
assert.equal(reduced.drawerMounted || reduced.bodyLocked, false);
|
|
assert.equal(reduced.partialFrames, 0, "reduced motion still animates the sidebar");
|
|
if (width === 390) {
|
|
await page.evaluate(() => window.scrollTo(0, 320));
|
|
await page.waitForTimeout(200);
|
|
await page.evaluate(() => document.documentElement.style.setProperty("--visual-viewport-top", "24px"));
|
|
const offset = await traceNavigation(page, [0, 90]);
|
|
checkTrace(offset, true);
|
|
await page.evaluate(() => document.documentElement.style.removeProperty("--visual-viewport-top"));
|
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
await page.locator(".gallery-sidebar-toggle").click();
|
|
await page.locator(".mobile-sidebar .brand-name").waitFor();
|
|
await page.setViewportSize({ width: 1000, height: 844 });
|
|
await page.waitForFunction(() => !document.querySelector(".mobile-nav-layer") && document.body.style.overflow !== "hidden");
|
|
assert.equal(await page.locator(".gallery-sidebar-toggle").getAttribute("aria-controls"), "desktop-album-navigation");
|
|
await page.setViewportSize({ width, height: 844 });
|
|
await page.waitForFunction(() => document.querySelector(".gallery-sidebar-toggle").getAttribute("aria-controls") === "mobile-album-navigation");
|
|
assert.equal(await page.locator(".gallery-sidebar-toggle").getAttribute("aria-expanded"), "false");
|
|
}
|
|
assert.deepEqual(errors, []);
|
|
console.log(JSON.stringify({ width, frames: opened.frames + closed.frames + rapid.frames, maxReversalJump: Math.max(...rapid.reversals), brandClickable, reducedMotion: true }));
|
|
await page.close();
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|