refactor(tests): overhaul visual tests — single server, mock emails, fix init_db
- Consolidate 3 duplicate server processes into 1 session-scoped live_server fixture in conftest.py (port 5111, shared across all visual test modules). Reduces startup overhead from ~3× to 1×. - Fix init_db mock: patch padelnomics.app.init_db (where it's used) instead of core.init_db (where it's defined). The before_serving hook imported init_db locally — patching core alone didn't prevent the real init_db from replacing the in-memory test DB. - Keep patches active through app.run_task() so before_serving hooks can't replace the test DB during the server's lifetime. - Force RESEND_API_KEY="" in the visual test server subprocess to prevent real email sends (dev mode: prints to stdout, returns "dev"). - Remove 4 screenshot-only no-op tests, replace with single test_capture_screenshots that grabs all pages in one pass. - Fix test_planner_tab_switching: remove nonexistent "metrics" tab. - Delete ~200 lines of duplicated boilerplate from 3 test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,20 +10,11 @@ Run explicitly with:
|
||||
|
||||
Screenshots are saved to tests/screenshots/ for manual review.
|
||||
"""
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import re
|
||||
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
|
||||
from playwright.sync_api import expect
|
||||
|
||||
pytestmark = pytest.mark.visual
|
||||
|
||||
@@ -33,76 +24,13 @@ 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."""
|
||||
"""Desktop page with dark OS preference to catch theme leaks."""
|
||||
pg = browser.new_page(
|
||||
viewport={"width": 1280, "height": 900},
|
||||
color_scheme="dark",
|
||||
@@ -114,24 +42,15 @@ def page(browser):
|
||||
# ── 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;
|
||||
})()
|
||||
@@ -146,8 +65,7 @@ def test_landing_heading_colors(live_server, page):
|
||||
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 is intentionally white (#fff) on the dark hero — verify it has a color set.
|
||||
h1_color = page.evaluate(
|
||||
"getComputedStyle(document.querySelector('h1')).color"
|
||||
)
|
||||
@@ -163,7 +81,7 @@ def test_landing_heading_colors(live_server, page):
|
||||
""")
|
||||
for i, item in enumerate(h2_data):
|
||||
if item["inDark"]:
|
||||
continue # white-on-dark is intentional
|
||||
continue
|
||||
rgb = parse_rgb(item["color"])
|
||||
brightness = sum(rgb) / 3
|
||||
assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})"
|
||||
@@ -180,8 +98,6 @@ def test_landing_heading_colors(live_server, page):
|
||||
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})"
|
||||
|
||||
|
||||
@@ -190,7 +106,6 @@ def test_landing_logo_present(live_server, page):
|
||||
page.goto(live_server)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Logo is a text <span> inside an <a class="nav-logo">, not an <img>
|
||||
logo = page.locator("nav a.nav-logo")
|
||||
expect(logo).to_be_visible()
|
||||
|
||||
@@ -203,7 +118,6 @@ def test_landing_nav_no_overlap(live_server, page):
|
||||
page.goto(live_server)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Get bounding boxes of visible direct children in the nav inner div
|
||||
boxes = page.evaluate("""
|
||||
(() => {
|
||||
const navDiv = document.querySelector('nav > div');
|
||||
@@ -214,14 +128,12 @@ def test_landing_nav_no_overlap(live_server, page):
|
||||
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); // skip display:none / hidden elements
|
||||
.filter(r => r.width > 0);
|
||||
})()
|
||||
""")
|
||||
# Check no horizontal overlap between consecutive items
|
||||
for i in range(len(boxes) - 1):
|
||||
a, b = boxes[i], boxes[i + 1]
|
||||
h_overlap = a["right"] - b["left"]
|
||||
# Allow a few px of overlap from padding/margins, but not significant
|
||||
assert h_overlap < 10, (
|
||||
f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px"
|
||||
)
|
||||
@@ -250,7 +162,6 @@ def test_landing_logo_links_to_landing(live_server, page):
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
href = page.locator("nav a.nav-logo").get_attribute("href")
|
||||
# Accept "/" or any language-prefixed landing path, e.g. "/en/"
|
||||
assert href == "/" or (href.startswith("/") and href.endswith("/")), (
|
||||
f"Nav logo href unexpected: {href}"
|
||||
)
|
||||
@@ -261,7 +172,6 @@ def test_landing_teaser_light_theme(live_server, page):
|
||||
page.goto(live_server)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Was .teaser-calc; now .roi-calc (white card embedded in dark hero)
|
||||
teaser_bg = page.evaluate(
|
||||
"getComputedStyle(document.querySelector('.roi-calc')).backgroundColor"
|
||||
)
|
||||
@@ -270,36 +180,6 @@ def test_landing_teaser_light_theme(live_server, page):
|
||||
assert brightness > 240, f"ROI calc background too dark: {teaser_bg}"
|
||||
|
||||
|
||||
# ── Auth page tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_screenshot(live_server, page):
|
||||
"""Take a screenshot of the login page."""
|
||||
page.goto(f"{live_server}/auth/login")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.screenshot(path=str(SCREENSHOTS_DIR / "login.png"), full_page=True)
|
||||
|
||||
|
||||
def test_signup_screenshot(live_server, page):
|
||||
"""Take a screenshot of the signup page."""
|
||||
page.goto(f"{live_server}/auth/signup")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.screenshot(path=str(SCREENSHOTS_DIR / "signup.png"), full_page=True)
|
||||
|
||||
|
||||
# ── Mobile viewport tests ───────────────────────────────────
|
||||
|
||||
|
||||
def test_mobile_landing_screenshot(live_server, browser):
|
||||
"""Take a mobile-width screenshot of the landing page."""
|
||||
page = browser.new_page(viewport={"width": 375, "height": 812})
|
||||
page.goto(live_server)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.screenshot(path=str(SCREENSHOTS_DIR / "landing_mobile.png"), full_page=True)
|
||||
page.close()
|
||||
|
||||
|
||||
|
||||
def test_landing_no_dark_remnants(live_server, page):
|
||||
"""Check that no major elements have dark backgrounds."""
|
||||
page.goto(live_server)
|
||||
@@ -308,11 +188,9 @@ def test_landing_no_dark_remnants(live_server, page):
|
||||
dark_elements = page.evaluate("""
|
||||
(() => {
|
||||
const dark = [];
|
||||
// Known intentional dark sections on the landing page
|
||||
const allowedClasses = ['hero-dark', 'cta-card'];
|
||||
const els = document.querySelectorAll('article, section, header, footer, main, div');
|
||||
for (const el of els) {
|
||||
// Skip intentionally dark sections
|
||||
const cls = el.className || '';
|
||||
if (allowedClasses.some(c => cls.includes(c))) continue;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
@@ -333,3 +211,34 @@ def test_landing_no_dark_remnants(live_server, page):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user