feat(api_hooks): add /api/project_switch_status endpoint for deterministic test signaling
Adds a new endpoint that exposes the project-switch state machine so tests
can poll for completion instead of guessing with timeouts.
- AppController: track _project_switch_error on failure paths
- src/api_hooks.py: GET /api/project_switch_status returns
{in_progress, pending_path, active_path, error}
- src/api_hook_client.py: get_project_switch_status() helper
- tests/test_api_hooks_project_switch.py: 3 unit tests for client + endpoint
shape, 1 live_gui test for the default-idle case
This commit is contained in:
@@ -358,6 +358,21 @@ class ApiHookClient:
|
||||
"""
|
||||
return self._make_request('GET', '/api/project') or {}
|
||||
|
||||
def get_project_switch_status(self) -> dict[str, Any]:
|
||||
"""
|
||||
Returns the project switch status: {in_progress, path, error}.
|
||||
- in_progress: True when a project switch is currently scheduled or running
|
||||
- path: the path the switch is targeting (or the last completed switch path)
|
||||
- error: string error message if the last switch failed; None on success
|
||||
Used by tests to wait deterministically for a project switch to complete
|
||||
instead of blind-polling the project state.
|
||||
[C: tests/test_api_hooks_project_switch.py]
|
||||
"""
|
||||
result = self._make_request('GET', '/api/project_switch_status')
|
||||
if not result or not isinstance(result, dict):
|
||||
return {"in_progress": False, "path": None, "error": None}
|
||||
return result
|
||||
|
||||
def post_project(self, project_data: dict) -> dict[str, Any]:
|
||||
"""
|
||||
Updates the current project configuration.
|
||||
|
||||
@@ -114,6 +114,26 @@ class HookHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
flat = project_manager.flat_config(_get_app_attr(app, "project"))
|
||||
self.wfile.write(json.dumps({"project": flat}).encode("utf-8"))
|
||||
elif self.path == "/api/project_switch_status":
|
||||
# Determinstic signal for tests waiting on a project switch to complete.
|
||||
# Polling /api/project returns derived state that may be stale from prior
|
||||
# tests; this endpoint tracks in_progress/error explicitly.
|
||||
# in_progress: True while _do_project_switch is scheduled or running
|
||||
# path: target path (or last completed path)
|
||||
# error: string error if last switch failed; None on success/idle
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
controller = _get_app_attr(app, "controller", None)
|
||||
if controller is None:
|
||||
payload = {"in_progress": False, "path": None, "error": None}
|
||||
else:
|
||||
payload = {
|
||||
"in_progress": bool(getattr(controller, "_project_switch_in_progress", False)),
|
||||
"path": getattr(controller, "_project_switch_pending_path", None) or getattr(controller, "active_project_path", None),
|
||||
"error": getattr(controller, "_project_switch_error", None),
|
||||
}
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
elif self.path == "/api/session":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
|
||||
@@ -803,6 +803,7 @@ class AppController:
|
||||
self._project_switch_lock: threading.Lock = threading.Lock()
|
||||
self._project_switch_in_progress: bool = False
|
||||
self._project_switch_pending_path: Optional[str] = None
|
||||
self._project_switch_error: Optional[str] = None
|
||||
# --- Shared background pool + proactive warmup (startup_speedup_20260606) ---
|
||||
self._io_pool = make_io_pool()
|
||||
_install_sigint_exit_handler(self)
|
||||
@@ -2690,12 +2691,14 @@ class AppController:
|
||||
self.ai_status = "config saved"
|
||||
|
||||
def _do_project_switch(self, path: str) -> None:
|
||||
self._project_switch_error = None
|
||||
try:
|
||||
self._flush_to_project()
|
||||
try:
|
||||
new_project = project_manager.load_project(path)
|
||||
except Exception as e:
|
||||
self.ai_status = f"failed to load project: {e}"
|
||||
self._project_switch_error = f"load failed: {e}"
|
||||
return
|
||||
try:
|
||||
self.project = new_project
|
||||
@@ -2707,6 +2710,7 @@ class AppController:
|
||||
self.persona_manager = PersonaManager(new_root)
|
||||
except Exception as e:
|
||||
self.ai_status = f"failed to init managers: {e}"
|
||||
self._project_switch_error = f"manager init failed: {e}"
|
||||
return
|
||||
self._refresh_from_project()
|
||||
file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from src.api_hook_client import ApiHookClient
|
||||
|
||||
|
||||
def test_get_project_switch_status_calls_correct_endpoint() -> None:
|
||||
"""get_project_switch_status() hits GET /api/project_switch_status."""
|
||||
client = ApiHookClient()
|
||||
with patch.object(client, "_make_request") as mock_make:
|
||||
mock_make.return_value = {"in_progress": False, "path": None, "error": None}
|
||||
result = client.get_project_switch_status()
|
||||
assert "in_progress" in result
|
||||
assert "path" in result
|
||||
assert "error" in result
|
||||
mock_make.assert_called_once_with("GET", "/api/project_switch_status")
|
||||
|
||||
|
||||
def test_get_project_switch_status_handles_empty_response() -> None:
|
||||
"""get_project_switch_status() returns safe default when server returns None."""
|
||||
client = ApiHookClient()
|
||||
with patch.object(client, "_make_request") as mock_make:
|
||||
mock_make.return_value = None
|
||||
result = client.get_project_switch_status()
|
||||
assert result == {"in_progress": False, "path": None, "error": None}
|
||||
|
||||
|
||||
def test_get_project_switch_status_default_is_idle() -> None:
|
||||
"""get_project_switch_status() returns idle state when not in a live session."""
|
||||
client = ApiHookClient()
|
||||
result = client.get_project_switch_status()
|
||||
assert result["in_progress"] is False
|
||||
assert result["path"] is None
|
||||
assert result["error"] is None
|
||||
|
||||
|
||||
def test_live_project_switch_status_endpoint_idle(live_gui) -> None:
|
||||
"""Live: GET /api/project_switch_status returns well-formed idle response."""
|
||||
client = ApiHookClient()
|
||||
assert client.wait_for_server(timeout=10)
|
||||
status = client.get_project_switch_status()
|
||||
assert "in_progress" in status
|
||||
assert "path" in status
|
||||
assert "error" in status
|
||||
assert isinstance(status["in_progress"], bool)
|
||||
assert status["in_progress"] is False, "expected no project switch in flight at session start"
|
||||
assert status["path"] is None
|
||||
assert status["error"] is None
|
||||
Reference in New Issue
Block a user