import { test, expect, type Page } from '@playwright/test'; import { login, navigateTo } from '../smoke/helpers'; /** * Visual regression baselines for stable list/landing pages. * * On first run (or after intentional UI changes), regenerate: * pnpm exec playwright test --project=visual --update-snapshots * * Subsequent runs diff against the committed PNGs under * tests/e2e/visual/snapshots.spec.ts-snapshots/. * * Pages chosen are list/landing screens that don't depend on per-row * fixture data — they tolerate seed drift between runs. Detail screens * (yacht detail, EOI dialog, invoice form review) are intentionally * deferred until we have stable fixtures wired up. */ const PAGES = [ { name: 'portal-login', path: '/portal/login', requireAuth: false }, { name: 'dashboard', path: '/dashboard', requireAuth: true }, { name: 'clients-list', path: '/clients', requireAuth: true }, { name: 'yachts-list', path: '/yachts', requireAuth: true }, { name: 'berths-list', path: '/berths', requireAuth: true }, { name: 'invoices-list', path: '/invoices', requireAuth: true }, { name: 'hub-root', path: '/documents', requireAuth: true }, ] as const; async function settle(page: Page) { // Quiet the page so dynamic content (timers, spinners, blinking cursors) // doesn't cause flaky pixel diffs. await page.addStyleTag({ content: ` *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; caret-color: transparent !important; } `, }); await page.waitForLoadState('networkidle'); // Tiny pause to let TanStack Query flush await page.waitForTimeout(500); } test.describe('Visual regression', () => { for (const p of PAGES) { test(`${p.name} matches baseline`, async ({ page }) => { if (p.requireAuth) { await login(page, 'super_admin'); await navigateTo(page, p.path); } else { await page.goto(p.path); } await settle(page); await expect(page).toHaveScreenshot(`${p.name}.png`, { fullPage: true, // Tolerate small text-rendering differences across machines/runs. maxDiffPixelRatio: 0.02, }); }); } /** * Hub entity-folder visual — click into the Clients system root, then into * the first visible entity sub-folder. This is a best-effort baseline: * if no entity sub-folders exist yet the test skips with a clear message * rather than failing. */ test('hub-entity-folder matches baseline', async ({ page }) => { await login(page, 'super_admin'); await navigateTo(page, '/documents'); await page.waitForLoadState('networkidle'); // Expand the Clients system root. const expandBtn = page .locator('aside') .getByRole('button', { name: 'Expand' }) .first(); const hasExpand = await expandBtn.isVisible({ timeout: 5_000 }).catch(() => false); if (!hasExpand) { test.skip( true, 'No expandable folder found in sidebar — hub-entity-folder baseline skipped', ); return; } await expandBtn.click(); // Wait briefly for children to appear. await page.waitForTimeout(500); // Find the first entity sub-folder button (deeper indent = child of Clients). // We look for any button in the aside that is NOT one of the fixed pseudo-rows. const pseudoNames = ['All documents', 'Root (no folder)', 'Clients', 'Companies', 'Yachts']; const allFolderBtns = page.locator('aside button[type="button"]'); let entityFolderBtn: ReturnType | null = null; const count = await allFolderBtns.count(); for (let i = 0; i < count; i++) { const btn = allFolderBtns.nth(i); const text = (await btn.textContent())?.trim() ?? ''; if (text && !pseudoNames.includes(text) && text !== 'Expand' && text !== 'Collapse') { entityFolderBtn = btn; break; } } if (!entityFolderBtn) { test.skip( true, 'No entity sub-folder visible after expanding — hub-entity-folder baseline skipped', ); return; } await entityFolderBtn.click(); await settle(page); await expect(page).toHaveScreenshot('hub-entity-folder.png', { fullPage: true, maxDiffPixelRatio: 0.02, }); }); });