Files
emaui/apps/e2e/visual/theme.spec.ts

140 lines
5.0 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
import { VISUAL } from '../visual.config';
/**
* Theme baselines.
*
* These exist so a change to the shared theme can be reviewed as a diff rather
* than trusted. The gallery route renders every primitive the theme controls,
* so one screenshot per app per scheme per width covers the whole surface.
*
* Update baselines deliberately, never reflexively:
* npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
* A diff you did not intend is the entire point of the suite.
*/
const WIDTHS = [
{ name: 'desktop', width: 1440, height: 1200 },
{ name: 'tablet', width: 768, height: 1200 },
] as const;
const SCHEMES = ['light', 'dark'] as const;
const APPS = [
{ name: 'backoffice', url: VISUAL.backofficeUrl },
{ name: 'portal', url: VISUAL.portalUrl },
] as const;
/**
* Set the scheme the way the app itself does — the pre-paint script in
* index.html reads this key. Setting it before navigation means the very first
* paint is already correct, so no screenshot catches a flash of the wrong one.
*/
async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
await page.addInitScript((value) => {
window.localStorage.setItem('mantine-color-scheme-value', value);
}, scheme);
await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
// The gallery is static, but web fonts are not: screenshotting before they
// settle bakes a fallback-font baseline that every later run then fails
// against.
await page.evaluate(() => document.fonts.ready);
await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
}
for (const app of APPS) {
for (const scheme of SCHEMES) {
for (const size of WIDTHS) {
test(`${app.name} gallery — ${scheme}${size.name}`, async ({ page }) => {
await page.setViewportSize({ width: size.width, height: size.height });
await gotoGallery(page, app.url, scheme);
await expect(page).toHaveScreenshot(
`${app.name}-gallery-${scheme}-${size.name}.png`,
{ fullPage: true },
);
});
}
}
/**
* The focus ring, captured while actually focused.
*
* The full-page shots above can't show this: nothing is focused in them, so
* a regression that removed the ring entirely would leave them all green.
* Keyboard focus specifically, because `:focus-visible` deliberately does
* not match a mouse click.
*/
test(`${app.name} — focus ring is visible`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoGallery(page, app.url, 'light');
const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last();
await section.scrollIntoViewIfNeeded();
const button = page.getByRole('button', { name: 'Button', exact: true });
await button.focus();
await expect(button).toBeFocused();
// Assert the ring in computed styles as well as pixels. A screenshot alone
// would still pass if the outline came from somewhere unintended, and a
// token that failed to resolve leaves an empty string rather than an error.
const ring = await button.evaluate((el) => {
const s = getComputedStyle(el);
return {
width: s.outlineWidth,
style: s.outlineStyle,
token: getComputedStyle(document.documentElement)
.getPropertyValue('--ema-focus-ring')
.trim(),
};
});
expect(ring.style).toBe('solid');
expect(ring.width).toBe('2px');
expect(ring.token).not.toBe('');
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
});
}
/**
* The skip link, on a real app shell.
*
* Not on the gallery route: the point of a skip link is bypassing the nav, and
* the gallery has none. The login page is the shell-less public route both apps
* share, so this uses the landing route instead — it carries the chrome without
* needing a session.
*
* A skip link is invisible until focused, which means a broken one and a
* working one look identical in every screenshot. Only a focus test separates
* them.
*/
test.describe('skip link', () => {
for (const app of APPS) {
test(`${app.name} — reveals on focus and targets main`, async ({ page }) => {
await page.goto(`${app.url}/`, { waitUntil: 'networkidle' });
const link = page.locator('.ema-skip-link');
if ((await link.count()) === 0) {
// The public landing route does not mount the app shell in every app;
// skipping is honest here, where asserting absence would be wrong.
test.skip(true, 'landing route does not mount the app shell');
return;
}
// Off-screen until focused...
await expect(link).not.toBeInViewport();
await page.keyboard.press('Tab');
await expect(link).toBeFocused();
await expect(link).toBeInViewport();
// ...and it must point at something that exists.
const href = await link.getAttribute('href');
expect(href).toBe('#ema-main-content');
});
}
});