211 lines
6.7 KiB
Python
211 lines
6.7 KiB
Python
|
|
"""E2E UI tests with Playwright/Chromium (#120).
|
||
|
|
|
||
|
|
Run: pytest tests/e2e/ -m e2e --headed (with browser)
|
||
|
|
pytest tests/e2e/ -m e2e (headless)
|
||
|
|
|
||
|
|
Requires: pip install playwright && playwright install chromium
|
||
|
|
Target: https://gwoe.toppyr.de (live)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
BASE_URL = "https://gwoe.toppyr.de"
|
||
|
|
|
||
|
|
pytestmark = pytest.mark.e2e
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def browser():
|
||
|
|
from playwright.sync_api import sync_playwright
|
||
|
|
pw = sync_playwright().start()
|
||
|
|
br = pw.chromium.launch(headless=True)
|
||
|
|
yield br
|
||
|
|
br.close()
|
||
|
|
pw.stop()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def page(browser):
|
||
|
|
p = browser.new_page()
|
||
|
|
yield p
|
||
|
|
p.close()
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Page Load ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_main_page_loads(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
assert page.title() == "GWÖ-Antragsprüfer"
|
||
|
|
|
||
|
|
|
||
|
|
def test_assessments_render(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
items = page.locator(".list-item").count()
|
||
|
|
assert items >= 1, "Keine Assessments in der Liste"
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_js_errors(page):
|
||
|
|
errors = []
|
||
|
|
page.on("pageerror", lambda err: errors.append(err.message))
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
assert not errors, f"JS-Fehler: {errors}"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Navigation ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_detail_loads_on_click(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
first = page.locator(".list-item").first
|
||
|
|
first.click()
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
detail = page.locator("#detail-panel").inner_text()
|
||
|
|
assert len(detail) > 50, "Detail-Panel leer nach Klick"
|
||
|
|
|
||
|
|
|
||
|
|
def test_keyboard_navigation(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
# j selects first item
|
||
|
|
page.keyboard.press("j")
|
||
|
|
active = page.locator(".list-item.active").count()
|
||
|
|
assert active == 1, "j-Taste markiert kein Item"
|
||
|
|
# k goes back (stays on first)
|
||
|
|
page.keyboard.press("k")
|
||
|
|
assert page.locator(".list-item.active").count() == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_search(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(3000)
|
||
|
|
search = page.locator("#search-input")
|
||
|
|
search.fill("Bildung")
|
||
|
|
page.wait_for_timeout(1000)
|
||
|
|
# Should still show items (or search results)
|
||
|
|
items = page.locator(".list-item").count()
|
||
|
|
assert items >= 0 # search may return 0, but shouldn't error
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Subpages ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_impressum_page(page):
|
||
|
|
page.goto(f"{BASE_URL}/impressum")
|
||
|
|
page.wait_for_timeout(1000)
|
||
|
|
assert "Angaben" in page.content()
|
||
|
|
|
||
|
|
|
||
|
|
def test_datenschutz_page(page):
|
||
|
|
page.goto(f"{BASE_URL}/datenschutz")
|
||
|
|
page.wait_for_timeout(1000)
|
||
|
|
assert "Datenschutz" in page.content()
|
||
|
|
|
||
|
|
|
||
|
|
def test_methodik_page(page):
|
||
|
|
page.goto(f"{BASE_URL}/methodik")
|
||
|
|
page.wait_for_timeout(1000)
|
||
|
|
assert "Methodik" in page.content()
|
||
|
|
|
||
|
|
|
||
|
|
def test_auswertungen_page(page):
|
||
|
|
errors = []
|
||
|
|
page.on("pageerror", lambda err: errors.append(err.message))
|
||
|
|
page.goto(f"{BASE_URL}/auswertungen")
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
assert not errors, f"JS-Fehler auf Auswertungen: {errors}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_quellen_page(page):
|
||
|
|
page.goto(f"{BASE_URL}/quellen")
|
||
|
|
page.wait_for_timeout(1000)
|
||
|
|
assert "Wahlprogramm" in page.content() or "Quellen" in page.content()
|
||
|
|
|
||
|
|
|
||
|
|
# ─── API ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_api_assessments(page):
|
||
|
|
resp = page.request.get(f"{BASE_URL}/api/assessments")
|
||
|
|
assert resp.ok
|
||
|
|
data = resp.json()
|
||
|
|
assert isinstance(data, list)
|
||
|
|
assert len(data) > 0
|
||
|
|
# Lightweight: should NOT contain gwoeMatrix (memory optimization)
|
||
|
|
assert "gwoeMatrix" not in data[0], "List API should not contain detail fields"
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_assessment_detail(page):
|
||
|
|
list_resp = page.request.get(f"{BASE_URL}/api/assessments")
|
||
|
|
first = list_resp.json()[0]
|
||
|
|
ds = first["drucksache"]
|
||
|
|
resp = page.request.get(f"{BASE_URL}/api/assessment?drucksache={ds}")
|
||
|
|
assert resp.ok
|
||
|
|
detail = resp.json()
|
||
|
|
assert "gwoeMatrix" in detail, "Detail API should contain gwoeMatrix"
|
||
|
|
assert "gwoeBegründung" in detail
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_export_json(page):
|
||
|
|
resp = page.request.get(f"{BASE_URL}/api/auswertungen/export.json")
|
||
|
|
assert resp.ok
|
||
|
|
data = resp.json()
|
||
|
|
assert "meta" in data
|
||
|
|
assert data["meta"]["license"] == "CC BY 4.0"
|
||
|
|
assert len(data["assessments"]) > 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_votes(page):
|
||
|
|
resp = page.request.get(f"{BASE_URL}/api/votes?drucksache=18/8125")
|
||
|
|
assert resp.ok
|
||
|
|
data = resp.json()
|
||
|
|
assert "counts" in data
|
||
|
|
assert "my_votes" in data
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_health(page):
|
||
|
|
resp = page.request.get(f"{BASE_URL}/health")
|
||
|
|
assert resp.ok
|
||
|
|
assert resp.json()["status"] == "ok"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Dark Mode ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_dark_mode_toggle(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
# Initially light
|
||
|
|
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||
|
|
assert theme != "dark"
|
||
|
|
# Toggle via JS (simulates button)
|
||
|
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
||
|
|
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||
|
|
assert theme == "dark"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Accessibility ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_search_has_aria_label(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
label = page.locator("#search-input").get_attribute("aria-label")
|
||
|
|
assert label, "Suchfeld hat kein aria-label"
|
||
|
|
|
||
|
|
|
||
|
|
def test_hamburger_has_aria_label(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
btn = page.locator("button[aria-label='Menü öffnen']")
|
||
|
|
assert btn.count() == 1, "Hamburger-Button hat kein aria-label"
|
||
|
|
|
||
|
|
|
||
|
|
def test_focus_visible_indicator(page):
|
||
|
|
page.goto(BASE_URL)
|
||
|
|
page.wait_for_timeout(2000)
|
||
|
|
# Check that :focus-visible style exists
|
||
|
|
has_focus = page.evaluate("""() => {
|
||
|
|
const rules = [...document.styleSheets[0].cssRules];
|
||
|
|
return rules.some(r => r.selectorText && r.selectorText.includes('focus-visible'));
|
||
|
|
}""")
|
||
|
|
assert has_focus, "Kein :focus-visible CSS-Regel gefunden"
|