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'); }); } });