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"),
|
||||
|
||||
Reference in new issue
Block a user