Files
Artemis-Eapil/tests/unit/mcp/test_mcp_tools.py
T
chenmingxuan be6e8c2abd feat: release 2.2.0 with PC-only tests and execution reliability fixes
Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
2026-10-27 01:13:41 +08:00

804 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 artemis.runtime 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
assert "verification_level" in sig_run.parameters
assert "explorer_mode" in sig_run.parameters
assert sig_run.parameters["verification_level"].default is None
assert sig_run.parameters["explorer_mode"].default is None
# The tool description is the only schema an MCP caller sees.
assert "verification_level" in (mobile_run_task.__doc__ or "")
assert "explorer_mode" in (mobile_run_task.__doc__ or "")
# 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
assert "query" in sig_inspect.parameters
assert "step_range" in sig_inspect.parameters
assert sig_inspect.parameters["max_results"].default == 5
assert "'search'" in (mobile_inspect_trace.__doc__ or "")
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_auto_plugins_do_not_silently_fall_back_to_a_different_executor(temp_trace_env):
with (
patch("artemis.plugins.store.PluginStore.selections", return_value=["desktop"]),
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
):
result = mobile_run_task(task_desc="手机测试中操作电脑", model="flash")
assert result["status"] == "failed"
assert "统一任务服务" in result["error"]
popen.assert_not_called()
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,
# Hermetic device validation: the requested serial reports as ready
# regardless of what is attached to the host running the tests.
patch(
"artemis.runtime.device_pool.device_pool.try_list_devices",
return_value=[SimpleNamespace(serial="pixel-11-pro-001", state="device")],
),
):
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"
@pytest.mark.parametrize("model", ["flash", "pro"])
def test_pc_run_task_uses_daemon_without_android_validation(temp_trace_env, monkeypatch, model):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch(
"mcp_server.tools.task_runner._validate_device_serial",
side_effect=AssertionError("ADB must not be used"),
) as validate,
patch(
"mcp_server.tools.task_runner.submit_task_to_daemon",
side_effect=lambda **kwargs: {
"status": "started",
"tasks": [{"session_id": kwargs["session_id"]}],
},
) as submit,
patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn,
):
result = mobile_run_task(
"Check desktop",
model=model,
device_serial="offline-phone",
runtime_overrides={"execution_target": "computer"},
)
assert result["status"] == "pending"
assert submit.call_args.kwargs["runtime_overrides"]["execution_target"] == "computer"
assert submit.call_args.kwargs["device_serial"] == "computer:local"
assert trace_store.read_status(result["trace_id"])["device_serial"] == "computer:local"
validate.assert_not_called()
spawn.assert_not_called()
def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch(
"mcp_server.tools.task_runner.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"] == "pending"
assert "enqueued via unified Artemis Daemon" in result["message"]
assert "stdout_log" in result
assert "stderr_log" in result
@pytest.mark.parametrize("lost_reply", [False, True])
@pytest.mark.parametrize("worker_status", ["pending", "running", "completed", "failed"])
def test_daemon_receipt_preserves_queue_and_worker_status(
temp_trace_env, monkeypatch, lost_reply, worker_status
):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
queued = []
def submit(**kwargs):
sid = kwargs["session_id"]
assert trace_store.read_status(sid)["status"] == "pending"
queued.append({"session_id": sid, "status": "pending"})
if worker_status != "pending":
trace_store.update_trace_status(sid, worker_status)
return None if lost_reply else {"status": "started", "tasks": queued}
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch("mcp_server.tools.task_runner.submit_task_to_daemon", side_effect=submit),
patch(
"mcp_server.tools.task_runner.DeviceExecutionLock.get_queued_tasks",
side_effect=lambda: queued,
),
patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn,
):
result = mobile_run_task("Queue state regression", model="Pro")
assert result["status"] == worker_status
assert trace_store.read_status(result["trace_id"])["status"] == worker_status
spawn.assert_not_called()
@pytest.mark.parametrize(
("kwargs", "match"),
[
({"verification_level": "paranoid"}, "Invalid verification_level"),
({"explorer_mode": "turbo"}, "Invalid explorer_mode"),
],
)
def test_mobile_run_task_rejects_unknown_pro_tuning_before_creating_a_trace(
temp_trace_env, kwargs, match
):
with pytest.raises(ValueError, match=match):
mobile_run_task(task_desc="Open Settings", model="Pro", **kwargs)
# Rejected before init_trace: nothing was written to the trace store.
assert os.listdir(temp_trace_env) == []
def test_mobile_run_task_forwards_pro_tuning_to_background_runner(temp_trace_env):
process = MagicMock(pid=777)
with (
patch("mcp_server.tools.task_runner.DeviceExecutionLock.reserve", return_value="t"),
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation"),
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
):
mobile_run_task(
task_desc="Audit the checkout flow",
model="Pro",
verification_level=" Strict ",
explorer_mode="ULTRA",
)
cmd = popen.call_args.args[0]
# Same flag spelling as `artemis run` / the admin-console worker, normalised values.
assert cmd[cmd.index("--verification-level") + 1] == "strict"
assert cmd[cmd.index("--explorer-pro-mode") + 1] == "ultra"
def test_mobile_run_task_omits_pro_tuning_flags_when_unset(temp_trace_env):
process = MagicMock(pid=778)
with (
patch("mcp_server.tools.task_runner.DeviceExecutionLock.reserve", return_value="t"),
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation"),
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
):
mobile_run_task(task_desc="Open Settings", model="Pro", verification_level=" ")
cmd = popen.call_args.args[0]
assert "--verification-level" not in cmd
assert "--explorer-pro-mode" not in cmd
def test_mobile_run_task_forwards_pro_tuning_to_daemon(temp_trace_env, monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
patch(
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch(
"mcp_server.tools.task_runner.submit_task_to_daemon",
return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-2"}]},
) as submit,
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
):
result = mobile_run_task(
task_desc="Audit via Daemon",
model="Pro",
verification_level="Checkpoints",
explorer_mode="pro",
)
popen.assert_not_called()
assert result["trace_id"] == "daemon-sid-2"
# JSON field spelling of /api/run: verification_level / explorer_mode.
assert submit.call_args.kwargs["verification_level"] == "checkpoints"
assert submit.call_args.kwargs["explorer_mode"] == "pro"
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_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch):
import sqlite3
from pathlib import Path
import artemis.runtime.task_control as control
monkeypatch.setattr(control, "TRACES_PATH", Path(temp_trace_env))
monkeypatch.setattr("mcp_server.tools.task_manager._pid_alive", lambda pid: True)
trace_id = str(uuid.uuid4())
db = Path(temp_trace_env) / "data_engine.db"
with sqlite3.connect(db) as conn:
conn.execute(
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
"start_time REAL, end_time REAL, device_info TEXT)"
)
conn.execute(
"INSERT INTO sessions VALUES (?, 'running', 17, 1, NULL, ?)",
(trace_id, '{"device_id":"test-device"}'),
)
assert mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
assert mobile_manage_task(action="status", trace_id=trace_id)["status"] == "pausing"
control._write_atomic(
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
)
guided = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="先确认当前画面"
)
assert guided["status"] == "task_paused"
assert (Path(temp_trace_env) / trace_id / "resume.request").exists()
assert (
control.consume_instructions(control.control_dir(trace_id))["instruction"]
== "先确认当前画面"
)
guidance = mobile_manage_task(action="guidance_history", trace_id=trace_id)
assert guidance["instructions"][0]["status"] == "consumed"
assert guidance["instructions"][0]["instruction"] == "先确认当前画面"
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 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 "next safe checkpoint" in ok_res["message"]
assert "not guaranteed" in ok_res["message"]
assert ok_res["guidance"]["id"]
assert ok_res["guidance"]["status"] == "pending"
@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.executemany(
"INSERT INTO steps (step_id,session_id,step_number,timestamp) VALUES (?,?,?,?)",
[(f"actual-{n}", trace_id, n, 1000 + n) for n in range(1, 4)],
)
conn.commit()
conn.close()
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id, limit=2)
assert "**Device Serial:** `pixel-target-999`" in res_summary.content[0].text
assert res_summary.content[0].text.startswith("# ARTEMIS Flash Execution Summary")
assert res_summary.structuredContent["steps"] == {
"items": [
{"step_id": "actual-1", "step_number": 1},
{"step_id": "actual-2", "step_number": 2},
],
"total": 3,
"offset": 0,
"limit": 2,
"next_offset": 2,
}
last_page = await mobile_inspect_trace(
action="view_summary", trace_id=trace_id, offset=2, limit=2
)
assert last_page.structuredContent["steps"]["next_offset"] is None
assert last_page.structuredContent["steps"]["items"] == [
{"step_id": "actual-3", "step_number": 3}
]
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("mcp_server.tools.task_manager.is_daemon_running", return_value=True),
patch("mcp_server.tools.task_manager.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"
def _seed_trace(temp_dir: str, trace_id: str, *, with_image: bool = True) -> str:
"""Writes one Flash-style step (with a tool trace and a pre screenshot)
through the real storage layer, exactly as a live run would."""
import io
from PIL import Image
from artemis.data_engine.models import (
ImageRecord,
SessionMetadata,
StepRecord,
TraceRecord,
)
from artemis.data_engine.storage import StorageManager
storage = StorageManager(os.path.join(temp_dir, "data_engine.db"), temp_dir)
storage.create_session(
SessionMetadata(session_id=trace_id, initial_goal="Replay test", start_time=1000.0)
)
image_name = None
if with_image:
image_name = "a" * 64
images_dir = os.path.join(temp_dir, "images")
os.makedirs(images_dir, exist_ok=True)
buf = io.BytesIO()
Image.new("RGB", (1080, 2400), "white").save(buf, format="JPEG")
with open(os.path.join(images_dir, f"{image_name}.jpg"), "wb") as f:
f.write(buf.getvalue())
storage.create_image(
ImageRecord(
image_name=image_name,
ocr_result=[{"text": "Save ocrneedle", "bounds": [0, 0, 10, 10]}],
)
)
step_id = str(uuid.uuid4())
storage.create_step(
StepRecord(
step_id=step_id,
session_id=trace_id,
step_number=1,
timestamp=1012.0,
pre_image_name=image_name,
summary="Asked the explorer, then tapped Save.",
action_taken={"action": "click", "target": [540, 1200], "target_text": "Save"},
operator_raw_thinking="Confirm the toggle first.",
last_execution_result={"status": "success"},
extra_metadata={"width": 1080, "height": 2400},
)
)
storage.create_trace(
TraceRecord(
trace_id=str(uuid.uuid4()),
session_id=trace_id,
step_id=step_id,
type="tool",
name="ask_explorer",
status="success",
timestamp=1011.0,
duration=0.8,
payload={"args": {"question": "toggle?"}, "result": "The toggle is ON. " * 100},
)
)
return step_id
@pytest.mark.asyncio
async def test_mobile_inspect_trace_step_details_replays_tool_calls_for_flash(temp_trace_env):
"""view_step_details works for Flash runs too and replays the recorded
tool calls through the same friendly-step path the live DataEngine uses."""
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Replay test", "Flash", "conv-1", device_serial="pixel-7")
_seed_trace(temp_trace_env, trace_id)
res = await mobile_inspect_trace(action="view_step_details", trace_id=trace_id, step_number=1)
assert "error" not in res
assert res["device_serial"] == "pixel-7"
details = res["details"]
# The header carries the session clock (session start 1000.0, step at 1012.0).
assert "- **Step 1 (T+00:12)**" in details
assert "[Screen]: Asked the explorer, then tapped Save." in details
assert "Confirm the toggle first." in details
assert "`ask_explorer(" in details
assert "The toggle is ON. " * 100 in details
# Coordinates are replayed normalized, not as stored pixels.
assert "[540, 1200]" not in details
assert "Tapped 'Save'" in details
missing = await mobile_inspect_trace(
action="view_step_details", trace_id=trace_id, step_number=9
)
assert missing["error"] == "Step not found"
@pytest.mark.asyncio
async def test_mobile_inspect_trace_step_screenshots_draw_the_overlay(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Shot test", "Flash", "conv-1", device_serial="pixel-7")
_seed_trace(temp_trace_env, trace_id)
res = await mobile_inspect_trace(
action="view_step_screenshots", trace_id=trace_id, step_number=1
)
assert "error" not in res
assert res["before_screenshot"].startswith("file://")
assert res["before_screenshot"].endswith(f"{'a' * 64}.jpg")
assert res["after_screenshot"] is None
overlay = res["action_overlay_screenshot"]
assert overlay is not None
overlay_path = overlay[len("file://") :]
assert os.path.exists(overlay_path)
assert overlay_path.endswith("step_1_overlay.jpg")
# The overlay differs from the plain pre screenshot: the action was drawn.
with open(overlay_path, "rb") as f, open(res["before_screenshot"][7:], "rb") as g:
assert f.read() != g.read()
# Second call reuses the file it already wrote.
again = await mobile_inspect_trace(
action="view_step_screenshots", trace_id=trace_id, step_number=1
)
assert again["action_overlay_screenshot"] == overlay
no_step = await mobile_inspect_trace(
action="view_step_screenshots", trace_id=trace_id, step_number=None
)
assert no_step["error"] == "Missing parameter"
@pytest.mark.asyncio
async def test_mobile_inspect_trace_search_uses_the_shared_history_search(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Search test", "Flash", "conv-1", device_serial="pixel-7")
_seed_trace(temp_trace_env, trace_id)
res = await mobile_inspect_trace(action="search", trace_id=trace_id, query="toggle")
assert "error" not in res
assert res["device_serial"] == "pixel-7"
assert "[Step 1 " in res["results"]
assert "Screen: Asked the explorer, then tapped Save." in res["results"]
# On-screen OCR text of the step's screenshot is part of the search surface.
res = await mobile_inspect_trace(action="search", trace_id=trace_id, query="ocrneedle")
assert "[Step 1 " in res["results"]
ledger = await mobile_inspect_trace(action="search", trace_id=trace_id, step_range=[1, 1])
assert "Action ledger for Steps 1–1" in ledger["results"]
empty = await mobile_inspect_trace(action="search", trace_id=trace_id)
assert empty["error"] == "Missing parameter"