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
1099 lines
41 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|
|
});
|