diff --git a/tests/e2e/test_smoke_browser.py b/tests/e2e/test_smoke_browser.py new file mode 100644 index 0000000..1e0d2f4 --- /dev/null +++ b/tests/e2e/test_smoke_browser.py @@ -0,0 +1,220 @@ +"""E2E-Smoketests gegen gwoe-dev.toppyr.de (Phase 16). + +Nutzt den DEBUG_AUTH_TOKEN-Bypass (siehe app/auth.py:_check_debug_token, +ENV `DEBUG_AUTH_TOKEN`), damit Auth-required Pages ohne Keycloak-Login +geladen werden können. + +Aktivierung lokal: + DEBUG_AUTH_TOKEN= pytest tests/e2e/test_smoke_browser.py -m e2e + +Wird in CI ohne DEBUG_AUTH_TOKEN automatisch übersprungen. + +Test-Coverage als Regression-Schutz für die heutigen UI-Fixes: +- Cluster-Liste rendert (vormals leere Cards: members vs drucksachen) +- Cluster-Detail wird sichtbar (vormals display:'') +- Force-Graph rendert (vormals Edge-IDs vs drucksache-IDs) +- Stimmverhalten-Tab hat Charts mit echter Größe + (vormals: ungeschlossener controls-bar div verschluckte alle Folge-Panels) +- Antrag-Detail-Marker rendern (Heuchelei + Konsistenz + Mehrheits-Bar) +- Merkliste: Add + Click-Delete entfernt Row aus DOM +""" +from __future__ import annotations + +import os +import urllib.parse + +import pytest + +TOKEN = os.environ.get("DEBUG_AUTH_TOKEN", "").strip() +BASE = os.environ.get("E2E_BASE_URL", "https://gwoe-dev.toppyr.de").rstrip("/") + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif(not TOKEN, reason="DEBUG_AUTH_TOKEN nicht gesetzt"), +] + + +@pytest.fixture(scope="module") +def browser_ctx(): + try: + from playwright.sync_api import sync_playwright + except ImportError: + pytest.skip("playwright nicht installiert") + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + ctx = browser.new_context( + extra_http_headers={"X-Debug-Token": TOKEN}, + ) + yield ctx + browser.close() + + +@pytest.fixture +def page(browser_ctx): + p = browser_ctx.new_page() + p.on("pageerror", lambda exc: pytest.fail(f"page-error: {exc}")) + yield p + p.close() + + +# ─── Cluster ──────────────────────────────────────────────────────────────── + + +def test_cluster_list_has_cards(page): + """`/v2/cluster` zeigt mindestens 1 Cluster-Card.""" + page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + cards = page.query_selector_all(".cluster-card") + assert len(cards) >= 1, "keine Cluster-Cards im DOM" + + +def test_cluster_detail_visible_after_click(page): + """Klick auf Card → `#cluster-detail` wird sichtbar (display:block).""" + page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + card = page.query_selector(".cluster-card") + if not card: + pytest.skip("keine Cluster zum Klicken") + card.click() + page.wait_for_timeout(1500) + detail = page.query_selector("#cluster-detail") + assert detail and detail.is_visible(), "cluster-detail nicht sichtbar" + + +def test_cluster_force_graph_renders(page): + """Force-Graph hat mindestens 1 SVG-Circle nach Card-Klick.""" + page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + card = page.query_selector(".cluster-card") + if not card: + pytest.skip() + card.click() + page.wait_for_timeout(2500) # d3 force-sim braucht etwas + circles = page.query_selector_all("#cluster-graph svg circle") + assert len(circles) >= 2, f"Force-Graph hat nur {len(circles)} Circles" + + +# ─── Stimmverhalten / Auswertungen ────────────────────────────────────────── + + +def test_stimmverhalten_chart_has_size(page): + """`/stimmverhalten` rendert Stimm-Index-Chart mit echter Größe. + + Regression: ungeschlossener `
` verschluckte + alle nachfolgenden Panels, Charts hatten 0×0 Bounding-Box. + """ + page.goto(f"{BASE}/stimmverhalten", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(3000) + info = page.evaluate( + "() => {" + " const el = document.getElementById('sv-index-chart');" + " if (!el) return null;" + " const r = el.getBoundingClientRect();" + " return {w: r.width, h: r.height};" + "}" + ) + assert info is not None, "sv-index-chart nicht im DOM" + assert info["w"] > 100 and info["h"] > 100, ( + f"Chart hat zu kleine Größe: {info}" + ) + + +def test_score_histogram_has_buckets(page): + """`/auswertungen` Tab Score-Verteilung rendert Histogram-Canvas.""" + page.goto(f"{BASE}/auswertungen", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + btn = page.query_selector(".auswert-tab[onclick*=\"'histogram'\"]") + if not btn: + pytest.skip("histogram-tab nicht gefunden") + btn.click() + page.wait_for_timeout(2500) + info = page.evaluate( + "() => { const el = document.getElementById('hist-chart');" + " return el ? el.getBoundingClientRect() : null; }" + ) + assert info and info["width"] > 100 and info["height"] > 50 + + +# ─── Antrag-Detail ────────────────────────────────────────────────────────── + + +def test_antrag_detail_has_markers(page): + """Heuchelei-⚠ und Konsistenz-Block auf einem bekannten Vote-Antrag.""" + page.goto( + f"{BASE}/antrag/18/18089?bundesland=NRW", + wait_until="networkidle", timeout=15000, + ) + page.wait_for_timeout(2000) + body = page.content() + assert "stimmte mit Nein" in body or "stimmte mit Ja" in body, ( + "weder Heuchelei- noch Opportunismus-Marker rendert" + ) + assert "Mehrheit deckt sich" in body or "Mehrheit kontra" in body, ( + "Konsistenz-Block fehlt" + ) + assert "Fraktionen Ja" in body, "Mehrheits-Bar fehlt" + + +def test_antrag_matrix_coloring(page): + """Matrix-Zelle mit rating>=4 hat m-pp-Klasse (kräftiges Grün), nicht m-p.""" + page.goto( + f"{BASE}/antrag/18/18246?bundesland=NRW", + wait_until="networkidle", timeout=15000, + ) + page.wait_for_timeout(1500) + pp_count = page.evaluate( + "() => document.querySelectorAll('.v2-matrix-mini .m-pp').length" + ) + assert pp_count >= 1, "keine m-pp-Klasse — rating-Shift-Bug zurück?" + + +# ─── Merkliste ────────────────────────────────────────────────────────────── + + +def _get_csrf_or_token_header(): + return {"X-Debug-Token": TOKEN} + + +def test_merkliste_add_and_delete(browser_ctx, page): + """End-to-End: Eintrag via API anlegen, dann via UI-Klick entfernen.""" + import requests + headers = {"X-Debug-Token": TOKEN, "Content-Type": "application/json"} + # Cleanup: bestehenden Eintrag löschen (idempotent) + requests.delete( + f"{BASE}/api/me/merkliste/{urllib.parse.quote('18/18089', safe='')}", + headers=headers, timeout=10, + ) + # Add + r = requests.post( + f"{BASE}/api/me/merkliste", + json={"antrag_id": "18/18089"}, + headers=headers, timeout=10, + ) + assert r.status_code == 200, f"add fehlgeschlagen: {r.status_code} {r.text}" + + page.goto(f"{BASE}/v2/merkliste", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2500) + + pre = page.evaluate( + "() => document.querySelectorAll('.merkliste-entry').length" + ) + assert pre >= 1, "Eintrag nicht in Merkliste" + btn = page.query_selector('button[onclick*="merkliste_remove"]') + assert btn, "kein Lösch-Button" + btn.click() + page.wait_for_timeout(2000) + post = page.evaluate( + "() => document.querySelectorAll('.merkliste-entry').length" + ) + assert post == pre - 1, f"Row nicht entfernt: pre={pre} post={post}" + + +# ─── Aktuelle Themen ──────────────────────────────────────────────────────── + + +def test_aktuelle_themen_5_tabs(page): + """`/aktuelle-themen` rendert 5 Tabs.""" + page.goto(f"{BASE}/aktuelle-themen", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + tabs = page.query_selector_all(".at-tab") + assert len(tabs) >= 5, f"erwartet ≥5 Tabs, gefunden {len(tabs)}"