left4me/l4d2web/tests/e2e/conftest.py
mwiegand 0307416b92
test(e2e): console transcript pinned to bottom on tab + submit
Adds server_with_console_history fixture (30 seeded CommandHistory rows)
and two Playwright tests that verify the inline Console transcript is
scrolled to its bottom when the Console tab is activated and after a
command is submitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:29:05 +02:00

295 lines
9.6 KiB
Python

"""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:
<overlay_root>/server.cfg (text, editable)
<overlay_root>/icon.png (binary, replaceable)
<overlay_root>/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/<id> page lists files from
LEFT4ME_ROOT/runtime/<server_id>/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