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:
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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user