259 lines
14 KiB
TypeScript
259 lines
14 KiB
TypeScript
|
|
import { test, expect } from "@playwright/test";
|
|||
|
|
import { THEMES } from "../src/lib/themes";
|
|||
|
|
import { HAS_AUTH_FIXTURE, signIn } from "./_helpers";
|
|||
|
|
import { quoteStatusClassName } from "../src/lib/quote-status";
|
|||
|
|
|
|||
|
|
// Theme assertions must read current CSS, independently of installed PWA caches.
|
|||
|
|
test.use({ serviceWorkers: "block" });
|
|||
|
|
|
|||
|
|
// Public login loads the same global theme CSS without depending on PB data.
|
|||
|
|
// Resolve colours in Chrome: jsdom does not implement OKLCH or the CSS cascade.
|
|||
|
|
test("all themes have readable surfaces, semantic colours and focus indicators", async ({ page }) => {
|
|||
|
|
await page.goto("/login");
|
|||
|
|
const failures = await page.evaluate((themes) => {
|
|||
|
|
const failures: string[] = [];
|
|||
|
|
const root = document.documentElement;
|
|||
|
|
const canvas = document.createElement("canvas");
|
|||
|
|
canvas.width = canvas.height = 1;
|
|||
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
|||
|
|
function rgb(colour: string) {
|
|||
|
|
ctx.clearRect(0, 0, 1, 1);
|
|||
|
|
ctx.fillStyle = colour;
|
|||
|
|
ctx.fillRect(0, 0, 1, 1);
|
|||
|
|
return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3);
|
|||
|
|
}
|
|||
|
|
function luminance(channels: number[]) {
|
|||
|
|
const linear = channels.map((v) => {
|
|||
|
|
v /= 255;
|
|||
|
|
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|||
|
|
});
|
|||
|
|
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
|||
|
|
}
|
|||
|
|
function contrast(a: number[], b: number[]) {
|
|||
|
|
const x = luminance(a), y = luminance(b);
|
|||
|
|
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|||
|
|
}
|
|||
|
|
for (const theme of themes) {
|
|||
|
|
for (const mode of ["light", "dark"]) {
|
|||
|
|
root.dataset.theme = theme.id;
|
|||
|
|
root.classList.toggle("dark", mode === "dark");
|
|||
|
|
const css = getComputedStyle(root);
|
|||
|
|
const colour = (token: string) => rgb(css.getPropertyValue(`--${token}`).trim());
|
|||
|
|
const check = (name: string, a: number[], b: number[], minimum = 4.5) => {
|
|||
|
|
const ratio = contrast(a, b);
|
|||
|
|
if (ratio < minimum) failures.push(`${theme.id}/${mode} ${name}: ${ratio.toFixed(2)} < ${minimum}`);
|
|||
|
|
};
|
|||
|
|
const bgToken = css.getPropertyValue("--background").trim();
|
|||
|
|
if (!bgToken) failures.push(`${theme.id}/${mode}: missing background`);
|
|||
|
|
const lightness = Number(bgToken.match(/oklch\(\s*([\d.]+)/)?.[1]);
|
|||
|
|
if ((mode === "dark" || theme.category === "dark") && lightness > 0.4) {
|
|||
|
|
failures.push(`${theme.id}/${mode}: dark mode still has a light canvas`);
|
|||
|
|
}
|
|||
|
|
for (const surface of ["background", "card", "popover", "sidebar"]) {
|
|||
|
|
const bg = colour(surface);
|
|||
|
|
check(surface, colour(surface === "background" ? "foreground" : `${surface}-foreground`), bg);
|
|||
|
|
check(`muted on ${surface}`, colour("muted-foreground"), bg);
|
|||
|
|
check(`focus on ${surface}`, colour("ring"), bg, 3);
|
|||
|
|
for (const semantic of ["success", "warn", "info", "destructive", "ai"]) {
|
|||
|
|
const fg = colour(semantic);
|
|||
|
|
check(`${semantic} text on ${surface}`, fg, bg);
|
|||
|
|
// The app uses semantic text on 10–20% tinted badges/buttons.
|
|||
|
|
for (const alpha of [0.1, 0.2]) {
|
|||
|
|
const tinted = fg.map((v, i) => v * alpha + bg[i] * (1 - alpha));
|
|||
|
|
check(`${semantic}/${alpha} badge on ${surface}`, fg, tinted);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for (const token of ["primary", "sidebar-primary", "success", "warn", "info", "destructive", "ai"]) {
|
|||
|
|
check(`${token} filled label`, colour(`${token}-foreground`), colour(token));
|
|||
|
|
}
|
|||
|
|
for (const surface of ["background", "card", "sidebar"]) {
|
|||
|
|
const base = colour(surface), primary = colour("primary");
|
|||
|
|
const hover = primary.map((v, i) => v * 0.9 + base[i] * 0.1);
|
|||
|
|
check(`primary hover on ${surface}`, colour("primary-foreground"), hover);
|
|||
|
|
}
|
|||
|
|
for (let i = 1; i <= 5; i++) {
|
|||
|
|
if (!css.getPropertyValue(`--chart-${i}`).trim()) failures.push(`${theme.id}/${mode}: missing chart-${i}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return failures;
|
|||
|
|
}, THEMES.map(({ id, category }) => ({ id, category })));
|
|||
|
|
expect(failures).toEqual([]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("legacy status text and solid badges stay readable using the rendered utilities", async ({ page }) => {
|
|||
|
|
await page.goto("/login");
|
|||
|
|
const failures = await page.evaluate(({ themes, statusClasses }) => {
|
|||
|
|
const samples = [
|
|||
|
|
...statusClasses,
|
|||
|
|
"bg-warn/10 text-warn-foreground",
|
|||
|
|
"text-warn-foreground",
|
|||
|
|
"bg-success/15 text-success-foreground dark:text-success",
|
|||
|
|
"border-success/40 text-success-foreground dark:text-success",
|
|||
|
|
"bg-info/10 text-info-foreground",
|
|||
|
|
"bg-warn text-warn-foreground",
|
|||
|
|
"bg-success text-success-foreground",
|
|||
|
|
"bg-info text-info-foreground",
|
|||
|
|
"bg-destructive text-destructive-foreground",
|
|||
|
|
"bg-warn text-warn-foreground dark:bg-warn/15 dark:text-warn",
|
|||
|
|
];
|
|||
|
|
const canvas = document.createElement("canvas");
|
|||
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
|||
|
|
const rgb = (value: string) => {
|
|||
|
|
ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1);
|
|||
|
|
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
|||
|
|
};
|
|||
|
|
const lum = (rgb: number[]) => rgb.slice(0, 3).map((v) => {
|
|||
|
|
v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|||
|
|
}).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|||
|
|
const probe = document.createElement("span");
|
|||
|
|
probe.textContent = "Status";
|
|||
|
|
document.body.append(probe);
|
|||
|
|
const failures: string[] = [];
|
|||
|
|
for (const theme of themes) for (const mode of ["light", "dark"]) {
|
|||
|
|
document.documentElement.dataset.theme = theme.id;
|
|||
|
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
|||
|
|
const surface = rgb(getComputedStyle(document.documentElement).getPropertyValue("--card"));
|
|||
|
|
for (const sample of samples) {
|
|||
|
|
probe.className = sample;
|
|||
|
|
const css = getComputedStyle(probe), fg = rgb(css.color), bg = rgb(css.backgroundColor);
|
|||
|
|
const composite = bg.slice(0, 3).map((v, i) => v * bg[3] / 255 + surface[i] * (1 - bg[3] / 255));
|
|||
|
|
const paintedText = fg.slice(0, 3).map((v, i) => v * fg[3] / 255 + composite[i] * (1 - fg[3] / 255));
|
|||
|
|
const a = lum(paintedText), b = lum(composite);
|
|||
|
|
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|||
|
|
if (ratio < 4.5) failures.push(`${theme.id}/${mode} ${sample}: ${ratio.toFixed(2)}`);
|
|||
|
|
}
|
|||
|
|
// Selected customer rows use an inverse info-colour checkmark bubble.
|
|||
|
|
probe.className = "border-info-foreground bg-info-foreground text-info";
|
|||
|
|
const inverse = getComputedStyle(probe);
|
|||
|
|
const ink = rgb(getComputedStyle(document.documentElement).getPropertyValue("--info-foreground"));
|
|||
|
|
if (rgb(inverse.backgroundColor).join() !== ink.join() || rgb(inverse.borderTopColor).join() !== ink.join()) {
|
|||
|
|
failures.push(`${theme.id}/${mode}: missing inverse info background or border`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
probe.remove();
|
|||
|
|
return failures;
|
|||
|
|
}, {
|
|||
|
|
themes: THEMES.map(({ id }) => ({ id })),
|
|||
|
|
statusClasses: ["new", "claimed", "processed", "emailed", "sending", "template", "approval_pending", "partial_approval_pending", "follow_up", "approved", "complete", "partial_approved", "rejected", "email_failed", "mail_failed", "unmatched_bounce", "problem", "cancelled"].map(quoteStatusClassName),
|
|||
|
|
});
|
|||
|
|
expect(failures).toEqual([]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a destructive outline button gets the filled text colour on hover", async ({ page }) => {
|
|||
|
|
await page.goto("/login");
|
|||
|
|
await page.evaluate(() => {
|
|||
|
|
const button = document.createElement("button");
|
|||
|
|
button.id = "theme-hover-probe";
|
|||
|
|
button.className = "border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground";
|
|||
|
|
button.style.cssText = "position:fixed;top:20px;left:20px;z-index:9999;padding:10px";
|
|||
|
|
button.textContent = "Cancel";
|
|||
|
|
document.body.append(button);
|
|||
|
|
});
|
|||
|
|
const button = page.locator("#theme-hover-probe");
|
|||
|
|
await button.hover();
|
|||
|
|
for (const mode of ["light", "dark"]) for (const theme of THEMES) {
|
|||
|
|
const matches = await button.evaluate((el, { id, mode }) => {
|
|||
|
|
document.documentElement.dataset.theme = id;
|
|||
|
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
|||
|
|
const probe = document.createElement("span");
|
|||
|
|
probe.style.cssText = "color:var(--destructive-foreground);background:var(--destructive)";
|
|||
|
|
document.body.append(probe);
|
|||
|
|
const actual = getComputedStyle(el), expected = getComputedStyle(probe);
|
|||
|
|
const result = actual.color === expected.color && actual.backgroundColor === expected.backgroundColor;
|
|||
|
|
probe.remove();
|
|||
|
|
return result;
|
|||
|
|
}, { id: theme.id, mode });
|
|||
|
|
expect(matches).toBe(true);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test.describe("appearance controls", () => {
|
|||
|
|
test.skip(!process.env.E2E_STORAGE_STATE && !HAS_AUTH_FIXTURE, "Needs a signed-in browser fixture");
|
|||
|
|
test.use({ storageState: process.env.E2E_STORAGE_STATE });
|
|||
|
|
|
|||
|
|
test("each preview matches the selected theme in both modes and selection survives reload", async ({ page }) => {
|
|||
|
|
if (!process.env.E2E_STORAGE_STATE) {
|
|||
|
|
await signIn(page, process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!);
|
|||
|
|
}
|
|||
|
|
await page.goto("/settings");
|
|||
|
|
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 30_000 });
|
|||
|
|
for (const mode of ["light", "dark"] as const) {
|
|||
|
|
await page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last().click();
|
|||
|
|
await expect(page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last()).toHaveAttribute("aria-pressed", "true");
|
|||
|
|
for (const theme of THEMES) {
|
|||
|
|
const card = page.getByRole("button", { name: `Use ${theme.label} theme`, exact: true });
|
|||
|
|
await card.click();
|
|||
|
|
await expect(card).toHaveAttribute("aria-pressed", "true");
|
|||
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", theme.id);
|
|||
|
|
const colours = await card.locator(".theme-preview").evaluate((preview) => {
|
|||
|
|
const actual = getComputedStyle(document.documentElement);
|
|||
|
|
const miniature = getComputedStyle(preview);
|
|||
|
|
return ["--background", "--primary", "--foreground"].map((token) => ({
|
|||
|
|
actual: actual.getPropertyValue(token).trim(),
|
|||
|
|
preview: miniature.getPropertyValue(token).trim(),
|
|||
|
|
}));
|
|||
|
|
});
|
|||
|
|
for (const colour of colours) expect(colour.preview).toBe(colour.actual);
|
|||
|
|
}
|
|||
|
|
await page.reload();
|
|||
|
|
await expect(page.getByRole("button", { name: "Use WFDASH theme", exact: true })).toHaveAttribute("aria-pressed", "true");
|
|||
|
|
await expect(page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last()).toHaveAttribute("aria-pressed", "true");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("dialogs follow every palette and remain usable on a phone", async ({ page }) => {
|
|||
|
|
test.setTimeout(60_000);
|
|||
|
|
if (!process.env.E2E_STORAGE_STATE) {
|
|||
|
|
await signIn(page, process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!);
|
|||
|
|
}
|
|||
|
|
await page.goto("/settings");
|
|||
|
|
await page.getByRole("button", { name: "What's New", exact: true }).click();
|
|||
|
|
const dialog = page.getByRole("dialog");
|
|||
|
|
await expect(dialog).toBeVisible();
|
|||
|
|
for (const mode of ["light", "dark"]) {
|
|||
|
|
for (const theme of THEMES) {
|
|||
|
|
await page.evaluate(({ id, mode }) => {
|
|||
|
|
document.documentElement.dataset.theme = id;
|
|||
|
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
|||
|
|
}, { id: theme.id, mode });
|
|||
|
|
await expect.poll(async () => dialog.evaluate((el) => {
|
|||
|
|
const probe = document.createElement("div");
|
|||
|
|
probe.style.cssText = "background:var(--popover);color:var(--popover-foreground)";
|
|||
|
|
document.body.append(probe);
|
|||
|
|
const expected = getComputedStyle(probe), actual = getComputedStyle(el);
|
|||
|
|
// Transitions can serialize equivalent colours in OKLab rather
|
|||
|
|
// than OKLCH. Compare their painted sRGB channels, not notation.
|
|||
|
|
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!;
|
|||
|
|
const rgb = (colour: string) => {
|
|||
|
|
ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = colour; ctx.fillRect(0, 0, 1, 1);
|
|||
|
|
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
|||
|
|
};
|
|||
|
|
const same = (a: string, b: string) => {
|
|||
|
|
const x = rgb(a), y = rgb(b);
|
|||
|
|
return x.every((v, i) => Math.abs(v - y[i]) <= 1);
|
|||
|
|
};
|
|||
|
|
const result = same(actual.backgroundColor, expected.backgroundColor) && same(actual.color, expected.color);
|
|||
|
|
probe.remove();
|
|||
|
|
return result;
|
|||
|
|
}), { message: `${theme.id}/${mode} dialog colours` }).toBe(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|||
|
|
const box = await dialog.boundingBox();
|
|||
|
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
|||
|
|
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
|||
|
|
// Enter keyboard modality before programmatic focus: a mouse-opened
|
|||
|
|
// dialog correctly does not show :focus-visible on button.focus() alone.
|
|||
|
|
await page.keyboard.press("Tab");
|
|||
|
|
await dialog.getByRole("button", { name: "Close", exact: true }).focus();
|
|||
|
|
await expect.poll(async () => dialog.getByRole("button", { name: "Close", exact: true }).evaluate((el) => {
|
|||
|
|
const css = getComputedStyle(el);
|
|||
|
|
return { width: css.outlineWidth, style: css.outlineStyle, offset: css.outlineOffset };
|
|||
|
|
})).toEqual({ width: "2px", style: "solid", offset: "3px" });
|
|||
|
|
await page.keyboard.press("Escape");
|
|||
|
|
await expect(dialog).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
});
|