""" 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.app import create_app from padelnomics.migrations.migrate import migrate from playwright.sync_api import expect, sync_playwright from padelnomics import core 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