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:
Deeman
2026-02-23 18:40:11 +01:00
parent 5b6c4182f7
commit 777333e918
6 changed files with 148 additions and 349 deletions

View File

@@ -218,3 +218,86 @@ def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str
data = f"{ts}:{payload_bytes.decode()}".encode() data = f"{ts}:{payload_bytes.decode()}".encode()
h1 = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest() h1 = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
return f"ts={ts};h1={h1}" return f"ts={ts};h1={h1}"
# ── Visual test fixtures (Playwright) ────────────────────────
# Session-scoped: one server + one browser for all visual tests.
_VISUAL_PORT = 5111
def _run_visual_server(ready_event):
"""Run a Quart dev server in a subprocess for visual/E2E tests.
Forces RESEND_API_KEY="" so no real emails are sent.
Runs migrations in-process to build the full schema (including FTS tables).
"""
import asyncio
import os
os.environ["RESEND_API_KEY"] = ""
async def _serve():
# Build schema DDL in-process (FTS5 virtual tables need this)
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
# Patch init_db/close_db where they're USED (app.py imports them
# locally via `from .core import init_db` — patching core.init_db
# alone doesn't affect the local binding in app.py).
# Patches must stay active through run_task() because before_serving
# hooks call init_db() which would replace our in-memory DB.
with patch("padelnomics.app.init_db", new_callable=AsyncMock), \
patch("padelnomics.app.close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
ready_event.set()
await app.run_task(host="127.0.0.1", port=_VISUAL_PORT)
asyncio.run(_serve())
@pytest.fixture(scope="session")
def live_server():
"""Start a live Quart server on port 5111 for all visual/E2E tests."""
import multiprocessing
ready = multiprocessing.Event()
proc = multiprocessing.Process(
target=_run_visual_server, args=(ready,), daemon=True
)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield f"http://127.0.0.1:{_VISUAL_PORT}"
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="session")
def browser():
"""Launch a headless Chromium browser (once per test session)."""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB

After

Width:  |  Height:  |  Size: 722 KiB

View File

@@ -7,106 +7,21 @@ and cross-cutting checks (translations, footer, language switcher).
Skipped by default (requires `playwright install chromium`). Skipped by default (requires `playwright install chromium`).
Run explicitly with: Run explicitly with:
uv run pytest -m visual tests/test_e2e_flows.py -v uv run pytest -m visual tests/test_e2e_flows.py -v
Server runs on port 5113 (isolated from test_visual.py on 5111 and
test_quote_wizard.py on 5112).
""" """
import asyncio
import multiprocessing
import sqlite3
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.app import create_app from playwright.sync_api import expect
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
pytestmark = pytest.mark.visual pytestmark = pytest.mark.visual
PORT = 5113
BASE = f"http://127.0.0.1:{PORT}"
# =============================================================================
# Server / Browser Fixtures
# =============================================================================
def _run_server(ready_event):
"""Run the Quart dev server in a subprocess with in-memory SQLite."""
import aiosqlite
async def _serve():
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
ready_event.set()
await app.run_task(host="127.0.0.1", port=PORT)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield BASE
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture @pytest.fixture
def page(browser): def page(browser):
"""Wide desktop page for E2E tests (1440px for sidebar tests)."""
pg = browser.new_page(viewport={"width": 1440, "height": 900}) pg = browser.new_page(viewport={"width": 1440, "height": 900})
yield pg yield pg
pg.close() pg.close()
@pytest.fixture
def mobile_page(browser):
pg = browser.new_page(viewport={"width": 390, "height": 844})
yield pg
pg.close()
def dev_login(page, base, email="test@example.com"): def dev_login(page, base, email="test@example.com"):
"""Instantly authenticate via dev-login endpoint.""" """Instantly authenticate via dev-login endpoint."""
page.goto(f"{base}/auth/dev-login?email={email}") page.goto(f"{base}/auth/dev-login?email={email}")
@@ -141,7 +56,6 @@ def test_public_page_200(live_server, page, path):
"""Every public page should return 200 and contain meaningful content.""" """Every public page should return 200 and contain meaningful content."""
resp = page.goto(live_server + path) resp = page.goto(live_server + path)
assert resp.ok, f"{path} returned {resp.status}" assert resp.ok, f"{path} returned {resp.status}"
# Verify page has <h1> or <h2> — not a blank error page
heading = page.locator("h1, h2").first heading = page.locator("h1, h2").first
expect(heading).to_be_visible() expect(heading).to_be_visible()
@@ -171,9 +85,7 @@ def test_planner_en_loads(live_server, page):
"""Planner page renders wizard and tab bar.""" """Planner page renders wizard and tab bar."""
resp = page.goto(live_server + "/en/planner/") resp = page.goto(live_server + "/en/planner/")
assert resp.ok assert resp.ok
# Tab bar
expect(page.locator("#nav")).to_be_visible() expect(page.locator("#nav")).to_be_visible()
# Wizard form
expect(page.locator("#planner-form")).to_be_visible() expect(page.locator("#planner-form")).to_be_visible()
@@ -181,7 +93,6 @@ def test_planner_de_loads(live_server, page):
"""German planner renders with German UI strings.""" """German planner renders with German UI strings."""
resp = page.goto(live_server + "/de/planner/") resp = page.goto(live_server + "/de/planner/")
assert resp.ok assert resp.ok
# Should contain a German-language label somewhere
content = page.content() content = page.content()
assert "Investition" in content or "Annahmen" in content or "Anlage" in content assert "Investition" in content or "Annahmen" in content or "Anlage" in content
@@ -191,7 +102,6 @@ def test_planner_calculate_htmx(live_server, page):
page.goto(live_server + "/en/planner/") page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# #tab-content starts display:none; clicking a result tab makes it visible
capex_btn = page.locator("button[data-tab='capex'], [data-tab='capex']").first capex_btn = page.locator("button[data-tab='capex'], [data-tab='capex']").first
capex_btn.click() capex_btn.click()
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
@@ -206,20 +116,18 @@ def test_planner_tab_switching(live_server, page):
page.goto(live_server + "/en/planner/") page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
tabs = ["capex", "operating", "cashflow", "returns", "metrics"] tabs = ["capex", "operating", "cashflow", "returns"]
seen_contents = set() seen_contents = set()
for tab_id in tabs: for tab_id in tabs:
btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first
if btn.count() == 0: if btn.count() == 0:
# Try clicking by visible text
btn = page.get_by_role("button", name=tab_id, exact=False).first btn = page.get_by_role("button", name=tab_id, exact=False).first
btn.click() btn.click()
page.wait_for_timeout(600) page.wait_for_timeout(600)
html = page.locator("#tab-content").inner_html() html = page.locator("#tab-content").inner_html()
seen_contents.add(html[:100]) # first 100 chars as fingerprint seen_contents.add(html[:100])
# All tabs should render distinct content
assert len(seen_contents) >= 3, "Tab content didn't change across tabs" assert len(seen_contents) >= 3, "Tab content didn't change across tabs"
@@ -228,7 +136,6 @@ def test_planner_chart_data_present(live_server, page):
page.goto(live_server + "/en/planner/") page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Charts on the capex tab
chart_scripts = page.locator("script[type='application/json']") chart_scripts = page.locator("script[type='application/json']")
assert chart_scripts.count() >= 1, "No chart JSON script tags found" assert chart_scripts.count() >= 1, "No chart JSON script tags found"
@@ -241,11 +148,10 @@ def test_planner_quote_sidebar_visible_wide(live_server, browser):
sidebar = pg.locator(".quote-sidebar") sidebar = pg.locator(".quote-sidebar")
if sidebar.count() > 0: if sidebar.count() > 0:
# If present, should not be display:none
display = pg.evaluate( display = pg.evaluate(
"getComputedStyle(document.querySelector('.quote-sidebar')).display" "getComputedStyle(document.querySelector('.quote-sidebar')).display"
) )
assert display != "none", f"Quote sidebar is hidden on wide viewport: display={display}" assert display != "none", f"Quote sidebar hidden on wide viewport: display={display}"
pg.close() pg.close()
@@ -282,7 +188,7 @@ def test_authenticated_dashboard_loads(live_server, page):
def test_dashboard_quote_link_goes_to_wizard(live_server, page): def test_dashboard_quote_link_goes_to_wizard(live_server, page):
"""Dashboard 'request quote' button should link to the quote wizard, not suppliers page.""" """Dashboard 'request quote' button should link to the quote wizard."""
dev_login(page, live_server, "dashquote@example.com") dev_login(page, live_server, "dashquote@example.com")
href = page.locator("a", has_text="Quote").first.get_attribute("href") href = page.locator("a", has_text="Quote").first.get_attribute("href")
assert "/leads/quote" in href, f"Expected quote wizard link, got: {href}" assert "/leads/quote" in href, f"Expected quote wizard link, got: {href}"
@@ -318,7 +224,6 @@ def test_directory_de_loads(live_server, page):
resp = page.goto(live_server + "/de/directory/") resp = page.goto(live_server + "/de/directory/")
assert resp.ok assert resp.ok
content = page.content() content = page.content()
# Should have German-language UI (filter label, heading, etc.)
assert "Lieferanten" in content or "Anbieter" in content or "Kategorie" in content or resp.ok assert "Lieferanten" in content or "Anbieter" in content or "Kategorie" in content or resp.ok
@@ -333,7 +238,6 @@ def test_directory_search_htmx(live_server, page):
search.fill("padel") search.fill("padel")
page.wait_for_timeout(600) page.wait_for_timeout(600)
# Results container should exist
results = page.locator("#supplier-results, #results, [id*='result']").first results = page.locator("#supplier-results, #results, [id*='result']").first
expect(results).to_be_visible() expect(results).to_be_visible()
@@ -353,15 +257,12 @@ def test_quote_step1_de_loads(live_server, page):
resp = page.goto(live_server + "/de/leads/quote") resp = page.goto(live_server + "/de/leads/quote")
assert resp.ok assert resp.ok
content = page.content() content = page.content()
# Should have at least some German text
assert "Anlage" in content or "Platz" in content or "Projekt" in content or resp.ok assert "Anlage" in content or "Platz" in content or "Projekt" in content or resp.ok
def test_quote_verify_url_includes_lang(live_server, page): def test_quote_verify_url_includes_lang(live_server, page):
"""Verify the leads/verify route exists at /<lang>/leads/verify.""" """Verify the leads/verify route exists at /<lang>/leads/verify."""
# GET with no token should redirect to login or show error — but should NOT 404
resp = page.goto(live_server + "/en/leads/verify?token=invalid") resp = page.goto(live_server + "/en/leads/verify?token=invalid")
# Should be 200 (shows error) or a redirect — not 404
assert resp.status != 404, "Verify endpoint returned 404 — lang prefix missing?" assert resp.status != 404, "Verify endpoint returned 404 — lang prefix missing?"
@@ -392,8 +293,7 @@ def test_footer_present_on_public_pages(live_server, page):
"""Footer should be present on all public pages.""" """Footer should be present on all public pages."""
for path in ["/en/", "/en/features", "/en/about"]: for path in ["/en/", "/en/features", "/en/about"]:
page.goto(live_server + path) page.goto(live_server + path)
footer = page.locator("footer") expect(page.locator("footer")).to_be_visible()
expect(footer).to_be_visible()
def test_footer_has_four_column_layout(live_server, page): def test_footer_has_four_column_layout(live_server, page):
@@ -401,7 +301,6 @@ def test_footer_has_four_column_layout(live_server, page):
page.goto(live_server + "/en/") page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Count footer navigation columns (divs/sections with link lists)
footer_cols = page.evaluate(""" footer_cols = page.evaluate("""
(() => { (() => {
const footer = document.querySelector('footer'); const footer = document.querySelector('footer');
@@ -419,7 +318,6 @@ def test_language_switcher_en_to_de(live_server, page):
page.goto(live_server + "/en/") page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Find the footer language switcher link to /de/
de_link = page.locator("a[href='/de/']").first de_link = page.locator("a[href='/de/']").first
if de_link.count() == 0: if de_link.count() == 0:
pytest.skip("No DE language switcher link found") pytest.skip("No DE language switcher link found")
@@ -427,18 +325,16 @@ def test_language_switcher_en_to_de(live_server, page):
href = de_link.get_attribute("href") href = de_link.get_attribute("href")
assert href and "/de" in href, f"DE link has unexpected href: {href}" assert href and "/de" in href, f"DE link has unexpected href: {href}"
# Navigate directly to verify the German page loads
page.goto(live_server + "/de/") page.goto(live_server + "/de/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
assert "/de/" in page.url, f"Language switch failed: {page.url}" assert "/de/" in page.url, f"Language switch failed: {page.url}"
def test_no_missing_translations_en(live_server, page): def test_no_missing_translations_en(live_server, page):
"""EN pages should not contain 'None' or 'undefined' translation markers.""" """EN pages should not contain untranslated markers."""
for path in ["/en/", "/en/features", "/en/about"]: for path in ["/en/", "/en/features", "/en/about"]:
page.goto(live_server + path) page.goto(live_server + path)
content = page.locator("body").inner_text() content = page.locator("body").inner_text()
# Check for obvious translation failure markers
assert "t.auth_" not in content, f"Untranslated key in {path}" assert "t.auth_" not in content, f"Untranslated key in {path}"
assert "{{" not in content, f"Jinja template not rendered in {path}" assert "{{" not in content, f"Jinja template not rendered in {path}"
@@ -459,7 +355,6 @@ def test_auth_login_page_german(live_server, page):
}]) }])
page.goto(live_server + "/auth/login") page.goto(live_server + "/auth/login")
content = page.content() content = page.content()
# German auth page should contain German text
assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content
@@ -472,16 +367,15 @@ def test_404_for_nonexistent_page(live_server, page):
def test_legacy_redirect_terms(live_server, page): def test_legacy_redirect_terms(live_server, page):
"""Legacy /terms should redirect to /en/terms.""" """Legacy /terms should redirect to /en/terms."""
resp = page.goto(live_server + "/terms") resp = page.goto(live_server + "/terms")
# Either 301/302 redirect or 200 at /en/terms
assert resp.ok or resp.status in (301, 302) assert resp.ok or resp.status in (301, 302)
# ============================================================================= # =============================================================================
# H. Markets Waitlist (WAITLIST_MODE=False by default — page should load) # H. Markets
# ============================================================================= # =============================================================================
def test_markets_hub_loads(live_server, page): def test_markets_hub_loads(live_server, page):
"""Markets hub should load normally when WAITLIST_MODE is off.""" """Markets hub should load normally."""
resp = page.goto(live_server + "/en/markets") resp = page.goto(live_server + "/en/markets")
assert resp.ok assert resp.ok
expect(page.locator("h1, h2").first).to_be_visible() expect(page.locator("h1, h2").first).to_be_visible()
@@ -494,7 +388,7 @@ def test_markets_results_partial_loads(live_server, page):
# ============================================================================= # =============================================================================
# I. Tooltip Presence (result tab tooltips) # I. Tooltip Presence
# ============================================================================= # =============================================================================
def test_planner_tooltips_present(live_server, page): def test_planner_tooltips_present(live_server, page):
@@ -502,12 +396,10 @@ def test_planner_tooltips_present(live_server, page):
page.goto(live_server + "/en/planner/") page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# The returns tab should have tooltip spans
returns_btn = page.locator("button[data-tab='returns'], [data-tab='returns']").first returns_btn = page.locator("button[data-tab='returns'], [data-tab='returns']").first
if returns_btn.count() > 0: if returns_btn.count() > 0:
returns_btn.click() returns_btn.click()
page.wait_for_timeout(600) page.wait_for_timeout(600)
# After clicking returns tab, look for tooltip info spans
ti_spans = page.locator(".ti") ti_spans = page.locator(".ti")
assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab" assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab"

View File

@@ -7,20 +7,10 @@ and validation error handling.
Run explicitly with: Run explicitly with:
uv run pytest -m visual tests/test_quote_wizard.py -v uv run pytest -m visual tests/test_quote_wizard.py -v
""" """
import asyncio
import multiprocessing
import sqlite3
import tempfile
import time
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.app import create_app from playwright.sync_api import expect
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
pytestmark = pytest.mark.visual pytestmark = pytest.mark.visual
@@ -28,69 +18,9 @@ SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
SCREENSHOTS_DIR.mkdir(exist_ok=True) SCREENSHOTS_DIR.mkdir(exist_ok=True)
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
ready_event.set()
await app.run_task(host="127.0.0.1", port=5112)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5112 for quote wizard tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield "http://127.0.0.1:5112"
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 @pytest.fixture
def page(browser): def page(browser):
"""Create a page for the quote wizard tests.""" """Desktop page for quote wizard tests."""
pg = browser.new_page(viewport={"width": 1280, "height": 900}) pg = browser.new_page(viewport={"width": 1280, "height": 900})
yield pg yield pg
pg.close() pg.close()
@@ -102,38 +32,31 @@ def _check_radio(page, name, value):
def _fill_step_1(page): def _fill_step_1(page):
"""Fill step 1: facility type = indoor."""
_check_radio(page, "facility_type", "indoor") _check_radio(page, "facility_type", "indoor")
def _fill_step_2(page): def _fill_step_2(page):
"""Fill step 2: country = Germany."""
page.select_option("select[name='country']", "DE") page.select_option("select[name='country']", "DE")
def _fill_step_5(page): def _fill_step_5(page):
"""Fill step 5: timeline = 3-6mo."""
_check_radio(page, "timeline", "3-6mo") _check_radio(page, "timeline", "3-6mo")
def _fill_step_6(page): def _fill_step_6(page):
"""Fill step 6: financing status + decision process (both required)."""
_check_radio(page, "financing_status", "self_funded") _check_radio(page, "financing_status", "self_funded")
_check_radio(page, "decision_process", "solo") _check_radio(page, "decision_process", "solo")
def _fill_step_7(page): def _fill_step_7(page):
"""Fill step 7: stakeholder type = entrepreneur."""
_check_radio(page, "stakeholder_type", "entrepreneur") _check_radio(page, "stakeholder_type", "entrepreneur")
def _fill_step_8(page): def _fill_step_8(page):
"""Fill step 8: select at least one service (required, checkbox pill)."""
_check_radio(page, "services_needed", "installation") _check_radio(page, "services_needed", "installation")
def _fill_step_9(page): def _fill_step_9(page):
"""Fill step 9: contact details (name, email, phone, consent)."""
page.fill("input[name='contact_name']", "Test User") page.fill("input[name='contact_name']", "Test User")
page.fill("input[name='contact_email']", "test@example.com") page.fill("input[name='contact_email']", "test@example.com")
page.fill("input[name='contact_phone']", "+49 123 456789") page.fill("input[name='contact_phone']", "+49 123 456789")
@@ -141,13 +64,11 @@ def _fill_step_9(page):
def _click_next(page): def _click_next(page):
"""Click the Next button and wait for HTMX swap."""
page.locator("button.q-btn-next").click() page.locator("button.q-btn-next").click()
page.wait_for_timeout(500) page.wait_for_timeout(500)
def _click_back(page): def _click_back(page):
"""Click the Back button and wait for HTMX swap."""
page.locator("button.q-btn-back").click() page.locator("button.q-btn-back").click()
page.wait_for_timeout(500) page.wait_for_timeout(500)
@@ -167,7 +88,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_2(page) _fill_step_2(page)
_click_next(page) _click_next(page)
# Step 3: Build Context (optional — just click next) # Step 3: Build Context (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context") expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
_click_next(page) _click_next(page)
@@ -180,7 +101,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_5(page) _fill_step_5(page)
_click_next(page) _click_next(page)
# Step 6: Financing (has required fields) # Step 6: Financing
expect(page.locator("h2.q-step-title")).to_contain_text("Financing") expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
_fill_step_6(page) _fill_step_6(page)
_click_next(page) _click_next(page)
@@ -190,7 +111,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_7(page) _fill_step_7(page)
_click_next(page) _click_next(page)
# Step 8: Services Needed (at least one required) # Step 8: Services Needed
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed") expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
_fill_step_8(page) _fill_step_8(page)
_click_next(page) _click_next(page)
@@ -199,14 +120,16 @@ def test_quote_wizard_full_flow(live_server, page):
expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details") expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details")
_fill_step_9(page) _fill_step_9(page)
# Submit the form # Submit
page.locator("button.q-btn-submit").click() page.locator("button.q-btn-submit").click()
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Should see either success page or verification sent page (both acceptable)
body_text = page.locator("body").inner_text() body_text = page.locator("body").inner_text()
assert "matched" in body_text.lower() or "check your email" in body_text.lower() or "verify" in body_text.lower(), \ assert (
f"Expected success or verification page, got: {body_text[:200]}" "matched" in body_text.lower()
or "check your email" in body_text.lower()
or "verify" in body_text.lower()
), f"Expected success or verification page, got: {body_text[:200]}"
page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True) page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True)
@@ -216,29 +139,24 @@ def test_quote_wizard_back_navigation(live_server, page):
page.goto(f"{live_server}/en/leads/quote") page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Step 1: select Indoor
_fill_step_1(page) _fill_step_1(page)
_click_next(page) _click_next(page)
# Step 2: select Germany
_fill_step_2(page) _fill_step_2(page)
page.fill("input[name='city']", "Berlin") page.fill("input[name='city']", "Berlin")
_click_next(page) _click_next(page)
# Step 3: select a build context (radio is display:none, click label instead)
_check_radio(page, "build_context", "new_standalone") _check_radio(page, "build_context", "new_standalone")
_click_next(page) _click_next(page)
# Step 4: now go back to step 3 # Step 4: go back to step 3
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase") expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_back(page) _click_back(page)
# Verify we're on step 3 and build_context is preserved
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context") expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked() checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked()
assert checked, "Build context 'new_standalone' should still be checked after going back" assert checked, "Build context 'new_standalone' should still be checked after going back"
# Go back to step 2 and verify data preserved
_click_back(page) _click_back(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location") expect(page.locator("h2.q-step-title")).to_contain_text("Location")
@@ -254,22 +172,19 @@ def test_quote_wizard_validation_errors(live_server, page):
page.goto(f"{live_server}/en/leads/quote") page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Step 1: DON'T select facility_type, just click Next
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project") expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_click_next(page) _click_next(page)
# Should still be on step 1 with an error hint # Should still be on step 1 with error hint
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project") expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
error_hint = page.locator(".q-error-hint") expect(page.locator(".q-error-hint")).to_be_visible()
expect(error_hint).to_be_visible()
# Now fill the field and proceed — should work # Fix and proceed
_fill_step_1(page) _fill_step_1(page)
_click_next(page) _click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location") expect(page.locator("h2.q-step-title")).to_contain_text("Location")
# Skip country (required on step 2) — should stay on step 2 # Skip country (required)
_click_next(page) _click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location") expect(page.locator("h2.q-step-title")).to_contain_text("Location")
error_hint = page.locator(".q-error-hint") expect(page.locator(".q-error-hint")).to_be_visible()
expect(error_hint).to_be_visible()

View File

@@ -10,20 +10,11 @@ Run explicitly with:
Screenshots are saved to tests/screenshots/ for manual review. Screenshots are saved to tests/screenshots/ for manual review.
""" """
import asyncio import re
import multiprocessing
import sqlite3
import tempfile
import time
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.app import create_app from playwright.sync_api import expect
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
pytestmark = pytest.mark.visual pytestmark = pytest.mark.visual
@@ -33,76 +24,13 @@ SCREENSHOTS_DIR.mkdir(exist_ok=True)
def parse_rgb(color_str): def parse_rgb(color_str):
"""Parse rgb(r,g,b) or rgba(r,g,b,a) into [r, g, b].""" """Parse rgb(r,g,b) or rgba(r,g,b,a) into [r, g, b]."""
import re
nums = re.findall(r"[\d.]+", color_str) nums = re.findall(r"[\d.]+", color_str)
return [int(float(x)) for x in nums[:3]] 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 @pytest.fixture
def page(browser): 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( pg = browser.new_page(
viewport={"width": 1280, "height": 900}, viewport={"width": 1280, "height": 900},
color_scheme="dark", color_scheme="dark",
@@ -114,24 +42,15 @@ def page(browser):
# ── Landing page tests ────────────────────────────────────── # ── 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): def test_landing_light_background(live_server, page):
"""Verify the page has a light background, not dark.""" """Verify the page has a light background, not dark."""
page.goto(live_server) page.goto(live_server)
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Tailwind sets background on body via base layer
bg_color = page.evaluate(""" bg_color = page.evaluate("""
(() => { (() => {
const html_bg = getComputedStyle(document.documentElement).backgroundColor; const html_bg = getComputedStyle(document.documentElement).backgroundColor;
const body_bg = getComputedStyle(document.body).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; if (body_bg && !body_bg.includes('0, 0, 0, 0')) return body_bg;
return html_bg; return html_bg;
})() })()
@@ -146,8 +65,7 @@ def test_landing_heading_colors(live_server, page):
page.goto(live_server) page.goto(live_server)
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# H1 is intentionally white (#fff) on the dark hero background — skip brightness check. # H1 is intentionally white (#fff) on the dark hero — verify it has a color set.
# Instead verify it's not transparent/invisible (i.e. has some color set).
h1_color = page.evaluate( h1_color = page.evaluate(
"getComputedStyle(document.querySelector('h1')).color" "getComputedStyle(document.querySelector('h1')).color"
) )
@@ -163,7 +81,7 @@ def test_landing_heading_colors(live_server, page):
""") """)
for i, item in enumerate(h2_data): for i, item in enumerate(h2_data):
if item["inDark"]: if item["inDark"]:
continue # white-on-dark is intentional continue
rgb = parse_rgb(item["color"]) rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3 brightness = sum(rgb) / 3
assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})" 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 continue
rgb = parse_rgb(item["color"]) rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3 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})" 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.goto(live_server)
page.wait_for_load_state("networkidle") 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") logo = page.locator("nav a.nav-logo")
expect(logo).to_be_visible() expect(logo).to_be_visible()
@@ -203,7 +118,6 @@ def test_landing_nav_no_overlap(live_server, page):
page.goto(live_server) page.goto(live_server)
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Get bounding boxes of visible direct children in the nav inner div
boxes = page.evaluate(""" boxes = page.evaluate("""
(() => { (() => {
const navDiv = document.querySelector('nav > div'); const navDiv = document.querySelector('nav > div');
@@ -214,14 +128,12 @@ def test_landing_nav_no_overlap(live_server, page):
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width}; 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): for i in range(len(boxes) - 1):
a, b = boxes[i], boxes[i + 1] a, b = boxes[i], boxes[i + 1]
h_overlap = a["right"] - b["left"] h_overlap = a["right"] - b["left"]
# Allow a few px of overlap from padding/margins, but not significant
assert h_overlap < 10, ( assert h_overlap < 10, (
f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px" 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") page.wait_for_load_state("networkidle")
href = page.locator("nav a.nav-logo").get_attribute("href") 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("/")), ( assert href == "/" or (href.startswith("/") and href.endswith("/")), (
f"Nav logo href unexpected: {href}" f"Nav logo href unexpected: {href}"
) )
@@ -261,7 +172,6 @@ def test_landing_teaser_light_theme(live_server, page):
page.goto(live_server) page.goto(live_server)
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# Was .teaser-calc; now .roi-calc (white card embedded in dark hero)
teaser_bg = page.evaluate( teaser_bg = page.evaluate(
"getComputedStyle(document.querySelector('.roi-calc')).backgroundColor" "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}" 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): def test_landing_no_dark_remnants(live_server, page):
"""Check that no major elements have dark backgrounds.""" """Check that no major elements have dark backgrounds."""
page.goto(live_server) page.goto(live_server)
@@ -308,11 +188,9 @@ def test_landing_no_dark_remnants(live_server, page):
dark_elements = page.evaluate(""" dark_elements = page.evaluate("""
(() => { (() => {
const dark = []; const dark = [];
// Known intentional dark sections on the landing page
const allowedClasses = ['hero-dark', 'cta-card']; const allowedClasses = ['hero-dark', 'cta-card'];
const els = document.querySelectorAll('article, section, header, footer, main, div'); const els = document.querySelectorAll('article, section, header, footer, main, div');
for (const el of els) { for (const el of els) {
// Skip intentionally dark sections
const cls = el.className || ''; const cls = el.className || '';
if (allowedClasses.some(c => cls.includes(c))) continue; if (allowedClasses.some(c => cls.includes(c))) continue;
const bg = getComputedStyle(el).backgroundColor; 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"Found {len(dark_elements)} unexpected dark-background elements: "
f"{dark_elements[:3]}" 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()