import { test, expect } from '@chromatic-com/playwright'; import { loginViaAPI, CONFIG, navigateTo } from './helpers'; /** * Visual Regression Tests @visual * * Lightweight visual regression tests that capture screenshots and verify * pages render correctly. Combined from: * - visual-complete.spec.ts * - visual-regression.spec.ts * - visual/sidebar.spec.ts * - visual/visual-regression.spec.ts */ const ANIMATION_SETTLE_MS = 800; async function ensureDarkTheme(page: import('@playwright/test').Page) { await page.evaluate(() => { document.documentElement.classList.add('dark'); document.documentElement.setAttribute('data-theme', 'dark'); }); await page.waitForTimeout(100); } async function disableAnimations(page: import('@playwright/test').Page) { await page.addStyleTag({ content: ` *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; } `, }); } /** * Check whether login succeeded (page is no longer on /login). * Returns true if authenticated, false otherwise. */ function isLoggedIn(page: import('@playwright/test').Page): boolean { return !page.url().includes('/login'); } test.describe('VISUAL REGRESSION @visual', () => { test.describe('Auth Pages (unauthenticated)', () => { test('login page visual snapshot', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); await page.goto('/login', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); // Wait for the actual login form to render await page .waitForSelector('[data-testid="login-form"], input[type="email"]', { timeout: 15000 }) .catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('login-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('register page visual snapshot', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); await page.goto('/register', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); await page .waitForSelector('[data-testid="register-form"], form, input[type="email"]', { timeout: 15000, }) .catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); // Extra wait to ensure all fonts and lazy-loaded elements settle await page.waitForTimeout(ANIMATION_SETTLE_MS + 500); // Mask dynamic elements (e.g., cursor blink, timestamps) to avoid flaky diffs await expect(page).toHaveScreenshot('register-page.png', { fullPage: true, maxDiffPixelRatio: 0.25, mask: [ page.locator('input'), // Mask inputs (cursor blink) page.locator('time'), // Mask any time elements ], }); }); test('404 page visual snapshot', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); await page.goto('/non-existent-route-404', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('404-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); }); test.describe('Authenticated Pages', () => { test.beforeEach(async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test('dashboard full page', async ({ page }) => { await navigateTo(page, '/dashboard'); await page.waitForSelector('main, [role="main"]', { timeout: 15000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('dashboard-full.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('dashboard header only', async ({ page }) => { await navigateTo(page, '/dashboard'); const header = page.locator('header').first(); await header.waitFor({ timeout: 15000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(header).toHaveScreenshot('dashboard-header.png', { maxDiffPixelRatio: 0.15, }); }); test('dashboard sidebar only', async ({ page }) => { await navigateTo(page, '/dashboard'); const sidebar = page.getByTestId('app-sidebar').or(page.locator('aside')).first(); await sidebar .waitFor({ state: 'visible', timeout: 15000 }) .catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(sidebar).toHaveScreenshot('dashboard-sidebar.png', { maxDiffPixelRatio: 0.15, }); }); // The global player bar is always rendered (shows idle state when no track is playing). test('global player bar renders in idle state', async ({ page }) => { await navigateTo(page, '/dashboard'); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); // The player bar should always be present (idle or playing) const playerBar = page.getByTestId('global-player'); await expect(playerBar).toBeVisible({ timeout: 10000 }); await expect(playerBar).toHaveScreenshot('player-bar-idle.png', { maxDiffPixelRatio: 0.15, }); }); test('profile page visual snapshot', async ({ page }) => { await navigateTo(page, '/profile'); await page.waitForSelector('main, [role="main"]', { timeout: 15000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('profile-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('playlists page visual snapshot', async ({ page }) => { await navigateTo(page, '/playlists'); await page .waitForSelector('main, [role="main"]', { timeout: 15000 }) .catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('playlists-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('tracks list page visual snapshot', async ({ page }) => { test.setTimeout(60_000); await navigateTo(page, '/tracks'); await page.waitForSelector('main, [role="main"]', { timeout: 20000 }).catch(() => {}); await page.waitForTimeout(3000); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('tracks-list-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('library page visual snapshot', async ({ page }) => { await navigateTo(page, '/library'); await page .waitForSelector('main, [role="main"]', { timeout: 15000 }) .catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('library-page.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); }); test.describe('Responsive Viewports', () => { test.beforeEach(async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test('dashboard mobile 375x667', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.waitForTimeout(200); await navigateTo(page, '/dashboard'); await page.waitForSelector('main, [role="main"]', { timeout: 15000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('dashboard-mobile.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); test('dashboard tablet 768x1024', async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await page.waitForTimeout(200); await navigateTo(page, '/dashboard'); await page.waitForSelector('main, [role="main"]', { timeout: 15000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await expect(page).toHaveScreenshot('dashboard-tablet.png', { fullPage: true, maxDiffPixelRatio: 0.15, }); }); }); });