From feca97a7965396698f3962b88aac5c6fee8216d6 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sun, 18 Jan 2026 08:11:15 +0000 Subject: [PATCH] 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 --- flake.nix | 7 +++ lefthook.yml | 2 +- pyproject.toml | 5 ++ tests/e2e/__init__.py | 2 + tests/e2e/conftest.py | 122 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/test_smoke.py | 29 ++++++++++ 6 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_smoke.py diff --git a/flake.nix b/flake.nix index cf5b77c..d9dec6a 100644 --- a/flake.nix +++ b/flake.nix @@ -61,6 +61,8 @@ # Dev-only (not needed in Docker, but fine to include) pytest pytest-xdist + pytest-playwright + requests ruff filelock ]); @@ -84,8 +86,13 @@ pkgs.sqlite pkgs.skopeo # For pushing Docker images pkgs.lefthook # Git hooks manager + pkgs.playwright-driver # Browser binaries for e2e tests ]; + # Playwright browser configuration for NixOS + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + shellHook = '' export PYTHONPATH="$PWD/src:$PYTHONPATH" export PATH="$PWD/bin:$PATH" diff --git a/lefthook.yml b/lefthook.yml index d581ee8..29a3aa5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -12,4 +12,4 @@ pre-commit: run: ruff format --check src/ tests/ pytest: glob: "**/*.py" - run: pytest tests/ -q --tb=short + run: pytest tests/ --ignore=tests/e2e -q --tb=short diff --git a/pyproject.toml b/pyproject.toml index 50be31e..57c2f40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ dev = [ "pytest>=7.4.0", "pytest-xdist>=3.5.0", + "pytest-playwright>=0.4.0", + "requests>=2.31.0", "ruff>=0.1.0", "filelock>=3.13.0", ] @@ -56,3 +58,6 @@ python_files = "test_*.py" python_classes = "Test*" python_functions = "test_*" addopts = "--durations=20 -n auto" +markers = [ + "e2e: end-to-end browser tests (run with -n 0 to disable parallel execution)", +] diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..1d8e63b --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: End-to-end test package for browser-based testing. +# ABOUTME: Uses Playwright to test the full application stack. diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..a16a262 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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 diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py new file mode 100644 index 0000000..3a8fa4d --- /dev/null +++ b/tests/e2e/test_smoke.py @@ -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()