"""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)}"