import AxeBuilder from "@axe-core/playwright"; import { expect, test } 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 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(); 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("/"); 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("renders bookmark rows as a compact divider list", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop"); await page.goto("/"); 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("keeps the first screen usable on mobile", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile"); await page.goto("/"); await expect( page.getByRole("heading", { level: 1, name: "System Overview" }), ).toBeVisible(); await expect(page.getByLabel("Service groups")).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 expect(page.getByRole("main")).toHaveCount(1); 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("/"); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); test("honors reduced-motion preferences", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/"); 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, ); }); }); 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; }