from __future__ import annotations from pathlib import Path import pytest from device.manager import DeviceManager from driver.base import Driver from host_agent import execution from host_agent.cloud_planner_client import CloudProxyToolCallingClient from host_agent.config import HostAgentConfig from host_agent.execution import create_execution_factories from runtime.ai_planner import AIPlanner from runtime.planner import Planner from runtime.task import TaskRunner from workflow.runner import WorkflowRunner from workflow.store import WorkflowStore class FakeDriver(Driver): def __init__(self) -> None: self.calls: list[tuple[str, tuple[object, ...]]] = [] def connect(self) -> None: self.calls.append(("connect", ())) def disconnect(self) -> None: return None def screenshot(self) -> bytes: return b"fake-screenshot-bytes" def tap(self, x: float, y: float) -> None: return None def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None: return None def swipe( self, start_x: float, start_y: float, end_x: float, end_y: float, duration_ms: int = 500, ) -> None: return None def swipe_path( self, waypoints: list[tuple[float, float]], duration_ms: int ) -> None: return None def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None: return None def input(self, text: str) -> None: return None def launch(self, app_id: str) -> None: return None def terminate(self, app_id: str) -> None: return None def tree(self): return None def home(self) -> None: return None def lock(self) -> None: return None def unlock(self) -> None: return None def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) -> None: manager = DeviceManager() workflow_store = WorkflowStore(tmp_path / "workflows.sqlite3") factories = create_execution_factories( manager, workflow_store=workflow_store, ) task_runner = factories.task_runner_factory() workflow_runner = factories.workflow_runner_factory() assert isinstance(task_runner, TaskRunner) assert isinstance(workflow_runner, WorkflowRunner) assert workflow_runner.store is workflow_store assert isinstance(workflow_runner.task_runner_factory(), TaskRunner) def test_created_task_runner_screenshot_provider_uses_configured_manager( tmp_path, ) -> None: """Regression test: `create_task_runner()` must thread the Host Agent's own `manager` into `screenshot_provider`. Omitting `manager=` makes the tool fall back to the process-global `DEFAULT_MANAGER` singleton, which never has this device registered, so it raises `DeviceNotFoundError` even though the device is connected on the manager actually in use. """ manager = DeviceManager() manager.register_device("phone-1", lambda: FakeDriver()) manager.connect("phone-1") factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") ) task_runner = factories.task_runner_factory() assert task_runner.screenshot_provider("phone-1") == b"fake-screenshot-bytes" def test_created_task_runner_observer_uses_configured_manager( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: """Regression test: `create_task_runner()` must thread the Host Agent's own `manager` into `observer` the same way it does for `screenshot_provider` -- see the test above for the failure mode this guards against. """ manager = DeviceManager() seen: dict[str, object] = {} def fake_describe_screen(device_id, *, manager=None): seen["device_id"] = device_id seen["manager"] = manager return "scene-stub" monkeypatch.setattr(execution, "describe_screen", fake_describe_screen) factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") ) task_runner = factories.task_runner_factory() assert task_runner.observer("phone-1") == "scene-stub" assert seen == {"device_id": "phone-1", "manager": manager} @pytest.mark.parametrize( ("driver_type", "expected_platform"), [("wda", "ios"), ("uiautomator2", "android")], ) def test_created_task_runner_resolves_platform_from_configured_driver( tmp_path, driver_type: str, expected_platform: str ) -> None: manager = DeviceManager() manager.register_device("phone-1", lambda: FakeDriver(), driver_type=driver_type) factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") ) task_runner = factories.task_runner_factory() assert task_runner.device_platform_provider is not None assert task_runner.device_platform_provider("phone-1") == expected_platform def test_created_task_runner_defaults_to_ai_planner( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) manager = DeviceManager() factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") ) task_runner = factories.task_runner_factory() assert isinstance(task_runner.planner, AIPlanner) def test_created_task_runner_honors_explicit_ai_planner_opt_out( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("AI_PLANNER_ENABLED", "false") manager = DeviceManager() factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3") ) task_runner = factories.task_runner_factory() assert type(task_runner.planner) is Planner @pytest.mark.parametrize("transport", [None, "cloud"]) def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client( tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None ) -> None: monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) manager = DeviceManager() host_agent_config = HostAgentConfig( control_plane_url="https://control-plane.example", host_id="host-a", token="token-a", **({} if transport is None else {"ai_planner_transport": transport}), ) factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"), host_agent_config=host_agent_config, ) task_runner = factories.task_runner_factory() assert isinstance(task_runner.planner, AIPlanner) assert isinstance(task_runner.planner.client, CloudProxyToolCallingClient) assert task_runner.planner.client.config is host_agent_config def test_explicit_direct_transport_preserves_local_provider_construction( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False) manager = DeviceManager() host_agent_config = HostAgentConfig( control_plane_url="https://control-plane.example", host_id="host-a", token="token-a", ai_planner_transport="direct", ) factories = create_execution_factories( manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"), host_agent_config=host_agent_config, ) task_runner = factories.task_runner_factory() assert isinstance(task_runner.planner, AIPlanner) assert not isinstance(task_runner.planner.client, CloudProxyToolCallingClient) def test_runtime_owned_packages_do_not_import_host_or_cloud_concerns() -> None: root = Path(__file__).resolve().parents[3] forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent") for package in ("core", "device", "driver", "runtime", "tools"): for path in (root / package).rglob("*.py"): source = path.read_text(encoding="utf-8") assert not any(token in source for token in forbidden), path