""" 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 asyncio import multiprocessing import sqlite3 import tempfile import time from pathlib import Path from unittest.mock import AsyncMock, patch import pytest from padelnomics import core from padelnomics.app import create_app from padelnomics.migrations.migrate import migrate from playwright.sync_api import expect, sync_playwright 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].""" import re nums = re.findall(r"[\d.]+", color_str) return [int(float(x)) for x in nums[:3]] def _run_server(ready_event): """Run the Quart dev server in a separate process.""" import aiosqlite async def _serve(): # Build schema DDL by replaying migrations against a temp DB tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db") migrate(tmp_db) tmp_conn = sqlite3.connect(tmp_db) rows = tmp_conn.execute( "SELECT sql FROM sqlite_master" " WHERE sql IS NOT NULL" " AND name NOT LIKE 'sqlite_%'" " AND name NOT LIKE '%_fts_%'" " AND name != '_migrations'" " ORDER BY rowid" ).fetchall() tmp_conn.close() schema_ddl = ";\n".join(r[0] for r in rows) + ";" conn = await aiosqlite.connect(":memory:") conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await conn.executescript(schema_ddl) await conn.commit() core._db = conn with patch.object(core, "init_db", new_callable=AsyncMock), \ patch.object(core, "close_db", new_callable=AsyncMock): app = create_app() app.config["TESTING"] = True # Signal that the server is about to start ready_event.set() await app.run_task(host="127.0.0.1", port=5111) asyncio.run(_serve()) @pytest.fixture(scope="module") def live_server(): """Start a live Quart server on port 5111 for Playwright tests.""" ready = multiprocessing.Event() proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True) proc.start() ready.wait(timeout=10) # Give server a moment to bind time.sleep(1) yield "http://127.0.0.1:5111" proc.terminate() proc.join(timeout=5) @pytest.fixture(scope="module") def browser(): """Launch a headless Chromium browser.""" with sync_playwright() as p: b = p.chromium.launch(headless=True) yield b b.close() @pytest.fixture def page(browser): """Create a 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_screenshot(live_server, page): """Take a full-page screenshot of the landing page.""" page.goto(live_server) page.wait_for_load_state("networkidle") page.screenshot(path=str(SCREENSHOTS_DIR / "landing_full.png"), full_page=True) 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") # Tailwind sets background on body via base layer bg_color = page.evaluate(""" (() => { const html_bg = getComputedStyle(document.documentElement).backgroundColor; const body_bg = getComputedStyle(document.body).backgroundColor; // Use whichever is non-transparent 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 background — skip brightness check. # Instead verify it's not transparent/invisible (i.e. has some 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 # white-on-dark is intentional 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 # Allow up to 150 — catches near-white text while accepting readable # medium-gray secondary headings (e.g. slate #64748B ≈ brightness 118). 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 is a text inside an