- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization) - Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration - Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery - Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution - Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges - Add comprehensive unit test coverage and edge-case verification scripts
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# 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)."""
|
|
|
|
import pytest
|
|
from artemis import ArtemisClient, Task, TaskResult
|
|
|
|
|
|
@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"
|
|
|
|
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 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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artemis_client_stream_run():
|
|
"""Verify ArtemisClient.stream_run yields real-time StreamEvents."""
|
|
from artemis import StreamEvent, StreamEventType
|
|
|
|
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.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.concurrency_mode == "per_device"
|
|
|
|
client.concurrency_mode = "global"
|
|
assert client.concurrency_mode == "global"
|
|
|
|
|
|
@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
|
|
|
|
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")
|
|
|
|
# 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"
|
|
|
|
|
|
@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()
|
|
task = Task(
|
|
goal="Verify search functionality",
|
|
profile="flash",
|
|
device_serial="device-from-task-obj",
|
|
concurrency_mode="per_device",
|
|
)
|
|
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")
|
|
|
|
|
|
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"
|