"""Pytest fixtures for end-to-end browser tests. Boots the Flask app in a background thread on an ephemeral port and yields the base URL. The app uses a temp SQLite DB so e2e runs don't contaminate the dev database. """ import socket import threading import pytest from werkzeug.serving import make_server from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import Blueprint, Overlay, Server, User def _free_port() -> int: s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port def _boot_app(tmp_path, monkeypatch): """Set the env vars + create_app + init_db combo shared by every e2e fixture. Returns the Flask app. Sets DATABASE_URL → temp sqlite; SESSION_COOKIE_SECURE=0 so the browser keeps the session cookie over http://127.0.0.1 (app.py:57 would otherwise mark it Secure). Caller-specific env vars (e.g. LEFT4ME_ROOT for files-overlay tests) must be set BEFORE calling this, since create_app reads them during app construction. """ db_path = tmp_path / "e2e.db" db_url = f"sqlite:///{db_path}" monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("SESSION_COOKIE_SECURE", "0") app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"}) # create_app() already calls init_db() inside an app context, which # binds tables to the in-app engine. The seed work below uses # session_scope() OUTSIDE any app context, which reads DATABASE_URL # from the environment and binds its own engine. This second init_db() # call creates the tables on that env-derived engine so the seed # inserts have somewhere to land. init_db() return app def _serve(app): """Start the app on a background thread, return (base_url, shutdown). Caller is responsible for calling shutdown() in a finally block. """ port = _free_port() server = make_server("127.0.0.1", port, app) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() def shutdown(): server.shutdown() thread.join(timeout=2) return f"http://127.0.0.1:{port}", shutdown def login(page, base_url: str, username: str = "alice", password: str = "secret") -> None: """Form-POST login helper. Reused by every e2e test.""" page.goto(f"{base_url}/login") page.fill('input[name="username"]', username) page.fill('input[name="password"]', password) page.click('button[type="submit"]') @pytest.fixture(scope="function") def live_server(tmp_path, monkeypatch): # Some routes (e.g. POST /overlays via create_overlay_directory) write # under $LEFT4ME_ROOT. Point it at tmp_path so the prod default # /var/lib/left4me doesn't kick in on dev machines. monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() bp = Blueprint( user_id=user.id, name="bp", arguments="[]", config="[]" ) session.add(bp) session.flush() blueprint_id = bp.id user_id = user.id base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "blueprint_id": blueprint_id, } finally: shutdown() @pytest.fixture(scope="function") def files_overlay_server(tmp_path, monkeypatch): """live_server + a files-type Overlay owned by alice, seeded with: /server.cfg (text, editable) /icon.png (binary, replaceable) /cfg/admins.txt (nested-folder fixture) LEFT4ME_ROOT is monkey-patched to tmp_path BEFORE create_app() so overlay path resolution (l4d2host.paths.overlay_path) lands under the temp directory instead of /var/lib/left4me — without this every files-overlay route 404s on macOS (see AGENTS.md "symptom-to-cause"). """ monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() # Same insert-then-update-path pattern dev-server.py uses: we # need overlay.id to write into overlay.path, but the column is # NOT NULL so it gets a placeholder until we know the id. overlay = Overlay( name="cfgs", path="_pending", type="files", user_id=user.id, ) session.add(overlay) session.flush() overlay.path = str(overlay.id) user_id = user.id overlay_id = overlay.id overlay_root = tmp_path / "overlays" / str(overlay_id) overlay_root.mkdir(parents=True) (overlay_root / "server.cfg").write_text('hostname "left4me"\n') # 8-byte PNG signature + 60 null bytes — large enough for the # editor's binary-mode detection (which checks the magic header). (overlay_root / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 60) (overlay_root / "cfg").mkdir() (overlay_root / "cfg" / "admins.txt").write_text("STEAM_1:0:1\n") base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "overlay_id": overlay_id, "overlay_root": overlay_root, } finally: shutdown() @pytest.fixture(scope="function") def workshop_overlay_server(tmp_path, monkeypatch): """live_server + a workshop-type Overlay owned by alice. The overlay starts with zero items — tests that need items should seed them via direct DB writes or via UI actions inside the test.""" monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() overlay = Overlay( name="my-maps", path="_pending", type="workshop", user_id=user.id, ) session.add(overlay) session.flush() overlay.path = str(overlay.id) user_id = user.id overlay_id = overlay.id base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "overlay_id": overlay_id, } finally: shutdown() @pytest.fixture(scope="function") def server_with_files(tmp_path, monkeypatch): """live_server + a Server owned by alice with a populated runtime merged directory. Used by the server-detail e2e tests that exercise file rows + download. The /servers/ page lists files from LEFT4ME_ROOT/runtime//merged/ (the kernel-overlayfs view of a running server). On a dev/test box no overlayfs is mounted, so the fixture pre-creates that directory and seeds one plain file — enough for the file-tree partial to render rows. Yields {base_url, user_id, blueprint_id, server_id, merged_root}. """ monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() bp = Blueprint( user_id=user.id, name="bp", arguments="[]", config="[]" ) session.add(bp) session.flush() # Server.port has a global UNIQUE constraint, but this is a # fresh per-test SQLite DB so any value works — 27015 is the # L4D2 default, semantically obvious. server = Server( user_id=user.id, blueprint_id=bp.id, name="srv", port=27015, ) session.add(server) session.flush() user_id = user.id blueprint_id = bp.id server_id = server.id merged_root = tmp_path / "runtime" / str(server_id) / "merged" merged_root.mkdir(parents=True) (merged_root / "server.cfg").write_text('hostname "from-merged-runtime"\n') base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "blueprint_id": blueprint_id, "server_id": server_id, "merged_root": merged_root, } finally: shutdown() @pytest.fixture(scope="function") def server_with_console_history(server_with_files): """server_with_files + 30 seeded CommandHistory rows for that server, so the inline Console transcript exceeds its visible height and the autoscroll behaviour is observable.""" from datetime import UTC, datetime, timedelta from l4d2web.models import CommandHistory sid = server_with_files["server_id"] uid = server_with_files["user_id"] with session_scope() as session: for i in range(30): session.add(CommandHistory( user_id=uid, server_id=sid, command=f"seed_{i:02d}", reply=f"reply {i}", is_error=False, created_at=datetime.now(UTC) - timedelta(minutes=35 - i), )) return server_with_files