Add Playwright e2e test infrastructure

Set up browser-based end-to-end testing using pytest-playwright:
- Add playwright-driver and pytest-playwright to nix flake
- Configure PLAYWRIGHT_BROWSERS_PATH for NixOS compatibility
- Create ServerHarness to manage live server for tests
- Add smoke tests for health endpoint and page loading
- Exclude e2e tests from pre-commit hook (require special setup)

Run e2e tests with: pytest tests/e2e/ -v -n 0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 08:11:15 +00:00
parent c477d801d1
commit feca97a796
6 changed files with 166 additions and 1 deletions

2
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# ABOUTME: End-to-end test package for browser-based testing.
# ABOUTME: Uses Playwright to test the full application stack.

122
tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,122 @@
# ABOUTME: E2E test fixtures and server harness for Playwright tests.
# ABOUTME: Provides a live server instance for browser-based testing.
import os
import random
import subprocess
import sys
import time
import pytest
import requests
from animaltrack.db import get_db
from animaltrack.migrations import run_migrations
from animaltrack.seeds import run_seeds
class ServerHarness:
"""Manages a live AnimalTrack server for e2e tests.
Starts the server as a subprocess with an isolated test database,
waits for it to be ready, and cleans up after tests complete.
"""
def __init__(self, port: int):
self.port = port
self.url = f"http://127.0.0.1:{port}"
self.process = None
def start(self, db_path: str):
"""Start the server with the given database."""
env = {
**os.environ,
"DB_PATH": db_path,
"DEV_MODE": "true",
"CSRF_SECRET": "e2e-test-csrf-secret-32chars!!",
"TRUSTED_PROXY_IPS": "127.0.0.1",
}
# Use sys.executable to ensure we use the same Python environment
self.process = subprocess.Popen(
[
sys.executable,
"-m",
"animaltrack.cli",
"serve",
"--port",
str(self.port),
"--host",
"127.0.0.1",
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._wait_for_ready()
def _wait_for_ready(self, timeout: float = 30.0):
"""Poll /healthz until server is ready."""
start = time.time()
while time.time() - start < timeout:
try:
response = requests.get(f"{self.url}/healthz", timeout=1)
if response.ok:
return
except requests.RequestException:
pass
time.sleep(0.1)
# If we get here, dump stderr for debugging
if self.process:
stderr = self.process.stderr.read() if self.process.stderr else b""
raise TimeoutError(
f"Server not ready after {timeout}s. stderr: {stderr.decode('utf-8', errors='replace')}"
)
def stop(self):
"""Stop the server and clean up."""
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
@pytest.fixture(scope="session")
def e2e_db_path(tmp_path_factory):
"""Create and migrate a fresh database for e2e tests.
Session-scoped so all e2e tests share the same database state.
"""
temp_dir = tmp_path_factory.mktemp("e2e")
db_path = str(temp_dir / "animaltrack.db")
# Run migrations
run_migrations(db_path, "migrations", verbose=False)
# Seed with test data
db = get_db(db_path)
run_seeds(db)
return db_path
@pytest.fixture(scope="session")
def live_server(e2e_db_path):
"""Start the server for the entire e2e test session.
Uses a random port in the 33660-33759 range to avoid conflicts
with other services or parallel test runs.
"""
port = 33660 + random.randint(0, 99)
harness = ServerHarness(port)
harness.start(e2e_db_path)
yield harness
harness.stop()
@pytest.fixture(scope="session")
def base_url(live_server):
"""Provide the base URL for the live server."""
return live_server.url

29
tests/e2e/test_smoke.py Normal file
View File

@@ -0,0 +1,29 @@
# ABOUTME: Basic smoke tests to verify the e2e test setup works.
# ABOUTME: Tests server startup, health endpoint, and page loading.
import pytest
import requests
from playwright.sync_api import Page, expect
pytestmark = pytest.mark.e2e
def test_healthz_endpoint(live_server):
"""Verify health endpoint returns OK."""
response = requests.get(f"{live_server.url}/healthz")
assert response.status_code == 200
assert response.text == "OK"
def test_home_page_loads(page: Page, live_server):
"""Verify the home page loads successfully."""
page.goto(live_server.url)
# Should see the page body
expect(page.locator("body")).to_be_visible()
def test_animals_page_accessible(page: Page, live_server):
"""Verify animals list page is accessible."""
page.goto(f"{live_server.url}/animals")
# Should see some content (exact content depends on seed data)
expect(page.locator("body")).to_be_visible()