feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
# 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
"""Opt-in live contract test for the remote-only Python SDK.
Run with:
ARTEMIS_TEST_DEVICE_SERIAL=<serial> pytest tests/integration/test_thin_sdk_live.py
"""
from __future__ import annotations
import os
import asyncio
import time
import uuid
import pytest
from artemis_client import ArtemisClient, TaskRejectedError
def _live_client() -> tuple[ArtemisClient, str]:
serial = os.environ.get("ARTEMIS_TEST_DEVICE_SERIAL")
if not serial:
pytest.skip("ARTEMIS_TEST_DEVICE_SERIAL is required for the live SDK test")
return (
ArtemisClient(
os.environ.get("ARTEMIS_BASE_URL", "http://127.0.0.1:8000"),
device_serial=serial,
default_profile="flash",
poll_interval=0.5,
),
serial,
)
@pytest.mark.android
@pytest.mark.asyncio
async def test_thin_sdk_remote_contract_on_real_device():
client, serial = _live_client()
readiness = await client.health()
assert readiness
capabilities = await client.capabilities()
assert capabilities.supports("tasks.submit")
devices = await client.list_devices()
assert any(device.serial == serial and device.state == "device" for device in devices)
task_id = str(uuid.uuid4())
handle = await client.submit(
"Open Android Settings, navigate to About phone, and verify that the visible model is Pixel 10 "
"and the Android version is 16. Do not change any setting. Stop after both values are observed.",
task_id=task_id,
locked_app_package="com.android.settings",
)
assert handle.task_id == task_id
duplicate = await client.submit(
"Open Android Settings, navigate to About phone, and verify that the visible model is Pixel 10 "
"and the Android version is 16. Do not change any setting. Stop after both values are observed.",
task_id=task_id,
locked_app_package="com.android.settings",
)
assert duplicate.task_id == task_id
result = await client.wait_for_task(task_id, timeout=600)
assert result.succeeded, result.error or result.raw
cleanup_deadline = time.monotonic() + 30
while time.monotonic() < cleanup_deadline:
scheduler = await client.health()
if scheduler.get("status") == "idle" and not scheduler.get("active_tasks"):
break
await asyncio.sleep(0.5)
else:
pytest.fail("Artemis scheduler did not release the device after task completion")
@pytest.mark.android
@pytest.mark.asyncio
async def test_thin_sdk_rejects_unknown_real_device_serial():
client, _ = _live_client()
with pytest.raises(TaskRejectedError):
await client.submit(
"This task must never reach a device",
device_serial="artemis-nonexistent-device",
)
@pytest.mark.android
@pytest.mark.asyncio
async def test_thin_sdk_can_cancel_remote_task_and_release_device():
client, _ = _live_client()
task_id = str(uuid.uuid4())
await client.submit(
"Open Android Settings and inspect the top-level categories without changing anything.",
task_id=task_id,
locked_app_package="com.android.settings",
)
assert await client.stop(task_id)
cleanup_deadline = time.monotonic() + 30
while time.monotonic() < cleanup_deadline:
scheduler = await client.health()
active_ids = {
str(item.get("session_id"))
for item in scheduler.get("active_tasks", [])
if isinstance(item, dict)
}
queued_ids = {
str(item.get("session_id"))
for item in scheduler.get("queue", [])
if isinstance(item, dict)
}
if task_id not in active_ids | queued_ids:
break
await asyncio.sleep(0.5)
else:
pytest.fail("Cancelled task still owns a scheduler slot or device lock")
@@ -0,0 +1,212 @@
# 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.
"""Security regression tests for the Artemis console boundary.
Covers the invariants the no-auth security model depends on:
- secrets never appear in HTTP responses,
- media endpoints cannot read files outside the media allowlist,
- cross-origin browser traffic and DNS-rebinding Hosts are rejected,
- no CORS grants exist,
- lifecycle controls stay loopback-only.
"""
import secrets as py_secrets
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from apps.admin_console.server import app
from artemis.config import TRACES_PATH, WORKSPACE_ROOT
def _client(**transport_kwargs) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=app, **transport_kwargs), base_url="http://localhost"
)
# ---------------------------------------------------------------------------
# Secret exposure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_credentials_endpoint_never_returns_key_material(monkeypatch):
from artemis.config import settings
honeytoken = f"sk-honeytoken-{py_secrets.token_hex(16)}"
monkeypatch.setattr(
type(settings), "get_api_key", lambda self, provider: SecretStr(honeytoken)
)
async with _client() as ac:
res = await ac.get("/api/system/credentials")
assert res.status_code == 200
assert honeytoken not in res.text
providers = {entry["name"]: entry["configured"] for entry in res.json()["providers"]}
assert providers.get("google") is True
@pytest.mark.asyncio
async def test_server_status_omits_lifecycle_token_and_metadata():
async with _client() as ac:
res = await ac.get("/api/system/server-status")
assert res.status_code == 200
data = res.json()
assert "metadata" not in data
assert "lifecycle_token" not in res.text
assert "cmdline" not in res.text
assert "current_pid" in data
# ---------------------------------------------------------------------------
# File access boundaries
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_videos_endpoint_refuses_dotenv_and_non_video_files():
async with _client() as ac:
for target in (".env", "pyproject.toml", "artemis/__init__.py"):
res = await ac.get(f"/videos/{target}")
assert res.status_code in (403, 404), target
assert "API_KEY" not in res.text
@pytest.mark.asyncio
async def test_videos_endpoint_refuses_encoded_traversal():
async with _client() as ac:
for target in (
"%2e%2e/%2e%2e/etc/passwd",
"..%5c..%5cwindows%5cwin.ini",
"%2e%2e%2f.env",
):
res = await ac.get(f"/videos/{target}")
assert res.status_code in (403, 404), target
@pytest.mark.asyncio
async def test_videos_endpoint_still_serves_real_recordings():
TRACES_PATH.mkdir(parents=True, exist_ok=True)
probe = TRACES_PATH / f"security-probe-{py_secrets.token_hex(4)}.mp4"
probe.write_bytes(b"\x00\x00\x00\x18ftypmp42")
try:
async with _client() as ac:
res = await ac.get(f"/videos/{probe.name}")
assert res.status_code == 200
assert res.headers["content-type"].startswith("video/mp4")
finally:
probe.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_local_file_endpoint_refuses_non_media_workspace_files():
async with _client() as ac:
for target in (
str(WORKSPACE_ROOT / ".env"),
str(WORKSPACE_ROOT / "pyproject.toml"),
"file://" + str(WORKSPACE_ROOT / ".env"),
):
res = await ac.get("/local_file", params={"path": target})
assert res.status_code in (403, 404), target
assert "API_KEY" not in res.text
@pytest.mark.asyncio
async def test_spa_route_does_not_leak_files_outside_static_roots():
async with _client() as ac:
res = await ac.get("/%2e%2e/%2e%2e/pyproject.toml")
# The catch-all SPA route must fall back to HTML, never the file content.
assert "requires-python" not in res.text
assert res.headers["content-type"].startswith("text/html")
# ---------------------------------------------------------------------------
# Browser boundary: Host, Origin, CORS
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dns_rebinding_host_is_rejected():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://attacker.example"
) as ac:
res = await ac.get("/api/system/emulator/status")
assert res.status_code == 403
@pytest.mark.asyncio
async def test_cross_origin_browser_request_is_rejected_without_cors_grant():
async with _client() as ac:
res = await ac.post(
"/api/system/emulator/dismiss",
headers={"Origin": "https://attacker.example"},
)
assert res.status_code == 403
assert "access-control-allow-origin" not in {k.lower() for k in res.headers}
@pytest.mark.asyncio
async def test_same_origin_browser_request_passes():
async with _client() as ac:
res = await ac.get(
"/api/system/emulator/status", headers={"Origin": "http://localhost"}
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_null_origin_is_rejected():
async with _client() as ac:
res = await ac.get("/api/system/emulator/status", headers={"Origin": "null"})
assert res.status_code == 403
@pytest.mark.asyncio
async def test_security_headers_present_and_api_responses_uncacheable():
async with _client() as ac:
res = await ac.get("/api/system/emulator/status")
assert res.headers.get("x-content-type-options") == "nosniff"
assert res.headers.get("referrer-policy") == "no-referrer"
assert res.headers.get("cache-control") == "no-store"
# ---------------------------------------------------------------------------
# Lifecycle controls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_restart_is_loopback_only():
with patch("threading.Thread") as mock_thread:
mock_thread.return_value = MagicMock()
async with AsyncClient(
transport=ASGITransport(app=app, client=("203.0.113.9", 51000)),
base_url="http://localhost",
) as ac:
res = await ac.post("/api/system/restart")
assert res.status_code == 403
mock_thread.assert_not_called()
@@ -0,0 +1,60 @@
"""Legacy "success" session rows must surface as the canonical "completed".
The sessions table historically received "success" from the DataEngine while
the queue reconcile wrote "completed", so API consumers saw a mix of both
terminal strings. Reads through the repository now normalize the legacy alias;
get_session_status stays raw so the queue reconcile can persist the rewrite.
"""
from apps.admin_console.database.connection import db_session
from apps.admin_console.database.repositories.session_repository import SessionRepository
def _insert_session(db_path, session_id, status):
with db_session(db_path) as conn:
conn.execute(
"INSERT INTO sessions "
"(session_id, initial_goal, start_time, end_time, status, device_info, pid) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, session_id, 1.0, 2.0, status, "{}", None),
)
conn.commit()
def test_get_all_sessions_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
_insert_session(db_path, "modern", "completed")
_insert_session(db_path, "broken", "failed")
statuses = {r["session_id"]: r["status"] for r in repository.get_all_sessions()}
assert statuses == {"legacy": "completed", "modern": "completed", "broken": "failed"}
def test_get_session_by_id_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
row = repository.get_session_by_id("legacy")
assert row is not None
assert row["status"] == "completed"
def test_get_latest_session_normalizes_legacy_success(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
row = repository.get_latest_session()
assert row is not None
assert row["status"] == "completed"
def test_get_session_status_stays_raw_for_queue_reconcile(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_session(db_path, "legacy", "success")
assert repository.get_session_status("legacy") == "success"
@@ -43,7 +43,7 @@ def test_emulator_uses_windows_console_isolation():
async def test_system_emulator_status_endpoint():
"""Verify GET /api/system/emulator/status returns valid schema."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.get("/api/system/emulator/status")
assert res.status_code == 200
data = res.json()
@@ -57,7 +57,7 @@ async def test_system_emulator_status_endpoint():
async def test_system_emulator_dismiss_endpoint():
"""Verify POST /api/system/emulator/dismiss resets tracker state."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post("/api/system/emulator/dismiss")
assert res.status_code == 200
data = res.json()
@@ -68,6 +68,34 @@ async def test_system_emulator_dismiss_endpoint():
async def test_system_emulator_launch_empty_validation():
"""Verify POST /api/system/emulator/launch validates empty avd_name."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post("/api/system/emulator/launch", json={"avd_name": " "})
assert res.status_code == 400
@pytest.mark.asyncio
async def test_adb_endpoint_mutation_rejects_cross_origin_browser_requests():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post(
"/api/system/adb/server/probe",
json={"host": "127.0.0.1", "port": 5038, "persist": False},
headers={"Origin": "https://untrusted.example"},
)
assert res.status_code == 403
assert "Artemis console" in res.json()["detail"]
@pytest.mark.asyncio
async def test_adb_endpoint_mutation_is_local_only_by_default(monkeypatch):
monkeypatch.delenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", raising=False)
transport = ASGITransport(app=app, client=("192.168.1.20", 42000))
async with AsyncClient(transport=transport, base_url="http://localhost") as ac:
res = await ac.post(
"/api/system/adb/server/probe",
json={"host": "127.0.0.1", "port": 5038, "persist": False},
)
assert res.status_code == 403
assert "local-only" in res.json()["detail"]
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from types import SimpleNamespace
from unittest.mock import AsyncMock
from fastapi import HTTPException
@@ -112,3 +113,131 @@ async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch
assert kwargs.get("device_serial") == "emulator-5556"
set_active_mock.assert_called_once_with("emulator-5556")
@pytest.mark.asyncio
async def test_idempotent_retry_skips_device_probe_for_active_session(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(
tasks.readiness_engine, "run_device_submission_probe", run_probe
)
monkeypatch.setattr(tasks.state, "active_session_id", "sdk-task-1")
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(
tasks.session_repo,
"get_session_by_id",
lambda session_id: None,
)
result = await tasks.run_task(
RunRequest(
goal="Open Settings",
session_id="sdk-task-1",
device_serial="pixel-10",
ingress="python_sdk",
)
)
assert result["status"] == "running"
assert result["enqueued_count"] == 0
assert result["tasks"][0]["session_id"] == "sdk-task-1"
run_probe.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_explicit_device_is_rejected_before_readiness_probe(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(
return_value=[
SimpleNamespace(serial="pixel-10", state="device"),
]
),
)
result = await tasks.run_task(
RunRequest(
goal="Must not run",
device_serial="missing-device",
session_id="00000000-0000-4000-8000-000000000099",
)
)
assert result["status"] == "rejected"
assert result["tasks"] == []
assert "not connected" in result["error"]
run_probe.assert_not_awaited()
@pytest.mark.asyncio
async def test_explicit_device_is_rejected_when_attached_but_not_ready(monkeypatch):
run_probe = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(
return_value=[
SimpleNamespace(serial="pixel-10", state="unauthorized"),
]
),
)
result = await tasks.run_task(
RunRequest(
goal="Must not run",
device_serial="pixel-10",
session_id="00000000-0000-4000-8000-000000000100",
)
)
assert result["status"] == "rejected"
assert "unauthorized" in result["error"]
run_probe.assert_not_awaited()
@pytest.mark.parametrize("enumeration", [None, []])
@pytest.mark.asyncio
async def test_explicit_device_proceeds_when_enumeration_is_indeterminate(
monkeypatch, enumeration
):
"""A failed (None) or empty enumeration must never hard-reject an explicit
serial: the submission queues and fails downstream if the device is truly
absent. This is the startup-storm regression guard."""
run_probe = AsyncMock(return_value=None)
enqueue_tasks = AsyncMock(return_value={"status": "queued", "tasks": []})
monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
monkeypatch.setattr(tasks.state, "active_session_id", None)
monkeypatch.setattr(tasks.state, "active_connections", {})
monkeypatch.setattr(tasks.state, "queue_items", [])
monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None)
monkeypatch.setattr(
tasks.device_pool,
"try_list_devices_async",
AsyncMock(return_value=enumeration),
)
result = await tasks.run_task(
RunRequest(
goal="Queue through the startup storm",
device_serial="pixel-10",
session_id="00000000-0000-4000-8000-000000000101",
)
)
assert result["status"] == "queued"
enqueue_tasks.assert_awaited_once()
_, kwargs = enqueue_tasks.call_args
assert kwargs.get("device_serial") == "pixel-10"
@@ -25,12 +25,22 @@ from apps.admin_console.core.state import state
from apps.admin_console.routers.tasks import get_status
from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service
from artemis.runtime.device_lock import DeviceLockOwner
from artemis.runtime.adb_endpoint import AdbEndpoint
@pytest.fixture(autouse=True)
def clean_state(tmp_path, monkeypatch):
"""Reset global state between tests."""
isolated_pause_file = tmp_path / ".artemis_paused"
# Redirect DeviceExecutionLock's lock/queue directory into tmp_path so that
# enqueue reservations never touch the real %TEMP%/artemis/device-locks dir
# (a live daemon merges those tickets into its /api/status queue view).
isolated_lock_dir = tmp_path / "device-locks"
isolated_lock_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"artemis.runtime.device_lock.get_temp_dir",
lambda _subfolder=None: isolated_lock_dir,
)
state_module = importlib.import_module("apps.admin_console.core.state")
queue_module = importlib.import_module("apps.admin_console.services.task_queue_service")
monkeypatch.setattr(state_module, "PAUSE_FILE", isolated_pause_file)
@@ -62,16 +72,6 @@ def clean_state(tmp_path, monkeypatch):
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
from artemis.config.paths import get_temp_dir
from artemis.runtime.device_lock import DeviceExecutionLock
DeviceExecutionLock.cleanup_stale_locks()
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
if queue_dir.exists():
for p in queue_dir.glob("*.wait"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
def test_paused_error_reads_persisted_reason(tmp_path):
@@ -94,6 +94,37 @@ async def test_get_next_pending_task():
assert next_task["goal"] == "Goal A"
@pytest.mark.asyncio
async def test_enqueued_task_keeps_its_adb_endpoint_snapshot():
original = AdbEndpoint.create("127.0.0.1", 5038)
changed = AdbEndpoint.local()
with (
patch.object(TaskQueueService, "ensure_worker_running"),
patch(
"apps.admin_console.services.task_queue_service.current_adb_endpoint",
return_value=original,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
result = await TaskQueueService.enqueue_tasks(["Keep endpoint"])
task_item = result["tasks"][0]
assert task_item["adb_endpoint"]["identity"] == original.identity
with patch(
"apps.admin_console.services.task_queue_service.current_adb_endpoint",
return_value=changed,
):
target = TaskQueueService._task_target(task_item)
assert target.endpoint == original
assert target.serial == "emulator-5554"
assert target.lock_key == f"{original.identity}/emulator-5554"
@pytest.mark.parametrize(
("current_status", "returncode", "stopped", "expected"),
[
@@ -190,8 +221,18 @@ async def test_queue_worker_execution_lifecycle():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
# Enqueue two tasks
res = await task_queue_service.enqueue_tasks(["First Task", "Second Task"])
assert res["enqueued_count"] == 2
@@ -230,8 +271,18 @@ async def test_queue_worker_cmd_construction():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
enqueue_result = await task_queue_service.enqueue_tasks(
["Test Goal with Outputter"],
@@ -262,6 +313,11 @@ async def test_queue_worker_cmd_construction():
executed_kwargs[0]["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"]
== (enqueue_result["tasks"][0]["queue_ticket"])
)
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"]
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
if sys.platform == "win32":
assert executed_kwargs[0]["creationflags"] == (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
@@ -491,8 +547,18 @@ async def test_cancel_task_triggers_next_pending_task():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
await task_queue_service.enqueue_tasks(["Task 1", "Task 2"])
for _ in range(40):
@@ -536,8 +602,18 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch("apps.admin_console.services.task_queue_service.media_service"),
patch(
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
return_value=None,
),
patch(
"artemis.runtime.device_pool.device_pool.select_device",
return_value="emulator-5554",
),
):
mock_repo.get_running_session_id.return_value = None
mock_repo.get_video_recording_for_session.return_value = {"status": "ready"}
# Enqueue Task 1
res1 = await task_queue_service.enqueue_tasks(["Task 1"])
@@ -668,7 +744,7 @@ async def test_queue_worker_notifies_conversation():
patch("mcp_server.notifiers.notify") as mock_notify,
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
patch(
"artemis.runtime.device_pool.device_pool.list_devices_async",
"artemis.runtime.device_pool.device_pool.try_list_devices_async",
new=AsyncMock(return_value=[]),
),
):
@@ -854,7 +930,7 @@ async def test_enqueue_tasks_debounces_rapid_identical_submissions():
patch("apps.admin_console.services.task_queue_service.session_repo"),
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", return_value="ticket-456"),
patch(
"artemis.runtime.device_pool.device_pool.list_devices_async",
"artemis.runtime.device_pool.device_pool.try_list_devices_async",
new=AsyncMock(return_value=[]),
),
patch.object(TaskQueueService, "ensure_worker_running"),
@@ -0,0 +1,296 @@
# 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.
"""Tests for process-wide local and forwarded ADB server selection."""
import os
import subprocess
import pytest
from artemis.config import settings
from artemis.core.diagnostics.adb_server_connection import (
AdbServerConnectionManager,
AdbServerEndpoint,
InvalidAdbServerEndpoint,
adb_server_connection,
)
from artemis.core.diagnostics.engine import ReadinessEngine
from artemis.runtime.adb_endpoint import ADB_ENDPOINT_ID_ENV, AdbSession
@pytest.mark.parametrize(
("host", "port"),
[
("", 5037),
("http://127.0.0.1", 5037),
("127.0.0.1/path", 5037),
("127.0.0.1", 0),
("127.0.0.1", 65536),
],
)
def test_validate_endpoint_rejects_invalid_addresses(host, port):
with pytest.raises(InvalidAdbServerEndpoint):
AdbServerConnectionManager.validate_endpoint(host, port)
def test_endpoint_formats_ipv6_socket():
endpoint = AdbServerConnectionManager.validate_endpoint("[::1]", 5038)
assert endpoint.socket == "tcp:[::1]:5038"
assert endpoint.mode == "remote"
def test_adb_session_builds_explicit_endpoint_command_and_environment():
endpoint = AdbServerEndpoint("server.example", 5040)
session = AdbSession(endpoint, adb_path="adb")
assert session.command(["devices"]) == [
"adb",
"-H",
"server.example",
"-P",
"5040",
"devices",
]
environment = session.environment({"EXISTING": "value"})
assert environment["EXISTING"] == "value"
assert environment["ADB_SERVER_SOCKET"] == "tcp:server.example:5040"
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
@pytest.mark.asyncio
async def test_connect_activates_forwarded_server_after_success(monkeypatch):
captured: dict[str, object] = {}
def fake_run(command, **kwargs):
captured["command"] = command
captured["environment"] = kwargs["env"]
return subprocess.CompletedProcess(
command,
0,
stdout=(
"List of devices attached\n"
"R58M1234 device product:husky model:Pixel_8_Pro transport_id:1\n"
),
stderr="",
)
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:127.0.0.1:5999")
manager = AdbServerConnectionManager(adb_resolver=lambda: "adb", env_files=[])
result = await manager.connect("127.0.0.1", 5038, persist=False)
assert result["success"] is True
assert result["devices"] == [
{
"serial": "R58M1234",
"state": "device",
"model": "Pixel 8 Pro",
"product": "husky",
}
]
assert captured["command"] == [
"adb",
"-H",
"127.0.0.1",
"-P",
"5038",
"devices",
"-l",
]
assert "ADB_SERVER_SOCKET" not in captured["environment"]
assert settings.ADB_HOST == "127.0.0.1"
assert settings.ADB_PORT == 5038
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5038"
@pytest.mark.asyncio
async def test_connect_does_not_activate_unreachable_server(monkeypatch):
def fake_run(command, **kwargs):
return subprocess.CompletedProcess(
command,
1,
stdout="",
stderr="cannot connect to daemon at tcp:127.0.0.1:5038",
)
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
manager = AdbServerConnectionManager(adb_resolver=lambda: "adb", env_files=[])
result = await manager.connect("127.0.0.1", 5038, persist=False)
assert result["success"] is False
assert result["error_code"] == "server_unreachable"
assert settings.ADB_PORT == 5037
@pytest.mark.asyncio
async def test_probe_never_activates_reachable_endpoint(monkeypatch):
monkeypatch.setattr(
subprocess,
"run",
lambda command, **kwargs: subprocess.CompletedProcess(
command,
0,
stdout="List of devices attached\n",
stderr="",
),
)
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
manager = AdbServerConnectionManager(adb_resolver=lambda: "adb", env_files=[])
result = await manager.probe("127.0.0.1", 5038)
assert result["success"] is True
assert result["endpoint"]["port"] == 5038
assert settings.ADB_PORT == 5037
@pytest.mark.asyncio
async def test_default_resolver_uses_the_shared_toolchain(monkeypatch):
captured: dict[str, object] = {}
monkeypatch.setattr(
"artemis.core.diagnostics.adb_server_connection.toolchain.resolve",
lambda name: "sdk-platform-tools-adb" if name == "adb" else None,
)
def fake_run(command, **kwargs):
captured["command"] = command
return subprocess.CompletedProcess(
command,
0,
stdout="List of devices attached\n",
stderr="",
)
monkeypatch.setattr(subprocess, "run", fake_run)
manager = AdbServerConnectionManager(env_files=[])
result = await manager.probe("127.0.0.1", 5038)
assert result["success"] is True
assert captured["command"][0] == "sdk-platform-tools-adb"
@pytest.mark.asyncio
async def test_connect_persists_endpoint_without_replacing_other_settings(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text("EXISTING_VALUE=keep-me\n", encoding="utf-8")
def fake_run(command, **kwargs):
return subprocess.CompletedProcess(
command,
0,
stdout="List of devices attached\n",
stderr="",
)
monkeypatch.setattr(subprocess, "run", fake_run)
manager = AdbServerConnectionManager(adb_resolver=lambda: "adb", env_files=[env_file])
result = await manager.connect("tunnel.local", 5038, persist=True)
content = env_file.read_text(encoding="utf-8")
assert result["persisted"] is True
assert "EXISTING_VALUE=keep-me" in content
assert "ADB_HOST=tunnel.local" in content
assert "ADB_PORT=5038" in content
@pytest.mark.asyncio
async def test_connect_remains_active_when_persistence_fails(monkeypatch):
def fake_run(command, **kwargs):
return subprocess.CompletedProcess(
command,
0,
stdout="List of devices attached\n",
stderr="",
)
def fail_persist(endpoint):
raise OSError("read-only")
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
manager = AdbServerConnectionManager(adb_resolver=lambda: "adb", env_files=[])
monkeypatch.setattr(manager, "_persist", fail_persist)
result = await manager.connect("127.0.0.1", 5038, persist=True)
assert result["success"] is True
assert result["persisted"] is False
assert result["persistence_error"] == "read-only"
assert settings.ADB_PORT == 5038
@pytest.mark.asyncio
async def test_use_local_server_restores_standard_endpoint(monkeypatch):
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5038)
manager = AdbServerConnectionManager(env_files=[])
result = await manager.use_local_server(persist=False)
assert result["success"] is True
assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict()
assert settings.ADB_PORT == 5037
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5037"
@pytest.mark.asyncio
async def test_restart_skips_kill_server_for_forwarded_endpoint(monkeypatch):
endpoint = AdbServerEndpoint("127.0.0.1", 5038)
monkeypatch.setattr(adb_server_connection, "current_endpoint", lambda: endpoint)
result = await ReadinessEngine().restart_adb_server()
assert result["success"] is True
assert result["skipped"] is True
assert result["endpoint"]["port"] == 5038
@pytest.mark.asyncio
async def test_remote_endpoint_blocks_local_wireless_connection(monkeypatch):
monkeypatch.setattr(
adb_server_connection,
"current_endpoint",
lambda: AdbServerEndpoint("127.0.0.1", 5038),
)
result = await ReadinessEngine().connect_wireless_adb("192.168.1.100", 5555)
assert result["success"] is False
assert "Switch to local ADB" in result["message"]
@pytest.mark.asyncio
async def test_remote_endpoint_blocks_local_emulator_launch(monkeypatch):
monkeypatch.setattr(
adb_server_connection,
"current_endpoint",
lambda: AdbServerEndpoint("127.0.0.1", 5038),
)
result = await ReadinessEngine().launch_emulator("Pixel_8")
assert result["status"] == "failed"
assert "Switch to local ADB" in result["error"]
+17
View File
@@ -327,6 +327,23 @@ def test_resumed_session_monotonic_step_numbering(tmp_path):
assert [s.step_number for s in steps] == [1, 2, 3]
def test_end_session_normalizes_legacy_success_status(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
sid = engine.start_session("Terminal status normalization test")
engine.end_session("success")
session = engine.storage.get_session(sid)
assert session is not None
assert session.status == "completed"
assert session.end_time is not None
def test_update_step_summary_includes_step_number_in_sse(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
+6 -1
View File
@@ -119,6 +119,12 @@ def test_mobile_run_task_with_device_serial(temp_trace_env):
) 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",
@@ -204,7 +210,6 @@ def test_mobile_manage_task_status_and_stop(temp_trace_env):
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())
+21
View File
@@ -96,6 +96,27 @@ def test_db_never_overrides_user_cancellation(use_db):
assert current_status == "cancelled"
def test_legacy_success_status_is_normalized_to_completed(no_persistence):
status_data = {"status": "success", "result": "the answer", "pid": 111}
current_status, _pid, is_alive = _reconcile_task_state("t7", status_data)
assert current_status == "completed"
assert status_data["status"] == "completed"
assert status_data["result"] == "the answer"
assert is_alive is False
def test_db_success_verdict_surfaces_as_completed(use_db):
use_db("t8", "success")
status_data = {"status": "running", "pid": 111}
current_status, _pid, _is_alive = _reconcile_task_state("t8", status_data)
assert current_status == "completed"
assert status_data["status"] == "completed"
def test_pidless_running_task_is_assumed_alive(no_persistence):
# Daemon dispatch writes no pid into status.json; liveness must not be
# inferred even long past the startup grace window.
+13
View File
@@ -73,6 +73,19 @@ def test_update_trace_status(temp_trace_env):
assert failed["error"] == "App crashed"
def test_update_trace_status_normalizes_success_alias(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-789")
updated = trace_store.update_trace_status(trace_id=trace_id, status="success")
assert updated is not None
assert updated["status"] == "completed"
assert updated["end_time"] is not None
persisted = trace_store.read_status(trace_id)
assert persisted["status"] == "completed"
def test_read_nonexistent_status(temp_trace_env):
non_existent = str(uuid.uuid4())
assert trace_store.read_status(non_existent) is None
+17 -3
View File
@@ -53,13 +53,27 @@ def test_is_daemon_running_failure():
assert is_daemon_running() is False
def test_spawn_daemon():
def test_spawn_daemon(tmp_path):
import sys
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)
with (
patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
patch(
"artemis.runtime.daemon_client.daemon_log_path",
return_value=tmp_path / "daemon-8000.log",
),
):
proc = spawn_daemon(host="127.0.0.1", port=9123)
assert proc is mock_proc
assert mock_popen.called
# The daemon must launch via `python -m`, never through the console-script
# shim (a resident artemis.exe would block uv sync reinstalls on Windows).
cmd = mock_popen.call_args.args[0]
assert cmd[0] == sys.executable
assert cmd[1:3] == ["-m", "apps.admin_console.server"]
assert cmd[3:] == ["--host", "127.0.0.1", "--port", "9123"]
def test_ensure_daemon_running_when_already_active():
+27
View File
@@ -67,6 +67,33 @@ def test_device_lock_recovers_stale_owner(monkeypatch):
assert not lock.path.exists()
def test_same_serial_on_different_adb_endpoints_has_independent_locks():
first = DeviceExecutionLock(
"emulator-5554",
"first endpoint",
lock_scope="tcp:127.0.0.1:5037",
)
second = DeviceExecutionLock(
"emulator-5554",
"second endpoint",
lock_scope="tcp:127.0.0.1:5038",
)
first.acquire()
second.acquire()
try:
assert first.path != second.path
owners = DeviceExecutionLock.get_active_owners()
assert len(owners) == 2
assert {owner.lock_scope for owner in owners.values()} == {
"tcp:127.0.0.1:5037",
"tcp:127.0.0.1:5038",
}
finally:
second.release()
first.release()
def test_device_lock_release_does_not_remove_replacement_owner():
lock = DeviceExecutionLock("emulator-5554", "original task")
lock.acquire()
+191
View File
@@ -12,11 +12,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import importlib
from types import SimpleNamespace
import pytest
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.device_pool import DevicePool, DeviceStatus
device_pool_module = importlib.import_module("artemis.runtime.device_pool")
@pytest.fixture(autouse=True)
def isolated_lock_directory(tmp_path, monkeypatch):
@@ -83,3 +89,188 @@ def test_device_pool_list_devices_and_busy_status(monkeypatch):
assert pool.select_device("emulator-5554") == "emulator-5554"
finally:
lock.release()
RAW_DEVICE = ("emulator-5554", "device", "Emulator", "sdk")
def _install_fake_clock(monkeypatch):
clock = {"now": 1000.0}
monkeypatch.setattr(
device_pool_module,
"time",
SimpleNamespace(monotonic=lambda: clock["now"]),
)
return clock
def test_enumeration_snapshot_collapses_repeat_queries(monkeypatch):
pool = DevicePool()
calls = {"count": 0}
def fake_query(timeout=None):
calls["count"] += 1
return [RAW_DEVICE]
monkeypatch.setattr(pool, "_query_adb_devices_sync", fake_query)
first = pool.list_devices()
second = pool.list_devices()
assert calls["count"] == 1
assert [d.serial for d in first] == [d.serial for d in second] == ["emulator-5554"]
def test_enumeration_failure_serves_last_known_snapshot(monkeypatch):
clock = _install_fake_clock(monkeypatch)
pool = DevicePool()
responses = iter([[RAW_DEVICE], None, None])
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: next(responses))
assert [d.serial for d in pool.list_devices()] == ["emulator-5554"]
# Past the fresh TTL but inside the stale-on-error window, a failed query
# keeps answering with the last successful snapshot.
clock["now"] += pool.CACHE_TTL + 1.0
assert [d.serial for d in pool.list_devices()] == ["emulator-5554"]
# Once the stale window closes, persistent failure is reported honestly.
clock["now"] += pool.STALE_ON_ERROR_TTL + 1.0
assert pool.list_devices() == []
def test_query_timeout_widens_until_first_success(monkeypatch):
pool = DevicePool()
assert pool._current_query_timeout() == pool.COLD_QUERY_TIMEOUT
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: [RAW_DEVICE])
pool.list_devices()
assert pool._current_query_timeout() == pool.HOT_QUERY_TIMEOUT
def test_async_enumeration_single_flight(monkeypatch):
pool = DevicePool()
calls = {"count": 0}
async def fake_query(timeout=None):
calls["count"] += 1
await asyncio.sleep(0.05)
return [RAW_DEVICE]
monkeypatch.setattr(pool, "_query_adb_devices_async", fake_query)
async def run():
results = await asyncio.gather(
pool.list_devices_async(),
pool.list_devices_async(),
pool.list_devices_async(),
)
return results
results = asyncio.run(run())
assert calls["count"] == 1
for devices in results:
assert [d.serial for d in devices] == ["emulator-5554"]
def test_warm_up_waits_for_device_handshake(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_resolve_adb", lambda: "adb")
started = {"count": 0}
async def fake_start_server(timeout):
started["count"] += 1
responses = iter([[], [("emulator-5554", "offline", None, None)], [RAW_DEVICE]])
async def fake_query(timeout=None):
return next(responses)
monkeypatch.setattr(pool, "_start_adb_server", fake_start_server)
monkeypatch.setattr(pool, "_query_adb_devices_async", fake_query)
warmed = asyncio.run(pool.warm_up_async(settle_timeout=2.0, poll_interval=0.01))
assert warmed is True
assert started["count"] == 1
# The settled enumeration is cached for the first post-startup listings.
assert pool._cached_raw == [RAW_DEVICE]
def test_warm_up_accepts_empty_enumeration_at_deadline(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_resolve_adb", lambda: "adb")
async def fake_start_server(timeout):
pass
async def fake_query(timeout=None):
return []
monkeypatch.setattr(pool, "_start_adb_server", fake_start_server)
monkeypatch.setattr(pool, "_query_adb_devices_async", fake_query)
warmed = asyncio.run(pool.warm_up_async(settle_timeout=0.0))
assert warmed is True
assert pool._current_query_timeout() == pool.HOT_QUERY_TIMEOUT
def test_warm_up_without_adb_returns_false(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_resolve_adb", lambda: None)
assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False
def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
assert pool.try_list_devices() is None
assert pool.list_devices() == []
fresh_pool = DevicePool()
monkeypatch.setattr(fresh_pool, "_query_adb_devices_sync", lambda timeout=None: [])
assert fresh_pool.try_list_devices() == []
def test_validate_explicit_serial_fails_open_on_indeterminate_enumeration(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
assert pool.validate_explicit_serial("pixel-10") is None
empty_pool = DevicePool()
monkeypatch.setattr(empty_pool, "_query_adb_devices_sync", lambda timeout=None: [])
assert empty_pool.validate_explicit_serial("pixel-10") is None
def test_validate_explicit_serial_rejects_only_on_authoritative_enumeration(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(
pool,
"_query_adb_devices_sync",
lambda timeout=None: [
("pixel-10", "device", None, None),
("pixel-11", "unauthorized", None, None),
],
)
assert pool.validate_explicit_serial("pixel-10") is None
assert "not ready" in pool.validate_explicit_serial("pixel-11")
missing = pool.validate_explicit_serial("pixel-12")
assert "not connected" in missing
assert "pixel-10" in missing
def test_validate_explicit_serial_async_matches_sync(monkeypatch):
pool = DevicePool()
async def fake_query(timeout=None):
return [("pixel-10", "device", None, None)]
monkeypatch.setattr(pool, "_query_adb_devices_async", fake_query)
async def run():
return (
await pool.validate_explicit_serial_async("pixel-10"),
await pool.validate_explicit_serial_async("pixel-12"),
)
ok, missing = asyncio.run(run())
assert ok is None
assert "not connected" in missing
@@ -14,6 +14,7 @@
"""Unit tests for Artemis Server Lifecycle Management and API Endpoints."""
import asyncio
import json
import os
from unittest.mock import MagicMock, patch
@@ -21,13 +22,15 @@ from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from apps.admin_console.server import app
from apps.admin_console import server as server_module
from apps.admin_console.server import LIFECYCLE_TOKEN, app
from artemis.runtime.server_lifecycle import (
clear_server_info,
find_server_pids,
get_server_status,
is_port_in_use,
read_server_info,
request_graceful_shutdown,
stop_server,
write_server_info,
)
@@ -56,8 +59,12 @@ def test_write_read_clear_server_info(tmp_path, monkeypatch):
assert info["host"] == "127.0.0.1"
assert "started_at" in info
# Clear info
clear_server_info()
# A stop targeting another port must not erase this server's metadata.
clear_server_info(port=9999)
assert fake_info_file.exists()
# Clear matching info
clear_server_info(port=8888)
assert not fake_info_file.exists()
assert read_server_info() is None
@@ -121,6 +128,55 @@ def test_stop_server_when_not_running(monkeypatch):
assert "No active server" in msg
def test_server_main_parses_non_default_bind_address(monkeypatch):
run_server = MagicMock()
monkeypatch.setattr(server_module, "run_ui_server", run_server)
server_module.main(["--host", "127.0.0.1", "--port", "9123"])
run_server.assert_called_once_with(host="127.0.0.1", port=9123)
def test_request_graceful_shutdown_uses_metadata_token(monkeypatch):
response = MagicMock(status=202)
response.__enter__.return_value = response
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.read_server_info",
lambda: {"port": 9123, "lifecycle_token": "local-secret"},
)
urlopen = MagicMock(return_value=response)
monkeypatch.setattr("artemis.runtime.server_lifecycle.urllib.request.urlopen", urlopen)
assert request_graceful_shutdown(9123) is True
request = urlopen.call_args.args[0]
assert request.full_url == "http://127.0.0.1:9123/api/system/shutdown"
assert request.get_header("X-artemis-lifecycle-token") == "local-secret"
def test_stop_server_prefers_graceful_shutdown(monkeypatch):
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.request_graceful_shutdown", lambda port, timeout: True
)
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle._any_pid_alive", lambda pids: False)
terminate = MagicMock()
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.ProcessSupervisor.terminate_tree", terminate
)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.DeviceExecutionLock.cleanup_stale_locks", lambda: 0
)
monkeypatch.setattr("artemis.runtime.server_lifecycle.clear_server_info", lambda **kwargs: None)
success, msg, stopped = stop_server(9123)
assert success is True
assert stopped == [12345]
assert "gracefully" in msg
terminate.assert_not_called()
def test_stop_server_terminates_tree(monkeypatch):
"""Verify stop_server calls ProcessSupervisor.terminate_tree and returns stopped pids."""
terminated_pids = []
@@ -134,6 +190,9 @@ def test_stop_server_terminates_tree(monkeypatch):
return False
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", mock_in_use)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.request_graceful_shutdown", lambda port, timeout: False
)
monkeypatch.setattr(
"artemis.runtime.supervisor.ProcessSupervisor.terminate_tree",
lambda pid, timeout_seconds=4.0: terminated_pids.append(pid),
@@ -142,19 +201,25 @@ def test_stop_server_terminates_tree(monkeypatch):
"artemis.runtime.device_lock.DeviceExecutionLock.cleanup_stale_locks",
lambda: 1,
)
reconcile = MagicMock(return_value=1)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle._reconcile_orphaned_sessions", reconcile
)
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == [12345]
assert 12345 in terminated_pids
assert "stopped" in msg.lower()
assert "Reconciled 1 interrupted session" in msg
reconcile.assert_called_once_with()
@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"
transport=ASGITransport(app=app), base_url="http://localhost"
) as client:
response = await client.get("/api/system/server-status")
assert response.status_code == 200
@@ -172,7 +237,7 @@ async def test_api_restart_endpoint():
mock_thread.return_value = mock_instance
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
transport=ASGITransport(app=app), base_url="http://localhost"
) as client:
response = await client.post("/api/system/restart")
assert response.status_code == 200
@@ -185,17 +250,23 @@ async def test_api_restart_endpoint():
@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
"""Verify shutdown is authenticated and drives Uvicorn's graceful exit flag."""
fake_server = MagicMock(should_exit=False)
app.state.uvicorn_server = fake_server
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()
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://localhost"
) as client:
denied = await client.post("/api/system/shutdown")
assert denied.status_code == 403
response = await client.post(
"/api/system/shutdown",
headers={"X-Artemis-Lifecycle-Token": LIFECYCLE_TOKEN},
)
assert response.status_code == 202
assert response.json()["status"] == "shutting_down"
await asyncio.sleep(0.1)
assert fake_server.should_exit is True
app.state.uvicorn_server = None
+37 -2
View File
@@ -476,7 +476,42 @@ def test_cli_stop_command(monkeypatch):
def test_cli_restart_command(monkeypatch):
"""Verify 'artemis restart' stops previous server and invokes ui_command."""
"""Verify 'artemis restart' stops previous server and spawns a detached daemon by default."""
from unittest.mock import MagicMock
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
from artemis.runtime import server_lifecycle
stopped = {}
spawned = {}
def mock_stop(port, timeout=4.0, force=False):
stopped["port"] = port
return True, "Stopped server", [12345]
def mock_spawn(host, port):
spawned["host"] = host
spawned["port"] = port
proc = MagicMock()
proc.pid = 54321
proc.poll.return_value = None
return proc
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
monkeypatch.setattr(sl_cmd, "spawn_daemon", mock_spawn)
monkeypatch.setattr(sl_cmd, "is_daemon_running", lambda **kwargs: True)
monkeypatch.setattr(sl_cmd, "ensure_showcase_built", lambda console: None)
result = runner.invoke(app, ["restart", "--port", "8888", "--no-open"])
assert result.exit_code == 0
assert stopped["port"] == 8888
assert spawned["port"] == 8888
assert "PID: 54321" in result.output
def test_cli_restart_foreground_command(monkeypatch):
"""Verify 'artemis restart --foreground' runs the server attached via ui_command."""
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
from artemis.runtime import server_lifecycle
@@ -496,7 +531,7 @@ def test_cli_restart_command(monkeypatch):
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"])
result = runner.invoke(app, ["restart", "--port", "8888", "--no-open", "--foreground"])
assert result.exit_code == 0
assert stopped["port"] == 8888
assert ui_called["port"] == 8888
+103 -160
View File
@@ -5,190 +5,133 @@
# 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 the official Python SDK (ArtemisClient, Task, TaskResult)."""
"""Compatibility tests for the single, remote-only ArtemisClient implementation."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from typing import Any
import pytest
from artemis import ArtemisClient, Task, TaskResult
import artemis
from artemis import ArtemisClient, ConcurrencyMode, Task, TaskResult
from artemis_client import ArtemisClient as ThinArtemisClient
from artemis_client import TaskResult as ThinTaskResult
from artemis_client.errors import NotFoundError
@pytest.mark.asyncio
async def test_artemis_client_run_mock():
"""Verify ArtemisClient runs a task through the execution pipeline."""
client = ArtemisClient(device_id="mock-test-dev", default_profile="pro")
assert client.device_id == "mock-test-dev"
assert client.default_profile == "pro"
class FakeTransport:
def __init__(self) -> None:
self.responses: dict[tuple[str, str], list[Any]] = defaultdict(list)
self.calls: list[tuple[str, str, Mapping[str, Any] | None]] = []
result = await client.run(goal="Open Settings and enable Dark Theme")
assert isinstance(result, TaskResult)
assert result.status in ("completed", "running", "success")
assert result.trace_id.startswith("trace_") or len(result.trace_id) > 0
def add(self, method: str, path: str, *responses: Any) -> None:
self.responses[(method, path)].extend(responses)
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
self.calls.append((method, path, json_body))
queued = self.responses[(method, path)]
if not queued:
raise AssertionError(f"Unexpected request: {method} {path}")
response = queued.pop(0)
if isinstance(response, BaseException):
raise response
return response
def test_task_model_validation():
"""Verify Task validation and default arguments."""
task = Task(goal="Search for flight prices", profile="flash", device_id="emulator-5554")
assert task.goal == "Search for flight prices"
assert task.profile == "flash"
assert task.device_id == "emulator-5554"
assert task.max_turns == 30
assert task.locked_package is None
def test_full_runtime_reexports_thin_client_types():
assert ArtemisClient is ThinArtemisClient
assert TaskResult is ThinTaskResult
assert artemis.ArtemisClient is ThinArtemisClient
@pytest.mark.asyncio
async def test_artemis_client_stream_run():
"""Verify ArtemisClient.stream_run yields real-time StreamEvents."""
from artemis import StreamEvent, StreamEventType
def test_legacy_constructor_settings_are_remote_defaults():
client = ArtemisClient(
device_serial="emulator-5554",
default_profile="pro",
concurrency_mode=ConcurrencyMode.PER_DEVICE,
transport=FakeTransport(),
)
client = ArtemisClient(device_id="mock-stream-dev", default_profile="flash")
collected_events: list[StreamEvent] = []
def on_event(ev: StreamEvent):
collected_events.append(ev)
events: list[StreamEvent] = []
async for event in client.stream_run(
goal="Test streaming UI action flow",
profile="flash",
callbacks=[on_event],
):
events.append(event)
assert len(events) >= 2
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.base_url == "http://127.0.0.1:8000"
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.default_profile == "pro"
assert client.concurrency_mode == "per_device"
client.concurrency_mode = "global"
assert client.concurrency_mode == "global"
assert client.set_device("pixel-10") is client
assert client.device_serial == "pixel-10"
@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
async def test_run_uses_remote_api_and_returns_thin_result():
task_id = "00000000-0000-4000-8000-000000000001"
transport = FakeTransport()
transport.add(
"POST",
"/api/run",
{"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]},
)
transport.add(
"GET",
f"/api/sessions/{task_id}",
NotFoundError(404, "not ready"),
{"session_id": task_id, "status": "completed", "current_turn": 3},
)
transport.add(
"GET",
"/api/status",
{"status": "running", "queue": [{"session_id": task_id, "status": "pending"}]},
)
client = ArtemisClient(
device_serial="pixel-10",
default_profile="flash",
poll_interval=0.001,
transport=transport,
)
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")
result = await client.run("Open Settings", task_id=task_id, timeout=1)
# 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"
assert isinstance(result, ThinTaskResult)
assert result.succeeded
request_body = transport.calls[0][2]
assert request_body is not None
assert request_body["device_serial"] == "pixel-10"
assert request_body["profile"] == "flash"
@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()
async def test_legacy_task_object_is_forwarded_to_remote_api():
task_id = "00000000-0000-4000-8000-000000000002"
transport = FakeTransport()
transport.add(
"POST",
"/api/run",
{"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]},
)
transport.add(
"GET",
f"/api/sessions/{task_id}",
{"session_id": task_id, "status": "success"},
)
client = ArtemisClient(transport=transport)
task = Task(
goal="Verify search functionality",
profile="flash",
device_serial="device-from-task-obj",
concurrency_mode="per_device",
device_serial="pixel-10",
locked_package="com.android.settings",
)
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")
result = await client.run_task(task, task_id=task_id, timeout=1)
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"
assert result.succeeded
request_body = transport.calls[0][2]
assert request_body is not None
assert request_body["locked_app_package"] == "com.android.settings"