feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+103 -160
View File
@@ -5,190 +5,133 @@
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the official Python SDK (ArtemisClient, Task, TaskResult)."""
"""Compatibility tests for the single, remote-only ArtemisClient implementation."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from typing import Any
import pytest
from artemis import ArtemisClient, Task, TaskResult
import artemis
from artemis import ArtemisClient, ConcurrencyMode, Task, TaskResult
from artemis_client import ArtemisClient as ThinArtemisClient
from artemis_client import TaskResult as ThinTaskResult
from artemis_client.errors import NotFoundError
@pytest.mark.asyncio
async def test_artemis_client_run_mock():
"""Verify ArtemisClient runs a task through the execution pipeline."""
client = ArtemisClient(device_id="mock-test-dev", default_profile="pro")
assert client.device_id == "mock-test-dev"
assert client.default_profile == "pro"
class FakeTransport:
def __init__(self) -> None:
self.responses: dict[tuple[str, str], list[Any]] = defaultdict(list)
self.calls: list[tuple[str, str, Mapping[str, Any] | None]] = []
result = await client.run(goal="Open Settings and enable Dark Theme")
assert isinstance(result, TaskResult)
assert result.status in ("completed", "running", "success")
assert result.trace_id.startswith("trace_") or len(result.trace_id) > 0
def add(self, method: str, path: str, *responses: Any) -> None:
self.responses[(method, path)].extend(responses)
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
self.calls.append((method, path, json_body))
queued = self.responses[(method, path)]
if not queued:
raise AssertionError(f"Unexpected request: {method} {path}")
response = queued.pop(0)
if isinstance(response, BaseException):
raise response
return response
def test_task_model_validation():
"""Verify Task validation and default arguments."""
task = Task(goal="Search for flight prices", profile="flash", device_id="emulator-5554")
assert task.goal == "Search for flight prices"
assert task.profile == "flash"
assert task.device_id == "emulator-5554"
assert task.max_turns == 30
assert task.locked_package is None
def test_full_runtime_reexports_thin_client_types():
assert ArtemisClient is ThinArtemisClient
assert TaskResult is ThinTaskResult
assert artemis.ArtemisClient is ThinArtemisClient
@pytest.mark.asyncio
async def test_artemis_client_stream_run():
"""Verify ArtemisClient.stream_run yields real-time StreamEvents."""
from artemis import StreamEvent, StreamEventType
def test_legacy_constructor_settings_are_remote_defaults():
client = ArtemisClient(
device_serial="emulator-5554",
default_profile="pro",
concurrency_mode=ConcurrencyMode.PER_DEVICE,
transport=FakeTransport(),
)
client = ArtemisClient(device_id="mock-stream-dev", default_profile="flash")
collected_events: list[StreamEvent] = []
def on_event(ev: StreamEvent):
collected_events.append(ev)
events: list[StreamEvent] = []
async for event in client.stream_run(
goal="Test streaming UI action flow",
profile="flash",
callbacks=[on_event],
):
events.append(event)
assert len(events) >= 2
assert events[0].event_type == StreamEventType.STATUS
assert events[0].payload.get("status") == "starting"
assert len(collected_events) == len(events)
@pytest.mark.asyncio
async def test_client_device_specification():
"""Verify target device can be set via init, setters, or per-task run parameters."""
client = ArtemisClient(device_serial="emulator-5554", default_profile="flash")
assert client.base_url == "http://127.0.0.1:8000"
assert client.device_id == "emulator-5554"
assert client.device_serial == "emulator-5554"
# Mutate via property setter
client.device_serial = "pixel-9-pro"
assert client.device_id == "pixel-9-pro"
assert client.device_serial == "pixel-9-pro"
# Mutate via chaining method
client.set_device("pixel-8")
assert client.device_id == "pixel-8"
# Override device per run call using device_serial
res1 = await client.run("Task on dev 1", device_serial="target-device-alpha")
assert res1.device_id == "target-device-alpha"
# Override device per run call using device_id
res2 = await client.run("Task on dev 2", device_id="target-device-beta")
assert res2.device_id == "target-device-beta"
@pytest.mark.asyncio
async def test_client_stream_run_device_specification():
"""Verify stream_run propagates custom target device."""
client = ArtemisClient(device_id="default-stream-dev", default_profile="flash")
events = []
async for ev in client.stream_run("Test goal", device_serial="explicit-stream-dev"):
events.append(ev)
assert len(events) >= 1
assert events[0].payload.get("device_id") == "explicit-stream-dev"
@pytest.mark.asyncio
async def test_client_concurrency_mode_configuration():
"""Verify concurrency_mode can be configured as 'global' or 'per_device'."""
from artemis import ConcurrencyMode
client = ArtemisClient(concurrency_mode=ConcurrencyMode.GLOBAL)
assert client.concurrency_mode == "global"
client.set_concurrency_mode(ConcurrencyMode.PER_DEVICE)
assert client.default_profile == "pro"
assert client.concurrency_mode == "per_device"
client.concurrency_mode = "global"
assert client.concurrency_mode == "global"
assert client.set_device("pixel-10") is client
assert client.device_serial == "pixel-10"
@pytest.mark.asyncio
async def test_sdk_multi_device_parallel_execution():
"""Verify per_device concurrency allows tasks on different devices to run concurrently."""
import asyncio
async def test_run_uses_remote_api_and_returns_thin_result():
task_id = "00000000-0000-4000-8000-000000000001"
transport = FakeTransport()
transport.add(
"POST",
"/api/run",
{"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]},
)
transport.add(
"GET",
f"/api/sessions/{task_id}",
NotFoundError(404, "not ready"),
{"session_id": task_id, "status": "completed", "current_turn": 3},
)
transport.add(
"GET",
"/api/status",
{"status": "running", "queue": [{"session_id": task_id, "status": "pending"}]},
)
client = ArtemisClient(
device_serial="pixel-10",
default_profile="flash",
poll_interval=0.001,
transport=transport,
)
client_a = ArtemisClient(device_id="mock-dev-parallel-1", concurrency_mode="per_device")
client_b = ArtemisClient(device_id="mock-dev-parallel-2", concurrency_mode="per_device")
result = await client.run("Open Settings", task_id=task_id, timeout=1)
# Run tasks on two different devices concurrently
task_a = asyncio.create_task(client_a.run("Task on device 1"))
task_b = asyncio.create_task(client_b.run("Task on device 2"))
res_a, res_b = await asyncio.gather(task_a, task_b)
assert res_a.status in ("completed", "running", "success")
assert res_b.status in ("completed", "running", "success")
assert res_a.device_id == "mock-dev-parallel-1"
assert res_b.device_id == "mock-dev-parallel-2"
assert isinstance(result, ThinTaskResult)
assert result.succeeded
request_body = transport.calls[0][2]
assert request_body is not None
assert request_body["device_serial"] == "pixel-10"
assert request_body["profile"] == "flash"
@pytest.mark.asyncio
async def test_sdk_global_concurrency_serializes_across_devices():
"""Verify global concurrency restricts execution to 1 task across all devices."""
import asyncio
from artemis.runtime import DeviceBusyError, DeviceExecutionLock
# Pre-acquire a lock on device A with global concurrency mode
lock_a = DeviceExecutionLock("mock-global-dev-1", "active global task", concurrency_mode="global")
lock_a.acquire()
try:
client_b = ArtemisClient(device_id="mock-global-dev-2", concurrency_mode="global")
# Attempting non-blocking execution on device B should fail because global concurrency limit (1) is held by device A
with pytest.raises(DeviceBusyError, match="Global task concurrency limit"):
await client_b.run("Task on device 2", blocking=False)
finally:
lock_a.release()
@pytest.mark.asyncio
async def test_sdk_run_task_with_device_and_concurrency():
"""Verify Task object with device_serial and concurrency_mode executes via run_task."""
client = ArtemisClient()
async def test_legacy_task_object_is_forwarded_to_remote_api():
task_id = "00000000-0000-4000-8000-000000000002"
transport = FakeTransport()
transport.add(
"POST",
"/api/run",
{"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]},
)
transport.add(
"GET",
f"/api/sessions/{task_id}",
{"session_id": task_id, "status": "success"},
)
client = ArtemisClient(transport=transport)
task = Task(
goal="Verify search functionality",
profile="flash",
device_serial="device-from-task-obj",
concurrency_mode="per_device",
device_serial="pixel-10",
locked_package="com.android.settings",
)
assert task.device_id == "device-from-task-obj"
assert task.concurrency_mode == "per_device"
res = await client.run_task(task)
assert res.device_id == "device-from-task-obj"
assert res.status in ("completed", "running", "success")
result = await client.run_task(task, task_id=task_id, timeout=1)
def test_builder_and_agent_concurrency_and_device_targeting():
"""Verify AgentConfigBuilder and Agent support device targeting and concurrency configuration."""
from artemis import Agent, Builders
# 1. Builder supports string for_device directly
cfg1 = Builders.AgentConfig.for_device("emulator-9999").with_concurrency_mode("global").build()
assert cfg1.device_id == "emulator-9999"
assert cfg1.concurrency_mode == "global"
# 2. Builder supports for_device_serial
cfg2 = Builders.AgentConfig.for_device_serial("pixel-test-serial").with_concurrency_mode("per_device").build()
assert cfg2.device_id == "pixel-test-serial"
assert cfg2.concurrency_mode == "per_device"
# 3. Agent supports device_serial and concurrency_mode directly in constructor
agent = Agent(device_serial="pixel-constructor-dev", concurrency_mode="global")
assert agent._config.device_id == "pixel-constructor-dev"
assert agent._config.concurrency_mode == "global"
assert result.succeeded
request_body = transport.calls[0][2]
assert request_body is not None
assert request_body["locked_app_package"] == "com.android.settings"