180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
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("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;
|
|
}
|