mirror of https://github.com/razor-ai/soup.git
446 lines
15 KiB
Python
446 lines
15 KiB
Python
"""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
|