Drops the input-mode radio; the single textarea now accepts any mix of
items and collection URLs (backend autodetect landed in 5c56f18).
Refresh button moves below the items table into a .table-actions row
that also shows an item count + total size summary. Adds .workshop-input
mono font rule and a _humanize_bytes helper alongside the overlay_detail
view.
Plan deviation: PageAssertions has no not_to_contain_text method, so
the e2e test scopes those checks to a body locator instead. Caught in
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
8.8 KiB
Python
271 lines
8.8 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()
|