| """Shared pytest fixtures for FATHOM env tests.""" |
| from __future__ import annotations |
|
|
| import json |
| import os |
| import signal |
| import socket |
| import subprocess |
| import sys |
| import time |
| from pathlib import Path |
| from typing import Iterator |
|
|
| import pytest |
| from fastapi.testclient import TestClient |
|
|
|
|
| @pytest.fixture() |
| def fresh_test_client() -> Iterator[TestClient]: |
| """A TestClient bound to a FRESH FastAPI app — avoids single-_ENV state bleed between tests.""" |
| import importlib |
| import env.server.app as app_mod |
| importlib.reload(app_mod) |
| yield TestClient(app_mod.app) |
|
|
|
|
| @pytest.fixture(scope="session") |
| def fixture_rows() -> list[dict]: |
| """Parse env/data/phase0_fixture.jsonl once per test session.""" |
| path = Path("env/data/phase0_fixture.jsonl") |
| rows = [] |
| with path.open("r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if line: |
| rows.append(json.loads(line)) |
| assert len(rows) >= 3, f"Expected >=3 fixture rows, got {len(rows)}" |
| return rows |
|
|
|
|
| def _find_free_port() -> int: |
| with socket.socket() as s: |
| s.bind(("127.0.0.1", 0)) |
| return s.getsockname()[1] |
|
|
|
|
| @pytest.fixture() |
| def live_uvicorn_server() -> Iterator[str]: |
| """Launch uvicorn in a subprocess; yield base_url; terminate on teardown. |
| |
| Skipped when subprocess uvicorn fails to come up (e.g., flaky Windows CI). |
| """ |
| port = _find_free_port() |
| env = os.environ.copy() |
| env["PYTHONDONTWRITEBYTECODE"] = "1" |
| proc = subprocess.Popen( |
| [sys.executable, "-m", "uvicorn", "env.server.app:app", |
| "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| env=env, |
| ) |
| base_url = f"http://127.0.0.1:{port}" |
| try: |
| deadline = time.monotonic() + 10.0 |
| ready = False |
| import httpx |
| while time.monotonic() < deadline: |
| try: |
| r = httpx.get(base_url + "/healthz", timeout=0.5) |
| if r.status_code == 200: |
| ready = True |
| break |
| except Exception: |
| time.sleep(0.2) |
| if not ready: |
| out = proc.stdout.read().decode("utf-8", errors="replace")[:2000] if proc.stdout else "" |
| err = proc.stderr.read().decode("utf-8", errors="replace")[:2000] if proc.stderr else "" |
| pytest.skip(f"uvicorn failed to come up in 10s. stderr: {err}. stdout: {out}") |
| yield base_url |
| finally: |
| if proc.poll() is None: |
| try: |
| if sys.platform != "win32": |
| proc.send_signal(signal.SIGTERM) |
| else: |
| proc.terminate() |
| proc.wait(timeout=5) |
| except Exception: |
| proc.kill() |
|
|