""" Visual regression tests using Playwright. Takes screenshots of key pages and verifies styling invariants (heading colors, backgrounds, nav layout, logo presence). Skipped by default (requires `playwright install chromium`). Run explicitly with: uv run pytest -m visual tests/test_visual.py -v Screenshots are saved to tests/screenshots/ for manual review. """ import re from pathlib import Path import pytest from playwright.sync_api import expect pytestmark = pytest.mark.visual SCREENSHOTS_DIR = Path(__file__).parent / "screenshots" SCREENSHOTS_DIR.mkdir(exist_ok=True) def parse_rgb(color_str): """Parse rgb(r,g,b) or rgba(r,g,b,a) into [r, g, b].""" nums = re.findall(r"[\d.]+", color_str) return [int(float(x)) for x in nums[:3]] @pytest.fixture def page(browser): """Desktop page with dark OS preference to catch theme leaks.""" pg = browser.new_page( viewport={"width": 1280, "height": 900}, color_scheme="dark", ) yield pg pg.close() # ── Landing page tests ────────────────────────────────────── def test_landing_light_background(live_server, page): """Verify the page has a light background, not dark.""" page.goto(live_server) page.wait_for_load_state("networkidle") bg_color = page.evaluate(""" (() => { const html_bg = getComputedStyle(document.documentElement).backgroundColor; const body_bg = getComputedStyle(document.body).backgroundColor; if (body_bg && !body_bg.includes('0, 0, 0, 0')) return body_bg; return html_bg; })() """) rgb = parse_rgb(bg_color) brightness = sum(rgb) / 3 assert brightness > 200, f"Background too dark: {bg_color} (brightness={brightness})" def test_landing_heading_colors(live_server, page): """Verify headings are readable (dark on light, white on dark hero).""" page.goto(live_server) page.wait_for_load_state("networkidle") # H1 is intentionally white (#fff) on the dark hero — verify it has a color set. h1_color = page.evaluate( "getComputedStyle(document.querySelector('h1')).color" ) rgb = parse_rgb(h1_color) assert rgb != [0, 0, 0] or sum(rgb) > 0, f"H1 appears unset: {h1_color}" # H2s on light sections should be dark; skip h2s inside dark containers. h2_data = page.evaluate(""" Array.from(document.querySelectorAll('h2')).map(el => { const inDark = el.closest('.hero-dark, .cta-card') !== null; return {color: getComputedStyle(el).color, inDark}; }) """) for i, item in enumerate(h2_data): if item["inDark"]: continue rgb = parse_rgb(item["color"]) brightness = sum(rgb) / 3 assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})" # H3s on light sections should be dark h3_data = page.evaluate(""" Array.from(document.querySelectorAll('h3')).map(el => { const inDark = el.closest('.hero-dark, .cta-card') !== null; return {color: getComputedStyle(el).color, inDark}; }) """) for i, item in enumerate(h3_data): if item["inDark"]: continue rgb = parse_rgb(item["color"]) brightness = sum(rgb) / 3 assert brightness < 150, f"H3[{i}] too light: {item['color']} (brightness={brightness})" def test_landing_logo_present(live_server, page): """Verify the nav logo link is visible.""" page.goto(live_server) page.wait_for_load_state("networkidle") logo = page.locator("nav a.nav-logo") expect(logo).to_be_visible() text = logo.inner_text() assert len(text) > 0, "Nav logo link has no text" def test_landing_nav_no_overlap(live_server, page): """Verify nav items don't overlap each other.""" page.goto(live_server) page.wait_for_load_state("networkidle") boxes = page.evaluate(""" (() => { const navDiv = document.querySelector('nav > div'); if (!navDiv) return []; const items = navDiv.children; return Array.from(items) .map(el => { const r = el.getBoundingClientRect(); return {top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width}; }) .filter(r => r.width > 0); })() """) for i in range(len(boxes) - 1): a, b = boxes[i], boxes[i + 1] h_overlap = a["right"] - b["left"] assert h_overlap < 10, ( f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px" ) def test_landing_cards_have_colored_borders(live_server, page): """Verify landing page cards have a visible left border accent.""" page.goto(live_server) page.wait_for_load_state("networkidle") border_widths = page.evaluate(""" Array.from(document.querySelectorAll('.card')).map( el => parseFloat(getComputedStyle(el).borderLeftWidth) ) """) assert len(border_widths) > 0, "No .card elements found" cards_with_accent = [w for w in border_widths if w >= 4] assert len(cards_with_accent) >= 6, ( f"Expected >=6 cards with 4px left border, got {len(cards_with_accent)}" ) def test_landing_logo_links_to_landing(live_server, page): """Verify nav-logo links to the landing page (language-prefixed or root).""" page.goto(live_server) page.wait_for_load_state("networkidle") href = page.locator("nav a.nav-logo").get_attribute("href") assert href == "/" or (href.startswith("/") and href.endswith("/")), ( f"Nav logo href unexpected: {href}" ) def test_landing_teaser_light_theme(live_server, page): """Verify the ROI calc card has a white/light background.""" page.goto(live_server) page.wait_for_load_state("networkidle") teaser_bg = page.evaluate( "getComputedStyle(document.querySelector('.roi-calc')).backgroundColor" ) rgb = parse_rgb(teaser_bg) brightness = sum(rgb) / 3 assert brightness > 240, f"ROI calc background too dark: {teaser_bg}" def test_landing_no_dark_remnants(live_server, page): """Check that no major elements have dark backgrounds.""" page.goto(live_server) page.wait_for_load_state("networkidle") dark_elements = page.evaluate(""" (() => { const dark = []; const allowedClasses = ['hero-dark', 'cta-card']; const els = document.querySelectorAll('article, section, header, footer, main, div'); for (const el of els) { const cls = el.className || ''; if (allowedClasses.some(c => cls.includes(c))) continue; const bg = getComputedStyle(el).backgroundColor; if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue; const m = bg.match(/\\d+/g); if (m) { const [r, g, b] = m.map(Number); const brightness = (r + g + b) / 3; if (brightness < 50) { dark.push({tag: el.tagName, class: cls, bg, brightness}); } } } return dark; })() """) assert len(dark_elements) == 0, ( f"Found {len(dark_elements)} unexpected dark-background elements: " f"{dark_elements[:3]}" ) # ── Screenshots (all pages in one pass) ───────────────────── def test_capture_screenshots(live_server, browser): """Save reference screenshots for manual review (desktop + mobile + auth).""" desktop = browser.new_page(viewport={"width": 1280, "height": 900}) mobile = browser.new_page(viewport={"width": 375, "height": 812}) # Landing desktop desktop.goto(live_server) desktop.wait_for_load_state("networkidle") desktop.screenshot(path=str(SCREENSHOTS_DIR / "landing_full.png"), full_page=True) # Auth pages desktop.goto(f"{live_server}/auth/login") desktop.wait_for_load_state("networkidle") desktop.screenshot(path=str(SCREENSHOTS_DIR / "login.png"), full_page=True) desktop.goto(f"{live_server}/auth/signup") desktop.wait_for_load_state("networkidle") desktop.screenshot(path=str(SCREENSHOTS_DIR / "signup.png"), full_page=True) # Landing mobile mobile.goto(live_server) mobile.wait_for_load_state("networkidle") mobile.screenshot(path=str(SCREENSHOTS_DIR / "landing_mobile.png"), full_page=True) desktop.close() mobile.close()