diff --git a/web/tests/conftest.py b/web/tests/conftest.py index c2a7450..704ff58 100644 --- a/web/tests/conftest.py +++ b/web/tests/conftest.py @@ -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() diff --git a/web/tests/screenshots/landing_full.png b/web/tests/screenshots/landing_full.png index b1406b3..d28a696 100644 Binary files a/web/tests/screenshots/landing_full.png and b/web/tests/screenshots/landing_full.png differ diff --git a/web/tests/screenshots/landing_mobile.png b/web/tests/screenshots/landing_mobile.png index d27137e..c470868 100644 Binary files a/web/tests/screenshots/landing_mobile.png and b/web/tests/screenshots/landing_mobile.png differ diff --git a/web/tests/test_e2e_flows.py b/web/tests/test_e2e_flows.py index 2765978..bb70e83 100644 --- a/web/tests/test_e2e_flows.py +++ b/web/tests/test_e2e_flows.py @@ -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

or

— 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 //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() diff --git a/web/tests/test_quote_wizard.py b/web/tests/test_quote_wizard.py index c7c571a..d28b12e 100644 --- a/web/tests/test_quote_wizard.py +++ b/web/tests/test_quote_wizard.py @@ -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() diff --git a/web/tests/test_visual.py b/web/tests/test_visual.py index c18e4a2..5da515a 100644 --- a/web/tests/test_visual.py +++ b/web/tests/test_visual.py @@ -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 inside an