import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; const linkedServiceIds = [ "vaultwarden", "forgejo", "wiki", "aws-start", "adguard-primary", "adguard-secondary", "grafana", "uptime-kuma", "prometheus", "backrest", "n8n", "open-webui", "comfyui", "models", "prompt-registry", "adminer", "assistant", "suna", "cockpit-infra", "cockpit-gpu", "cockpit-network-core", "forgejo-ssh-relay", ]; test.describe("dashboard page QA gate", () => { test("renders the model-driven dashboard on desktop", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.goto("/"); await waitForDashboardReady(page); await expect(page.getByRole("main")).toHaveAttribute( "aria-labelledby", /title/, ); await expect( page.getByRole("heading", { level: 1, name: "System Overview" }), ).toBeVisible(); await expect(page.getByText("Infra RAM")).toBeVisible(); await expect(page.getByRole("link", { name: /Vaultwarden/i })).toBeVisible(); await expect(page.locator("[data-model-id='vaultwarden']")).toBeVisible(); await expect(page.locator(".service-row .icon-glyph svg").first()).toBeVisible(); const bodyBox = await page.locator("body").boundingBox(); expect(bodyBox?.width).toBeGreaterThan(1000); await expect(page).toHaveScreenshot("dashboard-desktop.png", { fullPage: true, }); }); test("fits the operational dashboard into a 1470 by 956 viewport", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.setViewportSize({ width: 1470, height: 956 }); await page.goto("/"); await waitForDashboardReady(page); const metrics = await page.evaluate(() => { const footer = document.querySelector("[data-model-id='footer-status']"); const runtime = document.querySelector("[data-model-id='runtime-health']"); const visibleItems = [ ...document.querySelectorAll(".telemetry-card"), ...document.querySelectorAll(".service-row"), ...document.querySelectorAll(".footer-cell"), ].map((element) => { const rect = element.getBoundingClientRect(); return { bottom: rect.bottom, height: rect.height, id: element.getAttribute("data-model-id"), top: rect.top, width: rect.width, }; }); return { clippedItems: visibleItems.filter((item) => item.top < 0 || item.bottom > window.innerHeight || item.width <= 0 || item.height <= 0 ), footerCellCount: document.querySelectorAll(".footer-cell").length, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, serviceRowCount: document.querySelectorAll(".service-row").length, telemetryCardCount: document.querySelectorAll(".telemetry-card").length, footerBottom: footer?.getBoundingClientRect().bottom ?? 0, runtimeBottom: runtime?.getBoundingClientRect().bottom ?? 0, }; }); expect(metrics.scrollWidth).toBeLessThanOrEqual(1470); expect(metrics.scrollHeight).toBeLessThanOrEqual(956); expect(metrics.runtimeBottom).toBeLessThanOrEqual(956); expect(metrics.footerBottom).toBeLessThanOrEqual(956); expect(metrics.telemetryCardCount).toBe(16); expect(metrics.serviceRowCount).toBe(28); expect(metrics.footerCellCount).toBe(5); expect(metrics.clippedItems).toEqual([]); }); test("keeps intermediate viewports scrollable without horizontal clipping", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); for (const viewport of [ { width: 900, height: 956 }, { width: 1024, height: 768 }, ]) { await page.setViewportSize(viewport); await page.goto("/"); await waitForDashboardReady(page); const metrics = await page.evaluate(() => { const footer = document.querySelector("[data-model-id='footer-status']"); const trackedElements = [ document.querySelector(".dashboard-frame__header"), ...document.querySelectorAll(".telemetry-card"), ...document.querySelectorAll(".service-panel"), document.querySelector("[data-model-id='runtime-health']"), footer, ].filter((element): element is Element => Boolean(element)); const footerRect = footer?.getBoundingClientRect(); const clippedRight = trackedElements .map((element) => { const rect = element.getBoundingClientRect(); return { id: element.getAttribute("data-model-id") || element.className, right: rect.right, width: rect.width, }; }) .filter((item) => item.right > window.innerWidth + 1 || item.width <= 0); return { clippedRight, footerBottomInDocument: (footerRect?.bottom ?? 0) + window.scrollY, frameOverflow: window.getComputedStyle( document.querySelector(".dashboard-frame") as Element, ).overflow, scrollHeight: document.documentElement.scrollHeight, scrollWidth: document.documentElement.scrollWidth, }; }); expect(metrics.scrollWidth).toBeLessThanOrEqual(viewport.width); expect(metrics.scrollHeight).toBeGreaterThanOrEqual( Math.ceil(metrics.footerBottomInDocument), ); expect(metrics.frameOverflow).not.toBe("hidden"); expect(metrics.clippedRight).toEqual([]); } }); test("renders bookmark rows as a compact divider list", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.goto("/"); await waitForDashboardReady(page); const bookmarkDensity = await page.evaluate(() => { const panelBody = document.querySelector(".service-panel .panel__body"); const rows = Array.from(document.querySelectorAll(".service-panel .service-row")); const first = rows[0]; const second = rows[1]; if (!panelBody || !first || !second) { throw new Error("expected at least two bookmark rows"); } const bodyStyle = window.getComputedStyle(panelBody); const rowStyle = window.getComputedStyle(first); const firstRect = first.getBoundingClientRect(); const secondRect = second.getBoundingClientRect(); return { backgroundColor: rowStyle.backgroundColor, borderBottomWidth: Number.parseFloat(rowStyle.borderBottomWidth), borderLeftWidth: Number.parseFloat(rowStyle.borderLeftWidth), borderRightWidth: Number.parseFloat(rowStyle.borderRightWidth), columnGap: Number.parseFloat(rowStyle.columnGap), paddingBottom: Number.parseFloat(rowStyle.paddingBottom), paddingTop: Number.parseFloat(rowStyle.paddingTop), panelGap: Number.parseFloat(bodyStyle.rowGap), rowGap: secondRect.top - firstRect.bottom, }; }); expect(bookmarkDensity.panelGap).toBe(0); expect(bookmarkDensity.rowGap).toBeLessThanOrEqual(1); expect(bookmarkDensity.paddingTop).toBeLessThanOrEqual(3); expect(bookmarkDensity.paddingBottom).toBeLessThanOrEqual(3); expect(bookmarkDensity.columnGap).toBeLessThanOrEqual(4); expect(bookmarkDensity.borderBottomWidth).toBeGreaterThanOrEqual(1); expect(bookmarkDensity.borderLeftWidth).toBe(0); expect(bookmarkDensity.borderRightWidth).toBe(0); expect(bookmarkDensity.backgroundColor).toBe("rgba(0, 0, 0, 0)"); }); test("balances dashboard typography at the target viewport", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.setViewportSize({ width: 1470, height: 956 }); await page.goto("/"); await waitForDashboardReady(page); const typeScale = await page.evaluate(() => { const fontSize = (selector: string) => { const element = document.querySelector(selector); if (!element) throw new Error(`missing ${selector}`); return Number.parseFloat(window.getComputedStyle(element).fontSize); }; return { h1: fontSize("h1"), panelTitle: fontSize(".service-panel h2"), serviceDescription: fontSize(".service-row p"), serviceLabel: fontSize(".service-row h3"), telemetryLabel: fontSize(".telemetry-card h3"), telemetryValue: fontSize(".telemetry-card strong"), }; }); expect(typeScale.serviceLabel).toBeGreaterThanOrEqual(11.4); expect(typeScale.serviceDescription).toBeGreaterThanOrEqual(8.8); expect(typeScale.h1).toBeLessThanOrEqual(49); expect(typeScale.panelTitle).toBeLessThanOrEqual(28); expect(typeScale.telemetryLabel).toBeLessThanOrEqual(9.5); expect(typeScale.telemetryValue).toBeLessThanOrEqual(36); }); test("keeps the first screen usable on mobile", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile"); await page.goto("/"); await waitForDashboardReady(page); await expect( page.getByRole("heading", { level: 1, name: "System Overview" }), ).toBeVisible(); await expect(page.getByLabel("Service groups")).toBeVisible(); await expect(page.locator(".service-row .icon-glyph svg").first()).toBeVisible(); await expect(page).toHaveScreenshot("dashboard-mobile.png", { fullPage: true, }); }); test("exposes usable landmarks and a visible keyboard focus state", async ({ page, }) => { await page.goto("/"); await waitForDashboardReady(page); await expect(page.getByRole("main")).toHaveCount(1); await page.keyboard.press("Tab"); const themeToggle = page.getByRole("button", { name: "Light theme" }); await expect(themeToggle).toBeFocused(); await expect(themeToggle).toHaveAttribute("aria-pressed", "false"); const themeFocusBoxShadow = await themeToggle.evaluate((element) => { return window.getComputedStyle(element).boxShadow; }); expect(themeFocusBoxShadow).not.toBe("none"); for (const serviceId of linkedServiceIds) { await page.keyboard.press("Tab"); const focused = page.locator(":focus"); await expect(focused).toHaveAttribute("data-model-id", serviceId); const focusBoxShadow = await focused.evaluate((element) => { return window.getComputedStyle(element).boxShadow; }); expect(focusBoxShadow).not.toBe("none"); } }); test("passes automated accessibility checks", async ({ page }) => { await page.goto("/"); await waitForDashboardReady(page); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); test("passes automated accessibility checks in light mode", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.goto("/"); await waitForDashboardReady(page); await page.getByRole("button", { name: "Light theme" }).click(); await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute( "aria-pressed", "true", ); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); await expect(page).toHaveScreenshot("dashboard-light-desktop.png", { fullPage: true, }); }); test("honors reduced-motion preferences", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/"); await waitForDashboardReady(page); const durations = await page.evaluate(() => { const element = document.createElement("div"); element.style.animation = "qa-motion-check 10s infinite"; element.style.transition = "opacity 10s linear"; document.body.append(element); const styles = window.getComputedStyle(element); return { animation: styles.animationDuration, transition: styles.transitionDuration, }; }); expect(cssDurationToMilliseconds(durations.animation)).toBeLessThanOrEqual( 0.01, ); expect(cssDurationToMilliseconds(durations.transition)).toBeLessThanOrEqual( 0.01, ); }); test("toggles the dashboard between dark and light themes", async ({ page }) => { await page.goto("/"); await waitForDashboardReady(page); await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "dark"); const switchToLight = page.getByRole("button", { name: "Light theme", }); await expect(switchToLight).toBeVisible(); await expect(switchToLight).toHaveAttribute("aria-pressed", "false"); await switchToLight.click(); await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute( "aria-pressed", "true", ); await page.reload(); await waitForDashboardReady(page); await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light"); }); }); async function waitForDashboardReady(page: Page): Promise { await expect( page.getByRole("heading", { level: 1, name: "System Overview" }), ).toBeVisible(); await expect(page.locator("[data-model-id='vaultwarden']")).toBeVisible(); } function cssDurationToMilliseconds(duration: string): number { if (duration.endsWith("ms")) return Number.parseFloat(duration); if (duration.endsWith("s")) return Number.parseFloat(duration) * 1000; return Number.NaN; }