255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
"""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()
|