feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pty
import select
import sys
import time
import pytest
@pytest.mark.skip(reason="Manual integration test requiring remote host")
def test_artemis_ssh_login():
"""Manual test to verify SSH login to remote Artemis instance."""
run_artemis_ssh_login()
def run_artemis_ssh_login():
host = os.getenv("ARTEMIS_SSH_TEST_HOST", "127.0.0.1")
user = os.getenv("ARTEMIS_SSH_TEST_USER", "artemis")
password = os.getenv("ARTEMIS_SSH_TEST_PASSWORD", "password")
cmd = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"PreferredAuthentications=password,keyboard-interactive",
f"{user}@{host}",
(
"echo '=================================================='; echo '🚀 BINGO!! PURE"
" PASSWORD LOGIN VICTORY 🚀'; echo"
" '=================================================='; whoami; hostname; uptime"
),
]
master, slave = pty.openpty()
pid = os.fork()
if pid == 0:
os.close(master)
os.execvp(cmd[0], cmd)
else:
os.close(slave)
output = b""
start = time.time()
password_sent = False
while time.time() - start < 15:
r, _, _ = select.select([master], [], [], 0.3)
if master in r:
try:
data = os.read(master, 2048)
if not data:
break
output += data
sys.stdout.buffer.write(data)
sys.stdout.flush()
if (b"Password:" in data or b"password:" in data) and not password_sent:
time.sleep(0.2)
os.write(master, f"{password}\n".encode())
password_sent = True
except OSError:
break
os.close(master)
os.waitpid(pid, 0)
if __name__ == "__main__":
run_artemis_ssh_login()
+126
View File
@@ -0,0 +1,126 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Unit and Integration Tests for GCP Cloud Brain Outer Server Gateway.
Tests multi-tenant authentication, device registration, device mutex locking,
workspace path resolution, RESTful /api/v1/* endpoints, and SSE event streaming.
"""
import pytest
from fastapi.testclient import TestClient
from cloud_service.gateway.auth import TokenAuthProvider
from cloud_service.gateway.manager import ConnectionManager
try:
from cloud_service.server import app
except ImportError:
from apps.cloud_service.server import app
client = TestClient(app)
def test_token_auth_provider():
auth = TokenAuthProvider()
# Test valid tenant name as Bearer token
assert auth.authenticate("Bearer alice") == "alice"
assert auth.authenticate("Bearer bob") == "bob"
assert auth.authenticate("alice-token") == "alice"
# Test invalid token
assert auth.authenticate("Bearer invalid-token-1234") is None
assert auth.authenticate("invalid-token-1234") is None
def test_connection_manager_device_lifecycle():
mgr = ConnectionManager()
device = mgr.register_device("alice", "pixel8-test-001", model="Pixel 8")
assert device.tenant == "alice"
assert device.device_serial == "pixel8-test-001"
assert device.status == "ONLINE"
devices = mgr.list_tenant_devices("alice")
assert len(devices) == 1
assert devices[0].device_serial == "pixel8-test-001"
# Test unregister
assert mgr.unregister_device("alice", "pixel8-test-001") is True
assert len(mgr.list_tenant_devices("alice")) == 0
assert mgr.unregister_device("alice", "non-existent") is False
def test_workspace_dir_resolution():
mgr = ConnectionManager()
ws_path = mgr.get_workspace_dir("alice", "session_test_001")
assert "alice" in str(ws_path)
assert "session_test_001" in str(ws_path)
assert ws_path.exists()
def test_api_v1_tenant_profile():
headers = {"Authorization": "Bearer alice"}
resp = client.get("/api/v1/tenants/me", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["tenant_id"] == "alice"
assert "max_concurrent_sessions" in data
def test_api_v1_unauthorized():
resp = client.get("/api/v1/tenants/me")
assert resp.status_code == 401
resp2 = client.get("/api/v1/tenants/me", headers={"Authorization": "Bearer bad-token"})
assert resp2.status_code in (401, 403)
def test_api_v1_device_register_and_list():
headers = {"Authorization": "Bearer alice"}
reg_payload = {
"device_serial": "pixel8-api-001",
"model": "Pixel 8 Pro",
"android_version": "14",
}
resp = client.post("/api/v1/devices/register", json=reg_payload, headers=headers)
assert resp.status_code == 200
data = resp.json().get("device", resp.json())
assert data["device_serial"] == "pixel8-api-001"
assert data["model"] == "Pixel 8 Pro"
# List devices
list_resp = client.get("/api/v1/devices", headers=headers)
assert list_resp.status_code == 200
devices = list_resp.json()["devices"]
assert any(d["device_serial"] == "pixel8-api-001" for d in devices)
# Unregister device
del_resp = client.delete("/api/v1/devices/pixel8-api-001", headers=headers)
assert del_resp.status_code == 200
assert del_resp.json()["status"] == "unregistered"
@pytest.mark.asyncio
async def test_device_mutex_lock():
mgr = ConnectionManager()
mgr.register_device("alice", "pixel8-mutex-001")
lock = mgr.get_device_lock("pixel8-mutex-001")
# Simulate device busy by acquiring lock
await lock.acquire()
try:
with pytest.raises(RuntimeError, match="currently BUSY"):
await mgr.spawn_user_session("alice", "pixel8-mutex-001", "test goal")
finally:
lock.release()
+167
View File
@@ -0,0 +1,167 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Unit tests for RemoteAdbDevice, RemoteAdbClient, and platform specific commands parity.
"""
from unittest.mock import MagicMock
from cloud_service.virtualization.remote_adb import (
RemoteAdbDevice,
RemoteAdbClient,
)
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.platform_specific_commands_controller import (
get_adb_device,
list_packages,
get_current_foreground_package,
get_device_date,
)
def test_remote_adb_device_shell():
mock_bridge = MagicMock()
mock_bridge.execute_sync.return_value = {
"status": "SUCCESS",
"output": "package:/data/app/com.android.chrome.apk=com.android.chrome\npackage:/data/app/com.android.settings.apk=com.android.settings",
}
device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge)
output = device.shell("pm list packages -f")
assert "com.android.chrome" in output
mock_bridge.execute_sync.assert_called_once_with("shell", {"command": "pm list packages -f"})
def test_remote_adb_device_current_app():
mock_bridge = MagicMock()
mock_bridge.execute_sync.return_value = {
"status": "SUCCESS",
"package": "com.android.chrome",
"activity": "com.google.android.apps.chrome.Main",
}
device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge)
app = device.current_app()
assert app is not None
assert app.package == "com.android.chrome"
assert app.activity == "com.google.android.apps.chrome.Main"
def test_remote_adb_device_window_size():
mock_bridge = MagicMock()
mock_bridge.execute_sync.return_value = {"status": "SUCCESS", "width": 1440, "height": 3120}
device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge)
size = device.window_size()
assert size == (1440, 3120)
def test_platform_commands_parity_with_remote_device(monkeypatch):
monkeypatch.setenv("ARTEMIS_CLOUD_MODE", "1")
monkeypatch.setenv("ARTEMIS_CLOUD_SESSION_ID", "session_parity_001")
monkeypatch.setenv("ADB_DEVICE_SERIAL", "emulator-5554")
mock_bridge = MagicMock()
mock_bridge.execute_sync.side_effect = [
# 1. date
{"status": "SUCCESS", "output": "Thu Aug 6 12:00:00 UTC 2026\n"},
# 2. pm list packages
{
"status": "SUCCESS",
"output": "package:/data/app/com.example.app1.apk=com.example.app1\npackage:/data/app/com.example.app2.apk=com.example.app2",
},
# 3. current_app
{"status": "SUCCESS", "package": "com.example.app1", "activity": ".MainActivity"},
]
client = RemoteAdbClient(bridge=mock_bridge)
device = client.device("emulator-5554")
ctx = ArtemisContext(
device=DeviceContext(
host_platform="LINUX",
mobile_platform=DevicePlatform.ANDROID,
device_id="emulator-5554",
device_width=1080,
device_height=2400,
),
adb_client=client,
)
dev = get_adb_device(ctx)
assert dev.serial == "emulator-5554"
# Verify real dynamic date
date_str = get_device_date(ctx)
assert "2026" in date_str
# Verify real dynamic package listing (Zero hardcoding)
pkgs = list_packages(ctx)
assert pkgs == "com.example.app1\ncom.example.app2"
# Verify real dynamic foreground package detection (Zero retry loop)
fg_pkg = get_current_foreground_package(ctx)
assert fg_pkg == "com.example.app1"
def test_remote_uiautomator_client_parity():
"""Verify RemoteUIAutomatorClient correctly returns structured UIAutomatorScreenData."""
from cloud_service.virtualization.remote_adb import RemoteUIAutomatorClient
mock_bridge = MagicMock()
mock_bridge.execute_sync.return_value = {
"status": "SUCCESS",
"base64": "fake_b64",
"hierarchy_xml": "<hierarchy><node text='Search' bounds='[100,200][300,400]'/></hierarchy>",
"elements": [{"text": "Search", "bounds": "[100,200][300,400]"}],
"width": 1080,
"height": 2400,
}
client = RemoteUIAutomatorClient(bridge=mock_bridge)
b64, xml, elements = client.get_screen_data()
assert b64 == "fake_b64"
assert "<node text='Search'" in xml
assert len(elements) == 1
assert elements[0]["text"] == "Search"
def test_controller_factory_cloud_mode_uses_unified_controller(monkeypatch):
"""Verify controller_factory creates UnifiedMobileController in Cloud Mode."""
monkeypatch.setenv("ARTEMIS_CLOUD_MODE", "1")
monkeypatch.setenv("ARTEMIS_CLOUD_SESSION_ID", "test_cloud_session")
monkeypatch.setenv("ADB_DEVICE_SERIAL", "emulator-5554")
from artemis.controllers.controller_factory import create_device_controller
from artemis.controllers.unified_controller import UnifiedMobileController
ctx = ArtemisContext(
device=DeviceContext(
host_platform="LINUX",
mobile_platform=DevicePlatform.ANDROID,
device_id="emulator-5554",
device_width=1080,
device_height=2400,
)
)
controller = create_device_controller(ctx)
assert isinstance(controller, UnifiedMobileController)
assert controller.driver.device_id == "emulator-5554"
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

@@ -32,7 +32,7 @@ async def test_video_analyzer_tools(artemis_context, mock_state, inputs_dir):
agent.blackboard_entries = []
agent.sub_system_prompt = "You are a sub agent"
agent.audio_system_prompt = "You are an audio sub agent"
agent.model_name = "gemini-3.6-flash"
agent.model_name = "gemini-3.7-flash"
# We must provide a valid FunctionDeclaration for the tool otherwise Pydantic will complain
from google.genai import types
@@ -0,0 +1,54 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for System Emulator Launch & Lifecycle Endpoints."""
import pytest
from httpx import ASGITransport, AsyncClient
from apps.admin_console.server import app
@pytest.mark.asyncio
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:
res = await ac.get("/api/system/emulator/status")
assert res.status_code == 200
data = res.json()
assert "status" in data
assert "stage_message" in data
assert "progress_percent" in data
assert "logs" in data
@pytest.mark.asyncio
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:
res = await ac.post("/api/system/emulator/dismiss")
assert res.status_code == 200
data = res.json()
assert data.get("success") is True
@pytest.mark.asyncio
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:
res = await ac.post("/api/system/emulator/launch", json={"avd_name": " "})
assert res.status_code == 400
@@ -0,0 +1,323 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from apps.admin_console.core.state import state
from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service
@pytest.fixture(autouse=True)
def clean_state():
"""Reset global state between tests."""
state.clear_queue()
state.queue_items.clear()
state.current_process = None
state.current_goal = None
state.current_profile = None
state.active_session_id = None
state.was_stopped_manually = False
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
yield
state.clear_queue()
state.queue_items.clear()
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
@pytest.mark.asyncio
async def test_get_next_pending_task():
state.queue_items = [
{"session_id": "s1", "goal": "Goal A", "status": "pending"},
{"session_id": "s2", "goal": "Goal B", "status": "pending"},
]
next_task = TaskQueueService._get_next_pending_task()
assert next_task is not None
assert next_task["goal"] == "Goal A"
@pytest.mark.asyncio
async def test_remove_task():
state.queue_items = [
{"session_id": "s1", "goal": "Goal A", "status": "pending"},
{"session_id": "s2", "goal": "Goal B", "status": "pending"},
]
TaskQueueService._remove_task("s1")
assert len(state.queue_items) == 1
assert state.queue_items[0]["goal"] == "Goal B"
@pytest.mark.asyncio
async def test_stop_tasks_clear_all():
state.queue_items = [
{"session_id": "s1", "goal": "Goal 1", "status": "pending"},
{"session_id": "s2", "goal": "Goal 2", "status": "pending"},
{"session_id": "s3", "goal": "Goal 3", "status": "pending"},
]
assert len(state.queue_tasks) == 3
with patch.object(TaskQueueService, "ensure_worker_running"):
stopped = task_queue_service.stop_tasks(clear_all=True)
assert len(state.queue_tasks) == 0
assert len(state.queue_items) == 0
@pytest.mark.asyncio
async def test_stop_tasks_keep_remaining():
state.queue_items = [
{"session_id": "s1", "goal": "Goal 1", "status": "running"},
{"session_id": "s2", "goal": "Goal 2", "status": "pending"},
]
state.active_session_id = "s1"
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_proc.returncode = None
state.current_process = mock_proc
with (
patch(
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree"
) as mock_term,
patch.object(TaskQueueService, "ensure_worker_running"),
):
stopped = task_queue_service.stop_tasks(clear_all=False)
assert stopped is True
mock_proc.kill.assert_called_once()
mock_term.assert_called_once_with(12345)
@pytest.mark.asyncio
async def test_queue_worker_execution_lifecycle():
executed_goals = []
async def fake_subprocess_exec(*args, **kwargs):
goal_arg = args[3]
executed_goals.append(goal_arg)
proc = MagicMock()
proc.pid = 99999
proc.wait = AsyncMock(return_value=0)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
# Enqueue two tasks
res = await task_queue_service.enqueue_tasks(["First Task", "Second Task"])
assert res["enqueued_count"] == 2
# Give event loop time for queue_worker to execute both tasks
for _ in range(30):
if len(executed_goals) == 2:
break
await asyncio.sleep(0.05)
assert executed_goals == ["First Task", "Second Task"]
assert len(state.queue_items) == 0
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
@pytest.mark.asyncio
async def test_queue_worker_cmd_construction():
executed_cmds = []
async def fake_subprocess_exec(*args, **kwargs):
executed_cmds.append(list(args))
proc = MagicMock()
proc.pid = 88888
proc.wait = AsyncMock(return_value=0)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
await task_queue_service.enqueue_tasks(
["Test Goal with Outputter"],
profile="pro",
expected_output="Final summary",
enable_outputter=True,
locked_app_package="com.google.android.apps.maps",
app_path="/path/to/app.apk",
)
for _ in range(30):
if len(executed_cmds) == 1 and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
assert len(executed_cmds) == 1
cmd = executed_cmds[0]
assert "--enable-outputter" in cmd
assert "true" not in cmd
assert "--output-description" in cmd
assert "Final summary" in cmd
assert "--locked-app" in cmd
assert "com.google.android.apps.maps" in cmd
assert "--app-path" in cmd
assert "/path/to/app.apk" in cmd
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
@pytest.mark.asyncio
async def test_stop_tasks_dead_process_resets_state():
state.queue_items = [
{"session_id": "s1", "goal": "Goal 1", "status": "pending"},
]
mock_proc = MagicMock()
mock_proc.pid = 99999999
mock_proc.returncode = None
mock_proc.kill.side_effect = ProcessLookupError("No such process")
state.current_process = mock_proc
with patch.object(TaskQueueService, "ensure_worker_running"):
stopped = task_queue_service.stop_tasks(clear_all=False)
assert stopped is True
assert state.current_process is None
assert state.is_running is False
assert len(state.queue_items) == 0
@pytest.mark.asyncio
async def test_cancel_task_triggers_next_pending_task():
executed_goals = []
async def fake_subprocess_exec(*args, **kwargs):
goal_arg = args[3]
executed_goals.append(goal_arg)
proc = MagicMock()
proc.pid = 77777
async def wait_side_effect():
if goal_arg == "Task 1":
# Simulate task 1 being stopped manually mid-execution
await asyncio.sleep(0.05)
task_queue_service.stop_tasks(clear_all=False)
return -9
await asyncio.sleep(0.02)
return 0
proc.wait = AsyncMock(side_effect=wait_side_effect)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
await task_queue_service.enqueue_tasks(["Task 1", "Task 2"])
for _ in range(40):
if len(executed_goals) == 2:
break
await asyncio.sleep(0.05)
assert executed_goals == ["Task 1", "Task 2"]
assert len(state.queue_items) == 0
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
@pytest.mark.asyncio
async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
executed_goals = []
task1_session_id = None
async def fake_subprocess_exec(*args, **kwargs):
goal_arg = args[3]
executed_goals.append(goal_arg)
proc = MagicMock()
proc.pid = 88888
async def wait_side_effect():
if goal_arg == "Task 1":
return -9
await asyncio.sleep(0.02)
return 0
proc.wait = AsyncMock(side_effect=wait_side_effect)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
# Enqueue Task 1
res1 = await task_queue_service.enqueue_tasks(["Task 1"])
task1_session_id = res1["tasks"][0]["session_id"]
# Immediately stop Task 1
task_queue_service.stop_tasks(clear_all=False)
assert task1_session_id in state.cancelled_session_ids
# Simulate stale session_started arriving over IPC for the cancelled Task 1
from apps.admin_console.services.ipc_service import ipc_service
ipc_service.sanitize_event_data("session_started", {"session_id": task1_session_id})
# If IPC handler checks cancelled_session_ids, state.active_session_id should not get stuck
assert state.active_session_id != task1_session_id
assert state.is_running is False
# Enqueue Task 2 and verify it runs immediately without getting stuck in pending
await task_queue_service.enqueue_tasks(["Task 2"])
for _ in range(40):
if "Task 2" in executed_goals:
break
await asyncio.sleep(0.05)
assert "Task 2" in executed_goals
assert len(state.queue_items) == 0
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
+15 -11
View File
@@ -50,7 +50,7 @@ async def test_explorer_run():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/test_screenshot.jpg"
@@ -138,7 +138,7 @@ async def test_explorer_submit_answer():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/test_screenshot.jpg"
@@ -226,7 +226,7 @@ async def test_explorer_submit_answer_self_correction():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/test_screenshot.jpg"
@@ -380,7 +380,7 @@ async def test_explorer_initial_visual_marking():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/test_screenshot.jpg"
@@ -466,7 +466,7 @@ async def test_explorer_initial_visual_marking_previous_screenshot():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
# current screenshot is different
@@ -559,7 +559,7 @@ async def test_explorer_initial_visual_marking_previous_screenshot_no_ui_tree():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/current_screenshot.jpg"
@@ -641,7 +641,7 @@ async def test_explorer_initial_visual_marking_previous_screenshot_ocr_fusion():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/current_screenshot.jpg"
@@ -743,7 +743,7 @@ async def test_explorer_initial_visual_marking_previous_screenshot_on_the_fly_oc
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/current_screenshot.jpg"
@@ -816,6 +816,10 @@ async def test_explorer_initial_visual_marking_previous_screenshot_on_the_fly_oc
new_callable=AsyncMock,
return_value=mock_ocr_result,
) as mock_perform_ocr,
patch(
"artemis.agents.explorer.explorer.is_ocr_configured",
return_value=True,
),
patch("artemis.agents.explorer.explorer.draw_dots") as mock_draw_dots,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.read_text", return_value=mock_prompt),
@@ -853,7 +857,7 @@ async def test_explorer_denylisted_tool():
mock_ctx.data_engine.base_dir = "/tmp/test_session"
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_ctx.agent_config = MagicMock()
mock_ctx.agent_config.denylisted_tools = {"explorer": ["search_xml_ocr"]}
@@ -1048,7 +1052,7 @@ async def test_explorer_final_turn_tool_stripping():
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.explorer = MagicMock()
mock_ctx.llm_config.explorer.model = "gemini-3.6-flash"
mock_ctx.llm_config.explorer.model = "gemini-3.7-flash"
mock_state = MagicMock(spec=State)
mock_state.latest_screenshot = "/tmp/test_screenshot.jpg"
@@ -1212,7 +1216,7 @@ async def test_explorer_ask_perception_tool():
detect_queries=["profile icon"],
)
assert "XML/OCR Text Search Results are:" in result["text"]
assert "Text Search Results are:" in result["text"]
assert "Coordinate Search Results are:" in result["text"]
assert "Object Detection Results are:" in result["text"]
@@ -339,7 +339,7 @@ async def test_swipe_happy_path_direction(mock_context, mock_controller):
ctx=mock_context,
)
assert "Swipe completed successfully" in outcome
mock_controller.swipe_coords.assert_called_once_with(648, 1920, 648, 480, 400)
mock_controller.swipe_coords.assert_called_once_with(648, 1680, 648, 720, 400)
@pytest.mark.asyncio
+3 -3
View File
@@ -158,7 +158,7 @@ async def test_perform_action_validation():
assert err is None
assert actions[0]["action"] == "swipe"
assert len(actions[0]["coordinates"]) == 4
assert actions[0]["duration"] == 380
assert actions[0]["duration"] == 800
# Test click with float target index (coercion)
actions, err = node._translate_and_validate_tool(
@@ -1547,7 +1547,7 @@ async def test_operator_swipe_translation():
assert err is None
assert len(actions) == 1
assert actions[0]["action"] == "swipe"
assert actions[0]["coordinates"] == [648, 1920, 648, 480]
assert actions[0]["coordinates"] == [648, 1680, 648, 720]
# 2. Start & End coordinate swipe (drag)
actions, err = node._translate_and_validate_tool(
@@ -1587,4 +1587,4 @@ async def test_operator_swipe_translation():
assert err is None
assert len(actions) == 1
assert actions[0]["action"] == "swipe"
assert actions[0]["coordinates"] == [648, 480, 648, 1920]
assert actions[0]["coordinates"] == [648, 720, 648, 1680]
@@ -0,0 +1,87 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import MagicMock
from artemis.graph.graph import check_plan_mutation_rejections
from artemis.graph.state import State
def test_check_plan_mutation_incomplete_nested():
content_before = "- [/] Main task\n - [ ] Subtask 1"
content_after = "- [x] Main task\n - [ ] Subtask 1"
res = check_plan_mutation_rejections(content_before, content_after)
assert res is not None
assert "goals in the task plan that have not been marked as completed" in res
def test_check_plan_mutation_delete_continuous_loop():
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] Monitor for new emails\n"
" - [x] Baseline established\n"
" - [x] Polling Check #1: Done"
)
res = check_plan_mutation_rejections(content_before, content_after)
assert res is not None
assert "cannot delete an active [Loop]" in res
def test_check_plan_mutation_mark_continuous_loop_completed():
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
res = check_plan_mutation_rejections(content_before, content_after)
assert res is not None
assert "cannot be unilaterally marked as completed [x]" in res
def test_check_plan_mutation_user_stopped_allowed():
content_before = (
"- [x] Open app\n"
"- [/] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
content_after = (
"- [x] Open app\n"
"- [x] [Loop] Periodically monitor for new emails "
"(Exit: Continuous monitoring until manually stopped; Interval: every 5 minutes)\n"
" - [x] Polling Check #1: Done"
)
state = MagicMock(spec=State)
state.injected_instruction = "Please stop the task now."
res = check_plan_mutation_rejections(content_before, content_after, state=state)
assert res is None
def test_check_plan_mutation_normal_bounded_task():
content_before = "- [ ] Open Settings\n- [ ] Toggle WiFi"
content_after = "- [x] Open Settings\n- [x] Toggle WiFi"
res = check_plan_mutation_rejections(content_before, content_after)
assert res is None
+3 -3
View File
@@ -28,7 +28,7 @@ async def test_video_analyzer_run():
mock_ctx.data_engine = None
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.utils.video_analyzer = MagicMock()
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.6-flash"
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash"
# Mock Gemini Client
mock_client = MagicMock()
@@ -79,7 +79,7 @@ async def test_video_analyzer_preserves_thought_signature():
mock_ctx.data_engine = None
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.utils.video_analyzer = MagicMock()
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.6-flash"
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash"
# Mock MobileDeviceController
mock_controller = MagicMock()
@@ -255,7 +255,7 @@ async def test_video_analyzer_sub_agent_confidence_validation():
mock_ctx.data_engine = None
mock_ctx.llm_config = MagicMock()
mock_ctx.llm_config.utils.video_analyzer = MagicMock()
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.6-flash"
mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash"
# Mock MobileDeviceController
mock_controller = MagicMock()
+42
View File
@@ -0,0 +1,42 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for CascadingConfigEngine and variable interpolation."""
from unittest.mock import patch
from artemis.config.core import CascadingConfigEngine, interpolate_config_value
def test_interpolate_env_variables():
"""Verify ${env:KEY} expressions are properly interpolated."""
with patch.dict("os.environ", {"TEST_KEY": "secret_123"}):
result = interpolate_config_value("Bearer ${env:TEST_KEY}")
assert result == "Bearer secret_123"
default_result = interpolate_config_value("${env:MISSING_KEY:-fallback_val}")
assert default_result == "fallback_val"
def test_interpolate_path_variables():
"""Verify ${path:KEY} expressions resolve system standard paths."""
result = interpolate_config_value("${path:temp_dir}/screenshots")
assert "/screenshots" in result or "\\screenshots" in result
def test_cascading_config_merge():
"""Verify CLI overrides take precedence in CascadingConfigEngine."""
engine = CascadingConfigEngine()
merged = engine.load_raw_config(cli_overrides={"default": {"model": "gemini-test-custom"}})
assert merged.get("default", {}).get("model") == "gemini-test-custom"
+187
View File
@@ -0,0 +1,187 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Artemis System Diagnostics & Readiness Engine."""
import pytest
from artemis.core.diagnostics.engine import ReadinessEngine
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.credentials_probe import (
LLMCredentialsProbe,
VisionOCRProbe,
)
from artemis.core.diagnostics.probes.runtime_probe import (
PythonRuntimeProbe,
SystemConfigProbe,
)
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
from artemis.core.diagnostics.schema import (
ProbeCategory,
ProbeResult,
ProbeStatus,
SystemReadinessReport,
)
@pytest.mark.asyncio
async def test_readiness_engine_run_all():
"""Verify that ReadinessEngine aggregates probes and builds structured report."""
engine = ReadinessEngine()
report: SystemReadinessReport = await engine.run_all()
assert isinstance(report, SystemReadinessReport)
assert report.blocker_count >= 3
assert isinstance(report.probes, list)
assert len(report.probes) >= 5
probe_ids = [p.id for p in report.probes]
assert "python_runtime" in probe_ids
assert "system_config" in probe_ids
assert "android_adb" in probe_ids
assert "gemini_api_key" in probe_ids
assert "vision_ocr_key" in probe_ids
assert "toolchain" in probe_ids
@pytest.mark.asyncio
async def test_python_runtime_probe_structure():
"""Verify PythonRuntimeProbe returns valid runtime inspection result."""
probe = PythonRuntimeProbe()
assert probe.probe_id == "python_runtime"
assert probe.category == ProbeCategory.RUNTIME
assert probe.is_blocker is True
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL)
assert "version" in result.metadata
assert "executable" in result.metadata
@pytest.mark.asyncio
async def test_system_config_probe_structure():
"""Verify SystemConfigProbe checks configuration file health."""
probe = SystemConfigProbe()
assert probe.probe_id == "system_config"
assert probe.category == ProbeCategory.RUNTIME
assert probe.is_blocker is True
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL)
assert "valid" in result.metadata
@pytest.mark.asyncio
async def test_vision_ocr_probe_structure():
"""Verify VisionOCRProbe returns optional non-blocker status."""
probe = VisionOCRProbe()
assert probe.probe_id == "vision_ocr_key"
assert probe.category == ProbeCategory.CREDENTIALS
assert probe.is_blocker is False
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status == ProbeStatus.PASS
assert "configured" in result.metadata
@pytest.mark.asyncio
async def test_adb_probe_structure():
"""Verify AdbDeviceProbe returns correct category, blocker status, and schema."""
probe = AdbDeviceProbe()
assert probe.probe_id == "android_adb"
assert probe.category == ProbeCategory.DEVICE
assert probe.is_blocker is True
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status in (ProbeStatus.PASS, ProbeStatus.WARN, ProbeStatus.FAIL)
assert "installed" in result.metadata
@pytest.mark.asyncio
async def test_llm_credentials_probe_structure():
"""Verify LLMCredentialsProbe returns correct category and schema."""
probe = LLMCredentialsProbe()
assert probe.probe_id == "gemini_api_key"
assert probe.category == ProbeCategory.CREDENTIALS
assert probe.is_blocker is True
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL)
assert "configured_count" in result.metadata
@pytest.mark.asyncio
async def test_toolchain_probe_structure():
"""Verify ToolchainProbe returns valid probe category, metadata, and schema."""
probe = ToolchainProbe()
assert probe.probe_id == "toolchain"
assert probe.category == ProbeCategory.TOOLCHAIN
assert probe.is_blocker is False
result: ProbeResult = await probe.probe()
assert isinstance(result, ProbeResult)
assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL)
assert "ffmpeg" in result.metadata
assert "scrcpy" in result.metadata
@pytest.mark.asyncio
async def test_active_device_selection():
"""Verify selecting a device updates engine state."""
engine = ReadinessEngine()
engine.set_active_device_serial("test-emulator-1234")
assert engine.get_active_device_serial() == "test-emulator-1234"
@pytest.mark.asyncio
async def test_credentials_probe_and_dynamic_update():
"""Verify dynamic API key updates and metadata reflection."""
from artemis.config import settings
settings.set_api_key("google", "test_gemini_key_1234567890", persist_to_env=False)
probe = LLMCredentialsProbe()
result = await probe.probe()
assert result.status == ProbeStatus.PASS
assert "current_key" in result.metadata
assert result.metadata["current_key"] == "test_gemini_key_1234567890"
assert "api_keys" in result.metadata
assert result.metadata["api_keys"]["google"] == "test_gemini_key_1234567890"
@pytest.mark.asyncio
async def test_emulator_manager_lifecycle():
"""Verify EmulatorManager status querying, validation, and dismissal."""
from artemis.core.diagnostics.emulator_manager import (
EmulatorLaunchStage,
EmulatorManager,
)
manager = EmulatorManager()
status = manager.get_status()
assert status.status == EmulatorLaunchStage.IDLE
# Invalid empty AVD name
empty_res = await manager.launch(" ")
assert empty_res.status == EmulatorLaunchStage.FAILED
assert "empty" in (empty_res.error or "").lower()
# Dismiss state
dismiss_res = manager.dismiss()
assert dismiss_res["success"] is True
assert manager.get_status().status == EmulatorLaunchStage.IDLE
+78
View File
@@ -0,0 +1,78 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Platform Abstraction Layer (PAL)."""
from pathlib import Path
from unittest.mock import patch
from artemis.platform import (
DarwinPlatform,
LinuxPlatform,
OSType,
WindowsPlatform,
get_platform,
)
def test_platform_singleton():
"""Verify global platform singleton is initialized and conforms to protocol."""
p = get_platform()
assert p is not None
assert p.os_type in (OSType.LINUX, OSType.MACOS, OSType.WINDOWS)
assert p.paths.config_dir is not None
assert p.paths.data_dir is not None
assert p.paths.temp_dir() is not None
assert p.process.path_separator in (":", ";")
def test_linux_platform_paths():
"""Verify Linux platform paths conform to XDG Base Directory specification."""
with patch.dict(
"os.environ",
{"XDG_CONFIG_HOME": "/tmp/test_xdg_config", "XDG_DATA_HOME": "/tmp/test_xdg_data"},
clear=False,
):
linux_p = LinuxPlatform()
assert linux_p.os_type == OSType.LINUX
assert linux_p.paths.config_dir == Path("/tmp/test_xdg_config/artemis")
assert linux_p.paths.data_dir == Path("/tmp/test_xdg_data/artemis")
assert linux_p.process.path_separator == ":"
def test_darwin_platform_paths():
"""Verify macOS platform paths adhere to Apple Library standards."""
darwin_p = DarwinPlatform()
assert darwin_p.os_type == OSType.MACOS
assert "Library/Application Support" in str(darwin_p.paths.config_dir)
assert darwin_p.process.path_separator == ":"
def test_windows_platform_paths():
"""Verify Windows platform paths adhere to AppData standards."""
with patch.dict(
"os.environ",
{
"APPDATA": "C:\\Users\\Test\\AppData\\Roaming",
"LOCALAPPDATA": "C:\\Users\\Test\\AppData\\Local",
},
clear=False,
):
with patch.object(Path, "mkdir", return_value=None):
win_p = WindowsPlatform()
assert win_p.os_type == OSType.WINDOWS
assert str(win_p.paths.config_dir).startswith("C:\\Users\\Test\\AppData\\Roaming")
assert str(win_p.paths.data_dir).startswith("C:\\Users\\Test\\AppData\\Local")
assert win_p.process.path_separator == ";"
assert win_p.get_install_command("adb") == "winget install Google.PlatformTools"
+48
View File
@@ -0,0 +1,48 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ToolchainResolver and binary discovery."""
from pathlib import Path
from unittest.mock import patch
from artemis.toolchain import ToolchainResolver, find_adb, find_ffmpeg, find_scrcpy
def test_toolchain_env_override():
"""Verify explicit environment variables override tool path resolution."""
resolver = ToolchainResolver()
fake_adb = Path("/tmp/fake_adb_binary")
fake_adb.touch(exist_ok=True)
with patch.dict("os.environ", {"ARTEMIS_ADB_PATH": str(fake_adb)}):
resolved = resolver.resolve("adb", force_refresh=True)
assert resolved == str(fake_adb.resolve())
def test_toolchain_helper_functions():
"""Verify toolchain discovery helper functions return strings."""
adb = find_adb()
ffmpeg = find_ffmpeg()
scrcpy = find_scrcpy()
assert isinstance(adb, str) and len(adb) > 0
assert isinstance(ffmpeg, str) and len(ffmpeg) > 0
assert isinstance(scrcpy, str) and len(scrcpy) > 0
def test_toolchain_is_installed_lookup():
"""Verify is_installed check on known and unknown tools."""
resolver = ToolchainResolver()
assert resolver.is_installed("non_existent_tool_xyz123") is False
@@ -328,7 +328,10 @@ async def test_ocr_api_persistent_http_client_singleton_and_tls_reuse():
]
}
with patch.object(client1, "post", new_callable=AsyncMock) as mock_post:
with (
patch.object(client1, "post", new_callable=AsyncMock) as mock_post,
patch.dict("os.environ", {"OCR_API_KEY": "mock_api_key"}),
):
mock_post.return_value = fake_ocr_resp
result = await ocr_api.perform_ocr(screenshot_b64="ZHVtbXlfYjY0")
@@ -434,7 +437,10 @@ async def test_validator_pre_execution_loop_reverted_to_exact_safety_contract(
new_callable=AsyncMock,
) as mock_analyze,
):
mock_get_screen.return_value = ("dummy_b64", "dummy_stem")
mock_get_screen.return_value = (
base64.b64encode(b"dummy_bytes_here").decode("utf-8"),
"dummy_stem",
)
# 1. XML check returns failure (TARGET_OCCUPIED by 'Sign Up' button)
mock_xml.return_value = (
False,
@@ -0,0 +1,63 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for backward compatibility layer with legacy 'jetski' imports."""
import importlib.util
import pytest
if not importlib.util.find_spec("jetski"):
pytest.skip("Legacy 'jetski' package not installed", allow_module_level=True)
def test_legacy_jetski_mcp_imports():
from jetski.mcp.base import mcp as jetski_mcp
from mcp_server.base import mcp as mcp_server_mcp
assert jetski_mcp is mcp_server_mcp
def test_legacy_jetski_tools_imports():
from jetski.mcp.tools.task_runner import mobile_run_task as j_run
from mcp_server.tools.task_runner import mobile_run_task as a_run
assert j_run is a_run
from jetski.mcp.tools.task_manager import mobile_manage_task as j_manage
from mcp_server.tools.task_manager import mobile_manage_task as a_manage
assert j_manage is a_manage
from jetski.mcp.tools.device_state import mobile_get_device_state as j_dev
from mcp_server.tools.device_state import mobile_get_device_state as a_dev
assert j_dev is a_dev
from jetski.mcp.tools.inspect_trace import mobile_inspect_trace as j_insp
from mcp_server.tools.inspect_trace import mobile_inspect_trace as a_insp
assert j_insp is a_insp
def test_legacy_jetski_utils_imports():
from jetski.utils.trace_store import init_trace
from mcp_server.utils.trace_store import init_trace as a_init
assert init_trace is a_init
from jetski.utils.notifier import notify, notify_jetski
from mcp_server.notifiers import notify as a_notify
assert notify is a_notify
assert callable(notify_jetski)
+113
View File
@@ -0,0 +1,113 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Artemis MCP Tools."""
import inspect
import shutil
import tempfile
import uuid
import pytest
from mcp_server.tools import (
mobile_get_device_state,
mobile_inspect_trace,
mobile_manage_task,
mobile_run_task,
)
from mcp_server.utils import trace_store
@pytest.fixture
def temp_trace_env(monkeypatch):
temp_dir = tempfile.mkdtemp()
monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir)
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_tool_signatures():
# mobile_run_task signature check
sig_run = inspect.signature(mobile_run_task)
assert "task_desc" in sig_run.parameters
assert "conversation_id" in sig_run.parameters
assert "model" in sig_run.parameters
assert "locked_app_package" in sig_run.parameters
assert "app_path" in sig_run.parameters
assert "expected_output_desc" in sig_run.parameters
# mobile_manage_task signature check
sig_manage = inspect.signature(mobile_manage_task)
assert "action" in sig_manage.parameters
assert "trace_id" in sig_manage.parameters
assert "instruction" in sig_manage.parameters
# mobile_get_device_state signature check
sig_device = inspect.signature(mobile_get_device_state)
assert "view_type" in sig_device.parameters
# mobile_inspect_trace signature check
sig_inspect = inspect.signature(mobile_inspect_trace)
assert "action" in sig_inspect.parameters
assert "trace_id" in sig_inspect.parameters
assert "step_number" in sig_inspect.parameters
def test_mobile_run_task_invalid_model():
with pytest.raises(ValueError, match="Invalid model"):
mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model")
def test_mobile_manage_task_unknown_trace(temp_trace_env):
res = mobile_manage_task(action="status", trace_id="non-existent-trace")
assert res["status"] == "unknown"
assert "not found" in res["message"]
def test_mobile_manage_task_status_and_stop(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1")
# Status check
status_res = mobile_manage_task(action="status", trace_id=trace_id)
assert status_res["trace_id"] == trace_id
assert status_res["status"] == "running"
assert status_res["model"] == "Flash"
# Stop without PID
stop_res = mobile_manage_task(action="stop", trace_id=trace_id)
assert stop_res["trace_id"] == trace_id
assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"]
def test_mobile_manage_task_inject_instruction(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1")
# Missing instruction parameter
err_res = mobile_manage_task(action="inject_instruction", trace_id=trace_id)
assert "Missing required argument" in err_res["message"]
# Valid injection
ok_res = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
)
assert "Successfully injected" in ok_res["message"]
@pytest.mark.asyncio
async def test_mobile_inspect_trace_invalid_action():
res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123")
assert "error" in res
assert "not supported" in res["message"]
+131
View File
@@ -0,0 +1,131 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for MCP Multi-Environment Notifiers."""
import json
import os
import shutil
import tempfile
import pytest
from mcp_server.notifiers import (
BaseNotifier,
CompositeNotifier,
DesktopNotifier,
FileNotifier,
WebhookNotifier,
notify,
)
from mcp_server.utils import trace_store
class DummyNotifier(BaseNotifier):
def __init__(self, available: bool = True, return_val: bool = True):
self._available = available
self._return_val = return_val
self.called_with = None
@property
def name(self) -> str:
return "dummy"
def is_available(self) -> bool:
return self._available
def notify(self, conversation_id, message, title=None, event_type="completed", payload=None):
self.called_with = {
"conversation_id": conversation_id,
"message": message,
"title": title,
"event_type": event_type,
"payload": payload,
}
return self._return_val
def test_file_notifier():
temp_dir = tempfile.mkdtemp()
try:
trace_id = "test-file-trace-123"
trace_dir = os.path.join(temp_dir, trace_id)
os.makedirs(trace_dir, exist_ok=True)
notifier = FileNotifier()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(trace_store, "TRACES_DIR", temp_dir)
success = notifier.notify(
conversation_id="conv-1",
message="Task completed",
title="Done",
event_type="completed",
payload={"trace_id": trace_id},
)
assert success is True
log_file = os.path.join(trace_dir, "notifications.jsonl")
assert os.path.exists(log_file)
with open(log_file, encoding="utf-8") as f:
lines = f.readlines()
assert len(lines) == 1
entry = json.loads(lines[0])
assert entry["event_type"] == "completed"
assert entry["message"] == "Task completed"
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_webhook_notifier_not_configured(monkeypatch):
for var in WebhookNotifier.ENV_VARS:
monkeypatch.delenv(var, raising=False)
notifier = WebhookNotifier()
assert notifier.is_available() is False
assert notifier.notify("conv-1", "hello") is False
def test_desktop_notifier_not_enabled(monkeypatch):
monkeypatch.delenv("ARTEMIS_DESKTOP_NOTIFY", raising=False)
notifier = DesktopNotifier()
assert notifier.is_available() is False
def test_composite_notifier_dispatch():
d1 = DummyNotifier(available=True, return_val=True)
d2 = DummyNotifier(available=False, return_val=False)
d3 = DummyNotifier(available=True, return_val=True)
composite = CompositeNotifier(notifiers=[d1, d2, d3])
assert composite.is_available() is True
result = composite.notify(
conversation_id="conv-abc",
message="Hello World",
title="Test Title",
event_type="completed",
payload={"key": "val"},
)
assert result is True
assert d1.called_with is not None
assert d1.called_with["conversation_id"] == "conv-abc"
assert d2.called_with is None
assert d3.called_with is not None
def test_global_notify_function():
res = notify(
conversation_id="conv-xyz",
message="Test message",
payload={"trace_id": "test-trace"},
)
assert isinstance(res, bool)
+77
View File
@@ -0,0 +1,77 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for MCP trace store."""
import shutil
import tempfile
import uuid
import pytest
from mcp_server.utils import trace_store
@pytest.fixture
def temp_trace_env(monkeypatch):
temp_dir = tempfile.mkdtemp()
monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir)
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_init_and_read_status(temp_trace_env):
trace_id = str(uuid.uuid4())
task_desc = "Test open settings"
model = "Flash"
conv_id = "test-conv-123"
init_res = trace_store.init_trace(trace_id, task_desc, model, conv_id)
assert init_res["trace_id"] == trace_id
assert init_res["status"] == "running"
assert init_res["model"] == "Flash"
status_data = trace_store.read_status(trace_id)
assert status_data is not None
assert status_data["trace_id"] == trace_id
assert status_data["task_desc"] == task_desc
assert status_data["conversation_id"] == conv_id
def test_update_trace_status(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-456")
updated = trace_store.update_trace_status(
trace_id=trace_id,
status="completed",
result={"success": True},
)
assert updated is not None
assert updated["status"] == "completed"
assert updated["result"] == {"success": True}
assert updated["end_time"] is not None
failed = trace_store.update_trace_status(
trace_id=trace_id,
status="failed",
error="App crashed",
)
assert failed is not None
assert failed["status"] == "failed"
assert failed["error"] == "App crashed"
def test_read_nonexistent_status(temp_trace_env):
non_existent = str(uuid.uuid4())
assert trace_store.read_status(non_existent) is None
+16 -1
View File
@@ -27,7 +27,6 @@ def test_cli_help():
assert "Artemis: Autonomous Multimodal Android Agent" in result.output
assert "run" in result.output
assert "batch" in result.output
assert "bench" in result.output
assert "server" in result.output
assert "trace" in result.output
assert "mcp" in result.output
@@ -63,3 +62,19 @@ def test_cli_trace_help():
assert result.exit_code == 0
assert "list" in result.output
assert "view" in result.output
def test_cli_mcp_help():
"""Verify 'artemis mcp --help' lists server options."""
result = runner.invoke(app, ["mcp", "--help"])
assert result.exit_code == 0
assert "--type" in result.output
assert "--generate-config" in result.output
def test_cli_mcp_generate_config():
"""Verify 'artemis mcp --generate-config cursor' produces valid configuration."""
result = runner.invoke(app, ["mcp", "--generate-config", "cursor"])
assert result.exit_code == 0
assert "mcpServers" in result.output
assert "mcp_server" in result.output
+41
View File
@@ -107,6 +107,47 @@ def test_settings_and_api_key_fallbacks(monkeypatch):
assert custom_settings.get_api_key("xai").get_secret_value() == "xai_test_key"
def test_placeholder_api_key_filtering(monkeypatch):
"""Test that default template placeholders are filtered out and treated as unconfigured."""
from artemis.config.settings import is_placeholder_key
assert is_placeholder_key("your_gemini_api_key_here") is True
assert is_placeholder_key("your_google_cloud_vision_api_key_here") is True
assert is_placeholder_key("your_openai_api_key_here") is True
assert is_placeholder_key("your_custom_key_here") is True
assert is_placeholder_key("<your-api-key>") is True
assert is_placeholder_key("[api_key]") is True
assert is_placeholder_key("API_KEY") is True
assert is_placeholder_key("") is True
assert is_placeholder_key(None) is True
assert is_placeholder_key("AIzaSyValidGeminiKey12345") is False
for env_k in (
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GCP_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPEN_ROUTER_API_KEY",
"XAI_API_KEY",
"OCR_API_KEY",
"VISION_API_KEY",
"API_KEY",
):
monkeypatch.delenv(env_k, raising=False)
placeholder_settings = Settings(
_env_file=None,
GEMINI_API_KEY=SecretStr("your_gemini_api_key_here"),
OPENAI_API_KEY=SecretStr("your_openai_api_key_here"),
OCR_API_KEY=SecretStr("your_google_cloud_vision_api_key_here"),
)
assert placeholder_settings.get_api_key("google") is None
assert placeholder_settings.get_api_key("gemini") is None
assert placeholder_settings.get_api_key("openai") is None
assert placeholder_settings.get_api_key("ocr") is None
def test_llm_config_parsing_and_merging():
"""Test LLMConfig parsing, agent querying, and deep merging."""
llm_cfg = get_default_llm_config()
-1
View File
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import pytest
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from artemis.utils.credentials_validator import validate_api_key, _extract_error_message
def test_extract_error_message():
resp_google = MagicMock()
resp_google.json.return_value = {"error": {"message": "API key invalid."}}
assert _extract_error_message(resp_google) == "API key invalid."
resp_openai = MagicMock()
resp_openai.json.return_value = {"message": "Incorrect API key."}
assert _extract_error_message(resp_openai) == "Incorrect API key."
resp_fallback = MagicMock()
resp_fallback.json.side_effect = Exception("JSON parse error")
resp_fallback.text = "Raw error text"
assert _extract_error_message(resp_fallback) == "Raw error text"
@pytest.mark.asyncio
async def test_validate_empty_and_placeholder():
is_valid, msg = await validate_api_key("google", "")
assert not is_valid
assert "empty" in msg.lower()
is_valid, msg = await validate_api_key("google", "API_KEY")
assert not is_valid
assert "placeholder" in msg.lower()
@pytest.mark.asyncio
async def test_validate_gemini_success():
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_get.return_value = mock_resp
is_valid, msg = await validate_api_key("google", "valid_key_12345")
assert is_valid
assert "verified successfully" in msg
@pytest.mark.asyncio
async def test_validate_gemini_failure():
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
mock_resp = MagicMock()
mock_resp.status_code = 400
mock_resp.json.return_value = {"error": {"message": "API key not valid."}}
mock_get.return_value = mock_resp
is_valid, msg = await validate_api_key("google", "bad_key_12345")
assert not is_valid
assert "API key not valid" in msg
@pytest.mark.asyncio
async def test_validate_ocr_success():
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_post.return_value = mock_resp
is_valid, msg = await validate_api_key("ocr", "valid_ocr_key_12345")
assert is_valid
assert "verified successfully" in msg
+14 -2
View File
@@ -17,10 +17,22 @@ from google.genai import types
from google.genai.errors import APIError
import pytest
pytestmark = pytest.mark.skip(
reason="Exploratory live API verification test for Gemini SDK role support"
)
@pytest.fixture
def client():
"""Initializes the Gemini client. Requires GEMINI_API_KEY in environment."""
import os
from artemis.config.settings import is_placeholder_key
key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if not key or is_placeholder_key(key):
pytest.skip(
"Valid GEMINI_API_KEY / GOOGLE_API_KEY not configured for live Gemini role tests"
)
try:
return genai.Client()
except Exception as e:
@@ -38,7 +50,7 @@ def test_gemini_role_tool_fails(client):
with pytest.raises(APIError) as exc_info:
client.models.generate_content(
model="gemini-3.6-flash",
model="gemini-3.7-flash",
contents=contents,
)
@@ -60,7 +72,7 @@ def test_gemini_role_user_succeeds(client):
# This should not raise any exceptions
response = client.models.generate_content(
model="gemini-3.6-flash",
model="gemini-3.7-flash",
contents=contents,
)
+30 -12
View File
@@ -13,10 +13,12 @@
# limitations under the License.
import asyncio
from pathlib import Path
from artemis.agents.planner.planner import run_async_planner_validation
from pathlib import Path
class DummyDataEngine:
def get_agent_friendly_steps(self):
return []
@@ -24,23 +26,39 @@ class DummyDataEngine:
class DummyCtx:
data_engine = DummyDataEngine()
project_dir = str(Path(__file__).resolve().parent.parent.parent)
project_dir = str(Path(__file__).resolve().parents[2])
async def test():
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.agents.planner.planner import ValidationResult
@pytest.mark.asyncio
async def test_planner_validation():
content_before = "- [/] Click the button\n- [ ] Complete task"
content_after = (
"- [/] Click the button\n- [ ] Complete task\n- [ ] New subgoal appended at bottom"
)
res = await run_async_planner_validation(
ctx=DummyCtx(),
initial_goal="Do the task",
content_before=content_before,
content_after=content_after,
operator_raw_thinking="I need to add a new subgoal",
operator_native_thinking="Thinking...",
mock_llm = MagicMock()
mock_structured = MagicMock()
mock_structured.ainvoke = AsyncMock(
return_value=ValidationResult(is_approved=True, feedback="Valid plan modification")
)
print(res)
mock_llm.with_structured_output.return_value = mock_structured
with patch("artemis.agents.planner.planner.get_llm", return_value=mock_llm):
res = await run_async_planner_validation(
ctx=DummyCtx(),
initial_goal="Do the task",
content_before=content_before,
content_after=content_after,
operator_raw_thinking="I need to add a new subgoal",
operator_native_thinking="Thinking...",
)
assert res is not None
assert res.get("status") == "success"
asyncio.run(test())
if __name__ == "__main__":
asyncio.run(test_planner_validation())
+72 -1
View File
@@ -112,7 +112,7 @@ async def test_swipe_direct_execution_with_ctx_direction(mock_ctx):
mock_controller_cls.return_value = mock_controller_inst
result = await swipe.execute(ctx=mock_ctx, action="down", duration=400)
mock_controller_inst.swipe_coords.assert_called_once_with(540, 480, 540, 1920, 400)
mock_controller_inst.swipe_coords.assert_called_once_with(648, 720, 648, 1680, 400)
assert "Swipe completed successfully." in result
@@ -253,3 +253,74 @@ def test_parse_swipe_parameters_direct():
k, t, d = parse_swipe_parameters({"action": "up"})
assert k == "direction"
assert t == "up"
def test_compute_smart_swipe_coordinates_fallback():
"""Verify fallback smart swipe coordinates on full screen."""
from artemis.utils.coordinates import compute_smart_swipe_coordinates
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates("up", width=1080, height=2400)
assert sx == 648
assert ex == 648
assert sy == 1680
assert ey == 720
assert 700 <= dur <= 900
def test_compute_smart_swipe_coordinates_with_target_element():
"""Verify container-aware coordinates when target element index is specified."""
from artemis.utils.coordinates import compute_smart_swipe_coordinates
indexed_elements = [
{"bounds": "[0, 200][1080, 1200]"}, # Element 1
{"bounds": "[100, 500][900, 1500]"}, # Element 2 (width=800, height=1000)
]
# Swipe up within element 2
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates(
direction="up",
target=2,
indexed_elements=indexed_elements,
width=1080,
height=2400,
)
# c_left=100, c_top=500, c_width=800, c_height=1000
# sx = ex = 100 + 800 * 0.6 = 580
# sy = 500 + 1000 * 0.7 = 1200
# ey = 500 + 1000 * 0.3 = 800
assert sx == 580
assert ex == 580
assert sy == 1200
assert ey == 800
def test_compute_smart_swipe_coordinates_with_scrollable_hierarchy():
"""Verify auto-detection of scrollable container in UI hierarchy."""
from artemis.utils.coordinates import compute_smart_swipe_coordinates
ui_hierarchy = [
{
"class": "android.widget.FrameLayout",
"bounds": "[0, 0][1080, 2400]",
"children": [
{
"class": "androidx.recyclerview.widget.RecyclerView",
"scrollable": True,
"bounds": "[50, 300][1030, 2100]", # width=980, height=1800
}
],
}
]
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates(
direction="down",
ui_hierarchy=ui_hierarchy,
width=1080,
height=2400,
)
# c_left=50, c_top=300, c_width=980, c_height=1800
# sx = ex = 50 + 980 * 0.6 = 638
# sy = 300 + 1800 * 0.3 = 840
# ey = 300 + 1800 * 0.7 = 1560
assert sx == 638
assert ex == 638
assert sy == 840
assert ey == 1560
@@ -0,0 +1,105 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for sequence coordinate normalization and overlay visualization."""
import io
from PIL import Image
from artemis.utils.coordinates import normalize_step_actions
from artemis.utils.visualization import draw_action_overlay_on_image
def test_normalize_step_actions_click_sequence_does_not_inherit_single_action():
"""Verify that click_sequence does NOT inherit single coordinates from action_taken."""
step_dict = {
"step_id": "step-123",
"action_taken": [
{
"action": "tap",
"coordinates": [540, 720],
"normalized_coordinates": [500, 300],
}
],
"generic_tools": [
{
"name": "click_sequence",
"payload": {
"args": {
"sequence": "[[500, 300], [876, 360]]",
"delay_ms": "50",
},
"result": {
"status": "success",
"outcome": "Sequence clicked successfully: Tapped at [540, 720]; Tapped at [946, 864]",
},
},
}
],
"extra_metadata": {"width": 1080, "height": 2400},
}
normalized = normalize_step_actions(step_dict)
tool = normalized["generic_tools"][0]
args = tool["payload"]["args"]
# Coordinates from first_act must NOT be injected into click_sequence
assert "coordinates" not in args or args.get("coordinates") is None
assert "normalized_coordinates" not in args or args.get("normalized_coordinates") is None
# Sequence must be parsed into a list and normalized
assert args["sequence"] == [[500, 300], [876, 360]]
assert args["normalized_sequence"] == [[500, 300], [876, 360]]
def test_normalize_step_actions_physical_sequence():
"""Verify that physical coordinates in sequence are converted into normalized_sequence."""
step_dict = {
"step_id": "step-456",
"action_taken": [],
"generic_tools": [
{
"name": "click_sequence",
"payload": {
"args": {
"sequence": [[540, 1200], [1080, 2400]],
}
},
}
],
"extra_metadata": {"width": 1080, "height": 2400},
}
normalized = normalize_step_actions(step_dict)
args = normalized["generic_tools"][0]["payload"]["args"]
assert args["normalized_sequence"] == [[500, 500], [1000, 1000]]
def test_draw_action_overlay_click_sequence():
"""Verify PIL overlay drawing for click_sequence with stringified sequence."""
img = Image.new("RGB", (1080, 2400), color="blue")
buf = io.BytesIO()
img.save(buf, format="JPEG")
raw_bytes = buf.getvalue()
action_args = {
"sequence": "[[500, 300], [876, 360]]",
}
annotated_bytes = draw_action_overlay_on_image(raw_bytes, "click_sequence", action_args)
assert annotated_bytes is not None
assert len(annotated_bytes) > 0
res_img = Image.open(io.BytesIO(annotated_bytes))
assert res_img.size == (1080, 2400)
+198
View File
@@ -0,0 +1,198 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests verifying that OCR is optional and the system degrades gracefully."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.explorer.explorer import Explorer
from artemis.config.settings import Settings
from artemis.graph.perception import perception_node
from artemis.graph.state import State
from artemis.tools.base import ToolRegistry
from artemis.tools.index import get_tools_from_wrappers
from artemis.tools.mobile.ocr import (
ocr_recognition,
ocr_recognition_wrapper,
)
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.utils.visualization import format_minimal_list_with_elements
@pytest.mark.asyncio
async def test_is_ocr_configured_false_when_unset(monkeypatch):
"""Verify is_ocr_configured returns False when keys are unset or placeholder."""
monkeypatch.delenv("OCR_API_KEY", raising=False)
monkeypatch.delenv("VISION_API_KEY", raising=False)
monkeypatch.delenv("API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
with patch.object(Settings, "get_api_key", return_value=None):
assert not is_ocr_configured()
@pytest.mark.asyncio
async def test_perform_ocr_returns_empty_list_when_unconfigured(monkeypatch):
"""Verify perform_ocr returns [] when no OCR key is present."""
with (
patch.object(Settings, "get_api_key", return_value=None),
patch.dict("os.environ", {}, clear=True),
):
result = await perform_ocr(screenshot_b64="dummy_b64")
assert result == []
@pytest.mark.asyncio
async def test_perform_ocr_executes_api_call_when_configured():
"""Verify perform_ocr makes API request and parses responses when configured."""
mock_key = MagicMock()
mock_key.get_secret_value.return_value = "test_key"
with (
patch.object(Settings, "get_api_key", return_value=mock_key),
patch("artemis.utils.ocr_api.get_http_client") as mock_client,
):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
"responses": [
{
"textAnnotations": [
{"description": "Full text\nLogin", "boundingPoly": {}},
{
"description": "Login",
"boundingPoly": {
"vertices": [{"x": 100, "y": 200}, {"x": 300, "y": 250}]
},
},
]
}
]
}
mock_http = AsyncMock()
mock_http.post.return_value = mock_resp
mock_client.return_value = mock_http
result = await perform_ocr(screenshot_b64="dummy_b64")
assert len(result) == 1
assert result[0]["text"] == "Login"
def test_fuse_ocr_with_xml_empty_ocr_retains_xml_pruning():
"""Verify fuse_ocr_with_xml preserves full XML structure and allows downstream pruning."""
sample_xml = [
{
"class": "android.widget.TextView",
"text": "Submit Button",
"bounds": "[100,200][300,250]",
},
{
"class": "android.widget.ImageView",
"content-desc": "Settings Icon",
"bounds": "[900,50][980,130]",
},
]
# Fusing with empty OCR results
fused_xml = fuse_ocr_with_xml(sample_xml, [])
assert len(fused_xml) == 2
assert fused_xml[0]["text"] == "Submit Button"
# Downstream XML formatting / pruning
minimal_list, elements, labels = format_minimal_list_with_elements(
fused_xml, width=1080, height=2400
)
assert len(elements) == 2
assert "Submit Button" in minimal_list
assert "Settings Icon" in minimal_list
assert labels == ["1", "2"]
def test_ocr_tool_not_exposed_when_unconfigured():
"""Verify ocr_recognition tool is hidden and not exposed when OCR is unconfigured."""
with patch("artemis.tools.mobile.ocr.is_ocr_configured", return_value=False):
assert not ocr_recognition.is_available()
# ToolRegistry.list_tools filters out unavailable tools
available_tools = ToolRegistry.list_tools(available_only=True)
assert "ocr_recognition" not in [t.name for t in available_tools]
# ToolRegistry.get_langchain_tools filters out unavailable tools
mock_ctx = MagicMock()
lc_tools = ToolRegistry.get_langchain_tools(mock_ctx, available_only=True)
assert "ocr_recognition" not in [t.name for t in lc_tools]
# ToolRegistry.get_genai_declarations filters out unavailable tools
genai_tools = ToolRegistry.get_genai_declarations(available_only=True)
assert "ocr_recognition" not in [t.name for t in genai_tools]
# get_tools_from_wrappers excludes ocr_recognition_wrapper
wrapped_tools = get_tools_from_wrappers(mock_ctx, [ocr_recognition_wrapper])
assert len(wrapped_tools) == 0
def test_explorer_does_not_expose_get_ocr_list_when_unconfigured():
"""Verify Explorer does not expose get_ocr_list tool declaration when OCR is unconfigured."""
with patch("artemis.agents.explorer.explorer.is_ocr_configured", return_value=False):
mock_ctx = MagicMock()
mock_ctx.agent_config = None
explorer = Explorer(mock_ctx)
explorer.denylisted_tools = set()
exposed = explorer.get_exposed_tools()
exposed_names = [t.name for t in exposed]
assert "get_ocr_list" not in exposed_names
@pytest.mark.asyncio
async def test_perception_node_runs_smoothly_without_ocr():
"""Verify perception_node runs from start to finish with pure XML when OCR is unconfigured."""
with (
patch("artemis.graph.perception.is_ocr_configured", return_value=False),
patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls,
):
mock_controller = MagicMock()
mock_device_data = MagicMock()
mock_device_data.width = 1080
mock_device_data.height = 2400
mock_device_data.base64 = "ZHVtbXk="
mock_device_data.bytes = b"dummy"
mock_device_data.elements = [
{
"class": "android.widget.Button",
"text": "Login",
"bounds": "[100,500][400,600]",
}
]
mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data)
mock_controller_cls.return_value = mock_controller
mock_ctx = MagicMock()
mock_ctx.data_engine = MagicMock()
mock_ctx.data_engine.current_step_id = "step_1"
mock_ctx.device = MagicMock()
mock_ctx.device.device_width = 1080
mock_ctx.device.device_height = 2400
mock_state = MagicMock(spec=State)
mock_state.structured_decisions = []
update = await perception_node(mock_state, mock_ctx)
assert update is not None
assert "latest_ui_hierarchy" in update
assert len(update["latest_ui_hierarchy"]) == 1
assert update["latest_ui_hierarchy"][0]["text"] == "Login"
assert update["operator_raw_data"]["ocr_results"] == []