refactor(web): move website into turbo app workspace
This commit is contained in:
parent
2664804e91
commit
b4e626a868
66 changed files with 318 additions and 298 deletions
382
apps/web/tests/e2e/dashboard.spec.ts
Normal file
382
apps/web/tests/e2e/dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
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<HTMLElement>(".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<void> {
|
||||
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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 454 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
35
apps/web/tests/e2e/storybook-server.ts
Normal file
35
apps/web/tests/e2e/storybook-server.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
const root = resolve(process.cwd(), "packages/ui/storybook-static");
|
||||
const port = Number(process.env.STORYBOOK_STATIC_PORT || 6007);
|
||||
|
||||
if (!existsSync(resolve(root, "iframe.html"))) {
|
||||
throw new Error(
|
||||
"packages/ui/storybook-static is missing. Run bun run build-storybook first.",
|
||||
);
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const relativePath = pathname === "/" ? "/index.html" : pathname;
|
||||
const filePath = resolve(root, `.${relativePath}`);
|
||||
|
||||
if (!filePath.startsWith(`${root}${sep}`)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(file);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Storybook static listening on http://127.0.0.1:${port}`);
|
||||
40
apps/web/tests/e2e/storybook.spec.ts
Normal file
40
apps/web/tests/e2e/storybook.spec.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const storybookPort = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT || 6007);
|
||||
const storybookURL = `http://127.0.0.1:${storybookPort}`;
|
||||
|
||||
test.describe("Storybook theme QA", () => {
|
||||
test("renders the ThemeToggle light story on the light canvas", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop");
|
||||
|
||||
const consoleMessages: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleMessages.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${storybookURL}/iframe.html?id=ui-themetoggle--light&viewMode=story`;
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Light theme" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Light theme" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(page.locator("body")).toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(238, 242, 231)",
|
||||
);
|
||||
expect(
|
||||
consoleMessages.filter((message) =>
|
||||
message.includes("Global args/argTypes can only be set globally"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue