197 lines
6.6 KiB
Python
197 lines
6.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 Artemis MCP Tools."""
|
|
|
|
import inspect
|
|
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)
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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 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"
|
|
|
|
|
|
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")
|
|
|
|
# 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"
|
|
|
|
# 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_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_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)
|