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:
1 parent
78e478f502
commit
9a5aaf99b7
249 files changed
+37226
-9339
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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"] == []
|
||||
Reference in new issue
Block a user