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:
somew1nd committed 2026-08-26 15:53:14 -07:00
1 parent a36a66ce6d
commit c2c90a6378
84 files changed
+5612 -645

No files matched your search

@@ -55,3 +55,44 @@ async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo.get_video_recording_for_session.assert_not_called()
repo.get_llm_traces_for_profile.assert_not_called()
repo.get_agent_trace_names.assert_not_called()
@pytest.mark.asyncio
async def test_list_sessions_extracts_device_serial(monkeypatch):
repo = MagicMock()
repo.get_all_sessions.return_value = [
{
"session_id": "session-dev-1",
"status": "completed",
"start_time": 1.0,
"device_info": '{"device_id": "63191FDKX00062", "profile": "flash"}',
},
{
"session_id": "session-dev-2",
"status": "completed",
"start_time": 2.0,
"device_info": None,
},
]
repo.get_video_recordings_map.return_value = {}
repo.get_latest_video_recordings_map.return_value = {
"session-dev-2": {"status": "ready", "device_id": "emulator-5554"},
}
repo.get_llm_traces_for_profiles_map.return_value = {}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
monkeypatch.setattr(
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
)
monkeypatch.setattr(
sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None)
)
result = await sessions_router.list_sessions()
assert len(result) == 2
assert result[0]["device_serial"] == "63191FDKX00062"
assert result[0]["device_id"] == "63191FDKX00062"
assert result[1]["device_serial"] == "emulator-5554"
assert result[1]["device_id"] == "emulator-5554"
@@ -180,8 +180,8 @@ async def test_queue_worker_execution_lifecycle():
assert res["enqueued_count"] == 2
# Give event loop time for queue_worker to execute both tasks
for _ in range(30):
if len(executed_goals) == 2:
for _ in range(40):
if len(executed_goals) == 2 and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
@@ -234,6 +234,7 @@ async def test_queue_worker_cmd_construction():
cmd = executed_cmds[0]
assert "--enable-outputter" in cmd
assert "true" not in cmd
assert "--session-id" in cmd
assert "--output-description" in cmd
assert "Final summary" in cmd
assert "--locked-app" in cmd
@@ -265,14 +266,16 @@ async def test_queue_worker_cmd_construction():
@pytest.mark.asyncio
async def test_forward_worker_output_preserves_split_utf8(capsys):
stream = asyncio.StreamReader()
encoded = "worker 输出正常\n".encode()
stream.feed_data(encoded[:9])
stream.feed_data(encoded[9:])
# "worker output: \U0001f600\n" contains a 4-byte UTF-8 emoji (b'\xf0\x9f\x98\x80') starting at byte 15.
# Splitting at index 17 cuts across the multi-byte sequence to verify partial UTF-8 buffering.
encoded = "worker output: \U0001f600\n".encode("utf-8")
stream.feed_data(encoded[:17])
stream.feed_data(encoded[17:])
stream.feed_eof()
await TaskQueueService._forward_worker_output(stream)
assert capsys.readouterr().out == "worker 输出正常\n"
assert capsys.readouterr().out == "worker output: \U0001f600\n"
@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only")
@@ -539,7 +542,7 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
await task_queue_service.enqueue_tasks(["Task 2"])
for _ in range(40):
if "Task 2" in executed_goals:
if "Task 2" in executed_goals and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
+132 -1
View File
@@ -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."""
+103
View File
@@ -0,0 +1,103 @@
# 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.
from unittest.mock import MagicMock, patch
import pytest
from artemis.runtime.daemon_client import (
ensure_daemon_running,
is_daemon_running,
is_standalone_forced,
spawn_daemon,
submit_task_to_daemon,
)
def test_is_standalone_forced(monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
assert is_standalone_forced() is False
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
assert is_standalone_forced() is True
monkeypatch.setenv("ARTEMIS_STANDALONE", "true")
assert is_standalone_forced() is True
monkeypatch.setenv("ARTEMIS_STANDALONE", "0")
assert is_standalone_forced() is False
def test_is_daemon_running_success():
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
assert is_daemon_running() is True
def test_is_daemon_running_failure():
with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")):
assert is_daemon_running() is False
def test_spawn_daemon():
mock_proc = MagicMock()
mock_proc.pid = 54321
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
proc = spawn_daemon(host="127.0.0.1", port=8000)
assert proc is mock_proc
assert mock_popen.called
def test_ensure_daemon_running_when_already_active():
with patch("artemis.runtime.daemon_client.is_daemon_running", return_value=True):
ok, base_url = ensure_daemon_running(host="127.0.0.1", port=8000)
assert ok is True
assert base_url == "http://127.0.0.1:8000"
def test_ensure_daemon_running_standalone_override(monkeypatch):
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
ok, base_url = ensure_daemon_running()
assert ok is False
assert base_url is None
def test_ensure_daemon_running_auto_spawns():
with (
patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]),
patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn,
):
ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True)
assert ok is True
assert base_url == "http://127.0.0.1:8000"
mock_spawn.assert_called_once()
def test_submit_task_to_daemon():
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}'
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
res = submit_task_to_daemon(
"test goal",
profile="flash",
device_serial="pixel-8",
)
assert res is not None
assert res["status"] == "queued"
assert res["tasks"][0]["session_id"] == "test-sess"
+94
View File
@@ -227,3 +227,97 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
assert annotated.ingress == "cli"
finally:
owner_lock.release()
def test_multi_device_locks_can_run_concurrently():
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
lock_a.acquire()
try:
# Device B should be acquired without blocking since it's a different device
lock_b.acquire(blocking=False)
try:
active_owners = DeviceExecutionLock.get_active_owners()
assert "emulator-5554" in active_owners
assert "pixel-9-test" in active_owners
# A second task on device A should be blocked
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
with pytest.raises(DeviceBusyError, match="task on device A"):
lock_a_second.acquire(blocking=False)
finally:
lock_b.release()
finally:
lock_a.release()
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "1")
lock_a = DeviceExecutionLock("device_alpha", "task alpha")
lock_b = DeviceExecutionLock("device_beta", "task beta")
lock_a.acquire()
try:
with pytest.raises(DeviceBusyError, match="concurrency limit"):
lock_b.acquire(blocking=False)
finally:
lock_a.release()
# Once lock A is released, lock B can acquire
lock_b.acquire(blocking=False)
lock_b.release()
def test_owner_is_alive_guards_against_recycled_pid():
from unittest.mock import MagicMock, patch
from artemis.runtime.device_lock import DeviceLockOwner
owner = DeviceLockOwner(
pid=99999,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
# 1. PID is 0 or negative -> immediately dead
invalid_owner = DeviceLockOwner(
pid=0,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
assert DeviceExecutionLock._owner_is_alive(invalid_owner) is False
# 2. Recycled PID belongs to non-Artemis system process -> treated as dead
mock_proc = MagicMock()
mock_proc.is_running.return_value = True
mock_proc.name.return_value = "systemd"
mock_proc.cmdline.return_value = ["/sbin/init"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is False
# 3. PID belongs to a Python / Artemis process -> alive
mock_proc.name.return_value = "python3"
mock_proc.cmdline.return_value = ["python3", "-m", "artemis.main"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is True
def test_safe_unlink_and_replace_retry(tmp_path):
f = tmp_path / "test_file.txt"
f.write_text("hello")
assert DeviceExecutionLock._safe_unlink(f) is True
assert not f.exists()
src = tmp_path / "src.txt"
dst = tmp_path / "dst.txt"
src.write_text("world")
assert DeviceExecutionLock._safe_replace(src, dst) is True
assert dst.read_text() == "world"
+85
View File
@@ -0,0 +1,85 @@
# 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.
import pytest
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.device_pool import DevicePool, DeviceStatus
@pytest.fixture(autouse=True)
def isolated_lock_directory(tmp_path, monkeypatch):
monkeypatch.setattr(
"artemis.runtime.device_lock.get_temp_dir",
lambda _name: tmp_path,
)
def test_device_pool_parse_lines():
lines = [
"List of devices attached",
"emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a",
"9A123XYZ device product:husky model:Pixel_8_Pro device:husky",
"offline-dev offline",
"",
]
parsed = DevicePool._parse_device_lines(lines)
assert len(parsed) == 3
assert parsed[0] == ("emulator-5554", "device", "sdk gphone64 arm64", "sdk_gphone64_arm64")
assert parsed[1] == ("9A123XYZ", "device", "Pixel 8 Pro", "husky")
assert parsed[2] == ("offline-dev", "offline", None, None)
def test_device_pool_list_devices_and_busy_status(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(
pool,
"_query_adb_devices_sync",
lambda: [
("emulator-5554", "device", "Emulator", "sdk"),
("pixel-8-pro", "device", "Pixel 8 Pro", "husky"),
],
)
# Initially neither is busy
devs = pool.list_devices()
assert len(devs) == 2
assert not devs[0].is_busy
assert not devs[1].is_busy
# Acquire lock for emulator-5554
lock = DeviceExecutionLock("emulator-5554", "test automation")
lock.acquire()
try:
devs = pool.list_devices()
emu = next(d for d in devs if d.serial == "emulator-5554")
pixel = next(d for d in devs if d.serial == "pixel-8-pro")
assert emu.is_busy is True
assert emu.active_task_desc == "test automation"
assert pixel.is_busy is False
# Test idle device filtering
idle = pool.get_idle_devices()
assert len(idle) == 1
assert idle[0].serial == "pixel-8-pro"
# Test select_device picks the idle device automatically
selected = pool.select_device()
assert selected == "pixel-8-pro"
# Test explicit selection returns requested device
assert pool.select_device("emulator-5554") == "emulator-5554"
finally:
lock.release()
@@ -0,0 +1,201 @@
# 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 Server Lifecycle Management and API Endpoints."""
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from apps.admin_console.server import app
from artemis.runtime.server_lifecycle import (
clear_server_info,
find_server_pids,
get_server_status,
is_port_in_use,
read_server_info,
stop_server,
write_server_info,
)
def test_write_read_clear_server_info(tmp_path, monkeypatch):
"""Verify write, read, and clear round-trip for server metadata file."""
fake_info_file = tmp_path / "test_server.json"
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
)
# Initial read should be None
assert read_server_info() is None
# Write info
written = write_server_info(port=8888, host="127.0.0.1", pid=99999)
assert written == fake_info_file
assert fake_info_file.exists()
# Read back
info = read_server_info()
assert info is not None
assert info["pid"] == 99999
assert info["port"] == 8888
assert info["host"] == "127.0.0.1"
assert "started_at" in info
# Clear info
clear_server_info()
assert not fake_info_file.exists()
assert read_server_info() is None
def test_find_server_pids_from_metadata(tmp_path, monkeypatch):
"""Verify find_server_pids discovers PID saved in metadata file."""
fake_info_file = tmp_path / "test_server.json"
fake_info_file.write_text(
json.dumps({"pid": os.getpid(), "port": 8000, "started_at": 100.0}),
encoding="utf-8",
)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
)
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
pids = find_server_pids(8000)
assert os.getpid() in pids
def test_get_server_status_offline(monkeypatch):
"""Verify get_server_status returns correct structure when server is offline."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
monkeypatch.setattr("artemis.runtime.server_lifecycle.read_server_info", lambda: None)
status = get_server_status(8000)
assert status["running"] is False
assert status["port"] == 8000
assert status["pids"] == []
assert status["url"] is None
def test_get_server_status_online(monkeypatch):
"""Verify get_server_status returns correct structure when server is online."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.read_server_info",
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0},
)
status = get_server_status(8000)
assert status["running"] is True
assert status["port"] == 8000
assert status["pids"] == [12345]
assert status["active_pid"] == 12345
assert status["url"] == "http://localhost:8000"
assert status["admin_url"] == "http://localhost:8000/admin"
assert status["uptime_seconds"] is not None
def test_stop_server_when_not_running(monkeypatch):
"""Verify stop_server returns early when no server is running."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == []
assert "No active server" in msg
def test_stop_server_terminates_tree(monkeypatch):
"""Verify stop_server calls ProcessSupervisor.terminate_tree and returns stopped pids."""
terminated_pids = []
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
port_in_use = [True, False]
def mock_in_use(port, **kwargs):
if port_in_use:
return port_in_use.pop(0)
return False
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", mock_in_use)
monkeypatch.setattr(
"artemis.runtime.supervisor.ProcessSupervisor.terminate_tree",
lambda pid, timeout_seconds=4.0: terminated_pids.append(pid),
)
monkeypatch.setattr(
"artemis.runtime.device_lock.DeviceExecutionLock.cleanup_stale_locks",
lambda: 1,
)
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == [12345]
assert 12345 in terminated_pids
assert "stopped" in msg.lower()
@pytest.mark.asyncio
async def test_api_server_status():
"""Verify GET /api/system/server-status returns server runtime metadata."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.get("/api/system/server-status")
assert response.status_code == 200
data = response.json()
assert "port" in data
assert "running" in data
assert "current_pid" in data
@pytest.mark.asyncio
async def test_api_restart_endpoint():
"""Verify POST /api/system/restart accepts request and schedules restart."""
with patch("threading.Thread") as mock_thread:
mock_instance = MagicMock()
mock_thread.return_value = mock_instance
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post("/api/system/restart")
assert response.status_code == 200
data = response.json()
assert data["status"] == "restarting"
assert "reconnection" in data["message"].lower() or "restarting" in data["message"].lower()
mock_thread.assert_called_once()
mock_instance.start.assert_called_once()
@pytest.mark.asyncio
async def test_api_shutdown_endpoint():
"""Verify POST /api/system/shutdown accepts request and schedules shutdown."""
with patch("threading.Thread") as mock_thread:
mock_instance = MagicMock()
mock_thread.return_value = mock_instance
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post("/api/system/shutdown")
assert response.status_code == 200
data = response.json()
assert data["status"] == "shutting_down"
mock_thread.assert_called_once()
mock_instance.start.assert_called_once()
@@ -113,3 +113,34 @@ async def test_locked_work_profile_does_not_block_unlocked_device_owner():
)
await agent._ensure_device_unlocked()
@pytest.mark.asyncio
async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monkeypatch):
import uuid
canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid)
agent = Agent()
assert agent._session_id == canonical_sid
task = MagicMock()
task.id = "fallback-id"
task.get_name.return_value = "task-name"
task.request.goal = "test goal"
task.request.profile = "flash"
task.request.task_name = "task-name"
task.request.trace_path = "traces"
context = MagicMock()
context.device = None
with patch("artemis.sdk.agent.DataEngine") as mock_data_engine_cls:
mock_data_engine = MagicMock()
mock_data_engine_cls.return_value = mock_data_engine
agent._prepare_tracing(task=task, context=context)
mock_data_engine.start_session.assert_called_once()
call_kwargs = mock_data_engine.start_session.call_args.kwargs
assert call_kwargs["session_id"] == uuid.UUID(canonical_sid)
+106 -3
View File
@@ -198,9 +198,8 @@ def test_cli_mcp_install_all(tmp_path, monkeypatch):
claude_data = json.loads((tmp_path / ".claude.json").read_text())
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
vscode_data = json.loads(
(tmp_path / "AppData" / "Roaming" / "Code" / "User" / "mcp.json").read_text()
)
from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir
vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text())
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
assert "mcpServers" not in vscode_data
@@ -333,3 +332,107 @@ def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeyp
assert data["mcp"]["servers"]["artemis"]["command"]
def test_cli_restart_help():
"""Verify 'artemis restart --help' displays lifecycle options."""
result = runner.invoke(app, ["restart", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
assert "--host" in result.output
assert "--force" in result.output
assert "--daemon" in result.output
assert "--open" in result.output
def test_cli_stop_help():
"""Verify 'artemis stop --help' displays stop options."""
result = runner.invoke(app, ["stop", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
assert "--force" in result.output
def test_cli_status_help():
"""Verify 'artemis status --help' displays status options."""
result = runner.invoke(app, ["status", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
def test_cli_status_offline(monkeypatch):
"""Verify 'artemis status' reports offline when port is unused."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None)
result = runner.invoke(app, ["status", "--port", "59998"])
assert result.exit_code == 0
assert "OFFLINE" in result.output or "STOPPED" in result.output
def test_cli_status_online(monkeypatch):
"""Verify 'artemis status' reports online details when server is active."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(
server_lifecycle,
"read_server_info",
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0, "cwd": "/tmp"},
)
result = runner.invoke(app, ["status", "--port", "8000"])
assert result.exit_code == 0
assert "ONLINE" in result.output or "RUNNING" in result.output
assert "12345" in result.output
def test_cli_stop_command(monkeypatch):
"""Verify 'artemis stop' invokes stop_server with given parameters."""
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
mock_called = {}
def mock_stop(port, timeout=4.0, force=False):
mock_called["port"] = port
mock_called["force"] = force
return True, "Artemis server stopped (PID: 12345).", [12345]
monkeypatch.setattr(sl_cmd, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
result = runner.invoke(app, ["stop", "--port", "8000", "--force"])
assert result.exit_code == 0
assert mock_called["port"] == 8000
assert mock_called["force"] is True
assert "stopped successfully" in result.output.lower() or "PID: 12345" in result.output
def test_cli_restart_command(monkeypatch):
"""Verify 'artemis restart' stops previous server and invokes ui_command."""
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
from artemis.runtime import server_lifecycle
stopped = {}
ui_called = {}
def mock_stop(port, timeout=4.0, force=False):
stopped["port"] = port
return True, "Stopped server", [12345]
def mock_ui(host, port, open_browser, reload):
ui_called["host"] = host
ui_called["port"] = port
ui_called["open_browser"] = open_browser
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
monkeypatch.setattr(sl_cmd, "ui_command", mock_ui)
result = runner.invoke(app, ["restart", "--port", "8888", "--no-open"])
assert result.exit_code == 0
assert stopped["port"] == 8888
assert ui_called["port"] == 8888
assert ui_called["open_browser"] is False
+1 -1
View File
@@ -21,7 +21,7 @@ from artemis.agents.operator.prompt_builder import OperatorPromptBuilder
def test_operator_prompt_builder():
"""Verify prompt rendering formats variables correctly."""
system_msg = OperatorPromptBuilder.build_system_message()
assert "ARTEMIS Operator" in system_msg
assert "Android Device Operator" in system_msg
assert "click" in system_msg
human_msg = OperatorPromptBuilder.build_human_message(
+128
View File
@@ -64,3 +64,131 @@ async def test_artemis_client_stream_run():
assert events[0].event_type == StreamEventType.STATUS
assert events[0].payload.get("status") == "starting"
assert len(collected_events) == len(events)
@pytest.mark.asyncio
async def test_client_device_specification():
"""Verify target device can be set via init, setters, or per-task run parameters."""
client = ArtemisClient(device_serial="emulator-5554", default_profile="flash")
assert client.device_id == "emulator-5554"
assert client.device_serial == "emulator-5554"
# Mutate via property setter
client.device_serial = "pixel-9-pro"
assert client.device_id == "pixel-9-pro"
assert client.device_serial == "pixel-9-pro"
# Mutate via chaining method
client.set_device("pixel-8")
assert client.device_id == "pixel-8"
# Override device per run call using device_serial
res1 = await client.run("Task on dev 1", device_serial="target-device-alpha")
assert res1.device_id == "target-device-alpha"
# Override device per run call using device_id
res2 = await client.run("Task on dev 2", device_id="target-device-beta")
assert res2.device_id == "target-device-beta"
@pytest.mark.asyncio
async def test_client_stream_run_device_specification():
"""Verify stream_run propagates custom target device."""
client = ArtemisClient(device_id="default-stream-dev", default_profile="flash")
events = []
async for ev in client.stream_run("Test goal", device_serial="explicit-stream-dev"):
events.append(ev)
assert len(events) >= 1
assert events[0].payload.get("device_id") == "explicit-stream-dev"
@pytest.mark.asyncio
async def test_client_concurrency_mode_configuration():
"""Verify concurrency_mode can be configured as 'global' or 'per_device'."""
from artemis import ConcurrencyMode
client = ArtemisClient(concurrency_mode=ConcurrencyMode.GLOBAL)
assert client.concurrency_mode == "global"
client.set_concurrency_mode(ConcurrencyMode.PER_DEVICE)
assert client.concurrency_mode == "per_device"
client.concurrency_mode = "global"
assert client.concurrency_mode == "global"
@pytest.mark.asyncio
async def test_sdk_multi_device_parallel_execution():
"""Verify per_device concurrency allows tasks on different devices to run concurrently."""
import asyncio
client_a = ArtemisClient(device_id="mock-dev-parallel-1", concurrency_mode="per_device")
client_b = ArtemisClient(device_id="mock-dev-parallel-2", concurrency_mode="per_device")
# Run tasks on two different devices concurrently
task_a = asyncio.create_task(client_a.run("Task on device 1"))
task_b = asyncio.create_task(client_b.run("Task on device 2"))
res_a, res_b = await asyncio.gather(task_a, task_b)
assert res_a.status in ("completed", "running", "success")
assert res_b.status in ("completed", "running", "success")
assert res_a.device_id == "mock-dev-parallel-1"
assert res_b.device_id == "mock-dev-parallel-2"
@pytest.mark.asyncio
async def test_sdk_global_concurrency_serializes_across_devices():
"""Verify global concurrency restricts execution to 1 task across all devices."""
import asyncio
from artemis.runtime import DeviceBusyError, DeviceExecutionLock
# Pre-acquire a lock on device A with global concurrency mode
lock_a = DeviceExecutionLock("mock-global-dev-1", "active global task", concurrency_mode="global")
lock_a.acquire()
try:
client_b = ArtemisClient(device_id="mock-global-dev-2", concurrency_mode="global")
# Attempting non-blocking execution on device B should fail because global concurrency limit (1) is held by device A
with pytest.raises(DeviceBusyError, match="Global task concurrency limit"):
await client_b.run("Task on device 2", blocking=False)
finally:
lock_a.release()
@pytest.mark.asyncio
async def test_sdk_run_task_with_device_and_concurrency():
"""Verify Task object with device_serial and concurrency_mode executes via run_task."""
client = ArtemisClient()
task = Task(
goal="Verify search functionality",
profile="flash",
device_serial="device-from-task-obj",
concurrency_mode="per_device",
)
assert task.device_id == "device-from-task-obj"
assert task.concurrency_mode == "per_device"
res = await client.run_task(task)
assert res.device_id == "device-from-task-obj"
assert res.status in ("completed", "running", "success")
def test_builder_and_agent_concurrency_and_device_targeting():
"""Verify AgentConfigBuilder and Agent support device targeting and concurrency configuration."""
from artemis import Agent, Builders
# 1. Builder supports string for_device directly
cfg1 = Builders.AgentConfig.for_device("emulator-9999").with_concurrency_mode("global").build()
assert cfg1.device_id == "emulator-9999"
assert cfg1.concurrency_mode == "global"
# 2. Builder supports for_device_serial
cfg2 = Builders.AgentConfig.for_device_serial("pixel-test-serial").with_concurrency_mode("per_device").build()
assert cfg2.device_id == "pixel-test-serial"
assert cfg2.concurrency_mode == "per_device"
# 3. Agent supports device_serial and concurrency_mode directly in constructor
agent = Agent(device_serial="pixel-constructor-dev", concurrency_mode="global")
assert agent._config.device_id == "pixel-constructor-dev"
assert agent._config.concurrency_mode == "global"