feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
Reference in new issue
Block a user