Merge branch 'worktree-visual-test-overhaul'
# Conflicts: # web/tests/test_e2e_flows.py
This commit is contained in:
@@ -237,3 +237,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 |
@@ -2,154 +2,26 @@
|
|||||||
Comprehensive E2E flow tests using Playwright.
|
Comprehensive E2E flow tests using Playwright.
|
||||||
|
|
||||||
Covers all major user flows: public pages, planner, auth, directory, quote wizard,
|
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`).
|
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
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
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
|
@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}")
|
||||||
@@ -184,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()
|
||||||
|
|
||||||
@@ -214,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -224,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
|
||||||
|
|
||||||
@@ -234,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)
|
||||||
@@ -249,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"
|
||||||
|
|
||||||
|
|
||||||
@@ -271,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"
|
||||||
|
|
||||||
@@ -284,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -325,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}"
|
||||||
@@ -361,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
|
||||||
|
|
||||||
|
|
||||||
@@ -376,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()
|
||||||
|
|
||||||
@@ -396,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?"
|
||||||
|
|
||||||
|
|
||||||
@@ -435,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):
|
||||||
@@ -444,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');
|
||||||
@@ -462,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")
|
||||||
@@ -470,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}"
|
||||||
|
|
||||||
@@ -502,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
|
||||||
|
|
||||||
|
|
||||||
@@ -515,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()
|
||||||
@@ -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):
|
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.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"
|
||||||
|
|
||||||
@@ -584,7 +433,6 @@ def test_pricing_page_has_plan_cards(live_server, page):
|
|||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
cards = page.locator(".card-header")
|
cards = page.locator(".card-header")
|
||||||
assert cards.count() >= 2, f"Expected >=2 plan cards, found {cards.count()}"
|
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")
|
ctas = page.locator("a.btn, a.btn-outline")
|
||||||
assert ctas.count() >= 2, f"Expected >=2 CTAs, found {ctas.count()}"
|
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."""
|
"""Pricing page is accessible without authentication."""
|
||||||
resp = page.goto(live_server + "/billing/pricing")
|
resp = page.goto(live_server + "/billing/pricing")
|
||||||
assert resp.ok
|
assert resp.ok
|
||||||
# Should NOT have redirected to login
|
|
||||||
assert "login" not in page.url and "auth" not in page.url
|
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")
|
resp = page.goto(live_server + "/billing/success")
|
||||||
assert resp.ok
|
assert resp.ok
|
||||||
expect(page.locator("h1").first).to_be_visible()
|
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()
|
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."""
|
"""Supplier signup wizard step 1 renders."""
|
||||||
resp = page.goto(live_server + "/en/suppliers/signup")
|
resp = page.goto(live_server + "/en/suppliers/signup")
|
||||||
assert resp.ok, f"/en/suppliers/signup returned {resp.status}"
|
assert resp.ok, f"/en/suppliers/signup returned {resp.status}"
|
||||||
# Wait for HTMX to load step content
|
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
expect(page.locator("[data-step='1']").first).to_be_visible()
|
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, (
|
assert plan_cards.count() >= 2, (
|
||||||
f"Expected >=2 plan cards, found {plan_cards.count()}"
|
f"Expected >=2 plan cards, found {plan_cards.count()}"
|
||||||
)
|
)
|
||||||
# Should have a next button
|
|
||||||
expect(page.locator(".s-btn-next").first).to_be_visible()
|
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")
|
resp = page.goto(live_server + "/en/suppliers/dashboard")
|
||||||
assert resp.ok, f"dashboard returned {resp.status}"
|
assert resp.ok, f"dashboard returned {resp.status}"
|
||||||
expect(page.locator(".dash").first).to_be_visible()
|
expect(page.locator(".dash").first).to_be_visible()
|
||||||
# Sidebar should show supplier name
|
|
||||||
expect(page.locator(".dash-sidebar__name").first).to_be_visible()
|
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")
|
dev_login(page, live_server, "supplier@test.com")
|
||||||
page.goto(live_server + "/en/suppliers/dashboard")
|
page.goto(live_server + "/en/suppliers/dashboard")
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
# Wait for HTMX to load overview content
|
|
||||||
page.wait_for_timeout(1000)
|
page.wait_for_timeout(1000)
|
||||||
stats = page.locator(".ov-stat")
|
stats = page.locator(".ov-stat")
|
||||||
assert stats.count() >= 2, f"Expected >=2 stat cards, found {stats.count()}"
|
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.goto(live_server + "/en/suppliers/dashboard/boosts")
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
content = page.content()
|
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, (
|
assert "25" in content or "50" in content or "100" in content, (
|
||||||
"No credit pack amounts found on boosts page"
|
"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")
|
resp = page.goto(live_server + "/en/planner/export")
|
||||||
assert resp.ok, f"export page returned {resp.status}"
|
assert resp.ok, f"export page returned {resp.status}"
|
||||||
expect(page.locator("h1").first).to_be_visible()
|
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-form").first).to_be_visible()
|
||||||
expect(page.locator("#export-buy-btn").first).to_be_visible()
|
expect(page.locator("#export-buy-btn").first).to_be_visible()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user