"""Tests for Web UI Training Live Monitor — SSE endpoints and progress API.""" import os from unittest.mock import MagicMock, patch import pytest def _auth_headers(): """Return auth headers with the current UI token.""" from soup_cli.ui.app import get_auth_token return {"Authorization": f"Bearer {get_auth_token()}"} class TestTrainLogsSSE: """Test GET /api/train/logs SSE endpoint.""" def test_logs_endpoint_exists(self): """Route /api/train/logs should be registered.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app app = create_app() routes = [route.path for route in app.routes] assert "/api/train/logs" in routes def test_logs_returns_event_stream(self): """SSE endpoint should return text/event-stream content type.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app # Simulate a running process with output mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.stdout = iter([b"Epoch 1/3\n", b"Loss: 2.5\n"]) mock_proc.poll.side_effect = [None, None, 0] ui_mod._train_process = mock_proc client = TestClient(create_app()) try: with client.stream("GET", "/api/train/logs") as resp: assert resp.status_code == 200 assert "text/event-stream" in resp.headers["content-type"] # Read at least one event lines = [] for line in resp.iter_lines(): lines.append(line) if len(lines) >= 2: break finally: ui_mod._train_process = None def test_logs_no_training_returns_done(self): """When no training is running, SSE should emit done event.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None client = TestClient(create_app()) with client.stream("GET", "/api/train/logs") as resp: assert resp.status_code == 200 body = b"" for chunk in resp.iter_bytes(): body += chunk text = body.decode() assert "done" in text def test_logs_last_event_id_reconnection(self): """Last-Event-ID header should skip earlier lines.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app mock_proc = MagicMock() lines = [b"Line 1\n", b"Line 2\n", b"Line 3\n"] mock_proc.stdout = iter(lines) mock_proc.poll.side_effect = [None, None, None, 0] ui_mod._train_process = mock_proc client = TestClient(create_app()) try: with client.stream( "GET", "/api/train/logs", headers={"Last-Event-ID": "1"}, ) as resp: assert resp.status_code == 200 body = b"" for chunk in resp.iter_bytes(): body += chunk text = body.decode() # Line 1 (id=0) should be skipped, Line 2+ should appear assert "Line 2" in text or "Line 3" in text finally: ui_mod._train_process = None def test_logs_no_auth_required(self): """SSE log endpoint is GET (read-only) — no auth needed.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None client = TestClient(create_app()) # No auth header — should still succeed (not 401) with client.stream("GET", "/api/train/logs") as resp: assert resp.status_code == 200 class TestLiveMetricsSSE: """Test GET /api/train/metrics/live SSE endpoint.""" def test_metrics_live_endpoint_exists(self): """Route /api/train/metrics/live should be registered.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app app = create_app() routes = [route.path for route in app.routes] assert "/api/train/metrics/live" in routes def test_metrics_live_returns_event_stream(self, tmp_path): """SSE metrics endpoint should return text/event-stream content type.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app # Simulate running training mock_proc = MagicMock() mock_proc.poll.return_value = 0 # Already finished ui_mod._train_process = mock_proc db_path = tmp_path / "test.db" with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) with client.stream("GET", "/api/train/metrics/live") as resp: assert resp.status_code == 200 assert "text/event-stream" in resp.headers["content-type"] ui_mod._train_process = None def test_metrics_live_emits_new_rows(self, tmp_path): """SSE should emit metric rows as they appear in the DB.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app db_path = tmp_path / "test.db" # Pre-populate a run with metrics from soup_cli.experiment.tracker import ExperimentTracker tracker = ExperimentTracker(db_path=db_path) run_id = tracker.start_run( config_dict={"base": "test", "task": "sft"}, device="cpu", device_name="CPU", gpu_info={"memory_total": "N/A"}, ) tracker.log_metrics(run_id, step=10, loss=2.5, lr=1e-5) tracker.close() # Simulate finished training mock_proc = MagicMock() mock_proc.poll.return_value = 0 ui_mod._train_process = mock_proc with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) with client.stream( "GET", f"/api/train/metrics/live?run_id={run_id}" ) as resp: assert resp.status_code == 200 body = b"" for chunk in resp.iter_bytes(): body += chunk text = body.decode() # Should contain metric data assert "step" in text or "done" in text ui_mod._train_process = None def test_metrics_live_no_auth_required(self, tmp_path): """SSE metrics endpoint is GET (read-only) — no auth needed.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app mock_proc = MagicMock() mock_proc.poll.return_value = 0 ui_mod._train_process = mock_proc db_path = tmp_path / "test.db" with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) with client.stream("GET", "/api/train/metrics/live") as resp: assert resp.status_code == 200 ui_mod._train_process = None def test_metrics_live_done_when_no_training(self, tmp_path): """Should emit done event when no training is running.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None db_path = tmp_path / "test.db" with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) with client.stream("GET", "/api/train/metrics/live") as resp: body = b"" for chunk in resp.iter_bytes(): body += chunk text = body.decode() assert "done" in text ui_mod._train_process = None class TestTrainProgress: """Test GET /api/train/progress endpoint.""" def test_progress_endpoint_exists(self): """Route /api/train/progress should be registered.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app app = create_app() routes = [route.path for route in app.routes] assert "/api/train/progress" in routes def test_progress_not_running(self): """Should return running=false when no training.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None client = TestClient(create_app()) response = client.get("/api/train/progress") assert response.status_code == 200 data = response.json() assert data["running"] is False def test_progress_running_with_metrics(self, tmp_path): """Should return progress when training is running.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app db_path = tmp_path / "test.db" from soup_cli.experiment.tracker import ExperimentTracker tracker = ExperimentTracker(db_path=db_path) run_id = tracker.start_run( config_dict={"base": "test", "task": "sft"}, device="cpu", device_name="CPU", gpu_info={"memory_total": "N/A"}, ) tracker.log_metrics(run_id, step=50, loss=1.5, lr=1e-5) tracker.close() mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.pid = 9999 ui_mod._train_process = mock_proc with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) response = client.get(f"/api/train/progress?run_id={run_id}") assert response.status_code == 200 data = response.json() assert data["running"] is True assert data["current_step"] == 50 ui_mod._train_process = None def test_progress_returns_correct_fields(self, tmp_path): """Progress response should contain all required fields.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app db_path = tmp_path / "test.db" from soup_cli.experiment.tracker import ExperimentTracker tracker = ExperimentTracker(db_path=db_path) run_id = tracker.start_run( config_dict={"base": "test", "task": "sft"}, device="cpu", device_name="CPU", gpu_info={"memory_total": "N/A"}, ) tracker.log_metrics(run_id, step=10, loss=2.0, lr=1e-5) tracker.close() mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.pid = 1234 ui_mod._train_process = mock_proc with patch.dict(os.environ, {"SOUP_DB_PATH": str(db_path)}): client = TestClient(create_app()) response = client.get(f"/api/train/progress?run_id={run_id}") data = response.json() expected_keys = {"running", "current_step", "run_id"} assert expected_keys.issubset(set(data.keys())) ui_mod._train_process = None def test_progress_no_auth_required(self): """Progress endpoint is GET (read-only) — no auth needed.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None client = TestClient(create_app()) response = client.get("/api/train/progress") assert response.status_code == 200 class TestSSEGracefulClose: """Test that SSE endpoints close gracefully.""" def test_logs_closes_when_training_stops(self): """Log SSE should end when training process finishes.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app mock_proc = MagicMock() mock_proc.stdout = iter([b"Starting\n"]) mock_proc.poll.side_effect = [None, 0] ui_mod._train_process = mock_proc client = TestClient(create_app()) try: with client.stream("GET", "/api/train/logs") as resp: body = b"" for chunk in resp.iter_bytes(): body += chunk text = body.decode() # Stream should terminate with done event assert "done" in text finally: ui_mod._train_process = None def test_heartbeat_event(self): """SSE should include heartbeat comments to keep connection alive.""" # This is a design test — heartbeats are sent as SSE comments (:heartbeat) # Testing that the generator yields at least one event try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") import soup_cli.ui.app as ui_mod from soup_cli.ui.app import create_app ui_mod._train_process = None client = TestClient(create_app()) with client.stream("GET", "/api/train/logs") as resp: body = b"" for chunk in resp.iter_bytes(): body += chunk # Stream should have some content (at least done event) assert len(body) > 0 ui_mod._train_process = None