Persist and publish recoverable provider retries so temporary 503 and throttling events remain visible in live and historical task streams. Aggregate repeated retries into an expandable action card, preserve terminal pause recovery, harden Windows worker isolation and output forwarding, and retain reliable Android hierarchy fallbacks with regression coverage.
165 lines
5.5 KiB
Python
165 lines
5.5 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_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)
|