- Unified configuration and paths under artemis.config as single source of truth - Consolidated execution across CLI, SDK, MCP, and Admin Console to default to Artemis Daemon - Fixed frontend step replay to preserve failure steps with proper sequential numbering - Implemented task deduplication, debouncing, and multi-device lock isolation - Added comprehensive end-to-end multi-port integration tests for live phone and emulator
405 lines
15 KiB
Python
405 lines
15 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 Artemis MCP Tools."""
|
|
|
|
import inspect
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from mcp_server.tools import (
|
|
mobile_get_device_state,
|
|
mobile_inspect_trace,
|
|
mobile_manage_task,
|
|
mobile_run_task,
|
|
)
|
|
from mcp_server.utils import trace_store
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_trace_env(monkeypatch):
|
|
temp_dir = tempfile.mkdtemp()
|
|
monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir)
|
|
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
|
|
yield temp_dir
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
|
|
def test_tool_signatures():
|
|
# mobile_run_task signature check
|
|
sig_run = inspect.signature(mobile_run_task)
|
|
assert "task_desc" in sig_run.parameters
|
|
assert "conversation_id" in sig_run.parameters
|
|
assert "model" in sig_run.parameters
|
|
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)
|
|
assert "action" in sig_manage.parameters
|
|
assert "trace_id" in sig_manage.parameters
|
|
assert "instruction" in sig_manage.parameters
|
|
|
|
# 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)
|
|
assert "action" in sig_inspect.parameters
|
|
assert "trace_id" in sig_inspect.parameters
|
|
assert "step_number" in sig_inspect.parameters
|
|
|
|
|
|
def test_mobile_run_task_invalid_model():
|
|
with pytest.raises(ValueError, match="Invalid model"):
|
|
mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model")
|
|
|
|
|
|
def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env):
|
|
process = MagicMock(pid=43210)
|
|
with (
|
|
patch(
|
|
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
|
|
return_value="queue-ticket-1",
|
|
) 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",
|
|
conversation_id="conv-1",
|
|
model="Flash",
|
|
)
|
|
|
|
reserve.assert_called_once()
|
|
transfer.assert_called_once_with(
|
|
"queue-ticket-1",
|
|
43210,
|
|
description="MCP task: Open Settings",
|
|
session_id=result["trace_id"],
|
|
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_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
|
|
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
|
|
with (
|
|
patch(
|
|
"artemis.runtime.ensure_daemon_running",
|
|
return_value=(True, "http://127.0.0.1:8000"),
|
|
),
|
|
patch(
|
|
"artemis.runtime.submit_task_to_daemon",
|
|
return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-1"}]},
|
|
),
|
|
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
|
|
):
|
|
result = mobile_run_task(
|
|
task_desc="Open Settings via Daemon",
|
|
conversation_id="conv-daemon",
|
|
model="Flash",
|
|
)
|
|
|
|
popen.assert_not_called()
|
|
assert result["trace_id"] == "daemon-sid-1"
|
|
assert result["status"] == "running"
|
|
assert "enqueued via unified Artemis Daemon" in result["message"]
|
|
assert "stdout_log" in result
|
|
assert "stderr_log" in result
|
|
|
|
|
|
def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
|
res = mobile_manage_task(action="status", trace_id="non-existent-trace")
|
|
assert res["status"] == "unknown"
|
|
assert "not found" in res["message"]
|
|
|
|
|
|
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", 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)
|
|
assert stop_res["trace_id"] == trace_id
|
|
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")
|
|
|
|
# Missing instruction parameter
|
|
err_res = mobile_manage_task(action="inject_instruction", trace_id=trace_id)
|
|
assert "Missing required argument" in err_res["message"]
|
|
|
|
# Valid injection
|
|
ok_res = mobile_manage_task(
|
|
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
|
|
)
|
|
assert "Successfully injected" in ok_res["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mobile_get_device_state_hierarchy_without_ocr():
|
|
"""XML hierarchy remains available when OCR is not configured."""
|
|
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=[
|
|
{
|
|
"class": "android.widget.Button",
|
|
"text": "Start",
|
|
"bounds": "[100,500][400,600]",
|
|
}
|
|
],
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
)
|
|
|
|
with (
|
|
patch("mcp_server.tools.device_state._get_controller", return_value=controller),
|
|
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
|
|
patch(
|
|
"mcp_server.tools.device_state.perform_ocr", new_callable=AsyncMock
|
|
) as perform_ocr_mock,
|
|
):
|
|
result = await mobile_get_device_state(view_type="hierarchy")
|
|
|
|
assert "Start" in result
|
|
perform_ocr_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mobile_inspect_trace_invalid_action():
|
|
res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123")
|
|
assert "error" in res
|
|
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."""
|
|
from mcp_server.base import mcp
|
|
|
|
tools = await mcp.list_tools()
|
|
tool_names = {t.name for t in tools}
|
|
assert {
|
|
"mobile_run_task",
|
|
"mobile_manage_task",
|
|
"mobile_get_device_state",
|
|
"mobile_inspect_trace",
|
|
}.issubset(tool_names)
|
|
|
|
|
|
def test_mobile_manage_task_syncs_terminal_status_from_db(temp_trace_env):
|
|
import sqlite3
|
|
trace_id = str(uuid.uuid4())
|
|
trace_store.init_trace(trace_id, "Running task to sync", "Flash", "conv-sync")
|
|
|
|
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
|
conn = sqlite3.connect(db_path)
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""CREATE TABLE IF NOT EXISTS sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
status TEXT,
|
|
pid INTEGER,
|
|
start_time REAL,
|
|
end_time REAL,
|
|
device_info TEXT
|
|
)"""
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO sessions (session_id, status, pid, start_time) VALUES (?, ?, ?, ?)",
|
|
(trace_id, "completed", 54321, 1000.0),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
res = mobile_manage_task(action="status", trace_id=trace_id)
|
|
assert res["status"] == "completed"
|
|
# Ensure status.json was persisted
|
|
saved_st = trace_store.read_status(trace_id)
|
|
assert saved_st["status"] == "completed"
|
|
assert saved_st["pid"] == 54321
|
|
|
|
|
|
def test_mobile_manage_task_stop_via_daemon(temp_trace_env, monkeypatch):
|
|
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
|
|
trace_id = str(uuid.uuid4())
|
|
trace_store.init_trace(trace_id, "Daemon task to stop", "Flash", "conv-stop")
|
|
|
|
with (
|
|
patch("artemis.runtime.is_daemon_running", return_value=True),
|
|
patch("artemis.runtime.stop_task_on_daemon", return_value=True) as mock_stop,
|
|
):
|
|
res = mobile_manage_task(action="stop", trace_id=trace_id)
|
|
mock_stop.assert_called_once_with(trace_id)
|
|
assert res["status"] == "cancelled"
|
|
assert trace_store.read_status(trace_id)["status"] == "cancelled"
|
|
|