feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream
- 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
This commit is contained in:
1 parent
a36a66ce6d
commit
c2c90a6378
84 files changed
+5612
-645
No files matched your search
@@ -15,6 +15,8 @@
|
||||
"""Unit tests for Artemis MCP Tools."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
@@ -49,6 +51,7 @@ def test_tool_signatures():
|
||||
assert "locked_app_package" in sig_run.parameters
|
||||
assert "app_path" in sig_run.parameters
|
||||
assert "expected_output_desc" in sig_run.parameters
|
||||
assert "device_serial" in sig_run.parameters
|
||||
|
||||
# mobile_manage_task signature check
|
||||
sig_manage = inspect.signature(mobile_manage_task)
|
||||
@@ -59,6 +62,7 @@ def test_tool_signatures():
|
||||
# mobile_get_device_state signature check
|
||||
sig_device = inspect.signature(mobile_get_device_state)
|
||||
assert "view_type" in sig_device.parameters
|
||||
assert "device_serial" in sig_device.parameters
|
||||
|
||||
# mobile_inspect_trace signature check
|
||||
sig_inspect = inspect.signature(mobile_inspect_trace)
|
||||
@@ -97,10 +101,54 @@ def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env)
|
||||
ingress="mcp",
|
||||
)
|
||||
assert result["trace_id"]
|
||||
assert result["device_serial"] == "auto-select"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] == "queue-ticket-1"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_TASK_INGRESS"] == "mcp"
|
||||
status = trace_store.read_status(result["trace_id"])
|
||||
assert status["queue_ticket"] == "queue-ticket-1"
|
||||
assert status["device_serial"] is None
|
||||
|
||||
|
||||
def test_mobile_run_task_with_device_serial(temp_trace_env):
|
||||
process = MagicMock(pid=54321)
|
||||
with (
|
||||
patch(
|
||||
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
|
||||
return_value="queue-ticket-dev",
|
||||
) as reserve,
|
||||
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer,
|
||||
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
|
||||
):
|
||||
result = mobile_run_task(
|
||||
task_desc="Open Settings on target phone",
|
||||
conversation_id="conv-2",
|
||||
model="Flash",
|
||||
device_serial="pixel-11-pro-001",
|
||||
)
|
||||
|
||||
reserve.assert_called_once_with(
|
||||
description="MCP task: Open Settings on target phone",
|
||||
session_id=result["trace_id"],
|
||||
ingress="mcp",
|
||||
device_id="pixel-11-pro-001",
|
||||
)
|
||||
transfer.assert_called_once_with(
|
||||
"queue-ticket-dev",
|
||||
54321,
|
||||
description="MCP task: Open Settings on target phone",
|
||||
session_id=result["trace_id"],
|
||||
ingress="mcp",
|
||||
device_id="pixel-11-pro-001",
|
||||
)
|
||||
assert result["device_serial"] == "pixel-11-pro-001"
|
||||
cmd = popen.call_args.args[0]
|
||||
assert "--device-serial" in cmd
|
||||
assert cmd[cmd.index("--device-serial") + 1] == "pixel-11-pro-001"
|
||||
assert popen.call_args.kwargs["env"]["ADB_DEVICE_SERIAL"] == "pixel-11-pro-001"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_ID"] == "pixel-11-pro-001"
|
||||
|
||||
status = trace_store.read_status(result["trace_id"])
|
||||
assert status["device_serial"] == "pixel-11-pro-001"
|
||||
|
||||
|
||||
def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
@@ -111,13 +159,14 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
|
||||
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1")
|
||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
||||
|
||||
# Status check
|
||||
status_res = mobile_manage_task(action="status", trace_id=trace_id)
|
||||
assert status_res["trace_id"] == trace_id
|
||||
assert status_res["status"] == "running"
|
||||
assert status_res["model"] == "Flash"
|
||||
assert status_res["device_serial"] == "device-xyz"
|
||||
|
||||
# Stop without PID
|
||||
stop_res = mobile_manage_task(action="stop", trace_id=trace_id)
|
||||
@@ -125,6 +174,31 @@ def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||
assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"]
|
||||
|
||||
|
||||
def test_mobile_manage_task_status_backfills_device_serial_from_db(temp_trace_env):
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task without serial", "Flash", "conv-1")
|
||||
|
||||
# Create dummy data_engine.db with session record
|
||||
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE sessions (session_id TEXT PRIMARY KEY, device_info TEXT, pid INTEGER, start_time REAL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions VALUES (?, ?, ?, ?)",
|
||||
(trace_id, json.dumps({"device_id": "auto-chosen-serial-777"}), 12345, 1000.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status_res = mobile_manage_task(action="status", trace_id=trace_id)
|
||||
assert status_res["device_serial"] == "auto-chosen-serial-777"
|
||||
|
||||
|
||||
def test_mobile_manage_task_inject_instruction(temp_trace_env):
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1")
|
||||
@@ -181,6 +255,63 @@ async def test_mobile_inspect_trace_invalid_action():
|
||||
assert "not supported" in res["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mobile_get_device_state_passes_device_serial():
|
||||
"""Verify that device_serial is forwarded to _get_controller."""
|
||||
controller = MagicMock()
|
||||
controller.ctx.device.device_width = 1080
|
||||
controller.ctx.device.device_height = 2400
|
||||
controller.get_screen_data = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
base64="ZHVtbXk=",
|
||||
elements=[],
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("mcp_server.tools.device_state._get_controller", return_value=controller) as get_ctrl_mock,
|
||||
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
|
||||
):
|
||||
await mobile_get_device_state(view_type="hierarchy", device_serial="device-serial-abc")
|
||||
|
||||
get_ctrl_mock.assert_called_once_with(device_serial="device-serial-abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env):
|
||||
import sqlite3
|
||||
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Inspect test", "Flash", "conv-1", device_serial="pixel-target-999")
|
||||
|
||||
# Create dummy database and table
|
||||
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE sessions (session_id TEXT PRIMARY KEY, start_time REAL, device_info TEXT)")
|
||||
conn.execute("CREATE TABLE steps (step_id TEXT PRIMARY KEY, session_id TEXT, step_number INTEGER, timestamp REAL, pre_image_name TEXT, post_image_name TEXT, summary TEXT, action_taken TEXT, operator_raw_thinking TEXT, last_execution_result TEXT, extra_metadata TEXT)")
|
||||
conn.execute("CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)")
|
||||
conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id)
|
||||
assert "**Device Serial:** `pixel-target-999`" in res_summary
|
||||
|
||||
|
||||
def test_trace_store_device_serial(temp_trace_env):
|
||||
trace_id = "test-store-serial"
|
||||
data = trace_store.init_trace(trace_id, "desc", "Flash", "conv", device_serial="init-serial")
|
||||
assert data["device_serial"] == "init-serial"
|
||||
|
||||
data_updated = trace_store.update_trace_device_serial(trace_id, "updated-serial")
|
||||
assert data_updated["device_serial"] == "updated-serial"
|
||||
|
||||
read_back = trace_store.read_status(trace_id)
|
||||
assert read_back["device_serial"] == "updated-serial"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_auto_registers_tools():
|
||||
"""Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import."""
|
||||
|
||||
Reference in new issue
Block a user