Files
CompanySite/tests/theme-cross-browser.spec.ts
T
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

1099 lines
41 KiB
TypeScript

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 <head>', 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 <html>
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,
});
});
}
});