Merge branch 'worktree-visual-test-overhaul'

# Conflicts:
#	web/tests/test_e2e_flows.py
This commit is contained in:
Deeman
2026-02-23 18:50:09 +01:00
6 changed files with 149 additions and 402 deletions

View File

@@ -237,3 +237,86 @@ def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str
data = f"{ts}:{payload_bytes.decode()}".encode()
h1 = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
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

@@ -2,154 +2,26 @@
Comprehensive E2E flow tests using Playwright.
Covers all major user flows: public pages, planner, auth, directory, quote wizard,
and cross-cutting checks (translations, footer, language switcher).
billing, supplier dashboard, and cross-cutting checks (translations, footer, language switcher).
Skipped by default (requires `playwright install chromium`).
Run explicitly with:
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
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
PORT = 5113
BASE = f"http://127.0.0.1:{PORT}"
# =============================================================================
# Server / Browser Fixtures
# =============================================================================
async def _seed_billing_data(conn):
"""Seed a supplier + feature flags for e2e billing tests."""
# Create user for supplier dashboard
await conn.execute(
"INSERT INTO users (id, email, created_at)"
" VALUES (999, 'supplier@test.com', datetime('now'))"
)
# Create pro-tier supplier claimed by that user
await conn.execute(
"INSERT INTO suppliers"
" (id, name, slug, tier, claimed_by, claimed_at,"
" country_code, region, category, credit_balance,"
" monthly_credits, contact_name, contact_email, created_at)"
" VALUES (1, 'Test Supplier GmbH', 'test-supplier', 'pro', 999,"
" datetime('now'), 'DE', 'Europe', 'construction', 50,"
" 10, 'Test User', 'supplier@test.com', datetime('now'))"
)
# Enable feature flags needed for billing/signup/export tests
for flag in ("supplier_signup", "markets", "payments",
"planner_export", "lead_unlock"):
await conn.execute(
"INSERT OR REPLACE INTO feature_flags (name, enabled)"
" VALUES (?, 1)", (flag,)
)
await conn.commit()
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)
# Safety: ensure feature_flags table exists (may be missed by
# executescript if an earlier DDL statement involving FTS5 fails)
await conn.execute(
"CREATE TABLE IF NOT EXISTS feature_flags"
" (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0,"
" description TEXT,"
" updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')))"
)
await _seed_billing_data(conn)
core._db = conn
# Never send real emails from the e2e server
core.config.RESEND_API_KEY = ""
# Patch init_db/close_db at the *app* module level (where before_serving
# imports them via `from .core import init_db`). Patching core.init_db
# alone won't affect the already-bound reference in app.py's globals.
with patch("padelnomics.app.init_db", new_callable=AsyncMock), \
patch("padelnomics.app.close_db", new_callable=AsyncMock), \
patch("padelnomics.app.open_analytics_db"), \
patch("padelnomics.app.close_analytics_db"):
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
def page(browser):
"""Wide desktop page for E2E tests (1440px for sidebar tests)."""
pg = browser.new_page(viewport={"width": 1440, "height": 900})
yield pg
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"):
"""Instantly authenticate via dev-login endpoint."""
page.goto(f"{base}/auth/dev-login?email={email}")
@@ -184,7 +56,6 @@ def test_public_page_200(live_server, page, path):
"""Every public page should return 200 and contain meaningful content."""
resp = page.goto(live_server + path)
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
expect(heading).to_be_visible()
@@ -214,9 +85,7 @@ def test_planner_en_loads(live_server, page):
"""Planner page renders wizard and tab bar."""
resp = page.goto(live_server + "/en/planner/")
assert resp.ok
# Tab bar
expect(page.locator("#nav")).to_be_visible()
# Wizard form
expect(page.locator("#planner-form")).to_be_visible()
@@ -224,7 +93,6 @@ def test_planner_de_loads(live_server, page):
"""German planner renders with German UI strings."""
resp = page.goto(live_server + "/de/planner/")
assert resp.ok
# Should contain a German-language label somewhere
content = page.content()
assert "Investition" in content or "Annahmen" in content or "Anlage" in content
@@ -234,7 +102,6 @@ def test_planner_calculate_htmx(live_server, page):
page.goto(live_server + "/en/planner/")
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.click()
page.wait_for_timeout(1000)
@@ -249,20 +116,18 @@ def test_planner_tab_switching(live_server, page):
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
tabs = ["capex", "operating", "cashflow", "returns", "metrics"]
tabs = ["capex", "operating", "cashflow", "returns"]
seen_contents = set()
for tab_id in tabs:
btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first
if btn.count() == 0:
# Try clicking by visible text
btn = page.get_by_role("button", name=tab_id, exact=False).first
btn.click()
page.wait_for_timeout(600)
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"
@@ -271,7 +136,6 @@ def test_planner_chart_data_present(live_server, page):
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
# Charts on the capex tab
chart_scripts = page.locator("script[type='application/json']")
assert chart_scripts.count() >= 1, "No chart JSON script tags found"
@@ -284,11 +148,10 @@ def test_planner_quote_sidebar_visible_wide(live_server, browser):
sidebar = pg.locator(".quote-sidebar")
if sidebar.count() > 0:
# If present, should not be display:none
display = pg.evaluate(
"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()
@@ -325,7 +188,7 @@ def test_authenticated_dashboard_loads(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")
href = page.locator("a", has_text="Quote").first.get_attribute("href")
assert "/leads/quote" in href, f"Expected quote wizard link, got: {href}"
@@ -361,7 +224,6 @@ def test_directory_de_loads(live_server, page):
resp = page.goto(live_server + "/de/directory/")
assert resp.ok
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
@@ -376,7 +238,6 @@ def test_directory_search_htmx(live_server, page):
search.fill("padel")
page.wait_for_timeout(600)
# Results container should exist
results = page.locator("#supplier-results, #results, [id*='result']").first
expect(results).to_be_visible()
@@ -396,15 +257,12 @@ def test_quote_step1_de_loads(live_server, page):
resp = page.goto(live_server + "/de/leads/quote")
assert resp.ok
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
def test_quote_verify_url_includes_lang(live_server, page):
"""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")
# Should be 200 (shows error) or a redirect — not 404
assert resp.status != 404, "Verify endpoint returned 404 — lang prefix missing?"
@@ -435,8 +293,7 @@ def test_footer_present_on_public_pages(live_server, page):
"""Footer should be present on all public pages."""
for path in ["/en/", "/en/features", "/en/about"]:
page.goto(live_server + path)
footer = page.locator("footer")
expect(footer).to_be_visible()
expect(page.locator("footer")).to_be_visible()
def test_footer_has_four_column_layout(live_server, page):
@@ -444,7 +301,6 @@ def test_footer_has_four_column_layout(live_server, page):
page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle")
# Count footer navigation columns (divs/sections with link lists)
footer_cols = page.evaluate("""
(() => {
const footer = document.querySelector('footer');
@@ -462,7 +318,6 @@ def test_language_switcher_en_to_de(live_server, page):
page.goto(live_server + "/en/")
page.wait_for_load_state("networkidle")
# Find the footer language switcher link to /de/
de_link = page.locator("a[href='/de/']").first
if de_link.count() == 0:
pytest.skip("No DE language switcher link found")
@@ -470,18 +325,16 @@ def test_language_switcher_en_to_de(live_server, page):
href = de_link.get_attribute("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.wait_for_load_state("networkidle")
assert "/de/" in page.url, f"Language switch failed: {page.url}"
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"]:
page.goto(live_server + path)
content = page.locator("body").inner_text()
# Check for obvious translation failure markers
assert "t.auth_" not in content, f"Untranslated key in {path}"
assert "{{" not in content, f"Jinja template not rendered in {path}"
@@ -502,7 +355,6 @@ def test_auth_login_page_german(live_server, page):
}])
page.goto(live_server + "/auth/login")
content = page.content()
# German auth page should contain German text
assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content
@@ -515,16 +367,15 @@ def test_404_for_nonexistent_page(live_server, page):
def test_legacy_redirect_terms(live_server, page):
"""Legacy /terms should redirect to /en/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)
# =============================================================================
# H. Markets Waitlist (WAITLIST_MODE=False by default — page should load)
# H. Markets
# =============================================================================
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")
assert resp.ok
expect(page.locator("h1, h2").first).to_be_visible()
@@ -537,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):
@@ -545,13 +396,11 @@ def test_planner_tooltips_present(live_server, page):
page.goto(live_server + "/en/planner/")
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
if returns_btn.count() > 0:
returns_btn.click()
page.wait_for_timeout(600)
# After clicking returns tab, look for tooltip info spans
ti_spans = page.locator(".ti")
assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab"
@@ -584,7 +433,6 @@ def test_pricing_page_has_plan_cards(live_server, page):
page.wait_for_load_state("networkidle")
cards = page.locator(".card-header")
assert cards.count() >= 2, f"Expected >=2 plan cards, found {cards.count()}"
# Each card should have a CTA button/link
ctas = page.locator("a.btn, a.btn-outline")
assert ctas.count() >= 2, f"Expected >=2 CTAs, found {ctas.count()}"
@@ -593,7 +441,6 @@ def test_pricing_page_no_auth_required(live_server, page):
"""Pricing page is accessible without authentication."""
resp = page.goto(live_server + "/billing/pricing")
assert resp.ok
# Should NOT have redirected to login
assert "login" not in page.url and "auth" not in page.url
@@ -615,7 +462,6 @@ def test_checkout_success_renders_for_authed_user(live_server, page):
resp = page.goto(live_server + "/billing/success")
assert resp.ok
expect(page.locator("h1").first).to_be_visible()
# Should have a CTA button (back to dashboard or similar)
expect(page.locator("a.btn").first).to_be_visible()
@@ -627,7 +473,6 @@ def test_supplier_signup_step1_loads(live_server, page):
"""Supplier signup wizard step 1 renders."""
resp = page.goto(live_server + "/en/suppliers/signup")
assert resp.ok, f"/en/suppliers/signup returned {resp.status}"
# Wait for HTMX to load step content
page.wait_for_load_state("networkidle")
expect(page.locator("[data-step='1']").first).to_be_visible()
@@ -640,7 +485,6 @@ def test_supplier_signup_step1_has_plan_cards(live_server, page):
assert plan_cards.count() >= 2, (
f"Expected >=2 plan cards, found {plan_cards.count()}"
)
# Should have a next button
expect(page.locator(".s-btn-next").first).to_be_visible()
@@ -669,7 +513,6 @@ def test_supplier_dashboard_loads(live_server, page):
resp = page.goto(live_server + "/en/suppliers/dashboard")
assert resp.ok, f"dashboard returned {resp.status}"
expect(page.locator(".dash").first).to_be_visible()
# Sidebar should show supplier name
expect(page.locator(".dash-sidebar__name").first).to_be_visible()
@@ -678,7 +521,6 @@ def test_supplier_dashboard_overview_tab(live_server, page):
dev_login(page, live_server, "supplier@test.com")
page.goto(live_server + "/en/suppliers/dashboard")
page.wait_for_load_state("networkidle")
# Wait for HTMX to load overview content
page.wait_for_timeout(1000)
stats = page.locator(".ov-stat")
assert stats.count() >= 2, f"Expected >=2 stat cards, found {stats.count()}"
@@ -697,7 +539,6 @@ def test_supplier_dashboard_boosts_has_credit_packs(live_server, page):
page.goto(live_server + "/en/suppliers/dashboard/boosts")
page.wait_for_load_state("networkidle")
content = page.content()
# Credit pack amounts should be visible (25, 50, 100, 250)
assert "25" in content or "50" in content or "100" in content, (
"No credit pack amounts found on boosts page"
)
@@ -735,6 +576,5 @@ def test_export_page_loads_for_authed_user(live_server, page):
resp = page.goto(live_server + "/en/planner/export")
assert resp.ok, f"export page returned {resp.status}"
expect(page.locator("h1").first).to_be_visible()
# Should have the export form with scenario select and buy button
expect(page.locator("#export-form").first).to_be_visible()
expect(page.locator("#export-buy-btn").first).to_be_visible()

View File

@@ -7,20 +7,10 @@ and validation error handling.
Run explicitly with:
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 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
@@ -28,69 +18,9 @@ SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
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
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})
yield pg
pg.close()
@@ -102,38 +32,31 @@ def _check_radio(page, name, value):
def _fill_step_1(page):
"""Fill step 1: facility type = indoor."""
_check_radio(page, "facility_type", "indoor")
def _fill_step_2(page):
"""Fill step 2: country = Germany."""
page.select_option("select[name='country']", "DE")
def _fill_step_5(page):
"""Fill step 5: timeline = 3-6mo."""
_check_radio(page, "timeline", "3-6mo")
def _fill_step_6(page):
"""Fill step 6: financing status + decision process (both required)."""
_check_radio(page, "financing_status", "self_funded")
_check_radio(page, "decision_process", "solo")
def _fill_step_7(page):
"""Fill step 7: stakeholder type = entrepreneur."""
_check_radio(page, "stakeholder_type", "entrepreneur")
def _fill_step_8(page):
"""Fill step 8: select at least one service (required, checkbox pill)."""
_check_radio(page, "services_needed", "installation")
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_email']", "test@example.com")
page.fill("input[name='contact_phone']", "+49 123 456789")
@@ -141,13 +64,11 @@ def _fill_step_9(page):
def _click_next(page):
"""Click the Next button and wait for HTMX swap."""
page.locator("button.q-btn-next").click()
page.wait_for_timeout(500)
def _click_back(page):
"""Click the Back button and wait for HTMX swap."""
page.locator("button.q-btn-back").click()
page.wait_for_timeout(500)
@@ -167,7 +88,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_2(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")
_click_next(page)
@@ -180,7 +101,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_5(page)
_click_next(page)
# Step 6: Financing (has required fields)
# Step 6: Financing
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
_fill_step_6(page)
_click_next(page)
@@ -190,7 +111,7 @@ def test_quote_wizard_full_flow(live_server, page):
_fill_step_7(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")
_fill_step_8(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")
_fill_step_9(page)
# Submit the form
# Submit
page.locator("button.q-btn-submit").click()
page.wait_for_load_state("networkidle")
# Should see either success page or verification sent page (both acceptable)
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(), \
f"Expected success or verification page, got: {body_text[:200]}"
assert (
"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)
@@ -216,29 +139,24 @@ def test_quote_wizard_back_navigation(live_server, page):
page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: select Indoor
_fill_step_1(page)
_click_next(page)
# Step 2: select Germany
_fill_step_2(page)
page.fill("input[name='city']", "Berlin")
_click_next(page)
# Step 3: select a build context (radio is display:none, click label instead)
_check_radio(page, "build_context", "new_standalone")
_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")
_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")
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"
# Go back to step 2 and verify data preserved
_click_back(page)
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.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")
_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")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()
expect(page.locator(".q-error-hint")).to_be_visible()
# Now fill the field and proceed — should work
# Fix and proceed
_fill_step_1(page)
_click_next(page)
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)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()
expect(page.locator(".q-error-hint")).to_be_visible()

View File

@@ -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()