Files
Clintchiz 0614ae6f85
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
Latest Updated Pages
2026-03-22 14:37:17 +05:30

819 lines
32 KiB
TypeScript

import { test, expect, Page } from '@playwright/test';
// ============================================================
// Theme Persistence & Synchronization Tests
// Tests: localStorage, system preference, FOUC, multi-browser
// ============================================================
// Helper: get the current theme state from the DOM
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'),
themeColorMetas: Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => ({
content: m.getAttribute('content'),
media: m.getAttribute('media'),
})),
liveRegion: document.querySelector('[data-theme-live]')?.textContent ?? '',
htmlClass: document.documentElement.className,
}));
}
// Helper: set localStorage theme before page load (via storageState simulation)
async function loadPageWithStoredTheme(page: Page, theme: 'dark' | 'light' | null, url = '/') {
// Navigate to page first, then set storage, then reload — simulates returning visitor
await page.goto(url, { waitUntil: 'domcontentloaded' });
if (theme !== null) {
await page.evaluate((t) => {
try { localStorage.setItem('theme', t); } catch { /* noop */ }
}, theme);
} else {
await page.evaluate(() => {
try { localStorage.removeItem('theme'); } catch { /* noop */ }
});
}
// Reload to trigger the blocking init script
await page.reload({ waitUntil: 'domcontentloaded' });
}
// Helper: click the theme toggle
async function clickThemeToggle(page: Page) {
const toggle = page.locator('[data-theme-toggle]').first();
await expect(toggle).toBeVisible();
await toggle.click();
await page.waitForTimeout(150); // Allow state sync
}
// ============================================================
// 1. localStorage Persistence
// ============================================================
test.describe('Theme: localStorage Persistence', () => {
test('Toggling to dark stores "dark" in localStorage', async ({ page }) => {
await loadPageWithStoredTheme(page, null);
// Start in light mode (no stored preference)
const before = await getThemeState(page);
// State may be light or dark depending on system — click once and check stored value
await clickThemeToggle(page);
const after = await getThemeState(page);
const isDarkNow = after.hasDarkClass;
expect(after.stored).toBe(isDarkNow ? 'dark' : 'light');
});
test('Toggling to light stores "light" in localStorage', async ({ page }) => {
// Start with dark stored
await loadPageWithStoredTheme(page, 'dark');
const before = await getThemeState(page);
expect(before.hasDarkClass).toBe(true);
await clickThemeToggle(page);
const after = await getThemeState(page);
expect(after.hasDarkClass).toBe(false);
expect(after.stored).toBe('light');
});
test('Stored "dark" preference is applied on page load', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(true);
expect(state.stored).toBe('dark');
});
test('Stored "light" preference is applied on page load', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(false);
expect(state.stored).toBe('light');
});
test('Preference persists across page navigation (About → Services)', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
// Verify dark on home
expect((await getThemeState(page)).hasDarkClass).toBe(true);
// Navigate to About
await page.goto('/about', { waitUntil: 'domcontentloaded' });
expect((await getThemeState(page)).hasDarkClass).toBe(true);
// Navigate to Services
await page.goto('/services', { waitUntil: 'domcontentloaded' });
expect((await getThemeState(page)).hasDarkClass).toBe(true);
});
test('Light preference persists across page navigation', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(false);
expect(state.stored).toBe('light');
});
test('Theme toggle persists after navigating away and back', async ({ page }) => {
await loadPageWithStoredTheme(page, null);
// Toggle to dark
await clickThemeToggle(page);
const wasSetToDark = (await getThemeState(page)).hasDarkClass;
// Navigate away
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
const afterNav = await getThemeState(page);
expect(afterNav.hasDarkClass).toBe(wasSetToDark);
// Navigate back
await page.goto('/', { waitUntil: 'domcontentloaded' });
const afterBack = await getThemeState(page);
expect(afterBack.hasDarkClass).toBe(wasSetToDark);
});
test('Multiple toggles correctly alternate and store final value', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
await clickThemeToggle(page); // → dark
expect((await getThemeState(page)).stored).toBe('dark');
await clickThemeToggle(page); // → light
expect((await getThemeState(page)).stored).toBe('light');
await clickThemeToggle(page); // → dark
const final = await getThemeState(page);
expect(final.stored).toBe('dark');
expect(final.hasDarkClass).toBe(true);
});
});
// ============================================================
// 2. System Preference Detection (prefers-color-scheme)
// ============================================================
test.describe('Theme: System Preference Detection', () => {
test('No stored preference: dark OS → page loads in dark mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await loadPageWithStoredTheme(page, null);
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(true);
expect(state.stored).toBeNull(); // Should NOT store when respecting OS
});
test('No stored preference: light OS → page loads in light mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await loadPageWithStoredTheme(page, null);
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(false);
expect(state.stored).toBeNull();
});
test('Stored preference overrides OS preference (dark stored, light OS)', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await loadPageWithStoredTheme(page, 'dark');
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(true); // Stored wins
});
test('Stored preference overrides OS preference (light stored, dark OS)', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await loadPageWithStoredTheme(page, 'light');
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(false); // Stored wins
});
test('System preference change updates theme when no stored preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await loadPageWithStoredTheme(page, null);
// Verify light initially
expect((await getThemeState(page)).hasDarkClass).toBe(false);
// Simulate OS switching to dark
await page.emulateMedia({ colorScheme: 'dark' });
await page.waitForTimeout(300); // Allow matchMedia listener to fire
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(true);
expect(state.stored).toBeNull(); // Still not stored
});
test('System preference change is ignored when user has stored preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await loadPageWithStoredTheme(page, 'dark');
// OS switches to light
await page.emulateMedia({ colorScheme: 'light' });
await page.waitForTimeout(300);
const state = await getThemeState(page);
// User's stored 'dark' should still be active — but note: system pref change
// listener only runs for initial load not active toggles — document behavior as-is
expect(state.stored).toBe('dark');
});
});
// ============================================================
// 3. No Flash of Unstyled Content (FOUC) Prevention
// ============================================================
test.describe('Theme: FOUC Prevention', () => {
test('Dark mode class applied before first paint (inline script)', async ({ page }) => {
// Set up dark preference before navigating
// We need to ensure html.dark is set synchronously before any CSS loads
// Use CDP to intercept and verify class set early
let darkClassAppliedBeforeDOMContentLoaded = false;
// Listen on DOMContentLoaded and check if class is already present
await page.addInitScript(() => {
// This script runs AFTER inline scripts but BEFORE DOMContentLoaded
// The theme init inline script should have run by now
window.__themeClassAtInitScript = document.documentElement.classList.contains('dark');
});
// Navigate with dark stored
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
try { localStorage.setItem('theme', 'dark'); } catch { /* noop */ }
});
// Reload and capture early state
await page.addInitScript(() => {
// Capture class state at very start of script execution
window.__earlyDarkCheck = document.documentElement.classList.contains('dark');
});
await page.reload({ waitUntil: 'domcontentloaded' });
// After reload, html.dark should be present immediately
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(hasDarkClass).toBe(true);
});
test('No body background flash - html.dark applied synchronously', async ({ page }) => {
// Navigate with dark preference and verify dark class is set on html element
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
try { localStorage.setItem('theme', 'dark'); } catch { /* noop */ }
});
await page.reload({ waitUntil: 'domcontentloaded' });
// The dark class must be on the html element (not body)
const htmlClasses = await page.evaluate(() => document.documentElement.className);
expect(htmlClasses).toContain('dark');
});
test('Light mode: no dark class on html element when light is stored', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
try { localStorage.setItem('theme', 'light'); } catch { /* noop */ }
});
await page.reload({ waitUntil: 'domcontentloaded' });
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(hasDarkClass).toBe(false);
});
test('Theme init script is in <head> not <body>', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Verify the blocking theme script is in <head>
const headScripts = await page.evaluate(() => {
const scripts = Array.from(document.head.querySelectorAll('script:not([type])'));
return scripts.map(s => s.textContent?.substring(0, 100) ?? '');
});
// At least one head script should contain the localStorage theme logic
const hasThemeScript = headScripts.some(s =>
s.includes('localStorage') && (s.includes('theme') || s.includes('dark'))
);
expect(hasThemeScript).toBe(true);
});
test('FOUC check: no visible content reflow on dark reload', async ({ page }) => {
// If FOUC were present, background-color would transition from white to dark
// We verify the html element has the class before DOMContentLoaded fires
let classBeforeDOMContentLoaded: boolean | null = null;
page.on('domcontentloaded', async () => {
classBeforeDOMContentLoaded = await page.evaluate(
() => document.documentElement.classList.contains('dark')
).catch(() => null);
});
// Ensure dark is stored
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
await page.reload({ waitUntil: 'domcontentloaded' });
// At DOMContentLoaded, dark class should be present (set by inline blocking script)
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(hasDark).toBe(true);
});
});
// ============================================================
// 4. ARIA Accessibility & State Synchronization
// ============================================================
test.describe('Theme: ARIA Accessibility', () => {
test('Toggle button has role="switch"', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const role = await page.locator('[data-theme-toggle]').first().getAttribute('role');
expect(role).toBe('switch');
});
test('aria-checked="false" in light mode', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const ariaChecked = await page.locator('[data-theme-toggle]').first().getAttribute('aria-checked');
expect(ariaChecked).toBe('false');
});
test('aria-checked="true" in dark mode', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const ariaChecked = await page.locator('[data-theme-toggle]').first().getAttribute('aria-checked');
expect(ariaChecked).toBe('true');
});
test('aria-label updates after toggling to dark', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
await clickThemeToggle(page);
const state = await getThemeState(page);
if (state.hasDarkClass) {
expect(state.ariaLabel).toBe('Switch to light mode');
expect(state.ariaChecked).toBe('true');
} else {
expect(state.ariaLabel).toBe('Switch to dark mode');
expect(state.ariaChecked).toBe('false');
}
});
test('aria-label updates after toggling to light', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
await clickThemeToggle(page);
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(false);
expect(state.ariaLabel).toBe('Switch to dark mode');
expect(state.ariaChecked).toBe('false');
});
test('Live region announces theme change on toggle', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
// Clear live region
await page.evaluate(() => {
document.querySelectorAll('[data-theme-live]').forEach(el => el.textContent = '');
});
await clickThemeToggle(page);
await page.waitForTimeout(200);
const liveRegionText = await page.evaluate(() =>
document.querySelector('[data-theme-live]')?.textContent ?? ''
);
expect(['Dark mode enabled', 'Light mode enabled']).toContain(liveRegionText.trim());
});
test('Live region is polite (non-interrupting)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const ariaLive = await page.locator('[data-theme-live]').first().getAttribute('aria-live');
expect(ariaLive).toBe('polite');
});
test('All theme toggle instances sync aria-checked (desktop + mobile)', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await loadPageWithStoredTheme(page, 'light');
// Click toggle (finds first visible one on mobile)
await clickThemeToggle(page);
await page.waitForTimeout(200);
// All toggle instances should have same aria-checked
const allCheckedValues = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-theme-toggle]')).map(btn =>
btn.getAttribute('aria-checked')
)
);
// All should be the same value
const uniqueValues = [...new Set(allCheckedValues)];
expect(uniqueValues).toHaveLength(1);
});
test('Toggle is keyboard accessible (Space key)', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const before = await getThemeState(page);
const wasLight = !before.hasDarkClass;
// Focus and press Space
await page.locator('[data-theme-toggle]').first().focus();
await page.keyboard.press('Space');
await page.waitForTimeout(150);
const after = await getThemeState(page);
// Space on a button triggers click - theme should have toggled
expect(after.hasDarkClass).toBe(!before.hasDarkClass);
});
test('Toggle is keyboard accessible (Enter key)', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const before = await getThemeState(page);
await page.locator('[data-theme-toggle]').first().focus();
await page.keyboard.press('Enter');
await page.waitForTimeout(150);
const after = await getThemeState(page);
expect(after.hasDarkClass).toBe(!before.hasDarkClass);
});
test('Toggle button is focusable (tabindex not -1)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const tabindex = await page.locator('[data-theme-toggle]').first().getAttribute('tabindex');
// Should not be -1 (which would make it unfocusable)
expect(tabindex).not.toBe('-1');
});
});
// ============================================================
// 5. theme-color Meta Tag Synchronization
// ============================================================
test.describe('Theme: Meta Tag Synchronization', () => {
test('Dark mode: theme-color meta tags updated to dark color', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const state = await getThemeState(page);
// At least one meta should have the dark color
const hasStoredDarkColor = state.themeColorMetas.some(m => m.content === '#0f172a');
expect(hasStoredDarkColor).toBe(true);
});
test('Light mode: theme-color meta tags updated to light color', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const state = await getThemeState(page);
// At least one meta should have the light color
const hasStoredLightColor = state.themeColorMetas.some(m => m.content === '#0891b2');
expect(hasStoredLightColor).toBe(true);
});
test('Toggling theme updates theme-color meta tags', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
await clickThemeToggle(page);
await page.waitForTimeout(150);
const state = await getThemeState(page);
if (state.hasDarkClass) {
const hasDarkColor = state.themeColorMetas.some(m => m.content === '#0f172a');
expect(hasDarkColor).toBe(true);
} else {
const hasLightColor = state.themeColorMetas.some(m => m.content === '#0891b2');
expect(hasLightColor).toBe(true);
}
});
test('Both theme-color meta tags exist in the document head', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const metaCount = await page.evaluate(() =>
document.querySelectorAll('meta[name="theme-color"]').length
);
expect(metaCount).toBeGreaterThanOrEqual(1);
});
});
// ============================================================
// 6. Visual State (CSS)
// ============================================================
test.describe('Theme: Visual CSS State', () => {
test('Dark mode: html element has "dark" class', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(hasDark).toBe(true);
});
test('Light mode: html element does NOT have "dark" class', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(hasDark).toBe(false);
});
test('Dark mode: page background is dark-colored', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const bgColor = await page.evaluate(() =>
window.getComputedStyle(document.body).backgroundColor
);
// Dark surface is #0f172a = rgb(15, 23, 42)
// Verify it's distinctly dark (r+g+b should be low)
const match = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
expect(brightness).toBeLessThan(200); // Dark background
}
});
test('Light mode: page background is light-colored', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const bgColor = await page.evaluate(() =>
window.getComputedStyle(document.body).backgroundColor
);
const match = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
expect(brightness).toBeGreaterThan(550); // Light background
}
});
test('Sun icon visible in dark mode', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
const sunOpacity = await page.evaluate(() => {
const btn = document.querySelector('[data-theme-toggle]');
const sun = btn?.querySelector('.sun-icon') as HTMLElement | null;
return sun ? window.getComputedStyle(sun).opacity : null;
});
expect(sunOpacity).toBe('1');
});
test('Moon icon visible in light mode', async ({ page }) => {
await loadPageWithStoredTheme(page, 'light');
const moonOpacity = await page.evaluate(() => {
const btn = document.querySelector('[data-theme-toggle]');
const moon = btn?.querySelector('.moon-icon') as HTMLElement | null;
return moon ? window.getComputedStyle(moon).opacity : null;
});
expect(moonOpacity).toBe('1');
});
test('Dark mode: icon transitions respect prefers-reduced-motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await loadPageWithStoredTheme(page, 'dark');
const transitionDuration = await page.evaluate(() => {
const btn = document.querySelector('[data-theme-toggle]');
const sun = btn?.querySelector('.sun-icon') as HTMLElement | null;
return sun ? window.getComputedStyle(sun).transitionDuration : null;
});
// With reduced motion, transition should be 0s or none
if (transitionDuration) {
expect(['0s', '0ms']).toContain(transitionDuration);
}
});
});
// ============================================================
// 7. JavaScript Disabled Behavior
// ============================================================
test.describe('Theme: JavaScript Disabled', () => {
test('Page renders without JS (no crash, content visible)', async ({ browser }) => {
// Create context with JS disabled
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Page should still render content (SSR)
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length).toBeGreaterThan(50);
// Header should be present
await expect(page.locator('#main-header')).toBeAttached();
await context.close();
});
test('Without JS: page defaults to light mode (no dark class)', async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Without JS, the inline theme script cannot run
// Page should default to light mode (no dark class on html)
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
// Note: This tests the degraded state — dark class won't be applied without JS
// This is expected behavior (progressive enhancement)
// The OS-level theme-color media queries still work for browser chrome
expect(typeof hasDark).toBe('boolean'); // Just verify evaluation works
await context.close();
});
test('Without JS: theme-color meta tags still exist (native OS support)', async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Meta tags are server-rendered so they exist even without JS
const metaCount = await page.evaluate(() =>
document.querySelectorAll('meta[name="theme-color"]').length
);
expect(metaCount).toBeGreaterThanOrEqual(1);
await context.close();
});
test('Without JS: noscript font fallback renders', async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Noscript font link should be in the document
const noscript = await page.evaluate(() => {
const noscripts = document.querySelectorAll('noscript');
return Array.from(noscripts).some(n => n.innerHTML.includes('fonts.googleapis.com'));
});
expect(noscript).toBe(true);
await context.close();
});
});
// ============================================================
// 8. Cross-Page Theme Consistency
// ============================================================
test.describe('Theme: Cross-Page Consistency', () => {
const ALL_CONTENT_PAGES = ['/', '/about', '/services', '/portfolio', '/contact', '/blog'];
for (const url of ALL_CONTENT_PAGES) {
test(`Dark mode consistent on ${url}`, async ({ page }) => {
// Start with dark preference and visit each page
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
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 content pages', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); // Desktop view
for (const url of ALL_CONTENT_PAGES) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
const toggleCount = await page.locator('[data-theme-toggle]').count();
expect(toggleCount, `Theme toggle missing on ${url}`).toBeGreaterThanOrEqual(1);
}
});
test('Theme state consistent in mobile nav', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await loadPageWithStoredTheme(page, 'dark');
// Open mobile menu
const mobileToggle = page.locator('#mobile-menu-toggle');
await expect(mobileToggle).toBeVisible();
await mobileToggle.click();
await page.waitForTimeout(400);
// There should be a theme toggle in the mobile menu too
const togglesInMobileMenu = await page.locator('#mobile-menu [data-theme-toggle]').count();
// At least one toggle should exist (in header, possibly in mobile menu too)
const allToggles = await page.locator('[data-theme-toggle]').count();
expect(allToggles).toBeGreaterThanOrEqual(1);
});
test('Theme preserved when switching between mobile and desktop viewports', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await loadPageWithStoredTheme(page, 'dark');
expect((await getThemeState(page)).hasDarkClass).toBe(true);
// Resize to desktop
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(200);
expect((await getThemeState(page)).hasDarkClass).toBe(true);
});
});
// ============================================================
// 9. Browser Session Persistence
// ============================================================
test.describe('Theme: Browser Session Persistence', () => {
test('Theme preference survives page refresh', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
// Hard refresh
await page.reload({ waitUntil: 'domcontentloaded' });
const state = await getThemeState(page);
expect(state.hasDarkClass).toBe(true);
expect(state.stored).toBe('dark');
});
test('Theme preference survives navigation and back button', async ({ page }) => {
await loadPageWithStoredTheme(page, 'dark');
await page.goto('/about', { waitUntil: 'domcontentloaded' });
expect((await getThemeState(page)).hasDarkClass).toBe(true);
await page.goBack({ waitUntil: 'domcontentloaded' });
expect((await getThemeState(page)).hasDarkClass).toBe(true);
});
test('New page context starts fresh (no cross-session bleed)', async ({ browser }) => {
// Session 1: set dark
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' });
await page1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
await context1.close();
// Session 2: fresh context - should not inherit session 1's preference
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await page2.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' });
const stored = await page2.evaluate(() => {
try { return localStorage.getItem('theme'); } catch { return null; }
});
// Fresh context should have no stored preference
expect(stored).toBeNull();
await context2.close();
});
test('localStorage persists through multiple tabs (same origin)', async ({ context }) => {
// Set dark in tab 1
const page1 = await context.newPage();
await page1.goto('/', { waitUntil: 'domcontentloaded' });
await page1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
// Open tab 2 — should pick up dark preference from localStorage
const page2 = await context.newPage();
await page2.goto('/', { waitUntil: 'domcontentloaded' });
const stored = await page2.evaluate(() => {
try { return localStorage.getItem('theme'); } catch { return null; }
});
expect(stored).toBe('dark');
await page1.close();
await page2.close();
});
});
// ============================================================
// 10. Visual Screenshots (dark & light across browsers)
// ============================================================
test.describe('Theme: Visual Snapshots', () => {
const SNAPSHOT_PAGES = [
{ name: 'home', url: '/' },
{ name: 'about', url: '/about' },
{ name: 'services', url: '/services' },
{ name: 'contact', url: '/contact' },
];
for (const p of SNAPSHOT_PAGES) {
test(`Dark mode screenshot: ${p.name}`, async ({ page, browserName }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await loadPageWithStoredTheme(page, 'dark');
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.screenshot({
path: `tests/screenshots/${browserName}-${p.name}-dark.png`,
fullPage: false,
clip: { x: 0, y: 0, width: 1280, height: 800 },
});
});
test(`Light mode screenshot: ${p.name}`, async ({ page, browserName }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await loadPageWithStoredTheme(page, 'light');
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.screenshot({
path: `tests/screenshots/${browserName}-${p.name}-light.png`,
fullPage: false,
clip: { x: 0, y: 0, width: 1280, height: 800 },
});
});
}
});