|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import json |
| 4 | +import os |
| 5 | +import shutil |
| 6 | +import signal |
| 7 | +import socket |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +import time |
| 11 | +import uuid |
| 12 | +from collections.abc import Iterator |
| 13 | +from pathlib import Path |
| 14 | +from typing import Any |
| 15 | + |
| 16 | +import httpx |
| 17 | +import pytest |
| 18 | +from fastapi.testclient import TestClient |
| 19 | +from dotenv import load_dotenv |
| 20 | + |
| 21 | +REPO_ROOT = Path(__file__).resolve().parents[1] |
| 22 | +if str(REPO_ROOT) not in sys.path: |
| 23 | + sys.path.insert(0, str(REPO_ROOT)) |
| 24 | +load_dotenv(REPO_ROOT / "kady_agent" / ".env") |
| 25 | +LIVE_BACKEND_URL = os.environ.get("KADY_TEST_BACKEND_URL", "http://127.0.0.1:8000") |
| 26 | +LIVE_FRONTEND_URL = os.environ.get("KADY_TEST_FRONTEND_URL", "http://localhost:3000") |
| 27 | +LIVE_LITELLM_URL = os.environ.get("KADY_TEST_LITELLM_URL", "http://127.0.0.1:4000") |
| 28 | + |
| 29 | + |
| 30 | +def pytest_configure(config: pytest.Config) -> None: |
| 31 | + config.addinivalue_line("markers", "live_e2e: requires live LLM/API services") |
| 32 | + config.addinivalue_line("markers", "browser: requires Playwright and Chromium") |
| 33 | + |
| 34 | + |
| 35 | +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: |
| 36 | + # Keep long-lived browser/real-service fixtures from running before the |
| 37 | + # async unit/API tests. The verification flow is fast tests first, live E2E |
| 38 | + # last, and this also prevents Playwright's loop from confusing pytest-asyncio. |
| 39 | + items.sort(key=lambda item: (1 if item.get_closest_marker("live_e2e") else 0, item.nodeid)) |
| 40 | + |
| 41 | + |
| 42 | +@pytest.fixture() |
| 43 | +def isolated_projects_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: |
| 44 | + root = tmp_path / "projects" |
| 45 | + root.mkdir() |
| 46 | + monkeypatch.setenv("KADY_PROJECTS_ROOT", str(root)) |
| 47 | + |
| 48 | + from kady_agent import projects |
| 49 | + |
| 50 | + monkeypatch.setattr(projects, "PROJECTS_ROOT", root) |
| 51 | + monkeypatch.setattr(projects, "INDEX_PATH", root / "index.json") |
| 52 | + token = projects.set_active_project(projects.DEFAULT_PROJECT_ID) |
| 53 | + try: |
| 54 | + yield root |
| 55 | + finally: |
| 56 | + projects.ACTIVE_PROJECT.reset(token) |
| 57 | + |
| 58 | + |
| 59 | +@pytest.fixture() |
| 60 | +def isolated_project(isolated_projects_root: Path) -> str: |
| 61 | + from kady_agent import projects |
| 62 | + |
| 63 | + project_id = f"pytest-{uuid.uuid4().hex[:10]}" |
| 64 | + projects.create_project("Pytest Project", project_id=project_id) |
| 65 | + projects.ensure_project_exists(project_id) |
| 66 | + return project_id |
| 67 | + |
| 68 | + |
| 69 | +@pytest.fixture() |
| 70 | +def active_project(isolated_project: str) -> Iterator[str]: |
| 71 | + from kady_agent import projects |
| 72 | + |
| 73 | + token = projects.set_active_project(isolated_project) |
| 74 | + try: |
| 75 | + yield isolated_project |
| 76 | + finally: |
| 77 | + projects.ACTIVE_PROJECT.reset(token) |
| 78 | + |
| 79 | + |
| 80 | +@pytest.fixture() |
| 81 | +def client(isolated_projects_root: Path) -> Iterator[TestClient]: |
| 82 | + import server |
| 83 | + |
| 84 | + # The singleton session service can retain sqlite handles across tests. |
| 85 | + server._session_service._services.clear() |
| 86 | + with TestClient(server.app) as test_client: |
| 87 | + yield test_client |
| 88 | + |
| 89 | + |
| 90 | +def make_project_headers(project_id: str) -> dict[str, str]: |
| 91 | + return {"X-Project-Id": project_id} |
| 92 | + |
| 93 | + |
| 94 | +@pytest.fixture() |
| 95 | +def project_headers(isolated_project: str) -> dict[str, str]: |
| 96 | + return make_project_headers(isolated_project) |
| 97 | + |
| 98 | + |
| 99 | +def _port_open(url: str) -> bool: |
| 100 | + parsed = httpx.URL(url) |
| 101 | + host = parsed.host or "127.0.0.1" |
| 102 | + port = parsed.port or (443 if parsed.scheme == "https" else 80) |
| 103 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: |
| 104 | + sock.settimeout(0.3) |
| 105 | + return sock.connect_ex((host, port)) == 0 |
| 106 | + |
| 107 | + |
| 108 | +def _wait_http(url: str, *, timeout: float = 90.0) -> None: |
| 109 | + deadline = time.time() + timeout |
| 110 | + last_error: Exception | None = None |
| 111 | + while time.time() < deadline: |
| 112 | + try: |
| 113 | + response = httpx.get(url, timeout=2.0) |
| 114 | + if response.status_code < 500: |
| 115 | + return |
| 116 | + except Exception as exc: # noqa: BLE001 |
| 117 | + last_error = exc |
| 118 | + time.sleep(1.0) |
| 119 | + raise AssertionError(f"Timed out waiting for {url}: {last_error}") |
| 120 | + |
| 121 | + |
| 122 | +def _require_command(name: str) -> None: |
| 123 | + if shutil.which(name) is None: |
| 124 | + raise AssertionError(f"Required command not found on PATH: {name}") |
| 125 | + |
| 126 | + |
| 127 | +def require_live_environment() -> None: |
| 128 | + for command in ("uv", "node", "npm", "gemini"): |
| 129 | + _require_command(command) |
| 130 | + if not (os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OR_API_KEY")): |
| 131 | + raise AssertionError("OPENROUTER_API_KEY or OR_API_KEY is required for live E2E tests") |
| 132 | + |
| 133 | + |
| 134 | +@pytest.fixture(scope="session") |
| 135 | +def live_projects_root(tmp_path_factory: pytest.TempPathFactory) -> Path: |
| 136 | + root = Path(os.environ.get("KADY_TEST_PROJECTS_ROOT", "")) |
| 137 | + if not root: |
| 138 | + root = tmp_path_factory.mktemp("kady-live-projects") |
| 139 | + root.mkdir(parents=True, exist_ok=True) |
| 140 | + return root |
| 141 | + |
| 142 | + |
| 143 | +@pytest.fixture(scope="session") |
| 144 | +def live_stack(live_projects_root: Path) -> Iterator[dict[str, str]]: |
| 145 | + require_live_environment() |
| 146 | + env = os.environ.copy() |
| 147 | + env["KADY_PROJECTS_ROOT"] = str(live_projects_root) |
| 148 | + env.setdefault("NEXT_PUBLIC_ADK_API_URL", LIVE_BACKEND_URL) |
| 149 | + |
| 150 | + processes: list[subprocess.Popen[str]] = [] |
| 151 | + |
| 152 | + try: |
| 153 | + if not _port_open(LIVE_LITELLM_URL): |
| 154 | + processes.append( |
| 155 | + subprocess.Popen( |
| 156 | + ["uv", "run", "litellm", "--config", "litellm_config.yaml", "--port", "4000"], |
| 157 | + cwd=REPO_ROOT, |
| 158 | + env=env, |
| 159 | + text=True, |
| 160 | + stdout=subprocess.PIPE, |
| 161 | + stderr=subprocess.STDOUT, |
| 162 | + ) |
| 163 | + ) |
| 164 | + |
| 165 | + backend_started = False |
| 166 | + if not _port_open(LIVE_BACKEND_URL): |
| 167 | + backend_started = True |
| 168 | + processes.append( |
| 169 | + subprocess.Popen( |
| 170 | + ["uv", "run", "uvicorn", "server:app", "--port", "8000"], |
| 171 | + cwd=REPO_ROOT, |
| 172 | + env=env, |
| 173 | + text=True, |
| 174 | + stdout=subprocess.PIPE, |
| 175 | + stderr=subprocess.STDOUT, |
| 176 | + ) |
| 177 | + ) |
| 178 | + |
| 179 | + if not _port_open(LIVE_FRONTEND_URL): |
| 180 | + processes.append( |
| 181 | + subprocess.Popen( |
| 182 | + ["npm", "run", "dev", "--", "--hostname", "127.0.0.1"], |
| 183 | + cwd=REPO_ROOT / "web", |
| 184 | + env=env, |
| 185 | + text=True, |
| 186 | + stdout=subprocess.PIPE, |
| 187 | + stderr=subprocess.STDOUT, |
| 188 | + ) |
| 189 | + ) |
| 190 | + |
| 191 | + _wait_http(f"{LIVE_BACKEND_URL}/health") |
| 192 | + _wait_http(LIVE_FRONTEND_URL) |
| 193 | + yield { |
| 194 | + "backend": LIVE_BACKEND_URL, |
| 195 | + "frontend": LIVE_FRONTEND_URL, |
| 196 | + "litellm": LIVE_LITELLM_URL, |
| 197 | + "projects_root": str(live_projects_root if backend_started else REPO_ROOT / "projects"), |
| 198 | + } |
| 199 | + finally: |
| 200 | + for proc in reversed(processes): |
| 201 | + if proc.poll() is None: |
| 202 | + proc.send_signal(signal.SIGTERM) |
| 203 | + try: |
| 204 | + proc.wait(timeout=10) |
| 205 | + except subprocess.TimeoutExpired: |
| 206 | + proc.kill() |
| 207 | + |
| 208 | + |
| 209 | +@pytest.fixture() |
| 210 | +def live_project(live_stack: dict[str, str]) -> Iterator[dict[str, Any]]: |
| 211 | + project_id = f"live-pytest-{uuid.uuid4().hex[:8]}" |
| 212 | + payload = {"id": project_id, "name": "Live Pytest Project", "spendLimitUsd": None} |
| 213 | + response = httpx.post( |
| 214 | + f"{live_stack['backend']}/projects", |
| 215 | + json=payload, |
| 216 | + timeout=120.0, |
| 217 | + ) |
| 218 | + response.raise_for_status() |
| 219 | + try: |
| 220 | + yield {"id": project_id, "headers": make_project_headers(project_id), **live_stack} |
| 221 | + finally: |
| 222 | + httpx.delete( |
| 223 | + f"{live_stack['backend']}/projects/{project_id}", |
| 224 | + headers=make_project_headers(project_id), |
| 225 | + timeout=30.0, |
| 226 | + ) |
| 227 | + |
| 228 | + |
| 229 | +@pytest.fixture(scope="session") |
| 230 | +def browser_page(live_stack: dict[str, str]) -> Iterator[Any]: |
| 231 | + try: |
| 232 | + from playwright.sync_api import sync_playwright |
| 233 | + except ImportError as exc: # pragma: no cover - dependency validation |
| 234 | + raise AssertionError("Playwright is required for browser tests") from exc |
| 235 | + |
| 236 | + with sync_playwright() as playwright: |
| 237 | + browser = playwright.chromium.launch(headless=True) |
| 238 | + page = browser.new_page(base_url=live_stack["frontend"]) |
| 239 | + try: |
| 240 | + yield page |
| 241 | + finally: |
| 242 | + browser.close() |
| 243 | + |
| 244 | + |
| 245 | +def read_jsonl(path: Path) -> list[dict[str, Any]]: |
| 246 | + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] |
0 commit comments