From a6605d9889e1e2dab4324ecda78d2fbc9c08ee40 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 8 Jun 2026 13:04:12 -0400 Subject: [PATCH] feat(api_hook_client): add wait_for_project_switch for deterministic test waits Adds a polling helper that blocks until the project switch completes, errors out, or times out. Replaces the fragile 10x1s blind poll in test_full_live_workflow with a condition-based wait on the /api/project_switch_status endpoint. Features: - Polls /api/project_switch_status every 200ms (configurable) - Returns immediately on error (with the error in the result) - Path matching: exact match OR basename match (handles absolute vs relative) - Times out with a clear 'timeout' flag instead of a generic assertion - Optional expected_path: if None, returns on any in_progress=False - src/api_hook_client.py: new wait_for_project_switch method (37 lines) - tests/test_api_hook_client_wait_for_project_switch.py: 6 unit tests with mocked _make_request covering all paths --- src/api_hook_client.py | 29 ++++++ ...api_hook_client_wait_for_project_switch.py | 91 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/test_api_hook_client_wait_for_project_switch.py diff --git a/src/api_hook_client.py b/src/api_hook_client.py index 44228790..67b8c260 100644 --- a/src/api_hook_client.py +++ b/src/api_hook_client.py @@ -373,6 +373,35 @@ class ApiHookClient: return {"in_progress": False, "path": None, "error": None} return result + def wait_for_project_switch(self, expected_path: str = None, timeout: float = 30.0, poll_interval: float = 0.2) -> dict[str, Any]: + """ + Blocks until the project switch completes (or fails). Returns the final + status dict. If expected_path is provided, also waits until the reported + path matches it (or its basename matches). + Fails fast (within timeout seconds) with a clear error if: + - the controller reports an error during the switch + - the path doesn't reach expected_path within the timeout + - the controller is hung (in_progress stays True) + [C: tests/test_live_workflow.py:test_full_live_workflow] + """ + import os + start = time.time() + last_status = {"in_progress": True, "path": None, "error": None} + while time.time() - start < timeout: + last_status = self.get_project_switch_status() + if last_status.get("error"): + return last_status + if not last_status.get("in_progress"): + if expected_path is None: + return last_status + if last_status.get("path") == expected_path: + return last_status + if last_status.get("path") and os.path.basename(str(last_status.get("path"))) == os.path.basename(expected_path): + return last_status + time.sleep(poll_interval) + last_status["timeout"] = True + return last_status + def post_project(self, project_data: dict) -> dict[str, Any]: """ Updates the current project configuration. diff --git a/tests/test_api_hook_client_wait_for_project_switch.py b/tests/test_api_hook_client_wait_for_project_switch.py new file mode 100644 index 00000000..cf939d16 --- /dev/null +++ b/tests/test_api_hook_client_wait_for_project_switch.py @@ -0,0 +1,91 @@ +"""Tests for ApiHookClient.wait_for_project_switch. + +These tests use mocked _make_request so they don't require a live_gui +session. They verify the polling logic: success, error, timeout, and +path-matching behavior. +""" +import sys +import os +import time +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_wait_for_project_switch_returns_immediately_when_idle() -> None: + """If the controller is already idle and path matches, return immediately.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": False, "path": "C:/projects/foo.toml", "error": None} + start = time.time() + result = client.wait_for_project_switch(expected_path="C:/projects/foo.toml", timeout=5.0) + elapsed = time.time() - start + assert result["in_progress"] is False + assert result["path"] == "C:/projects/foo.toml" + assert result.get("error") is None + assert "timeout" not in result + assert elapsed < 1.0, f"should return quickly, took {elapsed}s" + + +def test_wait_for_project_switch_surfaces_error() -> None: + """If the controller reports an error, return immediately with the error.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": False, "path": None, "error": "load failed: file not found"} + result = client.wait_for_project_switch(expected_path="C:/missing.toml", timeout=5.0) + assert result["error"] == "load failed: file not found" + assert "timeout" not in result + + +def test_wait_for_project_switch_matches_by_basename() -> None: + """Path matching is loose: matches by basename to handle absolute vs relative.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": False, "path": "C:/abs/path/temp_project.toml", "error": None} + result = client.wait_for_project_switch( + expected_path="tests/artifacts/temp_project.toml", + timeout=5.0, + ) + assert result["path"].endswith("temp_project.toml") + assert "timeout" not in result + + +def test_wait_for_project_switch_times_out_when_in_progress() -> None: + """If the controller stays in_progress past the timeout, return with timeout flag.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": True, "path": None, "error": None} + result = client.wait_for_project_switch(expected_path="C:/foo.toml", timeout=0.5, poll_interval=0.1) + assert result.get("timeout") is True + assert result["in_progress"] is True + + +def test_wait_for_project_switch_no_expected_path() -> None: + """If expected_path is None, return when in_progress becomes False (any path).""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": False, "path": "C:/anything.toml", "error": None} + result = client.wait_for_project_switch(expected_path=None, timeout=5.0) + assert result["in_progress"] is False + assert "timeout" not in result + + +def test_wait_for_project_switch_polls_then_completes() -> None: + """Simulate a switch that takes 2 polls to complete.""" + client = ApiHookClient() + call_count = [0] + + def fake_request(*args, **kwargs): + call_count[0] += 1 + if call_count[0] < 3: + return {"in_progress": True, "path": None, "error": None} + return {"in_progress": False, "path": "C:/foo.toml", "error": None} + + with patch.object(client, "_make_request", side_effect=fake_request): + result = client.wait_for_project_switch(expected_path="C:/foo.toml", timeout=5.0, poll_interval=0.05) + assert result["in_progress"] is False + assert result["path"] == "C:/foo.toml" + assert "timeout" not in result + assert call_count[0] >= 3