/** * Playwright E2E Tests for Organization Branding FOUC Prevention * * Tests that verify zero Flash of Unstyled Content (FOUC) during * organization branding application */ import { test, expect, type Page } from '@playwright/test'; test.describe('Organization Branding - FOUC Prevention', () => { test.beforeEach(async ({ page }) => { // Clear localStorage before each test to ensure clean state await page.goto('about:blank'); await page.evaluate(() => { localStorage.clear(); }); }); test('should apply cached branding before React hydration', async ({ page }) => { // First, prime the cache by visiting the app await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Verify cache was created const cacheExists = await page.evaluate(() => { return localStorage.getItem('bct_branding') !== null; }); expect(cacheExists).toBe(true); // Clear page and revisit to test cache application await page.goto('about:blank'); await page.goto('http://localhost:5173', { waitUntil: 'commit' }); // Check that CSS variables are applied immediately, before React loads const cssVarsApplied = await page.evaluate(() => { const root = document.documentElement; const accent = getComputedStyle(root).getPropertyValue('--color-accent'); const canvas = getComputedStyle(root).getPropertyValue('--color-bg-canvas'); const surface = getComputedStyle(root).getPropertyValue('--color-bg-surface'); return { accent: accent.trim(), canvas: canvas.trim(), surface: surface.trim(), hasAccent: accent.trim() !== '', hasCanvas: canvas.trim() !== '', hasSurface: surface.trim() !== '', }; }); expect(cssVarsApplied.hasAccent).toBe(true); expect(cssVarsApplied.hasCanvas).toBe(true); expect(cssVarsApplied.hasSurface).toBe(true); console.log('CSS Variables applied:', cssVarsApplied); }); test('should not show white flash during initial load', async ({ page }) => { // Set viewport to consistent size await page.setViewportSize({ width: 1280, height: 720 }); // Navigate and capture screenshots at different load stages const response = page.goto('http://localhost:5173', { waitUntil: 'commit' }); // Take screenshot immediately after HTML loads but before CSS/JS await page.waitForLoadState('domcontentloaded'); const domContentLoadedScreenshot = await page.screenshot({ clip: { x: 0, y: 0, width: 1280, height: 100 } // Just capture header area }); // Wait for full page load await response; await page.waitForLoadState('networkidle'); const fullyLoadedScreenshot = await page.screenshot({ clip: { x: 0, y: 0, width: 1280, height: 100 } }); // Take another screenshot after a brief delay to catch any flashes await page.waitForTimeout(200); const delayedScreenshot = await page.screenshot({ clip: { x: 0, y: 0, width: 1280, height: 100 } }); // Verify that the background isn't white at any point const hasWhiteFlash = await page.evaluate(() => { const canvas = document.documentElement.style.getPropertyValue('--color-bg-canvas') || getComputedStyle(document.documentElement).getPropertyValue('--color-bg-canvas'); const body = getComputedStyle(document.body).backgroundColor; return { canvasColor: canvas.trim(), bodyBg: body, isWhite: body === 'rgb(255, 255, 255)' || body === '#ffffff' || body === 'white' }; }); console.log('Background color check:', hasWhiteFlash); expect(hasWhiteFlash.isWhite).toBe(false); }); test('should maintain organization colors during navigation', async ({ page }) => { await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Get initial colors const initialColors = await page.evaluate(() => { const root = document.documentElement; return { accent: getComputedStyle(root).getPropertyValue('--color-accent').trim(), canvas: getComputedStyle(root).getPropertyValue('--color-bg-canvas').trim(), surface: getComputedStyle(root).getPropertyValue('--color-bg-surface').trim(), }; }); expect(initialColors.accent).toBeTruthy(); expect(initialColors.canvas).toBeTruthy(); expect(initialColors.surface).toBeTruthy(); // Navigate to different pages within the app const routes = ['/', '/events', '/dashboard', '/settings']; for (const route of routes) { await page.goto(`http://localhost:5173${route}`); await page.waitForLoadState('networkidle'); const currentColors = await page.evaluate(() => { const root = document.documentElement; return { accent: getComputedStyle(root).getPropertyValue('--color-accent').trim(), canvas: getComputedStyle(root).getPropertyValue('--color-bg-canvas').trim(), surface: getComputedStyle(root).getPropertyValue('--color-bg-surface').trim(), }; }); // Colors should remain consistent across pages expect(currentColors.accent).toBe(initialColors.accent); expect(currentColors.canvas).toBe(initialColors.canvas); expect(currentColors.surface).toBe(initialColors.surface); } }); test('should handle missing cache gracefully', async ({ page }) => { // Ensure no cache exists await page.goto('about:blank'); await page.evaluate(() => { localStorage.removeItem('bct_branding'); localStorage.removeItem('bct_last_org_id'); }); // Navigate to app await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Should still apply default theme without flash const themeApplied = await page.evaluate(() => { const root = document.documentElement; const accent = getComputedStyle(root).getPropertyValue('--color-accent').trim(); const canvas = getComputedStyle(root).getPropertyValue('--color-bg-canvas').trim(); return { accent, canvas, hasTheme: accent !== '' && canvas !== '', isDefaultTheme: accent === '#F0C457' || accent === 'rgb(240, 196, 87)' }; }); expect(themeApplied.hasTheme).toBe(true); console.log('Default theme applied:', themeApplied); }); test('should update favicon without visible delay', async ({ page }) => { await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Check if favicon was set (mock organization should have one) const favicon = await page.evaluate(() => { const faviconLink = document.querySelector('link[rel*="icon"]') as HTMLLinkElement; return { exists: !!faviconLink, href: faviconLink?.href || null, hasCustomFavicon: faviconLink?.href && !faviconLink.href.includes('vite.svg') }; }); console.log('Favicon state:', favicon); // For development, we might not have a custom favicon, but the system should handle it expect(favicon.exists).toBe(true); }); test('should show organization loading guard', async ({ page }) => { // Navigate to app and capture loading states await page.goto('http://localhost:5173'); // Look for loading indicators during organization resolution const loadingElements = await page.evaluate(() => { const spinners = document.querySelectorAll('.animate-spin').length; const loadingTexts = Array.from(document.querySelectorAll('*')).some( el => el.textContent?.includes('Loading Organization') ); return { spinners, hasLoadingText: loadingTexts, }; }); // Wait for full load await page.waitForLoadState('networkidle'); // After loading, check that organization is resolved const orgResolved = await page.evaluate(() => { // Check if header shows organization info const header = document.querySelector('header') || document.querySelector('[data-testid="header"]'); return { hasHeader: !!header, headerContent: header?.textContent || '', }; }); expect(orgResolved.hasHeader).toBe(true); console.log('Organization resolution:', orgResolved); }); test('should handle organization error states', async ({ page }) => { // Mock a network error for organization resolution await page.route('**/resolveDomain*', route => { route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal Server Error' }) }); }); await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Should still render with default theme const errorHandled = await page.evaluate(() => { const hasErrorUI = document.body.textContent?.includes('Configuration Error') || document.body.textContent?.includes('No organization'); const hasDefaultTheme = getComputedStyle(document.documentElement) .getPropertyValue('--color-accent').trim() !== ''; return { hasErrorUI, hasDefaultTheme, bodyText: document.body.textContent?.substring(0, 200) }; }); console.log('Error handling:', errorHandled); expect(errorHandled.hasDefaultTheme).toBe(true); }); test('should demonstrate theme switching (live preview)', async ({ page }) => { await page.goto('http://localhost:5173/admin/branding'); await page.waitForLoadState('networkidle'); // Look for branding settings interface const brandingUI = await page.evaluate(() => { const hasColorInputs = document.querySelectorAll('input[type="color"]').length > 0; const hasPreviewButton = Array.from(document.querySelectorAll('button')).some( btn => btn.textContent?.includes('Preview') ); return { hasColorInputs, hasPreviewButton, pageTitle: document.title, hasThemeControls: hasColorInputs || hasPreviewButton }; }); console.log('Branding settings UI:', brandingUI); // If branding UI is available, test live preview if (brandingUI.hasThemeControls) { // Get current accent color const currentAccent = await page.evaluate(() => { return getComputedStyle(document.documentElement) .getPropertyValue('--color-accent').trim(); }); // Try to change accent color (if UI allows) const colorInput = await page.$('input[type="color"]'); if (colorInput) { await colorInput.fill('#FF0000'); // Change to red // Check if preview mode updates colors const previewButton = await page.getByText('Preview', { exact: false }).first(); if (previewButton) { await previewButton.click(); await page.waitForTimeout(500); // Allow time for theme to apply const updatedAccent = await page.evaluate(() => { return getComputedStyle(document.documentElement) .getPropertyValue('--color-accent').trim(); }); console.log('Theme preview test:', { original: currentAccent, updated: updatedAccent, changed: currentAccent !== updatedAccent }); } } } }); }); test.describe('Performance Impact', () => { test('should not significantly impact initial page load time', async ({ page }) => { const startTime = Date.now(); await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); const loadTime = Date.now() - startTime; console.log(`Page load time: ${loadTime}ms`); // Load time should be reasonable (adjust threshold as needed) expect(loadTime).toBeLessThan(5000); // 5 seconds max }); test('should not cause layout shifts', async ({ page }) => { await page.goto('http://localhost:5173'); // Measure Cumulative Layout Shift (CLS) const cls = await page.evaluate(() => { return new Promise(resolve => { let clsValue = 0; new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.entryType === 'layout-shift') { clsValue += (entry as any).value; } } }).observe({ entryTypes: ['layout-shift'] }); setTimeout(() => resolve(clsValue), 2000); }); }); console.log(`Cumulative Layout Shift: ${cls}`); // CLS should be minimal (0.1 is considered good) expect(cls).toBeLessThan(0.1); }); }); test.describe('Accessibility', () => { test('should maintain WCAG contrast ratios with custom themes', async ({ page }) => { await page.goto('http://localhost:5173'); await page.waitForLoadState('networkidle'); // Check contrast ratios of key elements const contrastResults = await page.evaluate(() => { const getContrast = (element: Element) => { const styles = getComputedStyle(element); return { color: styles.color, backgroundColor: styles.backgroundColor, element: element.tagName }; }; // Check various text elements const elements = [ ...Array.from(document.querySelectorAll('h1, h2, h3')), ...Array.from(document.querySelectorAll('p, span')), ...Array.from(document.querySelectorAll('button')), ].slice(0, 10); // Limit to first 10 elements return elements.map(getContrast); }); console.log('Contrast analysis:', contrastResults); // Basic check - elements should have defined colors contrastResults.forEach(result => { expect(result.color).toBeTruthy(); }); }); });