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
819 lines
32 KiB
TypeScript
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 },
|
|
});
|
|
});
|
|
}
|
|
});
|