""" Full e2e lifecycle: up → health-wait → test → down → result. """ from __future__ import annotations import time from dataclasses import dataclass from pathlib import Path from .sandbox import Sandbox from .schema import E2EConfig @dataclass class RunResult: sandbox_id: str repo: str passed: bool exit_code: int duration_s: float output: str error: str = "" def run_e2e( repo_path: Path, host: str, ssh_user: str = "root", ssh_key: str | None = None, keep: bool = False, ) -> RunResult: config = E2EConfig.load(repo_path) sandbox = Sandbox( host=host, repo_path=repo_path, ssh_user=ssh_user, ssh_key=ssh_key, ) project_name = f"e2e-{config.name}-{sandbox.sandbox_id}" compose_path = f"{sandbox.remote_dir}/{config.compose_file}" started = time.time() output_lines: list[str] = [] def log(msg: str) -> None: print(msg) output_lines.append(msg) log(f"\n{'='*60}") log(f"E2E run: {config.name} sandbox={sandbox.sandbox_id}") log(f"Host: {host} project: {project_name}") log(f"{'='*60}\n") try: # 1. Provision sandbox.provision() # 2. docker compose up env_flags = " ".join(f"-e {k}={v}" for k, v in config.env.items()) up_cmd = ( f"cd {sandbox.remote_dir} && " f"docker compose -p {project_name} -f {compose_path} up -d" ) log(f"[runner] docker compose up ({project_name})") rc, out = sandbox.run(up_cmd, timeout=180, stream=False) output_lines.append(out) if rc != 0: raise RuntimeError(f"docker compose up failed (exit {rc}):\n{out}") # 3. Health checks for hc in config.health_checks: ok = sandbox.wait_for_url(hc.url, timeout=hc.timeout) if not ok: raise RuntimeError(f"Health check failed: {hc.name} ({hc.url})") # 4. Run tests log(f"\n[runner] running: {config.test_command}") test_cmd = f"cd {sandbox.remote_dir} && {config.test_command}" rc, test_out = sandbox.run(test_cmd, timeout=config.timeout, stream=False) output_lines.append(test_out) print(test_out) passed = rc == 0 duration = time.time() - started log(f"\n[runner] {'PASSED' if passed else 'FAILED'} (exit={rc}, {duration:.1f}s)") return RunResult( sandbox_id=sandbox.sandbox_id, repo=config.name, passed=passed, exit_code=rc, duration_s=duration, output="\n".join(output_lines), ) except Exception as exc: duration = time.time() - started log(f"\n[runner] ERROR: {exc}") return RunResult( sandbox_id=sandbox.sandbox_id, repo=config.name, passed=False, exit_code=-1, duration_s=duration, output="\n".join(output_lines), error=str(exc), ) finally: _compose_down(sandbox, project_name, compose_path, config, keep) def _compose_down( sandbox: Sandbox, project_name: str, compose_path: str, config: E2EConfig, keep: bool, ) -> None: if keep or config.cleanup == "never": print(f"[runner] skipping cleanup (keep={keep}, cleanup={config.cleanup})") return print(f"[runner] docker compose down ({project_name})") down_cmd = ( f"cd {sandbox.remote_dir} && " f"docker compose -p {project_name} -f {compose_path} down -v --remove-orphans 2>&1 || true" ) sandbox.run(down_cmd, timeout=60) if not keep: sandbox.teardown()