import { test, expect, Page, BrowserContext } from '@playwright/test'; // ============================================================ // THEME CROSS-BROWSER COMPATIBILITY TESTS // // Tests browser-specific quirks of the dark/light theme system: // - localStorage availability (Safari ITP, private browsing) // - CSS custom property support (Edge Legacy is dead but Edge Chromium confirmed) // - matchMedia / prefers-color-scheme across engines // - CSS transition behavior differences (Gecko vs Blink vs WebKit) // - theme-color meta tag browser chrome support // - Inline blocking script execution order // - Performance under CPU throttling (low-end device simulation) // - Browser extension color overrides (CSP-safe simulation) // ============================================================ // ─── Helpers ───────────────────────────────────────────────────────────────── async function getThemeState(page: Page) { return page.evaluate(() => ({ hasDarkClass: document.documentElement.classList.contains('dark'), stored: (() => { try { return localStorage.getItem('theme'); } catch { return null; } })(), ariaChecked: document.querySelector('[data-theme-toggle]')?.getAttribute('aria-checked'), ariaLabel: document.querySelector('[data-theme-toggle]')?.getAttribute('aria-label'), htmlClass: document.documentElement.className, cssVarSurface: getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim(), cssVarText: getComputedStyle(document.documentElement).getPropertyValue('--color-text-primary').trim(), themeColorMetas: Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => ({ content: m.getAttribute('content'), media: m.getAttribute('media'), })), bodyBg: getComputedStyle(document.body).backgroundColor, })); } async function setStoredTheme(page: Page, theme: 'dark' | 'light' | null) { await page.evaluate((t) => { try { if (t === null) localStorage.removeItem('theme'); else localStorage.setItem('theme', t); } catch { /* noop — private browsing */ } }, theme); } async function loadWithTheme(page: Page, theme: 'dark' | 'light' | null, url = '/') { await page.goto(url, { waitUntil: 'domcontentloaded' }); await setStoredTheme(page, theme); await page.reload({ waitUntil: 'domcontentloaded' }); } async function clickToggle(page: Page) { const btn = page.locator('[data-theme-toggle]').first(); await expect(btn).toBeVisible(); await btn.click(); await page.waitForTimeout(200); } // ─── 1. CSS Custom Property Support ────────────────────────────────────────── test.describe('Theme: CSS Custom Properties — Cross-Browser', () => { test('Light mode: CSS vars resolve to expected light surface color', async ({ page }) => { await loadWithTheme(page, 'light'); const state = await getThemeState(page); // --color-surface in light mode = #f8fafc expect(state.cssVarSurface).toBe('#f8fafc'); // --color-text-primary in light = #1e293b expect(state.cssVarText).toBe('#1e293b'); }); test('Dark mode: CSS vars resolve to expected dark surface color', async ({ page }) => { await loadWithTheme(page, 'dark'); const state = await getThemeState(page); // --color-surface in dark mode = #0f172a expect(state.cssVarSurface).toBe('#0f172a'); // --color-text-primary in dark = #f1f5f9 expect(state.cssVarText).toBe('#f1f5f9'); }); test('CSS vars update immediately on toggle (no delayed reflow)', async ({ page }) => { await loadWithTheme(page, 'light'); const before = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim() ); expect(before).toBe('#f8fafc'); // light surface await clickToggle(page); const after = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim() ); // After toggle to dark, CSS var should have updated expect(after).toBe('#0f172a'); }); test('All critical CSS vars are defined in both themes', async ({ page }) => { const criticalVars = [ '--color-surface', '--color-surface-raised', '--color-surface-alt', '--color-text-primary', '--color-text-secondary', '--color-text-muted', '--color-border', '--color-primary', '--color-brand', ]; // Test light mode await loadWithTheme(page, 'light'); const lightValues = await page.evaluate((vars) => vars.map(v => ({ var: v, value: getComputedStyle(document.documentElement).getPropertyValue(v).trim(), })) , criticalVars); for (const { var: v, value } of lightValues) { expect(value, `Light mode: ${v} should be defined`).not.toBe(''); } // Test dark mode await loadWithTheme(page, 'dark'); const darkValues = await page.evaluate((vars) => vars.map(v => ({ var: v, value: getComputedStyle(document.documentElement).getPropertyValue(v).trim(), })) , criticalVars); for (const { var: v, value } of darkValues) { expect(value, `Dark mode: ${v} should be defined`).not.toBe(''); } // Verify light and dark values DIFFER for key tokens const lightSurface = lightValues.find(v => v.var === '--color-surface')?.value; const darkSurface = darkValues.find(v => v.var === '--color-surface')?.value; expect(lightSurface).not.toBe(darkSurface); }); test('Tailwind dark: prefix classes apply correctly across browsers', async ({ page }) => { await loadWithTheme(page, 'dark'); // The header uses dark: Tailwind classes — verify computed bg is dark const headerBg = await page.evaluate(() => { const header = document.querySelector('#main-header'); return header ? getComputedStyle(header).backgroundColor : null; }); expect(headerBg).not.toBeNull(); // Dark header bg should be very dark (not white / near-white) if (headerBg) { const match = headerBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]); // Dark mode header should be dark expect(brightness).toBeLessThan(300); } } }); }); // ─── 2. localStorage Availability (Safari ITP & Private Browsing) ───────────── test.describe('Theme: localStorage — Browser Compatibility', () => { test('localStorage is accessible in standard browsing context', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const result = await page.evaluate(() => { try { localStorage.setItem('_test', '1'); const v = localStorage.getItem('_test'); localStorage.removeItem('_test'); return { available: true, value: v }; } catch (e) { return { available: false, value: null }; } }); expect(result.available).toBe(true); expect(result.value).toBe('1'); }); test('Theme init handles localStorage failure gracefully (try/catch)', async ({ page }) => { // Simulate localStorage being unavailable by overriding it await page.addInitScript(() => { // Override localStorage to throw (simulates ITP/quota exceeded) Object.defineProperty(window, 'localStorage', { get() { throw new Error('localStorage is not available'); }, configurable: true, }); }); // Page should still load without errors const errors: string[] = []; page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); await page.goto('/', { waitUntil: 'domcontentloaded' }); // Filter non-theme errors const themeErrors = errors.filter(e => e.toLowerCase().includes('localstorage') || e.toLowerCase().includes('theme') || e.toLowerCase().includes('cannot read') ); expect(themeErrors).toHaveLength(0); // Page must still have content const bodyText = await page.locator('body').innerText(); expect(bodyText.trim().length).toBeGreaterThan(50); }); test('Theme stored value persists across reload in same context', async ({ page }) => { await loadWithTheme(page, 'dark'); // Hard reload await page.reload({ waitUntil: 'domcontentloaded' }); const stored = await page.evaluate(() => { try { return localStorage.getItem('theme'); } catch { return null; } }); expect(stored).toBe('dark'); }); test('Fresh browser context has no theme residue from prior sessions', async ({ browser }) => { // Context 1: set dark const ctx1 = await browser.newContext(); const p1 = await ctx1.newPage(); await p1.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); await p1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} }); await ctx1.close(); // Context 2: completely fresh — must have no stored theme const ctx2 = await browser.newContext(); const p2 = await ctx2.newPage(); await p2.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); const stored = await p2.evaluate(() => { try { return localStorage.getItem('theme'); } catch { return null; } }); expect(stored).toBeNull(); await ctx2.close(); }); test('Multiple tabs in same context share localStorage (same origin)', async ({ context }) => { const p1 = await context.newPage(); await p1.goto('/', { waitUntil: 'domcontentloaded' }); await p1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} }); const p2 = await context.newPage(); await p2.goto('/', { waitUntil: 'domcontentloaded' }); const stored = await p2.evaluate(() => { try { return localStorage.getItem('theme'); } catch { return null; } }); expect(stored).toBe('dark'); await p1.close(); await p2.close(); }); }); // ─── 3. matchMedia / prefers-color-scheme ───────────────────────────────────── test.describe('Theme: matchMedia — Cross-Browser System Preference', () => { test('matchMedia API is available', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const hasMatchMedia = await page.evaluate(() => typeof window.matchMedia === 'function'); expect(hasMatchMedia).toBe(true); }); test('prefers-color-scheme media query is supported', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const result = await page.evaluate(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)'); return { isMediaQueryList: mq instanceof MediaQueryList, hasMatches: typeof mq.matches === 'boolean', hasAddEventListener: typeof mq.addEventListener === 'function', }; }); expect(result.isMediaQueryList).toBe(true); expect(result.hasMatches).toBe(true); expect(result.hasAddEventListener).toBe(true); }); test('Dark OS preference (emulated): page loads in dark mode without stored pref', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await loadWithTheme(page, null); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); expect(state.stored).toBeNull(); }); test('Light OS preference (emulated): page loads in light mode without stored pref', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); await loadWithTheme(page, null); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(false); expect(state.stored).toBeNull(); }); test('Stored preference takes priority over OS preference (dark stored, light OS)', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); await loadWithTheme(page, 'dark'); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); }); test('Stored preference takes priority over OS preference (light stored, dark OS)', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await loadWithTheme(page, 'light'); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(false); }); test('OS change to dark propagates when no stored pref', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); await loadWithTheme(page, null); expect((await getThemeState(page)).hasDarkClass).toBe(false); // Simulate OS switch to dark await page.emulateMedia({ colorScheme: 'dark' }); await page.waitForTimeout(400); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); expect(state.stored).toBeNull(); // Not stored — just OS-driven }); test('OS change ignored when user has explicit stored preference', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await loadWithTheme(page, 'light'); // User explicitly chose light; switch OS to dark await page.emulateMedia({ colorScheme: 'dark' }); await page.waitForTimeout(400); // Stored preference wins — the event listener checks localStorage first expect((await getThemeState(page)).stored).toBe('light'); }); }); // ─── 4. CSS Transition Behavior ─────────────────────────────────────────────── test.describe('Theme: CSS Transitions — Cross-Browser', () => { test('Body background transitions on theme toggle (transition exists)', async ({ page }) => { await loadWithTheme(page, 'light'); const transition = await page.evaluate(() => getComputedStyle(document.body).transition ); // Background or color transition should be defined (non-empty) // Different browsers serialize transitions differently — just verify it's non-empty expect(transition.trim().length).toBeGreaterThan(0); }); test('Icon opacity transitions are defined on sun/moon icons', async ({ page }) => { await loadWithTheme(page, 'light'); const moonTransition = await page.evaluate(() => { const btn = document.querySelector('[data-theme-toggle]'); const moon = btn?.querySelector('.moon-icon') as HTMLElement | null; return moon ? getComputedStyle(moon).transition : null; }); expect(moonTransition).not.toBeNull(); expect(moonTransition!.trim().length).toBeGreaterThan(0); }); test('Reduced motion: sun/moon icon transitions are disabled', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await loadWithTheme(page, 'light'); const iconTransitionDuration = await page.evaluate(() => { const btn = document.querySelector('[data-theme-toggle]'); const icon = btn?.querySelector('.moon-icon') as HTMLElement | null; return icon ? getComputedStyle(icon).transitionDuration : null; }); if (iconTransitionDuration) { expect(['0s', '0ms', '']).toContain(iconTransitionDuration); } }); test('Reduced motion: theme toggle still functions (no animation freeze)', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await loadWithTheme(page, 'light'); await clickToggle(page); const state = await getThemeState(page); // Toggle should still work — just without transition expect(state.hasDarkClass).toBe(true); expect(state.stored).toBe('dark'); }); test('Theme toggle icon is visible after transition completes', async ({ page }) => { await loadWithTheme(page, 'dark'); // Wait for any transitions to settle await page.waitForTimeout(400); const sunOpacity = await page.evaluate(() => { const btn = document.querySelector('[data-theme-toggle]'); const sun = btn?.querySelector('.sun-icon') as HTMLElement | null; return sun ? parseFloat(getComputedStyle(sun).opacity) : null; }); expect(sunOpacity).toBeCloseTo(1, 1); // Should be ~1.0 in dark mode }); test('Theme toggle icon switches after toggle completes', async ({ page }) => { await loadWithTheme(page, 'light'); // Initially moon icon should be visible (opacity ~1) const moonBefore = await page.evaluate(() => { const btn = document.querySelector('[data-theme-toggle]'); const moon = btn?.querySelector('.moon-icon') as HTMLElement | null; return moon ? parseFloat(getComputedStyle(moon).opacity) : null; }); expect(moonBefore).toBeCloseTo(1, 1); await clickToggle(page); await page.waitForTimeout(400); // Allow transition to complete // After toggle to dark, sun should be visible, moon hidden const { sunOpacity, moonOpacity } = await page.evaluate(() => { const btn = document.querySelector('[data-theme-toggle]'); const sun = btn?.querySelector('.sun-icon') as HTMLElement | null; const moon = btn?.querySelector('.moon-icon') as HTMLElement | null; return { sunOpacity: sun ? parseFloat(getComputedStyle(sun).opacity) : null, moonOpacity: moon ? parseFloat(getComputedStyle(moon).opacity) : null, }; }); expect(sunOpacity).toBeCloseTo(1, 1); expect(moonOpacity).toBeCloseTo(0, 1); }); }); // ─── 5. Mobile Viewport Theme Behavior ─────────────────────────────────────── test.describe('Theme: Mobile Viewport Compatibility', () => { const MOBILE_VIEWPORTS = [ { name: 'iPhone SE', width: 375, height: 667 }, { name: 'Pixel 5', width: 393, height: 851 }, { name: 'iPad', width: 768, height: 1024 }, ]; for (const vp of MOBILE_VIEWPORTS) { test(`${vp.name}: theme toggle visible and functional`, async ({ page }) => { await page.setViewportSize({ width: vp.width, height: vp.height }); await loadWithTheme(page, 'light'); // At mobile widths, desktop toggle may be hidden; mobile toggle should be present const allToggles = await page.locator('[data-theme-toggle]').count(); expect(allToggles).toBeGreaterThanOrEqual(1); // At least one toggle should be visible let visibleToggle = false; for (let i = 0; i < allToggles; i++) { const isVisible = await page.locator('[data-theme-toggle]').nth(i).isVisible(); if (isVisible) { visibleToggle = true; break; } } expect(visibleToggle).toBe(true); }); test(`${vp.name}: dark mode CSS correct at this viewport`, async ({ page }) => { await page.setViewportSize({ width: vp.width, height: vp.height }); await loadWithTheme(page, 'dark'); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); const match = state.bodyBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]); expect(brightness).toBeLessThan(200); } }); } test('Mobile: theme toggle in mobile nav menu works correctly', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await loadWithTheme(page, 'light'); // Open mobile menu const menuBtn = page.locator('#mobile-menu-toggle'); if (await menuBtn.isVisible()) { await menuBtn.click(); await page.waitForTimeout(400); } // Theme toggle should be findable and interactive const toggle = page.locator('[data-theme-toggle]').first(); await expect(toggle).toBeVisible(); await toggle.click(); await page.waitForTimeout(200); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); expect(state.stored).toBe('dark'); }); test('Viewport resize dark → light → resize does not break theme', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await loadWithTheme(page, 'dark'); expect((await getThemeState(page)).hasDarkClass).toBe(true); // Resize to desktop await page.setViewportSize({ width: 1280, height: 800 }); await page.waitForTimeout(300); expect((await getThemeState(page)).hasDarkClass).toBe(true); // Resize back to mobile await page.setViewportSize({ width: 375, height: 667 }); await page.waitForTimeout(300); expect((await getThemeState(page)).hasDarkClass).toBe(true); }); }); // ─── 6. theme-color Meta Tag Behavior ───────────────────────────────────────── test.describe('Theme: theme-color Meta Tag — Browser Compatibility', () => { test('meta[name="theme-color"] tags exist in ', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const metas = await page.evaluate(() => Array.from(document.head.querySelectorAll('meta[name="theme-color"]')).map(m => ({ content: m.getAttribute('content'), media: m.getAttribute('media'), })) ); expect(metas.length).toBeGreaterThanOrEqual(1); }); test('Meta tags have media attributes for OS-level dark/light support', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const mediaMetas = await page.evaluate(() => Array.from(document.head.querySelectorAll('meta[name="theme-color"][media]')).length ); // Should have at least one media-query-gated theme-color meta expect(mediaMetas).toBeGreaterThanOrEqual(1); }); test('Dark mode: theme-color meta updated to dark color (#0f172a)', async ({ page }) => { await loadWithTheme(page, 'dark'); const metas = await page.evaluate(() => Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => m.getAttribute('content') ) ); expect(metas.some(c => c === '#0f172a')).toBe(true); }); test('Light mode: theme-color meta updated to light color (#0891b2)', async ({ page }) => { await loadWithTheme(page, 'light'); const metas = await page.evaluate(() => Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => m.getAttribute('content') ) ); expect(metas.some(c => c === '#0891b2')).toBe(true); }); test('Toggle: theme-color meta updates dynamically without page reload', async ({ page }) => { await loadWithTheme(page, 'light'); // Verify light color const beforeMetas = await page.evaluate(() => Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => m.getAttribute('content') ) ); expect(beforeMetas.some(c => c === '#0891b2')).toBe(true); await clickToggle(page); // After toggling to dark, meta should update const afterMetas = await page.evaluate(() => Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => m.getAttribute('content') ) ); expect(afterMetas.some(c => c === '#0f172a')).toBe(true); }); }); // ─── 7. Performance Under Simulated Low-End Device Conditions ──────────────── test.describe('Theme: Performance — Low-End Device Simulation', () => { test('Theme init completes without measurable JS error under CPU throttle', async ({ page, browserName }) => { // Note: Playwright CDP throttling only works in Chromium if (browserName !== 'chromium') { test.skip(); return; } const client = await (page.context() as BrowserContext).newCDPSession(page); // Simulate 4x CPU slowdown await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); const errors: string[] = []; page.on('pageerror', e => errors.push(e.message)); await loadWithTheme(page, 'dark'); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); // No JS errors even under throttle expect(errors).toHaveLength(0); // Reset throttle await client.send('Emulation.setCPUThrottlingRate', { rate: 1 }); }); test('Theme init does not block page render measurably (inline script)', async ({ page }) => { // Verify the init script is small (inline IIFE, not a network request) await page.goto('/', { waitUntil: 'domcontentloaded' }); const inlineThemeScript = await page.evaluate(() => { const scripts = Array.from(document.head.querySelectorAll('script:not([src]):not([type="module"])')); return scripts.map(s => ({ text: s.textContent ?? '', length: (s.textContent ?? '').length })); }); // Find the theme init script const themeScript = inlineThemeScript.find(s => s.text.includes('localStorage') && s.text.includes('dark') ); expect(themeScript).toBeTruthy(); // The inline theme script should be compact (< 1KB) to not block the render thread if (themeScript) { expect(themeScript.length).toBeLessThan(1000); } }); test('Theme toggle responds within 300ms on all pages', async ({ page }) => { await loadWithTheme(page, 'light'); const start = Date.now(); await clickToggle(page); const elapsed = Date.now() - start; const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); // Toggle should complete well under 300ms (CSS class change is synchronous) expect(elapsed).toBeLessThan(500); // generous CI budget }); test('3G simulation: theme persists after slow page load', async ({ page, browserName }) => { if (browserName !== 'chromium') { test.skip(); return; } const client = await (page.context() as BrowserContext).newCDPSession(page); // Simulate slow 3G await client.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: 50 * 1024 / 8, // 50 kbps uploadThroughput: 20 * 1024 / 8, latency: 300, }); // Set dark preference and reload await page.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); await setStoredTheme(page, 'dark'); await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }); const state = await getThemeState(page); // Dark class should be applied even on slow networks (inline blocking script) expect(state.hasDarkClass).toBe(true); // Reset network await client.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0, }); }); }); // ─── 8. JavaScript Disabled Fallback ───────────────────────────────────────── test.describe('Theme: No-JavaScript Fallback — Cross-Browser', () => { test('Without JS: page loads and has content (SSR)', async ({ browser }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); await page.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); const bodyText = await page.locator('body').innerText(); expect(bodyText.trim().length).toBeGreaterThan(100); await expect(page.locator('#main-header')).toBeAttached(); await expect(page.locator('footer')).toBeAttached(); await ctx.close(); }); test('Without JS: no dark class applied (progressive enhancement)', async ({ browser }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); await page.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark') ); // Without JS, the inline blocking script cannot run — no dark class // This is expected: theme system uses progressive enhancement expect(typeof hasDark).toBe('boolean'); await ctx.close(); }); test('Without JS: theme-color meta tags still present in SSR output', async ({ browser }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); await page.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); const metaCount = await page.evaluate(() => document.querySelectorAll('meta[name="theme-color"]').length ); expect(metaCount).toBeGreaterThanOrEqual(1); await ctx.close(); }); test('Without JS: noscript font fallback link is rendered', async ({ browser }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); await page.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' }); const hasNoscriptFont = await page.evaluate(() => { return Array.from(document.querySelectorAll('noscript')).some(n => n.innerHTML.includes('fonts.googleapis.com') ); }); expect(hasNoscriptFont).toBe(true); await ctx.close(); }); }); // ─── 9. Cross-Browser Color Accuracy ───────────────────────────────────────── test.describe('Theme: Color Accuracy — Cross-Browser', () => { test('Dark mode body background: computed color matches #0f172a (slate-900)', async ({ page }) => { await loadWithTheme(page, 'dark'); const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); // #0f172a = rgb(15, 23, 42) const normalized = bg.replace(/\s/g, ''); // Accept rgb(15,23,42) format expect(normalized).toMatch(/rgb\(15,23,42\)/); }); test('Light mode body background: computed color is light', async ({ page }) => { await loadWithTheme(page, 'light'); const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); const match = bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]); expect(brightness).toBeGreaterThan(500); // Light background } }); test('Dark mode primary text color: renders as near-white', async ({ page }) => { await loadWithTheme(page, 'dark'); // Verify computed text color of a heading is light (not dark text on dark bg) const headingColor = await page.evaluate(() => { const h1 = document.querySelector('h1'); return h1 ? getComputedStyle(h1).color : null; }); if (headingColor) { const match = headingColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]); // Dark mode heading text should be light expect(brightness).toBeGreaterThan(400); } } }); test('Light mode primary text color: renders as near-black', async ({ page }) => { await loadWithTheme(page, 'light'); const headingColor = await page.evaluate(() => { const h1 = document.querySelector('h1'); return h1 ? getComputedStyle(h1).color : null; }); if (headingColor) { const match = headingColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]); // Light mode heading text should be dark expect(brightness).toBeLessThan(200); } } }); test('Color contrast: dark mode text-on-background meets WCAG AA', async ({ page }) => { await loadWithTheme(page, 'dark'); // Read actual computed colors and compute relative luminance const colors = await page.evaluate(() => { function parseCSSColor(c: string): [number, number, number] | null { const m = c.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null; } function relativeLuminance(rgb: [number, number, number]): number { const [r, g, b] = rgb.map(c => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function contrastRatio(c1: [number, number, number], c2: [number, number, number]): number { const l1 = relativeLuminance(c1); const l2 = relativeLuminance(c2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } const bodyBg = parseCSSColor(getComputedStyle(document.body).backgroundColor); const headingEl = document.querySelector('h1, h2'); const textColor = headingEl ? parseCSSColor(getComputedStyle(headingEl).color) : null; if (!bodyBg || !textColor) return null; return contrastRatio(bodyBg, textColor); }); if (colors !== null) { // WCAG AA: 4.5:1 for normal text, 3:1 for large text (headings qualify as large) expect(colors).toBeGreaterThanOrEqual(3.0); } }); test('Color contrast: light mode text-on-background meets WCAG AA', async ({ page }) => { await loadWithTheme(page, 'light'); const contrast = await page.evaluate(() => { function parseCSSColor(c: string): [number, number, number] | null { const m = c.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null; } function relativeLuminance(rgb: [number, number, number]): number { const [r, g, b] = rgb.map(c => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function contrastRatio(c1: [number, number, number], c2: [number, number, number]): number { const l1 = relativeLuminance(c1); const l2 = relativeLuminance(c2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } const bodyBg = parseCSSColor(getComputedStyle(document.body).backgroundColor); const heading = document.querySelector('h1, h2'); const textColor = heading ? parseCSSColor(getComputedStyle(heading).color) : null; if (!bodyBg || !textColor) return null; return contrastRatio(bodyBg, textColor); }); if (contrast !== null) { expect(contrast).toBeGreaterThanOrEqual(3.0); } }); }); // ─── 10. Browser Extension Color Override Simulation ───────────────────────── test.describe('Theme: Browser Extension Color Override Simulation', () => { test('Theme class survives programmatic html class manipulation', async ({ page }) => { // Simulate a dark-mode extension that modifies html classes await loadWithTheme(page, 'dark'); // Extension adds its own class to await page.evaluate(() => { document.documentElement.classList.add('ext-forced-dark'); }); await page.waitForTimeout(100); // Our dark class should still be there const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); expect(state.htmlClass).toContain('ext-forced-dark'); }); test('Theme toggle still works after external class additions', async ({ page }) => { await loadWithTheme(page, 'light'); // Simulate extension adding classes await page.evaluate(() => { document.documentElement.classList.add('ext-mode', 'ext-modified'); }); await clickToggle(page); const state = await getThemeState(page); expect(state.hasDarkClass).toBe(true); expect(state.stored).toBe('dark'); // Extension classes still present expect(state.htmlClass).toContain('ext-mode'); }); test('CSS custom properties survive !important override attempt', async ({ page }) => { await loadWithTheme(page, 'dark'); // Simulate a style injection (like some browser extensions do) await page.evaluate(() => { const style = document.createElement('style'); style.textContent = ` /* Simulated extension override */ body { background-color: white !important; } `; document.head.appendChild(style); }); await page.waitForTimeout(200); // Our html.dark class should still be present const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark') ); expect(hasDark).toBe(true); // The theme toggle button aria state should still be correct const ariaChecked = await page.locator('[data-theme-toggle]').first().getAttribute('aria-checked'); expect(ariaChecked).toBe('true'); }); }); // ─── 11. Cross-Page Consistency (all routes) ───────────────────────────────── test.describe('Theme: Cross-Page Consistency — All Routes', () => { const ALL_PAGES = [ '/', '/about', '/services', '/portfolio', '/contact', '/blog', '/privacy', '/terms', '/sitemap', ]; for (const url of ALL_PAGES) { test(`Dark mode consistent on: ${url}`, async ({ page }) => { // Set dark in storage, then load target page directly await page.goto('/', { waitUntil: 'domcontentloaded' }); await setStoredTheme(page, 'dark'); await page.goto(url, { waitUntil: 'domcontentloaded' }); const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark') ); expect(hasDark).toBe(true); }); } test('Theme toggle present on all key content pages (desktop)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); const contentPages = ['/', '/about', '/services', '/portfolio', '/contact']; for (const url of contentPages) { await page.goto(url, { waitUntil: 'domcontentloaded' }); const count = await page.locator('[data-theme-toggle]').count(); expect(count, `Theme toggle missing on ${url}`).toBeGreaterThanOrEqual(1); } }); test('Theme state consistent after SPA-style rapid navigation', async ({ page }) => { await loadWithTheme(page, 'dark'); // Navigate rapidly across pages const pages = ['/about', '/services', '/portfolio', '/contact', '/']; for (const url of pages) { await page.goto(url, { waitUntil: 'domcontentloaded' }); } const finalState = await getThemeState(page); expect(finalState.hasDarkClass).toBe(true); }); test('Theme persists through browser back/forward navigation', async ({ page }) => { await loadWithTheme(page, 'dark'); await page.goto('/about', { waitUntil: 'domcontentloaded' }); expect((await getThemeState(page)).hasDarkClass).toBe(true); await page.goto('/services', { waitUntil: 'domcontentloaded' }); expect((await getThemeState(page)).hasDarkClass).toBe(true); await page.goBack({ waitUntil: 'domcontentloaded' }); expect((await getThemeState(page)).hasDarkClass).toBe(true); await page.goBack({ waitUntil: 'domcontentloaded' }); expect((await getThemeState(page)).hasDarkClass).toBe(true); }); }); // ─── 12. Visual Regression Snapshots — Theme Variants ──────────────────────── test.describe('Theme: Visual Snapshots — Cross-Browser', () => { const PAGES = [ { name: 'home', url: '/' }, { name: 'services', url: '/services' }, { name: 'portfolio', url: '/portfolio' }, { name: 'contact', url: '/contact' }, ]; for (const p of PAGES) { test(`Dark/light screenshot pair: ${p.name}`, async ({ page, browserName }) => { // Dark mode await page.setViewportSize({ width: 1280, height: 800 }); await loadWithTheme(page, 'dark'); await page.goto(p.url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(400); await page.screenshot({ path: `tests/screenshots/theme-compat/${browserName}-${p.name}-dark.png`, clip: { x: 0, y: 0, width: 1280, height: 800 }, }); // Light mode await loadWithTheme(page, 'light'); await page.goto(p.url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(400); await page.screenshot({ path: `tests/screenshots/theme-compat/${browserName}-${p.name}-light.png`, clip: { x: 0, y: 0, width: 1280, height: 800 }, }); }); test(`Mobile dark/light screenshot: ${p.name}`, async ({ page, browserName }) => { await page.setViewportSize({ width: 375, height: 667 }); // Dark await loadWithTheme(page, 'dark'); await page.goto(p.url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); await page.screenshot({ path: `tests/screenshots/theme-compat/${browserName}-${p.name}-mobile-dark.png`, fullPage: false, }); // Light await loadWithTheme(page, 'light'); await page.goto(p.url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); await page.screenshot({ path: `tests/screenshots/theme-compat/${browserName}-${p.name}-mobile-light.png`, fullPage: false, }); }); } });