"""End-to-End-Smoke-Tests im echten Browser (Playwright, headless). Die Tests starten die Flask-App selbst (temporäre Datenbank, zufälliger Port) und verwenden standardmäßig ein installiertes Microsoft Edge oder Google Chrome. Mit ``GLORY_E2E_BROWSER`` lässt sich ein Kanal erzwingen (``msedge``, ``chrome`` oder ``chromium`` für den von Playwright geladenen Browser). Ist kein Browser verfügbar, werden die Tests mit Begründung übersprungen. """ from __future__ import annotations import os import threading import time from collections.abc import Iterator from pathlib import Path import pytest from app import create_app sync_api = pytest.importorskip("playwright.sync_api") expect = sync_api.expect pytestmark = pytest.mark.e2e GAME = "window.gloryDefense" def _channels() -> list[str | None]: forced = os.environ.get("GLORY_E2E_BROWSER", "").strip() if forced: return [None if forced == "chromium" else forced] return ["msedge", "chrome", None] @pytest.fixture(scope="module") def live_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]: from werkzeug.serving import make_server db_path: Path = tmp_path_factory.mktemp("e2e") / "e2e.sqlite3" app = create_app({"TESTING": True, "SECRET_KEY": "e2e-secret", "DATABASE_PATH": str(db_path)}) server = make_server("127.0.0.1", 0, app, threaded=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() yield f"http://127.0.0.1:{server.server_port}" server.shutdown() thread.join(timeout=5) @pytest.fixture(scope="module") def browser() -> Iterator[object]: with sync_api.sync_playwright() as playwright: failures: list[str] = [] for channel in _channels(): try: launched = playwright.chromium.launch(channel=channel, headless=True) except Exception as exc: # jeder Startfehler heißt: nächsten Kandidaten probieren failures.append(f"{channel or 'chromium'}: {str(exc).splitlines()[0]}") continue yield launched launched.close() return pytest.skip("Kein Browser für Playwright gefunden – " + " | ".join(failures)) @pytest.fixture() def page(browser: object, live_server: str) -> Iterator[object]: context = browser.new_context(viewport={"width": 1280, "height": 720}) page = context.new_page() page.errors = [] # type: ignore[attr-defined] def on_console(msg: object) -> None: if msg.type == "error": page.errors.append(f"console.{msg.type}: {msg.text}") page.on("pageerror", lambda exc: page.errors.append(f"pageerror: {exc}")) page.on("console", on_console) page.goto(live_server + "/") yield page context.close() def _wait_until(page: object, expression: str, timeout_s: float = 15.0) -> None: """Wartet, bis ``expression`` im Browser wahr ist. Absichtlich nicht ``page.wait_for_function``: Das wertet den Ausdruck per ``eval`` aus, was die Content-Security-Policy der App (kein ``unsafe-eval``) zu Recht blockiert. ``page.evaluate`` läuft über das DevTools-Protokoll und ist davon nicht betroffen – so testen wir mit der echten, unveränderten CSP. """ deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if page.evaluate(f"() => Boolean({expression})"): return page.wait_for_timeout(100) raise AssertionError(f"Zeitüberschreitung beim Warten auf: {expression}") def _wait_for_scene(page: object, key: str) -> None: _wait_until(page, f"{GAME} && {GAME}.scene.isActive('{key}')") def _accept_disclaimer_and_start(page: object) -> None: accept = page.get_by_test_id("disclaimer-accept") expect(accept).to_be_visible() accept.click() _wait_for_scene(page, "Menu") page.locator("#game canvas").click(position={"x": 40, "y": 40}) page.keyboard.press("Enter") _wait_for_scene(page, "Briefing") page.keyboard.press("Space") page.keyboard.press("Space") _wait_for_scene(page, "Game") _wait_for_scene(page, "Hud") def test_page_loads_disclaimer_then_game_starts(page: object) -> None: expect(page).to_have_title("Glory Defense") _accept_disclaimer_and_start(page) assert page.evaluate("localStorage.getItem('gloryDefense.disclaimerAccepted.v1')") == "true" # Etwas spielen: nach rechts laufen, zielen und Dauerfeuer geben. page.keyboard.down("d") page.mouse.move(1000, 300) page.mouse.down() page.wait_for_timeout(1200) page.mouse.up() page.keyboard.up("d") _wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 1500") _wait_until(page, f"{GAME}.scene.getScene('Game').runToken !== null") state = page.evaluate( f"""() => {{ const s = {GAME}.scene.getScene('Game'); return {{ magazine: s.player.weapon.magazine, x: s.player.x }}; }}""" ) assert state["magazine"] < 30, "Es wurde nicht geschossen" assert state["x"] > 1200, "Der Präsident hat sich nicht bewegt" # Pause per Esc, Lautstärke ändern, weiterspielen. page.keyboard.press("Escape") resume = page.get_by_test_id("pause-resume") expect(resume).to_be_visible() page.get_by_test_id("volume-music").fill("40") resume.click() _wait_for_scene(page, "Game") assert page.errors == [] # type: ignore[attr-defined] def test_orbital_commands_via_keyboard(page: object) -> None: _accept_disclaimer_and_start(page) page.mouse.move(800, 360) # Orbitalschlag mit Pfeiltasten, Nachschub mit WASD – beides bei gehaltenem Q. page.keyboard.down("q") for key in ("ArrowUp", "ArrowRight", "ArrowDown", "ArrowDown"): page.keyboard.press(key) for key in ("a", "a", "s", "d"): page.keyboard.press(key) page.keyboard.up("q") _wait_until(page, f"{GAME}.scene.getScene('Game').orbital.pending.length === 2", timeout_s=5) ready = page.evaluate( f"""() => {{ const s = {GAME}.scene.getScene('Game'); return s.orbital.hudState(s.gameTime).commands.map(c => [c.id, c.ready]); }}""" ) assert ready == [["strike", False], ["supply", False], ["turret", True]] # Nach dem Einschlag liegt eine Nachschubkapsel in der Arena. _wait_until(page, f"{GAME}.scene.getScene('Game').pods.countActive(true) === 1", timeout_s=8) assert page.errors == [] # type: ignore[attr-defined] def test_disclaimer_is_remembered_and_reachable_from_menu(page: object) -> None: page.get_by_test_id("disclaimer-accept").click() _wait_for_scene(page, "Menu") page.reload() _wait_for_scene(page, "Menu") expect(page.locator("#disclaimer-overlay")).to_have_count(0) # Im Menü: 4. Eintrag „Disclaimer“ (dreimal nach unten, Enter). page.locator("#game canvas").click(position={"x": 40, "y": 40}) for _ in range(3): page.keyboard.press("ArrowDown") page.keyboard.press("Enter") expect(page.locator("#disclaimer-overlay")).to_be_visible() expect(page.locator("#disclaimer-overlay")).to_contain_text("Satire und reine Fiktion") page.get_by_test_id("disclaimer-accept").click() expect(page.locator("#disclaimer-overlay")).to_have_count(0) assert page.errors == [] # type: ignore[attr-defined] def test_game_over_submits_highscore(page: object) -> None: _accept_disclaimer_and_start(page) _wait_until(page, f"{GAME}.scene.getScene('Game').runToken !== null") _wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 2000") # Test-Abkürzung: Punkte gutschreiben und den Präsidenten fallen lassen. page.evaluate( f"""() => {{ const s = {GAME}.scene.getScene('Game'); s.score.addBonus(150); s.player.hitInvulnerableUntil = 0; s.player.dodgeUntil = 0; s.damagePlayer(9999); }}""" ) _wait_for_scene(page, "GameOver") name = page.get_by_test_id("highscore-name") expect(name).to_be_visible() name.fill("E2E Kadett") page.get_by_test_id("highscore-submit").click() expect(page.locator(".highscore-list__own")).to_contain_text("E2E Kadett") expect(page.locator("#gameover-overlay")).to_contain_text("Platz 1") assert page.errors == [] # type: ignore[attr-defined] def test_game_runs_with_placeholders_when_assets_are_missing( browser: object, live_server: str ) -> None: """Abnahmekriterium: Das Spiel ist auch ohne echte Grafiken vollständig spielbar.""" context = browser.new_context(viewport={"width": 1280, "height": 720}) page = context.new_page() page.errors = [] # type: ignore[attr-defined] page.on("pageerror", lambda exc: page.errors.append(str(exc))) # Sämtliche Grafik-, Schrift- und Klangdateien blockieren. page.route("**/static/assets/**", lambda route: route.abort()) page.goto(live_server + "/") _accept_disclaimer_and_start(page) _wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 2500") textures = page.evaluate( f"""() => {{ const t = {GAME}.textures; return ['player', 'enemy-krabbler', 'enemy-walzmeister', 'supply-pod', 'emblem'] .map(key => [key, t.exists(key), t.get(key).source[0].width]); }}""" ) # Platzhalter existieren (Größen der Platzhalter, nicht der SVGs). assert all(exists for _key, exists, _width in textures) assert dict((key, width) for key, _e, width in textures)["player"] != 64 assert page.errors == [] # type: ignore[attr-defined] context.close() def test_disclaimer_page_works_without_javascript(browser: object, live_server: str) -> None: context = browser.new_context(java_script_enabled=False) page = context.new_page() page.goto(live_server + "/disclaimer") expect(page.locator("main")).to_contain_text("frei erfunden") expect(page.get_by_role("link", name="Zurück zum Spiel")).to_be_visible() context.close()