free-claude-code/smoke/lib/server.py
Ali Khokhar 462a9430bb
Add local live smoke test suite (#148)
## Summary
- add an opt-in local `smoke/` pytest suite for API, auth, providers,
CLI, IDE-shaped requests, messaging, voice, tools, and thinking stream
contracts
- keep smoke tests out of normal CI collection with `testpaths =
["tests"]`
- write sanitized smoke artifacts under `.smoke-results/`

## Verification
- `uv run ruff format`
- `uv run ruff check`
- `uv run ty check`
- `uv run ty check smoke`
- `FCC_LIVE_SMOKE=1 FCC_SMOKE_TARGETS=all FCC_SMOKE_RUN_VOICE=1 uv run
pytest smoke -n 0 -m live -s --tb=short` -> 17 passed, 9 skipped
- `uv run pytest` -> 904 passed

## Notes
- Skipped live checks require local credentials/tools/services, such as
provider models, Telegram/Discord targets, voice backend, or Claude CLI.
- `claude-pick` smoke was intentionally removed.
2026-04-23 19:06:09 -07:00

126 lines
3.2 KiB
Python

"""Subprocess lifecycle helpers for local smoke servers."""
from __future__ import annotations
import os
import socket
import subprocess
import time
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import SmokeConfig, redacted
@dataclass(slots=True)
class RunningServer:
base_url: str
port: int
log_path: Path
process: subprocess.Popen[bytes]
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
@contextmanager
def start_server(
config: SmokeConfig,
*,
env_overrides: dict[str, str] | None = None,
command: list[str] | None = None,
name: str = "server",
) -> Iterator[RunningServer]:
port = find_free_port()
config.results_dir.mkdir(parents=True, exist_ok=True)
log_path = config.results_dir / f"{name}-{config.worker_id}-{port}.log"
env = os.environ.copy()
env.update(
{
"HOST": "127.0.0.1",
"PORT": str(port),
"LOG_FILE": str(log_path),
"MESSAGING_PLATFORM": "none",
"PYTHONUNBUFFERED": "1",
}
)
if env_overrides:
env.update(env_overrides)
cmd = command or [
"uv",
"run",
"uvicorn",
"server:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--timeout-graceful-shutdown",
"5",
]
with log_path.open("ab") as log_file:
process = subprocess.Popen(
cmd,
cwd=config.root,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
running = RunningServer(
base_url=f"http://127.0.0.1:{port}",
port=port,
log_path=log_path,
process=process,
)
try:
_wait_for_health(running, timeout_s=config.timeout_s)
yield running
finally:
_stop_process(process)
def _wait_for_health(server: RunningServer, *, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
last_error = ""
while time.monotonic() < deadline:
if server.process.poll() is not None:
break
try:
response = httpx.get(f"{server.base_url}/health", timeout=2.0)
if response.status_code == 200:
return
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(0.25)
log_excerpt = ""
with suppress(OSError):
log_excerpt = server.log_path.read_text(encoding="utf-8", errors="replace")[
-2000:
]
raise AssertionError(
"Smoke server did not become healthy. "
f"last_error={last_error!r} log={redacted(log_excerpt)!r}"
)
def _stop_process(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)