diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3c2118 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +# 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. + +"""Global pytest fixtures for ARTEMIS test suite.""" + +import pytest +from artemis.core.context import ExecutionContext +from artemis.drivers.mock.mock_driver import MockDeviceDriver + + +@pytest.fixture +def mock_driver(): + """Provides an isolated MockDeviceDriver instance.""" + return MockDeviceDriver(device_id="fixture-mock-device", width=1080, height=2400) + + +@pytest.fixture +def test_context(): + """Provides a fresh ExecutionContext.""" + return ExecutionContext(task_goal="Test sample task objective", device_id="fixture-mock-device") diff --git a/tests/e2e/play_youtube.py b/tests/e2e/play_youtube.py new file mode 100644 index 0000000..266f897 --- /dev/null +++ b/tests/e2e/play_youtube.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +# 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 re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + + +def run_cmd(cmd): + """Runs a local shell command and returns stdout, stderr.""" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result.stdout.strip(), result.stderr.strip() + + +def adb_cmd(cmd): + """Runs an adb command.""" + return run_cmd(f"adb {cmd}") + + +def dump_ui(): + """Dumps the UI hierarchy and returns it as an XML string.""" + # Try to dump to a specified path + out, err = adb_cmd("shell uiautomator dump /data/local/tmp/uidump.xml") + path = "/data/local/tmp/uidump.xml" + + if "dumped to" not in out and "dumped to" not in err: + # Try default dump + out, err = adb_cmd("shell uiautomator dump") + match = re.search(r"dumped to: (.*)", out + "\n" + err) + if match: + path = match.group(1).strip() + else: + # Fallback common path + path = "/sdcard/window_dump.xml" + + local_path = "temp_uidump.xml" + # Ensure we remove local temp file if it exists + if os.path.exists(local_path): + os.remove(local_path) + + out, err = adb_cmd(f"pull {path} {local_path}") + + if os.path.exists(local_path): + with open(local_path, encoding="utf-8") as f: + content = f.read() + os.remove(local_path) + return content + else: + print(f"Failed to pull UI dump from {path}. Out: {out}, Err: {err}") + return None + + +def parse_bounds(bounds_str): + """Parses bounds string '[left,top][right,bottom]' into [left, top, right, bottom].""" + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str) + if m: + return [int(x) for x in m.groups()] + return None + + +def get_center(bounds): + """Returns the center (x, y) of the bounds.""" + return (bounds[0] + bounds[2]) // 2, (bounds[1] + bounds[3]) // 2 + + +def find_nodes(xml_str, filter_fn): + """Finds all nodes in the XML that match the filter function.""" + try: + root = ET.fromstring(xml_str) + except Exception as e: + print(f"Failed to parse XML: {e}") + return [] + + matched = [] + for node in root.iter("node"): + attrib = node.attrib + if filter_fn(attrib): + matched.append(attrib) + return matched + + +def tap_element(element, description="element"): + bounds_str = element.get("bounds") + if bounds_str: + bounds = parse_bounds(bounds_str) + if bounds: + x, y = get_center(bounds) + print(f"Tapping {description} at [{x}, {y}]") + adb_cmd(f"shell input tap {x} {y}") + return True + return False + + +def bypass_chrome_welcome(timeout=15): + """Bypasses Chrome welcome/sync screens if they appear.""" + print("Checking for Chrome welcome/sync screens...") + start_time = time.time() + while time.time() - start_time < timeout: + xml = dump_ui() + if not xml: + time.sleep(1) + continue + + # Check for "Accept & continue" + accept_nodes = find_nodes( + xml, + lambda a: ( + a.get("text") == "Accept & continue" or "terms_accept" in a.get("resource-id", "") + ), + ) + if accept_nodes: + print("Found 'Accept & continue' screen.") + tap_element(accept_nodes[0], "'Accept & continue' button") + time.sleep(2) + continue + + # Check for "No thanks" or "Don't turn on" (Sync promo) + no_thanks_nodes = find_nodes( + xml, + lambda a: ( + a.get("text") in ["No thanks", "Don't turn on"] + or "negative_button" in a.get("resource-id", "") + ), + ) + if no_thanks_nodes: + print("Found Sync/Promo screen.") + tap_element(no_thanks_nodes[0], "'No thanks' button") + time.sleep(2) + continue + + # Check if we are already on a webpage or home page (e.g., search bar is visible, or youtube content) + # If we don't see welcome screens anymore, we might be good. + # Let's check if we see "Search or type URL" or similar Chrome home elements, or YouTube. + youtube_nodes = find_nodes( + xml, + lambda a: ( + "youtube" in a.get("text", "").lower() + or "youtube" in a.get("content-desc", "").lower() + ), + ) + chrome_home = find_nodes( + xml, + lambda a: "search or type web address" in a.get("text", "").lower(), + ) + + if youtube_nodes or chrome_home: + print("Chrome seems to be in main browser view.") + break + + print("Waiting for welcome screens to resolve or main view to appear...") + time.sleep(1) + + +def play_first_video(timeout=20): + """Finds and clicks the first video on the YouTube page.""" + print("Waiting for YouTube to load and finding a video...") + start_time = time.time() + while time.time() - start_time < timeout: + xml = dump_ui() + if not xml: + time.sleep(1) + continue + + # Look for video elements. + # Heuristic: clickable elements with content-desc containing "views" and "ago" + # or elements that look like video titles. + video_nodes = [] + + # Try finding by content-desc (common for video cards) + video_nodes = find_nodes( + xml, + lambda a: a.get("clickable") == "true" and "views" in a.get("content-desc", "").lower(), + ) + + # If not found, try finding any node with "views" and "ago" in text/desc and click it + if not video_nodes: + video_nodes = find_nodes( + xml, + lambda a: ( + ( + "views" in a.get("content-desc", "").lower() + and "ago" in a.get("content-desc", "").lower() + ) + or ("views" in a.get("text", "").lower() and "ago" in a.get("text", "").lower()) + ), + ) + + # If still not found, maybe we are on a cookie consent page? + # Check for "Before you continue to YouTube" or "Reject all" / "Accept all" + consent_nodes = find_nodes( + xml, + lambda a: a.get("text") in ["Reject all", "Accept all", "I agree"], + ) + if consent_nodes: + print("Found cookie consent banner. Clicking 'Accept all' or 'I agree'...") + # Prefer Accept/Agree + agree_node = [n for n in consent_nodes if n.get("text") in ["Accept all", "I agree"]] + node_to_click = agree_node[0] if agree_node else consent_nodes[0] + tap_element(node_to_click, "Consent button") + time.sleep(3) + continue + + if video_nodes: + print(f"Found {len(video_nodes)} potential video elements.") + # Click the first one + first_video = video_nodes[0] + desc = first_video.get("content-desc", first_video.get("text", "video")) + print(f"Selecting video: {desc[:50]}...") + if tap_element(first_video, "first video"): + return True + + print("Still waiting for videos to load...") + time.sleep(2) + + print("Failed to find a video to play.") + return False + + +def verify_playback(timeout=15): + """Verifies that the video is playing.""" + print("Verifying playback...") + start_time = time.time() + while time.time() - start_time < timeout: + xml = dump_ui() + if not xml: + time.sleep(1) + continue + + # Check for video player element + player_nodes = find_nodes( + xml, + lambda a: ( + "video player" in a.get("text", "").lower() + or "video player" in a.get("content-desc", "").lower() + ), + ) + if player_nodes: + print("Video player detected on screen.") + + # Check if it's paused or if we need to click play + # Sometimes there is a "Play video" button if it didn't auto-play + play_button = find_nodes( + xml, + lambda a: a.get("text") == "Play" or a.get("content-desc") == "Play video", + ) + if play_button: + print("Video is paused. Clicking play...") + tap_element(play_button[0], "Play button") + time.sleep(2) + else: + print("Video seems to be playing (no play button blocking it, player active).") + + # Check for "TAP TO UNMUTE" + unmute_button = find_nodes(xml, lambda a: "unmute" in a.get("text", "").lower()) + if unmute_button: + print("Optional: Clicking unmute...") + tap_element(unmute_button[0], "Unmute button") + + return True + + print("Waiting for video player to appear...") + time.sleep(2) + return False + + +def main(): + # Ensure a device is connected + out, err = run_cmd("adb devices") + devices = [ + line.split()[0] + for line in out.splitlines()[1:] + if line.strip() and not line.startswith("*") + ] + if not devices: + print("No ADB devices found. Please connect a device and try again.") + sys.exit(1) + print(f"Using device: {devices[0]}") + + # Optional: Clear Chrome to test clean run (highly recommended for testing the script itself) + # To enable, uncomment the line below: + # print("Clearing Chrome data for a clean test...") + # adb_cmd("shell pm clear com.android.chrome") + # time.sleep(2) + + # Start Chrome and navigate to YouTube + url = "https://m.youtube.com" + print(f"Starting Chrome and navigating to {url}...") + # -d specifies the data URI + # -n specifies the component + adb_cmd(f"shell am start -n com.android.chrome/com.google.android.apps.chrome.Main -d {url}") + + # Wait for app to launch + time.sleep(3) + + # Step 1: Bypass welcome screens + bypass_chrome_welcome() + + # Step 2: Play the first video + if play_first_video(): + # Step 3: Verify it plays + time.sleep(3) # Wait for transition + if verify_playback(): + print("SUCCESS: Chrome opened, navigated to YouTube, and video playback started!") + else: + print("FAILURE: Video player not detected or failed to play.") + else: + print("FAILURE: Could not find or click a video.") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/test_failure_analyzer_e2e_youtube.py b/tests/e2e/test_failure_analyzer_e2e_youtube.py new file mode 100644 index 0000000..203ef60 --- /dev/null +++ b/tests/e2e/test_failure_analyzer_e2e_youtube.py @@ -0,0 +1,124 @@ +# 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 +import logging +import os +import sys +from unittest.mock import patch +from dotenv import load_dotenv + +# Ensure we can import from project +sys.path.append(os.path.dirname(__file__)) + +from artemis.sdk import Agent +from artemis.sdk.builders import Builders +from artemis.sdk.types import AgentProfile +from artemis.agents.validator.validator import ValidatorNode, ValidationErrorCategory + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("test_fa_e2e") + +# We want to fail the first validation to trigger the Failure Analyzer +original_validate = ValidatorNode._validate_action_precondition +original_validate_pixel = ValidatorNode._validate_action_precondition_pixel + +has_failed_once = False + + +async def mock_validate_action_precondition(self, session, action_item, state=None): + global has_failed_once + if not has_failed_once: + has_failed_once = True + logger.warning(">>> MOCK VALIDATOR: Simulating pre-execution XML validation failure!") + return ( + False, + ValidationErrorCategory.TARGET_DISAPPEARED, + "Simulated pre-execution XML validation failure", + ) + return await original_validate(self, session, action_item, state=state) + + +async def mock_validate_action_precondition_pixel( + self, + session, + action_item, + pre_screenshot_b64, + original_coords=None, + state=None, +): + global has_failed_once + if not has_failed_once: + has_failed_once = True + logger.warning(">>> MOCK VALIDATOR: Simulating pre-execution Pixel validation failure!") + return ( + False, + ValidationErrorCategory.TARGET_DISAPPEARED, + "Simulated pre-execution Pixel validation failure", + ) + return await original_validate_pixel( + self, + session, + action_item, + pre_screenshot_b64, + original_coords, + state=state, + ) + + +async def main(): + load_dotenv() + + # Check if emulator/device is connected + import subprocess + + res = subprocess.run(["adb", "devices"], capture_output=True, text=True) + print("ADB Devices:") + print(res.stdout) + + # 1. Initialize agent + logger.info("Initializing Agent...") + profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") + config = Builders.AgentConfig.with_default_profile(profile).build() + + agent = Agent(config=config) + await agent.init() + + # 2. Run the task with patches + goal = "Play a video in full screen on YouTube in Chrome" + logger.info(f"Running task with goal: {goal}") + + with ( + patch.object( + ValidatorNode, + "_validate_action_precondition", + mock_validate_action_precondition, + ), + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + mock_validate_action_precondition_pixel, + ), + ): + try: + result = await agent.run_task(goal=goal, name="test_fa_youtube_fullscreen") + logger.info(f"Task finished! Result: {result}") + except Exception: + logger.exception("Error during execution:") + finally: + await agent.clean() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py b/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py new file mode 100644 index 0000000..d74e678 --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py @@ -0,0 +1,68 @@ +# 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. + +"""Helper utilities for Explorer integration tests.""" + +from pathlib import Path +from PIL import Image +from unittest.mock import MagicMock +from artemis.context import ArtemisContext +from artemis.graph.state import State + + +def create_mock_context() -> MagicMock: + """Creates a mock ArtemisContext suitable for Explorer tests.""" + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + ctx.llm_config = MagicMock() + ctx.agent_config = MagicMock() + ctx.agent_config.blacklisted_tools = {"explorer": []} + ctx.data_engine = None + ctx.adb_client = None + return ctx + + +def create_mock_state() -> MagicMock: + """Creates a mock State suitable for Explorer tests.""" + state = MagicMock(spec=State) + state.operator_raw_data = {"width": 1080, "height": 2400} + state.latest_ui_hierarchy = [] + state.latest_screenshot = None + return state + + +def get_or_create_test_screenshot(preferred_path: Path | None = None) -> Path: + """Finds an existing test screenshot or generates a valid 1080x2400 mock JPEG.""" + if preferred_path and preferred_path.exists() and preferred_path.stat().st_size > 0: + return preferred_path + + # Search known repo screenshot paths + candidate_paths = [ + Path(__file__).parent / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg", + Path(__file__).parent / "input_screenshot_test_explorer_ask_vision_coder_tool.jpg", + Path(__file__).parents[3] / "tools" / "inputs" / "screenshot.jpg", + ] + for p in candidate_paths: + if p.exists() and p.stat().st_size > 0: + return p + + # If none found, generate a mock RGB image + out_path = ( + Path(__file__).parent / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" + ) + img = Image.new("RGB", (1080, 2400), color=(73, 109, 137)) + img.save(out_path, format="JPEG") + return out_path diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg new file mode 100644 index 0000000..9b41aff Binary files /dev/null and b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg differ diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg new file mode 100644 index 0000000..9b41aff Binary files /dev/null and b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg differ diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py b/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py new file mode 100644 index 0000000..52aa515 --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py @@ -0,0 +1,280 @@ +# 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 +import json +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.agents.explorer.explorer import Explorer +from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( + create_mock_context, + create_mock_state, +) +from tests.integration.agents.explorer.test_explorer_all_tools.seed_mock_image import ( + seed_database, +) + +# Set up logging to stdout +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("live_tools_test") + + +def extract_and_print_tool_results(contents, set_idx, logger): + logger.info("\n======================================================================") + logger.info(f" RESULTS FOR INPUT TEST SET {set_idx}") + logger.info("======================================================================") + + for content in contents: + if content.role != "tool": + continue + for part in content.parts: + if part.function_response: + name = part.function_response.name + response_data = part.function_response.response + + # Extract result text + tool_text = None + if hasattr(response_data, "get"): + tool_text = response_data.get("result") + elif hasattr(response_data, "fields"): + tool_text = response_data.fields.get("result") + else: + tool_text = getattr(response_data, "result", None) + + logger.info(f"\n🛠️ Tool: '{name}'") + logger.info("--------------------------------------------------") + logger.info(f"Output Result:\n{tool_text}") + + if part.inline_data and part.inline_data.mime_type == "image/jpeg": + logger.info( + f"📷 Visual Data: Attached {len(part.inline_data.data)}" + " bytes of annotated JPEG screenshot" + ) + + +async def run_single_set(set_idx, query, search_query, labels, coords_nx, coords_ny, candidates): + import os + + os.environ["GOOGLE_API_KEY"] = "dummy_api_key" + + mock_ctx = create_mock_context() + mock_ctx.llm_config.utils = MagicMock() + mock_ctx.llm_config.utils.object_detector = MagicMock() + mock_ctx.llm_config.utils.object_detector.model = "gemini-2.5-flash" + mock_ctx.llm_config.utils.object_detector.timeout = 10.0 + + mock_state = create_mock_state() + + screenshot_path = ( + Path(__file__).resolve().parent + / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" + ) + + # Multi-turn sequential mock client + # Turn 1: get_ocr_list + mock_fc_ocr = MagicMock() + mock_fc_ocr.name = "get_ocr_list" + mock_fc_ocr.args = {} + mock_resp_ocr = MagicMock() + mock_resp_ocr.function_calls = [mock_fc_ocr] + + # Turn 2: search_xml_ocr with query + mock_fc_search = MagicMock() + mock_fc_search.name = "search_xml_ocr" + mock_fc_search.args = {"query": search_query} + mock_resp_search = MagicMock() + mock_resp_search.function_calls = [mock_fc_search] + + # Turn 3: detect_objects + mock_fc_detect = MagicMock() + mock_fc_detect.name = "detect_objects" + mock_fc_detect.args = {"labels": labels, "target_image_id": "img_0"} + mock_resp_detect = MagicMock() + mock_resp_detect.function_calls = [mock_fc_detect] + + # Turn 4: search_xml_ocr with coordinates + mock_fc_coords = MagicMock() + mock_fc_coords.name = "search_xml_ocr" + mock_fc_coords.args = {"nx": coords_nx, "ny": coords_ny} + mock_resp_coords = MagicMock() + mock_resp_coords.function_calls = [mock_fc_coords] + + # Turn 5: submit_answer + mock_fc_submit = MagicMock() + mock_fc_submit.name = "submit_answer" + mock_fc_submit.args = { + "candidates": candidates, + "fallback_message": f"Successfully verified test set {set_idx}", + } + mock_resp_submit = MagicMock() + mock_resp_submit.function_calls = [mock_fc_submit] + + mock_client = MagicMock() + + # Mock client.aio.models.generate_content side effects for ReAct turns + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[ + mock_resp_ocr, + mock_resp_search, + mock_resp_detect, + mock_resp_coords, + mock_resp_submit, + ] + ) + + # Mock the internal model call for _detect_single_label inside detect_objects + mock_detect_response = MagicMock() + y_norm, x_norm = 0, 0 + if "gear icon" in labels: + y_norm, x_norm = 300, 200 + elif "dashboard icon" in labels: + y_norm, x_norm = 500, 500 + elif "magnifying glass icon" in labels: + y_norm, x_norm = 140, 875 + elif "avatar icon" in labels: + y_norm, x_norm = 825, 375 + elif "arrow back icon" in labels: + y_norm, x_norm = 75, 100 + + mock_detect_response.text = json.dumps([{"point": [y_norm, x_norm]}]) + + mock_detector_client = MagicMock() + mock_detector_client.aio.models.generate_content = AsyncMock(return_value=mock_detect_response) + + with ( + patch( + "google.genai.Client", + side_effect=[mock_client, mock_detector_client], + ), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query=query, + context_feedback="", + screenshot_path=str(screenshot_path), + state=mock_state, + ) + + call_args_list = mock_client.aio.models.generate_content.call_args_list + contents = call_args_list[4].kwargs.get("contents") or call_args_list[4].args[0] + extract_and_print_tool_results(contents, set_idx, logger) + + +async def main(): + import shutil + from artemis.config import settings + + # Define outputs directory + outputs_dir = Path(__file__).resolve().parent / "outputs" + if outputs_dir.exists(): + shutil.rmtree(outputs_dir) + outputs_dir.mkdir(parents=True, exist_ok=True) + + # Redirect traces output to outputs/ and only keep the latest run + settings.TRACES_PATH = outputs_dir + + # 1. Seed the SQLite DB record first + seed_database() + + # 2. Define the 5 groups of input tests + test_sets = [ + { + "query": "Verify Settings button", + "search_query": "Settings", + "labels": ["gear icon"], + "coords_nx": (185), # Matches pixel center [200, 225] on [1080, 2400] scale + "coords_ny": 93, + "candidates": [ + { + "label": "S1", + "coords": [185, 93], + "description": "Settings Button", + } + ], + }, + { + "query": "Verify Dashboard button", + "search_query": "Dashboard", + "labels": ["dashboard icon"], + "coords_nx": (462), # Matches pixel center [500, 500] on [1080, 2400] scale + "coords_ny": 208, + "candidates": [ + { + "label": "S1", + "coords": [462, 208], + "description": "Dashboard Button", + } + ], + }, + { + "query": "Verify Search bar", + "search_query": "Search", + "labels": ["magnifying glass icon"], + "coords_nx": (810), # Matches pixel center [875, 140] on [1080, 2400] scale + "coords_ny": 58, + "candidates": [ + { + "label": "S1", + "coords": [810, 58], + "description": "Search bar", + } + ], + }, + { + "query": "Verify Profile icon", + "search_query": "Profile", + "labels": ["avatar icon"], + "coords_nx": (347), # Matches pixel center [375, 825] on [1080, 2400] scale + "coords_ny": 343, + "candidates": [ + { + "label": "S1", + "coords": [347, 343], + "description": "Profile icon", + } + ], + }, + { + "query": "Verify Back button", + "search_query": "Back", + "labels": ["arrow back icon"], + "coords_nx": (92), # Matches pixel center [100, 75] on [1080, 2400] scale + "coords_ny": 31, + "candidates": [ + { + "label": "S1", + "coords": [92, 31], + "description": "Back button", + } + ], + }, + ] + + # 3. Run all 5 sets sequentially + for i, test in enumerate(test_sets, 1): + await run_single_set( + set_idx=i, + query=test["query"], + search_query=test["search_query"], + labels=test["labels"], + coords_nx=test["coords_nx"], + coords_ny=test["coords_ny"], + candidates=test["candidates"], + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py b/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py new file mode 100644 index 0000000..86b860c --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py @@ -0,0 +1,162 @@ +# 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 hashlib +import json +from pathlib import Path +import sqlite3 + + +def seed_database(): + db_path = Path(__file__).resolve().parents[5] / "traces" / "data_engine.db" + screenshot_path = ( + Path(__file__).resolve().parent + / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" + ) + + if not screenshot_path.exists(): + # Try fallback to tests/screenshots/pixel_phone_mockup.png + source_path = ( + Path(__file__).resolve().parents[5] / "tests" / "screenshots" / "pixel_phone_mockup.png" + ) + import shutil + + shutil.copy(source_path, screenshot_path) + print(f"Copied screenshot from {source_path} to {screenshot_path}") + + # Compute SHA-256 hash + sha256_hash = hashlib.sha256() + with open(screenshot_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + computed_hash = sha256_hash.hexdigest() + print(f"Computed screenshot hash: {computed_hash}") + + # Bounding elements + # 1. Settings [100, 200][300, 250] -> center pixel [200, 225] -> normalized [185, 93] + # 2. Dashboard [400, 400][600, 600] -> center pixel [500, 500] -> normalized [462, 208] + # 3. Search [800, 100][950, 180] -> center pixel [875, 140] -> normalized [810, 58] + # 4. Profile [300, 800][450, 850] -> center pixel [375, 825] -> normalized [347, 343] + # 5. Back [50, 50][150, 100] -> center pixel [100, 75] -> normalized [92, 31] + + ocr_result = [ + {"text": "Screen Dashboard Title", "position": []}, + { + "text": "Settings", + "position": [ + {"x": 100, "y": 200}, + {"x": 300, "y": 200}, + {"x": 300, "y": 250}, + {"x": 100, "y": 250}, + ], + }, + { + "text": "Dashboard", + "position": [ + {"x": 400, "y": 400}, + {"x": 600, "y": 400}, + {"x": 600, "y": 600}, + {"x": 400, "y": 600}, + ], + }, + { + "text": "Search", + "position": [ + {"x": 800, "y": 100}, + {"x": 950, "y": 100}, + {"x": 950, "y": 180}, + {"x": 800, "y": 180}, + ], + }, + { + "text": "Profile", + "position": [ + {"x": 300, "y": 800}, + {"x": 450, "y": 800}, + {"x": 450, "y": 850}, + {"x": 300, "y": 850}, + ], + }, + { + "text": "Back", + "position": [ + {"x": 50, "y": 50}, + {"x": 150, "y": 50}, + {"x": 150, "y": 100}, + {"x": 50, "y": 100}, + ], + }, + ] + + ui_tree = [ + {"class": "android.widget.FrameLayout", "bounds": "[0,0][1080,2400]"}, + { + "class": "android.widget.Button", + "text": "Settings", + "resource-id": "com.example.app:id/settings", + "clickable": "true", + "bounds": "[100,200][300,250]", + }, + { + "class": "android.widget.Button", + "text": "Dashboard", + "resource-id": "com.example.app:id/dashboard", + "clickable": "true", + "bounds": "[400,400][600,600]", + }, + { + "class": "android.widget.Button", + "text": "Search", + "resource-id": "com.example.app:id/search", + "clickable": "true", + "bounds": "[800,100][950,180]", + }, + { + "class": "android.widget.Button", + "text": "Profile", + "resource-id": "com.example.app:id/profile", + "clickable": "true", + "bounds": "[300,800][450,850]", + }, + { + "class": "android.widget.Button", + "text": "Back", + "resource-id": "com.example.app:id/back", + "clickable": "true", + "bounds": "[50,50][150,100]", + }, + ] + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "INSERT OR REPLACE INTO images (image_name, timestamp, ocr_result," + " ui_tree, extra_metadata) VALUES (?, ?, ?, ?, ?)", + ( + computed_hash, + 1717398000.0, + json.dumps(ocr_result), + json.dumps(ui_tree), + json.dumps({}), + ), + ) + + conn.commit() + conn.close() + print(f"Successfully inserted mock image record {computed_hash} into database at {db_path}") + + +if __name__ == "__main__": + seed_database() diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py new file mode 100644 index 0000000..ea3cad2 --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py @@ -0,0 +1,62 @@ +# 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 AsyncMock, patch +from artemis.agents.object_detector.object_detector import _run_object_detection +import pytest + + +@pytest.mark.asyncio +async def test_coordinate_normalization_swap(): + """Verify that _run_object_detection correctly swaps coordinates from [y, x] to [x, y].""" + + # 1. Define the mock output returned by the VLM task (VLM native format: [y_norm, x_norm]) + mock_vlm_output = [ + { + "label": "settings icon", + "point": [800, 200], # [y, x] -> y = 800, x = 200 + } + ] + + # 2. Patch dependencies to run in a pure offline sandbox + with ( + patch( + "artemis.agents.object_detector.object_detector._detect_single_label", + new_callable=AsyncMock, + return_value=mock_vlm_output, + ), + patch("artemis.agents.object_detector.object_detector.get_llm"), + ): + # Mock context setup + mock_ctx = AsyncMock() + mock_ctx.llm_config.utils.object_detector = None + + # 3. Execute the function under test + json_output = await _run_object_detection( + ctx=mock_ctx, + image_bytes=b"fake_image_bytes", + queries=["settings icon"], + templates=["find the {label}"], + ) + + # 4. Verify that the coordinates were successfully swapped + result = json_output + detected_items = result["detected"] + + assert len(detected_items) == 1 + assert detected_items[0]["label"] == "settings icon" + + # CRITICAL ASSERTION: + # The coordinate MUST be swapped from [800, 200] (y, x) to [200, 800] (x, y) + assert detected_items[0]["point"] == [200, 800] diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py new file mode 100644 index 0000000..78df7ee --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py @@ -0,0 +1,334 @@ +# 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 +import json +import logging +from pathlib import Path +import shutil +import sys +from unittest.mock import AsyncMock, MagicMock, patch +from artemis.agents.explorer.explorer import Explorer +from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( + create_mock_context, + create_mock_state, + get_or_create_test_screenshot, +) +import pytest + + +def _verify_all_tools_responses(contents: list, logger: logging.Logger): + """Parses the multi-turn conversational history payload and maps tool outputs.""" + logger.info("=== VERIFYING TOOL RESPONSES IN CONVERSATION HISTORY ===") + tool_responses = {} + for content in contents: + for part in content.parts: + if part.function_response: + name = part.function_response.name + response_data = part.function_response.response + + # Extract result text + tool_text = None + if hasattr(response_data, "get"): + tool_text = response_data.get("result") + elif hasattr(response_data, "fields"): + tool_text = response_data.fields.get("result") + else: + tool_text = getattr(response_data, "result", None) + + if name not in tool_responses: + tool_responses[name] = [] + tool_responses[name].append({"text": tool_text, "has_image": False}) + logger.info(f"Found tool response for '{name}':\n{tool_text}") + + if part.inline_data and part.inline_data.mime_type == "image/jpeg": + # The image byte block corresponds to the preceding function response + if tool_responses: + last_tool = list(tool_responses.keys())[-1] + tool_responses[last_tool][-1]["has_image"] = True + tool_responses[last_tool][-1]["img_bytes"] = part.inline_data.data + logger.info( + f" -> Attached {len(part.inline_data.data)} bytes of" + f" JPEG inline visual content to '{last_tool}'" + ) + + return tool_responses + + +@pytest.mark.asyncio +async def test_explorer_all_tools_sequential_mocked(): + """Verify that the Explorer ReAct loop correctly sequences, dispatches, processes, and + + annotates screenshot outputs for all available visual tools: + 1. get_ocr_list + 2. search_xml_ocr (query) + 3. detect_objects + 4. search_xml_ocr (coordinates) + 5. submit_answer + """ + test_name = sys._getframe().f_code.co_name + + # Set up standard log file saved on the same level as test file + log_file = Path(__file__).parent / f"log_{test_name}.log" + logger = logging.getLogger(test_name) + logger.setLevel(logging.INFO) + handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") + logger.addHandler(handler) + + # Attach the main agent logger to capture internal step logs + agent_logger = logging.getLogger("artemis.agents.explorer.explorer") + agent_logger.setLevel(logging.INFO) + agent_logger.addHandler(handler) + + logger.info(f"Starting comprehensive test: {test_name}") + + # Define outputs directory and redirect settings to ensure only the latest run is saved + outputs_dir = Path(__file__).resolve().parent / "outputs" + if outputs_dir.exists(): + shutil.rmtree(outputs_dir) + outputs_dir.mkdir(parents=True, exist_ok=True) + + from artemis.config import settings + + settings.TRACES_PATH = outputs_dir + + mock_ctx = create_mock_context() + mock_state = create_mock_state() + + logger.info( + f"Mock context: device={mock_ctx.device.device_width}x{mock_ctx.device.device_height}" + ) + + # Load and verify screenshot + input_screenshot = get_or_create_test_screenshot() + logger.info(f"Visual screenshot loaded: {input_screenshot}") + + # ========================================================================= + # 1. Mock LLM Multi-Turn Responses (Sequentially calling each tool) + # ========================================================================= + + # Turn 1: call get_ocr_list + mock_fc_ocr = MagicMock() + mock_fc_ocr.name = "get_ocr_list" + mock_fc_ocr.args = {} + mock_resp_ocr = MagicMock() + mock_resp_ocr.function_calls = [mock_fc_ocr] + + # Turn 2: call ask_perception_tool + mock_fc_search = MagicMock() + mock_fc_search.name = "ask_perception_tool" + mock_fc_search.args = { + "search_query": "Settings", + "nx": 500, + "ny": 500, + "detect_queries": ["gear icon"], + } + mock_resp_search = MagicMock() + mock_resp_search.function_calls = [mock_fc_search] + + # Turn 3: call detect_objects + mock_fc_detect = MagicMock() + mock_fc_detect.name = "detect_objects" + mock_fc_detect.args = {"queries": ["gear icon"], "target_image_id": "img_0"} + mock_resp_detect = MagicMock() + mock_resp_detect.function_calls = [mock_fc_detect] + + # Turn 4: call inspect_region + mock_fc_coords = MagicMock() + mock_fc_coords.name = "inspect_region" + mock_fc_coords.args = { + "x_min": 100, + "y_min": 100, + "x_max": 300, + "y_max": 300, + "zoom_factor": 2.0, + } + mock_resp_coords = MagicMock() + mock_resp_coords.function_calls = [mock_fc_coords] + + # Turn 5: call submit_answer to complete loop + mock_fc_submit = MagicMock() + mock_fc_submit.name = "submit_answer" + mock_fc_submit.args = { + "candidates": [ + { + "label": "T1", + "coords": [462, 208], + "description": "Dashboard Button", + }, + { + "label": "S1", + "coords": [462, 208], + "description": "Settings Button", + }, + {"label": "D1", "coords": [200, 300], "description": "gear icon"}, + ], + "fallback_message": ("Successfully verified all visual controls sequentially."), + } + mock_resp_submit = MagicMock() + mock_resp_submit.function_calls = [mock_fc_submit] + + mock_client = MagicMock() + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[ + mock_resp_ocr, + mock_resp_search, + mock_resp_detect, + mock_resp_coords, + mock_resp_submit, + ] + ) + + mock_ctx._genai_client = mock_client + mock_ctx.llm_config.explorer = None + + # ========================================================================= + # 2. Mock Downstream Tool Logic Dependencies + # ========================================================================= + + # Mock SQLite StorageManager for get_ocr_list + mock_storage = MagicMock() + mock_record = MagicMock() + mock_record.ocr_result = [ + {"text": "screen description text", "position": []}, + { + "text": "Dashboard Button", + "position": [ + {"x": 400, "y": 400}, + {"x": 600, "y": 400}, + {"x": 600, "y": 600}, + {"x": 400, "y": 600}, + ], + }, + ] + mock_storage.get_image.return_value = mock_record + + # Mock low-level layout searching (search_ui_func) + mock_search_ui = MagicMock( + return_value={ + "matches": [ + { + "matched_text": "Settings Button", + "bounds": [400, 400, 600, 600], + } + ] + } + ) + + # Mock downstream object detection pipeline (_run_object_detection) + mock_detect = AsyncMock( + return_value={"detected": [{"label": "gear icon", "point": [200, 300]}]} + ) + + # Mock spatial coordinate lookup (search_by_coordinates_func) + mock_lookup = MagicMock(return_value="Matching Elements: Container/DashboardFrame") + + logger.info("Starting Explorer.run execution loop with sequential tool mocks...") + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch("artemis.agents.explorer.explorer.search_ui_func", mock_search_ui), + patch("artemis.agents.explorer.explorer._run_object_detection", mock_detect), + patch( + "artemis.agents.explorer.explorer.search_by_coordinates_func", + mock_lookup, + ), + ): + explorer = Explorer(mock_ctx) + result = await explorer.run( + query="Test all tools", + context_feedback="", + screenshot_path=str(input_screenshot), + state=mock_state, + version="ultra", + ) + + # Verify ReAct loop went through all 5 turns + assert mock_client.aio.models.generate_content.call_count == 5 + logger.info("Successfully completed all 5 ReAct turns.") + + # Retrieve final submission outcome + outcome_data = json.loads(result) + assert "candidates" in outcome_data + assert ( + outcome_data["fallback_message"] + == "Successfully verified all visual controls sequentially." + ) + logger.info("Submission response verified successfully.") + + # Extract all tool outputs from history to assert correctness + call_args_list = mock_client.aio.models.generate_content.call_args_list + # The fifth call receives the accumulated tool history + contents = call_args_list[4].kwargs.get("contents") or call_args_list[4].args[0] + + tool_responses = _verify_all_tools_responses(contents, logger) + + # ASSERTIONS FOR EACH TOOL INDIVIDUALLY + + # Verify each tool execution record in explorer.trace_history + assert len(explorer.trace_history) >= 4 + # 1. Verify get_ocr_list + ocr_trace = explorer.trace_history[0]["tool_calls"][0] + assert ocr_trace["name"] == "get_ocr_list" + assert "[T1]" in ocr_trace["response"]["text"] + assert "[462,208]" in ocr_trace["response"]["text"] + assert ocr_trace["response"]["image_path"] is not None + + # 2. Verify ask_perception_tool + search_trace = explorer.trace_history[1]["tool_calls"][0] + assert search_trace["name"] == "ask_perception_tool" + assert "XML/OCR Text Search Results" in search_trace["response"]["text"] + assert len(search_trace["response"]["image_paths"]) > 0 + + # 3. Verify detect_objects + detect_trace = explorer.trace_history[2]["tool_calls"][0] + assert detect_trace["name"] == "detect_objects" + assert "[D" in detect_trace["response"]["text"] + assert detect_trace["response"]["image_path"] is not None + + # 4. Verify inspect_region + coords_trace = explorer.trace_history[3]["tool_calls"][0] + assert coords_trace["name"] == "inspect_region" + assert coords_trace["response"]["image_path"] is not None + # Compute expected screenshot hash + import hashlib + + sha256_hash = hashlib.sha256() + with open(input_screenshot, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + expected_hash = sha256_hash.hexdigest() + + # Should map normalized [500, 500] to pixel [540, 1200] + mock_lookup.assert_called_once_with(expected_hash, 540, 1200) + + logger.info( + "All assertions passed! The entire suite of explorer tools is fully functional." + ) + + # Clean up logging handlers + agent_logger.removeHandler(handler) + logger.removeHandler(handler) + handler.close() + + +if __name__ == "__main__": + asyncio.run(test_explorer_all_tools_sequential_mocked()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py new file mode 100644 index 0000000..742e206 --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py @@ -0,0 +1,372 @@ +# 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 +import json +import logging +from pathlib import Path +import sys +from unittest.mock import AsyncMock, MagicMock, patch +from artemis.agents.explorer.explorer import Explorer +from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( + create_mock_context, + create_mock_state, + get_or_create_test_screenshot, +) +from PIL import Image +import pytest + + +def _verify_tool_responses(contents: list, logger: logging.Logger): + """Parses the multi-turn conversational history payload and maps tool outputs.""" + logger.info("=== VERIFYING TOOL RESPONSES IN CONVERSATION HISTORY ===") + tool_responses = {} + for content in contents: + for part in content.parts: + if part.function_response: + name = part.function_response.name + response_data = part.function_response.response + + # Extract result text + tool_text = None + if hasattr(response_data, "get"): + tool_text = response_data.get("result") + elif hasattr(response_data, "fields"): + tool_text = response_data.fields.get("result") + else: + tool_text = getattr(response_data, "result", None) + + if name not in tool_responses: + tool_responses[name] = [] + tool_responses[name].append({"text": tool_text, "has_image": False}) + logger.info(f"Found tool response for '{name}':\n{tool_text}") + + if part.inline_data and part.inline_data.mime_type == "image/jpeg": + if tool_responses: + last_tool = list(tool_responses.keys())[-1] + tool_responses[last_tool][-1]["has_image"] = True + tool_responses[last_tool][-1]["img_bytes"] = part.inline_data.data + logger.info( + f" -> Attached {len(part.inline_data.data)} bytes of" + f" JPEG inline visual content to '{last_tool}'" + ) + + return tool_responses + + +@pytest.mark.asyncio +async def test_explorer_ask_image_processor_tool(): + """Verify that the Explorer ReAct loop correctly dispatches, runs, processes, and + + handles the output image/transform details for the 'ask_image_processor' + tool. + The test ends immediately after the Vision Coder responds (in the second + turn). + """ + test_name = sys._getframe().f_code.co_name + + # Set up standard log file saved on the same level as test file + log_file = Path(__file__).parent / f"log_{test_name}.log" + logger = logging.getLogger(test_name) + logger.setLevel(logging.INFO) + handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") + logger.addHandler(handler) + + # Attach the main agent logger to capture internal step logs + agent_logger = logging.getLogger("artemis.agents.explorer.explorer") + agent_logger.setLevel(logging.INFO) + agent_logger.addHandler(handler) + + logger.info(f"Starting test: {test_name}") + + # Define outputs directory and redirect settings to ensure only the latest run is saved + outputs_dir = Path(__file__).resolve().parent / "outputs" + if not outputs_dir.exists(): + outputs_dir.mkdir(parents=True, exist_ok=True) + + from artemis.config import settings + + settings.TRACES_PATH = outputs_dir + + mock_ctx = create_mock_context() + mock_state = create_mock_state() + + logger.info( + f"Mock context: device={mock_ctx.device.device_width}x{mock_ctx.device.device_height}" + ) + + # Load and verify screenshot + input_screenshot = get_or_create_test_screenshot() + logger.info(f"Visual screenshot loaded: {input_screenshot}") + + # ========================================================================= + # 1. Mock LLM Multi-Turn Responses (ask_image_processor -> submit_answer) + # ========================================================================= + + # Turn 1: call ask_image_processor + mock_fc_coder = MagicMock() + mock_fc_coder.name = "ask_image_processor" + mock_fc_coder.args = { + "instruction": ( + "Crop the top-right area, convert to HSV, resize, then convert to grayscale." + ), + "target_image_id": "img_0", + } + mock_resp_coder = MagicMock() + mock_resp_coder.function_calls = [mock_fc_coder] + + # Turn 2: call submit_answer to complete loop immediately after vision coder responds + mock_fc_submit = MagicMock() + mock_fc_submit.name = "submit_answer" + mock_fc_submit.args = { + "candidates": [], + "fallback_message": ("Successfully processed the image via Vision Coder."), + } + mock_resp_submit = MagicMock() + mock_resp_submit.function_calls = [mock_fc_submit] + + mock_client = MagicMock() + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[mock_resp_coder, mock_resp_submit] + ) + + mock_ctx._genai_client = mock_client + mock_ctx.llm_config.explorer = None + + # ========================================================================= + # 2. Mock ImageProcessor agent + # ========================================================================= + + # Create the mock output image path + mock_new_image_path = outputs_dir / f"mock_image_processor_output_{test_name}.jpg" + # Create a small dummy image of size 100x100 using PIL so that cv2.imread inside explorer reads these dimensions + img = Image.new("RGB", (100, 100), color="red") + img.save(mock_new_image_path) + + # Also write a transform JSON file for the new image + transform_path = Path(str(mock_new_image_path) + ".transform.json") + mock_transform_data = { + "offset_x": 108.0, + "offset_y": 240.0, + "scale_x": 0.5, + "scale_y": 0.5, + } + with open(transform_path, "w") as f: + json.dump(mock_transform_data, f) + + # We mock ImageProcessor class itself + mock_image_processor_instance = MagicMock() + mock_image_processor_instance.run = AsyncMock( + return_value={ + "outputs": [ + { + "image_id": "img_1", + "path": str(mock_new_image_path), + "transform": { + "offset_x": 108.0, + "offset_y": 240.0, + "scale_x": 0.5, + "scale_y": 0.5, + }, + "annotations": {"V1": [50, 100], "V2": [180, 240]}, + } + ], + "summary": "Cropped and highlighted the red button", + } + ) + + logger.info("Starting Explorer.run execution loop with ask_image_processor...") + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.image_processor.image_processor.ImageProcessor", + return_value=mock_image_processor_instance, + ), + ): + explorer = Explorer(mock_ctx) + + result = await explorer.run( + query=("Click on the red button in the top-right corner of the screen."), + context_feedback="", + screenshot_path=str(input_screenshot), + state=mock_state, + version="ultra", + ) + + # Verify initial image pool has img_0 + assert "img_0" in explorer.image_pool + assert explorer.image_pool["img_0"]["path"] == str(input_screenshot) + + # Verify ReAct loop went through exactly 2 turns + assert mock_client.aio.models.generate_content.call_count == 2 + logger.info("Successfully completed both ReAct turns.") + + # Verify ImageProcessor run was called with correct parameters + mock_image_processor_instance.run.assert_called_once_with( + "Crop the top-right area, convert to HSV, resize, then convert to grayscale.", + str(input_screenshot), + ) + logger.info("Verified ImageProcessor was invoked with correct parameters.") + + # Verify new image is registered in explorer's image_pool + assert "img_1" in explorer.image_pool + img_1_info = explorer.image_pool["img_1"] + assert img_1_info["path"] == str(mock_new_image_path) + + # Verify transform calculations: + # Since parent (img_0) transform is {"offset_x": 0.0, "offset_y": 0.0, "scale_x": 1.0, "scale_y": 1.0} + # and child transform is {"offset_x": 108.0, "offset_y": 240.0, "scale_x": 0.5, "scale_y": 0.5} + assert img_1_info["transform"]["scale_x"] == 0.5 + assert img_1_info["transform"]["scale_y"] == 0.5 + assert img_1_info["transform"]["offset_x"] == 108.0 + assert img_1_info["transform"]["offset_y"] == 240.0 + assert "Cropped and highlighted the red button" in img_1_info["description"] + logger.info("Verified image_pool registrations and coordinate transforms.") + + # Retrieve final submission outcome + outcome_data = json.loads(result) + assert ( + outcome_data["fallback_message"] == "Successfully processed the image via Vision Coder." + ) + logger.info("Submission response verified successfully.") + + # Extract all tool outputs from history to assert correctness + call_args_list = mock_client.aio.models.generate_content.call_args_list + # The second call receives the accumulated tool history + contents = call_args_list[1].kwargs.get("contents") or call_args_list[1].args[0] + + tool_responses = _verify_tool_responses(contents, logger) + + # Assertions for ask_image_processor tool response (Turn 1 output) + assert "ask_image_processor" in tool_responses + coder_resp = tool_responses["ask_image_processor"][0] + assert "Image Processor completed successfully" in coder_resp["text"] + assert "img_1" in coder_resp["text"] + assert "Annotations (wrt original screenshot):" in coder_resp["text"] + assert "img_1:" in coder_resp["text"] + assert "- [V1]: [208, 440]" in coder_resp["text"] + assert "- [V2]: [468, 720]" in coder_resp["text"] + assert coder_resp["has_image"] + assert len(coder_resp["img_bytes"]) > 0 + logger.info( + "Verified tool response contains the response text, annotations" + " list, and the attached image bytes." + ) + + # Clean up dummy files (Commented out temporarily so you can see the images!) + # if mock_new_image_path.exists(): + # mock_new_image_path.unlink() + # if transform_path.exists(): + # transform_path.unlink() + # if input_screenshot.exists(): + # input_screenshot.unlink() + + # Clean up logging handlers + agent_logger.removeHandler(handler) + logger.removeHandler(handler) + handler.close() + + +if __name__ == "__main__": + asyncio.run(test_explorer_ask_image_processor_tool()) + + +def test_cv_canvas_annotations(tmp_path): + import numpy as np + import cv2 + from artemis.utils.cv_canvas import ImageCanvas + + # Create a dummy image + img_dir = tmp_path / "images" + img_dir.mkdir() + img_path = img_dir / "img_0.jpg" + img_data = np.zeros((100, 100, 3), dtype=np.uint8) + cv2.imwrite(str(img_path), img_data) + + # Initialize transforms registry + registry_path = img_dir / "intermediate_transforms.json" + registry_data = { + "img_0": { + "image_id": "img_0", + "path": str(img_path.resolve()), + "transform": { + "offset_x": 0.0, + "offset_y": 0.0, + "scale_x": 1.0, + "scale_y": 1.0, + }, + "annotations": {}, + } + } + with open(registry_path, "w") as f: + json.dump(registry_data, f) + + canvas = ImageCanvas("img_0", str(img_dir)) + assert canvas.annotations == {} + + # Test _get_resolution and initial width/height + assert canvas._get_resolution() == (100, 100) + assert canvas.width == 100 + assert canvas.height == 100 + + # Test normalized_to_pixel_coords + assert canvas.normalized_to_pixel_coords(500, 500) == (50, 50) + assert canvas.normalized_to_pixel_coords(0, 0) == (0, 0) + assert canvas.normalized_to_pixel_coords(1000, 1000) == (99, 99) + + # Draw dot + canvas.draw_dot(10, 20, 1) + assert canvas.annotations == {"V1": [10, 20]} + + # Crop + canvas.crop(5, 5, 20, 20) + assert canvas.width == 20 + assert canvas.height == 20 + assert canvas._get_resolution() == (20, 20) + assert canvas.normalized_to_pixel_coords(500, 500) == (10, 10) + assert canvas.annotations == {"V1": [5, 15]} + + # Out of bounds test + canvas.draw_dot(2, 2, 2) + assert canvas.annotations == {"V1": [5, 15], "V2": [2, 2]} + + # Crop again to discard V1 + canvas.crop(1, 1, 5, 5) + assert canvas.width == 5 + assert canvas.height == 5 + assert canvas.normalized_to_pixel_coords(500, 500) == (2, 2) + # V1 was at [5, 15], after crop(1, 1), it becomes [4, 14], which is out of bounds of 5x5. + # V2 was at [2, 2], after crop(1, 1), it becomes [1, 1], which is inside 5x5. + assert canvas.annotations == {"V2": [1, 1]} + + # Resize by factor + canvas.resize_by_factor(2.0) + assert canvas.width == 10 + assert canvas.height == 10 + assert canvas._get_resolution() == (10, 10) + assert canvas.normalized_to_pixel_coords(500, 500) == (5, 5) + assert canvas.annotations == {"V2": [2, 2]} + + # Save and verify registry + canvas.save(final=True) + with open(registry_path) as f: + saved_registry = json.load(f) + + assert "img_1" in saved_registry + assert saved_registry["img_1"]["annotations"] == {"V2": [2, 2]} + assert saved_registry["img_1"]["is_output"] is True diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py new file mode 100644 index 0000000..17a31d7 --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py @@ -0,0 +1,232 @@ +# 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 +import json +import logging +from pathlib import Path +import shutil +from unittest.mock import patch + +from artemis.config import settings +from artemis.agents.image_processor.image_processor import ImageProcessor +from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( + create_mock_context, + get_or_create_test_screenshot, +) +from artemis.utils.python_executor import PythonExecutor +import pytest + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("test_explorer_ask_image_processor_jupyter") + + +@pytest.mark.asyncio +async def test_explorer_ask_image_processor_jupyter(): + test_name = "test_explorer_ask_image_processor_jupyter" + + # 1. Setup outputs directory + outputs_dir = Path(__file__).resolve().parent / "outputs" / test_name + outputs_dir.mkdir(parents=True, exist_ok=True) + + # Redirect traces path to the new outputs directory + settings.TRACES_PATH = outputs_dir + + # 2. Prepare the input image + src_image_path = get_or_create_test_screenshot() + input_screenshot = outputs_dir / "input_screenshot.jpg" + shutil.copy2(src_image_path, input_screenshot) + logger.info(f"Copied input screenshot to: {input_screenshot}") + + # 3. Formulate the instruction with the injected G-Logo coordinate prior + instruction = ( + 'The user wants to find "the red patch above the green patch". Let\'s' + " analyze the screen layout. \nIn the dock / app icons, we have:\n-" + " Play Store (9): colorful triangle (has green, blue, yellow, red)\n-" + " Gmail (10): M icon (red, yellow, green, blue)\n- Photos (11):" + " pinwheel (red, yellow, green, blue)\n- Chrome (16): circular logo" + " with green on the bottom, red on top, yellow on the side, blue" + " center.\nWait, let's look at the Photos icon (11) or Chrome icon (16)" + " or Gmail (10) or Google search bar logo (21) or Play Store" + " (9).\nLet's zoom into Chrome (16), Gmail (10), Photos (11), Play" + " Store (9), Google logo (21) to analyze the relative positions of red" + " patches and green patches.\nThe G-Logo is exactly at pixel" + " coordinates [2173, 107, 2241, 174].\nLet's crop these icons to find" + ' which icon has a "red patch above the green patch" or is it a' + " specific patch?\nWait!\nLet's look at Chrome (16):\nRed is at the" + " top, green is at the bottom/left. Red is directly above green? Yes," + " the top slice of Chrome is red, and the bottom slice is green.\nLet's" + " look at Photos (11):\nIt has four petals. The top petal is red? No," + " top petal is red/pink, bottom is green? Let's check.\nLet's look at" + " Gmail (10):\nThe red is the M shape, green is at the bottom" + " right?\nLet's write Python code to crop these areas and output the" + " crop to see clearly.\nSpecifically, let's crop:\n- Play Store (bounds" + " around 9)\n- Gmail (bounds around 10)\n- Photos (bounds around 11)\n-" + " Chrome (bounds around 16)\n- Google widget logo (bounds around" + " 21)\nWe will save a visualization of these cropped regions side by" + " side or separately so we can examine the colors." + ) + + # 4. Intercept Python executions to record them for the Jupyter Notebook + executed_turns = [] + original_execute = PythonExecutor.execute + + def wrapped_execute(self, code: str) -> str: + logger.info(f"Intercepting Python code execution. Code:\n{code}\n") + result = original_execute(self, code) + logger.info(f"Execution result:\n{result}\n") + executed_turns.append({"code": code, "output": result}) + return result + + from langchain_core.messages import AIMessage + from unittest.mock import AsyncMock, MagicMock + + sample_code = """ +from artemis.utils.cv_canvas import ImageCanvas +import glob +from pathlib import Path +from artemis.config import settings + +dirs = sorted(glob.glob(str(Path(settings.TRACES_PATH) / "images" / "image_processor" / "intermediates_*"))) +intermediate_dir = dirs[-1] if dirs else "." + +canvas = ImageCanvas("img_0", intermediate_dir) +canvas.crop(10, 10, 50, 50) +canvas.save(final=True) +""" + + mock_llm = MagicMock() + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock( + side_effect=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "execute_python", + "args": {"code": sample_code}, + "id": "call_1", + } + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "name": "submit_result", + "args": {"summary": "Cropped target G-Logo region."}, + "id": "call_2", + } + ], + ), + ] + ) + mock_llm.bind_tools.return_value = mock_bound + + mock_ctx = create_mock_context() + image_processor = ImageProcessor(mock_ctx) + + # Run with patched executor and mock LLM + logger.info("Running Image Processor with G-Logo prior...") + with ( + patch.object(PythonExecutor, "execute", wrapped_execute), + patch( + "artemis.agents.image_processor.image_processor.get_llm", + return_value=mock_llm, + ), + ): + result = await image_processor.run(instruction, str(input_screenshot)) + + logger.info(f"Image Processor finished. Result: {result}") + + # 5. Compile and save the Jupyter Notebook (.ipynb) + notebook_cells = [] + # Add a markdown introduction cell + notebook_cells.append( + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Image Processor Execution Trace\n", + f"**Task**: {instruction[:150]}...\n", + "**Input Image**: `input_screenshot.jpg`", + ], + } + ) + + for turn_idx, turn in enumerate(executed_turns): + # Add code cell + notebook_cells.append( + { + "cell_type": "code", + "execution_count": turn_idx + 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [line + "\n" for line in turn["output"].split("\n")], + } + ], + "source": [line + "\n" for line in turn["code"].split("\n")], + } + ) + + # If a result was submitted, add a final markdown cell + outputs = result.get("outputs", []) + if outputs: + final_image_path = outputs[-1]["path"] + notebook_cells.append( + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎉 Execution Completed Successfully\n", + f"**Summary**: {result.get('summary')}\n", + f"**Output Image**: `{Path(final_image_path).name}`", + ], + } + ) + # Copy final image to output directory root for easy access + shutil.copy2(final_image_path, outputs_dir / "final_output.jpg") + + notebook_json = { + "cells": notebook_cells, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + } + }, + "nbformat": 4, + "nbformat_minor": 2, + } + + notebook_path = outputs_dir / "vision_coder_execution.ipynb" + with open(notebook_path, "w", encoding="utf-8") as f: + json.dump(notebook_json, f, indent=2) + + logger.info(f"Saved compiled Jupyter notebook to: {notebook_path}") + + # 6. Assertions + assert "error" not in result, f"Image Processor failed: {result.get('error')}" + assert "outputs" in result, "Image Processor did not return outputs" + assert len(result["outputs"]) > 0, "Image Processor did not return any output images" + logger.info("Test completed successfully! All assertions passed.") + + +if __name__ == "__main__": + asyncio.run(test_explorer_ask_image_processor_jupyter()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py new file mode 100644 index 0000000..735f2da --- /dev/null +++ b/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py @@ -0,0 +1,130 @@ +# 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 +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.agents.object_detector.object_detector import _run_object_detection +from tests.integration.agents.explorer.test_explorer_all_tools.helpers import create_mock_context +import pytest + + +@pytest.mark.asyncio +async def test_object_detector_concurrency_and_throttling(tmp_path): + """Verify that object detector requests run in parallel and are correctly throttled by the semaphore.""" + # Create a dummy image file for the test + dummy_image_path = tmp_path / "dummy.jpg" + dummy_image_path.write_bytes(b"dummy image data") + + # Setup mock context + mock_ctx = create_mock_context() + + # Configure the object detector LLM config explicitly to avoid MagicMocks for timeout and model + od_cfg = MagicMock() + od_cfg.model = "gemini-robotics-er-early-access" + od_cfg.fix_model = "gemini-2.5-flash" + od_cfg.timeout = 10.0 + mock_ctx.llm_config.utils.object_detector = od_cfg + + # Track active requests + active_requests = 0 + max_concurrent_requests = 0 + request_lock = asyncio.Lock() + from langchain_core.messages import AIMessage + + mock_llm = MagicMock() + mock_llm.ainvoke = AsyncMock(side_effect=lambda *args, **kwargs: mock_generate_content()) + + async def mock_generate_content(*args, **kwargs): + nonlocal active_requests, max_concurrent_requests + async with request_lock: + active_requests += 1 + if active_requests > max_concurrent_requests: + max_concurrent_requests = active_requests + + try: + await asyncio.sleep(0.1) + finally: + async with request_lock: + active_requests -= 1 + + return AIMessage(content='[{"point": [500, 500]}]') + + mock_llm.ainvoke = AsyncMock(side_effect=mock_generate_content) + + with patch( + "artemis.agents.object_detector.object_detector.get_llm", + return_value=mock_llm, + ): + # --- TEST 1: Concurrency Assertion (5 labels) --- + # 5 labels should run in parallel, completing in ~0.1s (much faster than a sequential 0.5s) + # Reset trackers + active_requests = 0 + max_concurrent_requests = 0 + + start_time = time.time() + labels_5 = [f"label_{i}" for i in range(5)] + templates = ["Find {labels_str}"] + + result_5 = await _run_object_detection( + ctx=mock_ctx, + image_path=str(dummy_image_path), + queries=labels_5, + templates=templates, + ) + elapsed_5 = time.time() - start_time + + # Concurrency assertions + assert max_concurrent_requests == 5, ( + f"Expected 5 concurrent requests, got {max_concurrent_requests}" + ) + assert elapsed_5 < 0.3, f"Expected execution to take < 0.3s, but took {elapsed_5:.3f}s" + + # Verify result structure and swapped coordinates [y, x] -> [x, y] + parsed_result_5 = result_5 + assert len(parsed_result_5["detected"]) == 5 + for item in parsed_result_5["detected"]: + assert item["point"] == [500, 500] + + # --- TEST 2: Throttling Assertion (10 labels) --- + # 10 labels should be throttled by the semaphore limit of 6. + # It should run in two batches, completing in ~0.2s (much faster than a sequential 1.0s). + # Reset trackers + active_requests = 0 + max_concurrent_requests = 0 + + start_time = time.time() + labels_10 = [f"label_{i}" for i in range(10)] + + result_10 = await _run_object_detection( + ctx=mock_ctx, + image_path=str(dummy_image_path), + queries=labels_10, + templates=templates, + ) + elapsed_10 = time.time() - start_time + + # Throttling assertions + assert max_concurrent_requests == 6, ( + f"Expected max concurrent requests to be capped at 6, got {max_concurrent_requests}" + ) + assert elapsed_10 < 0.5, ( + f"Expected execution to take < 0.5s (two batches of 0.1s), but took {elapsed_10:.3f}s" + ) + + parsed_result_10 = result_10 + assert len(parsed_result_10["detected"]) == 10 + for item in parsed_result_10["detected"]: + assert item["point"] == [500, 500] diff --git a/tests/integration/test_failure_analyzer_standalone_youtube.py b/tests/integration/test_failure_analyzer_standalone_youtube.py new file mode 100644 index 0000000..8888443 --- /dev/null +++ b/tests/integration/test_failure_analyzer_standalone_youtube.py @@ -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. + +import asyncio +import base64 +import logging +import os +import sys +from dotenv import load_dotenv + +# Ensure we can import from project +sys.path.append(os.path.dirname(__file__)) + +from artemis.sdk import Agent +from artemis.sdk.builders import Builders +from artemis.sdk.types import AgentProfile +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.agents.validator.failure_analyzer import FailureAnalyzer, ValidationErrorCategory +from artemis.controllers.unified_controller import UnifiedMobileController +from artemis.utils.notes import get_note_file_path + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("test_fa_standalone") + + +async def main(): + load_dotenv() + + # 1. Initialize Agent to set up device and connection pools + logger.info("Initializing Agent and connecting to device...") + profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") + config = Builders.AgentConfig.with_default_profile(profile).build() + + agent = Agent(config=config) + await agent.init() + + # Create the ArtemisContext manually using agent's initialized clients + context = ArtemisContext( + device=agent._device_context, + adb_client=agent._adb_client, + ui_adb_client=agent._ui_adb_client, + llm_config=profile.llm_config, + agent_config=config, + ) + + # Set up trace session and data engine + from artemis.data_engine.engine import DataEngine + + data_engine = DataEngine(ctx=context) + context.data_engine = data_engine + data_engine.start_session(goal="Play a video in full screen on YouTube in Chrome") + + # Write the task plan note + task_plan_content = ( + "- [ ] Open Chrome and navigate to the YouTube website (youtube.com)\n" + "- [ ] Select and play any video\n" + "- [ ] Make the playing video full screen" + ) + task_plan_path = get_note_file_path(data_engine.base_dir, "task_plan") + task_plan_path.parent.mkdir(parents=True, exist_ok=True) + task_plan_path.write_text(task_plan_content, encoding="utf-8") + + # 2. Get initial screenshot + controller = UnifiedMobileController(context) + screen_data = await controller.controller.get_screen_data() + screenshot_b64 = screen_data.base64 + + # Set up State + state = State( + messages=[], + initial_goal="Play a video in full screen on YouTube in Chrome", + latest_ui_hierarchy=screen_data.elements, + latest_screenshot=str( + data_engine.get_image_path( + data_engine.get_or_create_image(base64.b64decode(screenshot_b64)) + ) + ), + focused_app_info="", + device_date="", + structured_decisions="", + complete_subgoals_by_ids=[], + validator_messages=[], + ) + + # 3. Instantiate and run Failure Analyzer directly + logger.info("Starting Failure Analyzer directly...") + analyzer = FailureAnalyzer(context) + analyzer.max_iterations = 45 + + # We pass a dummy failed action representing the fullscreen button tap + dummy_failed_action = { + "action": "tap", + "coordinates": [968, 883], + "target_text": "fullscreen button", + } + + try: + result = await analyzer.analyze( + state=state, + failed_action=dummy_failed_action, + error_msg="Simulated launch failure to trigger standalone FA run", + pre_screenshot=screenshot_b64, + post_screenshot=screenshot_b64, + error_category=ValidationErrorCategory.TARGET_DISAPPEARED, + ) + logger.info(f"Failure Analyzer direct execution finished! Result: {result}") + except Exception: + logger.exception("Error during Failure Analyzer execution:") + finally: + await agent.clean() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/test_flash_standalone_news.py b/tests/integration/test_flash_standalone_news.py new file mode 100644 index 0000000..ca9aed8 --- /dev/null +++ b/tests/integration/test_flash_standalone_news.py @@ -0,0 +1,58 @@ +# 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 +import logging +import os +import sys +from dotenv import load_dotenv + +# Ensure we can import from project +sys.path.append(os.path.dirname(__file__)) + +from artemis.sdk import Agent +from artemis.sdk.builders import Builders +from artemis.sdk.types import AgentProfile + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("test_flash_standalone") + + +async def main(): + load_dotenv() + + # 1. Initialize Agent to set up device and connection pools + logger.info("Initializing Agent and connecting to device...") + profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") + config = Builders.AgentConfig.with_default_profile(profile).build() + + agent = Agent(config=config) + await agent.init() + + # 2. Run task using 'flash' profile + logger.info("Starting Flash runner task execution...") + try: + result = await agent.run_task( + goal="Open USA Today in Chrome browser and view the USA news", + profile="flash", + ) + logger.info(f"Flash runner task execution finished! Result: {result}") + except Exception: + logger.exception("Error during Flash task execution:") + finally: + await agent.clean() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/test_flash_standalone_youtube.py b/tests/integration/test_flash_standalone_youtube.py new file mode 100644 index 0000000..e34f9f2 --- /dev/null +++ b/tests/integration/test_flash_standalone_youtube.py @@ -0,0 +1,57 @@ +# 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 +import logging +import os +import sys +from dotenv import load_dotenv + +# Ensure we can import from project +sys.path.append(os.path.dirname(__file__)) + +from artemis.sdk import Agent +from artemis.sdk.builders import Builders +from artemis.sdk.types import AgentProfile + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("test_flash_standalone") + + +async def main(): + load_dotenv() + + # 1. Initialize Agent to set up device and connection pools + logger.info("Initializing Agent and connecting to device...") + profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") + config = Builders.AgentConfig.with_default_profile(profile).build() + + agent = Agent(config=config) + await agent.init() + + # 2. Run task using 'flash' profile + logger.info("Starting Flash runner task execution...") + try: + result = await agent.run_task( + goal="Play a video in full screen on YouTube in Chrome", profile="flash" + ) + logger.info(f"Flash runner task execution finished! Result: {result}") + except Exception: + logger.exception("Error during Flash task execution:") + finally: + await agent.clean() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/test_ime.py b/tests/integration/test_ime.py new file mode 100644 index 0000000..be24341 --- /dev/null +++ b/tests/integration/test_ime.py @@ -0,0 +1,21 @@ +import time +import uiautomator2 as u2 + + +def test(): + device = u2.connect("emulator-5554") + print("Testing send_keys with immediate IME switch off") + device.set_fastinput_ime(True) + device.send_keys("Test 1") + device.set_fastinput_ime(False) + + time.sleep(2) + print("Testing send_keys with delay before IME switch off") + device.set_fastinput_ime(True) + device.send_keys("Test 2") + time.sleep(1.0) + device.set_fastinput_ime(False) + + +if __name__ == "__main__": + test() diff --git a/tests/integration/test_physical_actions.py b/tests/integration/test_physical_actions.py new file mode 100644 index 0000000..23f7588 --- /dev/null +++ b/tests/integration/test_physical_actions.py @@ -0,0 +1,102 @@ +# 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 pathlib import Path +import sys + +# Add root to sys.path +root_dir = Path(__file__).parent.parent +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + +from adbutils import AdbClient +from artemis.clients.ui_automator_client import UIAutomatorClient +from artemis.context import DeviceContext, DevicePlatform, ArtemisContext +from artemis.controllers.unified_controller import UnifiedMobileController + + +import pytest + + +@pytest.mark.asyncio +async def test_actions(): + print("=========================================") + print(" Physical Action Direct Validation Testbench") + print("=========================================") + adb = AdbClient(host="localhost", port=5037) + devices = adb.device_list() + if not devices: + print("❌ Error: No active Android emulator or physical device detected.") + return + device_id = devices[0].serial + print(f"✅ Target device locked: {device_id}") + + ui_client = UIAutomatorClient(device_id=device_id) + print("🔄 Connecting to UIAutomator2 and syncing screen properties...") + try: + ui_data = ui_client.get_screen_data() + w, h = ui_data.width, ui_data.height + print(f"✅ Screen sync ready, physical resolution: {w}x{h}") + except Exception as e: + print(f"❌ Screen properties sync failed: {e}") + return + + ctx = ArtemisContext( + trace_id="physical-test-bench", + device=DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id=device_id, + device_width=w, + device_height=h, + ), + adb_client=adb, + ui_adb_client=ui_client, + ) + + ctrl = UnifiedMobileController(ctx) + + print("\n[Task 1] Extract Live UI Hierarchy...") + try: + elems = await ctrl.get_ui_elements() + print(f"✅ Parsed UI element count: {len(elems)}") + except Exception as e: + print(f"❌ Failed to extract UI hierarchy: {e}") + + print("\n[Task 2] Capture Live Screenshot...") + try: + shot = await ctrl.take_screenshot() + print(f"✅ Screenshot captured, Base64 payload length: {len(shot)}") + except Exception as e: + print(f"❌ Failed to capture screenshot: {e}") + + print("\n[Task 3] Simulate Drag/Swipe Down...") + try: + # Swipe up from bottom to top + err = await ctrl.swipe_coords(w // 2, int(h * 0.8), w // 2, int(h * 0.2), duration=400) + if err: + print(f"❌ Swipe rejected by controller: {err}") + else: + print("✅ Swipe command injected successfully!") + except Exception as e: + print(f"❌ Swipe execution exception: {e}") + + print("\n🧹 Safely releasing connection and driver handles...") + await ctrl.cleanup() + print("🎉 Direct physical validation tests completed successfully!") + + +if __name__ == "__main__": + asyncio.run(test_actions()) diff --git a/tests/integration/test_reactive_runner_integration.py b/tests/integration/test_reactive_runner_integration.py new file mode 100644 index 0000000..c65c1d0 --- /dev/null +++ b/tests/integration/test_reactive_runner_integration.py @@ -0,0 +1,45 @@ +# 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. + +"""Integration tests for ReactiveRunner and Pipeline.""" + +import pytest +from artemis.core.context import ExecutionContext +from artemis.core.state import ExecutionStatus +from artemis.drivers.mock.mock_driver import MockDeviceDriver +from artemis.engine.pipeline import Pipeline +from artemis.engine.reactive_runner import ReactiveRunner + + +@pytest.mark.asyncio +async def test_reactive_runner_execution(): + """Verify ReactiveRunner executes workflow cycles on mock driver.""" + ctx = ExecutionContext(task_goal="Turn on Bluetooth in Settings", device_id="test-emulator") + driver = MockDeviceDriver(device_id="test-emulator") + + runner = ReactiveRunner(ctx=ctx, driver=driver) + state = await runner.run(max_turns=5) + + assert state.status == ExecutionStatus.SUCCESS + assert state.current_turn >= 1 + assert len(state.steps) >= 1 + assert state.steps[0].action_name == "wait_for_delay" + + +@pytest.mark.asyncio +async def test_pipeline_dispatch(): + """Verify Pipeline executes with default mock driver.""" + state = await Pipeline.execute(goal="Open YouTube and play video", profile="flash") + assert state.status == ExecutionStatus.SUCCESS + assert state.task_goal == "Open YouTube and play video" diff --git a/tests/tools/checker/test_checker_tools.py b/tests/tools/checker/test_checker_tools.py new file mode 100644 index 0000000..67d0b66 --- /dev/null +++ b/tests/tools/checker/test_checker_tools.py @@ -0,0 +1,21 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for checker tools. + +The Checker agent does not define its own specific exposed tools for testing. +It primarily reuses common tools (e.g. from scratchpad) and its behavior is +tested at the agent level. Therefore, there are no specific checker tools to +test here. +""" diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py new file mode 100644 index 0000000..a5407a6 --- /dev/null +++ b/tests/tools/conftest.py @@ -0,0 +1,95 @@ +# 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 pathlib import Path +from unittest.mock import MagicMock + +from artemis.context import ArtemisContext +from artemis.data_engine.engine import DataEngine +import pytest + +INPUTS_DIR = Path(__file__).parent / "inputs" + + +@pytest.fixture(scope="session") +def inputs_dir(): + return INPUTS_DIR + + +@pytest.fixture +def mock_adb_client(): + client = MagicMock() + # Dummy outputs for commands and physical interactions that tools might use + client.shell.return_value = "dummy adb output" + # Basic UI hierarchy for mobile read_hierarchy tool + client.get_hierarchy.return_value = ( + "" + ) + return client + + +@pytest.fixture +def artemis_context(inputs_dir, mock_adb_client): + """Provides a ArtemisContext with loaded database and actual side-effects, + + except the ADB connection is customized to read from fixtures instead of a + real device. + """ + ctx = MagicMock(spec=ArtemisContext) + + ctx.execution_setup = MagicMock() + ctx.execution_setup.traces_path = str(inputs_dir) + + # Initialize real DataEngine if the fixture database exists + db_path = inputs_dir / "data_engine.db" + if db_path.exists(): + engine = DataEngine(ctx) + engine.start_session(goal="Test goal") + import uuid + + engine.current_step_id = uuid.uuid4() + ctx.data_engine = engine + else: + ctx.data_engine = None + + ctx.ui_adb_client = mock_adb_client + + # Fake device representation + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + + # Trace output directory setup to point inside inputs or a dedicated test folder + trace_out = inputs_dir / "trace_output" + trace_out.mkdir(exist_ok=True) + ctx.trace_dir = trace_out + + ctx.agent_config = MagicMock() + ctx.agent_config.blacklisted_tools = {} + + return ctx + + +@pytest.fixture +def mock_state(inputs_dir): + """Provides a dummy graph State with realistic artifact paths for tools that need state injection.""" + from artemis.graph.state import State + + state = MagicMock(spec=State) + state.latest_screenshot = str(inputs_dir / "screenshot.jpg") + state.video_path = str(inputs_dir / "recording.mp4") + state.indexed_points = [] + state.indexed_elements = [] + return state diff --git a/tests/tools/diagnoser/test_diagnoser_tools.py b/tests/tools/diagnoser/test_diagnoser_tools.py new file mode 100644 index 0000000..554645a --- /dev/null +++ b/tests/tools/diagnoser/test_diagnoser_tools.py @@ -0,0 +1,128 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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 artemis.tools.diagnoser_submit_answer_tool import get_submit_answer_tool + + +def test_diagnoser_submit_answer_tool(artemis_context): + tool = get_submit_answer_tool(artemis_context) + + # Execute the tool with some typical payload + result = tool.invoke( + { + "analysis": "The problem is caused by a network timeout.", + "actionable_steps": ["Check network connection", "Restart the server"], + } + ) + + # Assert that the tool executes without raising errors and produces expected output structure + assert result == "Answer submitted successfully." + assert "error" not in result.lower() + + +def test_submit_answer_tool_subclass_and_registry(): + """Verify SubmitAnswerTool is an ArtemisTool subclass and registered in ToolRegistry.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.diagnoser_submit_answer_tool import ( + DiagnoserSubmitAnswerTool, + SubmitAnswer, + SubmitAnswerArgs, + SubmitAnswerTool, + submit_answer, + ) + + assert issubclass(SubmitAnswerTool, ArtemisTool) + assert issubclass(SubmitAnswer, ArtemisTool) + assert issubclass(DiagnoserSubmitAnswerTool, ArtemisTool) + assert isinstance(submit_answer, ArtemisTool) + assert isinstance(submit_answer, SubmitAnswerTool) + + assert submit_answer.name == "submit_answer" + assert submit_answer.category == "custom" + assert submit_answer.args_schema == SubmitAnswerArgs + + # Registry lookup + reg_tool = ToolRegistry.get("submit_answer") + assert reg_tool is not None + assert isinstance(reg_tool, SubmitAnswerTool) + + # GenAI FunctionDeclaration export + declaration = submit_answer.to_genai_declaration() + assert declaration.name == "submit_answer" + assert "analysis" in declaration.parameters.properties + assert "actionable_steps" in declaration.parameters.properties + + +def test_ask_diagnoser_tool_subclass_and_registry(): + """Verify AskDiagnoserTool is an ArtemisTool subclass and registered in ToolRegistry.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.diagnostic_tool import ( + AskDiagnoser, + AskDiagnoserArgs, + AskDiagnoserTool, + DiagnosticTool, + ask_diagnoser, + ) + + assert issubclass(AskDiagnoserTool, ArtemisTool) + assert issubclass(AskDiagnoser, ArtemisTool) + assert issubclass(DiagnosticTool, ArtemisTool) + assert isinstance(ask_diagnoser, ArtemisTool) + assert isinstance(ask_diagnoser, AskDiagnoserTool) + + assert ask_diagnoser.name == "ask_diagnoser" + assert ask_diagnoser.category == "custom" + assert ask_diagnoser.args_schema == AskDiagnoserArgs + + # Registry lookup + reg_tool = ToolRegistry.get("ask_diagnoser") + assert reg_tool is not None + assert isinstance(reg_tool, AskDiagnoserTool) + + # GenAI FunctionDeclaration export + declaration = ask_diagnoser.to_genai_declaration() + assert declaration.name == "ask_diagnoser" + assert "query" in declaration.parameters.properties + + +def test_analyze_logs_tool_subclass_and_registry(): + """Verify AnalyzeLogsTool is an ArtemisTool subclass and registered in ToolRegistry.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.log_tool import ( + AnalyzeLogs, + AnalyzeLogsArgs, + AnalyzeLogsTool, + LogTool, + analyze_logs, + ) + + assert issubclass(AnalyzeLogsTool, ArtemisTool) + assert issubclass(AnalyzeLogs, ArtemisTool) + assert issubclass(LogTool, ArtemisTool) + assert isinstance(analyze_logs, ArtemisTool) + assert isinstance(analyze_logs, AnalyzeLogsTool) + + assert analyze_logs.name == "analyze_logs" + assert analyze_logs.category == "custom" + assert analyze_logs.args_schema == AnalyzeLogsArgs + + # Registry lookup + reg_tool = ToolRegistry.get("analyze_logs") + assert reg_tool is not None + assert isinstance(reg_tool, AnalyzeLogsTool) + + # GenAI FunctionDeclaration export + declaration = analyze_logs.to_genai_declaration() + assert declaration.name == "analyze_logs" + assert "specific_query" in declaration.parameters.properties diff --git a/tests/tools/explorer/test_explorer_tools.py b/tests/tools/explorer/test_explorer_tools.py new file mode 100644 index 0000000..811fe36 --- /dev/null +++ b/tests/tools/explorer/test_explorer_tools.py @@ -0,0 +1,83 @@ +# 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 artemis.agents.explorer.explorer import Explorer +import pytest + + +@pytest.fixture +def explorer_instance(artemis_context, mock_state): + explorer = Explorer(artemis_context) + explorer.image_name = "test_img" + explorer.screenshot_path = mock_state.latest_screenshot + + # Initialize the image pool with img_0 required by processing tools + explorer.image_pool = { + "img_0": { + "path": mock_state.latest_screenshot, + "transform": { + "offset_x": 0.0, + "offset_y": 0.0, + "scale_x": 1.0, + "scale_y": 1.0, + }, + } + } + return explorer + + +@pytest.mark.asyncio +async def test_exec_detect_objects(explorer_instance): + result = await explorer_instance.exec_detect_objects( + ["button", "text"], target_image_id="img_0" + ) + assert isinstance(result, dict) + assert "text" in result + assert "image_path" in result + + +@pytest.mark.asyncio +async def test_exec_ask_perception_tool(explorer_instance): + result = await explorer_instance.exec_ask_perception_tool("login", 500, 500, ["button"]) + assert isinstance(result, dict) + assert "text" in result + assert "image_paths" in result + + +@pytest.mark.asyncio +async def test_exec_get_ocr_list(explorer_instance): + result = await explorer_instance.exec_get_ocr_list() + assert isinstance(result, dict) + assert "text" in result + assert "image_path" in result + + +@pytest.mark.asyncio +async def test_exec_inspect_region(explorer_instance): + # Depending on cv2 logic, it might return an error string if coordinates are out of bounds or image is missing + # But it shouldn't raise unhandled exceptions + result = await explorer_instance.exec_inspect_region(100, 100, 400, 400, 2.0) + assert isinstance(result, dict) + assert "text" in result + assert "image_path" in result + + +@pytest.mark.asyncio +async def test_exec_ask_image_processor(explorer_instance): + result = await explorer_instance.exec_ask_image_processor( + "Invert colors", target_image_id="img_0" + ) + assert isinstance(result, dict) + assert "text" in result + assert "image_paths" in result diff --git a/tests/tools/history_analyzer/test_history_analyzer_tools.py b/tests/tools/history_analyzer/test_history_analyzer_tools.py new file mode 100644 index 0000000..0add9d3 --- /dev/null +++ b/tests/tools/history_analyzer/test_history_analyzer_tools.py @@ -0,0 +1,101 @@ +# 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 json +from artemis.agents.history_analyzer.history_analyzer import HistoryAnalyzer +from artemis.config import parse_llm_config +import pytest + + +@pytest.mark.asyncio +async def test_exec_get_step_details(artemis_context): + """Test the exec_get_step_details method with manually configured steps.""" + analyzer = HistoryAnalyzer(artemis_context) + analyzer.history_steps = [ + { + "step_number": 0, + "relative_time": 0.0, + "summary": "Initial step", + "action_taken": "none", + "operator_raw_thinking": "Starting up", + "last_execution_result": "Success", + }, + { + "step_number": 1, + "relative_time": 5.0, + "summary": "Second step", + "action_taken": "click", + "operator_raw_thinking": "Clicking button", + "last_execution_result": "Success", + }, + ] + + first_step_num = 0 + result = analyzer.exec_get_step_details(first_step_num, first_step_num) + + assert "Error:" not in result + assert "No steps found" not in result + + parsed = json.loads(result) + assert isinstance(parsed, list) + assert len(parsed) == 1 + assert parsed[0]["step_number"] == first_step_num + assert "summary" in parsed[0] + + +@pytest.mark.asyncio +async def test_get_step_details_tool_with_fixture(artemis_context): + """Test _get_step_details_tool against real steps loaded from the inputs/data_engine.db fixture.""" + if not artemis_context.data_engine: + pytest.skip("DataEngine fixture not initialized") + + artemis_context.data_engine.current_session_id = "1a7fc344-ea69-4b1a-b066-d8a667502b8c" + steps = artemis_context.data_engine.get_agent_friendly_steps() + assert len(steps) > 0 + + analyzer = HistoryAnalyzer(artemis_context) + tool = analyzer._get_step_details_tool(steps) + + result = tool.invoke({"start_step": 1, "end_step": 3}) + assert isinstance(result, str) + assert "Error:" not in result + assert "No steps found" not in result + + parsed = json.loads(result) + assert isinstance(parsed, list) + assert len(parsed) == 3 + assert parsed[0]["step_number"] == 1 + assert "YouTube" in parsed[0]["summary"] + + +@pytest.mark.asyncio +async def test_history_analyzer_blackbox_run(artemis_context): + """End-to-end blackbox test of HistoryAnalyzer.run(query) using real steps from inputs/data_engine.db and real LLM calls.""" + if not artemis_context.data_engine: + pytest.skip("DataEngine fixture not initialized") + + artemis_context.llm_config = parse_llm_config() + artemis_context.data_engine.current_session_id = "1a7fc344-ea69-4b1a-b066-d8a667502b8c" + + analyzer = HistoryAnalyzer(artemis_context) + query = ( + "What application did the operator open and what query did they search" + " for? Briefly summarize the session steps." + ) + result = await analyzer.run(query) + + assert isinstance(result, str) + assert len(result) > 0 + assert "Error:" not in result + assert "No history recorded" not in result diff --git a/tests/tools/hopper/test_hopper_tools.py b/tests/tools/hopper/test_hopper_tools.py new file mode 100644 index 0000000..deb7fb7 --- /dev/null +++ b/tests/tools/hopper/test_hopper_tools.py @@ -0,0 +1,19 @@ +# 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. + +"""This file is intentionally empty. + +The hopper agent does not expose any tools directly. It is invoked +programmatically by other tools (e.g., launch_app_tool) as an LLM utility. +""" diff --git a/tests/tools/image_processor/test_image_processor_tools.py b/tests/tools/image_processor/test_image_processor_tools.py new file mode 100644 index 0000000..1619c34 --- /dev/null +++ b/tests/tools/image_processor/test_image_processor_tools.py @@ -0,0 +1,43 @@ +# 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 artemis.tools.image_processor_tool import get_ask_image_processor_tool +import pytest + + +@pytest.mark.asyncio +async def test_ask_image_processor_tool(artemis_context, mock_state): + """Tests the ask_image_processor tool as a blackbox. + + Allows actual side effects and full lifecycle execution (including real LLM + API calls). Uses shared pytest fixtures for realistic context. + """ + tool = get_ask_image_processor_tool(artemis_context) + + # Passing a simple visual reasoning instruction. + # The tool will use target_image_id="img_0" corresponding to the artifact in mock_state. + result = await tool.ainvoke( + { + "instruction": "Identify the primary colors present in this image.", + "target_image_id": "img_0", + "state": mock_state, + } + ) + + # Assert execution without errors and produces expected output structure + assert result is not None + assert isinstance(result, dict) + assert "outputs" in result + assert "summary" in result + assert isinstance(result["outputs"], list) diff --git a/tests/tools/inputs/audio_recording.mp3 b/tests/tools/inputs/audio_recording.mp3 new file mode 100644 index 0000000..8124f85 Binary files /dev/null and b/tests/tools/inputs/audio_recording.mp3 differ diff --git a/tests/tools/log_analyzer/test_log_analyzer_tools.py b/tests/tools/log_analyzer/test_log_analyzer_tools.py new file mode 100644 index 0000000..bd63a9b --- /dev/null +++ b/tests/tools/log_analyzer/test_log_analyzer_tools.py @@ -0,0 +1,61 @@ +# 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 artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode +import pytest + + +@pytest.mark.asyncio +async def test_spawn_log_reader(artemis_context, mock_state): + """Test the spawn_log_reader tool exposed by LogAnalyzerNode.""" + from artemis.sdk.utils import load_llm_config_override + from pathlib import Path + + node = LogAnalyzerNode(ctx=artemis_context) + artemis_context.llm_config = load_llm_config_override(Path("llm-config.json")) + if artemis_context.data_engine: + artemis_context.data_engine.start_session("test_session") + tool = node._get_spawn_log_reader_tool() + + # Let the tool run its lifecycle. It will use the real LLM APIs. + # Note: State injection is normally handled by LangGraph/tool invocation, + # but since we are testing the coroutine directly, we can pass it manually. + result = await tool.coroutine( + specific_query=("Find any errors related to network timeout in the logs."), + state=mock_state, + ) + + assert result is not None + assert isinstance(result, (str, list)) + assert len(result) > 0 + + +@pytest.mark.asyncio +async def test_log_analyzer_run(artemis_context, mock_state): + """Test LogAnalyzerNode.run end-to-end execution with grounding configuration.""" + from artemis.config.llm import parse_llm_config + + node = LogAnalyzerNode(ctx=artemis_context) + artemis_context.llm_config = parse_llm_config() + if artemis_context.data_engine: + artemis_context.data_engine.start_session("test_log_analyzer_session") + + result = await node.run( + prompt="Analyze recent error logs for potential crash or exception tags.", + state=mock_state, + ) + + assert result is not None + assert isinstance(result, (str, list)) + assert len(result) > 0 diff --git a/tests/tools/object_detector/test_object_detector_tools.py b/tests/tools/object_detector/test_object_detector_tools.py new file mode 100644 index 0000000..48ffd63 --- /dev/null +++ b/tests/tools/object_detector/test_object_detector_tools.py @@ -0,0 +1,58 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for object_detector universal tools.""" + +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.object_detection_tool import ( + ObjectDetection, + ObjectDetectionArgs, + ObjectDetectionTool, + ObjectDetectorTool, + OperatorObjectDetection, + OperatorObjectDetectionArgs, + OperatorObjectDetectionTool, + OperatorObjectDetectorTool, + object_detection, + operator_object_detection, +) + + +def test_object_detector_tool_subclass_and_registry(): + """Verify ObjectDetectionTool is an ArtemisTool subclass and registered in ToolRegistry.""" + assert issubclass(ObjectDetectionTool, ArtemisTool) + assert issubclass(ObjectDetection, ArtemisTool) + assert issubclass(ObjectDetectorTool, ArtemisTool) + assert issubclass(OperatorObjectDetectionTool, ArtemisTool) + assert issubclass(OperatorObjectDetectionTool, ObjectDetectionTool) + assert issubclass(OperatorObjectDetection, ArtemisTool) + assert issubclass(OperatorObjectDetectorTool, ArtemisTool) + + assert isinstance(object_detection, ArtemisTool) + assert isinstance(object_detection, ObjectDetectionTool) + assert isinstance(operator_object_detection, ArtemisTool) + assert isinstance(operator_object_detection, OperatorObjectDetectionTool) + + assert object_detection.name == "object_detection" + assert object_detection.category == "perception" + assert object_detection.args_schema == ObjectDetectionArgs + + assert operator_object_detection.name == "object_detection" + assert operator_object_detection.category == "perception" + assert operator_object_detection.args_schema == OperatorObjectDetectionArgs + + # Registry lookup + reg_tool = ToolRegistry.get("object_detection") + assert reg_tool is not None + assert isinstance(reg_tool, ObjectDetectionTool) diff --git a/tests/tools/operator/test_operator_tools.py b/tests/tools/operator/test_operator_tools.py new file mode 100644 index 0000000..d5c2cce --- /dev/null +++ b/tests/tools/operator/test_operator_tools.py @@ -0,0 +1,68 @@ +# 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 langchain_core.tools import BaseTool +from artemis.agents.operator.operator import OperatorNode +import pytest + + +@pytest.mark.asyncio +async def test_operator_tools(artemis_context, mock_state): + """Test all tools exposed by the OperatorNode.""" + node = OperatorNode(ctx=artemis_context) + + click_tool = node._get_click_tool() + assert isinstance(click_tool, BaseTool) + res = click_tool.invoke({"target": 1, "times": 1, "delay_ms": 100}) + assert res == "Action Recorded" + + input_text_tool = node._get_input_text_tool() + assert isinstance(input_text_tool, BaseTool) + res = input_text_tool.invoke({"text": "hello", "target": 1, "clear_exist": True}) + assert res == "Action Recorded" + + swipe_tool = node._get_swipe_tool() + assert isinstance(swipe_tool, BaseTool) + res = swipe_tool.invoke({"gesture": "up"}) + assert res == "Action Recorded" + + press_key_tool = node._get_press_key_tool() + assert isinstance(press_key_tool, BaseTool) + res = press_key_tool.invoke({"key": "ENTER"}) + assert res == "Action Recorded" + + manage_app_tool = node._get_manage_app_tool() + assert isinstance(manage_app_tool, BaseTool) + res = manage_app_tool.invoke({"action": "launch", "app_name": "Settings"}) + assert res == "Action Recorded" + + wait_for_delay_tool = node._get_wait_for_delay_tool() + assert isinstance(wait_for_delay_tool, BaseTool) + res = wait_for_delay_tool.invoke({"time_in_ms": 1000}) + assert res == "Action Recorded" + + long_press_tool = node._get_long_press_tool() + assert isinstance(long_press_tool, BaseTool) + res = long_press_tool.invoke({"target": 1, "duration": 1000}) + assert res == "Action Recorded" + + wait_for_text_tool = node._get_wait_for_text_tool() + assert isinstance(wait_for_text_tool, BaseTool) + res = wait_for_text_tool.invoke({"text": "Success", "state": "visible", "timeout_ms": 5000}) + assert res == "Action Recorded" + + reply_tool = node._get_reply_to_checker_tool() + assert isinstance(reply_tool, BaseTool) + res = reply_tool.invoke({"reasoning": "This is a test reply."}) + assert isinstance(res, str) diff --git a/tests/tools/outputter/test_outputter_tools.py b/tests/tools/outputter/test_outputter_tools.py new file mode 100644 index 0000000..dca9fdf --- /dev/null +++ b/tests/tools/outputter/test_outputter_tools.py @@ -0,0 +1,61 @@ +# 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 artemis.agents.outputter.tools import ( + get_search_history_tool, + get_step_details_tool, + get_step_screenshot_tool, +) +from artemis.context import ArtemisContext +import pytest + + +@pytest.fixture +def history_steps(artemis_context: ArtemisContext): + if artemis_context.data_engine: + return artemis_context.data_engine.get_agent_friendly_steps() + return [] + + +def test_get_step_details_tool(history_steps): + """Test get_step_details_tool to ensure it runs without errors.""" + tool = get_step_details_tool(history_steps) + result = tool.invoke({"start_step": 1, "end_step": 5}) + + assert isinstance(result, str) + assert len(result) > 0 + + +def test_get_step_screenshot_tool(artemis_context: ArtemisContext, history_steps): + """Test get_step_screenshot_tool to ensure it handles requests gracefully.""" + tool = get_step_screenshot_tool(artemis_context, history_steps) + + result = tool.invoke({"step_number": 1}) + + if isinstance(result, list): + assert len(result) == 2 + assert result[0]["type"] == "text" + assert result[1]["type"] == "image_url" + else: + assert isinstance(result, str) + assert len(result) > 0 + + +def test_get_search_history_tool(artemis_context: ArtemisContext): + """Test get_search_history_tool to ensure it can search through history.""" + tool = get_search_history_tool(artemis_context) + result = tool.invoke({"query": "test"}) + + assert isinstance(result, str) + assert len(result) > 0 diff --git a/tests/tools/planner/test_planner_tools.py b/tests/tools/planner/test_planner_tools.py new file mode 100644 index 0000000..2954348 --- /dev/null +++ b/tests/tools/planner/test_planner_tools.py @@ -0,0 +1,17 @@ +# 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. + + +# The planner agent does not have exposed tools of its own to test here. +# It uses shared tools from artemis.tools.scratchpad, which are tested in their respective modules. diff --git a/tests/tools/summarizer/test_summarizer_tools.py b/tests/tools/summarizer/test_summarizer_tools.py new file mode 100644 index 0000000..4fe4350 --- /dev/null +++ b/tests/tools/summarizer/test_summarizer_tools.py @@ -0,0 +1,16 @@ +# 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. + + +# The summarizer agent does not have exposed tools of its own to test here. diff --git a/tests/tools/validator/test_validator_tools.py b/tests/tools/validator/test_validator_tools.py new file mode 100644 index 0000000..89b9627 --- /dev/null +++ b/tests/tools/validator/test_validator_tools.py @@ -0,0 +1,75 @@ +# 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 base64 +from pathlib import Path + +from artemis.agents.validator.failure_analyzer import ValidationErrorCategory +from artemis.agents.validator.validator import ValidatorNode +import pytest + + +@pytest.mark.asyncio +async def test_safety_net_validation(artemis_context, mock_state): + """Test the safety_net_validation tool.""" + node = ValidatorNode(artemis_context) + + action_item = { + "action": "tap", + "coordinates": [100, 200], + "target_text": "Submit", + } + + # We pass session=None so it relies on fallback or fails gracefully + # We assert it runs without unhandled exceptions. + try: + passed, category, reason = await node._validate_action_precondition( + session=None, action_item=action_item, state=mock_state + ) + except Exception as e: + pytest.fail(f"Tool raised an exception: {e}") + + assert isinstance(passed, bool) + assert isinstance(category, ValidationErrorCategory) + assert isinstance(reason, str) + + +@pytest.mark.asyncio +async def test_safety_net_pixel_validation(artemis_context, mock_state): + """Test the safety_net_pixel_validation tool.""" + node = ValidatorNode(artemis_context) + + action_item = {"action": "tap", "coordinates": [100, 200]} + + # Read the real screenshot from the mock_state to provide as pre_screenshot_b64 + screenshot_path = mock_state.latest_screenshot + pre_screenshot_b64 = "dummy_base64" + if screenshot_path and Path(screenshot_path).exists(): + with open(screenshot_path, "rb") as f: + pre_screenshot_b64 = base64.b64encode(f.read()).decode("utf-8") + + try: + passed, category, reason = await node._validate_action_precondition_pixel( + session=None, + action_item=action_item, + pre_screenshot_b64=pre_screenshot_b64, + original_coords=[100, 200], + state=mock_state, + ) + except Exception as e: + pytest.fail(f"Tool raised an exception: {e}") + + assert isinstance(passed, bool) + assert isinstance(category, ValidationErrorCategory) + assert isinstance(reason, str) diff --git a/tests/tools/video_analyzer/test_video_analyzer_tools.py b/tests/tools/video_analyzer/test_video_analyzer_tools.py new file mode 100644 index 0000000..a4c668c --- /dev/null +++ b/tests/tools/video_analyzer/test_video_analyzer_tools.py @@ -0,0 +1,103 @@ +# 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 pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer + + +@pytest.mark.asyncio +async def test_video_analyzer_tools(artemis_context, mock_state, inputs_dir): + artemis_context.device.mobile_platform = "android" + artemis_context.adb_client = artemis_context.ui_adb_client + + agent = VideoAnalyzer(ctx=artemis_context) + + agent.local_files_to_cleanup = set() + agent.local_dirs_to_cleanup = set() + agent.cloud_files_to_cleanup = set() + 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" + + # We must provide a valid FunctionDeclaration for the tool otherwise Pydantic will complain + from google.genai import types + + agent.submit_answer_tool = types.FunctionDeclaration( + name="submit_answer", + description=("Submit the final findings for the requested segment analysis."), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "summary": types.Schema(type=types.Type.STRING, description="summary"), + "analysis": types.Schema(type=types.Type.STRING, description="analysis"), + "timeline_events": types.Schema( + type=types.Type.ARRAY, + items=types.Schema( + type=types.Type.OBJECT, + properties={ + "start_time": types.Schema(type=types.Type.NUMBER), + "end_time": types.Schema(type=types.Type.NUMBER), + "transcription": types.Schema(type=types.Type.STRING), + "confidence_score": types.Schema(type=types.Type.NUMBER), + "verification_timestamp_secs": types.Schema(type=types.Type.NUMBER), + }, + ), + ), + }, + ), + ) + + import google.genai as genai + from artemis.config import settings + + agent.client = genai.Client( + api_key=settings.GOOGLE_API_KEY.get_secret_value() if settings.GOOGLE_API_KEY else None + ) + artemis_context._genai_client = agent.client + + mock_session = MagicMock() + mock_session.local_video_path = Path(inputs_dir) / "recording.mp4" + mock_session.start_time = 0.0 + mock_session.data_engine_start_time = None + mock_session.process = MagicMock() + mock_session.process.returncode = None + mock_session.android_video_segments = [] + mock_session.android_segment_index = 0 + + from unittest.mock import AsyncMock + + sample_video = Path(inputs_dir) / "recording.mp4" + mock_driver = MagicMock() + mock_driver.stop_video_recording = AsyncMock(return_value=sample_video) + artemis_context._active_driver = mock_driver + + with patch( + "artemis.controllers.android_controller.get_active_session", + return_value=mock_session, + ): + result = await agent.exec_extract_segment_metadata(start_time=0.0, end_time=1.0) + assert isinstance(result, str) + assert len(result) > 0 + + # Test sub-agent with extremely short audio query to not waste resources and time + audio_result = await agent.exec_analyze_audio_only( + start_time=0.0, + end_time=1.0, + specific_query="Reply with a short summary only.", + ) + assert isinstance(audio_result, str) diff --git a/tests/unit/agents/test_checker.py b/tests/unit/agents/test_checker.py new file mode 100644 index 0000000..11d964c --- /dev/null +++ b/tests/unit/agents/test_checker.py @@ -0,0 +1,98 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.agents.checker.checker import CheckerResult, run_async_check +from artemis.context import ArtemisContext +import pytest + + +@pytest.mark.asyncio +async def test_run_async_check(tmp_path): + notes_dir = tmp_path / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + + # Put a dummy task plan file + (notes_dir / "task_plan.md").write_text("Plan") + + # Put a dummy verification_chat file + import hashlib + + subgoal_text = "Open WhatsApp" + s_hash = hashlib.md5(subgoal_text.encode("utf-8")).hexdigest() + (notes_dir / f"verification_chat_{s_hash}.json").write_text("[]") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(tmp_path) + mock_ctx.data_engine.get_agent_friendly_steps.return_value = [] + mock_ctx.execution_setup = None + + # Mock controller for screen data + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="dummy_screenshot_base64") + + from artemis.controllers.device_controller import ScreenDataResponse + + mock_screen_data = ScreenDataResponse( + base64="dummy_screenshot_base64", + elements=[], + width=1080, + height=2400, + platform="android", + ) + mock_controller.get_screen_data = AsyncMock(return_value=mock_screen_data) + + # Mock LLM response with with_structured_output + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Task completed successfully." + mock_response.tool_calls = [] + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + mock_llm.bind_tools.return_value = mock_llm + + mock_structured_llm = MagicMock() + mock_structured_llm.ainvoke = AsyncMock( + return_value=CheckerResult(success=True, reason="Task completed successfully.") + ) + mock_llm.with_structured_output.return_value = mock_structured_llm + + with ( + patch( + "artemis.agents.checker.checker.UnifiedMobileController", + return_value=mock_controller, + ), + patch("artemis.agents.checker.checker.get_llm", return_value=mock_llm), + patch( + "artemis.utils.task_tree.build_plan_and_history", + return_value="Task tree", + ), + ): + result = await run_async_check( + mock_ctx, + subgoal_text=subgoal_text, + subgoal_hash=s_hash, + raw_perception_data={"screenshot_b64": "dummy", "width": 1080, "height": 2400}, + latest_ui_hierarchy=[], + ) + + assert result["status"] == "success" + assert result["reason"] == "Task completed successfully." + assert mock_llm.astream.called + assert mock_llm.with_structured_output.called diff --git a/tests/unit/agents/test_diagnoser.py b/tests/unit/agents/test_diagnoser.py new file mode 100644 index 0000000..34617de --- /dev/null +++ b/tests/unit/agents/test_diagnoser.py @@ -0,0 +1,308 @@ +# 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 AsyncMock, Mock, patch +from langchain_core.messages import AIMessage, ToolMessage +from langgraph.types import Command +from artemis.agents.diagnoser.diagnoser import Diagnoser +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +import pytest + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + ctx.data_engine = Mock() + ctx.data_engine.base_dir = "/tmp" + ctx.data_engine.get_agent_friendly_steps.return_value = [] + + # Mock execution_setup + mock_setup = Mock() + mock_setup.video_recording_tools_enabled = True + ctx.execution_setup = mock_setup + + return ctx + + +@pytest.fixture +def mock_state(): + state = Mock(spec=State) + state.initial_goal = "Test goal" + state.latest_screenshot = None + state.latest_ui_hierarchy = [] + return state + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") +@pytest.mark.asyncio +async def test_diagnostic_agent_simple_call( + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test that DiagnosticAgent calls LLM and returns result.""" + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response = AIMessage(content="I diagnosed the issue: it is a network error.") + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tools + mock_video_tool = Mock() + mock_video_tool.name = "video_analyzer" + mock_get_video_tool.return_value = mock_video_tool + + mock_log_tool = Mock() + mock_log_tool.name = "analyze_logs" + mock_get_log_tool.return_value = mock_log_tool + + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose why the app crashed", mock_state) + + assert result == "I diagnosed the issue: it is a network error." + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") +@pytest.mark.asyncio +async def test_diagnostic_agent_tool_calls( + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test that DiagnosticAgent handles tool calls.""" + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + # Simulate response with tool calls + mock_response = AIMessage( + content="I need to check logs and video.", + tool_calls=[ + { + "name": "analyze_logs", + "args": {"specific_query": "Check for crashes"}, + "id": "call_1", + }, + { + "name": "video_analyzer", + "args": { + "time_description": "around 10s", + "purpose": "Check UI", + }, + "id": "call_2", + }, + ], + ) + # Second call returns final answer + mock_final_response = AIMessage(content="Diagnosis complete.") + + responses = [mock_response, mock_final_response] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tools + mock_log_tool = AsyncMock() + mock_log_tool.name = "analyze_logs" + mock_log_tool.args = {"specific_query": None, "state": None} + mock_log_tool.ainvoke.return_value = "Logs show no crash." + mock_get_log_tool.return_value = mock_log_tool + + mock_video_tool = AsyncMock() + mock_video_tool.name = "video_analyzer" + mock_video_tool.args = { + "time_description": None, + "purpose": None, + "state": None, + } + mock_command = Command( + update={ + VALIDATOR_MESSAGES_KEY: [ + ToolMessage(content="Video shows UI loading.", tool_call_id="call_2") + ] + } + ) + mock_video_tool.ainvoke.return_value = mock_command + mock_get_video_tool.return_value = mock_video_tool + + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose issue", mock_state) + + assert result == "Diagnosis complete." + mock_log_tool.ainvoke.assert_called_once_with( + {"specific_query": "Check for crashes", "state": mock_state} + ) + mock_video_tool.ainvoke.assert_called_once() + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") +@pytest.mark.asyncio +async def test_diagnostic_agent_no_video_tool_when_disabled( + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test that Diagnoser does not bind video tool when disabled.""" + # Set video recording to False + mock_context.execution_setup.video_recording_tools_enabled = False + + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response = AIMessage(content="Diagnosis complete.") + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tools + mock_video_tool = Mock() + mock_video_tool.name = "video_analyzer" + mock_get_video_tool.return_value = mock_video_tool + + mock_log_tool = Mock() + mock_log_tool.name = "analyze_logs" + mock_get_log_tool.return_value = mock_log_tool + + agent = Diagnoser(mock_context) + await agent.run("Diagnose issue", mock_state) + + # Verify bind_tools was called + mock_llm.bind_tools.assert_called_once() + bound_tools = mock_llm.bind_tools.call_args[1]["tools"] + + # Verify video_analyzer is NOT in bound tools + tool_names = [t.name for t in bound_tools if hasattr(t, "name")] + assert "video_analyzer" not in tool_names + # Verify log tool IS in bound tools (should still be there) + assert "analyze_logs" in tool_names + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.tools.video_tool.VideoAnalyzer") +@pytest.mark.asyncio +async def test_diagnoser_background_job_lifecycle( + mock_video_analyzer_class, mock_get_llm, mock_state +): + # Setup context + ctx = ArtemisContext.model_construct( + device=Mock(), + llm_config=Mock(), + execution_setup=Mock(video_recording_tools_enabled=True), + ) + ctx.data_engine = None + + # The Mock VideoAnalyzer agent running inside the background job + mock_agent_instance = AsyncMock() + + async def mock_agent_run(time_desc, purpose): + return "Video shows step successfully completed.", "success" + + mock_agent_instance.run.side_effect = mock_agent_run + mock_video_analyzer_class.return_value = mock_agent_instance + + # Prepare LLM responses + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + # Turn 1: LLM requests tool call + mock_response_1 = AIMessage( + content="Checking the video first.", + tool_calls=[ + { + "name": "video_analyzer", + "args": { + "time_description": "from 0s to 5s", + "purpose": "check UI", + }, + "id": "call_video_1", + } + ], + ) + # Turn 2: LLM gets the initial job start message, continues, then background job finishes, and result is injected. + mock_response_2 = AIMessage(content="Final complete diagnosis.") + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock asanitize_update on mock_state + async def mock_asanitize_update(ctx, update, agent): + return update + + mock_state.asanitize_update = mock_asanitize_update + + # Instantiate the Diagnoser + agent = Diagnoser(ctx) + + # We want to use the actual video analyzer tool so it triggers the background task + from artemis.tools.video_tool import get_video_analyzer_tool + + real_video_tool = get_video_analyzer_tool(ctx, role="diagnoser") + + with patch( + "artemis.tools.log_tool.get_analyze_logs_tool", + return_value=Mock(name="analyze_logs"), + ): + with patch( + "artemis.tools.video_tool.get_video_analyzer_tool", + return_value=real_video_tool, + ): + result = await agent.run("Diagnose video issue", mock_state) + + assert result == "Final complete diagnosis." + # Verify job registry + assert len(ctx.background_jobs) == 1 + job_id = list(ctx.background_jobs.keys())[0] + job = ctx.background_jobs[job_id] + assert job["tool_call_id"] == "call_video_1" + assert job["status"] == "completed" + assert job["result"] == "Video shows step successfully completed." + assert job["consumed"] is True diff --git a/tests/unit/agents/test_diagnoser_failures.py b/tests/unit/agents/test_diagnoser_failures.py new file mode 100644 index 0000000..72a7390 --- /dev/null +++ b/tests/unit/agents/test_diagnoser_failures.py @@ -0,0 +1,343 @@ +# 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 AsyncMock, Mock, patch +from adbutils import AdbClient +from langchain_core.messages import AIMessage, ToolMessage +from artemis.agents.diagnoser.diagnoser import Diagnoser +from artemis.context import ArtemisContext +from artemis.graph.state import State +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def ensure_device_connected(): + try: + client = AdbClient() + if not client.device_list(): + pytest.skip("No active Android device/emulator found. Skipping diagnostic tool tests.") + except Exception as e: + pytest.skip(f"ADB Client connection failed: {e}. Skipping tests.") + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + ctx.data_engine = Mock() + ctx.data_engine.base_dir = "/tmp" + ctx.data_engine.get_agent_friendly_steps.return_value = [] + + # Mock execution_setup + mock_setup = Mock() + mock_setup.video_recording_tools_enabled = True + ctx.execution_setup = mock_setup + + return ctx + + +@pytest.fixture +def mock_state(): + state = Mock(spec=State) + state.initial_goal = "Test goal" + state.latest_screenshot = None + state.latest_ui_hierarchy = [] + return state + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.tools.video_tool.get_video_analyzer_tool") +@patch("artemis.tools.log_tool.get_analyze_logs_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") +@pytest.mark.asyncio +async def test_maestro_uninstall_timeout( + mock_get_ui_tool, + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test Maestro Uninstall Timeout / Blocker Failure.""" + # Mock LLM calls + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response_1 = AIMessage( + content="I need to check the UI hierarchy.", + tool_calls=[ + { + "name": "get_ui_hierarchy", + "args": {}, + "id": "call_hierarchy", + } + ], + ) + mock_response_2 = AIMessage( + content=( + "Diagnoser identifies the blocker failure and recommends restarting" + " ADB/uninstalling conflicting tools manually." + ) + ) + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tool + mock_ui_tool = AsyncMock() + mock_ui_tool.name = "get_ui_hierarchy" + mock_ui_tool.args = {} + mock_ui_tool.ainvoke.return_value = "Error: pm list packages timed out" + mock_get_ui_tool.return_value = mock_ui_tool + + # Run agent + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose why the app crashed", mock_state) + + assert "recommends restarting ADB" in result + assert "uninstalling conflicting tools" in result + + # Assert get_ui_hierarchy was called + mock_ui_tool.ainvoke.assert_called_once() + + # Assert that the ToolMessage was sent back to LLM + assert len(mock_llm.astream.call_args_list) == 2 + sent_messages = mock_llm.astream.call_args_list[1][0][0] + tool_message = sent_messages[-2] + assert isinstance(tool_message, ToolMessage) + assert tool_message.tool_call_id == "call_hierarchy" + assert tool_message.content == "Error: pm list packages timed out" + assert tool_message.status == "error" + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.tools.video_tool.get_video_analyzer_tool") +@patch("artemis.tools.log_tool.get_analyze_logs_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") +@pytest.mark.asyncio +async def test_uiautomator2_connection_lost( + mock_get_ui_tool, + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test UIAutomator2 Daemon Connection Lost.""" + # Mock LLM + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response_1 = AIMessage( + content="Let me check the UI hierarchy.", + tool_calls=[ + { + "name": "get_ui_hierarchy", + "args": {}, + "id": "call_hierarchy_lost", + } + ], + ) + mock_response_2 = AIMessage(content="Diagnoser reports connection loss with UIAutomator2.") + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tool to throw ConnectionError + mock_ui_tool = AsyncMock() + mock_ui_tool.name = "get_ui_hierarchy" + mock_ui_tool.args = {} + mock_ui_tool.ainvoke.side_effect = ConnectionError( + "UIAutomator2 connection lost, reconnecting..." + ) + mock_get_ui_tool.return_value = mock_ui_tool + + # Run agent + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose crash", mock_state) + + assert "reports connection loss" in result + + # Assert that tool error is wrapped and returned in the ToolMessage + assert len(mock_llm.astream.call_args_list) == 2 + sent_messages = mock_llm.astream.call_args_list[1][0][0] + tool_message = sent_messages[-2] + assert isinstance(tool_message, ToolMessage) + assert tool_message.tool_call_id == "call_hierarchy_lost" + assert "UIAutomator2 connection lost, reconnecting..." in tool_message.content + assert tool_message.status == "error" + + # Verify that the trace span recorded the failure + record_trace_calls = mock_context.data_engine.record_trace.call_args_list + tool_trace_calls = [c for c in record_trace_calls if c[1].get("name") == "get_ui_hierarchy"] + failed_trace = next((c for c in tool_trace_calls if c[1].get("status") == "failed"), None) + assert failed_trace is not None + assert "UIAutomator2 connection lost, reconnecting..." in failed_trace[1]["payload"]["error"] + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.tools.video_tool.get_video_analyzer_tool") +@patch("artemis.tools.log_tool.get_analyze_logs_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") +@pytest.mark.asyncio +async def test_ui_hierarchy_dump_stall( + mock_get_ui_tool, + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test UI Hierarchy Dump Stall (UI Thread Frozen).""" + # Mock LLM + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response_1 = AIMessage( + content="Let me fetch the UI hierarchy to see what's on screen.", + tool_calls=[ + { + "name": "get_ui_hierarchy", + "args": {}, + "id": "call_hierarchy_stall", + } + ], + ) + mock_response_2 = AIMessage( + content=( + "Diagnoser identifies frozen UI and suggests rendering-safe" + " recovery actions (like ADB wake commands or a wait/retry cycle)." + ) + ) + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tool + mock_ui_tool = AsyncMock() + mock_ui_tool.name = "get_ui_hierarchy" + mock_ui_tool.args = {} + mock_ui_tool.ainvoke.return_value = ( + "Error retrieving UI hierarchy: dump_hierarchy timed out after 3 seconds" + ) + mock_get_ui_tool.return_value = mock_ui_tool + + # Run agent + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose stall", mock_state) + + assert "rendering-safe recovery actions" in result + + # Assert ToolMessage was passed back with correct error details + assert len(mock_llm.astream.call_args_list) == 2 + sent_messages = mock_llm.astream.call_args_list[1][0][0] + tool_message = sent_messages[-2] + assert isinstance(tool_message, ToolMessage) + assert tool_message.tool_call_id == "call_hierarchy_stall" + assert "dump_hierarchy timed out after 3 seconds" in tool_message.content + assert tool_message.status == "error" + + +@patch("artemis.agents.diagnoser.diagnoser.get_llm") +@patch("artemis.tools.video_tool.get_video_analyzer_tool") +@patch("artemis.tools.log_tool.get_analyze_logs_tool") +@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") +@pytest.mark.asyncio +async def test_device_offline( + mock_get_ui_tool, + mock_get_log_tool, + mock_get_video_tool, + mock_get_llm, + mock_context, + mock_state, +): + """Test Device Offline (ADB Transport Lost).""" + # Mock LLM + mock_llm = Mock() + mock_llm.bind_tools.return_value = mock_llm + + mock_response_1 = AIMessage( + content="Let's grab the UI hierarchy.", + tool_calls=[ + { + "name": "get_ui_hierarchy", + "args": {}, + "id": "call_hierarchy_offline", + } + ], + ) + mock_response_2 = AIMessage( + content=("The device is offline. Diagnoser flags the offline device state.") + ) + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + mock_get_llm.return_value = mock_llm + + # Mock tool + mock_ui_tool = AsyncMock() + mock_ui_tool.name = "get_ui_hierarchy" + mock_ui_tool.args = {} + mock_ui_tool.ainvoke.side_effect = RuntimeError("device offline") + mock_get_ui_tool.return_value = mock_ui_tool + + # Run agent + agent = Diagnoser(mock_context) + result = await agent.run("Diagnose offline", mock_state) + + assert "flags the offline device state" in result + + # Assert ToolMessage was passed back with correct error details + assert len(mock_llm.astream.call_args_list) == 2 + sent_messages = mock_llm.astream.call_args_list[1][0][0] + tool_message = sent_messages[-2] + assert isinstance(tool_message, ToolMessage) + assert tool_message.tool_call_id == "call_hierarchy_offline" + assert "device offline" in tool_message.content + assert tool_message.status == "error" diff --git a/tests/unit/agents/test_explorer.py b/tests/unit/agents/test_explorer.py new file mode 100644 index 0000000..beaeaed --- /dev/null +++ b/tests/unit/agents/test_explorer.py @@ -0,0 +1,1270 @@ +# 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 + +os.environ["ARTEMIS_USE_FILE_API"] = "false" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import json +from google.genai import types + +from artemis.agents.explorer.explorer import Explorer +from artemis.context import ArtemisContext +from artemis.graph.state import State + +MOCK_PROMPT_JSON = """{ + "IDENTITY": "You are the agentic UI Explorer designed to identify objects, scan text, and extract UI attributes on a phone screen.", + "OPERATING PRINCIPLES": [ + "Confidence-Driven Cost-Benefit Strategy: Maximize localization accuracy...", + "Tool Capabilities: You have three classes of tools...", + "Parallel vs Sequential Invocations: You are fully supported..." + ] +}""" + + +@pytest.fixture(autouse=True) +def disable_file_api(monkeypatch): + monkeypatch.setenv("ARTEMIS_USE_FILE_API", "false") + + +@pytest.mark.asyncio +async def test_explorer_run(): + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + + # Mock Gemini Client and Response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Target button", + } + ], + "fallback_message": "", + } + mock_response.function_calls = [mock_func_call] + + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager + mock_storage = MagicMock() + mock_storage.get_image.return_value = "dummy_hash" + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + # Custom mock open to handle both binary and text reads + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + # Provide a sequence of bytes for iter(lambda: f.read(4096), b"") and b64 encode + mock_file.read.side_effect = [ + b"fake_image_data", + b"", + b"fake_image_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + result = await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + minimal_list="", + state=mock_state, + ) + + expected_outcome = json.dumps(mock_func_call.args, ensure_ascii=False) + assert result == expected_outcome + assert mock_client.aio.models.generate_content.called + + +@pytest.mark.asyncio +async def test_explorer_submit_answer(): + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + + # Mock Gemini Client and Function Call for submit_answer + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Target button", + } + ], + "fallback_message": "", + } + mock_response.function_calls = [mock_func_call] + + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager + mock_storage = MagicMock() + mock_storage.get_image.return_value = "dummy_hash" + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_data", + b"", + b"fake_image_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + result = await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + minimal_list="", + state=mock_state, + ) + + # Ensure it returned the structured JSON outcome + expected_outcome = json.dumps(mock_func_call.args, ensure_ascii=False) + assert result == expected_outcome + assert mock_client.aio.models.generate_content.called + + +@pytest.mark.asyncio +async def test_explorer_submit_answer_self_correction(): + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + + # Response 1: Returns invalid coords [1500, -200] (out of bounds) + mock_response1 = MagicMock() + mock_response1.text = "" + + mock_func_call1 = MagicMock() + mock_func_call1.name = "submit_answer" + mock_func_call1.args = { + "candidates": [ + { + "label": "S1", + "coords": [1500, -200], + "description": "Ambiguous button", + } + ] + } + + mock_response1.candidates = [ + MagicMock() + ] # Provide dummy candidate content for loop history tracking + mock_response1.candidates[0].content = types.Content( + role="model", + parts=[ + types.Part.from_function_call( + name="submit_answer", + args={ + "candidates": [ + { + "label": "S1", + "coords": [1500, -200], + "description": "Ambiguous button", + } + ] + }, + ) + ], + ) + mock_response1.function_calls = [mock_func_call1] + + # Response 2: Returns corrected valid coords [500, 500] + mock_response2 = MagicMock() + mock_response2.text = "" + + mock_func_call2 = MagicMock() + mock_func_call2.name = "submit_answer" + mock_func_call2.args = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Corrected button", + } + ] + } + + mock_response2.candidates = [MagicMock()] + mock_response2.candidates[0].content = types.Content( + role="model", + parts=[ + types.Part.from_function_call( + name="submit_answer", + args={ + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Corrected button", + } + ] + }, + ) + ], + ) + mock_response2.function_calls = [mock_func_call2] + + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[mock_response1, mock_response2] + ) + + # Mock storage manager + mock_storage = MagicMock() + mock_storage.get_image.return_value = "dummy_hash" + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_data", + b"", + b"fake_image_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + result = await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + minimal_list="", + state=mock_state, + ) + + # Assert loops successfully completed on the second corrected turn + expected_args = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Corrected button", + } + ] + } + assert result == json.dumps(expected_args, ensure_ascii=False) + assert mock_client.aio.models.generate_content.call_count == 2 + + +@pytest.mark.asyncio +async def test_explorer_initial_visual_marking(): + # Mock context and state with latest_ui_hierarchy + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + mock_state.latest_ui_hierarchy = [{"text": "Clickable Button", "bounds": "[100,100][200,200]"}] + + # Mock Gemini Client and response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] + } + mock_response.function_calls = [mock_func_call] + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager + mock_storage = MagicMock() + mock_storage.get_image.return_value = "dummy_hash" + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_marked_data", + b"", + b"fake_image_marked_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + 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), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + state=mock_state, + minimal_list="", + ) + + # Ensure draw_dots was successfully called with calculated center coordinates of bounds [100,100][200,200] -> [150, 150] + mock_draw_dots.assert_called_once() + points_arg = mock_draw_dots.call_args[0][1] + assert points_arg == [[150, 150]] + + labels_arg = mock_draw_dots.call_args[0][2] + assert labels_arg == ["1"] + + +@pytest.mark.asyncio +async def test_explorer_initial_visual_marking_previous_screenshot(): + from artemis.data_engine.models import ImageRecord + + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + # current screenshot is different + mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" + mock_state.latest_ui_hierarchy = [{"text": "Current Button", "bounds": "[300,300][400,400]"}] + + # Mock Gemini Client and response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] + } + mock_response.function_calls = [mock_func_call] + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager to return correct previous image record from DB + mock_storage = MagicMock() + + # Mock ImageRecord with the previous screenshot's UI hierarchy + mock_record = MagicMock(spec=ImageRecord) + mock_record.ui_tree = [{"text": "Previous Button", "bounds": "[100,100][200,200]"}] + mock_storage.get_image.return_value = mock_record + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_marked_data", + b"", + b"fake_image_marked_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + 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), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query="Find button", + context_feedback="", + # Pass previous screenshot path (different from state.latest_screenshot) + screenshot_path="/tmp/previous_screenshot.jpg", + state=mock_state, + minimal_list="", + ) + + # Ensure draw_dots was called with previous bounds [100,100][200,200] -> [150, 150] + # instead of current bounds [300,300][400,400] -> [350, 350] + mock_draw_dots.assert_called_once() + points_arg = mock_draw_dots.call_args[0][1] + assert points_arg == [[150, 150]] # Uses previous screenshot bounds! + assert points_arg != [[350, 350]] # Does not use current screenshot bounds! + + labels_arg = mock_draw_dots.call_args[0][2] + assert labels_arg == ["1"] + + +@pytest.mark.asyncio +async def test_explorer_initial_visual_marking_previous_screenshot_no_ui_tree(): + + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" + mock_state.latest_ui_hierarchy = [{"text": "Current Button", "bounds": "[300,300][400,400]"}] + + # Mock Gemini Client and response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] + } + mock_response.function_calls = [mock_func_call] + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager to return None (meaning not found in DB) + mock_storage = MagicMock() + mock_storage.get_image.return_value = None + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_raw_data", + b"", + b"fake_image_raw_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + 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), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query="Find button", + context_feedback="", + # Pass previous screenshot path (different from state.latest_screenshot) + screenshot_path="/tmp/previous_screenshot.jpg", + state=mock_state, + minimal_list="", + ) + + # Ensure draw_dots was NEVER called since no ui_tree was available for previous screenshot + mock_draw_dots.assert_not_called() + + +@pytest.mark.asyncio +async def test_explorer_initial_visual_marking_previous_screenshot_ocr_fusion(): + from artemis.data_engine.models import ImageRecord + + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" + + # Mock Gemini Client and response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] + } + mock_response.function_calls = [mock_func_call] + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager + mock_storage = MagicMock() + + # Mock ImageRecord with previous raw XML and raw OCR + mock_record = MagicMock(spec=ImageRecord) + mock_record.ui_tree = [{"text": "", "bounds": "[100,100][200,200]"}] + # First ocr_result is full screen text and ignored; second overlaps with bounds + mock_record.ocr_result = [ + {"text": "full screen", "position": [{"x": 0, "y": 0}]}, + { + "text": "OCR Text", + "position": [ + {"x": 110, "y": 110}, + {"x": 190, "y": 110}, + {"x": 190, "y": 190}, + {"x": 110, "y": 190}, + ], + }, + ] + mock_storage.get_image.return_value = mock_record + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_marked_data", + b"", + b"fake_image_marked_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + 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), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/previous_screenshot.jpg", + state=mock_state, + minimal_list="", + ) + + # Ensure draw_dots was called with center coordinates of the fused OCR element [110,110][190,190] -> [150, 150] + mock_draw_dots.assert_called_once() + points_arg = mock_draw_dots.call_args[0][1] + assert points_arg == [[150, 150]] # Successfully fused and drawn! + + labels_arg = mock_draw_dots.call_args[0][2] + assert labels_arg == ["1"] + + +@pytest.mark.asyncio +async def test_explorer_initial_visual_marking_previous_screenshot_on_the_fly_ocr(): + from artemis.data_engine.models import ImageRecord + + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" + + # Mock Gemini Client and response + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_response = MagicMock() + mock_response.text = "" + + mock_func_call = MagicMock() + mock_func_call.name = "submit_answer" + mock_func_call.args = { + "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] + } + mock_response.function_calls = [mock_func_call] + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + # Mock storage manager + mock_storage = MagicMock() + + # Mock ImageRecord containing ui_tree but ocr_result is None + mock_record = MagicMock(spec=ImageRecord) + mock_record.ui_tree = [{"text": "", "bounds": "[100,100][200,200]"}] + mock_record.ocr_result = None + mock_storage.get_image.return_value = mock_record + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + # Mock dynamic OCR result + mock_ocr_result = [ + {"text": "full screen", "position": [{"x": 0, "y": 0}]}, + { + "text": "On-the-fly OCR Text", + "position": [ + {"x": 110, "y": 110}, + {"x": 190, "y": 110}, + {"x": 190, "y": 190}, + {"x": 110, "y": 190}, + ], + }, + ] + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_marked_data", + b"", + b"fake_image_marked_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch( + "artemis.agents.explorer.explorer.perform_ocr", + new_callable=AsyncMock, + return_value=mock_ocr_result, + ) as mock_perform_ocr, + 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), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/previous_screenshot.jpg", + state=mock_state, + minimal_list="", + ) + + # Verify perform_ocr was triggered on-the-fly + mock_perform_ocr.assert_called_once() + + # Ensure draw_dots was called with center coordinates of the fused OCR element [110,110][190,190] -> [150, 150] + mock_draw_dots.assert_called_once() + points_arg = mock_draw_dots.call_args[0][1] + assert points_arg == [[150, 150]] + + labels_arg = mock_draw_dots.call_args[0][2] + assert labels_arg == ["1"] + + +@pytest.mark.asyncio +async def test_explorer_blacklisted_tool(): + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + 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.agent_config = MagicMock() + mock_ctx.agent_config.blacklisted_tools = {"explorer": ["search_xml_ocr"]} + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + + # Response 1: Model calls search_xml_ocr (which is blacklisted) + mock_response1 = MagicMock() + mock_response1.text = "" + mock_func_call1 = MagicMock() + mock_func_call1.name = "search_xml_ocr" + mock_func_call1.args = {"use_coords": False, "search_value": "test"} + mock_response1.function_calls = [mock_func_call1] + mock_response1.candidates = [MagicMock()] + mock_response1.candidates[0].content = types.Content( + role="model", + parts=[types.Part.from_function_call(name="search_xml_ocr", args=mock_func_call1.args)], + ) + + # Response 2: Model calls submit_answer + mock_response2 = MagicMock() + mock_response2.text = "" + mock_func_call2 = MagicMock() + mock_func_call2.name = "submit_answer" + mock_func_call2.args = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "Corrected button", + } + ], + "fallback_message": "", + } + mock_response2.function_calls = [mock_func_call2] + mock_response2.candidates = [MagicMock()] + mock_response2.candidates[0].content = types.Content( + role="model", + parts=[types.Part.from_function_call(name="submit_answer", args=mock_func_call2.args)], + ) + + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_client.aio.models.generate_content = AsyncMock( + side_effect=[mock_response1, mock_response2] + ) + + # Mock storage manager + mock_storage = MagicMock() + mock_storage.get_image.return_value = "dummy_hash" + + # Mock file reading for prompt + mock_prompt = MOCK_PROMPT_JSON + + def custom_open(file, mode="r", *args, **kwargs): + mock_file = MagicMock() + if "b" in mode: + mock_file.read.side_effect = [ + b"fake_image_data", + b"", + b"fake_image_data", + ] + else: + mock_file.read.return_value = '{"templates": [], "instructions": ""}' + + mock_file.__enter__.return_value = mock_file + return mock_file + + with ( + patch( + "artemis.agents.explorer.explorer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch("builtins.open", custom_open), + ): + explorer = Explorer(mock_ctx) + + # Verify get_exposed_tools doesn't expose search_xml_ocr + exposed_tools = explorer.get_exposed_tools() + exposed_tool_names = [tool.name for tool in exposed_tools] + assert "search_xml_ocr" not in exposed_tool_names + + result = await explorer.run( + query="Find button", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + minimal_list="", + state=mock_state, + ) + + expected_outcome = json.dumps(mock_func_call2.args, ensure_ascii=False) + assert result == expected_outcome + + # Ensure we called generate_content twice + assert mock_client.aio.models.generate_content.call_count == 2 + + # Verify the contents passed to the second call contain the error response + # indicating that the tool is blacklisted and unavailable. + call_args = mock_client.aio.models.generate_content.call_args_list[1] + contents = call_args[1]["contents"] + + # Find the tool response in contents + tool_content = next( + ( + c + for c in contents + if c.role in ("tool", "user") and any(p.function_response for p in c.parts) + ), + None, + ) + assert tool_content is not None + + # Verify that the response part contains the expected error message + found_error = False + for part in tool_content.parts: + func_resp = part.function_response + if func_resp and func_resp.name == "search_xml_ocr": + assert "blacklisted and unavailable" in func_resp.response["error"] + found_error = True + + assert found_error + + +@pytest.mark.asyncio +async def test_explorer_inspect_region(): + import pathlib + import numpy as np + + # Mock context + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + + explorer = Explorer(mock_ctx) + explorer.width = 1080 + explorer.height = 2400 + explorer.screenshot_path = "/tmp/test_screenshot.jpg" + explorer.image_name = "test_image" + + dummy_img = np.zeros((2400, 1080, 3), dtype=np.uint8) + + with ( + patch("cv2.imread", return_value=dummy_img) as mock_imread, + patch("cv2.resize", return_value=dummy_img) as mock_resize, + patch("cv2.imwrite", return_value=True) as mock_imwrite, + patch("pathlib.Path.mkdir") as mock_mkdir, + patch("glob.glob", return_value=[]), + patch("artemis.agents.explorer.explorer.settings") as mock_settings, + ): + mock_settings.TRACES_PATH = pathlib.Path("/tmp/traces") + res = await explorer.exec_inspect_region( + x_min=100, y_min=200, x_max=300, y_max=400, zoom_factor=2.0 + ) + assert res["image_path"] is not None + assert "Inspected region coordinates" in res["text"] + mock_imread.assert_called_once_with("/tmp/test_screenshot.jpg") + + # Verify resize calculations: + # px_start = 100 * 1080 / 1000 = 108 + # py_start = 200 * 2400 / 1000 = 480 + # px_end = 300 * 1080 / 1000 = 324 + # py_end = 400 * 2400 / 1000 = 960 + # width = 324 - 108 = 216 + # height = 960 - 480 = 480 + # new_width = 216 * 2.0 = 432 + # new_height = 480 * 2.0 = 960 + mock_resize.assert_called_once() + resize_args = mock_resize.call_args[0] + assert resize_args[1] == (432, 960) + mock_imwrite.assert_called_once() + + +@pytest.mark.asyncio +async def test_explorer_final_turn_tool_stripping(): + """Test that on the final iteration (turn 10), Explorer strips other tools and injects warning.""" + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/test_session" + explorer = Explorer(mock_ctx) + + mock_ctx.llm_config = MagicMock() + mock_ctx.llm_config.explorer = MagicMock() + mock_ctx.llm_config.explorer.model = "gemini-3.6-flash" + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" + + # Set up generate_content mock to capture call arguments + mock_client = MagicMock() + mock_ctx._genai_client = mock_client + + # Turn 1: model returns search_xml_ocr + fc_search = MagicMock() + fc_search.name = "search_xml_ocr" + fc_search.args = {"use_coords": False, "search_value": "test"} + mock_response = MagicMock() + mock_response.function_calls = [fc_search] + mock_response.candidates = [MagicMock()] + mock_response.candidates[0].content = types.Content( + role="model", + parts=[types.Part.from_function_call(name="search_xml_ocr", args=fc_search.args)], + ) + + # Always return the same mock response to force it to run out of turns + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + from unittest.mock import mock_open + + with ( + patch("builtins.open", mock_open(read_data=b"")), + patch("hashlib.sha256") as mock_sha, + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", return_value="{}"), + ): + mock_sha.return_value.hexdigest.return_value = "dummy_hash" + + await explorer.run( + query="test", + context_feedback="", + screenshot_path="/tmp/test_screenshot.jpg", + state=mock_state, + minimal_list="test list", + ) + + # Total number of iterations is max_iterations = 3 under "pro" mode default + assert mock_client.aio.models.generate_content.call_count == 3 + + # Check the final turn call (index 2) keyword args + call_args_3 = mock_client.aio.models.generate_content.call_args_list[2] + config = call_args_3.kwargs["config"] + + # Tools in the 10th call should only contain "submit_answer" + tools = config.tools[0].function_declarations + assert len(tools) == 1 + assert tools[0].name == "submit_answer" + + # Warning should be injected in contents + contents = call_args_3.kwargs["contents"] + # Find warning in contents parts + warning_found = False + for content in contents: + if content.role == "user": + for part in content.parts: + if part.text and "[WARNING] This is your final iteration" in part.text: + warning_found = True + assert warning_found, "Warning message was not found in contents" + + +@pytest.mark.asyncio +async def test_explorer_ask_perception_tool(): + import numpy as np + from unittest.mock import mock_open + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/test_session" + explorer = Explorer(mock_ctx) + explorer.image_name = "dummy_hash" + explorer.screenshot_path = "/tmp/test_screenshot.jpg" + explorer.width = 1080 + explorer.height = 2400 + explorer.global_label_idx = 1 + explorer.image_pool = { + "img_0": { + "path": "/tmp/test_screenshot.jpg", + "transform": { + "offset_x": 0.0, + "offset_y": 0.0, + "scale_x": 1.0, + "scale_y": 1.0, + }, + "description": "Original complete screenshot", + } + } + + # Mock search_ui_func + mock_search_ui_res = { + "matches": [ + { + "matched_text": "Settings", + "bounds": [540, 480, 540, 480], + "type": "xml", + }, + { + "matched_text": "Sign In", + "bounds": [100, 100, 200, 200], + "type": "ocr", + }, + ] + } + + # Mock storage record + mock_record = MagicMock() + mock_record.ui_tree = [ + { + "bounds": "[500,400][600,560]", + "text": "Settings Button", + "resource-id": "com.android.settings:id/btn_settings", + "class": "android.widget.Button", + } + ] + mock_record.ocr_result = [] + + mock_storage = MagicMock() + mock_storage.get_image.return_value = mock_record + + # Mock _run_object_detection + mock_detector_res = { + "detected": [{"label": "profile icon", "point": [900, 100]}], + "failed": [], + } + + # Mock draw_dots + mock_draw_dots = MagicMock() + + with ( + patch( + "artemis.agents.explorer.explorer.search_ui_func", + return_value=mock_search_ui_res, + ), + patch( + "artemis.agents.explorer.explorer.StorageManager", + return_value=mock_storage, + ), + patch( + "artemis.mcp.xml_search_server.StorageManager", + return_value=mock_storage, + ), + patch( + "artemis.agents.explorer.explorer._run_object_detection", + return_value=mock_detector_res, + ), + patch("artemis.agents.explorer.explorer.draw_dots", mock_draw_dots), + patch("artemis.utils.visualization.draw_dots", mock_draw_dots), + patch("pathlib.Path.mkdir"), + patch("glob.glob", return_value=[]), + patch("builtins.open", mock_open(read_data=b'{"templates": []}')), + patch("cv2.imread", return_value=np.zeros((2400, 1080, 3), dtype=np.uint8)), + ): + result = await explorer.exec_ask_perception_tool( + search_query="Settings", + nx=550, + ny=200, + detect_queries=["profile icon"], + ) + + assert "XML/OCR Text Search Results are:" in result["text"] + assert "Coordinate Search Results are:" in result["text"] + assert "Object Detection Results are:" in result["text"] + + # XML Text search should return label X1 + assert "[X1]" in result["text"] + # OCR Text search should return label O2 + assert "[O2]" in result["text"] + # Coordinate search should return label X3 + assert "[X3]" in result["text"] + # Object detection should return label D4 (since global label idx is shared/contiguous) + assert "[D4]" in result["text"] + + assert len(result["image_paths"]) == 3 + + # Verify call arguments of draw_dots + assert mock_draw_dots.call_count == 3 + # First call (XML/OCR Text search): green color + first_call = mock_draw_dots.call_args_list[0] + assert first_call.kwargs.get("color") == "green" + assert first_call.args[2] == ["X1", "O2"] + + # Second call (XML Coordinate audit): blue color + second_call = mock_draw_dots.call_args_list[1] + assert second_call.kwargs.get("color") == "blue" + assert second_call.args[2] == ["X3"] + + # Third call (Object detection): red color + third_call = mock_draw_dots.call_args_list[2] + assert third_call.kwargs.get("color") == "red" + assert third_call.args[2] == ["D4"] + + +def test_explorer_prune_historical_images(): + from artemis.agents.explorer.explorer import Explorer + from google.genai import types + + mock_ctx = MagicMock() + explorer = Explorer(mock_ctx) + + part1 = types.Part(inline_data=types.Blob(data=b"img1", mime_type="image/jpeg")) + part2 = types.Part(inline_data=types.Blob(data=b"img2", mime_type="image/jpeg")) + part3 = types.Part.from_text(text="Some text") + + contents = [ + types.Content(role="user", parts=[part1, part3]), + types.Content(role="user", parts=[part2]), + ] + + explorer._prune_historical_images(contents, keep_last=1) + + # First image part should be pruned to text part + assert contents[0].parts[0].text == "[Image pruned to maintain visual focus on latest state]" + assert contents[0].parts[1].text == "Some text" + # Second (last) image part should be kept intact + assert contents[1].parts[0] is part2 diff --git a/tests/unit/agents/test_explorer_universal.py b/tests/unit/agents/test_explorer_universal.py new file mode 100644 index 0000000..33d21fb --- /dev/null +++ b/tests/unit/agents/test_explorer_universal.py @@ -0,0 +1,127 @@ +# 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 Universal Explorer and Multi-Model Grounding Engine.""" + +import json +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from langchain_core.messages import AIMessage + +from artemis.agents.explorer.explorer import ( + UNIVERSAL_EXPLORER_TOOLS, + Explorer, +) +from artemis.config import settings +from artemis.context import ArtemisContext +from artemis.graph.state import State + + +@pytest.fixture +def mock_context(tmp_path): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + mock_llm_cfg = Mock() + mock_llm_cfg.model = "claude-3-7-sonnet" + mock_llm_cfg.temperature = 0.1 + ctx.llm_config.explorer = mock_llm_cfg + + ctx.device = Mock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + + ctx.agent_config = Mock() + ctx.agent_config.blacklisted_tools = {} + + ctx.data_engine = Mock() + ctx.data_engine.base_dir = tmp_path + return ctx + + +def test_explorer_universal_engine_detection(mock_context): + """Test that Explorer routes to Universal Engine when non-Gemini model is configured.""" + with patch.object(settings, "GOOGLE_API_KEY", None): + agent = Explorer(mock_context) + assert agent.use_native_gemini is False + assert agent.model_name == "claude-3-7-sonnet" + + +def test_universal_explorer_tool_schemas(): + """Verify schema integrity of Universal Explorer tool declarations.""" + assert len(UNIVERSAL_EXPLORER_TOOLS) == 6 + names = [t["function"]["name"] for t in UNIVERSAL_EXPLORER_TOOLS] + assert "ask_perception_tool" in names + assert "detect_objects" in names + assert "get_ocr_list" in names + assert "ask_image_processor" in names + assert "inspect_region" in names + assert "submit_answer" in names + + +@pytest.mark.asyncio +async def test_run_universal_explorer_success(mock_context, tmp_path): + """Test universal Explorer tool invocation and submit_answer coordinate parsing.""" + agent = Explorer(mock_context) + agent.use_native_gemini = False + + fake_screenshot = tmp_path / "screenshot.jpg" + fake_screenshot.write_bytes(b"fake_image_bytes") + + # Mock tool call response for submit_answer + msg_response = AIMessage( + content="Submitting grounded elements.", + tool_calls=[ + { + "name": "submit_answer", + "args": { + "candidates": [ + { + "label": "D1", + "coords": [500, 600], + "description": "Settings button", + } + ], + "fallback_message": "", + }, + "id": "exp_call_1", + } + ], + ) + + mock_llm = MagicMock() + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(return_value=msg_response) + mock_llm.bind_tools.return_value = mock_bound + + with patch( + "artemis.agents.explorer.explorer.get_llm", + return_value=mock_llm, + ): + mock_state = Mock(spec=State) + res_str = await agent._run_universal( + query="find settings icon", + context_feedback="", + screenshot_path=str(fake_screenshot), + state=mock_state, + minimal_list="", + version="pro", + prompt_template="You are an explorer.", + max_iterations=3, + ) + + res = json.loads(res_str) + assert len(res["candidates"]) == 1 + assert res["candidates"][0]["label"] == "D1" + assert res["candidates"][0]["coords"] == [500, 600] diff --git a/tests/unit/agents/test_failure_analyzer.py b/tests/unit/agents/test_failure_analyzer.py new file mode 100644 index 0000000..252aa30 --- /dev/null +++ b/tests/unit/agents/test_failure_analyzer.py @@ -0,0 +1,213 @@ +# 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 Universal Failure Analyzer.""" + +import base64 +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from langchain_core.messages import AIMessage + +from artemis.agents.validator.failure_analyzer import ( + FailureAnalyzer, + PixelTargetDisappearedStrategy, + TargetDisappearedStrategy, + ValidationErrorCategory, + VALIDATOR_TOOLS_DECLARATION, +) +from artemis.context import ArtemisContext + + +class DummyState: + def __init__(self, latest_screenshot=None): + self.latest_screenshot = latest_screenshot + self.indexed_points = [] + self.indexed_elements = [] + + +@pytest.fixture +def mock_context(tmp_path): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + mock_llm_cfg = Mock() + mock_llm_cfg.model = "gemini-2.5-pro" + mock_llm_cfg.temperature = 0.1 + ctx.llm_config.get_agent.return_value = mock_llm_cfg + + ctx.device = Mock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + + ctx.data_engine = Mock() + ctx.data_engine.base_dir = tmp_path + ctx.data_engine.get_agent_friendly_steps.return_value = [] + ctx.adb_client = None + ctx.driver = Mock() + return ctx + + +@pytest.mark.asyncio +async def test_failure_analyzer_cannot_fix(mock_context, tmp_path): + """Test that FailureAnalyzer natively executes and handles 'cannot_fix' report.""" + screenshot_file = tmp_path / "shot.jpg" + screenshot_file.write_bytes(b"dummy_bytes") + + state = DummyState(latest_screenshot=str(screenshot_file)) + failed_action = {"action": "click", "coordinates": [100, 200]} + error_msg = "Element not found" + + analyzer = FailureAnalyzer(mock_context) + + mock_llm = MagicMock() + mock_response = AIMessage( + content="I see the error.", + tool_calls=[ + { + "name": "report_failure_analysis", + "args": { + "status": "cannot_fix", + "analysis": "The element was definitely not there.", + }, + "id": "call_1", + } + ], + ) + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_bound + + with ( + patch("artemis.agents.validator.failure_analyzer.get_llm", return_value=mock_llm), + patch( + "artemis.agents.validator.failure_analyzer.UnifiedMobileController" + ) as mock_controller_cls, + ): + mock_controller = Mock() + mock_controller_cls.return_value = mock_controller + mock_device_data = Mock() + mock_device_data.base64 = base64.b64encode(b"mocked_screenshot").decode() + mock_device_data.elements = [] + mock_device_data.width = 1080 + mock_device_data.height = 2400 + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + mock_controller.get_ui_elements = AsyncMock(return_value=mock_device_data.elements) + + result = await analyzer.analyze( + state, + failed_action, + error_msg, + pre_screenshot=base64.b64encode(b"dummy").decode(), + post_screenshot=base64.b64encode(b"dummy").decode(), + ) + + assert result["status"] == "cannot_fix" + assert "not there" in result["analysis"] + assert result["new_remaining_actions"] == [] + + +def test_report_failure_analysis_tool_declaration(): + """Test that report_failure_analysis tool declaration is correct and matches.""" + report_tool = next( + (t for t in VALIDATOR_TOOLS_DECLARATION if t.name == "report_failure_analysis"), + None, + ) + assert report_tool is not None + props = report_tool.parameters.get("properties", {}) + assert "analysis" in props + assert "single paragraph" in props["analysis"]["description"] + + +@pytest.mark.asyncio +async def test_failure_analyzer_strategy_routing(mock_context): + """Test that FailureAnalyzer selects appropriate strategies based on error category.""" + analyzer = FailureAnalyzer(mock_context) + + strat_disappeared = analyzer._select_strategy(ValidationErrorCategory.TARGET_DISAPPEARED) + assert isinstance(strat_disappeared, TargetDisappearedStrategy) + assert "target_disappeared" in strat_disappeared.get_prompt_template_name() + + strat_pixel = analyzer._select_strategy(ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED) + assert isinstance(strat_pixel, PixelTargetDisappearedStrategy) + assert "pixel_target_disappeared" in strat_pixel.get_prompt_template_name() + + +@pytest.mark.asyncio +async def test_failure_analyzer_click_sequence(mock_context, tmp_path): + """Test click_sequence execution in FailureAnalyzer.""" + screenshot_file = tmp_path / "shot.jpg" + screenshot_file.write_bytes(b"dummy_bytes") + + state = DummyState(latest_screenshot=str(screenshot_file)) + analyzer = FailureAnalyzer(mock_context) + + # Turn 1: Call click_sequence tool with direct coordinates + call_step_1 = AIMessage( + content="Let me click in sequence.", + tool_calls=[ + { + "name": "click_sequence", + "args": {"sequence": [[100, 200], [300, 400]]}, + "id": "call_seq_1", + } + ], + ) + # Turn 2: Report failure fixed + call_step_2 = AIMessage( + content="Repaired successfully.", + tool_calls=[ + { + "name": "report_failure_analysis", + "args": { + "status": "fixed", + "analysis": "Sequence clicked successfully.", + }, + "id": "call_report_2", + } + ], + ) + + mock_llm = MagicMock() + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(side_effect=[call_step_1, call_step_2]) + mock_llm.bind_tools.return_value = mock_bound + + with ( + patch("artemis.agents.validator.failure_analyzer.get_llm", return_value=mock_llm), + patch( + "artemis.agents.validator.failure_analyzer.UnifiedMobileController" + ) as mock_controller_cls, + ): + mock_controller = Mock() + mock_controller_cls.return_value = mock_controller + mock_device_data = Mock() + mock_device_data.base64 = base64.b64encode(b"mocked_screenshot").decode() + mock_device_data.elements = [] + mock_device_data.width = 1080 + mock_device_data.height = 2400 + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + mock_controller.get_ui_elements = AsyncMock(return_value=mock_device_data.elements) + mock_controller.tap_at = AsyncMock(return_value=Mock(error=None)) + + result = await analyzer.analyze( + state, + {"action": "click", "coordinates": [100, 200]}, + "Element not found", + pre_screenshot=base64.b64encode(b"dummy").decode(), + post_screenshot=base64.b64encode(b"dummy").decode(), + error_category=ValidationErrorCategory.TARGET_DISAPPEARED, + ) + + assert result["status"] == "fixed" + assert mock_controller.tap_at.call_count == 2 diff --git a/tests/unit/agents/test_failure_analyzer_tools.py b/tests/unit/agents/test_failure_analyzer_tools.py new file mode 100644 index 0000000..d4065e3 --- /dev/null +++ b/tests/unit/agents/test_failure_analyzer_tools.py @@ -0,0 +1,460 @@ +# 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 base64 +from unittest.mock import AsyncMock, Mock, patch + +from artemis.agents.validator.failure_analyzer import ( + _exec_click, + _exec_click_sequence, + _exec_input_text, + _exec_long_press, + _exec_manage_app, + _exec_press_key, + _exec_swipe, + _exec_wait_for_delay, +) +from artemis.context import ArtemisContext +import pytest + + +class DummyState: + def __init__(self, latest_screenshot=None): + self.latest_screenshot = latest_screenshot + self.indexed_points = [] + self.indexed_elements = [] + + +@pytest.fixture +def mock_context(tmp_path): + ctx = Mock(spec=ArtemisContext) + ctx.device = Mock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + ctx.data_engine = Mock() + ctx.data_engine.base_dir = tmp_path + ctx.data_engine.get_or_create_image.return_value = "dummy_image_name" + ctx.data_engine.get_image_path.return_value = tmp_path / "dummy_image_name.jpg" + return ctx + + +@pytest.fixture +def mock_controller(): + controller = Mock() + mock_device_data = Mock() + mock_device_data.base64 = base64.b64encode(b"mocked_screenshot").decode() + mock_device_data.elements = [] + mock_device_data.width = 1080 + mock_device_data.height = 2400 + controller.get_screen_data = AsyncMock(return_value=mock_device_data) + controller.get_ui_elements = AsyncMock(return_value=mock_device_data.elements) + controller.tap_at = AsyncMock(return_value=Mock(error=None)) + controller.type_text = AsyncMock(return_value=True) + controller.erase_text = AsyncMock(return_value=True) + controller.swipe_coords = AsyncMock(return_value=None) + controller.press_enter = AsyncMock(return_value=True) + controller.go_back = AsyncMock(return_value=True) + controller.go_home = AsyncMock(return_value=True) + controller.press_key = AsyncMock(return_value=True) + controller.terminate_app = AsyncMock(return_value=True) + return controller + + +# ========================================== +# SLIDE 1: click +# ========================================== + + +@pytest.mark.asyncio +async def test_click_happy_path(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_click( + target=[500, 600], + state=state, + controller=mock_controller, + ctx=mock_context, + times=2, + delay_ms=150, + ) + assert "Clicked at [500, 600]" in outcome + assert mock_controller.tap_at.call_count == 1 + mock_controller.tap_at.assert_any_call(540, 1440, times=2, delay_ms=150) + + +@pytest.mark.asyncio +async def test_click_input_validation(mock_context, mock_controller): + state = DummyState() + # Coordinates holding non-integer values + outcome, _, _, _ = await _exec_click( + target=["invalid", "invalid"], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error during click:" in outcome + + +@pytest.mark.asyncio +async def test_click_edge_cases(mock_context, mock_controller): + state = DummyState() + # Underflow/overflow coordinates should be clamped to valid boundary + outcome, _, _, _ = await _exec_click( + target=[-10, 1050], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Clicked at [-10, 1050]" in outcome + mock_controller.tap_at.assert_called_with(0, 2399, times=1, delay_ms=100) + + +@pytest.mark.asyncio +async def test_click_graceful_failure(mock_context, mock_controller): + state = DummyState() + mock_controller.tap_at.return_value = Mock(error="Click failed connection lost") + outcome, _, _, _ = await _exec_click( + target=[500, 600], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error executing click: Click failed connection lost" in outcome + + +# ========================================== +# SLIDE 2: click_sequence +# ========================================== + + +@pytest.mark.asyncio +async def test_click_sequence_happy_path(mock_context, mock_controller): + state = DummyState() + state.indexed_points = [[540, 1200]] # Mapped from index 1 (1-indexed) + outcome, img_bytes, shot_path, xml_list = await _exec_click_sequence( + sequence=[1, [300, 400]], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + assert mock_controller.tap_at.call_count == 2 + mock_controller.tap_at.assert_any_call(540, 1200) + mock_controller.tap_at.assert_any_call(324, 960) + + +@pytest.mark.asyncio +async def test_click_sequence_input_validation(mock_context, mock_controller): + state = DummyState() + state.indexed_points = [[540, 1200], [270, 600]] + + # Subsequent elements can also be integers (mixed array of indexes and coordinates) + outcome, _, _, _ = await _exec_click_sequence( + sequence=[1, 2, [300, 400]], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + assert mock_controller.tap_at.call_count == 3 + mock_controller.tap_at.assert_any_call(540, 1200) + mock_controller.tap_at.assert_any_call(270, 600) + mock_controller.tap_at.assert_any_call(324, 960) + + # Reset indexed_points since _exec_click_sequence overwrites them + # via _capture_screenshot_and_parse_ui + state.indexed_points = [[540, 1200], [270, 600]] + + # Index out of bounds + outcome, _, _, _ = await _exec_click_sequence( + sequence=[5, [300, 400]], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error during click sequence: Index 5 is out of range" in outcome + + +@pytest.mark.asyncio +async def test_click_sequence_edge_cases(mock_context, mock_controller): + state = DummyState() + # Coordinates in sequence outside range should be clamped + outcome, _, _, _ = await _exec_click_sequence( + sequence=[[0, 0], [1200, -50]], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + mock_controller.tap_at.assert_any_call(0, 0) + mock_controller.tap_at.assert_any_call(1079, 0) + + +@pytest.mark.asyncio +async def test_click_sequence_graceful_failure(mock_context, mock_controller): + state = DummyState() + mock_controller.tap_at.side_effect = [ + Mock(error=None), + Mock(error="Connection error on step 2"), + ] + outcome, _, _, _ = await _exec_click_sequence( + sequence=[[100, 200], [300, 400]], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error executing click at" in outcome + assert "Connection error on step 2" in outcome + + +@pytest.mark.asyncio +async def test_click_sequence_stringified_formats(mock_context, mock_controller): + state = DummyState() + state.indexed_points = [[540, 1200]] + # Test stringified coordinate formats commonly generated by LLMs: + # 1. string representation of lists: ["[500, 280]", "[884, 362]"] + outcome, _, _, _ = await _exec_click_sequence( + sequence=["[500, 280]", "[884, 362]"], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + mock_controller.tap_at.assert_any_call(540, 672) + mock_controller.tap_at.assert_any_call(954, 868) + + # 2. comma-separated strings: ["500,280", "884,362"] + outcome, _, _, _ = await _exec_click_sequence( + sequence=["500,280", "884,362"], + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + + # 3. whole sequence as a string + outcome, _, _, _ = await _exec_click_sequence( + sequence="[[500, 280], [884, 362]]", + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Sequence clicked successfully" in outcome + + +# ========================================== +# SLIDE 3: long_press +# ========================================== + + +@pytest.mark.asyncio +async def test_long_press_happy_path(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_long_press( + target=[500, 500], + duration=1500, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Long pressed at [500, 500]" in outcome + mock_controller.tap_at.assert_called_once_with( + 540, 1200, long_press=True, long_press_duration=1500 + ) + + +@pytest.mark.asyncio +async def test_long_press_input_validation(mock_context, mock_controller): + state = DummyState() + # Invalid target type + outcome, _, _, _ = await _exec_long_press( + target="not-a-list", + duration=1500, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error during long press:" in outcome + + +# ========================================== +# SLIDE 4: input_text +# ========================================== + + +@pytest.mark.asyncio +async def test_input_text_happy_path(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_input_text( + text="hello", + target=[500, 500], + clear_exist=True, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Executed typing 'hello'." in outcome + mock_controller.tap_at.assert_called_once_with(540, 1200) + mock_controller.erase_text.assert_called_once() + mock_controller.type_text.assert_called_once_with("hello") + + +@pytest.mark.asyncio +async def test_input_text_input_validation(mock_context, mock_controller): + state = DummyState() + outcome, _, _, _ = await _exec_input_text( + text="hello", + target="invalid", + clear_exist=True, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error during input text:" in outcome + + +# ========================================== +# SLIDE 5: swipe +# ========================================== + + +@pytest.mark.asyncio +async def test_swipe_happy_path_direction(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_swipe( + action="up", + duration=400, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Swipe completed successfully" in outcome + mock_controller.swipe_coords.assert_called_once_with(648, 1920, 648, 480, 400) + + +@pytest.mark.asyncio +async def test_swipe_happy_path_coords(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_swipe( + action=[100, 900, 100, 100], + duration=400, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Swipe completed successfully" in outcome + mock_controller.swipe_coords.assert_called_once_with(108, 2160, 108, 240, 400) + + +@pytest.mark.asyncio +async def test_swipe_input_validation(mock_context, mock_controller): + state = DummyState() + outcome, _, _, _ = await _exec_swipe( + action="invalid_direction", + duration=400, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error during swipe: Invalid direction:" in outcome + + +# ========================================== +# SLIDE 6: press_key +# ========================================== + + +@pytest.mark.asyncio +async def test_press_key_happy_path(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_press_key( + key="BACK", + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Executed key press 'BACK'." in outcome + mock_controller.go_back.assert_called_once() + + +@pytest.mark.asyncio +async def test_press_key_input_validation(mock_context, mock_controller): + state = DummyState() + outcome, _, _, _ = await _exec_press_key( + key="INVALID_KEY", + state=state, + controller=mock_controller, + ctx=mock_context, + ) + # The current execution doesn't raise error on unknown keys, just logs error + assert "Error executing key press 'INVALID_KEY'." in outcome + + +# ========================================== +# SLIDE 7: manage_app +# ========================================== + + +@pytest.mark.asyncio +async def test_manage_app_happy_path(mock_context, mock_controller): + state = DummyState() + with ( + patch("artemis.agents.validator.tool_declarations.find_package") as mock_find_package, + patch("artemis.agents.validator.tool_declarations.launch_app_with_retries") as mock_launch, + ): + mock_find_package.return_value = "com.google.android.youtube" + mock_launch.return_value = (True, "") + + outcome, img_bytes, shot_path, xml_list = await _exec_manage_app( + action="launch", + app_name="YouTube", + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Launched app 'YouTube' (com.google.android.youtube) successfully." in outcome + mock_find_package.assert_called_once_with(mock_context, "YouTube", use_fallback=False) + mock_launch.assert_called_once_with(mock_context, "com.google.android.youtube") + + +@pytest.mark.asyncio +async def test_manage_app_input_validation(mock_context, mock_controller): + state = DummyState() + with patch("artemis.agents.validator.tool_declarations.find_package") as mock_find_package: + mock_find_package.return_value = None + + outcome, _, _, _ = await _exec_manage_app( + action="launch", + app_name="NonExistentApp", + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Error finding package for app: NonExistentApp" in outcome + + +# ========================================== +# SLIDE 8: wait_for_delay +# ========================================== + + +@pytest.mark.asyncio +async def test_wait_for_delay_happy_path(mock_context, mock_controller): + state = DummyState() + outcome, img_bytes, shot_path, xml_list = await _exec_wait_for_delay( + time_in_ms=100, + state=state, + controller=mock_controller, + ctx=mock_context, + ) + assert "Waited for 100ms successfully." in outcome diff --git a/tests/unit/agents/test_flash_runner.py b/tests/unit/agents/test_flash_runner.py new file mode 100644 index 0000000..ad753aa --- /dev/null +++ b/tests/unit/agents/test_flash_runner.py @@ -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 Universal FlashRunner.""" + +from unittest.mock import Mock, patch +import pytest +from langchain_core.messages import HumanMessage, ToolMessage + +from artemis.agents.flash.runner import FlashRunner +from artemis.context import ArtemisContext + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + mock_llm_cfg = Mock() + mock_llm_cfg.model = "gemini-2.5-flash" + mock_llm_cfg.temperature = 0.1 + ctx.llm_config.get_agent.return_value = mock_llm_cfg + + ctx.device = Mock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + ctx.data_engine = None + ctx.adb_client = None + ctx.driver = Mock() + return ctx + + +def test_flash_runner_tools_initialization(mock_context): + """Verify FlashRunner gathers correct active tools.""" + with patch("artemis.controllers.unified_controller.get_driver"): + runner = FlashRunner(mock_context, goal="Test Open Settings") + tools = runner._get_tools() + + tool_names = [t.name for t in tools] + assert "click" in tool_names + assert "click_sequence" in tool_names + assert "swipe" in tool_names + assert "input_text" in tool_names + assert "ask_explorer" in tool_names + assert "report_task_status" in tool_names + # Validator's report_failure_analysis should NOT be in FlashRunner + assert "report_failure_analysis" not in tool_names + + +def test_flash_runner_screenshot_pruning(mock_context): + """Verify that earlier screenshot blocks are pruned, keeping only the latest.""" + with patch("artemis.controllers.unified_controller.get_driver"): + runner = FlashRunner(mock_context, goal="Test Pruning") + + messages = [ + HumanMessage( + content=[ + {"type": "text", "text": "Turn 1 goal"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_1"}, + }, + ] + ), + ToolMessage( + tool_call_id="tc1", + name="click", + content=[ + {"type": "text", "text": "Clicked button"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_2"}, + }, + ], + ), + ToolMessage( + tool_call_id="tc2", + name="swipe", + content=[ + {"type": "text", "text": "Swiped up"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_3"}, + }, + ], + ), + ] + + runner._prune_intermediate_screenshots(messages) + + # Check Turn 1: image should be removed + assert len(messages[0].content) == 1 + assert messages[0].content[0]["type"] == "text" + + # Check Turn 2 (Tool 1): image should be removed + assert len(messages[1].content) == 1 + assert messages[1].content[0]["type"] == "text" + + # Check Turn 3 (Tool 2 - Latest): image MUST be preserved + assert len(messages[2].content) == 2 + assert messages[2].content[0]["type"] == "text" + assert messages[2].content[1]["type"] == "image_url" + assert messages[2].content[1]["image_url"]["url"] == "data:image/jpeg;base64,SCREENSHOT_3" diff --git a/tests/unit/agents/test_flash_step_summarizer.py b/tests/unit/agents/test_flash_step_summarizer.py new file mode 100644 index 0000000..6ed5459 --- /dev/null +++ b/tests/unit/agents/test_flash_step_summarizer.py @@ -0,0 +1,289 @@ +# 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 VisualStepSummarizer and Context Compressor in Flash profile.""" + +import asyncio +from unittest.mock import AsyncMock, Mock +import pytest +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from artemis.agents.flash.context_compressor import compress_flash_messages +from artemis.agents.flash.summarizer import VisualStepSummarizer +from artemis.context import ArtemisContext +from artemis.sdk.builders import Builders + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.data_engine = None + return ctx + + +@pytest.mark.asyncio +async def test_summarizer_dispatch_and_caching(mock_context): + """Verify non-blocking dispatch and summary generation.""" + summarizer = VisualStepSummarizer(mock_context) + + mock_llm_response = AIMessage( + content="[Step 1 State: Tapped 'Login' button. Home view opened.]" + ) + summarizer._llm = Mock() + summarizer._llm.ainvoke = AsyncMock(return_value=mock_llm_response) + + summarizer.dispatch( + step_number=1, + action_name="click", + action_args={"target": [500, 600]}, + pre_img_bytes=b"fake_pre", + post_img_bytes=b"fake_post", + exec_outcome="Clicked successfully", + ) + + # Await background task flush + await summarizer.flush() + + assert summarizer.has_summary(1) + summary = summarizer.get_summary(1) + assert "[Step 1 State: Tapped 'Login' button. Home view opened.]" in summary + + # Verify memory optimization: image buffers are released + assert summarizer._step_inputs[1]["pre_img_bytes"] is None + assert summarizer._step_inputs[1]["post_img_bytes"] is None + + +@pytest.mark.asyncio +async def test_summarizer_retrigger_stalled_steps(mock_context): + """Verify that earlier stalled steps are re-triggered when subsequent steps arrive.""" + summarizer = VisualStepSummarizer(mock_context) + attempt_count = 0 + + async def mock_ainvoke(messages): + nonlocal attempt_count + attempt_count += 1 + # Fail the first attempt (Step 1 first call) + if attempt_count == 1: + raise asyncio.CancelledError("Simulated timeout") + # Check messages for step number + msg_str = str(messages) + if "Step 1" in msg_str: + return AIMessage(content="[Step 1 State: Recovered Step 1 summary]") + return AIMessage(content="[Step 2 State: Step 2 summary]") + + summarizer._llm = Mock() + summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) + + # Dispatch step 1 (fails on first attempt) + summarizer.dispatch( + step_number=1, + action_name="click", + action_args={"target": [100, 100]}, + pre_img_bytes=b"pre1", + post_img_bytes=b"post1", + exec_outcome="Outcome 1", + ) + await summarizer.flush() + assert not summarizer.has_summary(1) + + # Dispatch step 2 (should detect stalled Step 1 and re-trigger) + summarizer.dispatch( + step_number=2, + action_name="swipe", + action_args={"action": "up"}, + pre_img_bytes=b"pre2", + post_img_bytes=b"post2", + exec_outcome="Outcome 2", + ) + await summarizer.flush() + + assert summarizer.has_summary(1) + assert summarizer.has_summary(2) + assert "Recovered Step 1" in summarizer.get_summary(1) + + +def test_context_compressor_with_ready_summaries(mock_context): + """Verify that ready summaries replace past image blocks and prune outdated XML.""" + summarizer = VisualStepSummarizer(mock_context) + summarizer._summaries[1] = "[Step 1 State: Tapped Search bar. Keyboard appeared.]" + summarizer._summaries[2] = "[Step 2 State: Typed 'flights'. Suggestions displayed.]" + + messages = [ + HumanMessage( + content=[ + {"type": "text", "text": "Task: Search flights"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, + {"type": "text", "text": "--- UI Element List ---\nOld Tree 1"}, + ] + ), + ToolMessage( + tool_call_id="tc1", + name="click", + content=[ + {"type": "text", "text": "Action 'click' completed."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}}, + {"type": "text", "text": "--- UI Element List ---\nOld Tree 2"}, + ], + ), + ToolMessage( + tool_call_id="tc2", + name="input_text", + content=[ + {"type": "text", "text": "Action 'input_text' completed."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}}, + {"type": "text", "text": "--- UI Element List ---\nLive Latest Tree 3"}, + ], + ), + ] + + compress_flash_messages(messages, summarizer=summarizer, prune_history_xml=True) + + # Initial HumanMessage: Historical initial image pruned, task text preserved + assert len(messages[0].content) == 1 + assert messages[0].content[0]["text"] == "Task: Search flights" + + # Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned + assert len(messages[1].content) == 2 + assert messages[1].content[0]["text"] == "Action 'click' completed." + assert messages[1].content[1]["text"] == "[Step 1 State: Tapped Search bar. Keyboard appeared.]" + + # Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved + assert len(messages[2].content) == 3 + assert messages[2].content[0]["text"] == "Action 'input_text' completed." + assert messages[2].content[1]["type"] == "image_url" + assert "Live Latest Tree 3" in messages[2].content[2]["text"] + + +def test_context_compressor_graceful_fallback(mock_context): + """Verify that when a summary is not ready yet, it cleanly falls back to standard pruned state.""" + summarizer = VisualStepSummarizer(mock_context) + # Step 1 has no summary in cache + + messages = [ + HumanMessage( + content=[ + {"type": "text", "text": "Task: Search"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, + ] + ), + ToolMessage( + tool_call_id="tc1", + name="click", + content=[ + {"type": "text", "text": "Action 'click' completed."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}}, + ], + ), + ] + + compress_flash_messages(messages, summarizer=summarizer, prune_history_xml=True) + + # Turn 1 should simply have image omitted (standard fallback) + assert len(messages[0].content) == 1 + assert messages[0].content[0]["text"] == "Task: Search" + + # Turn 2 (Latest) preserves image + assert len(messages[1].content) == 2 + assert messages[1].content[1]["type"] == "image_url" + + +def test_flash_config_and_builder(): + """Verify Flash profile configuration model and SDK builder fluent API.""" + cfg = Builders.AgentConfig.with_flash_config( + max_turns=25, + explorer_mode="flash", + step_summarizer=True, + step_summarizer_model="gemini-2.5-flash-lite", + prune_history_xml=True, + ).build() + + assert cfg.flash.max_turns == 25 + assert cfg.flash.explorer_mode == "flash" + assert cfg.flash.step_summarizer.enabled is True + assert cfg.flash.step_summarizer.model == "gemini-2.5-flash-lite" + assert cfg.flash.step_summarizer.prune_history_xml is True + + +@pytest.mark.asyncio +async def test_data_engine_integration_with_step_summarizer(mock_context): + """Verify that generated summaries sync into DataEngine and are visible to get_agent_friendly_steps.""" + mock_de = Mock() + mock_de.update_step_summary = Mock() + mock_context.data_engine = mock_de + + summarizer = VisualStepSummarizer(mock_context) + summarizer._llm = Mock() + summarizer._llm.ainvoke = AsyncMock( + return_value=AIMessage(content="[Step 1 State: Input text confirmed.]") + ) + + summarizer.dispatch( + step_number=1, + action_name="input_text", + action_args={"text": "hello"}, + pre_img_bytes=b"pre", + post_img_bytes=b"post", + exec_outcome="Executed typing", + ) + await summarizer.flush() + + mock_de.update_step_summary.assert_called_once_with(1, "[Step 1 State: Input text confirmed.]") + + +def test_draw_action_overlay_rendering(): + """Verify that draw_action_overlay_on_image correctly renders markers on images.""" + import io + from PIL import Image + from artemis.utils.visualization import draw_action_overlay_on_image + + # Create a blank RGB image in memory + img = Image.new("RGB", (1080, 2400), color="white") + buf = io.BytesIO() + img.save(buf, format="JPEG") + raw_bytes = buf.getvalue() + + # 1. Click overlay + annotated_click = draw_action_overlay_on_image( + image_bytes=raw_bytes, + action_name="click", + action_args={"target": [540, 1200]}, + ) + assert len(annotated_click) > 0 + assert annotated_click != raw_bytes + + # 2. Swipe overlay + annotated_swipe = draw_action_overlay_on_image( + image_bytes=raw_bytes, + action_name="swipe", + action_args={"start": [500, 1800], "end": [500, 600]}, + ) + assert len(annotated_swipe) > 0 + assert annotated_swipe != raw_bytes + + # 3. Click Sequence overlay + annotated_seq = draw_action_overlay_on_image( + image_bytes=raw_bytes, + action_name="click_sequence", + action_args={"sequence": [[200, 300], [400, 500], [600, 700]]}, + ) + assert len(annotated_seq) > 0 + assert annotated_seq != raw_bytes + + # 4. Fail-safe on corrupt bytes + corrupt_result = draw_action_overlay_on_image( + image_bytes=b"invalid_bytes", + action_name="click", + action_args={"target": [100, 100]}, + ) + assert corrupt_result == b"invalid_bytes" diff --git a/tests/unit/agents/test_history_analyzer.py b/tests/unit/agents/test_history_analyzer.py new file mode 100644 index 0000000..af0064e --- /dev/null +++ b/tests/unit/agents/test_history_analyzer.py @@ -0,0 +1,377 @@ +# 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 json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from langchain_core.messages import ToolMessage +from artemis.agents.history_analyzer.history_analyzer import HistoryAnalyzer +from artemis.context import ArtemisContext +import pytest + + +@pytest.mark.asyncio +async def test_history_analyzer_no_history(): + # Mock context with a data engine that has no steps + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.get_agent_friendly_steps.return_value = [] + + analyzer = HistoryAnalyzer(mock_ctx) + result = await analyzer.run("What happened?") + assert result == "No history recorded for this session yet." + + +@pytest.mark.asyncio +async def test_history_analyzer_simple_query_no_tool_call(): + # Mock context and data engine + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/fake_traces" + + steps = [ + { + "step_number": 1, + "relative_time": "1.2s", + "summary": "Opened the settings app", + }, + { + "step_number": 2, + "relative_time": "4.5s", + "summary": "Toggled the wifi switch", + }, + ] + mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps + + # Mock LLM to return a direct text response (no tool calls) + mock_response = MagicMock() + mock_response.content = "First you opened settings, then you toggled wifi." + mock_response.tool_calls = [] + + mock_llm = MagicMock() + mock_llm.bind_tools.return_value = mock_llm + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + + with ( + patch( + "artemis.agents.history_analyzer.history_analyzer.get_llm", + return_value=mock_llm, + ), + patch("pathlib.Path.exists", return_value=False), # Force system prompt fallback + ): + analyzer = HistoryAnalyzer(mock_ctx) + result = await analyzer.run("Summarize the session") + + assert result == "First you opened settings, then you toggled wifi." + assert mock_llm.astream.called + + +@pytest.mark.asyncio +async def test_history_analyzer_detailed_query_with_tool_call(): + # Mock context and data engine with full details + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/fake_traces" + + steps = [ + { + "step_number": 1, + "relative_time": "1.2s", + "summary": "Opened the settings app", + "action_taken": {"action": "tap", "coordinates": [500, 600]}, + "operator_raw_thinking": "Need to open settings to configure wifi", + "last_execution_result": {"status": "success"}, + }, + { + "step_number": 2, + "relative_time": "4.5s", + "summary": "Toggled the wifi switch", + "action_taken": {"action": "tap", "coordinates": [100, 200]}, + "operator_raw_thinking": "Toggle the switch to turn wifi on", + "last_execution_result": {"status": "success"}, + }, + ] + mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps + + # Turn 1 LLM response: Tool call to get_step_details for step 2 + mock_response_turn_1 = MagicMock() + mock_response_turn_1.content = "" + mock_tool_call = { + "name": "get_step_details", + "args": {"start_step": 2, "end_step": 2}, + "id": "call_123456", + } + mock_response_turn_1.tool_calls = [mock_tool_call] + + # Turn 2 LLM response: Final answer using the details + mock_response_turn_2 = MagicMock() + mock_response_turn_2.content = ( + "In step 2, the operator was thinking: 'Toggle the switch to turn wifi on'." + ) + mock_response_turn_2.tool_calls = [] + + mock_llm = MagicMock() + mock_llm.bind_tools.return_value = mock_llm + responses = [mock_response_turn_1, mock_response_turn_2] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + + with ( + patch( + "artemis.agents.history_analyzer.history_analyzer.get_llm", + return_value=mock_llm, + ), + patch("pathlib.Path.exists", return_value=False), # Force fallback prompt + ): + analyzer = HistoryAnalyzer(mock_ctx) + result = await analyzer.run("What was the operator thinking in step 2?") + + assert ( + result == "In step 2, the operator was thinking: 'Toggle the switch to turn wifi on'." + ) + assert mock_llm.astream.call_count == 2 + + # Verify the tool was invoked with correct parameters internally + tool_msg = mock_llm.astream.call_args_list[1][0][0][ + -2 + ] # ToolMessage is second to last after final response is appended + assert isinstance(tool_msg, ToolMessage) + assert tool_msg.tool_call_id == "call_123456" + + # Parse the JSON in the ToolMessage content + tool_result = json.loads(tool_msg.content) + assert len(tool_result) == 1 + assert tool_result[0]["step_number"] == 2 + assert tool_result[0]["operator_raw_thinking"] == "Toggle the switch to turn wifi on" + assert tool_result[0]["action_taken"] == { + "action": "tap", + "coordinates": [100, 200], + } + + +@pytest.mark.asyncio +async def test_history_analyzer_read_note_tool_call(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/fake_traces" + + steps = [{"step_number": 1, "relative_time": "1.0s", "summary": "Initiated"}] + mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps + + # Turn 1 LLM response: Tool call to list_notes + mock_response_turn_1 = MagicMock() + mock_response_turn_1.content = "" + mock_tool_call_list = {"name": "list_notes", "args": {}, "id": "call_list"} + mock_response_turn_1.tool_calls = [mock_tool_call_list] + + # Turn 2 LLM response: Tool call to read_note for tactical_plan + mock_response_turn_2 = MagicMock() + mock_response_turn_2.content = "" + mock_tool_call_read = { + "name": "read_note", + "args": {"key": "tactical_plan"}, + "id": "call_read", + } + mock_response_turn_2.tool_calls = [mock_tool_call_read] + + # Turn 3 LLM response: Final answer + mock_response_turn_3 = MagicMock() + mock_response_turn_3.content = "The plan was to toggle wifi." + mock_response_turn_3.tool_calls = [] + + mock_llm = MagicMock() + mock_llm.bind_tools.return_value = mock_llm + responses = [ + mock_response_turn_1, + mock_response_turn_2, + mock_response_turn_3, + ] + call_count = 0 + + async def mock_astream(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + yield responses[call_count] + call_count += 1 + + mock_llm.astream.side_effect = mock_astream + + # Mock the file system read using patch + fake_plan_content = "Goal: Toggle wifi" + + def mock_exists(path): + # Let Path("/tmp/fake_traces/notes").exists() and notes/tactical_plan.md exist + return True + + def mock_read_text(self, encoding="utf-8"): + return fake_plan_content + + def mock_glob(self, pattern): + return [Path("/tmp/fake_traces/notes/tactical_plan.md")] + + with ( + patch( + "artemis.agents.history_analyzer.history_analyzer.get_llm", + return_value=mock_llm, + ), + patch("pathlib.Path.exists", mock_exists), + patch("pathlib.Path.read_text", mock_read_text), + patch("pathlib.Path.glob", mock_glob), + ): + analyzer = HistoryAnalyzer(mock_ctx) + result = await analyzer.run("What is the saved tactical plan?") + + assert result == "The plan was to toggle wifi." + assert mock_llm.astream.call_count == 3 + + # Verify list_notes response + list_msg = mock_llm.astream.call_args_list[1][0][0][ + 3 + ] # Index 3 contains the ToolMessage for list_notes + assert isinstance(list_msg, ToolMessage) + assert list_msg.tool_call_id == "call_list" + assert list_msg.content == "Here are all the notes:\n- tactical_plan (1 lines)" + + # Verify read_note response + read_msg = mock_llm.astream.call_args_list[2][0][0][ + 5 + ] # Index 5 contains the ToolMessage for read_note + assert isinstance(read_msg, ToolMessage) + assert read_msg.tool_call_id == "call_read" + assert ( + read_msg.content == "Successfully read note 'tactical_plan'. 'tactical_plan' note" + f" content:\n{fake_plan_content}" + ) + + +def test_history_analyzer_robust_tools_behavior(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/fake_traces" + + steps = [ + { + "step_number": 1, + "relative_time": "1.0s", + "summary": "Action 1", + "action_taken": {"action": "tap"}, + } + ] + + analyzer = HistoryAnalyzer(mock_ctx) + + # 1. Test get_step_details with string inputs + details_tool = analyzer._get_step_details_tool(steps) + result_details = details_tool.invoke({"start_step": "1", "end_step": "1"}) + parsed_result = json.loads(result_details) + assert len(parsed_result) == 1 + assert parsed_result[0]["step_number"] == 1 + + # 2. Test read_note with .md suffix + from artemis.tools.scratchpad import get_read_note_tool_pure + + read_tool = get_read_note_tool_pure(analyzer.ctx) + + def mock_exists(path): + return "tactical_plan.md" in str(path) + + with ( + patch("pathlib.Path.exists", mock_exists), + patch("pathlib.Path.read_text", return_value="Tactical plan content"), + ): + result_read = read_tool.invoke({"key": "tactical_plan.md"}) + assert ( + result_read == "Successfully read note 'tactical_plan.md'. 'tactical_plan.md'" + " note content:\nTactical plan content" + ) + + +@pytest.mark.asyncio +async def test_history_analyzer_integration_with_task_tree(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = "/tmp/fake_traces" + + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "1.0s", + "summary": "Step 1", + }, + ] + mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps + + mock_response = MagicMock() + mock_response.content = "Answer" + mock_response.tool_calls = [] + + mock_llm = MagicMock() + mock_llm.bind_tools.return_value = mock_llm + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + + fake_plan = """- [/] Active Subgoal +- [ ] Pending Subgoal""" + + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_path.read_text.return_value = fake_plan + + # Mock build_plan_and_history + with ( + patch( + "artemis.agents.history_analyzer.history_analyzer.get_llm", + return_value=mock_llm, + ), + patch( + "artemis.agents.history_analyzer.history_analyzer.get_note_file_path", + return_value=mock_path, + ), + patch( + "artemis.agents.history_analyzer.history_analyzer.build_plan_and_history" + ) as mock_build, + ): + mock_build.return_value = "Mocked operation history" + analyzer = HistoryAnalyzer(mock_ctx) + await analyzer.run("query") + + # Verify build_plan_and_history call args + mock_build.assert_called_once() + args, kwargs = mock_build.call_args + assert args[0] == fake_plan + assert args[1] == steps + + import hashlib + + expected_hash = hashlib.md5(b"Active Subgoal").hexdigest() + assert args[2] == expected_hash + + assert kwargs.get("last_n_detailed") == 1 + assert kwargs.get("min_summaries") == len(steps) diff --git a/tests/unit/agents/test_operator.py b/tests/unit/agents/test_operator.py new file mode 100644 index 0000000..c79ce7b --- /dev/null +++ b/tests/unit/agents/test_operator.py @@ -0,0 +1,1590 @@ +# 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 json +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from artemis.agents.operator.operator import OperatorNode +from artemis.context import ArtemisContext +import pytest + + +@pytest.mark.asyncio +async def test_operator_node_fast_path(): + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + mock_ctx.trace_id = "test-trace" + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.subgoal_plan = [] + mock_state.initial_goal = "Test goal" + mock_state.operator_tactical_plan = [] + + # Mock controller and data + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock( + return_value="YmFzZTY0X3NjcmVlbnNob3Q=" + ) # "base64_screenshot" in base64 + + mock_device_data = MagicMock() + mock_device_data.elements = [{"text": "Item 1", "bounds": "[0,0][100,100]"}] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + # Mock LLM response + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [ + { + "name": "click", + "args": {"target": [50, 50], "reasoning": "Test reasoning"}, + "id": "call_123", + } + ] + + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm # Allow chaining + + # Patch dependencies + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx) + await node(mock_state) + + # Verify state was updated with action + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [54, 120] + assert update_dict["complete_subgoals_by_ids"] == [] + + +@pytest.mark.asyncio +async def test_perform_action_validation(): + from artemis.agents.operator.operator import OperatorNode + from unittest.mock import MagicMock + from artemis.graph.state import State + + mock_ctx = MagicMock() + node = OperatorNode(mock_ctx) + + mock_state = MagicMock(spec=State) + mock_state.indexed_points = [[200, 300], [400, 500]] + mock_state.indexed_elements = [ + { + "center": [200, 300], + "text": "Item 1", + "bounds": "[100,100][300,500]", + "class": "android.widget.Button", + "resource_id": "btn_1", + }, + { + "center": [400, 500], + "text": "hello", + "bounds": "[300,400][500,600]", + "class": "android.widget.EditText", + "resource_id": "input_1", + }, + ] + + # Test click with valid target index + actions, err = node._translate_and_validate_tool( + {"name": "click", "args": {"target": 1}}, mock_state + ) + assert err is None + assert actions == [ + { + "action": "tap", + "coordinates": [200, 300], + "normalized_coordinates": [185, 125], + "times": 1, + "delay_ms": 100, + "target_text": "Item 1", + "target_bounds": "[100,100][300,500]", + "target_resource_id": "btn_1", + "target_class": "android.widget.Button", + } + ] + + # Test click with invalid index + actions, err = node._translate_and_validate_tool( + {"name": "click", "args": {"target": 5}}, mock_state + ) + assert "Error" in err + + # Test input_text with clear_exist + actions, err = node._translate_and_validate_tool( + { + "name": "input_text", + "args": {"text": "hello", "target": 2, "clear_exist": True}, + }, + mock_state, + ) + assert err is None + assert len(actions) == 1 + assert actions[0] == { + "action": "focus_and_input_text", + "coordinates": [400, 500], + "normalized_coordinates": [370, 208], + "text": "hello", + "clear_before_input": True, + "target_text": "hello", + "target_bounds": "[300,400][500,600]", + "target_resource_id": "input_1", + "target_class": "android.widget.EditText", + } + + # Test swipe direction + actions, err = node._translate_and_validate_tool( + {"name": "swipe", "args": {"gesture": "up"}}, mock_state + ) + assert err is None + assert actions[0]["action"] == "swipe" + assert len(actions[0]["coordinates"]) == 4 + assert actions[0]["duration"] == 380 + + # Test click with float target index (coercion) + actions, err = node._translate_and_validate_tool( + {"name": "click", "args": {"target": 2.0}}, mock_state + ) + assert err is None + assert actions == [ + { + "action": "tap", + "coordinates": [400, 500], + "normalized_coordinates": [370, 208], + "times": 1, + "delay_ms": 100, + "target_text": "hello", + "target_bounds": "[300,400][500,600]", + "target_resource_id": "input_1", + "target_class": "android.widget.EditText", + } + ] + + # Test input_text with missing text argument + actions, err = node._translate_and_validate_tool( + {"name": "input_text", "args": {"target": 2}}, mock_state + ) + assert "Error" in err + assert "Missing required argument 'text'" in err + + # Test input_text with non-string text argument + actions, err = node._translate_and_validate_tool( + {"name": "input_text", "args": {"text": 123, "target": 2}}, mock_state + ) + assert "Error" in err + assert "text' must be a string" in err + + # Test swipe with invalid duration + actions, err = node._translate_and_validate_tool( + {"name": "swipe", "args": {"gesture": "up", "duration": "fast"}}, + mock_state, + ) + assert "Error" in err + assert "duration' must be a number" in err + + # Test swipe with float duration (coercion) + actions, err = node._translate_and_validate_tool( + {"name": "swipe", "args": {"gesture": "up", "duration": 500.0}}, + mock_state, + ) + assert err is None + assert actions[0]["duration"] == 500 + + # Test manage_app with missing app_name + actions, err = node._translate_and_validate_tool( + {"name": "manage_app", "args": {"action": "launch"}}, mock_state + ) + assert "Error" in err + assert "app_name' must be a valid non-empty string" in err + + # Test manage_app with invalid action + actions, err = node._translate_and_validate_tool( + { + "name": "manage_app", + "args": {"action": "restart", "app_name": "Settings"}, + }, + mock_state, + ) + assert "Error" in err + assert "Unsupported app management action" in err + + +@pytest.mark.asyncio +async def test_operator_node_multiple_actions(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.subgoal_plan = [] + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [ + { + "name": "input_text", + "args": {"text": "hello", "target": [50, 50]}, + "id": "call_1", + }, + {"name": "click", "args": {"target": [50, 50]}, "id": "call_2"}, + ] + + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm + + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx) + await node(mock_state) + + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 2 + assert decisions[0]["action"] == "focus_and_input_text" + assert decisions[0]["text"] == "hello" + assert decisions[0]["coordinates"] == [54, 120] + assert decisions[0]["clear_before_input"] == True + assert decisions[1]["action"] == "tap" + assert decisions[1]["coordinates"] == [54, 120] + + +@pytest.mark.asyncio +async def test_operator_node_no_record_step(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.current_session_id = "test-session" + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm + + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx) + await node(mock_state) + + mock_ctx.data_engine.record_step.assert_not_called() + + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + assert "structured_decisions" in update_dict + + +@pytest.mark.asyncio +async def test_operator_dynamic_prompt(): + from artemis.agents.operator.operator import OperatorNode, PromptComponent + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + class MockComponent(PromptComponent): + async def __call__(self, builder, state, ctx, **kwargs): + builder.add_system_text("System part.") + builder.add_human_content("Human part.") + builder.set_human_footer("Footer part.") + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + async def mock_ainvoke(*args, **kwargs): + messages = args[0] + assert len(messages) == 2 + assert messages[0].content == "System part." + assert messages[1].content[0]["text"] == "Human part." + assert messages[1].content[-1]["text"] == "Footer part." + return mock_response + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx, prompt_components=[MockComponent()]) + await node(mock_state) + + +@pytest.mark.asyncio +async def test_operator_history_compression(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + import hashlib + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + subgoal_old_hash = hashlib.md5(b"subgoal_old").hexdigest() + subgoal_current_hash = hashlib.md5(b"subgoal_current").hexdigest() + + # Create mock steps spanning multiple subgoals + # We need at least 5 summaries to trigger folding of older subgoals + mock_steps = [ + { + "step_id": "step_1", + "step_number": 1, + "summary": "Step 1 summary", + "relative_time": "1.0s", + "extra_metadata": {"subgoal_hash": subgoal_old_hash}, + }, + { + "step_id": "step_2", + "step_number": 2, + "summary": "Step 2 summary", + "relative_time": "2.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + }, + { + "step_id": "step_3", + "step_number": 3, + "summary": "Step 3 summary", + "relative_time": "3.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + }, + { + "step_id": "step_4", + "step_number": 4, + "summary": "Step 4 summary", + "relative_time": "4.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + }, + { + "step_id": "step_5", + "step_number": 5, + "summary": "Step 5 summary", + "relative_time": "5.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + }, + { + "step_id": "step_6", + "step_number": 6, + "summary": "Step 6 summary", + "relative_time": "6.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + }, + { + "step_id": "step_7", + "step_number": 7, + "summary": "Step 7 summary", + "relative_time": "7.0s", + "extra_metadata": {"subgoal_hash": subgoal_current_hash}, + "action_taken": {"action": "tap"}, + }, + ] + mock_ctx.data_engine.get_agent_friendly_steps.return_value = mock_steps + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + # Smarter file reading mock + def mock_read_text(path_self, encoding=None): + path_str = str(path_self) + if "operator.json" in path_str: + return json.dumps( + { + "main_template": ( + "Goals: {{ initial_goal }}\nPlan:\n{{ plan_and_history" + " }}\n# CURRENT OBSERVATION" + ), + "troubleshooter_template": ( + "Goals: {{ initial_goal }}\nPlan:\n{{ plan_and_history" + " }}\n# CURRENT OBSERVATION" + ), + } + ) + elif "task_plan.md" in path_str: + return """- [x] subgoal_old +- [/] subgoal_current""" + return "" + + async def mock_ainvoke(*args, **kwargs): + messages = args[0] + full_text = "".join([str(m.content) for m in messages]) + + # Check that old subgoal line is present + assert "- [x] subgoal_old" in full_text + # Check that step 1 (old) is NOT present (folded) + assert "Step 1 summary" not in full_text + + # Check that current subgoal line is present + assert "- [/] subgoal_current" in full_text + # Check that current subgoal steps are present as summaries + assert "Step 2 summary" in full_text + assert "Step 6 summary" not in full_text + assert "Step 6" in full_text + # Check that last step has full details + assert "Step 7" in full_text + return mock_response + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.OperatorNode._get_active_subgoal_hash", + return_value=subgoal_current_hash, + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_text", mock_read_text), + ): + node = OperatorNode(mock_ctx, last_n_detailed=5) + await node(mock_state) + + +@pytest.mark.asyncio +async def test_operator_loop_detection(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import MagicMock + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = ["ask_explorer", "ask_explorer", "ask_explorer"] + mock_state.initial_goal = "Test goal" + + node = OperatorNode(mock_ctx) + + with pytest.raises(RuntimeError) as exc_info: + await node(mock_state) + + assert "Infinite loop detected" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_operator_tracks_subagent_calls(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = ["ask_explorer"] + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [ + {"name": "ask_diagnoser", "args": {"query": "test"}, "id": "call_1"} + ] + + mock_response_2 = MagicMock() + mock_response_2.tool_calls = [{"name": "click", "args": {"target": [10, 20]}, "id": "call_2"}] + + responses = [mock_response, mock_response_2] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + mock_diagnoser_tool = MagicMock() + mock_diagnoser_tool.name = "ask_diagnoser" + mock_diagnoser_tool.args = {"state": None} + mock_diagnoser_tool.ainvoke = AsyncMock(return_value="Diagnosis result") + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool]) + await node(mock_state) + + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "subagent_calls" in update_dict + assert update_dict["subagent_calls"] == [ + "ask_explorer", + "ask_diagnoser", + ] + + +@pytest.mark.asyncio +async def test_operator_defer_action_for_gathering(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + + # First response calls both ask_diagnoser (gathering) and click (action) + mock_response_1 = MagicMock() + mock_response_1.tool_calls = [ + { + "name": "ask_diagnoser", + "args": {"query": "check something"}, + "id": "call_diagnose", + }, + {"name": "click", "args": {"target": [50, 50]}, "id": "call_click"}, + ] + + # Second response only calls click + mock_response_2 = MagicMock() + mock_response_2.tool_calls = [ + { + "name": "click", + "args": {"target": [50, 50]}, + "id": "call_click_final", + } + ] + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + from langgraph.prebuilt import InjectedState + from typing import Annotated + + mock_diagnoser_tool = MagicMock() + mock_diagnoser_tool.name = "ask_diagnoser" + mock_diagnoser_tool.args = {"state": None} + + mock_call_receiver = Mock() + + async def dummy_diagnoser_func(query: str, state: Annotated[MagicMock, InjectedState]): + mock_call_receiver(query=query, state=state) + return "Diagnostics OK" + + mock_diagnoser_tool.coroutine = dummy_diagnoser_func + mock_diagnoser_tool.func = None + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool]) + await node(mock_state) + + # Subagent tool should have been executed + mock_call_receiver.assert_called_once_with(query="check something", state=mock_state) + + # Verify final state has the action execution from the second response + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 1 + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [54, 120] + + +@pytest.mark.asyncio +async def test_operator_defer_reply_to_checker_for_gathering(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + from pathlib import Path + + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = Path("/tmp/test_session") + mock_ctx.data_engine.get_agent_friendly_steps.return_value = [] + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_llm = MagicMock() + + # First response calls ask_diagnoser and reply_to_checker + mock_response_1 = MagicMock() + mock_response_1.tool_calls = [ + {"name": "ask_diagnoser", "args": {"query": "test"}, "id": "call_diag"}, + { + "name": "reply_to_checker", + "args": {"reasoning": "I solved it"}, + "id": "call_reply", + }, + ] + + # Second response stops without action tools + mock_response_2 = MagicMock() + mock_response_2.tool_calls = [] + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + from langgraph.prebuilt import InjectedState + from typing import Annotated + + mock_diagnoser_tool = MagicMock() + mock_diagnoser_tool.name = "ask_diagnoser" + mock_diagnoser_tool.args = {"state": None} + + mock_call_receiver = Mock() + + async def dummy_diagnoser_func(query: str, state: Annotated[MagicMock, InjectedState]): + mock_call_receiver(query=query, state=state) + return "Diagnostics completed" + + mock_diagnoser_tool.coroutine = dummy_diagnoser_func + mock_diagnoser_tool.func = None + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + patch( + "artemis.agents.operator.operator.OperatorNode._has_checker_feedback", + return_value=True, + ), + patch( + "artemis.agents.operator.operator.OperatorNode._get_verification_chat_rounds", + return_value=(1, 1), + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool]) + await node(mock_state) + + # Subagent was called + mock_call_receiver.assert_called_once() + + # Verify reply to checker was NOT executed / flagged + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert update_dict.get("operator_replied") is False + + +@pytest.mark.asyncio +async def test_wait_actions_translation(): + from artemis.agents.operator.operator import OperatorNode + from unittest.mock import MagicMock + from artemis.graph.state import State + + mock_ctx = MagicMock() + node = OperatorNode(mock_ctx) + mock_state = MagicMock(spec=State) + + # 1. Test wait_for_delay translation + actions, err = node._translate_and_validate_tool( + {"name": "wait_for_delay", "args": {"time_in_ms": 2500}}, mock_state + ) + assert err is None + assert actions == [{"action": "wait_for_delay", "time_in_ms": 2500}] + + # Test wait_for_delay invalid type coercion + actions, err = node._translate_and_validate_tool( + {"name": "wait_for_delay", "args": {"time_in_ms": "3000"}}, mock_state + ) + assert err is None + assert actions == [{"action": "wait_for_delay", "time_in_ms": 3000}] + + +@pytest.mark.asyncio +async def test_long_press_action_translation(): + from artemis.agents.operator.operator import OperatorNode + from unittest.mock import MagicMock + from artemis.graph.state import State + + mock_ctx = MagicMock() + node = OperatorNode(mock_ctx) + mock_state = MagicMock(spec=State) + mock_state.indexed_points = [[200, 300], [400, 500]] + mock_state.indexed_elements = [ + { + "center": [200, 300], + "text": "Item 1", + "bounds": "[100,100][300,500]", + "class": "android.widget.Button", + "resource_id": "btn_1", + }, + { + "center": [400, 500], + "text": "hello", + "bounds": "[300,400][500,600]", + "class": "android.widget.EditText", + "resource_id": "input_1", + }, + ] + + # 1. Test long_press with element index target + actions, err = node._translate_and_validate_tool( + {"name": "long_press", "args": {"target": 1, "duration": 1500}}, + mock_state, + ) + assert err is None + assert actions == [ + { + "action": "long_press_on", + "coordinates": [200, 300], + "normalized_coordinates": [185, 125], + "duration": 1500, + "target_text": "Item 1", + "target_bounds": "[100,100][300,500]", + "target_resource_id": "btn_1", + "target_class": "android.widget.Button", + } + ] + + # 2. Test long_press with custom coordinates and default duration + actions, err = node._translate_and_validate_tool( + {"name": "long_press", "args": {"target": [500, 600]}}, mock_state + ) + assert err is None + # Since coordinates are in 0-1000 scale, they should be converted using device width (default 1080) and height (default 2400) + # x = 500 * 1080 / 1000 = 540 + # y = 600 * 2400 / 1000 = 1440 + assert actions == [ + { + "action": "long_press_on", + "coordinates": [540, 1440], + "normalized_coordinates": [500, 600], + "duration": 1000, + "target_text": None, + "target_bounds": None, + "target_resource_id": None, + "target_class": None, + } + ] + + # 3. Test long_press with invalid index + actions, err = node._translate_and_validate_tool( + {"name": "long_press", "args": {"target": 5}}, mock_state + ) + assert "Error" in err + + +@pytest.mark.asyncio +async def test_operator_no_defer_for_note_updating(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + + # LLM calls both update_note (writing, non-deferring) and click (action) in 1st turn + mock_response_1 = MagicMock() + mock_response_1.tool_calls = [ + { + "name": "update_note", + "args": {"key": "progress", "content": "step 1"}, + "id": "call_save", + }, + {"name": "click", "args": {"target": [50, 50]}, "id": "call_click"}, + ] + + # We only expect 1 LLM call (astream should only yield once) + responses = [mock_response_1] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + # Mock the update_note tool + mock_update_note_tool = MagicMock() + mock_update_note_tool.name = "update_note" + mock_update_note_tool.args = {} + + mock_call_receiver = MagicMock() + + async def dummy_update_note(key: str, content: str, **kwargs): + mock_call_receiver(key=key, content=content) + return "Note updated" + + mock_update_note_tool.coroutine = dummy_update_note + mock_update_note_tool.func = None + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_update_note_tool]) + await node(mock_state) + + # The update_note tool should have been executed immediately + mock_call_receiver.assert_called_once_with(key="progress", content="step 1") + + # The click action should NOT be deferred, so it should be in structured_decisions + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 1 + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [54, 120] + + +@pytest.mark.asyncio +async def test_operator_defer_for_note_reading(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + + # 1st turn: LLM calls both read_note (reading, deferring) and click (action) + mock_response_1 = MagicMock() + mock_response_1.tool_calls = [ + {"name": "read_note", "args": {"key": "progress"}, "id": "call_read"}, + {"name": "click", "args": {"target": [50, 50]}, "id": "call_click"}, + ] + + # 2nd turn: LLM calls only click after receiving note content + mock_response_2 = MagicMock() + mock_response_2.tool_calls = [ + { + "name": "click", + "args": {"target": [50, 50]}, + "id": "call_click_final", + } + ] + + responses = [mock_response_1, mock_response_2] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + # Mock the read_note tool + mock_read_note_tool = MagicMock() + mock_read_note_tool.name = "read_note" + mock_read_note_tool.args = {} + + mock_call_receiver = MagicMock() + + async def dummy_read_note(key: str, **kwargs): + mock_call_receiver(key=key) + return "Note content" + + mock_read_note_tool.coroutine = dummy_read_note + mock_read_note_tool.func = None + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_read_note_tool]) + await node(mock_state) + + # The read_note tool should have been executed + mock_call_receiver.assert_called_once_with(key="progress") + + # The click action from the first response should have been deferred, + # so we should have needed the second response to get the final click. + # Total call_count should be 2. + assert call_count == 2 + + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 1 + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [54, 120] + + +@pytest.mark.asyncio +async def test_operator_no_defer_for_note_appending(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + + # LLM calls both append_note (writing, non-deferring) and click (action) in 1st turn + mock_response_1 = MagicMock() + mock_response_1.tool_calls = [ + { + "name": "append_note", + "args": {"key": "progress", "content": "step 1"}, + "id": "call_append", + }, + {"name": "click", "args": {"target": [50, 50]}, "id": "call_click"}, + ] + + responses = [mock_response_1] + call_count = 0 + + async def mock_ainvoke(*args, **kwargs): + nonlocal call_count + if call_count < len(responses): + res = responses[call_count] + call_count += 1 + return res + + mock_llm.ainvoke.side_effect = mock_ainvoke + mock_llm.bind_tools.return_value = mock_llm + + # Mock the append_note tool + mock_append_note_tool = MagicMock() + mock_append_note_tool.name = "append_note" + mock_append_note_tool.args = {} + + mock_call_receiver = MagicMock() + + async def dummy_append_note(key: str, content: str, **kwargs): + mock_call_receiver(key=key, content=content) + return "Note appended" + + mock_append_note_tool.coroutine = dummy_append_note + mock_append_note_tool.func = None + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + node = OperatorNode(mock_ctx, tools=[mock_append_note_tool]) + await node(mock_state) + + # The append_note tool should have been executed immediately + mock_call_receiver.assert_called_once_with(key="progress", content="step 1") + + # The click action should NOT be deferred, so it should be in structured_decisions + mock_state.asanitize_update.assert_called_once() + args, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 1 + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [54, 120] + + +@pytest.mark.asyncio +async def test_operator_background_tasks_prompt_injection(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from artemis.tools.command_tool import _BACKGROUND_TASKS, BackgroundTask + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm + + # Setup a mock background task + mock_proc = MagicMock() + bg_task = BackgroundTask( + task_id="task_99999", + command="ping google.com", + process=mock_proc, + terminal_id="term_xyz", + cwd="/data/local/tmp", + ) + + _BACKGROUND_TASKS["task_99999"] = bg_task + + try: + captured_messages = [] + + async def mock_invoke_llm_loop(*args, **kwargs): + active_background_tasks = [ + { + "task_id": "task_99999", + "command": "ping google.com", + "cwd": "/data/local/tmp", + "terminal_id": "term_xyz", + "output_line_count": 0, + } + ] + messages = await node._build_prompt( + state=mock_state, + latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", + fused_xml=[], + minimal_list="", + current_step_num=1, + steps=[], + task_plan="", + active_background_tasks=active_background_tasks, + ) + captured_messages.extend(messages) + return [], False, None, None, False + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", + side_effect=mock_invoke_llm_loop, + ), + ): + node = OperatorNode(mock_ctx) + await node(mock_state) + + # Assert that the captured messages (prompts) contain the background tasks section + full_content = "" + for m in captured_messages: + if isinstance(m.content, list): + for part in m.content: + if isinstance(part, dict) and "text" in part: + full_content += part["text"] + "\n" + elif isinstance(m.content, str): + full_content += m.content + "\n" + + assert "--- Active Background ADB Tasks ---" in full_content + assert "task_99999" in full_content + assert "ping google.com" in full_content + assert "term_xyz" in full_content + assert "Accumulated Output: 0 lines of logs" in full_content + finally: + if "task_99999" in _BACKGROUND_TASKS: + del _BACKGROUND_TASKS["task_99999"] + + +@pytest.mark.asyncio +async def test_operator_task_plan_warning_prompt_injection(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm + + # Case 1: task_plan updated in the last two steps (using the "key" argument) + steps_updated = [ + { + "step_id": "step_1", + "step_number": 1, + "tool_calls": [ + { + "name": "update_note", + "args": {"key": "task_plan", "target": "a", "replacement": "b"}, + } + ], + }, + { + "step_id": "step_2", + "step_number": 2, + "tool_calls": [{"name": "click", "args": {"target": 1}}], + }, + ] + + captured_messages_1 = [] + + async def mock_invoke_llm_loop_1(*args, **kwargs): + messages = await node._build_prompt( + state=mock_state, + latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", + fused_xml=[], + minimal_list="", + current_step_num=3, + steps=steps_updated, + task_plan="", + ) + captured_messages_1.extend(messages) + return [], False, None, None, False + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", + side_effect=mock_invoke_llm_loop_1, + ), + ): + node = OperatorNode(mock_ctx) + await node(mock_state) + + full_content_1 = "" + for m in captured_messages_1: + if isinstance(m.content, list): + for part in m.content: + if isinstance(part, dict) and "text" in part: + full_content_1 += part["text"] + "\n" + elif isinstance(m.content, str): + full_content_1 += m.content + "\n" + + assert ( + "Reminder: You have not updated the task_plan for two consecutive" + " turns." not in full_content_1 + ) + + # Case 2: task_plan NOT updated in the last two steps + steps_not_updated = [ + { + "step_id": "step_1", + "step_number": 1, + "tool_calls": [{"name": "click", "args": {"target": 1}}], + }, + { + "step_id": "step_2", + "step_number": 2, + "tool_calls": [{"name": "click", "args": {"target": 2}}], + }, + ] + + captured_messages_2 = [] + + async def mock_invoke_llm_loop_2(*args, **kwargs): + messages = await node._build_prompt( + state=mock_state, + latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", + fused_xml=[], + minimal_list="", + current_step_num=3, + steps=steps_not_updated, + task_plan="", + ) + captured_messages_2.extend(messages) + return [], False, None, None, False + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", + side_effect=mock_invoke_llm_loop_2, + ), + ): + node = OperatorNode(mock_ctx) + await node(mock_state) + + full_content_2 = "" + for m in captured_messages_2: + if isinstance(m.content, list): + for part in m.content: + if isinstance(part, dict) and "text" in part: + full_content_2 += part["text"] + "\n" + elif isinstance(m.content, str): + full_content_2 += m.content + "\n" + + assert ( + "Reminder: You have not updated the task_plan for two consecutive" + " turns." in full_content_2 + ) + + +@pytest.mark.asyncio +async def test_operator_tool_limit_exceeded_warning(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + # Set the flag to True to simulate tool call limit exceeded in previous turn + mock_state.operator_tool_limit_exceeded = True + mock_state.initial_goal = "Test goal" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + captured_messages = [] + + async def mock_invoke_llm_loop(*args, **kwargs): + messages = await node._build_prompt( + state=mock_state, + latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", + fused_xml=[], + minimal_list="", + current_step_num=1, + steps=[], + task_plan="", + ) + captured_messages.extend(messages) + # Return 5 elements: action_result, requested_argue, raw_thinking, native_thinking, tool_limit_exceeded + return [], False, None, None, False + + with ( + patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), + patch( + "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", + side_effect=mock_invoke_llm_loop, + ), + ): + node = OperatorNode(mock_ctx) + await node(mock_state) + + full_content = "" + for m in captured_messages: + if isinstance(m.content, list): + for part in m.content: + if isinstance(part, dict) and "text" in part: + full_content += part["text"] + "\n" + elif isinstance(m.content, str): + full_content += m.content + "\n" + + assert ( + "You did not execute any screen interaction actions in your last turn" in full_content + ) + + mock_state.asanitize_update.assert_called_once() + _, kwargs = mock_state.asanitize_update.call_args + assert kwargs["update"].get("operator_tool_limit_exceeded") is False + + +@pytest.mark.asyncio +async def test_operator_fallback_function_call_parsing(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import AsyncMock, MagicMock, patch + from langchain_core.messages import AIMessage + import json + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.execution_setup = None + mock_ctx.data_engine = None + + mock_state = MagicMock() + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + # Mock response with empty tool_calls but populated additional_kwargs['function_call'] + mock_response = AIMessage( + content="I intend to click the button.", + additional_kwargs={ + "function_call": { + "name": "click", + "arguments": '{"target": [123, 456]}', + } + }, + tool_calls=[], + ) + + mock_llm = MagicMock() + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_llm + + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx) + await node(mock_state) + + # Verify state was updated with parsed click action + mock_state.asanitize_update.assert_called_once() + _, kwargs = mock_state.asanitize_update.call_args + update_dict = kwargs["update"] + + assert "structured_decisions" in update_dict + decisions = json.loads(update_dict["structured_decisions"]) + assert len(decisions) == 1 + assert decisions[0]["action"] == "tap" + assert decisions[0]["coordinates"] == [132, 1094] + + +@pytest.mark.asyncio +async def test_operator_swipe_translation(): + from artemis.agents.operator.operator import OperatorNode + from artemis.context import ArtemisContext + from unittest.mock import MagicMock + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_state = MagicMock() + mock_state.operator_raw_data = {"width": 1080, "height": 2400} + node = OperatorNode(mock_ctx) + + # 1. Directional swipe + actions, err = node._translate_and_validate_tool( + {"name": "swipe", "args": {"direction": "up"}}, mock_state + ) + assert err is None + assert len(actions) == 1 + assert actions[0]["action"] == "swipe" + assert actions[0]["coordinates"] == [648, 1920, 648, 480] + + # 2. Start & End coordinate swipe (drag) + actions, err = node._translate_and_validate_tool( + { + "name": "swipe", + "args": {"start": [100, 200], "end": [500, 600], "duration": 1500}, + }, + mock_state, + ) + assert err is None + assert len(actions) == 1 + assert actions[0]["action"] == "swipe" + assert actions[0]["coordinates"] == [108, 480, 540, 1440] + assert actions[0]["duration"] == 1500 + + # 3. Coordinates alias + actions, err = node._translate_and_validate_tool( + { + "name": "swipe", + "args": {"coordinates": [200, 300, 400, 500]}, + }, + mock_state, + ) + assert err is None + assert len(actions) == 1 + assert actions[0]["action"] == "swipe" + assert actions[0]["coordinates"] == [216, 720, 432, 1200] + + # 4. Backward compatible gesture parameter + actions, err = node._translate_and_validate_tool( + { + "name": "swipe", + "args": {"gesture": "down"}, + }, + mock_state, + ) + assert err is None + assert len(actions) == 1 + assert actions[0]["action"] == "swipe" + assert actions[0]["coordinates"] == [648, 480, 648, 1920] diff --git a/tests/unit/agents/test_outputter.py b/tests/unit/agents/test_outputter.py new file mode 100644 index 0000000..148b841 --- /dev/null +++ b/tests/unit/agents/test_outputter.py @@ -0,0 +1,508 @@ +# 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 AsyncMock, Mock, patch + +from pydantic import BaseModel +import pytest + +from artemis.agents.outputter.outputter import outputter +from artemis.config import LLM, OutputConfig # noqa: E402 +from artemis.context import ArtemisContext # noqa: E402 +from artemis.utils.logger import get_logger # noqa: E402 + +logger = get_logger(__name__) + + +class _CyFunctionDetectorMeta(type): + def __instancecheck__(self, instance): + name = type(instance).__name__ + return ( + name + in ( + "cyfunction", + "cython_function_or_method", + "builtin_function_or_method", + ) + or "cyfunction" in name.lower() + ) + + +class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta): + pass + + +class MockPydanticSchema(BaseModel): + model_config = {"ignored_types": (CyFunctionDetector,)} + color: str + price: float + currency_symbol: str + website_url: str + + +mock_dict = { + "color": "green", + "price": 20, + "currency_symbol": "$", + "website_url": "http://superwebsite.fr", +} + + +class DummyState: + def __init__(self, messages, initial_goal, operator_raw_data=None): + self.messages = messages + self.initial_goal = initial_goal + self.operator_raw_data = operator_raw_data + + +@pytest.fixture +def mock_context(): + """Create a properly mocked context with all required fields.""" + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = { + "planner": LLM(provider="openai", model="gpt-5-nano"), + "operator": LLM(provider="openai", model="gpt-5-nano"), + "validator": LLM(provider="openai", model="gpt-5-nano"), + } + ctx.device = Mock() + ctx.data_engine = None + return ctx + + +@pytest.fixture +def mock_state(): + """Create a mock state with test data.""" + return DummyState( + messages=[], + initial_goal="Find a green product on my website", + ) + + +def setup_mock_llm(mock_get_llm, react_response_content="Paris", structured_response=None): + mock_llm = Mock() + mock_llm_fallback = Mock() + + # Mock bind_tools + mock_llm_with_tools = Mock() + mock_llm_with_tools.ainvoke = AsyncMock() + mock_react_response = Mock() + mock_react_response.content = react_response_content + mock_react_response.tool_calls = [] + mock_llm_with_tools.ainvoke.return_value = mock_react_response + + mock_llm.bind_tools.return_value = mock_llm_with_tools + mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools + + # Mock with_structured_output + mock_structured_llm = Mock() + mock_structured_llm.ainvoke = AsyncMock() + if structured_response is not None: + mock_structured_llm.ainvoke.return_value = structured_response + mock_llm.with_structured_output.return_value = mock_structured_llm + mock_llm_fallback.with_structured_output.return_value = mock_structured_llm + + def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): + if use_fallback: + return mock_llm_fallback + return mock_llm + + mock_get_llm.side_effect = get_llm_side_effect + + return mock_llm, mock_llm_with_tools, mock_structured_llm + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_with_pydantic_model(mock_get_llm, mock_context, mock_state): + """Test outputter with Pydantic model output.""" + expected_structured = MockPydanticSchema( + color="green", + price=20, + currency_symbol="$", + website_url="http://superwebsite.fr", + ) + setup_mock_llm( + mock_get_llm, + react_response_content="Raw details about green product", + structured_response=expected_structured, + ) + + config = OutputConfig( + structured_output=MockPydanticSchema, + output_description=None, + ) + + result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) + + assert isinstance(result, dict) + assert result.get("color") == "green" + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_with_dict(mock_get_llm, mock_context, mock_state): + """Test outputter with dictionary output.""" + expected_dict = { + "color": "green", + "price": 20, + "currency_symbol": "$", + "website_url": "http://superwebsite.fr", + } + setup_mock_llm( + mock_get_llm, + react_response_content="Raw details", + structured_response=expected_dict, + ) + + config = OutputConfig( + structured_output=mock_dict, + output_description=None, + ) + + result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) + + assert isinstance(result, dict) + assert result.get("color") == "green" + assert result.get("price") == 20 + assert result.get("currency_symbol") == "$" + assert result.get("website_url") == "http://superwebsite.fr" + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_with_natural_language_output(mock_get_llm, mock_context, mock_state): + """Test outputter with natural language description output (returns JSON string).""" + expected_json = ( + '{"color": "green", "price": 20, "currency_symbol": "$", "website_url":' + ' "http://superwebsite.fr"}' + ) + setup_mock_llm(mock_get_llm, react_response_content=expected_json) + + config = OutputConfig( + structured_output=None, + output_description=( + "A JSON object with a color, a price, a currency_symbol and a website_url key" + ), + ) + + result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) + + assert isinstance(result, dict) + assert result.get("color") == "green" + assert result.get("price") == 20 + assert result.get("currency_symbol") == "$" + assert result.get("website_url") == "http://superwebsite.fr" + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_with_history_and_image(mock_get_llm, mock_context): + """Test outputter with explicit history and image.""" + expected_json = '{"answer": "Paris"}' + _, mock_llm_with_tools, _ = setup_mock_llm(mock_get_llm, react_response_content=expected_json) + + config = OutputConfig( + structured_output=None, + output_description="The capital of France", + ) + + class StateWithRawData: + def __init__(self, messages, initial_goal, operator_raw_data): + self.messages = messages + self.initial_goal = initial_goal + self.operator_raw_data = operator_raw_data + + state = StateWithRawData( + messages=[], + initial_goal="What is the capital of France?", + operator_raw_data={"screenshot_b64": "mock_b64_data"}, + ) + + await outputter( + ctx=mock_context, + output_config=config, + graph_output=state, + plan_and_history="Plan: Find capital\nStep 1: Opened browser", + ) + + call_args = mock_llm_with_tools.ainvoke.call_args + assert call_args is not None + messages = call_args[0][0] + + assert ( + "Your sole objective is to verify whether the user's initial goal was" + " achieved" in messages[0].content + ) + + human_content = messages[1].content + assert isinstance(human_content, list) + assert human_content[0]["type"] == "text" + assert "Step 1: Opened browser" in human_content[0]["text"] + assert "Plan: Find capital" in human_content[0]["text"] + assert human_content[1]["type"] == "image_url" + assert human_content[1]["image_url"]["url"] == "data:image/jpeg;base64,mock_b64_data" + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_builds_concise_history(mock_get_llm): + """Test that the outputter dynamically builds clean Summary ➡️ Action history.""" + expected_json = '{"answer": "Success"}' + _, mock_llm_with_tools, _ = setup_mock_llm(mock_get_llm, react_response_content=expected_json) + + config = OutputConfig( + structured_output=None, + output_description="Test output", + ) + + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = { + "planner": LLM(provider="openai", model="gpt-5-nano"), + "operator": LLM(provider="openai", model="gpt-5-nano"), + "validator": LLM(provider="openai", model="gpt-5-nano"), + } + ctx.device = Mock() + + mock_data_engine = Mock() + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Tapped search button", + "action_taken": [{"action": "click", "target_text": "Search"}], + "last_execution_result": {"status": "success"}, + } + ] + mock_data_engine.get_agent_friendly_steps.return_value = steps + mock_data_engine.base_dir = "/tmp/mock_session" + ctx.data_engine = mock_data_engine + + class StateWithRawData: + def __init__(self, messages, initial_goal, operator_raw_data): + self.messages = messages + self.initial_goal = initial_goal + self.operator_raw_data = operator_raw_data + + state = StateWithRawData( + messages=[], + initial_goal="Search for something", + operator_raw_data=None, + ) + + # Test Case 1: Fallback path (no plan file) + from pathlib import Path + + original_exists = Path.exists + + def mock_exists_fallback(self, *args, **kwargs): + if self.name == "task_plan.md": + return False + return original_exists(self, *args, **kwargs) + + with patch("pathlib.Path.exists", mock_exists_fallback): + await outputter( + ctx=ctx, + output_config=config, + graph_output=state, + ) + + call_args = mock_llm_with_tools.ainvoke.call_args + assert call_args is not None + messages = call_args[0][0] + human_content_fallback = messages[1].content[0]["text"] + + assert "--- Execution History ---" in human_content_fallback + assert "- *Step 1 (Start: 2.5s): Tapped search button*" in human_content_fallback + assert "*Action*: Tapped 'Search' at None" in human_content_fallback + + # Reset mock call history + mock_llm_with_tools.ainvoke.reset_mock() + + # Test Case 2: Standard path (plan file exists) + def mock_exists_standard(self, *args, **kwargs): + if self.name == "task_plan.md": + return True + return original_exists(self, *args, **kwargs) + + original_read_text = Path.read_text + + def mock_read_text(self, *args, **kwargs): + if self.name == "task_plan.md": + return "- [ ] Goal 1\n - [/] Subgoal 1.1" + return original_read_text(self, *args, **kwargs) + + with ( + patch("pathlib.Path.exists", mock_exists_standard), + patch("pathlib.Path.read_text", mock_read_text), + ): + await outputter( + ctx=ctx, + output_config=config, + graph_output=state, + ) + + call_args = mock_llm_with_tools.ainvoke.call_args + assert call_args is not None + messages = call_args[0][0] + human_content_standard = messages[1].content[0]["text"] + + assert "--- Execution History ---" in human_content_standard + assert "- *Step 1 (Start: 2.5s): Tapped search button*" in human_content_standard + assert "*Action*: Tapped 'Search' at None" in human_content_standard + + +@patch("artemis.tools.video_tool.VideoAnalyzer") +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_executes_video_analyzer_tool( + mock_get_llm, mock_video_analyzer, mock_context, mock_state +): + """Test that the outputter ReAct loop can successfully invoke the video_analyzer tool.""" + # Setup mock VideoAnalyzer + mock_agent_instance = Mock() + mock_agent_instance.run = AsyncMock( + return_value=("The user played a video for 5 seconds.", "success") + ) + mock_video_analyzer.return_value = mock_agent_instance + + # Setup mock LLM with two turns + mock_llm = Mock() + mock_llm_fallback = Mock() + + mock_llm_with_tools = Mock() + mock_llm_with_tools.ainvoke = AsyncMock() + + # Turn 1: LLM calls video_analyzer tool + tool_call = { + "name": "video_analyzer", + "args": { + "time_description": "from 5s to 10s", + "purpose": "verify video played", + }, + "id": "call_123", + } + msg_turn1 = Mock() + msg_turn1.content = "" + msg_turn1.tool_calls = [tool_call] + + # Turn 2: LLM returns final answer + msg_turn2 = Mock() + msg_turn2.content = "Video played successfully." + msg_turn2.tool_calls = [] + + mock_llm_with_tools.ainvoke.side_effect = [msg_turn1, msg_turn2] + + mock_llm.bind_tools.return_value = mock_llm_with_tools + mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools + + def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): + if use_fallback: + return mock_llm_fallback + return mock_llm + + mock_get_llm.side_effect = get_llm_side_effect + + config = OutputConfig( + structured_output=None, + output_description="Verify video", + ) + + result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) + + # Assert VideoAnalyzer was called correctly + mock_video_analyzer.assert_called_once_with(mock_context) + mock_agent_instance.run.assert_called_once_with("from 5s to 10s", "verify video played") + + # Assert LLM's ainvoke was called twice + assert mock_llm_with_tools.ainvoke.call_count == 2 + + # Assert tool message was appended (messages list is mutated in place, + # so it eventually has 5 elements) + sent_messages = mock_llm_with_tools.ainvoke.call_args_list[1][0][0] + assert len(sent_messages) == 5 + + # Verify the sequence of messages + assert sent_messages[2] == msg_turn1 + + tool_message = sent_messages[3] + from langchain_core.messages import ToolMessage + + assert isinstance(tool_message, ToolMessage) + assert tool_message.content == "The user played a video for 5 seconds." + assert tool_message.status == "success" + + assert sent_messages[4] == msg_turn2 + + # Assert final result + assert result == "Video played successfully." + + +@patch("artemis.agents.outputter.outputter.get_llm") +@pytest.mark.asyncio +async def test_outputter_executes_save_note_tool(mock_get_llm, mock_context, mock_state): + """Test that the outputter ReAct loop can successfully invoke the save_note tool.""" + # Setup mock LLM with two turns + mock_llm = Mock() + mock_llm_fallback = Mock() + + mock_llm_with_tools = Mock() + mock_llm_with_tools.ainvoke = AsyncMock() + + # Turn 1: LLM calls save_note tool + tool_call = { + "name": "save_note", + "args": {"key": "verification_code", "content": "123456"}, + "id": "call_save_123", + } + msg_turn1 = Mock() + msg_turn1.content = "" + msg_turn1.tool_calls = [tool_call] + + # Turn 2: LLM returns final answer + msg_turn2 = Mock() + msg_turn2.content = "Verification code is 123456." + msg_turn2.tool_calls = [] + + mock_llm_with_tools.ainvoke.side_effect = [msg_turn1, msg_turn2] + + mock_llm.bind_tools.return_value = mock_llm_with_tools + mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools + + def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): + if use_fallback: + return mock_llm_fallback + return mock_llm + + mock_get_llm.side_effect = get_llm_side_effect + + config = OutputConfig( + structured_output=None, + output_description="Save verification code to note", + ) + + # Setup mock data_engine on context to support saving notes + mock_data_engine = Mock() + mock_data_engine.base_dir = "/tmp/mock_session" + mock_context.data_engine = mock_data_engine + + # Patch save_note_content utility to avoid writing to disk + with patch("artemis.tools.scratchpad.save_note_content") as mock_save_content: + result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) + mock_save_content.assert_called_once_with( + "/tmp/mock_session", "verification_code", "123456" + ) + + # Assert final result + assert result == "Verification code is 123456." diff --git a/tests/unit/agents/test_outputter_tools_unit.py b/tests/unit/agents/test_outputter_tools_unit.py new file mode 100644 index 0000000..8342e59 --- /dev/null +++ b/tests/unit/agents/test_outputter_tools_unit.py @@ -0,0 +1,102 @@ +# 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 json +from unittest.mock import MagicMock, mock_open, patch + +from artemis.agents.outputter.tools import ( + get_search_history_tool, + get_step_details_tool, + get_step_screenshot_tool, +) +from artemis.context import ArtemisContext +import pytest + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.global_base_dir = "/tmp/fake_traces" + return ctx + + +def test_get_step_details_tool(): + steps = [ + { + "step_number": 1, + "relative_time": "1s", + "summary": "Step 1", + "action_taken": "tap", + }, + { + "step_number": 2, + "relative_time": "2s", + "summary": "Step 2", + "action_taken": "swipe", + }, + ] + tool = get_step_details_tool(steps) + + # Test valid range + result = tool.invoke({"start_step": 1, "end_step": 1}) + parsed = json.loads(result) + assert len(parsed) == 1 + assert parsed[0]["step_number"] == 1 + assert parsed[0]["action_taken"] == "tap" + + # Test invalid range + result_invalid = tool.invoke({"start_step": 3, "end_step": 4}) + assert "No steps found" in result_invalid + + +def test_get_step_screenshot_tool(mock_context): + steps = [ + {"step_number": 1, "pre_image_name": "img1", "post_image_name": "img2"}, + ] + tool = get_step_screenshot_tool(mock_context, steps) + + # Mock open and exists + with ( + patch("pathlib.Path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=b"fake_image_bytes")), + ): + result = tool.invoke({"step_number": 1}) + assert isinstance(result, list) + assert result[0]["type"] == "text" + assert result[1]["type"] == "image_url" + assert "data:image/jpeg;base64" in result[1]["image_url"]["url"] + + +def test_search_history_tool(mock_context): + tool = get_search_history_tool(mock_context) + + # Mock steps returned by storage + mock_step_1 = MagicMock() + mock_step_1.step_number = 1 + mock_step_1.pre_image_name = "img1" + mock_step_1.post_image_name = None + + mock_context.data_engine.storage.get_steps.return_value = [mock_step_1] + + # Mock search_ui_by_hash + mock_context.data_engine.storage.search_ui_by_hash.return_value = [ + {"type": "ocr", "matched_text": "verification code: 1234", "score": 0.9} + ] + + result = tool.invoke({"query": "verification"}) + parsed = json.loads(result) + assert len(parsed) == 1 + assert parsed[0]["step_number"] == 1 + assert parsed[0]["matches"][0]["matched_text"] == "verification code: 1234" diff --git a/tests/unit/agents/test_planner_node.py b/tests/unit/agents/test_planner_node.py new file mode 100644 index 0000000..38bfb35 --- /dev/null +++ b/tests/unit/agents/test_planner_node.py @@ -0,0 +1,89 @@ +# 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 Mock, patch + +from artemis.agents.planner.planner import PlannerNode +from artemis.context import DevicePlatform, ArtemisContext +import pytest + + +class DummyState: + def __init__( + self, + initial_goal, + needs_replan=False, + operator_raw_data=None, + operator_replan_reason=None, + ): + self.initial_goal = initial_goal + self.needs_replan = needs_replan + self.operator_raw_data = operator_raw_data + self.operator_replan_reason = operator_replan_reason + + async def asanitize_update(self, ctx, update, agent): + return update + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.device = Mock() + ctx.device.mobile_platform = DevicePlatform.ANDROID + ctx.adb_client = Mock() + ctx.ui_adb_client = Mock() + mock_screen_data = Mock() + mock_screen_data.base64 = "YmFzZTY0" + mock_screen_data.elements = [] + mock_screen_data.width = 1080 + mock_screen_data.height = 2400 + ctx.ui_adb_client.get_screen_data.return_value = mock_screen_data + ctx.data_engine = None + + return ctx + + +@pytest.mark.asyncio +async def test_planner_initial_plan(mock_context): + state = DummyState( + initial_goal="Test Goal", + operator_raw_data={"screenshot_b64": "dummy_base64_string"}, + ) + node = PlannerNode(mock_context) + + mock_llm = Mock() + mock_response = Mock() + mock_response.content = "- [ ] Subgoal 1" + mock_response.tool_calls = [] + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + mock_llm.bind_tools = Mock(return_value=mock_llm) + + with patch("artemis.agents.planner.planner.get_llm", return_value=mock_llm): + await node(state) + + assert mock_llm.astream.called + args, kwargs = mock_llm.astream.call_args + messages = args[0] + + system_msg = messages[0].content + assert "## Role" in system_msg + + human_msg_content = messages[1].content + assert isinstance(human_msg_content, list) + assert human_msg_content[0]["type"] == "text" + assert "Goal: Test Goal" in human_msg_content[0]["text"] diff --git a/tests/unit/agents/test_snapshot_rollback.py b/tests/unit/agents/test_snapshot_rollback.py new file mode 100644 index 0000000..aefa5b0 --- /dev/null +++ b/tests/unit/agents/test_snapshot_rollback.py @@ -0,0 +1,112 @@ +# 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 + +from artemis.agents.operator.operator import OperatorNode +from artemis.context import ArtemisContext +from artemis.graph.graph import execution_check_node +from artemis.graph.state import State +import pytest + + +@pytest.mark.asyncio +async def test_snapshot_rollback_flow(tmp_path): + # Setup dummy directories + base_dir = tmp_path / "trace" + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True) + + task_plan_path = notes_dir / "task_plan.md" + task_plan_path.write_text("Initial Plan", encoding="utf-8") + + # Mock Context + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = base_dir + mock_ctx.execution_setup = None + + # Mock State + mock_state = MagicMock(spec=State) + mock_state.subagent_calls = [] + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + mock_state.initial_goal = "Test goal" + mock_state.short_term_memory = "" + mock_state.operator_raw_data = { + "screenshot_b64": "YmFzZTY0", + "xml_hierarchy": "", + "ocr_results": [], + } + mock_state.current_step_id = "test-step-id" + + # Mock Controller and LLM to let Operator run minimally + mock_controller = MagicMock() + mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0") + mock_device_data = MagicMock() + mock_device_data.elements = [] + mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.tool_calls = [] + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + mock_llm.bind_tools.return_value = mock_llm + + # 1. Run Operator to trigger snapshot + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + node = OperatorNode(mock_ctx) + await node(mock_state) + + # Verify snapshot was created + snapshot_dir = notes_dir.with_name("notes_snapshot") + assert snapshot_dir.exists() + assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "Initial Plan" + assert mock_ctx.task_plan_snapshot == snapshot_dir + + # 2. Simulate Operator optimistic modification + task_plan_path.write_text("Modified Plan", encoding="utf-8") + + # 3. Run execution_check_node with failure to trigger rollback + future = asyncio.Future() + future.set_result({"status": "failed"}) + mock_ctx.checker_task = future + + await execution_check_node(mock_state, mock_ctx) + + # Verify rollback restored the file + assert task_plan_path.read_text(encoding="utf-8") == "Initial Plan" + + # 4. Run Operator again to create new snapshot (simulate next attempt) + # First modify it again to simulate operator working on restored plan + task_plan_path.write_text("Modified Plan 2", encoding="utf-8") + + with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): + await node(mock_state) + + assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "Modified Plan 2" + + # 5. Run execution_check_node with success to trigger commit (cleanup) + future_success = asyncio.Future() + future_success.set_result({"status": "success"}) + mock_ctx.checker_task = future_success + + await execution_check_node(mock_state, mock_ctx) + + # Verify snapshot was deleted + assert not snapshot_dir.exists() diff --git a/tests/unit/agents/test_summarizer.py b/tests/unit/agents/test_summarizer.py new file mode 100644 index 0000000..d82bb47 --- /dev/null +++ b/tests/unit/agents/test_summarizer.py @@ -0,0 +1,216 @@ +# 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 json +from unittest.mock import MagicMock, Mock, patch +from artemis.agents.summarizer.summarizer import SummarizerNode +from artemis.context import ArtemisContext +import pytest + + +class DummyState: + def __init__( + self, + structured_decisions=None, + operator_raw_thinking=None, + operator_native_thinking=None, + last_execution_result=None, + current_step_id=None, + ): + self.structured_decisions = structured_decisions + self.operator_raw_thinking = operator_raw_thinking + self.operator_native_thinking = operator_native_thinking + self.last_execution_result = last_execution_result + self.current_step_id = current_step_id + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.device = None + ctx.data_engine = Mock() + ctx.background_tasks = [] + return ctx + + +@pytest.mark.asyncio +async def test_summarizer_success(mock_context): + """Test that SummarizerNode successfully generates summary from raw thinking and execution result.""" + decisions = json.dumps([{"action": "tap", "coordinates": [500, 600]}]) + thinking = "The operator clicked the search button." + result = { + "status": "success", + "executed_actions": [{"action": "tap", "coordinates": [500, 600]}], + } + + state = DummyState( + structured_decisions=decisions, + operator_raw_thinking=thinking, + last_execution_result=result, + current_step_id="12345678-1234-5678-1234-567812345678", + ) + + # Mock LLM response + mock_response = Mock() + mock_response.content = "Step completed successfully." + mock_llm = MagicMock() + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + + with ( + patch( + "artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm + ) as mock_get_llm, + patch("asyncio.create_task") as mock_create_task, + ): + # Run the node call + node = SummarizerNode(mock_context) + await node(state) + + # Verify task was registered in context + assert len(mock_context.background_tasks) == 1 + assert mock_context.background_tasks[0] == mock_create_task.return_value + + # Verify background task was spawned + assert mock_create_task.called + background_coroutine = mock_create_task.call_args[0][0] + + # Await the background task synchronously for test verification + await background_coroutine + + # Verify LLM was invoked with proper structured messages + assert mock_llm.astream.called + invoked_messages = mock_llm.astream.call_args[0][0] + + system_msg = invoked_messages[0].content + human_msg = invoked_messages[1].content + + assert "Summarizer Agent" in system_msg + assert "Decisions made" in human_msg + assert "The operator clicked the search button." in human_msg + + # Verify Data Engine updated the step summary + from uuid import UUID + + mock_context.data_engine.update_step_summary.assert_called_once_with( + UUID("12345678-1234-5678-1234-567812345678"), + "Step completed successfully.", + ) + + +@pytest.mark.asyncio +async def test_summarizer_with_native_thinking(mock_context): + """Test that SummarizerNode successfully generates summary from both explicit and native thoughts.""" + decisions = json.dumps([{"action": "tap", "coordinates": [500, 600]}]) + raw_thinking = "Explicit monologue." + native_thinking = "Silent core reasoning." + result = {"status": "success"} + + state = DummyState( + structured_decisions=decisions, + operator_raw_thinking=raw_thinking, + operator_native_thinking=native_thinking, + last_execution_result=result, + current_step_id="12345678-1234-5678-1234-567812345678", + ) + + mock_response = Mock() + mock_response.content = "Summary result." + mock_llm = MagicMock() + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + + with ( + patch( + "artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm + ) as mock_get_llm, + patch("asyncio.create_task") as mock_create_task, + ): + node = SummarizerNode(mock_context) + await node(state) + + # Verify task was registered in context + assert len(mock_context.background_tasks) == 1 + assert mock_context.background_tasks[0] == mock_create_task.return_value + + background_coroutine = mock_create_task.call_args[0][0] + await background_coroutine + + assert mock_llm.astream.called + invoked_messages = mock_llm.astream.call_args[0][0] + human_msg = invoked_messages[1].content + + assert "Operator explicit thoughts:" in human_msg + assert "Explicit monologue." in human_msg + assert "Operator native thoughts:" in human_msg + assert "Silent core reasoning." in human_msg + + +@pytest.mark.asyncio +async def test_summarizer_with_chronological_trace(mock_context): + """Test that SummarizerNode generates a prompt containing the chronological step trace when available.""" + step_id = "12345678-1234-5678-1234-567812345678" + state = DummyState( + current_step_id=step_id, + ) + + mock_step_data = { + "step_id": step_id, + "step_number": 1, + "relative_time": "5.0s", + "action_taken": [{"action": "click", "target_text": "Search"}], + "last_execution_result": {"status": "success"}, + "interleaved_events": [ + {"type": "thought", "content": "Let's click search."}, + ], + } + + mock_context.data_engine.get_agent_friendly_steps.return_value = [mock_step_data] + + # Mock LLM response + mock_response = Mock() + mock_response.content = "Summary using chronological trace." + mock_llm = MagicMock() + + async def mock_astream(*args, **kwargs): + yield mock_response + + mock_llm.astream.side_effect = mock_astream + + with ( + patch("artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm), + patch("asyncio.create_task") as mock_create_task, + ): + node = SummarizerNode(mock_context) + await node(state) + + # Verify task was registered in context + assert len(mock_context.background_tasks) == 1 + assert mock_context.background_tasks[0] == mock_create_task.return_value + + background_coroutine = mock_create_task.call_args[0][0] + await background_coroutine + + assert mock_llm.astream.called + invoked_messages = mock_llm.astream.call_args[0][0] + human_msg = invoked_messages[1].content + + assert "--- Execution History ---" in human_msg + assert "Let's click search." in human_msg diff --git a/tests/unit/agents/test_validator.py b/tests/unit/agents/test_validator.py new file mode 100644 index 0000000..5f4e8b8 --- /dev/null +++ b/tests/unit/agents/test_validator.py @@ -0,0 +1,913 @@ +# 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 json +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from langchain_core.messages import AIMessage +from artemis.agents.validator.failure_analyzer import ValidationErrorCategory +from artemis.agents.validator.validator import ValidatorNode +from artemis.context import ArtemisContext +import pytest + + +class DummyState: + def __init__( + self, + structured_decisions, + validator_messages=[], + current_step_id=None, + latest_screenshot=None, + ): + self.structured_decisions = structured_decisions + self.validator_messages = validator_messages + self.current_step_id = current_step_id + self.latest_screenshot = latest_screenshot + + async def asanitize_update(self, ctx, update, agent): + return update + + +@pytest.fixture +def mock_context(tmp_path): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + ctx.data_engine = Mock() + ctx.data_engine.base_dir = tmp_path + ctx.data_engine.get_relative_time.return_value = 1.0 + return ctx + + +@pytest.fixture +def temp_screenshot(tmp_path): + p = tmp_path / "screenshot.png" + p.write_bytes(b"dummy_data") + return str(p) + + +@pytest.fixture +def mock_mcp(): + with ( + patch("mcp.client.stdio.stdio_client") as mock_stdio, + patch("artemis.agents.validator.validator.ClientSession") as mock_session_cls, + ): + mock_ctx = AsyncMock() + mock_ctx.__aenter__.return_value = (Mock(), Mock()) + mock_stdio.return_value = mock_ctx + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session_cls.return_value = mock_session + + yield mock_session + + +@pytest.mark.asyncio +async def test_validator_success(mock_mcp, mock_context, temp_screenshot): + """Test that ValidatorNode executes actions successfully.""" + # Mock MCP calls: screenshot, tap, screenshot + mock_mcp.call_tool.side_effect = [ + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # pre-screenshot + Mock(content=[Mock(text="Success")]), # tap action + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # post-screenshot + ] + + decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + with patch("artemis.utils.image_diff.check_ui_change", return_value=True): + node = ValidatorNode(mock_context) + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "tap" + assert "attempts" not in report["execution"][0] + + +@pytest.mark.asyncio +async def test_validator_failure_analysis(mock_mcp, mock_context, temp_screenshot): + """Test that ValidatorNode triggers failure analysis on error and handles + cannot_fix via tool. + """ + tap_count = 0 + + def mock_call_tool(name, args): + nonlocal tap_count + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "tap": + tap_count += 1 + return Mock(content=[Mock(text="Error: Element not found")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=False), + ): + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Analysis: Coordinates are likely wrong.", + } + + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "tap" + assert report["execution"][0]["attempts"] == [ + "Error: Element not found", + "Error: Element not found", + ] + assert report["execution"][0]["repair"] == "Analysis: Coordinates are likely wrong." + + +@pytest.mark.asyncio +async def test_validator_repair_success(mock_mcp, mock_context, temp_screenshot): + """Test that ValidatorNode terminates successfully and clears remaining actions + after successful repair. + """ + tap_count = 0 + + def mock_call_tool(name, args): + nonlocal tap_count + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "tap": + tap_count += 1 + if tap_count <= 2: # First tap fails + return Mock(content=[Mock(text="Error: Element not found")]) + return Mock(content=[Mock(text="Success")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + # Two actions planned initially + decisions = json.dumps( + [ + {"action": "tap", "coordinates": [105, 205]}, + {"action": "tap", "coordinates": [205, 305]}, + ] + ) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=False), + ): + mock_analyze.return_value = { + "status": "fixed", + "analysis": "Fixed by updating actions", + } + + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 # Only the first failed action is in report + + assert report["execution"][0]["coordinates"] == [105, 205] + assert report["execution"][0]["attempts"] == [ + "Error: Element not found", + "Error: Element not found", + ] + assert report["execution"][0]["repair"] == "Fixed by updating actions" + + +@pytest.mark.asyncio +async def test_validator_wait_for_delay(mock_mcp, mock_context, temp_screenshot): + """Test that wait_for_delay executes successfully.""" + + def mock_call(name, args): + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + + mock_mcp.call_tool.side_effect = mock_call + + decisions = json.dumps([{"action": "wait_for_delay", "time_in_ms": 10}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + with patch("artemis.utils.image_diff.check_ui_change", return_value=False): + node = ValidatorNode(mock_context) + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "wait_for_delay" + + +@pytest.mark.asyncio +async def test_validator_focus_and_clear_text_no_ui_change(mock_mcp, mock_context, temp_screenshot): + """Test that focus_and_clear_text executes successfully even if no UI change is detected.""" + + def mock_call(name, args): + if name == "focus_and_clear_text": + return Mock(content=[Mock(text="Success")]) + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + + mock_mcp.call_tool.side_effect = mock_call + + decisions = json.dumps([{"action": "focus_and_clear_text", "coordinates": [400, 500]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + with patch("artemis.utils.image_diff.check_ui_change", return_value=False): + node = ValidatorNode(mock_context) + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "focus_and_clear_text" + + +@pytest.mark.asyncio +async def test_validator_silent_failure_treated_as_success(mock_mcp, mock_context, temp_screenshot): + """Test that silent failure (exec succeeds but no UI change) is treated as + success and does not trigger FailureAnalyzer. + """ + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "tap": + return Mock(content=[Mock(text="Success")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.validator.VALIDATOR_POLL_TIMEOUT", 0.1), + patch("artemis.agents.validator.validator.VALIDATOR_POLL_INTERVAL", 0.01), + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=False), # No UI change + ): + result = await node(state) + + # Assert FailureAnalyzer was NOT called + mock_analyze.assert_not_called() + + # Assert the action is considered executed successfully + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "tap" + assert "attempts" not in report["execution"][0] + + +@pytest.mark.asyncio +async def test_validator_records_skipped_actions_on_cannot_fix( + mock_mcp, mock_context, temp_screenshot +): + """Test that ValidatorNode marks unexecuted actions as Skipped when repair fails.""" + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + return Mock(content=[Mock(text="Error: Connection lost")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + # Two actions planned + decisions = json.dumps( + [ + {"action": "tap", "coordinates": [100, 200]}, + {"action": "press_key", "keycode": "ENTER"}, + ] + ) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=False), + ): + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Cannot fix: System crashed.", + } + + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert "execution" in report + assert len(report["execution"]) == 2 + + # First action failed + assert report["execution"][0]["action"] == "tap" + assert report["execution"][0]["attempts"] == [ + "Error: Connection lost", + "Error: Connection lost", + ] + assert report["execution"][0]["repair"] == "Cannot fix: System crashed." + + # Second action was skipped + assert report["execution"][1]["action"] == "press_key" + assert report["execution"][1]["attempts"] == ["Skipped"] + + +@pytest.mark.asyncio +async def test_validator_pre_execution_validation_and_repair( + mock_mcp, mock_context, temp_screenshot +): + """Test that pre-execution validation fails when element changes, and is + repaired by FailureAnalyzer. + """ + mock_live_elements = [ + { + "text": "Sign Up", + "bounds": "[100,200][300,300]", + "resource-id": "btn_signup", + } + ] + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "get_ui_hierarchy": + return Mock(content=[Mock(text=json.dumps(mock_live_elements))]) + if name == "tap": + return Mock(content=[Mock(text="Success")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_login", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + ): + mock_pixel.return_value = ( + False, + ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, + "Pixel check failed", + ) + mock_analyze.return_value = { + "status": "fixed", + "analysis": ("Pre-exec failure matched and resolved by switching to Sign Up button."), + } + + result = await node(state) + + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert "Pre-execution validation failed" in args[2] + assert "Login" in args[2] + assert kwargs.get("error_category") == ValidationErrorCategory.TARGET_OCCUPIED + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "tap" + assert "Pre-execution validation failed" in report["execution"][0]["attempts"][0] + assert ( + report["execution"][0]["repair"] + == "Pre-exec failure matched and resolved by switching to Sign Up" + " button." + ) + + +@pytest.mark.asyncio +async def test_validator_pre_execution_validation_self_healing( + mock_mcp, mock_context, temp_screenshot +): + """Test that pre-execution validation succeeds and self-heals when element shifts slightly.""" + mock_live_elements = [ + { + "text": "Login", + "bounds": "[100,220][300,320]", + "resource-id": "btn_login", + } + ] + + tapped_coordinates = None + + def mock_call_tool(name, args): + nonlocal tapped_coordinates + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "get_ui_hierarchy": + return Mock(content=[Mock(text=json.dumps(mock_live_elements))]) + if name == "tap": + tapped_coordinates = args.get("coordinates") + return Mock(content=[Mock(text="Success")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_login", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with patch("artemis.utils.image_diff.check_ui_change", return_value=True): + await node(state) + + # Center of [100, 220][300, 320] is [200, 270] + assert tapped_coordinates == [200, 270] + + +@pytest.mark.asyncio +async def test_validator_pre_execution_validation_anonymous_occupant( + mock_mcp, mock_context, temp_screenshot +): + """Test that pre-execution validation correctly flags TARGET_OCCUPIED + when coordinates are blocked by an anonymous clickable view. + """ + # The live screen contains an anonymous clickable view covering + # target coordinates [200, 250] + mock_live_elements = [ + { + "text": "", + "bounds": "[150,200][250,300]", + "clickable": True, + "resource-id": "", + } + ] + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "get_ui_hierarchy": + return Mock(content=[Mock(text=json.dumps(mock_live_elements))]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_login", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + ): + mock_pixel.return_value = ( + False, + ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, + "Pixel check failed", + ) + mock_analyze.return_value = { + "status": "fixed", + "analysis": "Pre-exec failure bypassed.", + } + + await node(state) + + # Assert that the validator correctly classified this as TARGET_OCCUPIED + # (due to the clickable anonymous view blocking the click) + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert kwargs.get("error_category") == ValidationErrorCategory.TARGET_OCCUPIED + assert "occupied/intercepted by a different element: interactive anonymous element" in args[2] + + +@pytest.mark.asyncio +async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_screenshot): + """Test that the pixel safety net succeeds when Gemini reports target is present.""" + mock_mcp.call_tool.side_effect = [ + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # live screenshot for pixel check + Mock(content=[Mock(text="Success")]), # tap action + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # post-screenshot + ] + + decisions = json.dumps([{"action": "tap", "coordinates": [100, 200]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + mock_agent_cfg = Mock() + mock_agent_cfg.provider = "google" + mock_agent_cfg.model = "gemini-3.5-flash-lite" + mock_context.llm_config.get_agent.return_value = mock_agent_cfg + + mock_response = Mock() + mock_response.text = json.dumps( + { + "is_present": True, + "reason": "Button is clearly visible and clickable.", + "confidence": 0.95, + } + ) + + mock_llm = MagicMock() + mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content=mock_response.text)) + + # Mock crop and draw helpers to return dummy bytes + with ( + patch( + "artemis.utils.visualization.crop_and_annotate_target", + return_value=b"dummy_bytes", + ), + patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + ): + node = ValidatorNode(mock_context) + result = await node(state) + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert len(report["execution"]) == 1 + assert "attempts" not in report["execution"][0] + + +@pytest.mark.asyncio +async def test_validator_pixel_validation_failure(mock_mcp, mock_context, temp_screenshot): + """Test that the pixel safety net fails and triggers FailureAnalyzer + when Gemini reports target is missing. + """ + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps([{"action": "tap", "coordinates": [100, 200]}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + mock_agent_cfg = Mock() + mock_agent_cfg.provider = "google" + mock_agent_cfg.model = "gemini-3.5-flash-lite" + mock_context.llm_config.get_agent.return_value = mock_agent_cfg + + mock_response = Mock() + mock_response.text = json.dumps( + { + "is_present": False, + "reason": "The button has completely disappeared from the screen.", + "confidence": 0.90, + } + ) + + mock_llm = MagicMock() + mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content=mock_response.text)) + + with ( + patch( + "artemis.utils.visualization.crop_and_annotate_target", + return_value=b"dummy_bytes", + ), + patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), + patch("artemis.utils.image_diff.check_ui_change", return_value=False), + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + ): + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Pixel safety net flagged missing target.", + } + + node = ValidatorNode(mock_context) + await node(state) + + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert "Pixel-level validation failed" in args[2] + assert kwargs.get("error_category") == ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED + + +@pytest.mark.asyncio +@patch("artemis.agents.validator.validator.find_package") +@patch("artemis.utils.app_launch_utils.launch_app_with_retries") +async def test_validator_launch_app_passes_use_fallback_mcp( + mock_launch_app, mock_find_package, mock_mcp, mock_context, temp_screenshot +): + """Test that ValidatorNode calls find_package with use_fallback=False + for launch_app action in MCP mode. + """ + mock_find_package.return_value = "com.example.app" + mock_launch_app.return_value = (True, None) + + # Mock MCP calls: screenshot (pre), screenshot (post) + mock_mcp.call_tool.side_effect = [ + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # pre-screenshot + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # post-screenshot + ] + + decisions = json.dumps([{"action": "launch_app", "app_name": "My App"}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + with patch("artemis.utils.image_diff.check_ui_change", return_value=True): + node = ValidatorNode(mock_context) + result = await node(state) + + mock_find_package.assert_called_once_with(mock_context, "My App", use_fallback=False) + mock_launch_app.assert_called_once_with(mock_context, "com.example.app") + assert "last_execution_result" in result + + +@pytest.mark.asyncio +@patch("artemis.agents.validator.validator.find_package") +@patch("artemis.utils.app_launch_utils.launch_app_with_retries") +async def test_validator_launch_app_failure_no_retry( + mock_launch_app, mock_find_package, mock_mcp, mock_context, temp_screenshot +): + """Test that ValidatorNode does NOT retry launch_app action on failure.""" + mock_find_package.return_value = "com.example.app" + mock_launch_app.return_value = (False, "Error: Force close") + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps([{"action": "launch_app", "app_name": "My App"}]) + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + + with ( + patch("artemis.agents.validator.validator.VALIDATOR_POLL_TIMEOUT", 0.05), + patch("artemis.agents.validator.validator.VALIDATOR_POLL_INTERVAL", 0.01), + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=False), + ): + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Cannot launch app.", + } + + node = ValidatorNode(mock_context) + result = await node(state) + + # Verify find_package was called only once + mock_find_package.assert_called_once_with(mock_context, "My App", use_fallback=False) + # Verify launch_app_with_retries was called only once + # (no retry by ValidatorNode itself since it relies on launch_app_with_retries) + mock_launch_app.assert_called_once_with(mock_context, "com.example.app") + + # Verify attempts list has only 1 element + assert "last_execution_result" in result + report = result["last_execution_result"] + assert len(report["execution"]) == 1 + assert report["execution"][0]["action"] == "launch_app" + assert report["execution"][0]["attempts"] == ["Error: Force close"] + + +@pytest.mark.asyncio +async def test_validator_pre_execution_validation_disappeared_not_shifted( + mock_mcp, mock_context, temp_screenshot +): + """Test that a disappeared small button matching text in a giant container + is classified as TARGET_DISAPPEARED, not TARGET_SHIFTED. + """ + # Live hierarchy only contains a giant background/container + # containing the text "Sign Up" + mock_live_elements = [ + { + "text": "Sign Up", + "bounds": "[0,0][1080,2400]", + "resource-id": "root_container", + } + ] + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "get_ui_hierarchy": + return Mock(content=[Mock(text=json.dumps(mock_live_elements))]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Sign Up", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_signup", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + ): + mock_pixel.return_value = ( + False, + ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, + "Pixel check failed", + ) + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Sign Up button disappeared.", + } + + await node(state) + + # Assert that the validator correctly classified this as TARGET_DISAPPEARED + # (since size mismatch prevents shift matching) + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert kwargs.get("error_category") == ValidationErrorCategory.TARGET_DISAPPEARED + + +@pytest.mark.asyncio +async def test_validator_pre_execution_validation_ocr_direct_to_pixel( + mock_mcp, mock_context, temp_screenshot +): + """Test that OCR-derived elements bypass XML validation and + directly use pixel-based validation. + """ + mock_mcp.call_tool.side_effect = [ + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # live screenshot for pixel check + Mock(content=[Mock(text="Success")]), # tap action + Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]), # post-screenshot + ] + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_class": "android.view.View [OCR]", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with ( + patch.object( + ValidatorNode, + "_validate_action_precondition", + new_callable=AsyncMock, + ) as mock_xml, + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + ): + mock_pixel.return_value = (True, ValidationErrorCategory.NONE, "") + + result = await node(state) + + # Assert XML validation was NOT called + mock_xml.assert_not_called() + # Assert Pixel validation WAS called + mock_pixel.assert_called_once() + + assert "last_execution_result" in result + report = result["last_execution_result"] + assert report["status"] == "success" + + +@pytest.mark.asyncio +async def test_validator_pre_execution_xml_failure_not_overridden_when_pixel_bypassed( + mock_mcp, mock_context, temp_screenshot +): + """Test that when XML validation fails AND Pixel validation is bypassed + (PIXEL_BYPASSED), the XML failure is NOT overridden. + """ + mock_live_elements = [ + { + "text": "Sign Up", + "bounds": "[100,200][300,300]", + "resource-id": "btn_signup", + } + ] + + def mock_call_tool(name, args): + if name == "take_screenshot": + return Mock(content=[Mock(text="ZHVtbXlfZGF0YQ==")]) + if name == "get_ui_hierarchy": + return Mock(content=[Mock(text=json.dumps(mock_live_elements))]) + return Mock(content=[Mock(text="Success")]) + + mock_mcp.call_tool.side_effect = mock_call_tool + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_login", + } + ] + ) + + state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) + node = ValidatorNode(mock_context) + + with ( + patch("artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze") as mock_analyze, + patch("artemis.utils.image_diff.check_ui_change", return_value=True), + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + ): + # Pixel check was bypassed due to VLM error or low confidence + mock_pixel.return_value = ( + True, + ValidationErrorCategory.PIXEL_BYPASSED, + "", + ) + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": ("XML validation failed and pixel bypass did not override."), + } + + await node(state) + + # FailureAnalyzer SHOULD be called because XML validation failed + # and PIXEL_BYPASSED did not override it + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert "Pre-execution validation failed" in args[2] diff --git a/tests/unit/agents/test_video_analyzer.py b/tests/unit/agents/test_video_analyzer.py new file mode 100644 index 0000000..e400aaa --- /dev/null +++ b/tests/unit/agents/test_video_analyzer.py @@ -0,0 +1,456 @@ +# 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 +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer +from artemis.context import ArtemisContext +import pytest + + +@pytest.mark.asyncio +async def test_video_analyzer_run(): + # Mock context + mock_ctx = MagicMock(spec=ArtemisContext) + 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 Gemini Client + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.models = MagicMock() + mock_chunk = MagicMock() + mock_chunk.text = "Final analysis: The action succeeded." + mock_chunk.function_calls = [] + + async def mock_stream(): + yield mock_chunk + + mock_client.aio.models.generate_content_stream = AsyncMock(return_value=mock_stream()) + + # Mock file reading for prompts + mock_prompt = "# Dynamic Video Analyzer..." + + with ( + patch( + "artemis.agents.video_analyzer.video_analyzer.genai.Client", + return_value=mock_client, + ), + patch( + "pathlib.Path.exists", + autospec=True, + side_effect=lambda self: not str(self).endswith(".jpg"), + ), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch( + "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", + AsyncMock(), + ), + ): + analyzer = VideoAnalyzer(mock_ctx) + result, status = await analyzer.run( + time_description="from 10s to 15s", purpose="Verify action" + ) + + assert result == "Final analysis: The action succeeded." + assert status == "success" + assert mock_client.aio.models.generate_content_stream.called + + +@pytest.mark.asyncio +async def test_video_analyzer_preserves_thought_signature(): + # Mock context + mock_ctx = MagicMock(spec=ArtemisContext) + 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 MobileDeviceController + mock_controller = MagicMock() + mock_controller.extract_segment_metadata = AsyncMock( + return_value=MagicMock( + success=True, + video_path="/tmp/video.mp4", + actual_start_relative_time=10.0, + ) + ) + + from google.genai import types + + dummy_signature = b"dummy_thought_signature_bytes_56789" + + # Turn 1: Model calls spawn_sub_agent + spawn_call = types.FunctionCall( + name="spawn_sub_agent", + args={ + "start_time": 10.0, + "end_time": 15.0, + "specific_query": "Verify login screen", + }, + ) + spawn_part = types.Part( + function_call=spawn_call, + thought_signature=dummy_signature, + ) + + mock_chunk_1 = MagicMock() + mock_chunk_1.text = "" + mock_chunk_1.function_calls = [spawn_call] + mock_chunk_1.candidates = [ + types.Candidate(content=types.Content(role="model", parts=[spawn_part])) + ] + + # Turn 2: Model returns final answer + mock_chunk_2 = MagicMock() + mock_chunk_2.text = "Final analysis: The spawn succeeded and login button is visible." + mock_chunk_2.function_calls = [] + mock_chunk_2.candidates = [ + types.Candidate( + content=types.Content( + role="model", + parts=[types.Part.from_text(text=mock_chunk_2.text)], + ) + ) + ] + + # We mock generate_content_stream + call_count = 0 + captured_contents = [] + + async def mock_stream_1(): + yield mock_chunk_1 + + async def mock_stream_2(): + yield mock_chunk_2 + + async def mock_generate_content_stream(model, contents, config): + nonlocal call_count + has_submit_answer = False + if config.tools: + for t in config.tools: + fd_list = getattr(t, "function_declarations", None) + if fd_list: + for fd in fd_list: + if fd.name == "submit_answer": + has_submit_answer = True + break + if not has_submit_answer: + captured_contents.append(list(contents)) # copy history contents passed to call + call_count += 1 + if call_count == 1: + return mock_stream_1() + else: + return mock_stream_2() + else: + submit_call = types.FunctionCall( + name="submit_answer", + args={ + "confidence_score": 0.9, + "summary": "Sub-agent verification finished", + "analysis": "Analysis here", + "verification_timestamp_secs": 10.0, + }, + ) + mock_sub_chunk = MagicMock() + mock_sub_chunk.text = "" + mock_sub_chunk.function_calls = [submit_call] + + async def sub_stream(): + yield mock_sub_chunk + + return sub_stream() + + # Mock Gemini Client + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.files = MagicMock() + + mock_file = MagicMock() + mock_file.name = "dummy_cloud_file_123" + mock_file.uri = "https://gemini-file-api/dummy_cloud_file_123" + mock_file.mime_type = "video/mp4" + mock_client.aio.files.upload = AsyncMock(return_value=mock_file) + + mock_f_state = MagicMock() + mock_f_state.state.name = "ACTIVE" + mock_client.aio.files.get = AsyncMock(return_value=mock_f_state) + mock_client.aio.files.delete = AsyncMock() + + mock_client.aio.models = MagicMock() + mock_client.aio.models.generate_content_stream = AsyncMock( + side_effect=mock_generate_content_stream + ) + + # Mock file reading for prompts + mock_prompt = "# Dynamic Video Analyzer..." + + with ( + patch( + "artemis.agents.video_analyzer.video_analyzer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.video_analyzer.video_analyzer.get_controller", + return_value=mock_controller, + ), + patch( + "pathlib.Path.exists", + autospec=True, + side_effect=lambda self: not str(self).endswith(".jpg"), + ), + patch( + "pathlib.Path.stat", + return_value=os.stat_result((0o100644, 0, 0, 0, 0, 0, 1024 * 1024, 0, 0, 0)), + ), + patch("pathlib.Path.read_text", return_value=mock_prompt), + patch( + "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", + AsyncMock(), + ), + patch( + "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", + AsyncMock(return_value=Path("/tmp/video.mp4")), + ), + ): + analyzer = VideoAnalyzer(mock_ctx) + result, status = await analyzer.run( + time_description="from 10s to 15s", purpose="Verify login" + ) + + assert status == "success" + assert "spawn succeeded" in result + assert call_count == 2 + + # Verify that the second model call's history contains the model turn with the thought_signature + second_call_history = captured_contents[1] + assert len(second_call_history) >= 4 + + model_turn = second_call_history[1] + assert model_turn.role == "model" + assert len(model_turn.parts) == 1 + assert model_turn.parts[0].function_call.name == "spawn_sub_agent" + assert model_turn.parts[0].thought_signature == dummy_signature + + +@pytest.mark.asyncio +async def test_video_analyzer_sub_agent_confidence_validation(): + # Mock context + mock_ctx = MagicMock(spec=ArtemisContext) + 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 MobileDeviceController + mock_controller = MagicMock() + mock_controller.extract_segment_metadata = AsyncMock( + return_value=MagicMock( + success=True, + video_path="/tmp/video.mp4", + actual_start_relative_time=10.0, + ) + ) + + from google.genai import types + + # Main agent responses + # Turn 1: Calls spawn_sub_agent + spawn_call = types.FunctionCall( + name="spawn_sub_agent", + args={ + "start_time": 10.0, + "end_time": 15.0, + "specific_query": "Verify login screen", + }, + ) + mock_main_chunk_1 = MagicMock() + mock_main_chunk_1.text = "" + mock_main_chunk_1.function_calls = [spawn_call] + + # Turn 2: Returns final answer + mock_main_chunk_2 = MagicMock() + mock_main_chunk_2.text = "Final blackboard status checked." + mock_main_chunk_2.function_calls = [] + + # Sub-agent responses + # Turn 1: Calls submit_answer with invalid confidence_score (string) + invalid_submit_call = types.FunctionCall( + name="submit_answer", + args={ + "timeline_events": [ + { + "confidence_score": "HIGH", + "transcription": "Invalid confidence score call", + "verification_timestamp_secs": 10.0, + } + ], + "summary": "Invalid confidence score call", + "analysis": "Some analysis", + }, + ) + sub_dummy_signature = b"sub_dummy_signature_9999" + sub_part_1 = types.Part( + function_call=invalid_submit_call, + thought_signature=sub_dummy_signature, + ) + mock_sub_chunk_1 = MagicMock() + mock_sub_chunk_1.text = "" + mock_sub_chunk_1.function_calls = [invalid_submit_call] + mock_sub_chunk_1.candidates = [ + types.Candidate(content=types.Content(role="model", parts=[sub_part_1])) + ] + + # Turn 2: Calls submit_answer with valid confidence_score + valid_submit_call = types.FunctionCall( + name="submit_answer", + args={ + "timeline_events": [ + { + "confidence_score": 0.8, + "transcription": "Valid confidence score call", + "verification_timestamp_secs": 10.0, + } + ], + "summary": "Valid confidence score call", + "analysis": "Some analysis", + }, + ) + mock_sub_chunk_2 = MagicMock() + mock_sub_chunk_2.text = "" + mock_sub_chunk_2.function_calls = [valid_submit_call] + mock_sub_chunk_2.candidates = [ + types.Candidate( + content=types.Content(role="model", parts=[types.Part(function_call=valid_submit_call)]) + ) + ] + + # Stream generator mocks + main_calls = 0 + sub_calls = 0 + captured_sub_contents = [] + + async def mock_main_stream(): + nonlocal main_calls + if main_calls == 0: + main_calls += 1 + yield mock_main_chunk_1 + else: + yield mock_main_chunk_2 + + async def mock_sub_stream(): + nonlocal sub_calls + if sub_calls == 0: + sub_calls += 1 + yield mock_sub_chunk_1 + else: + yield mock_sub_chunk_2 + + async def mock_generate_content_stream(model, contents, config): + system_instruction = config.system_instruction + if "Dynamic Video Analyzer" in str(system_instruction): + return mock_main_stream() + else: + captured_sub_contents.append(list(contents)) + return mock_sub_stream() + + # Mock Gemini Client + mock_client = MagicMock() + mock_client.aio = MagicMock() + mock_client.aio.files = MagicMock() + + mock_file = MagicMock() + mock_file.name = "dummy_cloud_file_123" + mock_file.uri = "https://gemini-file-api/dummy_cloud_file_123" + mock_file.mime_type = "video/mp4" + mock_client.aio.files.upload = AsyncMock(return_value=mock_file) + + mock_f_state = MagicMock() + mock_f_state.state.name = "ACTIVE" + mock_client.aio.files.get = AsyncMock(return_value=mock_f_state) + mock_client.aio.files.delete = AsyncMock() + + mock_client.aio.models = MagicMock() + mock_client.aio.models.generate_content_stream = AsyncMock( + side_effect=mock_generate_content_stream + ) + + # Mock file reading for prompts + mock_main_prompt = "# Dynamic Video Analyzer..." + mock_sub_prompt = "# Sub Agent..." + + def mock_read_text(self, encoding="utf-8"): + if "video_analyzer.md" in str(self): + return mock_main_prompt + elif "video_sub_agent.md" in str(self): + return mock_sub_prompt + return "" + + with ( + patch( + "artemis.agents.video_analyzer.video_analyzer.genai.Client", + return_value=mock_client, + ), + patch( + "artemis.agents.video_analyzer.video_analyzer.get_controller", + return_value=mock_controller, + ), + patch( + "pathlib.Path.exists", + autospec=True, + side_effect=lambda self: not str(self).endswith(".jpg"), + ), + patch( + "pathlib.Path.stat", + return_value=os.stat_result((0o100644, 0, 0, 0, 0, 0, 1024 * 1024, 0, 0, 0)), + ), + patch("pathlib.Path.read_text", mock_read_text), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.touch"), + patch("pathlib.Path.is_dir", return_value=True), + patch( + "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", + AsyncMock(), + ), + patch( + "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", + AsyncMock(return_value=Path("/tmp/video.mp4")), + ), + patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=MagicMock(communicate=AsyncMock(return_value=(b"", b"")))), + ), + ): + analyzer = VideoAnalyzer(mock_ctx) + result, status = await analyzer.run( + time_description="from 10s to 15s", purpose="Verify login" + ) + + assert status == "success" + assert sub_calls == 1 + assert len(analyzer.blackboard_entries) == 1 + entry = analyzer.blackboard_entries[0] + assert entry["confidence_score"] == 0.8 + assert entry["summary"] == "Valid confidence score call" + + # Verify that sub-agent turn 2 received the model turn with the thought_signature + assert len(captured_sub_contents) >= 2 + sub_turn_2_contents = captured_sub_contents[1] + model_turns = [c for c in sub_turn_2_contents if c.role == "model"] + assert len(model_turns) == 1 + assert model_turns[0].parts[0].thought_signature == sub_dummy_signature diff --git a/tests/unit/agents/test_video_analyzer_universal.py b/tests/unit/agents/test_video_analyzer_universal.py new file mode 100644 index 0000000..b707a03 --- /dev/null +++ b/tests/unit/agents/test_video_analyzer_universal.py @@ -0,0 +1,234 @@ +# 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 Universal VideoAnalyzer dual-path execution engine.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from langchain_core.messages import AIMessage + +from artemis.agents.video_analyzer.video_analyzer import ( + UNIVERSAL_MAIN_TOOLS, + UNIVERSAL_SUBMIT_ANSWER_TOOL, + VideoAnalyzer, +) +from artemis.context import ArtemisContext + + +@pytest.fixture +def mock_context(tmp_path): + ctx = Mock(spec=ArtemisContext) + ctx.llm_config = Mock() + mock_llm_cfg = Mock() + mock_llm_cfg.model = "claude-3-7-sonnet" + mock_llm_cfg.temperature = 0.2 + ctx.llm_config.utils = Mock() + ctx.llm_config.utils.video_analyzer = mock_llm_cfg + + ctx.device = Mock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + + ctx.data_engine = Mock() + ctx.data_engine.base_dir = tmp_path + return ctx + + +def test_video_analyzer_universal_engine_detection(mock_context): + """Test that VideoAnalyzer routes to Universal Engine when non-Gemini model is set.""" + from artemis.config import settings + + with patch.object(settings, "GOOGLE_API_KEY", None): + agent = VideoAnalyzer(mock_context) + assert agent.use_native_gemini is False + assert agent.model_name == "claude-3-7-sonnet" + + +def test_universal_tool_schemas(): + """Verify schema structures for Universal tools.""" + assert UNIVERSAL_SUBMIT_ANSWER_TOOL["type"] == "function" + assert UNIVERSAL_SUBMIT_ANSWER_TOOL["function"]["name"] == "submit_answer" + assert len(UNIVERSAL_MAIN_TOOLS) == 3 + tool_names = [t["function"]["name"] for t in UNIVERSAL_MAIN_TOOLS] + assert "extract_segment_metadata" in tool_names + assert "spawn_sub_agent" in tool_names + assert "analyze_audio_only" in tool_names + + +@pytest.mark.asyncio +async def test_exec_single_chunk_universal(mock_context, tmp_path): + """Test keyframe extraction and LangChain tool invocation in universal sub-agent.""" + agent = VideoAnalyzer(mock_context) + agent.use_native_gemini = False + + fake_video = tmp_path / "compressed.mp4" + fake_video.write_bytes(b"video_bytes") + raw_video = tmp_path / "raw.mp4" + raw_video.write_bytes(b"raw_bytes") + + fake_keyframes = [ + (0.0, b"frame0"), + (1.0, b"frame1"), + (2.0, b"frame2"), + ] + + mock_llm = MagicMock() + mock_response = AIMessage( + content="Sub-agent analyzed frames.", + tool_calls=[ + { + "name": "submit_answer", + "args": { + "summary": "Detected settings button tap.", + "analysis": "At 1.0s user clicked Settings icon.", + "timeline_events": [ + { + "start_time": 0.5, + "end_time": 1.5, + "transcription": "Tap on Settings", + "confidence_score": 0.95, + "verification_timestamp_secs": 1.0, + } + ], + }, + "id": "sub_call_1", + } + ], + ) + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_bound + + with ( + patch( + "artemis.agents.video_analyzer.video_analyzer.extract_keyframes_from_video", + return_value=fake_keyframes, + ), + patch( + "artemis.agents.video_analyzer.video_analyzer.get_llm", + return_value=mock_llm, + ), + ): + result = await agent._exec_single_chunk_universal( + compressed_path=fake_video, + raw_path=raw_video, + start_time=0.0, + end_time=3.0, + actual_start=0.0, + prompt_with_context="Find when settings was opened.", + specific_query="settings icon", + ) + + assert "Detected settings button tap" in result + assert "At 1.0s user clicked Settings icon" in result + assert len(agent.blackboard_entries) == 1 + assert agent.blackboard_entries[0]["summary"] == "Tap on Settings" + assert agent.blackboard_entries[0]["confidence_score"] == 0.95 + + +@pytest.mark.asyncio +async def test_exec_analyze_audio_universal(mock_context, tmp_path): + """Test audio analysis in universal mode.""" + agent = VideoAnalyzer(mock_context) + agent.use_native_gemini = False + + fake_audio = tmp_path / "audio.aac" + fake_audio.write_bytes(b"audio_bytes") + + mock_llm = MagicMock() + mock_response = AIMessage( + content="", + tool_calls=[ + { + "name": "submit_answer", + "args": { + "summary": "Heard notification sound.", + "analysis": "Notification bell sounded at 2.0s.", + }, + "id": "audio_call_1", + } + ], + ) + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(return_value=mock_response) + mock_llm.bind_tools.return_value = mock_bound + + with patch( + "artemis.agents.video_analyzer.video_analyzer.get_llm", + return_value=mock_llm, + ): + result = await agent._exec_analyze_audio_universal( + audio_path=fake_audio, + start_time=1.0, + end_time=4.0, + actual_start=1.0, + prompt_with_context="Listen for notification sound.", + specific_query="notification bell", + ) + + assert "Heard notification sound" in result + assert len(agent.blackboard_entries) == 1 + assert agent.blackboard_entries[0]["summary"] == "Heard notification sound." + + +@pytest.mark.asyncio +async def test_run_universal_multi_turn_reasoning(mock_context): + """Test main reasoning loop of VideoAnalyzer in universal mode with tool dispatch.""" + agent = VideoAnalyzer(mock_context) + agent.use_native_gemini = False + + # Turn 1: Call extract_segment_metadata + msg_turn1 = AIMessage( + content="Let me check the video metadata first.", + tool_calls=[ + { + "name": "extract_segment_metadata", + "args": {"start_time": 0.0, "end_time": 10.0}, + "id": "main_call_1", + } + ], + ) + # Turn 2: Final response + msg_turn2 = AIMessage( + content="The video shows the app launched successfully at 3.5s.", + tool_calls=[], + ) + + mock_llm = MagicMock() + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock(side_effect=[msg_turn1, msg_turn2]) + mock_llm.bind_tools.return_value = mock_bound + + with ( + patch( + "artemis.agents.video_analyzer.video_analyzer.get_llm", + return_value=mock_llm, + ), + patch.object( + agent, + "exec_extract_segment_metadata", + new_callable=AsyncMock, + return_value='{"duration_seconds": 10.0, "file_size_mb": 2.5}', + ), + ): + outcome, status = await agent._run_universal( + time_description="from 0s to 10s", + purpose="Verify app launch", + system_prompt="You are a video analyzer.", + ) + + assert status == "success" + assert "launched successfully at 3.5s" in outcome + assert agent.exec_extract_segment_metadata.call_count == 1 diff --git a/tests/unit/data_engine/test_end_to_end_thoughts.py b/tests/unit/data_engine/test_end_to_end_thoughts.py new file mode 100644 index 0000000..0e3f4a8 --- /dev/null +++ b/tests/unit/data_engine/test_end_to_end_thoughts.py @@ -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. + +from pathlib import Path +import shutil +import tempfile +from unittest.mock import MagicMock +from uuid import uuid4 + +from langchain_core.messages import AIMessage, HumanMessage +from artemis.context import ArtemisContext +from artemis.data_engine.engine import DataEngine +from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler +from artemis.utils.task_tree import build_plan_and_history +import pytest + + +@pytest.fixture +def temp_workspace(): + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.mark.asyncio +async def test_end_to_end_thoughts_non_duplication(temp_workspace): + # 1. Setup mock context and DataEngine + mock_ctx = MagicMock(spec=ArtemisContext) + mock_execution_setup = MagicMock() + mock_execution_setup.traces_path = str(temp_workspace / "traces") + mock_ctx.execution_setup = mock_execution_setup + mock_ctx.device = None + mock_ctx.llm_config = MagicMock() + + engine = DataEngine(mock_ctx) + mock_ctx.data_engine = engine + + # Start session + engine.start_session("Test initial goal") + + # 2. Allocate step ID + step_id = engine.allocate_step_id() + + # 3. Simulate Operator Agent run starts + operator_trace_id = uuid4() + engine.record_trace( + type="agent", + name="operator", + payload={"args": {}}, + trace_id=operator_trace_id, + step_id=step_id, + status="running", + ) + + # Set context variables for parenting + token = CURRENT_TRACE_ID.set(operator_trace_id) + try: + # 4. Simulate LLM Call callback sequence + handler = DataEngineCallbackHandler(mock_ctx) + llm_run_id = uuid4() + + # Start model + handler.on_chat_model_start( + serialized={"name": "ChatGoogleGenerativeAI"}, + messages=[[HumanMessage(content="What should I do?")]], + run_id=llm_run_id, + parent_run_id=None, + ) + + # End model (generating thought + action) + class MockGeneration: + def __init__(self, message): + self.message = message + + class MockLLMResult: + def __init__(self, generations): + self.generations = generations + + response_message = AIMessage( + content=[ + { + "type": "thinking", + "thinking": "I need to tap the search bar to find books.", + }, + {"type": "text", "text": "Tapping search bar."}, + ] + ) + mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]]) + + handler.on_llm_end(mock_response, run_id=llm_run_id) + finally: + CURRENT_TRACE_ID.reset(token) + + # 5. Finish Operator Agent trace + engine.record_trace( + type="agent", + name="operator", + payload={"result": "Success"}, + trace_id=operator_trace_id, + step_id=step_id, + status="success", + ) + + # 6. Record step + engine.record_step( + pre_screenshot_bytes=b"pre_screenshot", + ui_tree=[], + ocr_result=[], + action_taken=[{"action": "tap", "coordinates": [100, 200]}], + operator_raw_thinking="I need to tap the search bar to find books.", + operator_native_thinking="I need to tap the search bar to find books.", + last_execution_result={"status": "success"}, + summary="Tapped search bar", + ) + + # Flush any pending tasks + await engine.shutdown() + + # 7. Retrieve agent-friendly steps + steps = engine.get_agent_friendly_steps() + + # Assert step was retrieved + assert len(steps) == 1 + step_data = steps[0] + + # Assert thoughts are present in interleaved events (since we associated step_id now!) + events = step_data.get("interleaved_events", []) + thought_events = [e for e in events if e.get("type") in ("thought", "native_thought")] + assert len(thought_events) > 0 + assert any( + e["content"] == "I need to tap the search bar to find books." for e in thought_events + ) + + # 8. Build history and check for duplications + plan = "- [ ] Search for books" + history = build_plan_and_history( + plan, steps, "default", last_n_detailed=1, for_failure_analyzer=True + ) + + print("\n--- Generated History Output ---") + print(history) + print("--------------------------------") + + # Assert thought is printed + assert "I need to tap the search bar to find books." in history + + # Assert thought is NOT printed multiple times + # Count occurrences of the thought string + count = history.count("I need to tap the search bar to find books.") + # The thought might appear in native thinking block and raw thinking block if both were stored. + # In our test we set BOTH operator_raw_thinking and operator_native_thinking to the same string, + # AND the LLM trace generated both "thinking" block and "text" block. + # Let's count how many times it was actually rendered in the Operator Decision Loop. + # It should appear at most 2 times (once as Native Thought, once as Thought/Raw Thought). + # If there was duplicate appending due to fallback failure, it would appear 4+ times. + assert count <= 2 diff --git a/tests/unit/data_engine/test_engine.py b/tests/unit/data_engine/test_engine.py new file mode 100644 index 0000000..8bd4aba --- /dev/null +++ b/tests/unit/data_engine/test_engine.py @@ -0,0 +1,185 @@ +# 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.context import ArtemisContext +from artemis.data_engine.engine import DataEngine + + +def test_get_or_create_image_updates_missing_data(tmp_path): + # Setup mock context + mock_ctx = MagicMock(spec=ArtemisContext) + mock_execution_setup = MagicMock() + mock_execution_setup.traces_path = str(tmp_path) + mock_ctx.execution_setup = mock_execution_setup + mock_ctx.device = None + + # Initialize DataEngine + engine = DataEngine(mock_ctx) + + image_bytes = b"fake_image_bytes" + + # 1. Save image without OCR/UI tree + image_name1 = engine.get_or_create_image(image_bytes) + + # Verify it was saved with None for OCR/UI tree + record1 = engine.storage.get_image(image_name1) + assert record1 is not None + assert record1.ocr_result is None + assert record1.ui_tree is None + + # 2. Save same image with OCR/UI tree + fake_ocr = [{"text": "hello", "box": [0, 0, 10, 10]}] + fake_ui = [{"resource-id": "button1", "text": "click me"}] + + image_name2 = engine.get_or_create_image(image_bytes, ui_tree=fake_ui, ocr_result=fake_ocr) + + assert image_name1 == image_name2 + + # Verify it now has OCR/UI tree + record2 = engine.storage.get_image(image_name2) + assert record2 is not None + assert record2.ocr_result == fake_ocr + assert record2.ui_tree == fake_ui + + +def test_create_image_duplicate_handling(tmp_path): + from artemis.data_engine.storage import StorageManager + from artemis.data_engine.models import ImageRecord + + storage = StorageManager(tmp_path / "data_engine.db", tmp_path) + image_record = ImageRecord( + image_name="test_image_hash", + timestamp=123.456, + ocr_result=None, + ui_tree=None, + extra_metadata={}, + ) + + # First insert + storage.create_image(image_record) + + # Second insert with same image_name should not raise IntegrityError due to INSERT OR IGNORE + storage.create_image(image_record) + + record = storage.get_image("test_image_hash") + assert record is not None + assert record.image_name == "test_image_hash" + + +def test_video_recording_persistence(tmp_path): + from uuid import uuid4 + from artemis.data_engine.storage import StorageManager + from artemis.data_engine.models import VideoRecordingRecord + + storage = StorageManager(tmp_path / "data_engine.db", tmp_path) + video_id = uuid4() + session_id = uuid4() + + record = VideoRecordingRecord( + video_id=video_id, + session_id=session_id, + device_id="emulator-5554", + start_time=100.0, + end_time=None, + local_video_path="/tmp/recording.mp4", + ) + + # Test insert + storage.create_video_recording(record) + + persisted = storage.get_video_recording(video_id) + assert persisted is not None + assert persisted.video_id == video_id + assert persisted.session_id == session_id + assert persisted.device_id == "emulator-5554" + assert persisted.start_time == 100.0 + assert persisted.end_time is None + assert persisted.local_video_path == "/tmp/recording.mp4" + + # Test update + record.end_time = 150.0 + record.local_video_path = "/tmp/recording_final.mp4" + storage.update_video_recording(record) + + updated = storage.get_video_recording(video_id) + assert updated is not None + assert updated.end_time == 150.0 + assert updated.local_video_path == "/tmp/recording_final.mp4" + + +def test_record_step_suppresses_identical_post_screenshot(tmp_path): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_execution_setup = MagicMock() + mock_execution_setup.traces_path = str(tmp_path) + mock_ctx.execution_setup = mock_execution_setup + mock_ctx.device = None + + engine = DataEngine(mock_ctx) + engine.start_session("test_goal") + same_image_bytes = b"identical_screen_bytes_123" + + step_id = engine.record_step( + pre_screenshot_bytes=same_image_bytes, + post_screenshot_bytes=same_image_bytes, + summary="Test step with identical screenshot", + ) + for t in list(engine._pending_threads): + t.join() + + step_record = engine.storage.get_steps(engine.current_session_id)[0] + assert step_record is not None + assert step_record.pre_image_name is not None + # post_image_name must be None when no visual transition occurred + assert step_record.post_image_name is None + + +def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_path): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_execution_setup = MagicMock() + mock_execution_setup.traces_path = str(tmp_path) + mock_ctx.execution_setup = mock_execution_setup + mock_ctx.device = None + + engine = DataEngine(mock_ctx) + engine.start_session("test_goal") + image_bytes = b"pre_screen_bytes_456" + + step_id = engine.record_step( + pre_screenshot_bytes=image_bytes, + summary="Initial step", + ) + + step_record = engine.storage.get_steps(engine.current_session_id)[0] + assert step_record.post_image_name is None + + # Update with post_image_name matching pre_image_name + engine.storage.update_step_execution_result( + step_id, + {"status": "success"}, + post_image_name=step_record.pre_image_name, + ) + + updated_step = engine.storage.get_steps(engine.current_session_id)[0] + assert updated_step.post_image_name is None + + # Update with distinct post_image_name + engine.storage.update_step_execution_result( + step_id, + {"status": "success"}, + post_image_name="distinct_new_hash_789", + ) + + updated_step_distinct = engine.storage.get_steps(engine.current_session_id)[0] + assert updated_step_distinct.post_image_name == "distinct_new_hash_789" diff --git a/tests/unit/data_engine/test_latency_optimizations_deep.py b/tests/unit/data_engine/test_latency_optimizations_deep.py new file mode 100644 index 0000000..49361f7 --- /dev/null +++ b/tests/unit/data_engine/test_latency_optimizations_deep.py @@ -0,0 +1,566 @@ +# 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 +import base64 +import hashlib +import json +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from langchain_core.messages import HumanMessage +from artemis.context import ArtemisContext +from artemis.data_engine.engine import DataEngine +from artemis.graph.state import State +from artemis.utils.logger import DataEngineHandler +import pytest + + +@pytest.fixture +def mock_artemis_ctx(tmp_path): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_execution_setup = MagicMock() + mock_execution_setup.traces_path = str(tmp_path) + mock_ctx.execution_setup = mock_execution_setup + mock_ctx.device = None + mock_ctx.task_plan_snapshot = None + return mock_ctx + + +@pytest.mark.asyncio +async def test_data_engine_handler_queue_buffering_and_background_worker( + mock_artemis_ctx, +): + """Verify DataEngineHandler.emit buffers log records via in-memory queue without spawning OS threads per log line.""" + engine = DataEngine(mock_artemis_ctx) + session_id = engine.start_session("Test Log Queue") + + handler = DataEngineHandler() + + with patch("sys.modules") as mock_modules: + mock_engine_mod = MagicMock() + mock_engine_mod._CURRENT_DATA_ENGINE = engine + mock_modules.get.return_value = mock_engine_mod + + # Emit 50 rapid log records + for i in range(50): + record = logging.LogRecord( + name=f"test.logger.{i}", + level=logging.INFO, + pathname="test.py", + lineno=10 + i, + msg=f"Async queue test log message {i}", + args=(), + exc_info=None, + ) + handler.emit(record) + + # Wait for the background worker thread of DataEngineHandler and DataEngine tasks to drain + await asyncio.sleep(0.4) + if engine._pending_tasks: + await asyncio.gather(*engine._pending_tasks) + + # Verify that traces were recorded and persisted accurately via direct SQLite query + with engine.storage._get_connection() as conn: + cursor = conn.execute( + "SELECT * FROM traces WHERE session_id = ? AND type = 'log'", + (str(session_id),), + ) + log_rows = cursor.fetchall() + + assert len(log_rows) >= 50 + messages = {json.loads(row["payload"])["message"] for row in log_rows} + for i in range(50): + assert f"Async queue test log message {i}" in messages + + +@pytest.mark.asyncio +async def test_update_step_action_and_metadata_background_offloading(mock_artemis_ctx, tmp_path): + """Verify that update_step_* methods publish SSE events instantly in memory and offload SQLite & disk I/O to background.""" + engine = DataEngine(mock_artemis_ctx) + session_id = engine.start_session("Test Step Offloading") + step_id = engine.allocate_step_id() + + # Create the initial step + engine.record_step(summary="Initial step summary") + if engine._pending_tasks: + await asyncio.gather(*engine._pending_tasks) + + # Mock _publish to verify immediate real-time SSE delivery + published_events = [] + engine._publish = lambda event_type, payload: published_events.append((event_type, payload)) + + fake_action = {"action": "tap", "coordinates": [100, 200]} + fake_post_jpg = b"fake_post_screenshot_jpg_bytes_12345" + + # Call update_step_action with post screenshot bytes + engine.update_step_action(fake_action, post_screenshot_bytes=fake_post_jpg) + + # Verify SSE was published immediately before/during task execution + assert len(published_events) == 1 + assert published_events[0][0] == "step_updated" + assert published_events[0][1]["action_taken"] == fake_action + + # Call other update_step_* methods + engine.update_step_summary(step_id, "Updated concise summary") + engine.update_step_thinking(step_id, "Raw thinking process...") + engine.update_step_native_thinking(step_id, "Native thinking process...") + engine.update_step_execution_result( + step_id, {"status": "success"}, post_image_name="hash_post_123" + ) + + # Now await all background pending tasks + if engine._pending_tasks: + await asyncio.gather(*engine._pending_tasks) + + # Verify SQLite record has been correctly updated by background tasks + steps = engine.storage.get_steps(session_id) + step_record = next((s for s in steps if s.step_id == step_id), None) + assert step_record is not None + assert step_record.action_taken == fake_action + assert step_record.summary == "Updated concise summary" + assert step_record.operator_raw_thinking == "Raw thinking process..." + assert step_record.operator_native_thinking == "Native thinking process..." + assert step_record.last_execution_result == {"status": "success"} + assert step_record.post_image_name == "hash_post_123" + + # Verify post.jpg was written to disk inside step directory + post_jpg_path = engine.current_step_dir / "post.jpg" + assert post_jpg_path.exists() + assert post_jpg_path.read_bytes() == fake_post_jpg + + +@pytest.mark.asyncio +async def test_record_step_background_image_and_step_persistence( + mock_artemis_ctx, +): + """Verify record_step immediately calculates SHA-256 hashes and offloads get_or_create_image + create_step to background.""" + engine = DataEngine(mock_artemis_ctx) + session_id = engine.start_session("Test Record Step Image Offloading") + step_id = engine.allocate_step_id() + + pre_img_bytes = b"fake_pre_screenshot_data_001" + post_img_bytes = b"fake_post_screenshot_data_002" + expected_pre_hash = hashlib.sha256(pre_img_bytes).hexdigest() + expected_post_hash = hashlib.sha256(post_img_bytes).hexdigest() + + returned_step_id = engine.record_step( + pre_screenshot_bytes=pre_img_bytes, + post_screenshot_bytes=post_img_bytes, + summary="Testing background image persistence", + ) + assert returned_step_id == step_id + + # Await background storage tasks + if engine._pending_tasks: + await asyncio.gather(*engine._pending_tasks) + + # Verify image records were created in SQLite + pre_record = engine.storage.get_image(expected_pre_hash) + post_record = engine.storage.get_image(expected_post_hash) + assert pre_record is not None + assert pre_record.image_name == expected_pre_hash + assert post_record is not None + assert post_record.image_name == expected_post_hash + + # Verify image files exist on disk + pre_path = engine.get_image_path(expected_pre_hash) + post_path = engine.get_image_path(expected_post_hash) + assert pre_path.exists() and pre_path.read_bytes() == pre_img_bytes + assert post_path.exists() and post_path.read_bytes() == post_img_bytes + + # Verify StepRecord has correct hashes and summary + steps = engine.storage.get_steps(session_id) + step_record = next((s for s in steps if s.step_id == step_id), None) + assert step_record is not None + assert step_record.pre_image_name == expected_pre_hash + assert step_record.post_image_name == expected_post_hash + assert step_record.summary == "Testing background image persistence" + + +@pytest.mark.asyncio +async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path): + """Verify perception_node offloads injected_instruction reading/unlinking and get_or_create_image to background.""" + from artemis.graph.perception import perception_node + + engine = DataEngine(mock_artemis_ctx) + engine.start_session("Test Perception Node Offloading") + mock_artemis_ctx.data_engine = engine + + # Create an injected_instruction.json file + instruction_file = Path(engine.base_dir) / "injected_instruction.json" + instruction_file.write_text(json.dumps({"instruction": "Avoid clicking Ads"}), encoding="utf-8") + + # Prepare mock controller + fake_screenshot_bytes = b"fake_screenshot_for_perception_node_123" + fake_screenshot_b64 = base64.b64encode(fake_screenshot_bytes).decode("utf-8") + expected_hash = hashlib.sha256(fake_screenshot_bytes).hexdigest() + + mock_screen_data = MagicMock() + mock_screen_data.base64 = fake_screenshot_b64 + mock_screen_data.elements = [{"resource-id": "app_bar", "bounds": "[0,0][1080,200]"}] + mock_screen_data.width = 1080 + mock_screen_data.height = 2400 + + with ( + patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls, + patch("artemis.graph.perception.perform_ocr", new_callable=AsyncMock) as mock_ocr, + patch("artemis.graph.perception._detect_status_bar_height", return_value=0), + patch("artemis.graph.perception._should_skip_settling", return_value=True), + ): + mock_controller = mock_controller_cls.return_value + mock_controller.get_screen_data = AsyncMock(return_value=mock_screen_data) + mock_ocr.return_value = [{"text": "Home", "box": [100, 100, 200, 200]}] + + state = State( + messages=[HumanMessage(content="test")], + initial_goal="test goal", + complete_subgoals_by_ids=[], + validator_messages=[], + latest_ui_hierarchy=[], + latest_screenshot=None, + focused_app_info=None, + device_date=None, + structured_decisions=None, + ) + + update = await perception_node(state, mock_artemis_ctx) + + # Verify perception_node returned expected update immediately + assert update["injected_instruction"] == "Avoid clicking Ads" + assert update["latest_screenshot"] == str(engine.get_image_path(expected_hash)) + assert update["operator_raw_data"]["width"] == 1080 + + # Verify injected_instruction.json was unlinked + assert not instruction_file.exists() + + # Await background tasks triggered by perception_node + await asyncio.sleep(0.1) + if engine._pending_tasks: + await asyncio.gather(*engine._pending_tasks) + + # Verify image record and file were created in background by perception_node + img_record = engine.storage.get_image(expected_hash) + assert img_record is not None + assert img_record.image_name == expected_hash + assert engine.get_image_path(expected_hash).exists() + + +@pytest.mark.asyncio +async def test_snapshot_copy_and_restore_offloading(mock_artemis_ctx, tmp_path): + """Verify operator create_snapshot and graph rollback/restore execute asynchronously without blocking.""" + from artemis.utils.file import create_snapshot, restore_snapshot + + notes_dir = tmp_path / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + (notes_dir / "task_plan.md").write_text("# Plan\n- Step 1", encoding="utf-8") + (notes_dir / "verification_chat_1.json").write_text('{"messages": ["hello"]}', encoding="utf-8") + + snapshot_dir = tmp_path / "notes_snapshot" + + # Test async offloading of create_snapshot (operator pattern) + await asyncio.to_thread(create_snapshot, notes_dir, snapshot_dir) + assert snapshot_dir.exists() + assert (snapshot_dir / "task_plan.md").read_text(encoding="utf-8") == "# Plan\n- Step 1" + + # Modify live task_plan.md + (notes_dir / "task_plan.md").write_text("# Plan\n- Step 1 (Modified)", encoding="utf-8") + + # Test async offloading of restore_snapshot (graph pattern when checker fails) + def _restore_and_preserve(): + chat_contents = {} + for p in notes_dir.glob("verification_chat_*.json"): + chat_contents[p.name] = p.read_text(encoding="utf-8") + restore_snapshot(snapshot_dir, notes_dir) + for name, content in chat_contents.items(): + (notes_dir / name).write_text(content, encoding="utf-8") + + await asyncio.to_thread(_restore_and_preserve) + assert (notes_dir / "task_plan.md").read_text(encoding="utf-8") == "# Plan\n- Step 1" + assert (notes_dir / "verification_chat_1.json").read_text( + encoding="utf-8" + ) == '{"messages": ["hello"]}' + + +@pytest.mark.asyncio +async def test_ocr_api_persistent_http_client_singleton_and_tls_reuse(): + """Verify get_http_client returns a persistent singleton httpx.AsyncClient that reuses TLS connections across OCR calls.""" + import httpx + from artemis.utils import ocr_api + + # Reset singleton to test fresh creation & persistence + ocr_api._HTTP_CLIENT = None + client1 = ocr_api.get_http_client() + client2 = ocr_api.get_http_client() + + assert isinstance(client1, httpx.AsyncClient) + assert client1 is client2 + + # Verify perform_ocr uses this exact persistent client + fake_ocr_resp = MagicMock() + fake_ocr_resp.status_code = 200 + fake_ocr_resp.json.return_value = { + "responses": [ + { + "textAnnotations": [ + { + "description": "Combined text block", + "boundingPoly": {"vertices": []}, + }, + { + "description": "Hello World", + "boundingPoly": {"vertices": [{"x": 10, "y": 20}, {"x": 100, "y": 200}]}, + }, + ] + } + ] + } + + with patch.object(client1, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = fake_ocr_resp + result = await ocr_api.perform_ocr(screenshot_b64="ZHVtbXlfYjY0") + + mock_post.assert_called_once() + assert len(result) == 1 + assert result[0]["text"] == "Hello World" + + +@pytest.mark.asyncio +async def test_ui_filter_and_ui_automator_client_pre_parsed_bounds_o1_lookup(): + """Verify XML parsing pre-populates parsed_bounds and _parse_bounds hits this cache in O(1) without regex matches.""" + import re + from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements + from artemis.utils.ui_filter import _parse_bounds + + # 1. Test ingestion pre-parsing in _parse_hierarchy_xml_to_elements + xml_data = '' + elements = _parse_hierarchy_xml_to_elements(xml_data) + assert len(elements) == 1 + assert elements[0]["parsed_bounds"] == { + "left": 15, + "top": 25, + "right": 350, + "bottom": 120, + } + + # 2. Test _parse_bounds O(1) hit vs fallback + with patch.object(re, "match", wraps=re.match) as spy_match: + # Hit pre-parsed bounds -> re.match MUST NOT be called + hit_result = _parse_bounds(elements[0]) + assert hit_result == { + "left": 15, + "top": 25, + "right": 350, + "bottom": 120, + } + spy_match.assert_not_called() + + # Fallback raw string -> re.match is called exactly once + fallback_result = _parse_bounds("[50,60][150,160]") + assert fallback_result == { + "left": 50, + "top": 60, + "right": 150, + "bottom": 160, + } + assert spy_match.call_count == 1 + + +@pytest.mark.asyncio +async def test_validator_pre_execution_loop_reverted_to_exact_safety_contract( + mock_artemis_ctx, tmp_path +): + """Verify Validator pre-execution check runs VLM on XML failure, but retains XML error if VLM fails (reverted contract).""" + from artemis.agents.validator.validator import ValidatorNode, ValidationErrorCategory + + # Set mock context properties required by tracing decorators + mock_artemis_ctx.data_engine = MagicMock() + + node = ValidatorNode(mock_artemis_ctx) + session = MagicMock() + session.call_tool = AsyncMock( + return_value=MagicMock(content=[MagicMock(text="ZHVtbXlfZGF0YQ==")]) + ) + + decisions = json.dumps( + [ + { + "action": "tap", + "coordinates": [200, 250], + "target_text": "Login", + "target_bounds": [100, 200, 300, 300], + "target_resource_id": "btn_login", + } + ] + ) + state = State( + messages=[], + initial_goal="test", + complete_subgoals_by_ids=[], + validator_messages=[], + latest_ui_hierarchy=[], + latest_screenshot="dummy.png", + structured_decisions=decisions, + focused_app_info=None, + device_date=None, + ) + + with ( + patch.object( + ValidatorNode, "_get_initial_screenshot", new_callable=AsyncMock + ) as mock_get_screen, + patch.object( + ValidatorNode, "_validate_action_precondition", new_callable=AsyncMock + ) as mock_xml, + patch.object( + ValidatorNode, + "_validate_action_precondition_pixel", + new_callable=AsyncMock, + ) as mock_pixel, + patch( + "artemis.agents.validator.failure_analyzer.FailureAnalyzer.analyze", + new_callable=AsyncMock, + ) as mock_analyze, + ): + mock_get_screen.return_value = ("dummy_b64", "dummy_stem") + # 1. XML check returns failure (TARGET_OCCUPIED by 'Sign Up' button) + mock_xml.return_value = ( + False, + ValidationErrorCategory.TARGET_OCCUPIED, + "Occupied by Sign Up button", + ) + # 2. VLM check returns failure (PIXEL_TARGET_DISAPPEARED) + mock_pixel.return_value = ( + False, + ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, + "VLM target missing", + ) + + mock_analyze.return_value = { + "status": "cannot_fix", + "analysis": "Unrepairable drift", + } + + result = await node(state) + + # Verify both XML check and VLM check were called (reverted double-check contract) + mock_xml.assert_called_once() + mock_pixel.assert_called_once() + + # Verify FailureAnalyzer received the EXACT XML error (TARGET_OCCUPIED), NOT the VLM error + mock_analyze.assert_called_once() + args, kwargs = mock_analyze.call_args + assert kwargs.get("error_category") == ValidationErrorCategory.TARGET_OCCUPIED + assert "Occupied by Sign Up button" in args[2] + + +@pytest.mark.asyncio +async def test_failure_analyzer_prune_intermediate_screenshots(mock_artemis_ctx): + """Verify FailureAnalyzer._prune_intermediate_screenshots keeps initial reference images and the latest live image, while pruning intermediate images.""" + from google.genai import types + from artemis.agents.validator.failure_analyzer import FailureAnalyzer + + analyzer = FailureAnalyzer(mock_artemis_ctx) + + # Build simulated contents history: + # 0: User (Initial reference screenshots + prompt) + # 1: Model + # 2: User (Intermediate tool results) + # 3: User (Intermediate observation screenshot - SHOULD BE PRUNED) + # 4: Model + # 5: User (Intermediate tool results 2) + # 6: User (Latest observation screenshot - SHOULD BE PRESERVED) + contents = [ + types.Content( + role="user", + parts=[ + types.Part.from_text(text="Reference screenshot 1"), + types.Part.from_bytes(data=b"initial_pre_image_bytes", mime_type="image/jpeg"), + types.Part.from_bytes(data=b"initial_post_image_bytes", mime_type="image/jpeg"), + ], + ), + types.Content(role="model", parts=[types.Part.from_text(text="Clicked something")]), + types.Content( + role="user", + parts=[types.Part.from_text(text="Result text")], + ), + types.Content( + role="user", + parts=[ + types.Part.from_text(text="Intermediate observation"), + types.Part.from_bytes( + data=b"intermediate_image_bytes_to_prune", + mime_type="image/jpeg", + ), + ], + ), + types.Content(role="model", parts=[types.Part.from_text(text="Clicking again")]), + types.Content( + role="user", + parts=[types.Part.from_text(text="Result text 2")], + ), + types.Content( + role="user", + parts=[ + types.Part.from_text(text="Latest live observation"), + types.Part.from_bytes( + data=b"latest_live_image_bytes_to_keep", + mime_type="image/jpeg", + ), + ], + ), + ] + + analyzer._prune_intermediate_screenshots(contents) + + # 1. Verify index 0 images are untouched + assert contents[0].parts[1].inline_data.data == b"initial_pre_image_bytes" + assert contents[0].parts[2].inline_data.data == b"initial_post_image_bytes" + + # 2. Verify index 3 (intermediate image) is replaced by text placeholder + assert getattr(contents[3].parts[1], "inline_data", None) is None + assert "[Screenshot of intermediate step omitted for performance]" in contents[3].parts[1].text + + # 3. Verify index 6 (latest image) is fully preserved + assert contents[6].parts[1].inline_data.data == b"latest_live_image_bytes_to_keep" + + +@pytest.mark.asyncio +async def test_record_step_pre_allocated_id_reset(mock_artemis_ctx): + """Verify that after record_step is called, the pre-allocated current_step_id is reset to None so subsequent steps generate distinct IDs.""" + from artemis.data_engine.engine import DataEngine + + engine = DataEngine(mock_artemis_ctx) + engine.start_session("Pre-allocated ID Reset Test") + + # 1. Pre-allocate ID for step 1 + step_id_1 = engine.allocate_step_id() + assert engine.current_step_id == step_id_1 + + # 2. Record step 1 (should use step_id_1 and reset current_step_id to None) + returned_id_1 = engine.record_step(summary="Step 1") + assert returned_id_1 == step_id_1 + assert engine.current_step_id is None + + # 3. Pre-allocate ID for step 2 (should get a distinct new ID) + step_id_2 = engine.allocate_step_id() + assert step_id_2 != step_id_1 + assert engine.current_step_id == step_id_2 + + # 4. Record step 2 (should use step_id_2 and reset current_step_id to None) + returned_id_2 = engine.record_step(summary="Step 2") + assert returned_id_2 == step_id_2 + assert returned_id_2 != returned_id_1 + assert engine.current_step_id is None diff --git a/tests/unit/data_engine/test_trace_enhancements.py b/tests/unit/data_engine/test_trace_enhancements.py new file mode 100644 index 0000000..1d579de --- /dev/null +++ b/tests/unit/data_engine/test_trace_enhancements.py @@ -0,0 +1,189 @@ +# 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 json +from unittest.mock import MagicMock + +from artemis.context import ArtemisContext +from artemis.data_engine.trace import ( + TraceSpan, + smart_serialize, +) +import pytest + + +def test_smart_serialize(): + # Test dict + d = {"a": 1, "b": "string"} + assert smart_serialize(d) == json.dumps(d, ensure_ascii=False) + + # Test list + items_list = [1, 2, "three"] + assert smart_serialize(items_list) == json.dumps(items_list, ensure_ascii=False) + + # Test bytes + b = b"hello world" + import hashlib + + expected_sha = hashlib.sha256(b).hexdigest()[:8] + assert smart_serialize(b) == f"" + + # Test other types + assert smart_serialize(123) == "123" + assert smart_serialize("just a string") == "just a string" + + +@pytest.mark.asyncio +async def test_trace_span(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + with TraceSpan(name="test_span", ctx=mock_ctx) as span: + span.result = "span_result" + # Simulate work + + # TraceSpan writes twice: start (running) and end (success) + assert mock_ctx.data_engine.record_trace.call_count == 2 + + # Verify the final success call (which is the last call) + args, kwargs = mock_ctx.data_engine.record_trace.call_args + assert kwargs["type"] == "span" + assert kwargs["name"] == "test_span" + assert kwargs["status"] == "success" + assert kwargs["payload"]["result"] == "span_result" + + +@pytest.mark.asyncio +async def test_trace_span_failure(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + try: + with TraceSpan(name="failed_span", ctx=mock_ctx): + raise ValueError("Span error") + except ValueError: + pass + + # TraceSpan writes twice: start (running) and end (failed) + assert mock_ctx.data_engine.record_trace.call_count == 2 + + # Verify the final failed call (which is the last call) + args, kwargs = mock_ctx.data_engine.record_trace.call_args + assert kwargs["type"] == "span" + assert kwargs["name"] == "failed_span" + assert kwargs["status"] == "failed" + assert "error" in kwargs["payload"] + assert "Span error" in kwargs["payload"]["error"] + + +def test_data_engine_callback_handler(): + from uuid import uuid4 + from langchain_core.messages import HumanMessage, AIMessage + from artemis.data_engine.trace import DataEngineCallbackHandler + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + mock_step_id = uuid4() + mock_ctx.data_engine.current_step_id = mock_step_id + + handler = DataEngineCallbackHandler(mock_ctx) + run_id = uuid4() + + # 1. Simulate on_chat_model_start + serialized = {"name": "test_model"} + messages = [[HumanMessage(content="Hello LLM")]] + handler.on_chat_model_start(serialized, messages, run_id=run_id) + + assert mock_ctx.data_engine.record_trace.call_count == 1 + call1_args, call1_kwargs = mock_ctx.data_engine.record_trace.call_args_list[0] + assert call1_kwargs["type"] == "llm_call" + assert call1_kwargs["name"] == "test_model" + assert call1_kwargs["status"] == "running" + assert call1_kwargs["step_id"] == mock_step_id + assert call1_kwargs["payload"]["messages"][0]["content"] == "Hello LLM" + + # 2. Simulate on_llm_end (successful complete response) + class MockGeneration: + def __init__(self, message): + self.message = message + + class MockLLMResult: + def __init__(self, generations): + self.generations = generations + + response_message = AIMessage(content="Hello User") + mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]]) + + handler.on_llm_end(mock_response, run_id=run_id) + + assert mock_ctx.data_engine.record_trace.call_count == 3 + call2_args, call2_kwargs = mock_ctx.data_engine.record_trace.call_args_list[1] + assert call2_kwargs["type"] == "llm_call" + assert call2_kwargs["status"] == "success" + assert call2_kwargs["step_id"] == mock_step_id + assert call2_kwargs["payload"]["messages"][0]["content"] == "Hello LLM" + assert call2_kwargs["payload"]["response"][0]["content"] == "Hello User" + assert isinstance(call2_kwargs.get("duration"), float) + assert call2_kwargs["duration"] >= 0.0 + + call3_args, call3_kwargs = mock_ctx.data_engine.record_trace.call_args_list[2] + assert call3_kwargs["type"] == "raw_thinking" + assert call3_kwargs["parent_trace_id"] == run_id + assert call3_kwargs["payload"]["thought"] == "Hello User" + + +def test_callback_handler_chain_parenting(): + from uuid import uuid4 + from langchain_core.messages import HumanMessage + from artemis.data_engine.trace import DataEngineCallbackHandler, CURRENT_TRACE_ID + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + handler = DataEngineCallbackHandler(mock_ctx) + + agent_run_id = uuid4() + chain1_run_id = uuid4() + chain2_run_id = uuid4() + llm_run_id = uuid4() + + token = CURRENT_TRACE_ID.set(agent_run_id) + try: + # Simulate chain start (top level, parent is agent_run_id) + handler.on_chain_start({}, {}, run_id=chain1_run_id, parent_run_id=None) + # Simulate nested chain start (parent is chain1_run_id) + handler.on_chain_start({}, {}, run_id=chain2_run_id, parent_run_id=chain1_run_id) + + # Simulate LLM start inside nested chain (parent is chain2_run_id) + messages = [[HumanMessage(content="Hello Nested LLM")]] + handler.on_chat_model_start( + {"name": "test_nested_model"}, + messages, + run_id=llm_run_id, + parent_run_id=chain2_run_id, + ) + + # Verify parent trace ID resolves to agent_run_id (since chains are not recorded) + assert mock_ctx.data_engine.record_trace.call_count == 1 + args, kwargs = mock_ctx.data_engine.record_trace.call_args + assert kwargs["parent_trace_id"] == agent_run_id + + # Cleanup + handler.on_chain_end({}, run_id=chain2_run_id) + handler.on_chain_end({}, run_id=chain1_run_id) + + assert len(handler.chain_parents) == 0 + finally: + CURRENT_TRACE_ID.reset(token) diff --git a/tests/unit/graph/test_graph.py b/tests/unit/graph/test_graph.py new file mode 100644 index 0000000..075bf91 --- /dev/null +++ b/tests/unit/graph/test_graph.py @@ -0,0 +1,297 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.graph.graph import wrap_note_tool, wrap_update_note_tool +import pytest + + +@pytest.mark.asyncio +async def test_wrap_update_note_tool_valid_status_change(tmp_path): + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + task_plan_path = notes_dir / "task_plan.md" + + original_plan = """# Test Plan +- [ ] Milestone 1 + - [ ] Substep 1.1 +- [ ] Milestone 2 +""" + task_plan_path.write_text(original_plan, encoding="utf-8") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(base_dir) + mock_ctx.execution_setup = None + + mock_original_tool = MagicMock() + mock_original_tool.name = "update_note" + mock_original_tool.description = "Update a note" + mock_original_tool.ainvoke = AsyncMock(return_value="Success") + + async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): + content = task_plan_path.read_text(encoding="utf-8") + updated = content.replace(args["target"], args["replacement"]) + task_plan_path.write_text(updated, encoding="utf-8") + return "Success" + + target = "- [ ] Milestone 1" + replacement = "- [x] Milestone 1" + + with patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=fake_invoke_tool, + ): + # Instantiate inside patch context to capture the mocked import locally + wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool) + result = await wrapped_tool.ainvoke( + { + "key": "task_plan", + "target": target, + "replacement": replacement, + "tool_call_id": "test_call_id", + } + ) + + assert result == "Success" + updated_content = task_plan_path.read_text(encoding="utf-8") + assert "- [x] Milestone 1" in updated_content + assert "- [ ] Milestone 2" in updated_content + + +@pytest.mark.asyncio +async def test_wrap_update_note_tool_invalid_milestone_text_change(tmp_path): + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + task_plan_path = notes_dir / "task_plan.md" + + original_plan = """# Test Plan +- [ ] Milestone 1 + - [ ] Substep 1.1 +- [ ] Milestone 2 +""" + task_plan_path.write_text(original_plan, encoding="utf-8") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(base_dir) + mock_ctx.execution_setup = None + + mock_original_tool = MagicMock() + mock_original_tool.name = "update_note" + mock_original_tool.ainvoke = AsyncMock(return_value="Success") + + async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): + content = task_plan_path.read_text(encoding="utf-8") + updated = content.replace(args["target"], args["replacement"]) + task_plan_path.write_text(updated, encoding="utf-8") + return "Success" + + target = "- [ ] Milestone 1" + replacement = "- [ ] Milestone Renamed" + + mock_validation = AsyncMock(return_value={"status": "failed", "feedback": "rejected"}) + with ( + patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=fake_invoke_tool, + ), + patch( + "artemis.agents.planner.planner.run_async_planner_validation", + mock_validation, + ), + ): + wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool) + result = await wrapped_tool.ainvoke( + { + "key": "task_plan", + "target": target, + "replacement": replacement, + "tool_call_id": "test_call_id", + } + ) + + assert result == "Success" + assert mock_ctx.planner_task is not None + + +@pytest.mark.asyncio +async def test_wrap_update_note_tool_allow_subgoal_changes(tmp_path): + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + task_plan_path = notes_dir / "task_plan.md" + + original_plan = """# Test Plan +- [ ] Milestone 1 + - [ ] Substep 1.1 +- [ ] Milestone 2 +""" + task_plan_path.write_text(original_plan, encoding="utf-8") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(base_dir) + mock_ctx.execution_setup = None + + mock_original_tool = MagicMock() + mock_original_tool.name = "update_note" + mock_original_tool.ainvoke = AsyncMock(return_value="Success") + + async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): + content = task_plan_path.read_text(encoding="utf-8") + updated = content.replace(args["target"], args["replacement"]) + task_plan_path.write_text(updated, encoding="utf-8") + return "Success" + + target = " - [ ] Substep 1.1" + replacement = " - [x] Substep 1.1 Completed" + + with patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=fake_invoke_tool, + ): + wrapped_tool = wrap_update_note_tool(mock_ctx, mock_original_tool) + result = await wrapped_tool.ainvoke( + { + "key": "task_plan", + "target": target, + "replacement": replacement, + "tool_call_id": "test_call_id", + } + ) + + assert result == "Success" + updated_content = task_plan_path.read_text(encoding="utf-8") + assert "Substep 1.1 Completed" in updated_content + assert "- [ ] Milestone 1" in updated_content + + +@pytest.mark.asyncio +async def test_wrap_note_tool_invalid_rewrite(tmp_path): + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + task_plan_path = notes_dir / "task_plan.md" + + original_plan = """# Test Plan +- [ ] Milestone 1 + - [ ] Substep 1.1 +- [ ] Milestone 2 +""" + task_plan_path.write_text(original_plan, encoding="utf-8") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(base_dir) + mock_ctx.execution_setup = None + + mock_original_tool = MagicMock() + mock_original_tool.name = "save_note" + mock_original_tool.ainvoke = AsyncMock(return_value="Success") + + async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): + task_plan_path.write_text(args["content"], encoding="utf-8") + return "Success" + + corrupted_plan = """# Test Plan +- [ ] Milestone 1 (Altered) +- [ ] New Milestone Added +""" + + mock_validation = AsyncMock(return_value={"status": "failed", "feedback": "rejected"}) + with ( + patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=fake_invoke_tool, + ), + patch( + "artemis.agents.planner.planner.run_async_planner_validation", + mock_validation, + ), + ): + wrapped_tool = wrap_note_tool(mock_ctx, mock_original_tool) + result = await wrapped_tool.ainvoke( + { + "key": "task_plan", + "content": corrupted_plan, + "tool_call_id": "test_call_id", + } + ) + + assert result == "Success" + assert mock_ctx.planner_task is not None + + +@pytest.mark.asyncio +async def test_wrap_note_tool_valid_lexical_drift(tmp_path): + from langgraph.types import Command + from langchain_core.messages import ToolMessage + from artemis.constants import VALIDATOR_MESSAGES_KEY + + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + task_plan_path = notes_dir / "task_plan.md" + + original_plan = """# Test Plan +- [ ] Open the Chrome browser and navigate to youtube.com +""" + task_plan_path.write_text(original_plan, encoding="utf-8") + + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = str(base_dir) + mock_ctx.execution_setup = None + + # Create mock Command result + tool_msg = ToolMessage(tool_call_id="test_call_id", content="Success outcome", status="success") + mock_command = Command(update={VALIDATOR_MESSAGES_KEY: [tool_msg]}) + + mock_original_tool = MagicMock() + mock_original_tool.name = "save_note" + mock_original_tool.ainvoke = AsyncMock(return_value=mock_command) + + async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): + task_plan_path.write_text(args["content"], encoding="utf-8") + return mock_command + + # Minor wording change that is semantically valid (similarity ~92%) + minor_drift_plan = """# Test Plan +- [ ] Open Chrome browser and navigate to youtube.com +""" + + with patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=fake_invoke_tool, + ): + wrapped_tool = wrap_note_tool(mock_ctx, mock_original_tool) + result = await wrapped_tool.ainvoke( + { + "key": "task_plan", + "content": minor_drift_plan, + "tool_call_id": "test_call_id", + } + ) + + assert isinstance(result, Command) + assert not hasattr(mock_ctx, "planner_task") or mock_ctx.planner_task is None + + # Verify that the drift plan is actually saved + saved_content = task_plan_path.read_text(encoding="utf-8") + assert "Open Chrome browser" in saved_content diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py new file mode 100644 index 0000000..433961d --- /dev/null +++ b/tests/unit/test_agents.py @@ -0,0 +1,146 @@ +# 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 Base Agent Protocol & Lifecycle Registry.""" + +import pytest +from typing import Any +from artemis.agents.base import AgentConfig, AgentRegistry, BaseAgent +from artemis.agents.validator.failure_analyzer_agent import FailureAnalyzerAgent +from artemis.agents.validator.recovery_strategies import ( + RecoveryStrategyRegistry, +) +from artemis.drivers.mock.mock_driver import MockDeviceDriver + + +class DummyTestAgent(BaseAgent): + """Simple test agent implementing BaseAgent protocol.""" + + def __init__(self, config: AgentConfig | None = None, ctx: Any | None = None, **kwargs: Any): + super().__init__( + name="dummy_test_agent", + role="Test Worker", + description="Agent used for testing lifecycle transitions.", + config=config, + ctx=ctx, + ) + + async def process(self, state: Any) -> dict[str, Any]: + return {"status": "success", "processed_state": True} + + +@pytest.mark.asyncio +async def test_base_agent_lifecycle(): + """Verify BaseAgent initialization, execution, and cleanup lifecycle.""" + agent = DummyTestAgent() + assert agent.name == "dummy_test_agent" + assert not agent._is_initialized + + # 1. Initialize + mock_ctx = object() + await agent.initialize(mock_ctx) + assert agent._is_initialized + assert agent.ctx is mock_ctx + + # 2. Process + result = await agent.process({}) + assert result.get("status") == "success" + assert result.get("processed_state") is True + + # 3. Cleanup + await agent.cleanup() + assert not agent._is_initialized + + +def test_agent_registry(): + """Verify AgentRegistry registers and instantiates agents correctly.""" + registered = AgentRegistry.list_agents() + assert "planner" in registered + assert "summarizer" in registered + assert "operator" in registered + assert "failure_analyzer" in registered + + # Instantiate PlannerAgent via registry + planner = AgentRegistry.create_agent("planner", ctx=None) + assert planner.name == "planner" + assert planner.role == "Strategic Task Planner" + + # Instantiate SummarizerAgent via registry + summarizer = AgentRegistry.create_agent("summarizer", ctx=None) + assert summarizer.name == "summarizer" + assert summarizer.role == "Execution History Summarizer" + + # Instantiate OperatorAgent via registry + operator = AgentRegistry.create_agent("operator", ctx=None) + assert operator.name == "operator" + assert operator.role == "Mobile UI Interaction Operator" + + # Instantiate FailureAnalyzerAgent via registry + fa = AgentRegistry.create_agent("failure_analyzer", ctx=None) + assert fa.name == "failure_analyzer" + assert fa.role == "Autonomous Self-Healing Failure Analyzer" + + +def test_agent_config(): + """Verify AgentConfig parameters can be customized.""" + config = AgentConfig( + model_name="gemini-2.5-pro", + temperature=0.7, + max_tokens=2048, + enabled_tools=["click", "swipe"], + ) + agent = DummyTestAgent(config=config) + assert agent.config.model_name == "gemini-2.5-pro" + assert agent.config.temperature == 0.7 + assert agent.config.enabled_tools == ["click", "swipe"] + + +@pytest.mark.asyncio +async def test_recovery_strategies_and_failure_analyzer(): + """Verify self-healing recovery strategies and FailureAnalyzerAgent execution.""" + driver = MockDeviceDriver(device_id="mock-rec-1") + + # 1. Test dialog dismissal strategy + dialog_ctx = {"category": "ui_obstruction", "error_message": "Unexpected permission popup"} + dialog_res = await RecoveryStrategyRegistry.attempt_recovery(driver, dialog_ctx) + assert dialog_res is not None + assert dialog_res.success + assert dialog_res.action_type.value == "dismiss_dialog" + assert driver.action_history[-1].get("key") == "back" + + # 2. Test navigate back strategy + nav_ctx = {"category": "wrong_screen"} + nav_res = await RecoveryStrategyRegistry.attempt_recovery(driver, nav_ctx) + assert nav_res is not None + assert nav_res.success + assert nav_res.action_type.value == "navigate_back" + + # 3. Test scroll and search strategy + scroll_ctx = {"category": "element_not_found", "scroll_direction": "down"} + scroll_res = await RecoveryStrategyRegistry.attempt_recovery(driver, scroll_ctx) + assert scroll_res is not None + assert scroll_res.success + assert scroll_res.action_type.value == "scroll_and_retry" + + # 4. Test FailureAnalyzerAgent process + class MockContext: + pass + + ctx = MockContext() + setattr(ctx, "_active_driver", driver) + + fa_agent = FailureAnalyzerAgent(ctx=ctx) + fa_result = await fa_agent.process({"failure_context": {"category": "wrong_screen"}}) + assert fa_result["status"] == "recovered" + assert fa_result["recovery_action"] == "navigate_back" diff --git a/tests/unit/test_await.py b/tests/unit/test_await.py new file mode 100644 index 0000000..0e3c816 --- /dev/null +++ b/tests/unit/test_await.py @@ -0,0 +1,30 @@ +# 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 + + +async def f(): + return [1, 2] + + +async def m(): + try: + val = await f()[-1] + print(val) + except Exception as e: + print(repr(e)) + + +asyncio.run(m()) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..e20fcb3 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,65 @@ +# 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 Unified CLI application.""" + +from typer.testing import CliRunner +from artemis.interfaces.cli.main import app + +runner = CliRunner() + + +def test_cli_help(): + """Verify top-level CLI help returns status 0 and lists core subcommands.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + 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 + + +def test_cli_version(): + """Verify --version returns version banner.""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "Artemis Agent Platform" in result.output + + +def test_cli_run_help(): + """Verify 'artemis run --help' displays execution options.""" + result = runner.invoke(app, ["run", "--help"]) + assert result.exit_code == 0 + assert "--profile" in result.output + assert "--locked-app" in result.output + assert "--traces-path" in result.output + + +def test_cli_batch_help(): + """Verify 'artemis batch --help' displays batch options.""" + result = runner.invoke(app, ["batch", "--help"]) + assert result.exit_code == 0 + assert "--file" in result.output + assert "--delay" in result.output + + +def test_cli_trace_help(): + """Verify 'artemis trace --help' lists trace subcommands.""" + result = runner.invoke(app, ["trace", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "view" in result.output diff --git a/tests/unit/test_config_package.py b/tests/unit/test_config_package.py new file mode 100644 index 0000000..18cf71c --- /dev/null +++ b/tests/unit/test_config_package.py @@ -0,0 +1,541 @@ +# 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 the unified artemis.config package.""" + +from pathlib import Path +import tempfile +from unittest.mock import MagicMock +import pytest +from pydantic import SecretStr + +from artemis.config import ( + CONFIG_DIR, + ROOT_DIR, + AgentGlobalConfig, + LLMConfig, + OutputConfig, + Settings, + cleanup_temp_dir, + clear_ipc_port, + clear_ls_address, + deep_merge_llm_config, + get_app_dir, + get_config_path, + get_data_engine_db_path, + get_default_llm_config, + get_default_traces_path, + get_temp_dir, + get_traces_dir, + load_agent_config, + read_ipc_port, + read_ls_address, + record_events, + write_ipc_port, + write_ls_address, +) + + +def test_paths_and_directories(): + """Verify central path resolution.""" + assert ROOT_DIR.exists() + assert (ROOT_DIR / "artemis").exists() + assert CONFIG_DIR.exists() + assert (CONFIG_DIR / "artemis.jsonc").exists() + + app_dir = get_app_dir() + assert isinstance(app_dir, Path) + + traces_path = get_default_traces_path() + assert isinstance(traces_path, Path) + assert get_traces_dir() == traces_path + + db_path = get_data_engine_db_path() + assert db_path.name == "data_engine.db" + + temp_dir = get_temp_dir("test_subfolder") + assert temp_dir.exists() + assert temp_dir.name == "test_subfolder" + + +def test_config_file_resolver(): + """Test get_config_path for unified artemis.jsonc.""" + artemis_path = get_config_path("artemis.jsonc") + assert artemis_path.exists() + + with pytest.raises(FileNotFoundError): + get_config_path("non_existent_config_12345.json") + + +def test_settings_and_api_key_fallbacks(monkeypatch): + """Test Settings model, API key fallbacks, and get/set methods.""" + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GCP_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + custom_settings = Settings( + _env_file=None, + GOOGLE_API_KEY=None, + GEMINI_API_KEY=SecretStr("gemini_key_123"), + OPENAI_API_KEY=SecretStr("openai_key_456"), + OCR_API_KEY=SecretStr("ocr_key_789"), + ) + # Automatic fallback should populate GOOGLE_API_KEY from GEMINI_API_KEY + assert custom_settings.GOOGLE_API_KEY is not None + assert custom_settings.GOOGLE_API_KEY.get_secret_value() == "gemini_key_123" + assert custom_settings.get_api_key("ocr").get_secret_value() == "ocr_key_789" + + assert custom_settings.get_api_key("google").get_secret_value() == "gemini_key_123" + assert custom_settings.get_api_key("gemini").get_secret_value() == "gemini_key_123" + assert custom_settings.get_api_key("openai").get_secret_value() == "openai_key_456" + assert custom_settings.get_api_key("nonexistent") is None + + # Test dynamic set_api_key + custom_settings.set_api_key("xai", "xai_test_key", persist_to_env=False) + assert custom_settings.get_api_key("xai").get_secret_value() == "xai_test_key" + + +def test_llm_config_parsing_and_merging(): + """Test LLMConfig parsing, agent querying, and deep merging.""" + llm_cfg = get_default_llm_config() + assert isinstance(llm_cfg, LLMConfig) + assert llm_cfg.planner.provider in ("google", "openai", "openrouter", "xai", "vertexai") + assert llm_cfg.get_agent("planner") is not None + assert llm_cfg.get_utils("hopper") is not None + + # Deep merge overrides + overrides = { + "planner": { + "model": "custom-model-v1", + "temperature": 0.7, + } + } + merged = deep_merge_llm_config(llm_cfg, overrides) + assert merged.planner.model == "custom-model-v1" + assert merged.planner.temperature == 0.7 + + +def test_agent_config_loading(): + """Test AgentGlobalConfig parsing from agent_config.json / artemis.jsonc.""" + agent_cfg = load_agent_config() + assert isinstance(agent_cfg, AgentGlobalConfig) + assert "operator" in agent_cfg.explorer_versions + assert agent_cfg.explorer.default_version == "flash" + assert agent_cfg.explorer.flash_mode == "flash" + assert agent_cfg.explorer.pro_mode == "flash" + assert agent_cfg.explorer.caching is True + assert "explorer" in agent_cfg.blacklisted_tools + assert agent_cfg.video_analyzer.enable_ledger is True + assert agent_cfg.planner_validation.enabled is False + assert agent_cfg.planner_validation.similarity_threshold == 0.85 + assert agent_cfg.committee.enabled is False + assert agent_cfg.committee.debate_rounds == 2 + assert agent_cfg.checker.enabled is False + assert agent_cfg.checker.max_iterations == 20 + assert agent_cfg.checker.max_chat_rounds == 4 + assert agent_cfg.outputter.enabled is True + assert agent_cfg.outputter.force_synthesis is False + assert agent_cfg.flash.max_turns == 30 + assert agent_cfg.flash.explorer_mode == "flash" + assert agent_cfg.pro.explorer.mode == "flash" + assert agent_cfg.pro.checker.enabled is False + assert agent_cfg.pro.committee.enabled is False + assert agent_cfg.pro.planner_validation.enabled is False + assert agent_cfg.pro.video_analyzer.enable_ledger is True + + +def test_output_config_and_recording(): + """Test OutputConfig behavior and event recording.""" + cfg = OutputConfig(output_description="Output a valid JSON summary") + assert cfg.needs_structured_format() is True + + cfg_disabled = OutputConfig(output_description="Output JSON", enable_outputter=False) + assert cfg_disabled.needs_structured_format() is False + + cfg_forced = OutputConfig(force_synthesis=True) + assert cfg_forced.needs_structured_format() is True + + with tempfile.TemporaryDirectory() as tmpdir: + output_file = Path(tmpdir) / "events.json" + events_data = [{"event": "test_event", "status": "ok"}] + record_events(output_file, events_data) + assert output_file.exists() + assert "test_event" in output_file.read_text(encoding="utf-8") + + +def test_runtime_state_and_ipc(): + """Test IPC port and LS address state helpers.""" + # Test IPC port + write_ipc_port(49152) + assert read_ipc_port() == 49152 + clear_ipc_port() + + # Test LS address + write_ls_address("localhost:12345") + assert read_ls_address() == "localhost:12345" + clear_ls_address() + + # Test temp dir cleanup + test_temp_sub = get_temp_dir("test_cleanup_dir") + test_temp_file = test_temp_sub / "temp_marker.txt" + test_temp_file.write_text("temporary data") + assert test_temp_file.exists() + + deleted = cleanup_temp_dir("test_cleanup_dir") + assert deleted >= 1 + assert not test_temp_file.exists() + + +def test_planner_validation_builder_and_milestones(): + """Test AgentConfigBuilder methods for planner validation and validate_milestones threshold.""" + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + from artemis.graph.graph import validate_milestones + + # Default builder inherits from artemis.jsonc (enabled=False, similarity_threshold=0.85) + builder = AgentConfigBuilder() + cfg = builder.build() + assert cfg.disable_planner_validation is True + assert cfg.planner_validation_threshold == 0.85 + + # Fluent enabling + cfg_enabled = ( + AgentConfigBuilder() + .with_planner_validation(enabled=True, similarity_threshold=0.90) + .build() + ) + assert cfg_enabled.disable_planner_validation is False + assert cfg_enabled.planner_validation_threshold == 0.90 + + # Fluent disabling + cfg_disabled = AgentConfigBuilder().with_disable_planner_validation(True).build() + assert cfg_disabled.disable_planner_validation is True + + # Test validate_milestones with custom thresholds + before = "- [ ] Tap Login button\n- [ ] Enter password" + after_minor = "- [ ] Tap the Login button\n- [ ] Enter password" + # Minor edit has ~0.94 similarity + assert validate_milestones(before, after_minor, similarity_threshold=0.85) is False + assert validate_milestones(before, after_minor, similarity_threshold=0.98) is True + + +@pytest.mark.asyncio +async def test_committee_builder_and_graph_mounting(): + """Test AgentConfigBuilder committee methods and graph mounting.""" + from artemis.context import ArtemisContext, DeviceContext, DevicePlatform, ExecutionSetup + from artemis.graph.graph import get_graph + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + # Default: disabled + cfg_default = AgentConfigBuilder().build() + assert cfg_default.enable_committee is False + assert cfg_default.committee_debate_rounds == 2 + + # Enabled via builder + cfg_enabled = AgentConfigBuilder().with_committee(enabled=True, debate_rounds=3).build() + assert cfg_enabled.enable_committee is True + assert cfg_enabled.committee_debate_rounds == 3 + + # Test mounting in graph + device = DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id="dummy", + device_width=1080, + device_height=2400, + ) + ctx_disabled = ArtemisContext( + device=device, + execution_setup=ExecutionSetup(enable_committee=False), + ) + graph_disabled = await get_graph(ctx_disabled) + op_node_disabled = graph_disabled.nodes.get("operator") + assert op_node_disabled is not None + # Verify ask_committee is not mounted when disabled + op_tools_disabled = [t.name for t in op_node_disabled.bound.afunc.tools] + assert "ask_committee" not in op_tools_disabled + + ctx_enabled = ArtemisContext( + device=device, + execution_setup=ExecutionSetup(enable_committee=True, committee_debate_rounds=3), + ) + graph_enabled = await get_graph(ctx_enabled) + op_node_enabled = graph_enabled.nodes.get("operator") + assert op_node_enabled is not None + # Verify ask_committee is mounted when enabled + op_tools_enabled = [t.name for t in op_node_enabled.bound.afunc.tools] + assert "ask_committee" in op_tools_enabled + + +def test_checker_builder_and_context_propagation(): + """Test AgentConfigBuilder methods and context propagation for checker.""" + from unittest.mock import MagicMock + from artemis.context import ArtemisContext, DeviceContext, DevicePlatform + from artemis.sdk.agent import Agent + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + # Default builder inherits from artemis.jsonc (enabled=False, max_iterations=20, max_chat_rounds=4) + builder = AgentConfigBuilder() + cfg = builder.build() + assert cfg.disable_checker is True + assert cfg.checker_max_iterations == 20 + assert cfg.checker_max_chat_rounds == 4 + + # Fluent enabling + cfg_enabled = ( + AgentConfigBuilder() + .with_checker(enabled=True, max_iterations=25, max_chat_rounds=5) + .build() + ) + assert cfg_enabled.disable_checker is False + assert cfg_enabled.checker_max_iterations == 25 + assert cfg_enabled.checker_max_chat_rounds == 5 + + # Fluent disabling + cfg_disabled = AgentConfigBuilder().with_disable_checker(True).build() + assert cfg_disabled.disable_checker is True + + # Test propagation to ExecutionSetup via Agent._prepare_tracing + agent = Agent(config=cfg_enabled) + device = DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id="dummy", + device_width=1080, + device_height=2400, + ) + ctx = ArtemisContext(device=device) + mock_task = MagicMock() + mock_task.get_name.return_value = "test_task" + mock_task.request.record_trace = False + mock_task.request.name = "test_task" + mock_task.request.profile = None + mock_task.request.goal = "Test goal" + agent._prepare_tracing(mock_task, ctx) + + assert ctx.execution_setup is not None + assert ctx.execution_setup.disable_checker is False + assert ctx.execution_setup.checker_max_iterations == 25 + assert ctx.execution_setup.checker_max_chat_rounds == 5 + + +def test_explorer_builder_and_resolution(monkeypatch): + """Test AgentConfigBuilder explorer methods and multi-tier resolution logic.""" + from artemis.context import ArtemisContext, DeviceContext, DevicePlatform + from artemis.sdk.agent import Agent + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + from artemis.tools.explorer_tool import resolve_explorer_version + + # Default builder inherits from artemis.jsonc (default="flash", flash_mode="flash", pro_mode="flash", caching=True) + builder = AgentConfigBuilder() + cfg = builder.build() + assert cfg.explorer.default_version == "flash" + assert cfg.explorer.flash_mode == "flash" + assert cfg.explorer.pro_mode == "flash" + assert cfg.explorer.caching is True + + # Fluent configuration with with_explorer + cfg_custom = ( + AgentConfigBuilder() + .with_explorer( + version="pro", + flash_mode="flash", + pro_mode="ultra", + caching=False, + versions={"operator": "ultra", "validator": "pro"}, + ) + .build() + ) + assert cfg_custom.explorer.default_version == "pro" + assert cfg_custom.explorer.flash_mode == "flash" + assert cfg_custom.explorer.pro_mode == "ultra" + assert cfg_custom.explorer.caching is False + assert cfg_custom.explorer_versions["operator"] == "ultra" + assert cfg_custom.explorer_versions["validator"] == "pro" + + # Fluent configuration with with_explorer_version shorthand + cfg_shorthand = AgentConfigBuilder().with_explorer_version("ultra").build() + assert cfg_shorthand.explorer.default_version == "ultra" + + # Context setup for resolution tests + device = DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id="dummy", + device_width=1080, + device_height=2400, + ) + ctx = ArtemisContext(device=device, agent_config=cfg_custom) + + # 1. Explicit override takes highest priority + assert resolve_explorer_version(ctx, explicit_version="flash") == "flash" + assert resolve_explorer_version(ctx, explicit_version="pro") == "pro" + assert resolve_explorer_version(ctx, explicit_version="ultra") == "ultra" + + # 2. Environment variable override + monkeypatch.setenv("ARTEMIS_EXPLORER_VERSION", "ultra") + assert resolve_explorer_version(ctx) == "ultra" + monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) + + # 3. Per-agent mapping in explorer_versions + assert resolve_explorer_version(ctx, agent_or_profile_name="operator") == "ultra" + assert resolve_explorer_version(ctx, agent_or_profile_name="validator") == "pro" + + # 4. Pro profile vs Flash profile resolution + cfg_profiled = ( + AgentConfigBuilder() + .with_explorer(flash_mode="flash", pro_mode="pro", default_version="flash", versions={}) + .build() + ) + ctx_profiled = ArtemisContext(device=device, agent_config=cfg_profiled) + assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="flash") == "flash" + assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="flash_runner") == "flash" + assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="pro") == "pro" + assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="operator") == "pro" + + # 5. Propagation to ExecutionSetup via Agent._prepare_tracing + agent = Agent(config=cfg_custom) + mock_task = MagicMock() + mock_task.get_name.return_value = "explorer_test_task" + mock_task.request.record_trace = False + mock_task.request.name = "explorer_test_task" + mock_task.request.profile = None + mock_task.request.goal = "Test explorer propagation" + agent._prepare_tracing(mock_task, ctx) + + assert ctx.execution_setup is not None + assert ctx.execution_setup.explorer_version == "pro" + assert ctx.execution_setup.explorer_flash_mode == "flash" + assert ctx.execution_setup.explorer_pro_mode == "ultra" + assert ctx.execution_setup.explorer_caching is False + + +def test_outputter_builder_and_context_propagation(): + """Test AgentConfigBuilder outputter methods and propagation to ExecutionSetup.""" + from artemis.context import ArtemisContext, DeviceContext, DevicePlatform + from artemis.sdk.agent import Agent + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + # Default builder inherits from artemis.jsonc (enabled=True, force_synthesis=False) + builder = AgentConfigBuilder() + cfg = builder.build() + assert cfg.disable_outputter is False + assert cfg.outputter.enabled is True + assert cfg.outputter.force_synthesis is False + + # Fluent configuration with with_outputter + cfg_custom = AgentConfigBuilder().with_outputter(enabled=True, force_synthesis=True).build() + assert cfg_custom.disable_outputter is False + assert cfg_custom.outputter.enabled is True + assert cfg_custom.outputter.force_synthesis is True + + # Fluent disabling with with_disable_outputter + cfg_disabled = AgentConfigBuilder().with_disable_outputter(True).build() + assert cfg_disabled.disable_outputter is True + assert cfg_disabled.outputter.enabled is False + + # Test propagation to ExecutionSetup via Agent._prepare_tracing + device = DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id="dummy", + device_width=1080, + device_height=2400, + ) + ctx = ArtemisContext(device=device) + agent = Agent(config=cfg_custom) + mock_task = MagicMock() + mock_task.get_name.return_value = "outputter_test_task" + mock_task.request.record_trace = False + mock_task.request.name = "outputter_test_task" + mock_task.request.profile = None + mock_task.request.goal = "Test outputter propagation" + agent._prepare_tracing(mock_task, ctx) + + assert ctx.execution_setup is not None + assert ctx.execution_setup.disable_outputter is False + assert ctx.execution_setup.outputter.enabled is True + assert ctx.execution_setup.outputter.force_synthesis is True + + +def test_categorized_flash_and_pro_profile_builders(): + """Test with_flash_config and with_pro_config fluent builders and bidirectional sync.""" + from artemis.config.agent import AgentGlobalConfig + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + # 1. Test with_flash_config + cfg_flash = AgentConfigBuilder().with_flash_config(max_turns=15, explorer_mode="flash").build() + assert cfg_flash.flash.max_turns == 15 + assert cfg_flash.flash.explorer_mode == "flash" + assert cfg_flash.explorer.flash_mode == "flash" + + # 2. Test with_pro_config + cfg_pro = ( + AgentConfigBuilder() + .with_pro_config( + explorer_mode="ultra", + planner_validation=True, + committee=True, + checker=True, + video_ledger=False, + ) + .build() + ) + assert cfg_pro.pro.explorer.mode == "ultra" + assert cfg_pro.explorer.pro_mode == "ultra" + assert cfg_pro.disable_planner_validation is False + assert cfg_pro.enable_committee is True + assert cfg_pro.disable_checker is False + assert cfg_pro.enable_video_ledger is False + + # 3. Test AgentGlobalConfig bidirectional synchronization + raw_dict = { + "flash": {"max_turns": 25, "explorer_mode": "flash"}, + "pro": { + "explorer": {"mode": "pro", "caching": False}, + "checker": {"enabled": True, "max_iterations": 10}, + "committee": {"enabled": True, "debate_rounds": 4}, + }, + } + synced = AgentGlobalConfig.model_validate(raw_dict) + assert synced.flash.max_turns == 25 + assert synced.explorer.flash_mode == "flash" + assert synced.explorer.pro_mode == "pro" + assert synced.explorer.caching is False + assert synced.checker.enabled is True + assert synced.checker.max_iterations == 10 + assert synced.committee.enabled is True + assert synced.committee.debate_rounds == 4 + + +def test_agent_config_environment_variable_overrides(monkeypatch): + """Test environment variable overrides for AgentGlobalConfig switches.""" + from artemis.config.agent import load_agent_config + + monkeypatch.setenv("ARTEMIS_CHECKER_ENABLED", "true") + monkeypatch.setenv("ARTEMIS_COMMITTEE_ENABLED", "1") + monkeypatch.setenv("ARTEMIS_PLANNER_VALIDATION_ENABLED", "yes") + monkeypatch.setenv("ARTEMIS_OUTPUTTER_ENABLED", "false") + monkeypatch.setenv("ARTEMIS_VIDEO_LEDGER_ENABLED", "0") + + cfg = load_agent_config() + assert cfg.checker.enabled is True + assert cfg.pro.checker.enabled is True + assert cfg.committee.enabled is True + assert cfg.pro.committee.enabled is True + assert cfg.planner_validation.enabled is True + assert cfg.pro.planner_validation.enabled is True + assert cfg.outputter.enabled is False + assert cfg.video_analyzer.enable_ledger is False + assert cfg.pro.video_analyzer.enable_ledger is False diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py new file mode 100644 index 0000000..c93d267 --- /dev/null +++ b/tests/unit/test_context.py @@ -0,0 +1,30 @@ +import asyncio +import pytest +from artemis.context import ArtemisContext, DeviceContext, DevicePlatform + + +@pytest.mark.asyncio +async def test_artemis_context_waits_for_background_tasks(): + device = DeviceContext( + host_platform="LINUX", + mobile_platform=DevicePlatform.ANDROID, + device_id="dummy", + device_width=1080, + device_height=2400, + ) + + context = ArtemisContext(device=device) + + task_run = False + + async def dummy_task(): + nonlocal task_run + await asyncio.sleep(0.1) + task_run = True + + async with context: + task = asyncio.create_task(dummy_task()) + context.background_tasks.append(task) + + # After exiting the context, the task should have run to completion + assert task_run is True diff --git a/tests/unit/test_drivers.py b/tests/unit/test_drivers.py new file mode 100644 index 0000000..9c38a10 --- /dev/null +++ b/tests/unit/test_drivers.py @@ -0,0 +1,91 @@ +# 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 Unified Base Device Driver and implementations.""" + +import pytest +from unittest.mock import MagicMock + +from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection +from artemis.drivers.mock.mock_driver import MockDeviceDriver +from artemis.drivers.android.adb_driver import AndroidAdbDriver + + +@pytest.mark.asyncio +async def test_mock_driver_actions(): + """Verify MockDeviceDriver records and performs all core actions.""" + driver = MockDeviceDriver(device_id="test-mock", width=1080, height=2400) + assert driver.device_id == "test-mock" + assert driver.screen_size == (1080, 2400) + + # 1. Screen data + screen_data = await driver.get_screen_data() + assert isinstance(screen_data, ScreenData) + assert screen_data.width == 1080 + assert screen_data.height == 2400 + assert screen_data.screenshot_base64 is not None + + # 2. Tap + assert await driver.tap(500, 1000) is True + assert driver.action_history[-1]["action"] == "tap" + assert driver.action_history[-1]["x"] == 500 + + # 3. Swipe + assert await driver.swipe(100, 200, 300, 400) is True + assert driver.action_history[-1]["action"] == "swipe" + + # 4. Swipe direction + assert await driver.swipe_direction(SwipeDirection.UP) is True + assert driver.action_history[-1]["direction"] == "up" + + # 5. Input text + assert await driver.input_text("Hello Artemis") is True + assert driver.action_history[-1]["text"] == "Hello Artemis" + + # 6. Press key + assert await driver.press_key(KeyCode.HOME) is True + assert driver.action_history[-1]["key"] == "home" + + # 7. App lifecycle + assert await driver.launch_app("com.android.settings") is True + assert await driver.get_current_package() == "com.android.settings" + assert await driver.stop_app("com.android.settings") is True + + +@pytest.mark.asyncio +async def test_android_driver_with_mock_adb(): + """Verify AndroidAdbDriver formats shell commands properly.""" + mock_adb_client = MagicMock() + mock_adb_device = MagicMock() + mock_adb_client.device.return_value = mock_adb_device + + driver = AndroidAdbDriver( + device_id="emulator-5554", + adb_client=mock_adb_client, + width=1080, + height=2400, + ) + + # Test tap + await driver.tap(540, 1200) + mock_adb_device.shell.assert_called_with("input tap 540 1200") + + # Test multi-tap + await driver.tap(540, 1200, times=3, delay_ms=100) + expected_multi = "input tap 540 1200 && sleep 0.100 && input tap 540 1200 && sleep 0.100 && input tap 540 1200" + mock_adb_device.shell.assert_called_with(expected_multi) + + # Test press key + await driver.press_key(KeyCode.BACK) + mock_adb_device.shell.assert_called_with("input keyevent 4") diff --git a/tests/unit/test_gemini_role.py b/tests/unit/test_gemini_role.py new file mode 100644 index 0000000..ac8f8dc --- /dev/null +++ b/tests/unit/test_gemini_role.py @@ -0,0 +1,67 @@ +# 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 google import genai +from google.genai import types +from google.genai.errors import APIError +import pytest + + +@pytest.fixture +def client(): + """Initializes the Gemini client. Requires GEMINI_API_KEY in environment.""" + try: + return genai.Client() + except Exception as e: + pytest.skip(f"Could not initialize genai.Client: {e}") + + +def test_gemini_role_tool_fails(client): + """Confirm that using role='tool' results in a 400 APIError from Gemini.""" + contents = [ + types.Content( + role="tool", + parts=[types.Part.from_text(text="This is a test payload for tool execution.")], + ) + ] + + with pytest.raises(APIError) as exc_info: + client.models.generate_content( + model="gemini-3.6-flash", + contents=contents, + ) + + # Assert it failed specifically due to the invalid role string + error_msg = str(exc_info.value) + assert "Invalid role string" in error_msg or "400" in error_msg + + +def test_gemini_role_user_succeeds(client): + """Confirm that using role='user' (which is the correct fix) succeeds.""" + contents = [ + types.Content( + role="user", + parts=[ + types.Part.from_text(text="Acknowledge this message with 'ok' and nothing else.") + ], + ) + ] + + # This should not raise any exceptions + response = client.models.generate_content( + model="gemini-3.6-flash", + contents=contents, + ) + + assert response.text is not None diff --git a/tests/unit/test_model_router.py b/tests/unit/test_model_router.py new file mode 100644 index 0000000..b1097b8 --- /dev/null +++ b/tests/unit/test_model_router.py @@ -0,0 +1,211 @@ +# 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 Dynamic Model Router.""" + +from artemis.llm.router import ModelEndpoint, ModelProvider, ModelRouter + + +def test_model_router_default_routes(): + """Verify default role routing returns expected models.""" + router = ModelRouter() + + # Planner route + planner_ep = router.get_endpoint("planner") + assert planner_ep.provider == ModelProvider.GEMINI + assert "pro" in planner_ep.model_name + + # Operator route + operator_ep = router.get_endpoint("operator") + assert operator_ep.provider == ModelProvider.GEMINI + assert "flash" in operator_ep.model_name + + +def test_model_router_custom_route_override(): + """Verify dynamic role route override.""" + router = ModelRouter() + custom_ep = ModelEndpoint( + provider=ModelProvider.OPENAI, + model_name="gpt-4o", + api_base="https://custom.api.endpoint/v1", + ) + router.set_role_route("operator", custom_ep) + + ep = router.get_endpoint("operator") + assert ep.provider == ModelProvider.OPENAI + assert ep.model_name == "gpt-4o" + assert ep.api_base == "https://custom.api.endpoint/v1" + + +def test_model_router_fallbacks(): + """Verify fallback endpoint configuration.""" + router = ModelRouter() + fallbacks = [ + ModelEndpoint(provider=ModelProvider.GEMINI, model_name="gemini-2.5-flash"), + ModelEndpoint(provider=ModelProvider.OPENAI, model_name="gpt-4o-mini"), + ] + router.set_role_route("custom_worker", "gemini-2.5-pro", fallbacks=fallbacks) + fb = router.get_fallbacks("custom_worker") + assert len(fb) == 2 + assert fb[0].model_name == "gemini-2.5-flash" + assert fb[1].model_name == "gpt-4o-mini" + + +def test_model_router_anthropic_instantiation(): + """Verify Anthropic Claude model instantiation with thinking budget.""" + router = ModelRouter() + anth_ep = ModelEndpoint( + provider=ModelProvider.ANTHROPIC, + model_name="claude-3-7-sonnet-20250219", + api_key="sk-ant-test-key", + reasoning_effort="high", + ) + model = router.instantiate_model(anth_ep) + assert model.model == "claude-3-7-sonnet-20250219" + assert getattr(model, "thinking", None) == {"type": "enabled", "budget_tokens": 32768} + + +def test_model_router_openai_instantiation(): + """Verify OpenAI model instantiation with reasoning effort.""" + router = ModelRouter() + oai_ep = ModelEndpoint( + provider=ModelProvider.OPENAI, + model_name="o3-mini", + api_key="sk-test-key", + reasoning_effort="medium", + ) + model = router.instantiate_model(oai_ep) + assert model.model_name == "o3-mini" + assert getattr(model, "reasoning_effort", None) == "medium" + + +def test_robust_chat_model_wrapper_grounding_google(): + """Verify RobustChatModelWrapper auto-injects google_search and server-side tool config for Gemini.""" + from artemis.services.llm import RobustChatModelWrapper + from langchain_core.tools import tool + + @tool + def dummy_tool(query: str) -> str: + """A test tool.""" + return query + + class MockGeminiModel: + def __init__(self): + self.bound_tools = [] + self.bound_kwargs = {} + + def bind_tools(self, tools, *args, **kwargs): + self.bound_tools = tools + self.bound_kwargs = kwargs + return self + + # Case 1: enable_grounding=True automatically attaches google_search and tool_config + ep_google = ModelEndpoint( + provider=ModelProvider.GOOGLE, + model_name="gemini-2.5-flash", + enable_grounding=True, + ) + wrapper = RobustChatModelWrapper(MockGeminiModel(), endpoint=ep_google) + bound = wrapper.bind_tools([dummy_tool]) + + assert any(isinstance(t, dict) and "google_search" in t for t in bound.base_model.bound_tools) + assert bound.base_model.bound_kwargs.get("tool_config") == { + "include_server_side_tool_invocations": True + } + + # Case 2: Explicit google_search in tools automatically ensures tool_config + ep_google_no_ground = ModelEndpoint( + provider=ModelProvider.GOOGLE, + model_name="gemini-2.5-flash", + enable_grounding=False, + ) + wrapper_explicit = RobustChatModelWrapper(MockGeminiModel(), endpoint=ep_google_no_ground) + bound_explicit = wrapper_explicit.bind_tools([dummy_tool, {"google_search": {}}]) + + assert any( + isinstance(t, dict) and "google_search" in t for t in bound_explicit.base_model.bound_tools + ) + assert bound_explicit.base_model.bound_kwargs.get("tool_config") == { + "include_server_side_tool_invocations": True + } + + +def test_robust_chat_model_wrapper_grounding_non_google(): + """Verify RobustChatModelWrapper ignores grounding and strips google_search for non-Google providers.""" + from artemis.services.llm import RobustChatModelWrapper + from langchain_core.tools import tool + + @tool + def dummy_tool(query: str) -> str: + """A test tool.""" + return query + + class MockOpenAIModel: + def __init__(self): + self.bound_tools = [] + self.bound_kwargs = {} + + def bind_tools(self, tools, *args, **kwargs): + self.bound_tools = tools + self.bound_kwargs = kwargs + return self + + ep_openai = ModelEndpoint( + provider=ModelProvider.OPENAI, + model_name="gpt-4o", + enable_grounding=True, + ) + wrapper = RobustChatModelWrapper(MockOpenAIModel(), endpoint=ep_openai) + bound = wrapper.bind_tools([dummy_tool, {"google_search": {}}]) + + # Should ignore grounding and strip {"google_search": {}} + assert len(bound.base_model.bound_tools) == 1 + assert bound.base_model.bound_tools[0].name == "dummy_tool" + assert "tool_config" not in bound.base_model.bound_kwargs + + +def test_chat_google_generative_ai_process_tool_config_patch(): + """Verify patched ChatGoogleGenerativeAI preserves include_server_side_tool_invocations even with tool_choice.""" + from artemis.llm.router import ModelFactory + from langchain_google_genai import ChatGoogleGenerativeAI + from langchain_core.tools import tool + + @tool + def dummy_tool(query: str) -> str: + """A test tool.""" + return query + + # Trigger patching via Google model creation + ep = ModelEndpoint(provider=ModelProvider.GOOGLE, model_name="gemini-2.5-flash", api_key="fake") + model = ModelFactory.create_model(ep) + assert isinstance(model, ChatGoogleGenerativeAI) + + formatted_tools = model._format_tools([dummy_tool, {"google_search": {}}], None) + + # 1. With tool_choice="auto" + config_auto = model._process_tool_config("auto", None, formatted_tools) + assert config_auto is not None + assert config_auto.include_server_side_tool_invocations is True + + # 2. With tool_choice="any" + config_any = model._process_tool_config("any", None, formatted_tools) + assert config_any is not None + assert config_any.include_server_side_tool_invocations is True + + # 3. With explicit tool_config + config_explicit = model._process_tool_config( + None, {"include_server_side_tool_invocations": True}, formatted_tools + ) + assert config_explicit is not None + assert config_explicit.include_server_side_tool_invocations is True diff --git a/tests/unit/test_planner.py b/tests/unit/test_planner.py new file mode 100644 index 0000000..c7dcee1 --- /dev/null +++ b/tests/unit/test_planner.py @@ -0,0 +1,46 @@ +# 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 pathlib import Path +from artemis.agents.planner.planner import run_async_planner_validation + + +class DummyDataEngine: + def get_agent_friendly_steps(self): + return [] + + +class DummyCtx: + data_engine = DummyDataEngine() + project_dir = str(Path(__file__).resolve().parent.parent.parent) + + +async def test(): + 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...", + ) + print(res) + + +asyncio.run(test()) diff --git a/tests/unit/test_prompt_builder.py b/tests/unit/test_prompt_builder.py new file mode 100644 index 0000000..948d234 --- /dev/null +++ b/tests/unit/test_prompt_builder.py @@ -0,0 +1,56 @@ +# 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 OperatorPromptBuilder and LoopDetector.""" + +from artemis.agents.operator.loop_detector import LoopDetector +from artemis.agents.operator.prompt_builder import OperatorPromptBuilder + + +def test_operator_prompt_builder(): + """Verify prompt rendering formats variables correctly.""" + system_msg = OperatorPromptBuilder.build_system_message() + assert "ARTEMIS Operator" in system_msg + assert "click" in system_msg + + human_msg = OperatorPromptBuilder.build_human_message( + goal="Open Settings", + sub_goal="Tap Network & internet", + current_turn=2, + history="1. Tapped Settings icon", + ) + assert "Open Settings" in human_msg + assert "Tap Network & internet" in human_msg + assert "Current Turn: 2" in human_msg + + +def test_loop_detector(): + """Verify loop detector flags repeated action sequences.""" + detector = LoopDetector(repetition_threshold=3) + assert not detector.is_loop_detected() + + # Record distinct actions + detector.record_action("click", {"target": [100, 200]}) + detector.record_action("swipe", {"action": "up"}) + assert not detector.is_loop_detected() + + # Record 3 identical actions + detector.record_action("click", {"target": [500, 500]}) + detector.record_action("click", {"target": [500, 500]}) + detector.record_action("click", {"target": [500, 500]}) + assert detector.is_loop_detected() + + # Reset + detector.reset() + assert not detector.is_loop_detected() diff --git a/tests/unit/test_sdk.py b/tests/unit/test_sdk.py new file mode 100644 index 0000000..7a886e7 --- /dev/null +++ b/tests/unit/test_sdk.py @@ -0,0 +1,66 @@ +# 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 the official Python SDK (ArtemisClient, Task, TaskResult).""" + +import pytest +from artemis import ArtemisClient, Task, TaskResult + + +@pytest.mark.asyncio +async def test_artemis_client_run_mock(): + """Verify ArtemisClient runs a task through the execution pipeline.""" + client = ArtemisClient(device_id="mock-test-dev", default_profile="pro") + assert client.device_id == "mock-test-dev" + assert client.default_profile == "pro" + + result = await client.run(goal="Open Settings and enable Dark Theme") + assert isinstance(result, TaskResult) + assert result.status in ("completed", "running", "success") + assert result.trace_id.startswith("trace_") or len(result.trace_id) > 0 + + +def test_task_model_validation(): + """Verify Task validation and default arguments.""" + task = Task(goal="Search for flight prices", profile="flash", device_id="emulator-5554") + assert task.goal == "Search for flight prices" + assert task.profile == "flash" + assert task.device_id == "emulator-5554" + assert task.max_turns == 30 + assert task.locked_package is None + + +@pytest.mark.asyncio +async def test_artemis_client_stream_run(): + """Verify ArtemisClient.stream_run yields real-time StreamEvents.""" + from artemis import StreamEvent, StreamEventType + + client = ArtemisClient(device_id="mock-stream-dev", default_profile="flash") + collected_events: list[StreamEvent] = [] + + def on_event(ev: StreamEvent): + collected_events.append(ev) + + events: list[StreamEvent] = [] + async for event in client.stream_run( + goal="Test streaming UI action flow", + profile="flash", + callbacks=[on_event], + ): + events.append(event) + + assert len(events) >= 2 + assert events[0].event_type == StreamEventType.STATUS + assert events[0].payload.get("status") == "starting" + assert len(collected_events) == len(events) diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py new file mode 100644 index 0000000..79cd004 --- /dev/null +++ b/tests/unit/test_telemetry.py @@ -0,0 +1,55 @@ +# 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 Telemetry & Span Lifecycle Tracing.""" + +import pytest +from pathlib import Path +from artemis.telemetry.models import SpanType +from artemis.telemetry.tracer import TelemetryTracer + + +@pytest.mark.asyncio +async def test_telemetry_span_lifecycle(tmp_path: Path): + """Verify TelemetryTracer properly records and persists nested spans.""" + tracer = TelemetryTracer(trace_id="test-session-001", traces_dir=tmp_path) + + async with tracer.start_span("parent_agent_step", span_type=SpanType.AGENT) as parent_span: + assert parent_span.status == "running" + assert parent_span.name == "parent_agent_step" + + async with tracer.start_span( + "child_device_action", span_type=SpanType.DEVICE_ACTION, payload={"action": "click"} + ) as child_span: + assert child_span.parent_id == parent_span.span_id + assert child_span.payload["action"] == "click" + + # Spans should be persisted in JSONL + assert len(tracer.spans) == 2 + child_recorded = tracer.spans[0] + parent_recorded = tracer.spans[1] + + assert child_recorded.name == "child_device_action" + assert child_recorded.status == "success" + assert child_recorded.duration_ms is not None + + assert parent_recorded.name == "parent_agent_step" + assert parent_recorded.status == "success" + + # File existence + log_file = tmp_path / "telemetry_spans.jsonl" + assert log_file.exists() + content = log_file.read_text(encoding="utf-8") + assert "child_device_action" in content + assert "parent_agent_step" in content diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py new file mode 100644 index 0000000..010c5b5 --- /dev/null +++ b/tests/unit/test_tools.py @@ -0,0 +1,70 @@ +# 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 Universal Tool Protocol and Actions.""" + +import pytest +from artemis.drivers.mock.mock_driver import MockDeviceDriver +from artemis.tools.base import ToolRegistry +import artemis.tools.actions.device_actions # noqa: F401 - Register action tools + + +@pytest.mark.asyncio +async def test_tool_registry_registration(): + """Verify standard device actions are registered in ToolRegistry.""" + click_tool = ToolRegistry.get("click") + assert click_tool is not None + assert click_tool.name == "click" + assert click_tool.category == "action" + + swipe_tool = ToolRegistry.get("swipe") + assert swipe_tool is not None + assert swipe_tool.name == "swipe" + + +@pytest.mark.asyncio +async def test_tool_execution_with_driver(): + """Verify tool execution correctly dispatches to MockDeviceDriver.""" + driver = MockDeviceDriver(width=1000, height=2000) + + # Click normalized [500, 500] -> pixel (500, 1000) + result = await ToolRegistry.execute("click", {"target": [500, 500]}, driver=driver, ctx=None) + assert "Click" in result or "Clicked" in result + assert driver.action_history[-1]["action"] == "tap" + assert driver.action_history[-1]["x"] == 500 + assert driver.action_history[-1]["y"] == 1000 + + # Swipe cardinal "up" + result = await ToolRegistry.execute("swipe", {"action": "up"}, driver=driver, ctx=None) + assert "Swipe" in result or "Swiped" in result + assert driver.action_history[-1]["action"] == "swipe_direction" + + +def test_tool_to_genai_declaration(): + """Verify tools export to Google GenAI FunctionDeclaration properly.""" + declarations = ToolRegistry.get_genai_declarations(["click", "swipe", "input_text"]) + assert len(declarations) == 3 + + decl_names = [d.name for d in declarations] + assert "click" in decl_names + assert "swipe" in decl_names + assert "input_text" in decl_names + + +def test_tool_to_langchain_tool(): + """Verify tools export to LangChain BaseTool.""" + lc_tools = ToolRegistry.get_langchain_tools(ctx=None, names=["click", "press_key"]) + assert len(lc_tools) == 2 + assert lc_tools[0].name == "click" + assert lc_tools[1].name == "press_key" diff --git a/tests/unit/test_unified_controller_video.py b/tests/unit/test_unified_controller_video.py new file mode 100644 index 0000000..c344f75 --- /dev/null +++ b/tests/unit/test_unified_controller_video.py @@ -0,0 +1,279 @@ +# 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 UnifiedMobileController video recording and playback features.""" + +import time +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from artemis.context import ArtemisContext +from artemis.controllers.unified_controller import UnifiedMobileController +from artemis.drivers.mock.mock_driver import MockDeviceDriver +from artemis.utils.video import ( + RecordingSession, + get_active_session, + remove_active_session, + set_active_session, +) + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_id = "emulator-5554" + ctx.device.mobile_platform = "android" + + # DataEngine mock + ctx.data_engine = MagicMock() + ctx.data_engine.current_session_id = uuid4() + ctx.data_engine.session_start_time = time.time() + ctx.data_engine.storage = MagicMock() + + # Mock driver + mock_driver = MockDeviceDriver(device_id="emulator-5554") + ctx._active_driver = mock_driver + return ctx + + +@pytest.mark.asyncio +async def test_unified_controller_start_recording(mock_ctx, tmp_path): + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + + # Mock scrcpy subprocess + mock_proc = MagicMock() + mock_proc.returncode = None + + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)): + with patch("asyncio.sleep", AsyncMock()): + res = await controller.start_video_recording(output_dir=tmp_path) + + assert res.success is True + assert get_active_session("emulator-5554") is not None + assert mock_ctx.data_engine.record_video_start.called + + # Calling start again should report already in progress + res2 = await controller.start_video_recording(output_dir=tmp_path) + assert res2.success is False + assert "already in progress" in res2.message + + remove_active_session("emulator-5554") + + +@pytest.mark.asyncio +async def test_unified_controller_stop_recording(mock_ctx, tmp_path): + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + + # Create dummy recording.mkv + mkv_path = tmp_path / "recording.mkv" + mkv_path.write_bytes(b"dummy mkv content") + + mock_proc = MagicMock() + mock_proc.returncode = None + mock_proc.terminate = MagicMock() + mock_proc.wait = AsyncMock() + + session = RecordingSession( + video_id=uuid4(), + device_id="emulator-5554", + start_time=time.time() - 10.0, + data_engine_start_time=time.time() - 10.0, + local_video_path=mkv_path, + process=mock_proc, + ) + set_active_session("emulator-5554", session) + + with patch.object( + controller, + "_convert_mkv_to_mp4", + AsyncMock(side_effect=lambda src, dst: dst.write_bytes(b"mp4 content") or True), + ): + res = await controller.stop_video_recording() + + assert res.success is True + assert res.video_path is not None + assert str(res.video_path).endswith("recording.mp4") + assert get_active_session("emulator-5554") is None + assert mock_ctx.data_engine.record_video_stop.called + + +@pytest.mark.asyncio +async def test_unified_controller_extract_segment_metadata(mock_ctx, tmp_path): + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + + mkv_path = tmp_path / "recording.mkv" + mkv_path.write_bytes(b"dummy mkv content") + + mock_proc = MagicMock() + mock_proc.returncode = None + + session = RecordingSession( + video_id=uuid4(), + device_id="emulator-5554", + start_time=time.time() - 20.0, + data_engine_start_time=time.time() - 20.0, + local_video_path=mkv_path, + process=mock_proc, + ) + set_active_session("emulator-5554", session) + + with patch( + "artemis.controllers.unified_controller.trim_video", + AsyncMock(side_effect=lambda src, s, e, dst: dst.write_bytes(b"segment mp4") or True), + ): + res = await controller.extract_segment_metadata(start_time=2.0, end_time=8.0) + + assert res.success is True + assert res.video_path is not None + assert res.video_path.exists() + # Ensure session is still active (not stopped by extraction!) + assert get_active_session("emulator-5554") is not None + remove_active_session("emulator-5554") + + +def test_data_engine_video_lifecycle(tmp_path): + from artemis.data_engine.engine import DataEngine + from artemis.context import ArtemisContext + + mock_c = MagicMock(spec=ArtemisContext) + mock_c.execution_setup = MagicMock(traces_path=str(tmp_path / "traces")) + mock_c.device = None + engine = DataEngine(mock_c) + session_id = engine.start_session(goal="Test Video Engine") + + vid = uuid4() + vpath = tmp_path / "recording.mp4" + vpath.write_bytes(b"video bytes") + + # Start video recording + engine.record_video_start(vid, "device-1", vpath) + rec = engine.storage.get_video_recording(vid) + assert rec is not None + assert rec.device_id == "device-1" + + # Stop video recording + engine.record_video_stop(vid, "device-1", vpath, time.time() - 10.0, time.time()) + sess = engine.storage.get_session(session_id) + assert sess.video_filepath == str(vpath) + + # Move video path on finalize + new_vpath = tmp_path / "archived" / "recording.mp4" + new_vpath.parent.mkdir(parents=True, exist_ok=True) + new_vpath.write_bytes(b"video bytes") + engine.update_video_path(new_vpath) + + sess_updated = engine.storage.get_session(session_id) + assert sess_updated.video_filepath == str(new_vpath) + + +@pytest.mark.asyncio +async def test_unified_controller_crash_recovery_and_multi_segment(mock_ctx, tmp_path): + """Verify that if recording is interrupted, segments are auto-recovered and concatenated seamlessly.""" + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + + seg1 = tmp_path / "recording_0.mkv" + seg1.write_bytes(b"segment 0") + seg2 = tmp_path / "recording_1.mkv" + seg2.write_bytes(b"segment 1") + + mock_proc = MagicMock() + mock_proc.returncode = 1 # Simulated crash + + session = RecordingSession( + video_id=uuid4(), + device_id="emulator-5554", + start_time=time.time() - 30.0, + data_engine_start_time=time.time() - 30.0, + local_video_path=seg2, + android_video_segments=[seg1], + android_segment_index=1, + process=mock_proc, + ) + set_active_session("emulator-5554", session) + + with ( + patch( + "artemis.controllers.unified_controller.concatenate_videos", + AsyncMock(side_effect=lambda segs, out: out.write_bytes(b"combined mkv") or True), + ), + patch.object( + controller, + "_convert_mkv_to_mp4", + AsyncMock(side_effect=lambda src, dst: dst.write_bytes(b"final mp4") or True), + ), + ): + res = await controller.stop_video_recording() + + assert res.success is True + assert res.video_path is not None + assert res.video_path.exists() + assert str(res.video_path).endswith("recording.mp4") + assert get_active_session("emulator-5554") is None + + +@pytest.mark.asyncio +async def test_unified_controller_timeline_alignment(mock_ctx, tmp_path): + """Verify that relative time offset between DataEngine T0 and video start is precisely calculated.""" + controller = UnifiedMobileController(mock_ctx) + remove_active_session("emulator-5554") + + mkv_path = tmp_path / "recording.mkv" + mkv_path.write_bytes(b"dummy mkv content") + + now = time.time() + # Assume session started 15s ago, and video started 12s ago (offset = 3.0s) + session_start_t0 = now - 15.0 + video_start_t = now - 12.0 + + session = RecordingSession( + video_id=uuid4(), + device_id="emulator-5554", + start_time=video_start_t, + data_engine_start_time=session_start_t0, + local_video_path=mkv_path, + process=MagicMock(returncode=None), + ) + set_active_session("emulator-5554", session) + + trimmed_ranges = [] + + async def mock_trim(src, s_time, e_time, dst): + trimmed_ranges.append((s_time, e_time)) + dst.write_bytes(b"trimmed") + return True + + with patch( + "artemis.controllers.unified_controller.trim_video", AsyncMock(side_effect=mock_trim) + ): + # Agent asks for system time range [5.0s, 10.0s] + res = await controller.extract_segment_metadata(start_time=5.0, end_time=10.0) + + assert res.success is True + # Since offset = 3.0s, video_start_relative_time should be 5.0 - 3.0 = 2.0s + # and video_end_relative_time should be 10.0 - 3.0 = 7.0s + assert len(trimmed_ranges) == 1 + s_time, e_time = trimmed_ranges[0] + assert abs(s_time - 2.0) < 0.1 + assert abs(e_time - 7.0) < 0.1 + # actual_start_relative_time reported back to agent must align with system T0 (5.0s) + assert abs(res.actual_start_relative_time - 5.0) < 0.1 + + remove_active_session("emulator-5554") diff --git a/tests/unit/test_update.py b/tests/unit/test_update.py new file mode 100644 index 0000000..23b96d4 --- /dev/null +++ b/tests/unit/test_update.py @@ -0,0 +1,36 @@ +# 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 +from artemis.utils.notes import update_note_content + +base_dir = "/tmp/artemis_test" +os.makedirs(f"{base_dir}/notes", exist_ok=True) +task_plan = """- [x] Initial task +- [/] Active task +- [ ] Future task""" +with open(f"{base_dir}/notes/task_plan.md", "w") as f: + f.write(task_plan) + +try: + res = update_note_content( + base_dir, + "task_plan", + "- [/] Active task", + "- [/] Active task\n - [ ] New subgoal", + ) + print("Update successful!") + print(open(f"{base_dir}/notes/task_plan.md").read()) +except Exception as e: + print("Error:", e) diff --git a/tests/unit/tools/test_append_note_pure_tool.py b/tests/unit/tools/test_append_note_pure_tool.py new file mode 100644 index 0000000..428dc77 --- /dev/null +++ b/tests/unit/tools/test_append_note_pure_tool.py @@ -0,0 +1,132 @@ +# 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 pathlib import Path +from unittest.mock import MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + AppendNoteArgs, + AppendNotePure, + AppendNotePureTool, + append_note_pure, + get_append_note_tool_pure, +) +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_append_note_pure_tool_subclass_and_registry(): + """Verify AppendNotePureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(AppendNotePureTool, ArtemisTool) + assert issubclass(AppendNotePure, ArtemisTool) + assert isinstance(append_note_pure, ArtemisTool) + assert isinstance(append_note_pure, AppendNotePureTool) + + assert append_note_pure.name == "append_note_pure" + assert append_note_pure.category == "memory" + assert append_note_pure.args_schema == AppendNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("append_note_pure") + assert reg_tool is not None + assert isinstance(reg_tool, AppendNotePureTool) + + # GenAI FunctionDeclaration export + declaration = append_note_pure.to_genai_declaration() + assert declaration.name == "append_note_pure" + assert "key" in declaration.parameters.properties + assert "content" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_append_note_pure_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of AppendNotePureTool.execute appends to note file.""" + # First append creates note + result1 = await append_note_pure.execute( + ctx=mock_ctx, + key="pure_append", + content="First line", + ) + assert result1 == "Successfully appended note to pure_append.md." + + note_path = Path(tmp_path) / "notes" / "pure_append.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "First line\n" + + # Second append appends line + result2 = await append_note_pure.execute( + ctx=mock_ctx, + key="pure_append", + content="Second line", + ) + assert result2 == "Successfully appended note to pure_append.md." + assert note_path.read_text(encoding="utf-8") == "First line\nSecond line\n" + + +@pytest.mark.asyncio +async def test_append_note_pure_callable_execution(mock_ctx, tmp_path): + """Verify invoking append_note_pure directly as a callable.""" + result = await append_note_pure( + ctx=mock_ctx, + key="callable_pure_append", + content="Pure callable append", + ) + assert result == "Successfully appended note to callable_pure_append.md." + + note_path = Path(tmp_path) / "notes" / "callable_pure_append.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Pure callable append\n" + + +@pytest.mark.asyncio +async def test_append_note_pure_execution_failure(mock_ctx): + """Verify error handling when append_note_content fails.""" + with patch( + "artemis.tools.scratchpad.append_note_content", + side_effect=PermissionError("Permission denied"), + ): + result = await append_note_pure.execute( + ctx=mock_ctx, + key="fail_append", + content="Some content", + ) + assert "Failed to append note fail_append.md: Permission denied" in result + + +@pytest.mark.asyncio +async def test_get_append_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_append_note_tool_pure exports a LangChain tool named 'append_note'.""" + pure_tool = get_append_note_tool_pure(mock_ctx) + assert pure_tool.name == "append_note" + + result = await pure_tool.ainvoke( + { + "key": "lc_pure_append", + "content": "Saved via pure append tool", + } + ) + assert result == "Successfully appended note to lc_pure_append.md." + + note_path = Path(tmp_path) / "notes" / "lc_pure_append.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Saved via pure append tool\n" diff --git a/tests/unit/tools/test_append_note_tool.py b/tests/unit/tools/test_append_note_tool.py new file mode 100644 index 0000000..9bd1060 --- /dev/null +++ b/tests/unit/tools/test_append_note_tool.py @@ -0,0 +1,190 @@ +# 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 pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + AppendNote, + AppendNoteArgs, + AppendNoteTool, + get_append_note_tool, + get_append_note_tool_pure, + append_note, + append_note_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_append_note_tool_subclass_and_registry(): + """Verify AppendNoteTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(AppendNoteTool, ArtemisTool) + assert issubclass(AppendNote, ArtemisTool) + assert isinstance(append_note, ArtemisTool) + assert isinstance(append_note, AppendNoteTool) + + assert append_note.name == "append_note" + assert append_note.category == "memory" + assert append_note.args_schema == AppendNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("append_note") + assert reg_tool is not None + assert isinstance(reg_tool, AppendNoteTool) + + # GenAI FunctionDeclaration export + declaration = append_note.to_genai_declaration() + assert declaration.name == "append_note" + assert "key" in declaration.parameters.properties + assert "content" in declaration.parameters.properties + + # Wrapper check + assert append_note_wrapper is not None + assert append_note_wrapper.tool_fn_getter == get_append_note_tool + + +@pytest.mark.asyncio +async def test_append_note_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of AppendNoteTool.execute appends to note file.""" + # First append creates the note + result1 = await append_note.execute( + ctx=mock_ctx, + key="test_append", + content="First line", + ) + assert result1 == "Successfully appended note to test_append.md." + + note_path = Path(tmp_path) / "notes" / "test_append.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "First line\n" + + # Second append appends with newline + result2 = await append_note.execute( + ctx=mock_ctx, + key="test_append", + content="Second line", + ) + assert result2 == "Successfully appended note to test_append.md." + assert note_path.read_text(encoding="utf-8") == "First line\nSecond line\n" + + +@pytest.mark.asyncio +async def test_append_note_callable_execution(mock_ctx, tmp_path): + """Verify invoking append_note directly as a callable.""" + result = await append_note( + ctx=mock_ctx, + key="callable_append", + content="Callable append test", + ) + assert result == "Successfully appended note to callable_append.md." + + note_path = Path(tmp_path) / "notes" / "callable_append.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Callable append test\n" + + +@pytest.mark.asyncio +async def test_append_note_with_state_command(mock_ctx, tmp_path): + """Verify AppendNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY.""" + mock_state = MagicMock(spec=State) + mock_state.current_agent = "operator" + mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + result = await append_note.execute( + ctx=mock_ctx, + key="state_append_note", + content="Appended with state", + state=mock_state, + tool_call_id="call_append_999", + ) + + assert isinstance(result, Command) + assert VALIDATOR_MESSAGES_KEY in result.update + tool_messages = result.update[VALIDATOR_MESSAGES_KEY] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_append_999" + assert tool_messages[0].content == "Successfully appended note to state_append_note.md." + assert tool_messages[0].status == "success" + + mock_state.asanitize_update.assert_called_once() + + note_path = Path(tmp_path) / "notes" / "state_append_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Appended with state\n" + + +@pytest.mark.asyncio +async def test_append_note_execution_failure(mock_ctx): + """Verify error handling when appending note fails.""" + with patch( + "artemis.tools.scratchpad.append_note_content", + side_effect=PermissionError("Permission denied"), + ): + result = await append_note.execute( + ctx=mock_ctx, + key="fail_append_note", + content="Fail content", + ) + assert "Failed to append note fail_append_note.md: Permission denied" in result + + +@pytest.mark.asyncio +async def test_get_append_note_tool_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_append_note_tool exports a LangChain tool that works with ainvoke.""" + lc_tool = get_append_note_tool(mock_ctx) + assert lc_tool.name == "append_note" + + result = await lc_tool.ainvoke( + { + "key": "lc_append_note", + "content": "Appended via LangChain BaseTool", + } + ) + assert result == "Successfully appended note to lc_append_note.md." + + note_path = Path(tmp_path) / "notes" / "lc_append_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Appended via LangChain BaseTool\n" + + +@pytest.mark.asyncio +async def test_get_append_note_tool_pure(mock_ctx, tmp_path): + """Verify get_append_note_tool_pure returns a pure LangChain tool.""" + pure_tool = get_append_note_tool_pure(mock_ctx) + assert pure_tool.name == "append_note" + + result = await pure_tool.ainvoke( + { + "key": "pure_append_note", + "content": "Appended via pure tool", + } + ) + assert result == "Successfully appended note to pure_append_note.md." + + note_path = Path(tmp_path) / "notes" / "pure_append_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Appended via pure tool\n" diff --git a/tests/unit/tools/test_click_tool.py b/tests/unit/tools/test_click_tool.py new file mode 100644 index 0000000..262ed75 --- /dev/null +++ b/tests/unit/tools/test_click_tool.py @@ -0,0 +1,158 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.exec_tools import ( + Click, + ClickArgs, + ClickTool, + click, + click_wrapper, + get_click_tool, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.screen_size = (1080, 2400) + driver.tap = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_click_tool_subclass_and_registry(): + """Verify ClickTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ClickTool, ArtemisTool) + assert issubclass(Click, ArtemisTool) + assert isinstance(click, ArtemisTool) + assert isinstance(click, ClickTool) + + assert click.name == "click" + assert click.category == "action" + assert click.args_schema == ClickArgs + + # Registry lookup + reg_tool = ToolRegistry.get("click") + assert reg_tool is not None + assert isinstance(reg_tool, ClickTool) + + # GenAI FunctionDeclaration export + declaration = click.to_genai_declaration() + assert declaration.name == "click" + assert "target" in declaration.parameters.properties + + # Wrapper check + assert click_wrapper is not None + assert click_wrapper.tool_fn_getter == get_click_tool + + +@pytest.mark.asyncio +async def test_click_direct_execution_with_driver(mock_driver): + """Verify direct execution of ClickTool with BaseDeviceDriver.""" + result = await click.execute(driver=mock_driver, target=[500, 500]) + mock_driver.tap.assert_called_once_with(540, 1200) + assert "Clicked at [500, 500] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_click_direct_execution_with_ctx(mock_ctx): + """Verify direct execution of ClickTool with ArtemisContext.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + result = await click.execute(ctx=mock_ctx, target=[250, 750]) + mock_controller_inst.tap_at.assert_called_once_with(270, 1800) + assert "Clicked at [250, 750] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_click_with_state_command(mock_ctx, mock_state): + """Verify ClickTool returns Command when state is provided.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + cmd = await click.execute( + ctx=mock_ctx, + target=[100, 200], + tool_call_id="call_click_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_click_1" + assert messages[0].status == "success" + assert "Clicked at [100, 200]" in messages[0].content + + +@pytest.mark.asyncio +async def test_click_callable_execution(mock_driver): + """Verify invoking click directly as a callable.""" + result = await click(driver=mock_driver, target=[100, 100]) + assert "Clicked at [100, 100] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_click_invalid_target(mock_ctx): + """Verify error handling on invalid target coordinates.""" + result = await click.execute(ctx=mock_ctx, target=[]) + assert "Error during click: Invalid target coordinates" in result + + +@pytest.mark.asyncio +async def test_get_click_tool_langchain_ainvoke(mock_ctx): + """Verify get_click_tool exports a LangChain tool that works with ainvoke.""" + click_tool = get_click_tool(mock_ctx) + assert click_tool.name == "click" + + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + result = await click_tool.ainvoke({"target": [500, 500]}) + assert "Clicked at [500, 500] (normalized) successfully." in result diff --git a/tests/unit/tools/test_command_tool.py b/tests/unit/tools/test_command_tool.py new file mode 100644 index 0000000..bee7999 --- /dev/null +++ b/tests/unit/tools/test_command_tool.py @@ -0,0 +1,603 @@ +# 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 +from artemis.context import ArtemisContext +from artemis.tools.command_tool import ( + _BACKGROUND_TASKS, + _PERSISTENT_ENVIRONMENTS, + get_manage_task_tool, + get_run_adb_command_tool, + get_run_short_adb_command_tool, +) +import pytest + + +class MockProcess: + def __init__( + self, + output_bytes: bytes, + exit_code: int = 0, + pid: int = 12345, + delay: float = 0, + ): + self.output_bytes = output_bytes + self.exit_code = exit_code + self.pid = pid + self.returncode = None + self._read_offset = 0 + self.delay = delay + + self.stdin = MagicMock() + self.stdin.write = MagicMock() + self.stdin.drain = AsyncMock() + self.stdin.close = MagicMock() + + self.stdout = MagicMock() + self.stdout.readline = AsyncMock(side_effect=self._readline) + + async def _readline(self) -> bytes: + if self.delay > 0: + await asyncio.sleep(self.delay) + self.delay = 0 # Only delay the first read + + if self._read_offset >= len(self.output_bytes): + self.returncode = self.exit_code + return b"" + + idx = self.output_bytes.find(b"\n", self._read_offset) + if idx == -1: + line = self.output_bytes[self._read_offset :] + self._read_offset = len(self.output_bytes) + else: + line = self.output_bytes[self._read_offset : idx + 1] + self._read_offset = idx + 1 + return line + + async def wait(self) -> int: + self.returncode = self.exit_code + return self.exit_code + + def terminate(self): + self.returncode = -15 + self.exit_code = -15 + + async def communicate(self) -> tuple[bytes, bytes]: + lines = [] + while True: + line = await self._readline() + if not line: + break + lines.append(line) + self.returncode = self.exit_code + return b"".join(lines), b"" + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = None + ctx.device = MagicMock() + ctx.device.device_id = "test_device_1234" + return ctx + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_command_sync(mock_exec, mock_ctx): + # Mock subprocess output + mock_process = MockProcess( + output_bytes=b"hello world\n===EXIT_CODE===0\n===ENV_START===\n", + exit_code=0, + ) + mock_exec.return_value = mock_process + + run_command = get_run_adb_command_tool(mock_ctx) + + result = await run_command.ainvoke( + { + "CommandLine": "echo 'hello world'", + "Cwd": "/data/local/tmp", + "RunPersistent": False, + "WaitMsBeforeAsync": 500, + } + ) + + assert "completed with exit code 0" in result + assert "hello world" in result + mock_exec.assert_called_once() + # Check that adb was called with device ID and "shell" + args, kwargs = mock_exec.call_args + assert args[0] == "adb" + assert args[1] == "-s" + assert args[2] == "test_device_1234" + assert args[3] == "shell" + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_command_persistent_env(mock_exec, mock_ctx): + run_command = get_run_adb_command_tool(mock_ctx) + terminal_id = "test_term_android" + + # 1. Set environment variable in persistent terminal + mock_process_1 = MockProcess( + output_bytes=(b"===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n"), + exit_code=0, + ) + mock_exec.return_value = mock_process_1 + + res1 = await run_command.ainvoke( + { + "CommandLine": "export ARTEMIS_TEST_ENV=coffee", + "Cwd": "/data/local/tmp", + "RunPersistent": True, + "RequestedTerminalID": terminal_id, + "WaitMsBeforeAsync": 500, + } + ) + assert "completed with exit code 0" in res1 + assert terminal_id in _PERSISTENT_ENVIRONMENTS + assert _PERSISTENT_ENVIRONMENTS[terminal_id].get("ARTEMIS_TEST_ENV") == "coffee" + + # 2. Query environment variable in subsequent command on same terminal + mock_process_2 = MockProcess( + output_bytes=b"coffee\n===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n", + exit_code=0, + ) + mock_exec.return_value = mock_process_2 + + res2 = await run_command.ainvoke( + { + "CommandLine": "echo $ARTEMIS_TEST_ENV", + "Cwd": "/data/local/tmp", + "RunPersistent": True, + "RequestedTerminalID": terminal_id, + "WaitMsBeforeAsync": 500, + } + ) + assert "completed with exit code 0" in res2 + assert "coffee" in res2 + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_command_async_and_status(mock_exec, mock_ctx): + # Mock a long running command by delaying stdout response + mock_process = MockProcess( + output_bytes=(b"background task finished\n===EXIT_CODE===0\n===ENV_START===\n"), + exit_code=0, + delay=1.0, # 1.0s delay will trigger timeout + ) + mock_exec.return_value = mock_process + + run_command = get_run_adb_command_tool(mock_ctx) + manage_task = get_manage_task_tool(mock_ctx) + + _BACKGROUND_TASKS.clear() + + res = await run_command.ainvoke( + { + "CommandLine": "sleep 2 && echo 'background task finished'", + "Cwd": "/data/local/tmp", + "RunPersistent": False, + "WaitMsBeforeAsync": 100, # 100ms wait threshold + } + ) + + assert "sent to the background as a task" in res + assert "TaskId: task_" in res + + lines = res.splitlines() + task_id_line = [line for line in lines if line.startswith("TaskId: ")][0] + task_id = task_id_line.split(": ")[1] + + assert task_id in _BACKGROUND_TASKS + + # Check status (should be running) + status_res = await manage_task.ainvoke( + { + "Action": "status", + "TaskId": task_id, + } + ) + assert "Status: running" in status_res + + # Wait for the background listener task to finish reading delayed output + await asyncio.sleep(1.2) + + # Status should be completed (and task removed from active map) + assert task_id not in _BACKGROUND_TASKS + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_command_kill(mock_exec, mock_ctx): + mock_process = MockProcess(output_bytes=b"", exit_code=0, delay=5.0) + mock_exec.return_value = mock_process + + run_command = get_run_adb_command_tool(mock_ctx) + manage_task = get_manage_task_tool(mock_ctx) + + _BACKGROUND_TASKS.clear() + + res = await run_command.ainvoke( + { + "CommandLine": "sleep 10", + "Cwd": "/data/local/tmp", + "RunPersistent": False, + "WaitMsBeforeAsync": 100, + } + ) + + lines = res.splitlines() + task_id_line = [line for line in lines if line.startswith("TaskId: ")][0] + task_id = task_id_line.split(": ")[1] + + # Kill the task + kill_res = await manage_task.ainvoke( + { + "Action": "kill", + "TaskId": task_id, + } + ) + assert f"Task {task_id} successfully terminated" in kill_res + assert task_id not in _BACKGROUND_TASKS + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_short_command_success(mock_exec, mock_ctx): + mock_process = MockProcess(output_bytes=b"short command success output", exit_code=0) + mock_exec.return_value = mock_process + + run_short_command = get_run_short_adb_command_tool(mock_ctx) + + result = await run_short_command.ainvoke( + { + "CommandLine": "echo 'short command'", + } + ) + + assert "completed with exit code 0" in result + assert "short command success output" in result + mock_exec.assert_called_once() + args, kwargs = mock_exec.call_args + assert args[0] == "adb" + assert args[1] == "-s" + assert args[2] == "test_device_1234" + assert args[3] == "shell" + # Verify it cd'd to /data/local/tmp by default + assert "cd /data/local/tmp" in args[4] + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_short_command_timeout(mock_exec, mock_ctx): + mock_process = MockProcess(output_bytes=b"some output", exit_code=0, delay=5.0) + mock_exec.return_value = mock_process + + run_short_command = get_run_short_adb_command_tool(mock_ctx) + + result = await run_short_command.ainvoke( + { + "CommandLine": "sleep 5", + } + ) + + assert "Error: Command timed out after 3 seconds." in result + + +@pytest.mark.asyncio +@patch("asyncio.create_subprocess_exec") +async def test_run_command_sync_long_output(mock_exec, mock_ctx): + # Generate long output (> 500 lines) + long_output = "\n".join(f"Output line {i}" for i in range(1, 600)) + mock_process = MockProcess( + output_bytes=long_output.encode() + b"\n===EXIT_CODE===0\n===ENV_START===\n", + exit_code=0, + ) + mock_exec.return_value = mock_process + + run_command = get_run_adb_command_tool(mock_ctx) + result = await run_command.ainvoke( + { + "CommandLine": "generate_long_output", + "Cwd": "/data/local/tmp", + "RunPersistent": False, + "WaitMsBeforeAsync": 500, + } + ) + + assert "ADB command completed with exit code 0." in result + assert "has been truncated" in result + assert "Output line 599" in result + assert "Output line 1" not in result # since it was truncated to last 200 lines + assert "analyze_task_output" in result + assert "TaskId: task_sync_" in result + + +@pytest.mark.asyncio +@patch("artemis.agents.log_analyzer.output_analyzer.get_llm") +async def test_analyze_task_output_tool(mock_get_llm, mock_ctx): + # Set up mock LLM + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "This is the analysis result: everything looks good." + mock_response.tool_calls = [] + mock_llm.bind_tools.return_value = mock_llm + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_get_llm.return_value = mock_llm + + # Set up finished tasks logs cache manually + from artemis.tools.command_tool import ( + _FINISHED_TASKS_LOGS, + _register_finished_task, + get_analyze_task_output_tool, + ) + + task_id = "test_task_long_output" + _register_finished_task( + task_id=task_id, + command="cat long_log.txt", + cwd="/data/local/tmp", + terminal_id=None, + status="completed", + exit_code=0, + output="line1\nline2\n" + "\n".join(f"line{i}" for i in range(3, 50)), + ) + + analyze_tool = get_analyze_task_output_tool(mock_ctx) + result = await analyze_tool.ainvoke({"TaskId": task_id, "Query": "Summarize the log"}) + + assert "Analysis result for Task test_task_long_output:" in result + assert "This is the analysis result: everything looks good." in result + mock_get_llm.assert_called_once_with(ctx=mock_ctx, name="output_analyzer") + + # Clean up registry + if task_id in _FINISHED_TASKS_LOGS: + del _FINISHED_TASKS_LOGS[task_id] + + +def test_run_adb_command_tool_subclass_and_registry(): + """Verify RunAdbCommandTool is a subclass of ArtemisTool and properly registered.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.command_tool import ( + RunAdbCommand, + RunAdbCommandArgs, + RunAdbCommandTool, + run_adb_command, + ) + + assert issubclass(RunAdbCommandTool, ArtemisTool) + assert issubclass(RunAdbCommand, ArtemisTool) + assert isinstance(run_adb_command, ArtemisTool) + assert isinstance(run_adb_command, RunAdbCommandTool) + + assert run_adb_command.name == "run_adb_command" + assert run_adb_command.category == "system" + assert run_adb_command.args_schema == RunAdbCommandArgs + + # Registry lookup + reg_tool = ToolRegistry.get("run_adb_command") + assert reg_tool is not None + assert isinstance(reg_tool, RunAdbCommandTool) + + # GenAI FunctionDeclaration export + declaration = run_adb_command.to_genai_declaration() + assert declaration.name == "run_adb_command" + assert "CommandLine" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_run_adb_command_tool_mock_driver_execute(): + """Verify RunAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None.""" + from artemis.drivers.mock.mock_driver import MockDeviceDriver + from artemis.tools.command_tool import RunAdbCommandTool + + driver = MockDeviceDriver(width=1080, height=2400) + tool_inst = RunAdbCommandTool() + + result = await tool_inst.execute( + driver=driver, + ctx=None, + CommandLine="pm list packages", + ) + assert "mock_output: pm list packages" in result + assert "pm list packages" in driver.action_history[-1]["command"] + + +def test_manage_task_tool_subclass_and_registry(): + """Verify ManageTaskTool is a subclass of ArtemisTool and properly registered.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.command_tool import ( + ManageTask, + ManageTaskArgs, + ManageTaskTool, + manage_task, + ) + + assert issubclass(ManageTaskTool, ArtemisTool) + assert issubclass(ManageTask, ArtemisTool) + assert isinstance(manage_task, ArtemisTool) + assert isinstance(manage_task, ManageTaskTool) + + assert manage_task.name == "manage_task" + assert manage_task.category == "system" + assert manage_task.args_schema == ManageTaskArgs + + # Registry lookup + reg_tool = ToolRegistry.get("manage_task") + assert reg_tool is not None + assert isinstance(reg_tool, ManageTaskTool) + + # GenAI FunctionDeclaration export + declaration = manage_task.to_genai_declaration() + assert declaration.name == "manage_task" + assert "Action" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_manage_task_tool_direct_execute(mock_ctx): + """Verify direct ManageTaskTool.execute execution.""" + from artemis.tools.command_tool import ( + _BACKGROUND_TASKS, + BackgroundTask, + manage_task, + ) + + _BACKGROUND_TASKS.clear() + list_res = await manage_task.execute(ctx=mock_ctx, Action="list") + assert "No active background tasks" in list_res + + # Create dummy task + dummy_proc = MagicMock() + bg_task = BackgroundTask( + task_id="dummy_task_99", + command="sleep 100", + process=dummy_proc, + terminal_id="term_1", + cwd="/data/local/tmp", + ) + _BACKGROUND_TASKS["dummy_task_99"] = bg_task + + list_res2 = await manage_task.execute(ctx=mock_ctx, Action="list") + assert "dummy_task_99" in list_res2 + assert "sleep 100" in list_res2 + + # Status check + status_res = await manage_task.execute(ctx=mock_ctx, Action="status", TaskId="dummy_task_99") + assert "Status: running" in status_res + assert "dummy_task_99" in status_res + + _BACKGROUND_TASKS.clear() + + +def test_analyze_task_output_tool_subclass_and_registry(): + """Verify AnalyzeTaskOutputTool is a subclass of ArtemisTool and properly registered.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.command_tool import ( + AnalyzeTaskOutput, + AnalyzeTaskOutputArgs, + AnalyzeTaskOutputTool, + analyze_task_output, + ) + + assert issubclass(AnalyzeTaskOutputTool, ArtemisTool) + assert issubclass(AnalyzeTaskOutput, ArtemisTool) + assert isinstance(analyze_task_output, ArtemisTool) + assert isinstance(analyze_task_output, AnalyzeTaskOutputTool) + + assert analyze_task_output.name == "analyze_task_output" + assert analyze_task_output.category == "system" + assert analyze_task_output.args_schema == AnalyzeTaskOutputArgs + + # Registry lookup + reg_tool = ToolRegistry.get("analyze_task_output") + assert reg_tool is not None + assert isinstance(reg_tool, AnalyzeTaskOutputTool) + + # GenAI FunctionDeclaration export + declaration = analyze_task_output.to_genai_declaration() + assert declaration.name == "analyze_task_output" + assert "TaskId" in declaration.parameters.properties + assert "Query" in declaration.parameters.properties + + +@pytest.mark.asyncio +@patch("artemis.agents.log_analyzer.output_analyzer.get_llm") +async def test_analyze_task_output_tool_direct_execute(mock_get_llm, mock_ctx): + """Verify direct AnalyzeTaskOutputTool.execute execution.""" + from artemis.tools.command_tool import ( + _FINISHED_TASKS_LOGS, + _register_finished_task, + analyze_task_output, + ) + + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Summary of task output." + mock_response.tool_calls = [] + mock_llm.bind_tools.return_value = mock_llm + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_get_llm.return_value = mock_llm + + task_id = "test_direct_analyze" + _register_finished_task( + task_id=task_id, + command="cat file.txt", + cwd="/data/local/tmp", + terminal_id=None, + status="completed", + exit_code=0, + output="Sample log content", + ) + + result = await analyze_task_output.execute( + ctx=mock_ctx, TaskId=task_id, Query="What is the content?" + ) + assert "Analysis result for Task test_direct_analyze:" in result + assert "Summary of task output." in result + + if task_id in _FINISHED_TASKS_LOGS: + del _FINISHED_TASKS_LOGS[task_id] + + +def test_run_short_adb_command_tool_subclass_and_registry(): + """Verify RunShortAdbCommandTool is a subclass of ArtemisTool and properly registered.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.command_tool import ( + RunShortAdbCommand, + RunShortAdbCommandArgs, + RunShortAdbCommandTool, + run_short_adb_command, + ) + + assert issubclass(RunShortAdbCommandTool, ArtemisTool) + assert issubclass(RunShortAdbCommand, ArtemisTool) + assert isinstance(run_short_adb_command, ArtemisTool) + assert isinstance(run_short_adb_command, RunShortAdbCommandTool) + + assert run_short_adb_command.name == "run_short_adb_command" + assert run_short_adb_command.category == "system" + assert run_short_adb_command.args_schema == RunShortAdbCommandArgs + + # Registry lookup + reg_tool = ToolRegistry.get("run_short_adb_command") + assert reg_tool is not None + assert isinstance(reg_tool, RunShortAdbCommandTool) + + # GenAI FunctionDeclaration export + declaration = run_short_adb_command.to_genai_declaration() + assert declaration.name == "run_short_adb_command" + assert "CommandLine" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_run_short_adb_command_tool_mock_driver_execute(): + """Verify RunShortAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None.""" + from artemis.drivers.mock.mock_driver import MockDeviceDriver + from artemis.tools.command_tool import RunShortAdbCommandTool + + driver = MockDeviceDriver(width=1080, height=2400) + tool_inst = RunShortAdbCommandTool() + + result = await tool_inst.execute( + driver=driver, + ctx=None, + CommandLine="getprop ro.build.version.release", + ) + assert "mock_output: getprop ro.build.version.release" in result + assert "getprop ro.build.version.release" in driver.action_history[-1]["command"] diff --git a/tests/unit/tools/test_committee_tool.py b/tests/unit/tools/test_committee_tool.py new file mode 100644 index 0000000..6783097 --- /dev/null +++ b/tests/unit/tools/test_committee_tool.py @@ -0,0 +1,222 @@ +# 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 pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.tools.committee_tool import get_ask_committee_tool + + +class TestCommitteeTool(unittest.IsolatedAsyncioTestCase): + async def test_ask_committee_tool_with_directive(self): + # Create a temp directory for testing + temp_dir = tempfile.mkdtemp() + + try: + notes_dir = Path(temp_dir) / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + + # Create a mock screenshot file + mock_screenshot_path = Path(temp_dir) / "mock_screenshot.jpg" + mock_screenshot_path.write_bytes(b"fake image bytes") + + # Create a mock failed plans history file + mock_failed_plans_history_path = notes_dir / "failed_plans_history.md" + mock_failed_plans_history_path.write_text( + "## Failed Plan Attempt #1\n### Plan:\n- [ ] Step 1\n###" + " Failure Reason:\nTimeout waiting for element", + encoding="utf-8", + ) + # Mock context and state + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + mock_ctx.data_engine = MagicMock() + mock_ctx.data_engine.base_dir = temp_dir + mock_ctx.data_engine.get_agent_friendly_steps.return_value = [ + {"pre_image_name": "test_image", "step_number": 1} + ] + + mock_state = MagicMock() + mock_state.initial_goal = "Test Goal" + mock_state.messages = [] + mock_state.latest_ui_hierarchy = None + mock_state.latest_screenshot = str(mock_screenshot_path) + mock_state.focused_app_info = None + mock_state.device_date = None + mock_state.structured_decisions = None + mock_state.complete_subgoals_by_ids = [] + mock_state.validator_messages = [] + mock_state.operator_replan_reason = "Mock Immediate Replan Reason" + + # Mock LLM responses for the members + mock_llm_pl = MagicMock() + mock_llm_pl.bind_tools.return_value = mock_llm_pl + + m1 = MagicMock(content="Op: I think we should tap.") + m1.tool_calls = [] + m2 = MagicMock(content="Op: I agree with Exp.") + m2.tool_calls = [] + m3 = MagicMock(content="Op: Final conclusion: Tap at [50, 50]") + m3.tool_calls = [] + + mock_llm_pl.ainvoke = AsyncMock(side_effect=[m1, m2, m3]) + + mock_llm_diag = MagicMock() + mock_llm_diag.bind_tools.return_value = mock_llm_diag + m_diag = MagicMock(content="Diag: I see an error.") + m_diag.tool_calls = [] + mock_llm_diag.ainvoke = AsyncMock(return_value=m_diag) + + # Mock History Analyzer to call a tool first + mock_llm_hist = MagicMock() + mock_llm_hist.bind_tools.return_value = mock_llm_hist + + mock_response_tool_call = MagicMock() + mock_response_tool_call.tool_calls = [ + { + "name": "get_step_details", + "args": {"start_step": 1, "end_step": 1}, + "id": "call_1", + } + ] + mock_response_tool_call.content = "" + + mock_response_final = MagicMock() + mock_response_final.tool_calls = [] + mock_response_final.content = "Hist: I found step 1 details." + + m_hist_agree = MagicMock(content="Hist: Still agree.") + m_hist_agree.tool_calls = [] + + mock_llm_hist.ainvoke = AsyncMock( + side_effect=[ + mock_response_tool_call, + mock_response_final, + # For second round + m_hist_agree, + ] + ) + + def mock_get_llm(ctx, name, temperature=None): + if name == "planner_avatar": + return mock_llm_pl + elif name == "diagnoser_expert": + return mock_llm_diag + elif name == "history_analyzer_expert": + return mock_llm_hist + return MagicMock() + + # Patch get_llm and the actual tool functions to avoid real calls + with ( + patch( + "artemis.tools.committee_tool.get_llm", + side_effect=mock_get_llm, + ), + patch( + "artemis.tools.committee_tool.trace_langchain_tool", + side_effect=lambda t, ctx: t, + ), + ): + tool = get_ask_committee_tool(mock_ctx) + + # Invoke tool with avatar_directive + from artemis.data_engine.trace import CURRENT_TRACE_ID + + token = CURRENT_TRACE_ID.set("test_trace_id") + try: + result = await tool.ainvoke( + { + "avatar_directive": "Advocate for paying", + "state": mock_state, + } + ) + finally: + CURRENT_TRACE_ID.reset(token) + + # Verify result + self.assertIn("Final conclusion: Tap at [50, 50]", result) + + # Verify file was created and contains content + notes_dir = Path(temp_dir) / "notes" + blackboard_files = list(notes_dir.glob("blackboard_*.md")) + self.assertTrue(len(blackboard_files) > 0, "No blackboard file created") + blackboard_path = blackboard_files[0] + content = blackboard_path.read_text(encoding="utf-8") + self.assertIn("Advocate for paying", content) # Check directive is in blackboard + self.assertIn("Hist: I found step 1 details.", content) + self.assertIn( + "Timeout waiting for element", content + ) # Check failed history is in blackboard + self.assertIn("Test Goal", content) # Check initial goal is in blackboard + self.assertIn( + "Mock Immediate Replan Reason", content + ) # Check current replan reason is in blackboard + + finally: + # Clean up temp directory + shutil.rmtree(temp_dir) + + def test_ask_committee_tool_subclass_and_registry(self): + """Verify AskCommitteeTool is a subclass of ArtemisTool and properly registered.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.committee_tool import ( + AskCommittee, + AskCommitteeArgs, + AskCommitteeTool, + AskCommitteeToolAlias, + ask_committee, + ask_committee_wrapper, + get_ask_committee_tool, + ) + + self.assertTrue(issubclass(AskCommitteeTool, ArtemisTool)) + self.assertTrue(issubclass(AskCommittee, ArtemisTool)) + self.assertTrue(issubclass(AskCommitteeToolAlias, ArtemisTool)) + self.assertIsInstance(ask_committee, ArtemisTool) + self.assertIsInstance(ask_committee, AskCommitteeTool) + + self.assertEqual(ask_committee.name, "ask_committee") + self.assertEqual(ask_committee.category, "custom") + self.assertEqual(ask_committee.args_schema, AskCommitteeArgs) + + # Registry lookup + reg_tool = ToolRegistry.get("ask_committee") + self.assertIsNotNone(reg_tool) + self.assertIsInstance(reg_tool, AskCommitteeTool) + + # GenAI FunctionDeclaration export + declaration = ask_committee.to_genai_declaration() + self.assertEqual(declaration.name, "ask_committee") + self.assertIn("avatar_directive", declaration.parameters.properties) + + # Wrapper check + self.assertIsNotNone(ask_committee_wrapper) + self.assertEqual(ask_committee_wrapper.tool_fn_getter, get_ask_committee_tool) + + async def test_ask_committee_no_ctx(self): + """Verify executing without ctx returns an error message.""" + from artemis.tools.committee_tool import ask_committee + + result = await ask_committee.execute(avatar_directive="Test") + self.assertEqual(result, "Error: ArtemisContext is required for ask_committee.") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/tools/test_explorer_tool.py b/tests/unit/tools/test_explorer_tool.py new file mode 100644 index 0000000..5ac85ce --- /dev/null +++ b/tests/unit/tools/test_explorer_tool.py @@ -0,0 +1,375 @@ +# 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 json +from unittest.mock import AsyncMock, MagicMock, mock_open, patch + +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.explorer_tool import ( + AskExplorer, + AskExplorerArgs, + AskExplorerTool, + AskExplorerToolAlias, + ask_explorer, + ask_explorer_wrapper, + get_ask_explorer_tool, +) +import pytest + + +def test_ask_explorer_tool_subclass_and_registry(): + """Verify AskExplorerTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(AskExplorerTool, ArtemisTool) + assert issubclass(AskExplorer, ArtemisTool) + assert issubclass(AskExplorerToolAlias, ArtemisTool) + assert isinstance(ask_explorer, ArtemisTool) + assert isinstance(ask_explorer, AskExplorerTool) + + assert ask_explorer.name == "ask_explorer" + assert ask_explorer.category == "explorer" + assert ask_explorer.args_schema == AskExplorerArgs + + # Registry lookup + reg_tool = ToolRegistry.get("ask_explorer") + assert reg_tool is not None + assert isinstance(reg_tool, AskExplorerTool) + + # GenAI FunctionDeclaration export + declaration = ask_explorer.to_genai_declaration() + assert declaration.name == "ask_explorer" + assert "query" in declaration.parameters.properties + + # Wrapper check + assert ask_explorer_wrapper is not None + assert ask_explorer_wrapper.tool_fn_getter == get_ask_explorer_tool + + +@pytest.mark.asyncio +async def test_ask_explorer_multiple_candidates(): + # Mock Context and State + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [[100, 200]] # Pre-existing point + mock_state.indexed_elements = [ + { + "index": 1, + "center": [100, 200], + "text": "Pre-existing", + "bounds": None, + "class": "Test", + "resource_id": None, + "is_ocr": False, + } + ] + + # Mock Explorer Agent Outcome with multiple candidates and fallback message + explorer_outcome = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "First Button", + }, + { + "label": "S2", + "coords": [250, 750], + "description": "Second Button", + }, + ], + "fallback_message": "Please choose carefully.", + } + explorer_output_str = json.dumps(explorer_outcome) + + # We need to patch the Explorer class inside the tool + mock_explorer_instance = MagicMock() + mock_explorer_instance.run = AsyncMock(return_value=explorer_output_str) + + with patch( + "artemis.tools.explorer_tool.Explorer", + return_value=mock_explorer_instance, + ): + # Retrieve the tool + ask_explorer_tool = get_ask_explorer_tool(mock_ctx, version="pro") + + result = await ask_explorer_tool.ainvoke( + { + "query": "Find buttons", + "context_feedback": "Attempt 1", + "state": mock_state, + } + ) + + assert mock_state.indexed_points == [ + [100, 200], + [540, 1200], + [270, 1800], + ] + + assert len(mock_state.indexed_elements) == 3 + assert mock_state.indexed_elements[1]["index"] == 2 + assert mock_state.indexed_elements[1]["center"] == [540, 1200] + assert mock_state.indexed_elements[1]["text"] == "First Button" + assert mock_state.indexed_elements[2]["index"] == 3 + assert mock_state.indexed_elements[2]["center"] == [270, 1800] + assert mock_state.indexed_elements[2]["text"] == "Second Button" + + assert isinstance(result, str) + assert "Explorer successfully located the following candidate(s):" in result + assert "- [2] 'First Button' at coordinate [500, 500]" in result + assert "- [3] 'Second Button' at coordinate [250, 750]" in result + assert ( + "You can click/act on them directly by calling perform_action with" + " their respective index." in result + ) + assert "Additional Notes from Explorer: Please choose carefully." in result + + +@pytest.mark.asyncio +async def test_ask_explorer_only_fallback_message(): + # Mock Context and State + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [] + mock_state.indexed_elements = [] + + explorer_outcome = { + "candidates": [], + "fallback_message": ("Element not found because it is covered by a keyboard."), + } + explorer_output_str = json.dumps(explorer_outcome) + + mock_explorer_instance = MagicMock() + mock_explorer_instance.run = AsyncMock(return_value=explorer_output_str) + + with patch( + "artemis.tools.explorer_tool.Explorer", + return_value=mock_explorer_instance, + ): + ask_explorer_tool = get_ask_explorer_tool(mock_ctx, version="pro") + + result = await ask_explorer_tool.ainvoke( + { + "query": "Find hidden button", + "context_feedback": "", + "state": mock_state, + } + ) + + assert mock_state.indexed_points == [] + assert mock_state.indexed_elements == [] + + assert ( + "Explorer could not locate the element. Message: Element not found" + " because it is covered by a keyboard." in result + ) + + +@pytest.mark.asyncio +async def test_ask_explorer_multimodal_success(): + # Mock Context and State + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [[100, 200]] + mock_state.indexed_elements = [ + { + "index": 1, + "center": [100, 200], + "text": "Pre-existing", + "bounds": None, + "class": "Test", + "resource_id": None, + "is_ocr": False, + } + ] + + explorer_outcome = { + "candidates": [ + { + "label": "S1", + "coords": [500, 500], + "description": "First Button", + }, + ], + "fallback_message": "", + } + explorer_output_str = json.dumps(explorer_outcome) + + mock_explorer_instance = MagicMock() + mock_explorer_instance.run = AsyncMock(return_value=explorer_output_str) + + m_open = mock_open(read_data=b"fake_annotated_image_bytes") + + with ( + patch( + "artemis.tools.explorer_tool.Explorer", + return_value=mock_explorer_instance, + ), + patch("artemis.tools.explorer_tool.draw_dots") as mock_draw_dots, + patch("builtins.open", m_open), + patch("pathlib.Path.mkdir"), + ): + ask_explorer_tool = get_ask_explorer_tool(mock_ctx, version="pro") + + result = await ask_explorer_tool.ainvoke( + { + "query": "Find buttons", + "context_feedback": "", + "state": mock_state, + } + ) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["type"] == "text" + assert "Explorer successfully located" in result[0]["text"] + assert result[1]["type"] == "image_url" + assert result[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") + + mock_draw_dots.assert_called_once() + points_arg = mock_draw_dots.call_args[0][1] + assert points_arg == [[100, 200], [540, 1200]] + labels_arg = mock_draw_dots.call_args[0][2] + assert labels_arg == ["1", "2"] + + +@pytest.mark.asyncio +async def test_ask_explorer_direct_execution(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_ctx.device = None + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [] + mock_state.indexed_elements = [] + + mock_explorer_instance = MagicMock() + mock_explorer_instance.run = AsyncMock( + return_value=json.dumps( + { + "candidates": [], + "fallback_message": "Direct execution not found.", + } + ) + ) + + with patch( + "artemis.tools.explorer_tool.Explorer", + return_value=mock_explorer_instance, + ): + result = await ask_explorer.execute( + ctx=mock_ctx, state=mock_state, query="Find icon", version="pro" + ) + assert "Direct execution not found." in result + + +@pytest.mark.asyncio +async def test_ask_explorer_callable_execution(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_ctx.device = None + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [] + mock_state.indexed_elements = [] + + mock_explorer_instance = MagicMock() + mock_explorer_instance.run = AsyncMock( + return_value=json.dumps( + { + "candidates": [], + "fallback_message": "Callable test message.", + } + ) + ) + + with patch( + "artemis.tools.explorer_tool.Explorer", + return_value=mock_explorer_instance, + ): + result = await ask_explorer( + ctx=mock_ctx, state=mock_state, query="Search bar", version="pro" + ) + assert "Callable test message." in result + + +@pytest.mark.asyncio +async def test_ask_explorer_flash_mode(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + mock_ctx.agent_config = None + mock_ctx.llm_config = None + mock_ctx.execution_setup = None + mock_ctx.device = MagicMock() + mock_ctx.device.device_width = 1080 + mock_ctx.device.device_height = 2400 + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test.jpg" + mock_state.indexed_points = [] + mock_state.indexed_elements = [] + + with ( + patch( + "artemis.tools.explorer_tool._run_object_detection", + new_callable=AsyncMock, + return_value={ + "detected": [ + {"label": "Icon", "point": [500, 500]}, + ], + "failed": [], + }, + ), + patch( + "builtins.open", + mock_open(read_data=json.dumps({"templates": [], "instructions": ""})), + ), + ): + result = await ask_explorer.execute( + ctx=mock_ctx, state=mock_state, query="Icon", version="flash" + ) + assert "Explorer successfully located" in result + assert "Icon" in result diff --git a/tests/unit/tools/test_image_processor_tool.py b/tests/unit/tools/test_image_processor_tool.py new file mode 100644 index 0000000..03b8072 --- /dev/null +++ b/tests/unit/tools/test_image_processor_tool.py @@ -0,0 +1,99 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.image_processor_tool import get_ask_image_processor_tool +import pytest + + +@pytest.mark.asyncio +async def test_ask_vision_coder_success(): + # Mock Context and State + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/tmp/test_screen.jpg" + + # Mock VisionCoder Agent Outcome + vision_coder_outcome = { + "new_image_path": "/tmp/coder_output.jpg", + "summary": "Found red button at pixel coords (100, 200)", + } + + # We need to patch the ImageProcessor class inside the tool + mock_coder_instance = MagicMock() + mock_coder_instance.run = AsyncMock(return_value=vision_coder_outcome) + + with patch( + "artemis.agents.image_processor.image_processor.ImageProcessor", + return_value=mock_coder_instance, + ): + # Retrieve the tool + ask_vision_coder_tool = get_ask_image_processor_tool(mock_ctx) + + # Invoke the tool + result = await ask_vision_coder_tool.ainvoke( + { + "instruction": "Find the red button", + "target_image_id": "img_0", + "state": mock_state, + } + ) + + # Verify the agent was called with correct arguments + mock_coder_instance.run.assert_called_once_with( + "Find the red button", "/tmp/test_screen.jpg" + ) + + # Verify the output matches + assert result == vision_coder_outcome + + +def test_ask_image_processor_tool_subclass_and_registry(): + """Verify AskImageProcessorTool is an ArtemisTool subclass and registered in ToolRegistry.""" + from artemis.tools.base import ArtemisTool, ToolRegistry + from artemis.tools.image_processor_tool import ( + AskImageProcessor, + AskImageProcessorTool, + AskVisionCoderArgs, + AskVisionCoderTool, + ImageProcessorTool, + ask_image_processor, + ) + + assert issubclass(AskImageProcessorTool, ArtemisTool) + assert issubclass(AskImageProcessor, ArtemisTool) + assert issubclass(AskVisionCoderTool, ArtemisTool) + assert issubclass(ImageProcessorTool, ArtemisTool) + assert isinstance(ask_image_processor, ArtemisTool) + assert isinstance(ask_image_processor, AskImageProcessorTool) + + assert ask_image_processor.name == "ask_image_processor" + assert ask_image_processor.category == "custom" + assert ask_image_processor.args_schema == AskVisionCoderArgs + + # Registry lookup + reg_tool = ToolRegistry.get("ask_image_processor") + assert reg_tool is not None + assert isinstance(reg_tool, AskImageProcessorTool) + + # GenAI FunctionDeclaration export + declaration = ask_image_processor.to_genai_declaration() + assert declaration.name == "ask_image_processor" + assert "instruction" in declaration.parameters.properties + assert "target_image_id" in declaration.parameters.properties diff --git a/tests/unit/tools/test_input_text_tool.py b/tests/unit/tools/test_input_text_tool.py new file mode 100644 index 0000000..f7ff923 --- /dev/null +++ b/tests/unit/tools/test_input_text_tool.py @@ -0,0 +1,182 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.exec_tools import ( + InputText, + InputTextArgs, + InputTextTool, + get_input_text_tool, + input_text, + input_text_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.screen_size = (1080, 2400) + driver.tap = AsyncMock(return_value=True) + driver.input_text = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_input_text_tool_subclass_and_registry(): + """Verify InputTextTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(InputTextTool, ArtemisTool) + assert issubclass(InputText, ArtemisTool) + assert isinstance(input_text, ArtemisTool) + assert isinstance(input_text, InputTextTool) + + assert input_text.name == "input_text" + assert input_text.category == "action" + assert input_text.args_schema == InputTextArgs + + # Registry lookup + reg_tool = ToolRegistry.get("input_text") + assert reg_tool is not None + assert isinstance(reg_tool, InputTextTool) + + # GenAI FunctionDeclaration export + declaration = input_text.to_genai_declaration() + assert declaration.name == "input_text" + assert "text" in declaration.parameters.properties + assert "target" in declaration.parameters.properties + assert "clear_exist" in declaration.parameters.properties + + # Wrapper check + assert input_text_wrapper is not None + assert input_text_wrapper.tool_fn_getter == get_input_text_tool + + +@pytest.mark.asyncio +async def test_input_text_direct_execution_with_driver(mock_driver): + """Verify direct execution of InputTextTool with BaseDeviceDriver.""" + result = await input_text.execute( + driver=mock_driver, text="hello world", target=[500, 500], clear_exist=True + ) + mock_driver.tap.assert_called_once_with(540, 1200) + mock_driver.input_text.assert_called_once_with("hello world", clear_existing=True) + assert "Typed 'hello world' successfully." in result + + +@pytest.mark.asyncio +async def test_input_text_direct_execution_with_ctx(mock_ctx): + """Verify direct execution of InputTextTool with ArtemisContext.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_inst.erase_text = AsyncMock(return_value=True) + mock_controller_inst.type_text = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + result = await input_text.execute( + ctx=mock_ctx, text="test input", target=[250, 750], clear_exist=True + ) + mock_controller_inst.tap_at.assert_called_once_with(270, 1800) + mock_controller_inst.erase_text.assert_called_once() + mock_controller_inst.type_text.assert_called_once_with("test input", clear_existing=False) + assert "Typed 'test input' successfully." in result + + +@pytest.mark.asyncio +async def test_input_text_with_state_command(mock_ctx, mock_state): + """Verify InputTextTool returns Command when state is provided.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_inst.erase_text = AsyncMock(return_value=True) + mock_controller_inst.type_text = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + cmd = await input_text.execute( + ctx=mock_ctx, + text="query", + target=[100, 200], + tool_call_id="call_it_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_it_1" + assert messages[0].status == "success" + assert "Typed 'query'" in messages[0].content + + +@pytest.mark.asyncio +async def test_input_text_callable_execution(mock_driver): + """Verify invoking input_text directly as a callable.""" + result = await input_text(driver=mock_driver, text="call text", target=[100, 100]) + assert "Typed 'call text' successfully." in result + + +@pytest.mark.asyncio +async def test_input_text_invalid_target(mock_ctx): + """Verify error handling on invalid target coordinates.""" + result = await input_text.execute(ctx=mock_ctx, text="bad", target=[]) + assert "Error during input text: Invalid target coordinates" in result + + +@pytest.mark.asyncio +async def test_get_input_text_tool_langchain_ainvoke(mock_ctx): + """Verify get_input_text_tool exports a LangChain tool that works with ainvoke.""" + it_tool = get_input_text_tool(mock_ctx) + assert it_tool.name == "input_text" + + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_inst.press_key = AsyncMock(return_value=True) + mock_controller_inst.type_text = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + result = await it_tool.ainvoke( + { + "text": "abc", + "target": [500, 500], + "clear_exist": False, + } + ) + mock_controller_inst.press_key.assert_called_once_with("123") + assert "Typed 'abc' successfully." in result diff --git a/tests/unit/tools/test_launch_app.py b/tests/unit/tools/test_launch_app.py new file mode 100644 index 0000000..bc45611 --- /dev/null +++ b/tests/unit/tools/test_launch_app.py @@ -0,0 +1,92 @@ +# 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, patch +from artemis.agents.hopper.hopper import HopperOutput +from artemis.context import ArtemisContext +from artemis.tools.mobile.launch_app import find_package +import pytest + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(spec=ArtemisContext) + ctx.package_cache = {} + return ctx + + +@pytest.mark.asyncio +@patch("artemis.tools.mobile.launch_app.list_packages_async") +@patch("artemis.tools.mobile.launch_app.hopper") +async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_context): + mock_list_packages.return_value = "com.example.app1\ncom.example.app2" + + # First call: not in cache, calls hopper + mock_hopper.return_value = HopperOutput( + found=True, output="com.example.app1", reason="Found it" + ) + + pkg1 = await find_package(mock_context, "App 1") + assert pkg1 == "com.example.app1" + assert mock_hopper.call_count == 1 + assert mock_context.package_cache["App 1"] == "com.example.app1" + + # Second call: cache hit, does not call hopper or list_packages again + mock_hopper.reset_mock() + mock_list_packages.reset_mock() + + pkg2 = await find_package(mock_context, "App 1") + assert pkg2 == "com.example.app1" + mock_hopper.assert_not_called() + mock_list_packages.assert_not_called() + + +@pytest.mark.asyncio +@patch("artemis.tools.mobile.launch_app.list_packages_async") +@patch("artemis.tools.mobile.launch_app.hopper") +async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context): + mock_list_packages.return_value = "com.example.app1\ncom.example.app2" + + # Hopper doesn't find it + mock_hopper.return_value = HopperOutput(found=False, output=None, reason="Not found") + + pkg1 = await find_package(mock_context, "App 3") + assert pkg1 is None + assert mock_hopper.call_count == 1 + assert mock_context.package_cache["App 3"] is None + + # Second call: cache hit (None), does not call hopper again + mock_hopper.reset_mock() + pkg2 = await find_package(mock_context, "App 3") + assert pkg2 is None + mock_hopper.assert_not_called() + + +@pytest.mark.asyncio +@patch("artemis.tools.mobile.launch_app.list_packages_async") +@patch("artemis.tools.mobile.launch_app.hopper") +async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages, mock_context): + mock_list_packages.return_value = "com.example.app1\ncom.example.app2" + mock_hopper.return_value = HopperOutput( + found=True, output="com.example.app1", reason="Found it" + ) + + # Pass use_fallback=False + await find_package(mock_context, "App 1", use_fallback=False) + mock_hopper.assert_called_once_with( + ctx=mock_context, + request=("I'm looking for the package name of the following app: 'App 1'"), + data="com.example.app1\ncom.example.app2", + use_fallback=False, + ) diff --git a/tests/unit/tools/test_launch_app_tool.py b/tests/unit/tools/test_launch_app_tool.py new file mode 100644 index 0000000..7f28282 --- /dev/null +++ b/tests/unit/tools/test_launch_app_tool.py @@ -0,0 +1,208 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.launch_app import ( + LaunchApp, + LaunchAppArgs, + LaunchAppTool, + get_launch_app_tool, + launch_app, + launch_app_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.package_cache = {} + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.launch_app = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_launch_app_tool_subclass_and_registry(): + """Verify LaunchAppTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(LaunchAppTool, ArtemisTool) + assert issubclass(LaunchApp, ArtemisTool) + assert isinstance(launch_app, ArtemisTool) + assert isinstance(launch_app, LaunchAppTool) + + assert launch_app.name == "launch_app" + assert launch_app.category == "action" + assert launch_app.args_schema == LaunchAppArgs + + # Registry lookup + reg_tool = ToolRegistry.get("launch_app") + assert reg_tool is not None + assert isinstance(reg_tool, LaunchAppTool) + + # GenAI FunctionDeclaration export + declaration = launch_app.to_genai_declaration() + assert declaration.name == "launch_app" + assert "app_name" in declaration.parameters.properties + + # Wrapper check + assert launch_app_wrapper is not None + assert launch_app_wrapper.tool_fn_getter == get_launch_app_tool + + +@pytest.mark.asyncio +async def test_launch_app_direct_execution_with_driver(mock_driver): + """Verify direct execution with BaseDeviceDriver.""" + result = await launch_app.execute(driver=mock_driver, app_name="com.android.settings") + mock_driver.launch_app.assert_called_once_with("com.android.settings") + assert "App 'com.android.settings' launched successfully." in result + + +@pytest.mark.asyncio +async def test_launch_app_direct_execution_with_ctx(mock_ctx): + """Verify direct execution with ArtemisContext.""" + with ( + patch( + "artemis.tools.mobile.launch_app.find_package", + new_callable=AsyncMock, + return_value="com.example.myapp", + ) as mock_find, + patch( + "artemis.tools.mobile.launch_app.launch_app_with_retries", + new_callable=AsyncMock, + return_value=(True, None), + ) as mock_launch, + ): + result = await launch_app.execute(ctx=mock_ctx, app_name="My App") + mock_find.assert_called_once_with(ctx=mock_ctx, app_name="My App") + mock_launch.assert_called_once_with(ctx=mock_ctx, app_package="com.example.myapp") + assert "App 'My App' launched successfully." in result + + +@pytest.mark.asyncio +async def test_launch_app_package_not_found(mock_ctx): + """Verify outcome when package is not found.""" + with patch( + "artemis.tools.mobile.launch_app.find_package", + new_callable=AsyncMock, + return_value=None, + ): + result = await launch_app.execute(ctx=mock_ctx, app_name="Nonexistent App") + assert "Failed to launch app 'Nonexistent App': Package not found." in result + + +@pytest.mark.asyncio +async def test_launch_app_execution_failure(mock_ctx): + """Verify outcome when launch_app_with_retries fails.""" + with ( + patch( + "artemis.tools.mobile.launch_app.find_package", + new_callable=AsyncMock, + return_value="com.example.failapp", + ), + patch( + "artemis.tools.mobile.launch_app.launch_app_with_retries", + new_callable=AsyncMock, + return_value=(False, "App crashed on startup"), + ), + ): + result = await launch_app.execute(ctx=mock_ctx, app_name="Crash App") + assert "Failed to launch app 'Crash App': App crashed on startup" in result + + +@pytest.mark.asyncio +async def test_launch_app_with_state_command(mock_ctx, mock_state): + """Verify LaunchAppTool returns Command when state is provided.""" + with ( + patch( + "artemis.tools.mobile.launch_app.find_package", + new_callable=AsyncMock, + return_value="com.example.stateapp", + ), + patch( + "artemis.tools.mobile.launch_app.launch_app_with_retries", + new_callable=AsyncMock, + return_value=(True, None), + ), + ): + cmd = await launch_app.execute( + ctx=mock_ctx, + app_name="State App", + tool_call_id="call_la_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_la_1" + assert messages[0].status == "success" + assert "App 'State App' launched successfully." in messages[0].content + + +@pytest.mark.asyncio +async def test_launch_app_callable_execution(mock_driver): + """Verify invoking launch_app directly as a callable.""" + result = await launch_app(driver=mock_driver, app_name="com.android.chrome") + assert "App 'com.android.chrome' launched successfully." in result + + +@pytest.mark.asyncio +async def test_launch_app_missing_name(mock_ctx): + """Verify error handling when app_name is empty.""" + result = await launch_app.execute(ctx=mock_ctx, app_name="") + assert "Error during launch app: app_name parameter is required." in result + + +@pytest.mark.asyncio +async def test_get_launch_app_tool_langchain_ainvoke(mock_ctx): + """Verify get_launch_app_tool exports a LangChain tool that works with ainvoke.""" + la_tool = get_launch_app_tool(mock_ctx) + assert la_tool.name == "launch_app" + + with ( + patch( + "artemis.tools.mobile.launch_app.find_package", + new_callable=AsyncMock, + return_value="com.example.ainvoke", + ), + patch( + "artemis.tools.mobile.launch_app.launch_app_with_retries", + new_callable=AsyncMock, + return_value=(True, None), + ), + ): + result = await la_tool.ainvoke({"app_name": "Invoke App"}) + assert "App 'Invoke App' launched successfully." in result diff --git a/tests/unit/tools/test_list_notes_pure_tool.py b/tests/unit/tools/test_list_notes_pure_tool.py new file mode 100644 index 0000000..997115e --- /dev/null +++ b/tests/unit/tools/test_list_notes_pure_tool.py @@ -0,0 +1,109 @@ +# 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, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + ListNotesArgs, + ListNotesPure, + ListNotesPureTool, + get_list_notes_tool_pure, + list_notes_pure, +) +from artemis.utils.notes import save_note_content +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_list_notes_pure_tool_subclass_and_registry(): + """Verify ListNotesPureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ListNotesPureTool, ArtemisTool) + assert issubclass(ListNotesPure, ArtemisTool) + assert isinstance(list_notes_pure, ArtemisTool) + assert isinstance(list_notes_pure, ListNotesPureTool) + + assert list_notes_pure.name == "list_notes_pure" + assert list_notes_pure.category == "memory" + assert list_notes_pure.args_schema == ListNotesArgs + + # Registry lookup + reg_tool = ToolRegistry.get("list_notes_pure") + assert reg_tool is not None + assert isinstance(reg_tool, ListNotesPureTool) + + # GenAI FunctionDeclaration export + declaration = list_notes_pure.to_genai_declaration() + assert declaration.name == "list_notes_pure" + + +@pytest.mark.asyncio +async def test_list_notes_pure_direct_execution_empty(mock_ctx): + """Verify listing notes when no notes are stored.""" + result = await list_notes_pure.execute(ctx=mock_ctx) + assert "No notes saved yet." in result + + +@pytest.mark.asyncio +async def test_list_notes_pure_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of ListNotesPureTool.execute lists stored notes.""" + save_note_content(tmp_path, "note_1", "line 1\nline 2") + save_note_content(tmp_path, "note_2", "single line") + + result = await list_notes_pure.execute(ctx=mock_ctx) + assert "Here are all the notes:" in result + assert "- note_1 (2 lines)" in result + assert "- note_2 (1 lines)" in result + + +@pytest.mark.asyncio +async def test_list_notes_pure_callable_execution(mock_ctx, tmp_path): + """Verify invoking list_notes_pure directly as a callable.""" + save_note_content(tmp_path, "callable_note", "line A\nline B") + + result = await list_notes_pure(ctx=mock_ctx) + assert "Here are all the notes:" in result + assert "- callable_note (2 lines)" in result + + +@pytest.mark.asyncio +async def test_list_notes_pure_execution_failure(mock_ctx): + """Verify error handling when list_notes_pure fails.""" + with patch( + "artemis.tools.scratchpad.list_notes_info", + side_effect=RuntimeError("Disk failure"), + ): + result = await list_notes_pure.execute(ctx=mock_ctx) + assert "Failed to list notes: Disk failure" in result + + +@pytest.mark.asyncio +async def test_get_list_notes_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_list_notes_tool_pure exports a LangChain tool named 'list_notes'.""" + save_note_content(tmp_path, "pure_export_note", "content") + + pure_tool = get_list_notes_tool_pure(mock_ctx) + assert pure_tool.name == "list_notes" + + result = await pure_tool.ainvoke({}) + assert "Here are all the notes:" in result + assert "- pure_export_note (1 lines)" in result diff --git a/tests/unit/tools/test_list_notes_tool.py b/tests/unit/tools/test_list_notes_tool.py new file mode 100644 index 0000000..e0e25c7 --- /dev/null +++ b/tests/unit/tools/test_list_notes_tool.py @@ -0,0 +1,158 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + ListNotes, + ListNotesArgs, + ListNotesTool, + get_list_notes_tool, + get_list_notes_tool_pure, + list_notes, + list_notes_wrapper, +) +from artemis.utils.notes import save_note_content +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_list_notes_tool_subclass_and_registry(): + """Verify ListNotesTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ListNotesTool, ArtemisTool) + assert issubclass(ListNotes, ArtemisTool) + assert isinstance(list_notes, ArtemisTool) + assert isinstance(list_notes, ListNotesTool) + + assert list_notes.name == "list_notes" + assert list_notes.category == "memory" + assert list_notes.args_schema == ListNotesArgs + + # Registry lookup + reg_tool = ToolRegistry.get("list_notes") + assert reg_tool is not None + assert isinstance(reg_tool, ListNotesTool) + + # GenAI FunctionDeclaration export + declaration = list_notes.to_genai_declaration() + assert declaration.name == "list_notes" + + # Wrapper check + assert list_notes_wrapper is not None + assert list_notes_wrapper.tool_fn_getter == get_list_notes_tool + + +@pytest.mark.asyncio +async def test_list_notes_direct_execution_empty(mock_ctx): + """Verify listing notes when no notes are stored.""" + result = await list_notes.execute(ctx=mock_ctx) + assert "No notes saved yet." in result + + +@pytest.mark.asyncio +async def test_list_notes_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of ListNotesTool.execute lists stored notes.""" + save_note_content(tmp_path, "note_alpha", "line 1\nline 2") + save_note_content(tmp_path, "note_beta", "single line") + + result = await list_notes.execute(ctx=mock_ctx) + assert "Here are all the notes:" in result + assert "- note_alpha (2 lines)" in result + assert "- note_beta (1 lines)" in result + + +@pytest.mark.asyncio +async def test_list_notes_callable_execution(mock_ctx, tmp_path): + """Verify invoking list_notes directly as a callable.""" + save_note_content(tmp_path, "note_gamma", "line A\nline B\nline C") + + result = await list_notes(ctx=mock_ctx) + assert "Here are all the notes:" in result + assert "- note_gamma (3 lines)" in result + + +@pytest.mark.asyncio +async def test_list_notes_with_state_command(mock_ctx, tmp_path): + """Verify ListNotesTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY.""" + save_note_content(tmp_path, "state_note", "content") + + mock_state = MagicMock(spec=State) + mock_state.current_agent = "operator" + mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + result = await list_notes.execute( + ctx=mock_ctx, + state=mock_state, + tool_call_id="call_list_999", + ) + + assert isinstance(result, Command) + assert VALIDATOR_MESSAGES_KEY in result.update + tool_messages = result.update[VALIDATOR_MESSAGES_KEY] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_list_999" + assert "Here are all the notes:" in tool_messages[0].content + assert "- state_note (1 lines)" in tool_messages[0].content + assert tool_messages[0].status == "success" + + mock_state.asanitize_update.assert_called_once() + + +@pytest.mark.asyncio +async def test_list_notes_execution_failure(mock_ctx): + """Verify error handling when list_notes fails.""" + with patch( + "artemis.tools.scratchpad.list_notes_info", + side_effect=RuntimeError("Disk failure"), + ): + result = await list_notes.execute(ctx=mock_ctx) + assert "Failed to list notes: Disk failure" in result + + +@pytest.mark.asyncio +async def test_get_list_notes_tool_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_list_notes_tool exports a LangChain tool that works with ainvoke.""" + save_note_content(tmp_path, "lc_note", "hello") + + lc_tool = get_list_notes_tool(mock_ctx) + assert lc_tool.name == "list_notes" + + result = await lc_tool.ainvoke({}) + assert "Here are all the notes:" in result + assert "- lc_note (1 lines)" in result + + +@pytest.mark.asyncio +async def test_get_list_notes_tool_pure(mock_ctx, tmp_path): + """Verify get_list_notes_tool_pure returns a pure LangChain tool.""" + save_note_content(tmp_path, "pure_note", "world") + + pure_tool = get_list_notes_tool_pure(mock_ctx) + assert pure_tool.name == "list_notes" + + result = await pure_tool.ainvoke({}) + assert "Here are all the notes:" in result + assert "- pure_note (1 lines)" in result diff --git a/tests/unit/tools/test_log_tool.py b/tests/unit/tools/test_log_tool.py new file mode 100644 index 0000000..990d7bd --- /dev/null +++ b/tests/unit/tools/test_log_tool.py @@ -0,0 +1,115 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.log_tool import ( + AnalyzeLogs, + AnalyzeLogsArgs, + AnalyzeLogsTool, + LogTool, + analyze_logs, + analyze_logs_wrapper, + get_analyze_logs_tool, +) +import pytest + + +@pytest.mark.asyncio +async def test_analyze_logs_tool_execution(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + mock_state = MagicMock(spec=State) + + expected_output = "Found 2 errors in system log: NullPointerException at line 42." + + mock_analyst = MagicMock() + mock_analyst.run = AsyncMock(return_value=expected_output) + + with patch( + "artemis.tools.log_tool.LogAnalyzerNode", + return_value=mock_analyst, + ): + tool = get_analyze_logs_tool(mock_ctx) + + result = await tool.ainvoke( + { + "specific_query": "Check for crash exceptions", + "state": mock_state, + } + ) + + mock_analyst.run.assert_called_once_with("Check for crash exceptions", mock_state) + assert result == expected_output + + +@pytest.mark.asyncio +async def test_analyze_logs_tool_direct_call(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_state = MagicMock(spec=State) + + mock_analyst = MagicMock() + mock_analyst.run = AsyncMock(return_value="Log scan complete.") + + with patch( + "artemis.tools.log_tool.LogAnalyzerNode", + return_value=mock_analyst, + ): + result = await analyze_logs( + ctx=mock_ctx, + specific_query="Find ANR", + state=mock_state, + ) + mock_analyst.run.assert_called_once_with("Find ANR", mock_state) + assert result == "Log scan complete." + + +@pytest.mark.asyncio +async def test_analyze_logs_tool_no_context(): + result = await analyze_logs( + ctx=None, + specific_query="Find crash", + ) + assert result == "Error: ArtemisContext is required for LogAnalyzer." + + +def test_analyze_logs_tool_subclass_and_registry(): + """Verify AnalyzeLogsTool is an ArtemisTool subclass and registered in ToolRegistry.""" + assert issubclass(AnalyzeLogsTool, ArtemisTool) + assert issubclass(AnalyzeLogs, ArtemisTool) + assert issubclass(LogTool, ArtemisTool) + assert isinstance(analyze_logs, ArtemisTool) + assert isinstance(analyze_logs, AnalyzeLogsTool) + + assert analyze_logs.name == "analyze_logs" + assert analyze_logs.category == "custom" + assert analyze_logs.args_schema == AnalyzeLogsArgs + + # Registry lookup + reg_tool = ToolRegistry.get("analyze_logs") + assert reg_tool is not None + assert isinstance(reg_tool, AnalyzeLogsTool) + + # GenAI FunctionDeclaration export + declaration = analyze_logs.to_genai_declaration() + assert declaration.name == "analyze_logs" + assert "specific_query" in declaration.parameters.properties + + # Wrapper check + assert analyze_logs_wrapper is not None + assert analyze_logs_wrapper.tool_fn_getter == get_analyze_logs_tool diff --git a/tests/unit/tools/test_log_utils.py b/tests/unit/tools/test_log_utils.py new file mode 100644 index 0000000..eafc350 --- /dev/null +++ b/tests/unit/tools/test_log_utils.py @@ -0,0 +1,116 @@ +# 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 datetime import datetime +from unittest.mock import Mock, patch +from artemis.context import ArtemisContext +from artemis.tools.mobile.log_utils import fetch_and_filter_logs +import pytest + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.data_engine = Mock() + ctx.data_engine.session_start_time = 1700000000.0 # Example timestamp + return ctx + + +@pytest.fixture +def mock_device(): + return Mock() + + +@patch("artemis.tools.mobile.log_utils.get_adb_device") +def test_fetch_and_filter_logs_basic(mock_get_adb_device, mock_context, mock_device): + mock_get_adb_device.return_value = mock_device + mock_device.shell.return_value = "line 1\nline 2\n" + + result = fetch_and_filter_logs(mock_context, lines=10) + + mock_device.shell.assert_called_once_with("logcat -v threadtime -t 10") + assert result == "line 1\nline 2\n" + + +@patch("artemis.tools.mobile.log_utils.get_adb_device") +@patch("artemis.tools.mobile.log_utils.resolve_time") +def test_fetch_and_filter_logs_since_time_success( + mock_resolve_time, mock_get_adb_device, mock_context, mock_device +): + mock_get_adb_device.return_value = mock_device + since_dt = datetime(datetime.now().year, 5, 3, 11, 11, 11, 111000) + mock_resolve_time.side_effect = [ + ("05-03 11:11:11.111", since_dt), # for since_time + (None, None), # for until_time + ] + + mock_device.shell.return_value = "05-03 11:11:12.000 line 1\n05-03 11:11:13.000 line 2\n" + + result = fetch_and_filter_logs(mock_context, since_time="05-03 11:11:11.111") + + mock_device.shell.assert_called_once_with('logcat -v threadtime -t "05-03 11:11:11.111"') + assert "line 1" in result + assert "line 2" in result + + +@patch("artemis.tools.mobile.log_utils.get_adb_device") +@patch("artemis.tools.mobile.log_utils.resolve_time") +def test_fetch_and_filter_logs_since_time_fallback( + mock_resolve_time, mock_get_adb_device, mock_context, mock_device +): + mock_get_adb_device.return_value = mock_device + current_year = datetime.now().year + since_dt = datetime(current_year, 5, 4, 12, 0, 0) + mock_resolve_time.side_effect = [ + ("05-04 12:00:00.000", since_dt), # for since_time + (None, None), # for until_time + ] + + # Direct fetch fails + mock_device.shell.side_effect = [ + Exception("logcat failed"), # first call to direct fetch + "05-04 11:59:00.000 line old\n05-04 12:00:01.000 line new\n", # second call (fallback) + ] + + result = fetch_and_filter_logs(mock_context, lines=100, since_time="05-04 12:00:00.000") + + assert mock_device.shell.call_count == 2 + mock_device.shell.assert_any_call('logcat -v threadtime -t "05-04 12:00:00.000"') + mock_device.shell.assert_any_call("logcat -v threadtime -t 100") + + # Should filter out "line old" because it's before since_dt + assert "line old" not in result + assert "line new" in result + + +@patch("artemis.tools.mobile.log_utils.get_adb_device") +@patch("artemis.tools.mobile.log_utils.resolve_time") +def test_fetch_and_filter_logs_until_time( + mock_resolve_time, mock_get_adb_device, mock_context, mock_device +): + mock_get_adb_device.return_value = mock_device + current_year = datetime.now().year + until_dt = datetime(current_year, 5, 4, 12, 0, 0) + mock_resolve_time.side_effect = [ + (None, None), # for since_time + ("05-04 12:00:00.000", until_dt), # for until_time + ] + + mock_device.shell.return_value = "05-04 11:59:00.000 line old\n05-04 12:00:01.000 line new\n" + + result = fetch_and_filter_logs(mock_context, lines=100, until_time="05-04 12:00:00.000") + + mock_device.shell.assert_called_once_with("logcat -v threadtime -t 100") + assert "line old" in result + assert "line new" not in result diff --git a/tests/unit/tools/test_long_press_tool.py b/tests/unit/tools/test_long_press_tool.py new file mode 100644 index 0000000..86925d3 --- /dev/null +++ b/tests/unit/tools/test_long_press_tool.py @@ -0,0 +1,162 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.exec_tools import ( + LongPress, + LongPressArgs, + LongPressTool, + get_long_press_tool, + long_press, + long_press_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.screen_size = (1080, 2400) + driver.long_press = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_long_press_tool_subclass_and_registry(): + """Verify LongPressTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(LongPressTool, ArtemisTool) + assert issubclass(LongPress, ArtemisTool) + assert isinstance(long_press, ArtemisTool) + assert isinstance(long_press, LongPressTool) + + assert long_press.name == "long_press" + assert long_press.category == "action" + assert long_press.args_schema == LongPressArgs + + # Registry lookup + reg_tool = ToolRegistry.get("long_press") + assert reg_tool is not None + assert isinstance(reg_tool, LongPressTool) + + # GenAI FunctionDeclaration export + declaration = long_press.to_genai_declaration() + assert declaration.name == "long_press" + assert "target" in declaration.parameters.properties + assert "duration" in declaration.parameters.properties + + # Wrapper check + assert long_press_wrapper is not None + assert long_press_wrapper.tool_fn_getter == get_long_press_tool + + +@pytest.mark.asyncio +async def test_long_press_direct_execution_with_driver(mock_driver): + """Verify direct execution of LongPressTool with BaseDeviceDriver.""" + result = await long_press.execute(driver=mock_driver, target=[500, 500], duration=1500) + mock_driver.long_press.assert_called_once_with(540, 1200, duration_ms=1500) + assert "Long pressed at [500, 500] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_long_press_direct_execution_with_ctx(mock_ctx): + """Verify direct execution of LongPressTool with ArtemisContext.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + result = await long_press.execute(ctx=mock_ctx, target=[250, 750], duration=2000) + mock_controller_inst.tap_at.assert_called_once_with( + 270, 1800, long_press=True, long_press_duration=2000 + ) + assert "Long pressed at [250, 750] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_long_press_with_state_command(mock_ctx, mock_state): + """Verify LongPressTool returns Command when state is provided.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + cmd = await long_press.execute( + ctx=mock_ctx, + target=[100, 200], + duration=1200, + tool_call_id="call_lp_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_lp_1" + assert messages[0].status == "success" + assert "Long pressed at [100, 200]" in messages[0].content + + +@pytest.mark.asyncio +async def test_long_press_callable_execution(mock_driver): + """Verify invoking long_press directly as a callable.""" + result = await long_press(driver=mock_driver, target=[100, 100]) + assert "Long pressed at [100, 100] (normalized) successfully." in result + + +@pytest.mark.asyncio +async def test_long_press_invalid_target(mock_ctx): + """Verify error handling on invalid target coordinates.""" + result = await long_press.execute(ctx=mock_ctx, target=[]) + assert "Error during long press: Invalid target coordinates" in result + + +@pytest.mark.asyncio +async def test_get_long_press_tool_langchain_ainvoke(mock_ctx): + """Verify get_long_press_tool exports a LangChain tool that works with ainvoke.""" + lp_tool = get_long_press_tool(mock_ctx) + assert lp_tool.name == "long_press" + + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None)) + mock_controller_cls.return_value = mock_controller_inst + + result = await lp_tool.ainvoke({"target": [500, 500], "duration": 1500}) + assert "Long pressed at [500, 500] (normalized) successfully." in result diff --git a/tests/unit/tools/test_object_detection_tool.py b/tests/unit/tools/test_object_detection_tool.py new file mode 100644 index 0000000..acb2541 --- /dev/null +++ b/tests/unit/tools/test_object_detection_tool.py @@ -0,0 +1,186 @@ +# 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 ObjectDetectionTool and OperatorObjectDetectionTool.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.object_detection_tool import ( + ObjectDetection, + ObjectDetectionArgs, + ObjectDetectionTool, + ObjectDetectorTool, + OperatorObjectDetection, + OperatorObjectDetectionArgs, + OperatorObjectDetectionTool, + OperatorObjectDetectorTool, + get_object_detector_tool, + get_operator_object_detector_tool, + object_detection, + object_detection_wrapper, + operator_object_detection, + operator_object_detection_wrapper, +) +import pytest + + +def test_object_detection_tool_subclass_and_registry(): + """Verify ObjectDetectionTool and OperatorObjectDetectionTool subclass ArtemisTool and register.""" + assert issubclass(ObjectDetectionTool, ArtemisTool) + assert issubclass(ObjectDetection, ArtemisTool) + assert issubclass(ObjectDetectorTool, ArtemisTool) + assert issubclass(OperatorObjectDetectionTool, ArtemisTool) + assert issubclass(OperatorObjectDetectionTool, ObjectDetectionTool) + assert issubclass(OperatorObjectDetection, ArtemisTool) + assert issubclass(OperatorObjectDetectorTool, ArtemisTool) + + assert isinstance(object_detection, ArtemisTool) + assert isinstance(object_detection, ObjectDetectionTool) + assert isinstance(operator_object_detection, ArtemisTool) + assert isinstance(operator_object_detection, OperatorObjectDetectionTool) + + # Properties + assert object_detection.name == "object_detection" + assert object_detection.category == "perception" + assert object_detection.args_schema == ObjectDetectionArgs + + assert operator_object_detection.name == "object_detection" + assert operator_object_detection.category == "perception" + assert operator_object_detection.args_schema == OperatorObjectDetectionArgs + + # Registry lookup + reg_tool = ToolRegistry.get("object_detection") + assert reg_tool is not None + assert isinstance(reg_tool, ObjectDetectionTool) + + # GenAI FunctionDeclaration export + declaration = object_detection.to_genai_declaration() + assert declaration.name == "object_detection" + assert "image_path" in declaration.parameters.properties + assert "queries" in declaration.parameters.properties + + op_declaration = operator_object_detection.to_genai_declaration() + assert op_declaration.name == "object_detection" + assert "queries" in op_declaration.parameters.properties + + # Wrappers + assert object_detection_wrapper.tool_fn_getter == get_object_detector_tool + assert operator_object_detection_wrapper.tool_fn_getter == get_operator_object_detector_tool + + +@pytest.mark.asyncio +async def test_object_detection_direct_execution_success(): + """Verify direct async call to object_detection with image_path.""" + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + expected_result = { + "detected": [{"label": "button", "point": [100, 200]}], + "failed": [], + } + + with patch( + "artemis.tools.object_detection_tool._run_object_detection", + AsyncMock(return_value=expected_result), + ) as mock_run: + result = await object_detection( + ctx=mock_ctx, + image_path="/path/to/screenshot.jpg", + queries=["button"], + ) + + mock_run.assert_called_once() + assert json.loads(result) == expected_result + + +@pytest.mark.asyncio +async def test_operator_object_detection_direct_execution_with_state(): + """Verify operator_object_detection reads screenshot from state.""" + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/path/to/state_screen.jpg" + mock_state.asanitize_update = None # Non-langgraph direct call + + expected_result = { + "detected": [{"label": "search bar", "point": [500, 100]}], + "failed": [], + } + + with patch( + "artemis.tools.object_detection_tool._run_object_detection", + AsyncMock(return_value=expected_result), + ) as mock_run: + result = await operator_object_detection( + ctx=mock_ctx, + state=mock_state, + queries=["search bar"], + ) + + mock_run.assert_called_once() + # Verify it used the latest_screenshot from state + call_args = mock_run.call_args[0] + assert call_args[1] == "/path/to/state_screen.jpg" + assert json.loads(result) == expected_result + + +@pytest.mark.asyncio +async def test_object_detection_no_image_error(): + """Verify error when no image path is given and no state screenshot is available.""" + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + result = await object_detection( + ctx=mock_ctx, + image_path=None, + queries=["icon"], + ) + assert "Object Detection failed" in result + assert "No image path provided" in result + + +@pytest.mark.asyncio +async def test_object_detection_exception_handling(): + """Verify graceful handling when _run_object_detection raises an exception.""" + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = None + + with patch( + "artemis.tools.object_detection_tool._run_object_detection", + AsyncMock(side_effect=RuntimeError("Model inference timeout")), + ): + result = await object_detection( + ctx=mock_ctx, + image_path="/path/to/screen.jpg", + queries=["icon"], + ) + assert "Object Detection failed: Model inference timeout" in result + + +def test_get_langchain_tools(): + """Verify LangChain BaseTool exports.""" + mock_ctx = MagicMock(spec=ArtemisContext) + tool = get_object_detector_tool(mock_ctx) + op_tool = get_operator_object_detector_tool(mock_ctx) + + assert tool.name == "object_detection" + assert tool.args_schema == ObjectDetectionArgs + + assert op_tool.name == "object_detection" + assert op_tool.args_schema == OperatorObjectDetectionArgs diff --git a/tests/unit/tools/test_object_detection_trace.py b/tests/unit/tools/test_object_detection_trace.py new file mode 100644 index 0000000..8c73030 --- /dev/null +++ b/tests/unit/tools/test_object_detection_trace.py @@ -0,0 +1,70 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.object_detection_tool import get_operator_object_detector_tool +import pytest + + +@pytest.mark.asyncio +async def test_object_detection_trace(): + mock_ctx = MagicMock(spec=ArtemisContext) + mock_ctx.data_engine = MagicMock() + + mock_state = MagicMock(spec=State) + mock_state.latest_screenshot = "/path/to/screenshot.jpg" + mock_state.asanitize_update = AsyncMock(return_value={"status": "success"}) + + # We need to mock the decorator in object_detection_tool.py if we want to avoid it calling real get_llm + # Or we can just mock get_llm. + # In the tool definition: @trace(type="tool", ctx=ctx) + # This calls record_trace immediately on entry/exit. + + mock_tool = get_operator_object_detector_tool(mock_ctx) + + # Mock the internal function called by the tool + with patch( + "artemis.tools.object_detection_tool._run_object_detection", + AsyncMock(return_value="{}"), + ) as mock_run: + # We need to handle the fact that ainvoke might fail if LangChain internals are not fully mocked. + # But let's try it. + + await mock_tool.ainvoke( + { + "type": "tool_call", + "name": "object_detection", + "id": "call_123", + "args": {"queries": ["test_label"], "state": mock_state}, + } + ) + + # Verify _run_object_detection was called + mock_run.assert_called_once() + + # Verify record_trace was called for the span + assert mock_ctx.data_engine.record_trace.call_count >= 1 + + calls = mock_ctx.data_engine.record_trace.call_args_list + span_called = False + for call in calls: + kwargs = call.kwargs + if kwargs.get("type") == "span" and kwargs.get("name") == "run_object_detection": + span_called = True + break + + assert span_called, "TraceSpan 'run_object_detection' was not called" diff --git a/tests/unit/tools/test_ocr_tool.py b/tests/unit/tools/test_ocr_tool.py new file mode 100644 index 0000000..480dbe8 --- /dev/null +++ b/tests/unit/tools/test_ocr_tool.py @@ -0,0 +1,238 @@ +# 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 base64 +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.ocr import ( + OCRRecognition, + OcrArgs, + OcrRecognition, + OcrRecognitionTool, + OcrTool, + get_ocr_tool, + ocr_recognition, + ocr_recognition_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.screen_size = (1080, 2400) + screen_data = MagicMock() + screen_data.screenshot_base64 = base64.b64encode(b"dummy_image_data").decode("utf-8") + screen_data.screenshot_bytes = b"dummy_image_data" + driver.get_screen_data = AsyncMock(return_value=screen_data) + return driver + + +@pytest.fixture +def mock_state(tmp_path): + screenshot_file = tmp_path / "test_screenshot.png" + screenshot_file.write_bytes(b"dummy_image_content") + + state = MagicMock(spec=State) + state.latest_screenshot = str(screenshot_file) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_ocr_tool_subclass_and_registry(): + """Verify OcrRecognitionTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(OcrRecognitionTool, ArtemisTool) + assert issubclass(OcrRecognition, ArtemisTool) + assert issubclass(OCRRecognition, ArtemisTool) + assert issubclass(OcrTool, ArtemisTool) + assert isinstance(ocr_recognition, ArtemisTool) + assert isinstance(ocr_recognition, OcrRecognitionTool) + + assert ocr_recognition.name == "ocr_recognition" + assert ocr_recognition.category == "explorer" + assert ocr_recognition.args_schema == OcrArgs + + # Registry lookup + reg_tool = ToolRegistry.get("ocr_recognition") + assert reg_tool is not None + assert isinstance(reg_tool, OcrRecognitionTool) + + # GenAI FunctionDeclaration export + declaration = ocr_recognition.to_genai_declaration() + assert declaration.name == "ocr_recognition" + + # Wrapper check + assert ocr_recognition_wrapper is not None + assert ocr_recognition_wrapper.tool_fn_getter == get_ocr_tool + + +class SimpleState: + def __init__(self, screenshot_path: str): + self.latest_screenshot = screenshot_path + + +@pytest.mark.asyncio +async def test_ocr_direct_execution_with_state(mock_ctx, tmp_path): + """Verify direct execution with state and screenshot file.""" + screenshot_file = tmp_path / "test_screenshot.png" + screenshot_file.write_bytes(b"dummy_image_content") + simple_state = SimpleState(str(screenshot_file)) + + mock_results = [ + { + "text": "Submit", + "position": [ + {"x": 100, "y": 200}, + {"x": 200, "y": 200}, + {"x": 200, "y": 300}, + {"x": 100, "y": 300}, + ], + } + ] + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=mock_results, + ): + result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state) + assert "OCR Recognition successful." in result + assert "Submit" in result + + +@pytest.mark.asyncio +async def test_ocr_direct_execution_with_driver(mock_ctx, mock_driver): + """Verify direct execution with driver.""" + mock_results = [ + { + "text": "Settings", + "position": [ + {"x": 500, "y": 500}, + {"x": 600, "y": 600}, + ], + } + ] + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=mock_results, + ): + result = await ocr_recognition.execute(ctx=mock_ctx, driver=mock_driver) + assert "OCR Recognition successful." in result + assert "Settings" in result + + +@pytest.mark.asyncio +async def test_ocr_no_text_detected(mock_ctx, tmp_path): + """Verify outcome when no text is detected.""" + screenshot_file = tmp_path / "test_empty_screenshot.png" + screenshot_file.write_bytes(b"dummy_empty_content") + simple_state = SimpleState(str(screenshot_file)) + + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=[], + ): + result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state) + assert "No text detected on the screen." in result + + +@pytest.mark.asyncio +async def test_ocr_with_state_command(mock_ctx, mock_state): + """Verify OcrRecognitionTool returns Command when state is provided.""" + mock_results = [{"text": "Hello", "position": [{"x": 100, "y": 100}]}] + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=mock_results, + ): + cmd = await ocr_recognition.execute( + ctx=mock_ctx, + tool_call_id="call_ocr_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_ocr_1" + assert messages[0].status == "success" + assert "Hello" in messages[0].content + + +@pytest.mark.asyncio +async def test_ocr_failure_missing_screenshot(mock_ctx): + """Verify error handling when screenshot path is missing.""" + empty_state = MagicMock(spec=State) + empty_state.latest_screenshot = None + empty_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + cmd = await ocr_recognition.execute( + ctx=mock_ctx, + tool_call_id="call_ocr_err", + state=empty_state, + ) + assert isinstance(cmd, Command) + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert messages[0].status == "error" + assert "OCR Recognition failed" in messages[0].content + + +@pytest.mark.asyncio +async def test_ocr_callable_execution(mock_ctx, mock_driver): + """Verify invoking ocr_recognition directly as a callable.""" + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=[{"text": "Callable", "position": []}], + ): + result = await ocr_recognition(ctx=mock_ctx, driver=mock_driver) + assert "OCR Recognition successful." in result + assert "Callable" in result + + +@pytest.mark.asyncio +async def test_get_ocr_tool_langchain_ainvoke(mock_ctx, mock_driver): + """Verify get_ocr_tool exports a LangChain tool that works with ainvoke.""" + mock_ctx._active_driver = mock_driver + ocr_tool = get_ocr_tool(mock_ctx) + assert ocr_tool.name == "ocr_recognition" + + with patch( + "artemis.tools.mobile.ocr.perform_ocr", + new_callable=AsyncMock, + return_value=[{"text": "LangChain", "position": []}], + ): + result = await ocr_tool.ainvoke({}) + assert "OCR Recognition successful." in result + assert "LangChain" in result diff --git a/tests/unit/tools/test_press_key_tool.py b/tests/unit/tools/test_press_key_tool.py new file mode 100644 index 0000000..2eba60c --- /dev/null +++ b/tests/unit/tools/test_press_key_tool.py @@ -0,0 +1,174 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.exec_tools import ( + PressKey, + PressKeyArgs, + PressKeyTool, + get_press_key_tool, + press_key, + press_key_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.press_key = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_press_key_tool_subclass_and_registry(): + """Verify PressKeyTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(PressKeyTool, ArtemisTool) + assert issubclass(PressKey, ArtemisTool) + assert isinstance(press_key, ArtemisTool) + assert isinstance(press_key, PressKeyTool) + + assert press_key.name == "press_key" + assert press_key.category == "action" + assert press_key.args_schema == PressKeyArgs + + # Registry lookup + reg_tool = ToolRegistry.get("press_key") + assert reg_tool is not None + assert isinstance(reg_tool, PressKeyTool) + + # GenAI FunctionDeclaration export + declaration = press_key.to_genai_declaration() + assert declaration.name == "press_key" + assert "key" in declaration.parameters.properties + + # Wrapper check + assert press_key_wrapper is not None + assert press_key_wrapper.tool_fn_getter == get_press_key_tool + + +@pytest.mark.asyncio +async def test_press_key_direct_execution_with_driver(mock_driver): + """Verify direct execution with BaseDeviceDriver.""" + result = await press_key.execute(driver=mock_driver, key="BACK") + mock_driver.press_key.assert_called_once_with("BACK") + assert "Pressed key 'BACK' successfully." in result + + +@pytest.mark.asyncio +async def test_press_key_direct_execution_with_ctx(mock_ctx): + """Verify direct execution with ArtemisContext for various keys.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.press_enter = AsyncMock(return_value=True) + mock_controller_inst.go_back = AsyncMock(return_value=True) + mock_controller_inst.go_home = AsyncMock(return_value=True) + mock_controller_inst.press_key = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + # Test ENTER + res_enter = await press_key.execute(ctx=mock_ctx, key="ENTER") + mock_controller_inst.press_enter.assert_called_once() + assert "Pressed key 'ENTER' successfully." in res_enter + + # Test BACK + res_back = await press_key.execute(ctx=mock_ctx, key="BACK") + mock_controller_inst.go_back.assert_called_once() + assert "Pressed key 'BACK' successfully." in res_back + + # Test HOME + res_home = await press_key.execute(ctx=mock_ctx, key="HOME") + mock_controller_inst.go_home.assert_called_once() + assert "Pressed key 'HOME' successfully." in res_home + + # Test APP_SWITCH + res_app_switch = await press_key.execute(ctx=mock_ctx, key="APP_SWITCH") + mock_controller_inst.press_key.assert_called_with("KEYCODE_APP_SWITCH") + assert "Pressed key 'APP_SWITCH' successfully." in res_app_switch + + +@pytest.mark.asyncio +async def test_press_key_with_state_command(mock_ctx, mock_state): + """Verify PressKeyTool returns Command when state is provided.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.go_home = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + cmd = await press_key.execute( + ctx=mock_ctx, + key="HOME", + tool_call_id="call_pk_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_pk_1" + assert messages[0].status == "success" + assert "Pressed key 'HOME' successfully." in messages[0].content + + +@pytest.mark.asyncio +async def test_press_key_callable_execution(mock_driver): + """Verify invoking press_key directly as a callable.""" + result = await press_key(driver=mock_driver, key="ENTER") + assert "Pressed key 'ENTER' successfully." in result + + +@pytest.mark.asyncio +async def test_press_key_missing_key(mock_ctx): + """Verify error handling when key is empty or missing.""" + result = await press_key.execute(ctx=mock_ctx, key="") + assert "Error during press key: Key parameter is required." in result + + +@pytest.mark.asyncio +async def test_get_press_key_tool_langchain_ainvoke(mock_ctx): + """Verify get_press_key_tool exports a LangChain tool that works with ainvoke.""" + pk_tool = get_press_key_tool(mock_ctx) + assert pk_tool.name == "press_key" + + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.go_back = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller_inst + + result = await pk_tool.ainvoke({"key": "BACK"}) + assert "Pressed key 'BACK' successfully." in result diff --git a/tests/unit/tools/test_read_hierarchy_tool.py b/tests/unit/tools/test_read_hierarchy_tool.py new file mode 100644 index 0000000..9ee542a --- /dev/null +++ b/tests/unit/tools/test_read_hierarchy_tool.py @@ -0,0 +1,145 @@ +# 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 AsyncMock, MagicMock + +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.read_hierarchy import ( + GetUIHierarchy, + GetUiHierarchy, + GetUiHierarchyArgs, + GetUiHierarchyTool, + get_ui_hierarchy, + get_ui_hierarchy_tool, + ui_hierarchy_wrapper, +) +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.ui_adb_client = MagicMock() + ctx.ui_adb_client.get_hierarchy.return_value = "" + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + screen_data = MagicMock() + screen_data.ui_hierarchy_xml = "" + driver.get_screen_data = AsyncMock(return_value=screen_data) + return driver + + +def test_get_ui_hierarchy_tool_subclass_and_registry(): + """Verify GetUiHierarchyTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(GetUiHierarchyTool, ArtemisTool) + assert issubclass(GetUiHierarchy, ArtemisTool) + assert issubclass(GetUIHierarchy, ArtemisTool) + assert isinstance(get_ui_hierarchy, ArtemisTool) + assert isinstance(get_ui_hierarchy, GetUiHierarchyTool) + + assert get_ui_hierarchy.name == "get_ui_hierarchy" + assert get_ui_hierarchy.category == "diagnostic" + assert get_ui_hierarchy.args_schema == GetUiHierarchyArgs + + # Registry lookup + reg_tool = ToolRegistry.get("get_ui_hierarchy") + assert reg_tool is not None + assert isinstance(reg_tool, GetUiHierarchyTool) + + # GenAI FunctionDeclaration export + declaration = get_ui_hierarchy.to_genai_declaration() + assert declaration.name == "get_ui_hierarchy" + + # Wrapper check + assert ui_hierarchy_wrapper is not None + assert ui_hierarchy_wrapper.tool_fn_getter == get_ui_hierarchy_tool + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_direct_execution_with_ctx(mock_ctx): + """Verify direct execution with ArtemisContext.""" + result = await get_ui_hierarchy.execute(ctx=mock_ctx) + mock_ctx.ui_adb_client.get_hierarchy.assert_called_once() + assert "" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_direct_execution_with_driver_screen_data(mock_driver): + """Verify direct execution with driver's get_screen_data.""" + result = await get_ui_hierarchy.execute(driver=mock_driver) + mock_driver.get_screen_data.assert_called_once() + assert "" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_direct_execution_with_driver_get_hierarchy(): + """Verify direct execution with driver's get_ui_hierarchy.""" + driver = MagicMock(spec=BaseDeviceDriver) + del driver.get_screen_data + driver.get_ui_hierarchy = AsyncMock( + return_value="" + ) + result = await get_ui_hierarchy.execute(driver=driver) + driver.get_ui_hierarchy.assert_called_once() + assert "" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_with_state(mock_ctx): + """Verify GetUiHierarchyTool returns string when state is provided.""" + state = MagicMock() + result = await get_ui_hierarchy.execute( + ctx=mock_ctx, + tool_call_id="call_hier_1", + state=state, + ) + assert "" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_no_client_or_driver(): + """Verify error handling when no client or driver is provided.""" + result = await get_ui_hierarchy.execute() + assert "Error retrieving UI hierarchy: No UI Automator client or driver provided." in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_exception_handling(mock_ctx): + """Verify error handling when ui_adb_client raises an exception.""" + mock_ctx.ui_adb_client.get_hierarchy.side_effect = RuntimeError("ADB server died") + result = await get_ui_hierarchy.execute(ctx=mock_ctx) + assert "Error retrieving UI hierarchy: ADB server died" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_callable_execution(mock_ctx): + """Verify invoking get_ui_hierarchy directly as a callable.""" + result = await get_ui_hierarchy(ctx=mock_ctx) + assert "" in result + + +@pytest.mark.asyncio +async def test_get_ui_hierarchy_tool_langchain_ainvoke(mock_ctx): + """Verify get_ui_hierarchy_tool exports a LangChain tool that works with ainvoke.""" + tool = get_ui_hierarchy_tool(mock_ctx) + assert tool.name == "get_ui_hierarchy" + + result = await tool.ainvoke({}) + assert "" in result diff --git a/tests/unit/tools/test_read_logs_tool.py b/tests/unit/tools/test_read_logs_tool.py new file mode 100644 index 0000000..5e14340 --- /dev/null +++ b/tests/unit/tools/test_read_logs_tool.py @@ -0,0 +1,140 @@ +# 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, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.read_logs import ( + ReadLogs, + ReadLogsArgs, + ReadLogsTool, + ReadLogsToolAlias, + get_read_logs_tool, + read_logs, +) +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + return ctx + + +def test_read_logs_tool_subclass_and_registry(): + """Verify ReadLogsTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ReadLogsTool, ArtemisTool) + assert issubclass(ReadLogs, ArtemisTool) + assert issubclass(ReadLogsToolAlias, ArtemisTool) + assert isinstance(read_logs, ArtemisTool) + assert isinstance(read_logs, ReadLogsTool) + + assert read_logs.name == "read_logs" + assert read_logs.category == "diagnostic" + assert read_logs.args_schema == ReadLogsArgs + + # Registry lookup + reg_tool = ToolRegistry.get("read_logs") + assert reg_tool is not None + assert isinstance(reg_tool, ReadLogsTool) + + # GenAI FunctionDeclaration export + declaration = read_logs.to_genai_declaration() + assert declaration.name == "read_logs" + assert "lines" in declaration.parameters.properties + assert "since_time" in declaration.parameters.properties + assert "until_time" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_read_logs_direct_execution(mock_ctx): + """Verify direct execution of ReadLogsTool.""" + mock_log_output = "08-12 10:00:00.000 1000 1000 I Test: Hello Logs\n" + with patch( + "artemis.tools.mobile.read_logs.fetch_and_filter_logs", + return_value=mock_log_output, + ) as mock_fetch: + result = await read_logs.execute( + ctx=mock_ctx, + lines=100, + since_time="08-12 09:59:00", + until_time="08-12 10:01:00", + ) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=100, + since_time="08-12 09:59:00", + until_time="08-12 10:01:00", + ) + assert result == mock_log_output + + +@pytest.mark.asyncio +async def test_read_logs_default_lines(mock_ctx): + """Verify read_logs defaults lines to 200 when None.""" + with patch( + "artemis.tools.mobile.read_logs.fetch_and_filter_logs", + return_value="logs", + ) as mock_fetch: + result = await read_logs.execute(ctx=mock_ctx, lines=None) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=200, + since_time=None, + until_time=None, + ) + assert result == "logs" + + +@pytest.mark.asyncio +async def test_read_logs_exception_handling(mock_ctx): + """Verify error handling when fetching logs raises an exception.""" + with patch( + "artemis.tools.mobile.read_logs.fetch_and_filter_logs", + side_effect=RuntimeError("Device disconnected"), + ): + result = await read_logs.execute(ctx=mock_ctx) + assert "Failed to read logs: Device disconnected" in result + + +@pytest.mark.asyncio +async def test_read_logs_callable_execution(mock_ctx): + """Verify invoking read_logs directly as a callable.""" + with patch( + "artemis.tools.mobile.read_logs.fetch_and_filter_logs", + return_value="sample log stream", + ): + result = await read_logs(ctx=mock_ctx, lines=50) + assert result == "sample log stream" + + +@pytest.mark.asyncio +async def test_get_read_logs_tool_langchain_ainvoke(mock_ctx): + """Verify get_read_logs_tool exports a LangChain tool that works with ainvoke.""" + tool = get_read_logs_tool(mock_ctx) + assert tool.name == "read_logs" + + with patch( + "artemis.tools.mobile.read_logs.fetch_and_filter_logs", + return_value="langchain log content", + ) as mock_fetch: + result = await tool.ainvoke({"lines": 300}) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=300, + since_time=None, + until_time=None, + ) + assert result == "langchain log content" diff --git a/tests/unit/tools/test_read_note_pure_tool.py b/tests/unit/tools/test_read_note_pure_tool.py new file mode 100644 index 0000000..f8bb661 --- /dev/null +++ b/tests/unit/tools/test_read_note_pure_tool.py @@ -0,0 +1,127 @@ +# 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, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + ReadNoteArgs, + ReadNotePure, + ReadNotePureTool, + get_read_note_tool_pure, + read_note_pure, +) +from artemis.utils.notes import save_note_content +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_read_note_pure_tool_subclass_and_registry(): + """Verify ReadNotePureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ReadNotePureTool, ArtemisTool) + assert issubclass(ReadNotePure, ArtemisTool) + assert isinstance(read_note_pure, ArtemisTool) + assert isinstance(read_note_pure, ReadNotePureTool) + + assert read_note_pure.name == "read_note_pure" + assert read_note_pure.category == "memory" + assert read_note_pure.args_schema == ReadNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("read_note_pure") + assert reg_tool is not None + assert isinstance(reg_tool, ReadNotePureTool) + + # GenAI FunctionDeclaration export + declaration = read_note_pure.to_genai_declaration() + assert declaration.name == "read_note_pure" + assert "key" in declaration.parameters.properties + assert "start_line" in declaration.parameters.properties + assert "end_line" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_read_note_pure_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of ReadNotePureTool.execute reads saved note.""" + save_note_content(tmp_path, "sample_pure", "line 1\nline 2\nline 3") + + result = await read_note_pure.execute(ctx=mock_ctx, key="sample_pure") + assert "Successfully read note 'sample_pure'." in result + assert "line 1\nline 2\nline 3" in result + + +@pytest.mark.asyncio +async def test_read_note_pure_slice(mock_ctx, tmp_path): + """Verify reading a line slice with start_line and end_line via ReadNotePureTool.""" + save_note_content(tmp_path, "slice_pure", "line 1\nline 2\nline 3\nline 4") + + result = await read_note_pure.execute( + ctx=mock_ctx, + key="slice_pure", + start_line=2, + end_line=3, + ) + assert "Successfully read note 'slice_pure' (lines 2 to 3)." in result + assert "line 1" not in result + assert "line 2\nline 3" in result + assert "line 4" not in result + + +@pytest.mark.asyncio +async def test_read_note_pure_callable_execution(mock_ctx, tmp_path): + """Verify invoking read_note_pure directly as a callable.""" + save_note_content(tmp_path, "callable_pure", "hello from pure callable") + + result = await read_note_pure(ctx=mock_ctx, key="callable_pure") + assert "Successfully read note 'callable_pure'." in result + assert "hello from pure callable" in result + + +@pytest.mark.asyncio +async def test_read_note_pure_not_found(mock_ctx): + """Verify error handling when note is missing.""" + result = await read_note_pure.execute(ctx=mock_ctx, key="nonexistent_pure") + assert "Note 'nonexistent_pure' not found" in result + + +@pytest.mark.asyncio +async def test_read_note_pure_other_error(mock_ctx): + """Verify error handling when read_note_content raises an exception.""" + with patch( + "artemis.tools.scratchpad.read_note_content", + side_effect=OSError("Disk error"), + ): + result = await read_note_pure.execute(ctx=mock_ctx, key="err_note") + assert "Failed to read note 'err_note': Disk error" in result + + +@pytest.mark.asyncio +async def test_get_read_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_read_note_tool_pure exports a LangChain tool named 'read_note'.""" + save_note_content(tmp_path, "pure_export", "exported pure note") + + pure_tool = get_read_note_tool_pure(mock_ctx) + assert pure_tool.name == "read_note" + + result = await pure_tool.ainvoke({"key": "pure_export"}) + assert "Successfully read note 'pure_export'." in result + assert "exported pure note" in result diff --git a/tests/unit/tools/test_read_note_tool.py b/tests/unit/tools/test_read_note_tool.py new file mode 100644 index 0000000..537aa27 --- /dev/null +++ b/tests/unit/tools/test_read_note_tool.py @@ -0,0 +1,177 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + ReadNote, + ReadNoteArgs, + ReadNoteTool, + get_read_note_tool, + get_read_note_tool_pure, + read_note, + read_note_wrapper, +) +from artemis.utils.notes import save_note_content +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_read_note_tool_subclass_and_registry(): + """Verify ReadNoteTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(ReadNoteTool, ArtemisTool) + assert issubclass(ReadNote, ArtemisTool) + assert isinstance(read_note, ArtemisTool) + assert isinstance(read_note, ReadNoteTool) + + assert read_note.name == "read_note" + assert read_note.category == "memory" + assert read_note.args_schema == ReadNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("read_note") + assert reg_tool is not None + assert isinstance(reg_tool, ReadNoteTool) + + # GenAI FunctionDeclaration export + declaration = read_note.to_genai_declaration() + assert declaration.name == "read_note" + assert "key" in declaration.parameters.properties + assert "start_line" in declaration.parameters.properties + assert "end_line" in declaration.parameters.properties + + # Wrapper check + assert read_note_wrapper is not None + assert read_note_wrapper.tool_fn_getter == get_read_note_tool + + +@pytest.mark.asyncio +async def test_read_note_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of ReadNoteTool.execute reads saved note.""" + save_note_content(tmp_path, "sample", "line 1\nline 2\nline 3\nline 4\nline 5") + + result = await read_note.execute(ctx=mock_ctx, key="sample") + assert "Successfully read note 'sample'." in result + assert "line 1\nline 2\nline 3\nline 4\nline 5" in result + + +@pytest.mark.asyncio +async def test_read_note_slice(mock_ctx, tmp_path): + """Verify reading a line slice with start_line and end_line.""" + save_note_content(tmp_path, "sample", "line 1\nline 2\nline 3\nline 4\nline 5") + + result = await read_note.execute( + ctx=mock_ctx, + key="sample", + start_line=2, + end_line=4, + ) + assert "Successfully read note 'sample' (lines 2 to 4)." in result + assert "line 1" not in result + assert "line 2\nline 3\nline 4" in result + assert "line 5" not in result + + +@pytest.mark.asyncio +async def test_read_note_callable_execution(mock_ctx, tmp_path): + """Verify invoking read_note directly as a callable.""" + save_note_content(tmp_path, "callable_sample", "hello from callable") + + result = await read_note(ctx=mock_ctx, key="callable_sample") + assert "Successfully read note 'callable_sample'." in result + assert "hello from callable" in result + + +@pytest.mark.asyncio +async def test_read_note_with_state_command(mock_ctx, tmp_path): + """Verify ReadNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY.""" + save_note_content(tmp_path, "state_sample", "state note content") + + mock_state = MagicMock(spec=State) + mock_state.current_agent = "operator" + mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + result = await read_note.execute( + ctx=mock_ctx, + key="state_sample", + state=mock_state, + tool_call_id="call_read_999", + ) + + assert isinstance(result, Command) + assert VALIDATOR_MESSAGES_KEY in result.update + tool_messages = result.update[VALIDATOR_MESSAGES_KEY] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_read_999" + assert "Successfully read note 'state_sample'." in tool_messages[0].content + assert "state note content" in tool_messages[0].content + assert tool_messages[0].status == "success" + + mock_state.asanitize_update.assert_called_once() + + +@pytest.mark.asyncio +async def test_read_note_not_found(mock_ctx): + """Verify error handling when a note is not found.""" + result = await read_note.execute(ctx=mock_ctx, key="nonexistent") + assert "Note 'nonexistent' not found" in result + + +@pytest.mark.asyncio +async def test_read_note_other_error(mock_ctx): + """Verify error handling for unexpected exceptions.""" + with patch( + "artemis.tools.scratchpad.read_note_content", + side_effect=ValueError("Corrupted data"), + ): + result = await read_note.execute(ctx=mock_ctx, key="bad_note") + assert "Failed to read note 'bad_note': Corrupted data" in result + + +@pytest.mark.asyncio +async def test_get_read_note_tool_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_read_note_tool exports a LangChain tool that works with ainvoke.""" + save_note_content(tmp_path, "lc_sample", "langchain content") + + lc_tool = get_read_note_tool(mock_ctx) + assert lc_tool.name == "read_note" + + result = await lc_tool.ainvoke({"key": "lc_sample"}) + assert "Successfully read note 'lc_sample'." in result + assert "langchain content" in result + + +@pytest.mark.asyncio +async def test_get_read_note_tool_pure(mock_ctx, tmp_path): + """Verify get_read_note_tool_pure returns a pure LangChain tool.""" + save_note_content(tmp_path, "pure_sample", "pure content") + + pure_tool = get_read_note_tool_pure(mock_ctx) + assert pure_tool.name == "read_note" + + result = await pure_tool.ainvoke({"key": "pure_sample"}) + assert "Successfully read note 'pure_sample'." in result + assert "pure content" in result diff --git a/tests/unit/tools/test_save_note_pure_tool.py b/tests/unit/tools/test_save_note_pure_tool.py new file mode 100644 index 0000000..1a91c62 --- /dev/null +++ b/tests/unit/tools/test_save_note_pure_tool.py @@ -0,0 +1,122 @@ +# 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 pathlib import Path +from unittest.mock import MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + SaveNoteArgs, + SaveNotePure, + SaveNotePureTool, + get_save_note_tool_pure, + save_note_pure, +) +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_save_note_pure_tool_subclass_and_registry(): + """Verify SaveNotePureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(SaveNotePureTool, ArtemisTool) + assert issubclass(SaveNotePure, ArtemisTool) + assert isinstance(save_note_pure, ArtemisTool) + assert isinstance(save_note_pure, SaveNotePureTool) + + assert save_note_pure.name == "save_note_pure" + assert save_note_pure.category == "memory" + assert save_note_pure.args_schema == SaveNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("save_note_pure") + assert reg_tool is not None + assert isinstance(reg_tool, SaveNotePureTool) + + # GenAI FunctionDeclaration export + declaration = save_note_pure.to_genai_declaration() + assert declaration.name == "save_note_pure" + assert "key" in declaration.parameters.properties + assert "content" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_save_note_pure_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of SaveNotePureTool.execute creates note file.""" + result = await save_note_pure.execute( + ctx=mock_ctx, + key="test_pure_save", + content="Pure saved content", + ) + assert result == "Successfully saved note to test_pure_save.md." + + note_path = Path(tmp_path) / "notes" / "test_pure_save.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Pure saved content" + + +@pytest.mark.asyncio +async def test_save_note_pure_callable_execution(mock_ctx, tmp_path): + """Verify invoking save_note_pure directly as a callable.""" + result = await save_note_pure( + ctx=mock_ctx, + key="callable_pure_save", + content="Callable pure content", + ) + assert result == "Successfully saved note to callable_pure_save.md." + + note_path = Path(tmp_path) / "notes" / "callable_pure_save.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Callable pure content" + + +@pytest.mark.asyncio +async def test_save_note_pure_execution_failure(mock_ctx): + """Verify error handling when save_note_content fails.""" + with patch( + "artemis.tools.scratchpad.save_note_content", + side_effect=PermissionError("Permission denied"), + ): + result = await save_note_pure.execute( + ctx=mock_ctx, + key="fail_save", + content="Some content", + ) + assert "Failed to save note fail_save.md: Permission denied" in result + + +@pytest.mark.asyncio +async def test_get_save_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_save_note_tool_pure exports a LangChain tool named 'save_note'.""" + pure_tool = get_save_note_tool_pure(mock_ctx) + assert pure_tool.name == "save_note" + + result = await pure_tool.ainvoke( + { + "key": "lc_pure_save", + "content": "Saved via LangChain pure tool", + } + ) + assert result == "Successfully saved note to lc_pure_save.md." + + note_path = Path(tmp_path) / "notes" / "lc_pure_save.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Saved via LangChain pure tool" diff --git a/tests/unit/tools/test_save_note_tool.py b/tests/unit/tools/test_save_note_tool.py new file mode 100644 index 0000000..ed2a33e --- /dev/null +++ b/tests/unit/tools/test_save_note_tool.py @@ -0,0 +1,180 @@ +# 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 pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + SaveNote, + SaveNoteArgs, + SaveNoteTool, + get_save_note_tool, + get_save_note_tool_pure, + save_note, + save_note_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_save_note_tool_subclass_and_registry(): + """Verify SaveNoteTool is a subclass of ArtemisTool and properly registered in ToolRegistry.""" + assert issubclass(SaveNoteTool, ArtemisTool) + assert issubclass(SaveNote, ArtemisTool) + assert isinstance(save_note, ArtemisTool) + assert isinstance(save_note, SaveNoteTool) + + assert save_note.name == "save_note" + assert save_note.category == "memory" + assert save_note.args_schema == SaveNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("save_note") + assert reg_tool is not None + assert isinstance(reg_tool, SaveNoteTool) + + # GenAI FunctionDeclaration export + declaration = save_note.to_genai_declaration() + assert declaration.name == "save_note" + assert "key" in declaration.parameters.properties + assert "content" in declaration.parameters.properties + + # Wrapper check + assert save_note_wrapper is not None + assert save_note_wrapper.tool_fn_getter == get_save_note_tool + + +@pytest.mark.asyncio +async def test_save_note_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of SaveNoteTool.execute creates note file.""" + result = await save_note.execute( + ctx=mock_ctx, + key="test_note", + content="Hello Artemis memory", + ) + assert result == "Successfully saved note to test_note.md." + + note_path = Path(tmp_path) / "notes" / "test_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Hello Artemis memory" + + +@pytest.mark.asyncio +async def test_save_note_callable_execution(mock_ctx, tmp_path): + """Verify invoking save_note directly as a callable.""" + result = await save_note( + ctx=mock_ctx, + key="callable_note", + content="Callable invocation test", + ) + assert result == "Successfully saved note to callable_note.md." + + note_path = Path(tmp_path) / "notes" / "callable_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Callable invocation test" + + +@pytest.mark.asyncio +async def test_save_note_with_state_command(mock_ctx, tmp_path): + """Verify SaveNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY.""" + mock_state = MagicMock(spec=State) + mock_state.current_agent = "operator" + mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + result = await save_note.execute( + ctx=mock_ctx, + key="state_note", + content="Note with state", + state=mock_state, + tool_call_id="call_save_999", + ) + + assert isinstance(result, Command) + assert VALIDATOR_MESSAGES_KEY in result.update + tool_messages = result.update[VALIDATOR_MESSAGES_KEY] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_save_999" + assert tool_messages[0].content == "Successfully saved note to state_note.md." + assert tool_messages[0].status == "success" + + mock_state.asanitize_update.assert_called_once() + + note_path = Path(tmp_path) / "notes" / "state_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Note with state" + + +@pytest.mark.asyncio +async def test_save_note_execution_failure(mock_ctx): + """Verify error handling when saving note fails.""" + with patch( + "artemis.tools.scratchpad.save_note_content", + side_effect=PermissionError("Permission denied"), + ): + result = await save_note.execute( + ctx=mock_ctx, + key="fail_note", + content="Fail content", + ) + assert "Failed to save note fail_note.md: Permission denied" in result + + +@pytest.mark.asyncio +async def test_get_save_note_tool_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_save_note_tool exports a LangChain tool that works with ainvoke.""" + lc_tool = get_save_note_tool(mock_ctx) + assert lc_tool.name == "save_note" + + result = await lc_tool.ainvoke( + { + "key": "lc_note", + "content": "Saved via LangChain BaseTool", + } + ) + assert result == "Successfully saved note to lc_note.md." + + note_path = Path(tmp_path) / "notes" / "lc_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Saved via LangChain BaseTool" + + +@pytest.mark.asyncio +async def test_get_save_note_tool_pure(mock_ctx, tmp_path): + """Verify get_save_note_tool_pure returns a pure LangChain tool.""" + pure_tool = get_save_note_tool_pure(mock_ctx) + assert pure_tool.name == "save_note" + + result = await pure_tool.ainvoke( + { + "key": "pure_note", + "content": "Saved via pure tool", + } + ) + assert result == "Successfully saved note to pure_note.md." + + note_path = Path(tmp_path) / "notes" / "pure_note.md" + assert note_path.exists() + assert note_path.read_text(encoding="utf-8") == "Saved via pure tool" diff --git a/tests/unit/tools/test_search_logs.py b/tests/unit/tools/test_search_logs.py new file mode 100644 index 0000000..0facb93 --- /dev/null +++ b/tests/unit/tools/test_search_logs.py @@ -0,0 +1,262 @@ +# 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, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.search_logs import ( + SearchLogs, + SearchLogsArgs, + SearchLogsTool, + SearchLogsToolAlias, + get_search_logs_tool, + search_and_merge_logs, + search_logs, +) +import pytest + + +def test_search_and_merge_logs_no_matches(): + logs = "line 0: info\nline 1: info\nline 2: info" + result = search_and_merge_logs(logs, "ERROR", context_lines=2) + assert result == "No matches found for 'ERROR'." + + +def test_search_and_merge_logs_single_match(): + logs = "\n".join([f"line {i}" if i != 5 else "ERROR: failure" for i in range(10)]) + result = search_and_merge_logs(logs, "ERROR", context_lines=2) + + expected = ( + "--- Match at line(s) 5 ---\n" + " 3: line 3\n" + " 4: line 4\n" + " 5: [MATCH] ERROR: failure\n" + " 6: line 6\n" + " 7: line 7\n" + "--------------------" + ) + assert result == expected + + +def test_search_and_merge_logs_overlapping_matches(): + logs = "\n".join([f"line {i}" if i not in [4, 6] else "ERROR: failure" for i in range(10)]) + result = search_and_merge_logs(logs, "ERROR", context_lines=2) + + # Match at 4 (range 2 to 6 inclusive) + # Match at 6 (range 4 to 8 inclusive) + # Merged range: 2 to 8 inclusive + expected = ( + "--- Match at line(s) 4, 6 ---\n" + " 2: line 2\n" + " 3: line 3\n" + " 4: [MATCH] ERROR: failure\n" + " 5: line 5\n" + " 6: [MATCH] ERROR: failure\n" + " 7: line 7\n" + " 8: line 8\n" + "--------------------" + ) + assert result == expected + + +def test_search_and_merge_logs_adjacent_matches(): + # Match at 3 (range 1 to 5 inclusive) + # Match at 6 (range 4 to 8 inclusive) + # Overlap is at lines 4-5. They are adjacent/overlapping and should merge. + logs = "\n".join([f"line {i}" if i not in [3, 6] else "ERROR: failure" for i in range(10)]) + result = search_and_merge_logs(logs, "ERROR", context_lines=2) + + expected = ( + "--- Match at line(s) 3, 6 ---\n" + " 1: line 1\n" + " 2: line 2\n" + " 3: [MATCH] ERROR: failure\n" + " 4: line 4\n" + " 5: line 5\n" + " 6: [MATCH] ERROR: failure\n" + " 7: line 7\n" + " 8: line 8\n" + "--------------------" + ) + assert result == expected + + +def test_search_and_merge_logs_multiple_separate_blocks(): + # Match at 2 (range 0 to 4 inclusive) + # Match at 8 (range 6 to 9 inclusive since len is 10) + # Non-overlapping since end of first is 4, start of second is 6. + logs = "\n".join([f"line {i}" if i not in [2, 8] else "ERROR: failure" for i in range(10)]) + result = search_and_merge_logs(logs, "ERROR", context_lines=2) + + expected = ( + "--- Match at line(s) 2 ---\n" + " 0: line 0\n" + " 1: line 1\n" + " 2: [MATCH] ERROR: failure\n" + " 3: line 3\n" + " 4: line 4\n" + "--------------------\n" + "--- Match at line(s) 8 ---\n" + " 6: line 6\n" + " 7: line 7\n" + " 8: [MATCH] ERROR: failure\n" + " 9: line 9\n" + "--------------------" + ) + assert result == expected + + +def test_search_and_merge_logs_regex(): + logs = "line 0\nERR_123: failed\nline 2\nERR_456: critical" + result = search_and_merge_logs(logs, r"ERR_\d+", context_lines=1, is_regex=True) + + expected = ( + "--- Match at line(s) 1, 3 ---\n" + " 0: line 0\n" + " 1: [MATCH] ERR_123: failed\n" + " 2: line 2\n" + " 3: [MATCH] ERR_456: critical\n" + "--------------------" + ) + assert result == expected + + +def test_search_and_merge_logs_invalid_regex(): + logs = "line 0" + result = search_and_merge_logs(logs, "[invalid", is_regex=True) + assert result.startswith("Error: Invalid regex:") + + +# --------------------------------------------------------------------------- +# SearchLogsTool Unit Tests +# --------------------------------------------------------------------------- +@pytest.fixture +def mock_ctx(): + return MagicMock(spec=ArtemisContext) + + +def test_search_logs_tool_subclass_and_registry(): + """Verify SearchLogsTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(SearchLogsTool, ArtemisTool) + assert issubclass(SearchLogs, ArtemisTool) + assert issubclass(SearchLogsToolAlias, ArtemisTool) + assert isinstance(search_logs, ArtemisTool) + assert isinstance(search_logs, SearchLogsTool) + + assert search_logs.name == "search_logs" + assert search_logs.category == "diagnostic" + assert search_logs.args_schema == SearchLogsArgs + + # Registry lookup + reg_tool = ToolRegistry.get("search_logs") + assert reg_tool is not None + assert isinstance(reg_tool, SearchLogsTool) + + # GenAI FunctionDeclaration export + declaration = search_logs.to_genai_declaration() + assert declaration.name == "search_logs" + assert "keyword" in declaration.parameters.properties + assert "is_regex" in declaration.parameters.properties + assert "lines" in declaration.parameters.properties + assert "since_time" in declaration.parameters.properties + assert "until_time" in declaration.parameters.properties + assert "context_lines" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_search_logs_direct_execution(mock_ctx): + """Verify direct execution of SearchLogsTool.""" + mock_log_output = "line 1: FATAL: crash occurred\nline 2: normal" + with patch( + "artemis.tools.mobile.search_logs.fetch_and_filter_logs", + return_value=mock_log_output, + ) as mock_fetch: + result = await search_logs.execute( + ctx=mock_ctx, + keyword="FATAL", + is_regex=False, + lines=5000, + since_time="08-12 10:00:00", + until_time="08-12 10:05:00", + context_lines=1, + ) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=5000, + since_time="08-12 10:00:00", + until_time="08-12 10:05:00", + ) + assert "[MATCH] line 1: FATAL: crash occurred" in result + + +@pytest.mark.asyncio +async def test_search_logs_default_args(mock_ctx): + """Verify search_logs defaults lines to 10000 and context_lines to 0.""" + with patch( + "artemis.tools.mobile.search_logs.fetch_and_filter_logs", + return_value="info line\nerror line", + ) as mock_fetch: + result = await search_logs.execute( + ctx=mock_ctx, keyword="error", lines=None, context_lines=None + ) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=10000, + since_time=None, + until_time=None, + ) + assert "[MATCH] error line" in result + + +@pytest.mark.asyncio +async def test_search_logs_exception_handling(mock_ctx): + """Verify error handling when searching logs raises an exception.""" + with patch( + "artemis.tools.mobile.search_logs.fetch_and_filter_logs", + side_effect=RuntimeError("Device unreachable"), + ): + result = await search_logs.execute(ctx=mock_ctx, keyword="test") + assert "Error searching logs: Device unreachable" in result + + +@pytest.mark.asyncio +async def test_search_logs_callable_execution(mock_ctx): + """Verify invoking search_logs directly as a callable.""" + with patch( + "artemis.tools.mobile.search_logs.fetch_and_filter_logs", + return_value="NullPointerException in activity", + ): + result = await search_logs(ctx=mock_ctx, keyword="NullPointer", context_lines=0) + assert "[MATCH] NullPointerException in activity" in result + + +@pytest.mark.asyncio +async def test_get_search_logs_tool_langchain_ainvoke(mock_ctx): + """Verify get_search_logs_tool exports a LangChain tool that works with ainvoke.""" + tool = get_search_logs_tool(mock_ctx) + assert tool.name == "search_logs" + + with patch( + "artemis.tools.mobile.search_logs.fetch_and_filter_logs", + return_value="critical error detected", + ) as mock_fetch: + result = await tool.ainvoke({"keyword": "critical", "lines": 2000}) + mock_fetch.assert_called_once_with( + ctx=mock_ctx, + lines=2000, + since_time=None, + until_time=None, + ) + assert "[MATCH] critical error detected" in result diff --git a/tests/unit/tools/test_swipe_tool.py b/tests/unit/tools/test_swipe_tool.py new file mode 100644 index 0000000..f3b1663 --- /dev/null +++ b/tests/unit/tools/test_swipe_tool.py @@ -0,0 +1,255 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.drivers.base import BaseDeviceDriver +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.mobile.exec_tools import ( + Swipe, + SwipeArgs, + SwipeTool, + get_swipe_tool, + swipe, + swipe_wrapper, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + ctx.device = MagicMock() + ctx.device.device_width = 1080 + ctx.device.device_height = 2400 + return ctx + + +@pytest.fixture +def mock_driver(): + driver = MagicMock(spec=BaseDeviceDriver) + driver.screen_size = (1080, 2400) + driver.swipe_direction = AsyncMock(return_value=True) + driver.swipe = AsyncMock(return_value=True) + return driver + + +@pytest.fixture +def mock_state(): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_swipe_tool_subclass_and_registry(): + """Verify SwipeTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(SwipeTool, ArtemisTool) + assert issubclass(Swipe, ArtemisTool) + assert isinstance(swipe, ArtemisTool) + assert isinstance(swipe, SwipeTool) + + assert swipe.name == "swipe" + assert swipe.category == "action" + assert swipe.args_schema == SwipeArgs + + # Registry lookup + reg_tool = ToolRegistry.get("swipe") + assert reg_tool is not None + assert isinstance(reg_tool, ArtemisTool) + + # GenAI FunctionDeclaration export + declaration = swipe.to_genai_declaration() + assert declaration.name == "swipe" + assert "action" in declaration.parameters.properties + assert "duration" in declaration.parameters.properties + + # Wrapper check + assert swipe_wrapper is not None + assert swipe_wrapper.tool_fn_getter == get_swipe_tool + + +@pytest.mark.asyncio +async def test_swipe_direct_execution_with_driver_direction(mock_driver): + """Verify direct execution with direction on BaseDeviceDriver.""" + result = await swipe.execute(driver=mock_driver, action="up", duration=500) + mock_driver.swipe_direction.assert_called_once_with("up", duration_ms=500) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_direct_execution_with_driver_coords(mock_driver): + """Verify direct execution with coordinates on BaseDeviceDriver.""" + result = await swipe.execute(driver=mock_driver, action=[100, 200, 300, 400], duration=600) + mock_driver.swipe.assert_called_once_with(108, 480, 324, 960, duration_ms=600) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_direct_execution_with_ctx_direction(mock_ctx): + """Verify direct execution with direction on ArtemisContext.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.swipe_coords = AsyncMock(return_value=None) + 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) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_direct_execution_with_ctx_coords(mock_ctx): + """Verify direct execution with coordinates on ArtemisContext.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.swipe_coords = AsyncMock(return_value=None) + mock_controller_cls.return_value = mock_controller_inst + + result = await swipe.execute(ctx=mock_ctx, action=[200, 300, 800, 900], duration=800) + mock_controller_inst.swipe_coords.assert_called_once_with(216, 720, 864, 2160, 800) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_with_state_command(mock_ctx, mock_state): + """Verify SwipeTool returns Command when state is provided.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.swipe_coords = AsyncMock(return_value=None) + mock_controller_cls.return_value = mock_controller_inst + + cmd = await swipe.execute( + ctx=mock_ctx, + action="left", + tool_call_id="call_sw_1", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_sw_1" + assert messages[0].status == "success" + assert "Swipe completed successfully." in messages[0].content + + +@pytest.mark.asyncio +async def test_swipe_callable_execution(mock_driver): + """Verify invoking swipe directly as a callable.""" + result = await swipe(driver=mock_driver, action="right") + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_invalid_action(mock_ctx): + """Verify error handling on invalid action.""" + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController"): + result = await swipe.execute(ctx=mock_ctx, action="diagonal") + assert "Error during swipe: Invalid" in result + + +@pytest.mark.asyncio +async def test_get_swipe_tool_langchain_ainvoke(mock_ctx): + """Verify get_swipe_tool exports a LangChain tool that works with ainvoke.""" + sw_tool = get_swipe_tool(mock_ctx) + assert sw_tool.name == "swipe" + + with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls: + mock_controller_inst = MagicMock() + mock_controller_inst.swipe_coords = AsyncMock(return_value=None) + mock_controller_cls.return_value = mock_controller_inst + + result = await sw_tool.ainvoke({"action": "up"}) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_with_direction_and_start_end(mock_driver): + """Verify swipe works with explicit direction and start/end parameters.""" + # Direction parameter + result_dir = await swipe.execute(driver=mock_driver, direction="left", duration=500) + mock_driver.swipe_direction.assert_called_with("left", duration_ms=500) + assert "Swipe completed successfully." in result_dir + + # Start and End parameters + result_drag = await swipe.execute( + driver=mock_driver, start=[100, 200], end=[300, 400], duration=1500 + ) + mock_driver.swipe.assert_called_with(108, 480, 324, 960, duration_ms=1500) + assert "Swipe completed successfully." in result_drag + + +@pytest.mark.asyncio +async def test_swipe_with_coordinates_alias(mock_driver): + """Verify swipe works with coordinates alias parameter.""" + result = await swipe.execute(driver=mock_driver, coordinates=[200, 300, 400, 500], duration=800) + mock_driver.swipe.assert_called_with(216, 720, 432, 1200, duration_ms=800) + assert "Swipe completed successfully." in result + + +@pytest.mark.asyncio +async def test_swipe_with_string_coordinates(mock_driver): + """Verify swipe works with string-formatted coordinates.""" + # List format in string + result1 = await swipe.execute(driver=mock_driver, action="[920, 290, 920, 180]", duration=1000) + mock_driver.swipe.assert_called_with(993, 696, 993, 432, duration_ms=1000) + assert "Swipe completed successfully." in result1 + + # Space-separated format in string + result2 = await swipe.execute(driver=mock_driver, action="920 290 920 180", duration=1000) + assert "Swipe completed successfully." in result2 + + +def test_parse_swipe_parameters_direct(): + """Verify parse_swipe_parameters with various parameter formats.""" + from artemis.utils.coordinates import parse_swipe_parameters + + # List + k, t, d = parse_swipe_parameters([100, 200, 300, 400]) + assert k == "coords" + assert t == [100, 200, 300, 400] + + # String list + k, t, d = parse_swipe_parameters("[920, 290, 920, 180]") + assert k == "coords" + assert t == [920, 290, 920, 180] + + # String space separated + k, t, d = parse_swipe_parameters("920 290 920 180") + assert k == "coords" + assert t == [920, 290, 920, 180] + + # Dict with action string + k, t, d = parse_swipe_parameters({"action": "[920, 290, 920, 180]", "duration": 1000}) + assert k == "coords" + assert t == [920, 290, 920, 180] + assert d == 1000 + + # Dict with start and end + k, t, d = parse_swipe_parameters({"start": [100, 200], "end": [300, 400]}) + assert k == "coords" + assert t == [100, 200, 300, 400] + + # Direction string + k, t, d = parse_swipe_parameters({"action": "up"}) + assert k == "direction" + assert t == "up" diff --git a/tests/unit/tools/test_update_note_pure_tool.py b/tests/unit/tools/test_update_note_pure_tool.py new file mode 100644 index 0000000..8d34bf5 --- /dev/null +++ b/tests/unit/tools/test_update_note_pure_tool.py @@ -0,0 +1,146 @@ +# 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 pathlib import Path +from unittest.mock import MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + UpdateNoteArgs, + UpdateNotePure, + UpdateNotePureTool, + get_update_note_tool_pure, + update_note_pure, +) +from artemis.utils.notes import save_note_content +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_update_note_pure_tool_subclass_and_registry(): + """Verify UpdateNotePureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(UpdateNotePureTool, ArtemisTool) + assert issubclass(UpdateNotePure, ArtemisTool) + assert isinstance(update_note_pure, ArtemisTool) + assert isinstance(update_note_pure, UpdateNotePureTool) + + assert update_note_pure.name == "update_note_pure" + assert update_note_pure.category == "memory" + assert update_note_pure.args_schema == UpdateNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("update_note_pure") + assert reg_tool is not None + assert isinstance(reg_tool, UpdateNotePureTool) + + # GenAI FunctionDeclaration export + declaration = update_note_pure.to_genai_declaration() + assert declaration.name == "update_note_pure" + assert "key" in declaration.parameters.properties + assert "target" in declaration.parameters.properties + assert "replacement" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_update_note_pure_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of UpdateNotePureTool.execute updates existing note.""" + save_note_content(tmp_path, "plan", "Step 1: Pending\nStep 2: Done") + + result = await update_note_pure.execute( + ctx=mock_ctx, + key="plan", + target="Step 1: Pending", + replacement="Step 1: Completed", + ) + assert result == "Successfully updated note 'plan'." + + note_path = Path(tmp_path) / "notes" / "plan.md" + assert note_path.read_text(encoding="utf-8") == "Step 1: Completed\nStep 2: Done" + + +@pytest.mark.asyncio +async def test_update_note_pure_with_warning(mock_ctx, tmp_path): + """Verify update note pure handles warning from fuzzy matching.""" + save_note_content(tmp_path, "fuzzy_note", "Target String") + + with patch( + "artemis.tools.scratchpad.update_note_content", + return_value="Fuzzy match applied", + ): + result = await update_note_pure.execute( + ctx=mock_ctx, + key="fuzzy_note", + target="target string", + replacement="New String", + ) + assert "Successfully updated note 'fuzzy_note'." in result + assert "WARNING: Fuzzy match applied" in result + + +@pytest.mark.asyncio +async def test_update_note_pure_callable_execution(mock_ctx, tmp_path): + """Verify invoking update_note_pure directly as a callable.""" + save_note_content(tmp_path, "callable_plan", "alpha beta") + + result = await update_note_pure( + ctx=mock_ctx, + key="callable_plan", + target="beta", + replacement="gamma", + ) + assert result == "Successfully updated note 'callable_plan'." + + note_path = Path(tmp_path) / "notes" / "callable_plan.md" + assert note_path.read_text(encoding="utf-8") == "alpha gamma" + + +@pytest.mark.asyncio +async def test_update_note_pure_execution_failure(mock_ctx): + """Verify error handling when note update fails.""" + result = await update_note_pure.execute( + ctx=mock_ctx, + key="missing_note", + target="old", + replacement="new", + ) + assert "Failed to update note 'missing_note':" in result + + +@pytest.mark.asyncio +async def test_get_update_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_update_note_tool_pure exports a LangChain tool named 'update_note'.""" + save_note_content(tmp_path, "export_plan", "initial value") + + pure_tool = get_update_note_tool_pure(mock_ctx) + assert pure_tool.name == "update_note" + + result = await pure_tool.ainvoke( + { + "key": "export_plan", + "target": "initial", + "replacement": "updated", + } + ) + assert result == "Successfully updated note 'export_plan'." + + note_path = Path(tmp_path) / "notes" / "export_plan.md" + assert note_path.read_text(encoding="utf-8") == "updated value" diff --git a/tests/unit/tools/test_update_note_tool.py b/tests/unit/tools/test_update_note_tool.py new file mode 100644 index 0000000..b16dcbe --- /dev/null +++ b/tests/unit/tools/test_update_note_tool.py @@ -0,0 +1,208 @@ +# 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 pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.scratchpad import ( + UpdateNote, + UpdateNoteArgs, + UpdateNoteTool, + get_update_note_tool, + get_update_note_tool_pure, + update_note, + update_note_wrapper, +) +from artemis.utils.notes import save_note_content +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(tmp_path): + ctx = MagicMock(spec=ArtemisContext) + ctx.data_engine = MagicMock() + ctx.data_engine.base_dir = str(tmp_path) + return ctx + + +def test_update_note_tool_subclass_and_registry(): + """Verify UpdateNoteTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(UpdateNoteTool, ArtemisTool) + assert issubclass(UpdateNote, ArtemisTool) + assert isinstance(update_note, ArtemisTool) + assert isinstance(update_note, UpdateNoteTool) + + assert update_note.name == "update_note" + assert update_note.category == "memory" + assert update_note.args_schema == UpdateNoteArgs + + # Registry lookup + reg_tool = ToolRegistry.get("update_note") + assert reg_tool is not None + assert isinstance(reg_tool, UpdateNoteTool) + + # GenAI FunctionDeclaration export + declaration = update_note.to_genai_declaration() + assert declaration.name == "update_note" + assert "key" in declaration.parameters.properties + assert "target" in declaration.parameters.properties + assert "replacement" in declaration.parameters.properties + + # Wrapper check + assert update_note_wrapper is not None + assert update_note_wrapper.tool_fn_getter == get_update_note_tool + + +@pytest.mark.asyncio +async def test_update_note_direct_execution_success(mock_ctx, tmp_path): + """Verify direct execution of UpdateNoteTool.execute updates existing note.""" + save_note_content(tmp_path, "plan", "- [ ] Step 1\n- [ ] Step 2") + + result = await update_note.execute( + ctx=mock_ctx, + key="plan", + target="- [ ] Step 1", + replacement="- [x] Step 1", + ) + assert result == "Successfully updated note 'plan'." + + note_path = Path(tmp_path) / "notes" / "plan.md" + assert note_path.read_text(encoding="utf-8") == "- [x] Step 1\n- [ ] Step 2" + + +@pytest.mark.asyncio +async def test_update_note_with_warning(mock_ctx, tmp_path): + """Verify update note handles warning from fuzzy/relaxed matching.""" + save_note_content(tmp_path, "fuzzy_plan", "- [ ] Step 1") + + with patch( + "artemis.tools.scratchpad.update_note_content", + return_value="Fuzzy match applied", + ): + result = await update_note.execute( + ctx=mock_ctx, + key="fuzzy_plan", + target="Step 1", + replacement="Step 1 Done", + ) + assert "Successfully updated note 'fuzzy_plan'." in result + assert "WARNING: Fuzzy match applied" in result + + +@pytest.mark.asyncio +async def test_update_note_callable_execution(mock_ctx, tmp_path): + """Verify invoking update_note directly as a callable.""" + save_note_content(tmp_path, "callable_plan", "- [ ] Task 1\n- [ ] Task 2") + + result = await update_note( + ctx=mock_ctx, + key="callable_plan", + target="- [ ] Task 2", + replacement="- [x] Task 2", + ) + assert result == "Successfully updated note 'callable_plan'." + + note_path = Path(tmp_path) / "notes" / "callable_plan.md" + assert note_path.read_text(encoding="utf-8") == "- [ ] Task 1\n- [x] Task 2" + + +@pytest.mark.asyncio +async def test_update_note_with_state_command(mock_ctx, tmp_path): + """Verify UpdateNoteTool.execute with state returns Command updating VALIDATOR_MESSAGES_KEY.""" + save_note_content(tmp_path, "state_plan", "- [ ] Step 1") + + mock_state = MagicMock(spec=State) + mock_state.current_agent = "operator" + mock_state.asanitize_update = AsyncMock(side_effect=lambda ctx, update, agent: update) + + result = await update_note.execute( + ctx=mock_ctx, + key="state_plan", + target="- [ ] Step 1", + replacement="- [x] Step 1", + state=mock_state, + tool_call_id="call_update_999", + ) + + assert isinstance(result, Command) + assert VALIDATOR_MESSAGES_KEY in result.update + tool_messages = result.update[VALIDATOR_MESSAGES_KEY] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_update_999" + assert tool_messages[0].content == "Successfully updated note 'state_plan'." + assert tool_messages[0].status == "success" + + mock_state.asanitize_update.assert_called_once() + + note_path = Path(tmp_path) / "notes" / "state_plan.md" + assert note_path.read_text(encoding="utf-8") == "- [x] Step 1" + + +@pytest.mark.asyncio +async def test_update_note_execution_failure(mock_ctx): + """Verify error handling when updating a note fails.""" + result = await update_note.execute( + ctx=mock_ctx, + key="non_existent_note", + target="old", + replacement="new", + ) + assert "Failed to update note 'non_existent_note':" in result + + +@pytest.mark.asyncio +async def test_get_update_note_tool_langchain_ainvoke(mock_ctx, tmp_path): + """Verify get_update_note_tool exports a LangChain tool that works with ainvoke.""" + save_note_content(tmp_path, "lc_plan", "alpha beta gamma") + + lc_tool = get_update_note_tool(mock_ctx) + assert lc_tool.name == "update_note" + + result = await lc_tool.ainvoke( + { + "key": "lc_plan", + "target": "beta", + "replacement": "delta", + } + ) + assert result == "Successfully updated note 'lc_plan'." + + note_path = Path(tmp_path) / "notes" / "lc_plan.md" + assert note_path.read_text(encoding="utf-8") == "alpha delta gamma" + + +@pytest.mark.asyncio +async def test_get_update_note_tool_pure(mock_ctx, tmp_path): + """Verify get_update_note_tool_pure returns a pure LangChain tool.""" + save_note_content(tmp_path, "pure_plan", "hello world") + + pure_tool = get_update_note_tool_pure(mock_ctx) + assert pure_tool.name == "update_note" + + result = await pure_tool.ainvoke( + { + "key": "pure_plan", + "target": "world", + "replacement": "Artemis", + } + ) + assert result == "Successfully updated note 'pure_plan'." + + note_path = Path(tmp_path) / "notes" / "pure_plan.md" + assert note_path.read_text(encoding="utf-8") == "hello Artemis" diff --git a/tests/unit/tools/test_utils.py b/tests/unit/tools/test_utils.py new file mode 100644 index 0000000..63b9fd9 --- /dev/null +++ b/tests/unit/tools/test_utils.py @@ -0,0 +1,457 @@ +# 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 +import sys +from unittest.mock import Mock, patch + +import pytest + +# Mock the problematic langgraph import at module level +_orig_chat_agent_executor = sys.modules.get("langgraph.prebuilt.chat_agent_executor") +_orig_state = sys.modules.get("artemis.graph.state") + +sys.modules["langgraph.prebuilt.chat_agent_executor"] = Mock() +sys.modules["artemis.graph.state"] = Mock() + +from artemis.context import DeviceContext, DevicePlatform, ArtemisContext # noqa: E402 +from artemis.tools.types import Target # noqa: E402 +from artemis.tools.utils import ( # noqa: E402 + IdSelectorRequest, + SelectorRequestWithCoordinates, + focus_element_if_needed, + move_cursor_to_end_if_bounds, +) +from artemis.utils.ui_hierarchy import ElementBounds # noqa: E402 + +# Restore original modules to prevent contaminating other tests +if _orig_chat_agent_executor is not None: + sys.modules["langgraph.prebuilt.chat_agent_executor"] = _orig_chat_agent_executor +else: + del sys.modules["langgraph.prebuilt.chat_agent_executor"] + +if _orig_state is not None: + sys.modules["artemis.graph.state"] = _orig_state +else: + del sys.modules["artemis.graph.state"] + + +@pytest.fixture +def mock_context(): + """Create a mock ArtemisContext for testing.""" + ctx = Mock(spec=ArtemisContext) + + # Create device context with necessary attributes + ctx.device = Mock(spec=DeviceContext) + ctx.device.mobile_platform = DevicePlatform.ANDROID + ctx.device.device_id = "test_device_123" + ctx.device.device_width = 1080 + ctx.device.device_height = 2340 + ctx.device.host_platform = "LINUX" + + ctx.ui_adb_client = Mock() + + # Mock the ADB client for Android + ctx.adb_client = Mock() + mock_device = Mock() + mock_device.shell = Mock(return_value="") + ctx.adb_client.device = Mock(return_value=mock_device) + + # Mock the ADB client for Android + mock_response = Mock() + mock_response.json.return_value = {"elements": []} + ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response) + ctx.ui_adb_client.get_hierarchy = Mock( + return_value=( + b'' + ) + ) + + return ctx + + +@pytest.fixture +def mock_state(): + """Create a mock State for testing.""" + state = Mock() + state.latest_ui_hierarchy = [] + return state + + +@pytest.fixture +def sample_element(): + """Create a sample UI element for testing.""" + return { + "resourceId": "com.example:id/text_input", + "text": "Sample text", + "bounds": {"x": 100, "y": 200, "width": 300, "height": 50}, + "focused": "false", + } + + +@pytest.fixture +def sample_rich_element(): + """Create a sample rich UI element for testing.""" + return { + "attributes": { + "resource-id": "com.example:id/text_input", + "focused": "false", + "text": "Sample text", + "bounds": {"x": 100, "y": 200, "width": 300, "height": 50}, + }, + "children": [], + } + + +class TestMoveCursorToEndIfBounds: + """Test cases for move_cursor_to_end_if_bounds function.""" + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_move_cursor_with_resource_id( + self, + mock_find_element, + mock_tap, + mock_context, + mock_state, + sample_element, + ): + """Test moving cursor using resource_id (highest priority).""" + mock_state.latest_ui_hierarchy = [sample_element] + mock_find_element.return_value = sample_element + + target = Target( + resource_id="com.example:id/text_input", + resource_id_index=None, + text=None, + text_index=None, + bounds=None, + ) + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + mock_find_element.assert_called_once_with( + ui_hierarchy=[sample_element], + resource_id="com.example:id/text_input", + index=0, + ) + mock_tap.assert_called_once() + call_args = mock_tap.call_args[1] + selector_request = call_args["selector_request"] + assert isinstance(selector_request, SelectorRequestWithCoordinates) + coords = selector_request.coordinates + assert coords.x == 397 # 100 + 300 * 0.99 + assert coords.y == 249 # 200 + 50 * 0.99 + assert result == sample_element + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_move_cursor_with_coordinates_only( + self, mock_find_element, mock_tap, mock_context, mock_state + ): + """Test moving cursor when only coordinates are provided.""" + bounds = ElementBounds(x=50, y=150, width=200, height=40) + target = Target( + resource_id=None, + resource_id_index=None, + text=None, + text_index=None, + bounds=bounds, + ) + + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + mock_find_element.assert_not_called() + mock_tap.assert_called_once() + call_args = mock_tap.call_args[1] + selector_request = call_args["selector_request"] + coords = selector_request.coordinates + assert coords.x == 248 # 50 + 200 * 0.99 + assert coords.y == 189 # 150 + 40 * 0.99 + assert result is None # No element is returned when using coords directly + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_text") + def test_move_cursor_with_text_only_success( + self, mock_find_text, mock_tap, mock_context, mock_state, sample_element + ): + """Test moving cursor when only text is provided and succeeds.""" + mock_state.latest_ui_hierarchy = [sample_element] + mock_find_text.return_value = sample_element + + target = Target( + resource_id=None, + resource_id_index=None, + text="Sample text", + text_index=0, + bounds=None, + ) + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + mock_find_text.assert_called_once_with([sample_element], "Sample text", index=0) + mock_tap.assert_called_once() + assert result == sample_element + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_text") + def test_move_cursor_with_text_only_element_not_found( + self, mock_find_text, mock_tap, mock_context, mock_state + ): + """Test when searching by text finds no element.""" + mock_state.latest_ui_hierarchy = [] + mock_find_text.return_value = None + + target = Target( + resource_id=None, + resource_id_index=None, + text="Nonexistent text", + text_index=None, + bounds=None, + ) + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + mock_tap.assert_not_called() + assert result is None + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_text") + def test_move_cursor_with_text_only_no_bounds( + self, mock_find_text, mock_tap, mock_context, mock_state + ): + """Test when element is found by text but has no bounds.""" + element_no_bounds = {"text": "Text without bounds"} + mock_state.latest_ui_hierarchy = [element_no_bounds] + mock_find_text.return_value = element_no_bounds + + target = Target( + resource_id=None, + resource_id_index=None, + text="Text without bounds", + text_index=None, + bounds=None, + ) + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + mock_tap.assert_not_called() + assert result is None # Should return None as no action was taken + + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_move_cursor_element_not_found_by_id(self, mock_find_element, mock_context, mock_state): + """Test when element is not found by resource_id.""" + mock_find_element.return_value = None + + target = Target( + resource_id="com.example:id/nonexistent", + resource_id_index=None, + text=None, + text_index=None, + bounds=None, + ) + result = asyncio.run( + move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target) + ) + + assert result is None + + +class TestFocusElementIfNeeded: + """Test cases for focus_element_if_needed function.""" + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_focus_element_already_focused( + self, mock_find_element, mock_tap, mock_context, sample_rich_element + ): + """Test when element is already focused.""" + focused_element = sample_rich_element.copy() + focused_element["attributes"]["focused"] = "true" + + mock_response = Mock() + mock_response.json.return_value = {"elements": [focused_element]} + mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) + mock_find_element.return_value = focused_element["attributes"] + + target = Target( + resource_id="com.example:id/text_input", + resource_id_index=None, + text=None, + text_index=None, + bounds=None, + ) + result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target)) + + mock_tap.assert_not_called() + assert result == "resource_id" + mock_context.ui_adb_client.get_hierarchy.assert_called_once() + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_focus_element_needs_focus_success( + self, mock_find_element, mock_tap, mock_context, sample_rich_element + ): + """Test when element needs focus and focusing succeeds.""" + unfocused_element = sample_rich_element + focused_element = { + "attributes": { + "resource-id": "com.example:id/text_input", + "focused": "true", + }, + "children": [], + } + + mock_find_element.side_effect = [ + unfocused_element["attributes"], + focused_element["attributes"], + ] + + target = Target( + resource_id="com.example:id/text_input", + resource_id_index=None, + text=None, + text_index=None, + bounds=None, + ) + result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target)) + + mock_tap.assert_called_once_with( + ctx=mock_context, + selector_request=IdSelectorRequest(id="com.example:id/text_input"), + index=0, + ) + assert mock_context.ui_adb_client.get_hierarchy.call_count == 2 + assert result == "resource_id" + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.logger") + @patch("artemis.tools.utils.find_element_by_resource_id") + def test_focus_id_and_text_mismatch_fallback_to_text( + self, + mock_find_id, + mock_logger, + mock_tap, + mock_context, + sample_rich_element, + ): + """Test fallback when resource_id and text point to different elements.""" + element_from_id = sample_rich_element["attributes"].copy() + element_from_id["text"] = "Different text" + + element_from_text = sample_rich_element.copy() + element_from_text["attributes"]["bounds"] = { + "x": 10, + "y": 20, + "width": 100, + "height": 30, + } + + mock_response = Mock() + mock_response.json.return_value = {"elements": [element_from_text]} + mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) + mock_find_id.return_value = element_from_id + + with patch("artemis.tools.utils.find_element_by_text") as mock_find_text: + mock_find_text.return_value = element_from_text["attributes"] + + target = Target( + resource_id="com.example:id/text_input", + resource_id_index=None, + text="Sample text", + text_index=None, + bounds=None, + ) + result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target)) + + mock_logger.warning.assert_called_once() + mock_tap.assert_called_once() + assert result == "text" + + @patch("artemis.tools.utils.tap") + @patch("artemis.tools.utils.find_element_by_text") + def test_focus_fallback_to_text( + self, mock_find_text, mock_tap, mock_context, sample_rich_element + ): + """Test fallback to focusing using text.""" + element_with_bounds = sample_rich_element.copy() + element_with_bounds["attributes"]["bounds"] = { + "x": 10, + "y": 20, + "width": 100, + "height": 30, + } + + mock_response = Mock() + mock_response.json.return_value = {"elements": [element_with_bounds]} + mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) + mock_find_text.return_value = element_with_bounds["attributes"] + + target = Target( + resource_id=None, + resource_id_index=None, + text="Sample text", + text_index=None, + bounds=None, + ) + result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target)) + + mock_find_text.assert_called_once() + mock_tap.assert_called_once() + call_args = mock_tap.call_args[1] + selector = call_args["selector_request"] + assert isinstance(selector, SelectorRequestWithCoordinates) + assert selector.coordinates.x == 60 # 10 + 100/2 + assert selector.coordinates.y == 35 # 20 + 30/2 + assert result == "text" + + @patch("artemis.tools.utils.logger") + def test_focus_all_locators_fail(self, mock_logger, mock_context): + """Test failure when no locator can find an element.""" + + mock_response = Mock() + mock_response.json.return_value = {"elements": []} + mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) + with ( + patch("artemis.tools.utils.find_element_by_resource_id") as mock_find_id, + patch("artemis.tools.utils.find_element_by_text") as mock_find_text, + ): + mock_find_id.return_value = None + mock_find_text.return_value = None + + target = Target( + resource_id="nonexistent", + resource_id_index=None, + text="nonexistent", + text_index=None, + bounds=None, + ) + result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target)) + + mock_logger.error.assert_called_once_with( + "Failed to focus element." + + " No valid locator (resource_id, coordinates, or text) succeeded." + ) + assert result is None + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit/tools/test_video_analyzer_pure_tool.py b/tests/unit/tools/test_video_analyzer_pure_tool.py new file mode 100644 index 0000000..cb1e349 --- /dev/null +++ b/tests/unit/tools/test_video_analyzer_pure_tool.py @@ -0,0 +1,139 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.video_tool import ( + VideoAnalyzerArgs, + VideoAnalyzerPure, + VideoAnalyzerPureTool, + get_video_analyzer_tool_pure, + video_analyzer_pure, +) +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + return ctx + + +def test_video_analyzer_pure_tool_subclass_and_registry(): + """Verify VideoAnalyzerPureTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(VideoAnalyzerPureTool, ArtemisTool) + assert issubclass(VideoAnalyzerPure, ArtemisTool) + assert isinstance(video_analyzer_pure, ArtemisTool) + assert isinstance(video_analyzer_pure, VideoAnalyzerPureTool) + + assert video_analyzer_pure.name == "video_analyzer_pure" + assert video_analyzer_pure.category == "perception" + assert video_analyzer_pure.args_schema == VideoAnalyzerArgs + + # Registry lookup + reg_tool = ToolRegistry.get("video_analyzer_pure") + assert reg_tool is not None + assert isinstance(reg_tool, VideoAnalyzerPureTool) + + # GenAI FunctionDeclaration export + declaration = video_analyzer_pure.to_genai_declaration() + assert declaration.name == "video_analyzer_pure" + assert "time_description" in declaration.parameters.properties + assert "purpose" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_video_analyzer_pure_direct_execution_success(mock_ctx): + """Verify direct execution of VideoAnalyzerPureTool.execute returns outcome directly.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Pure video analysis success", "success"), + ): + result = await video_analyzer_pure.execute( + ctx=mock_ctx, + time_description="from 0s to 5s", + purpose="Verify video playback", + ) + assert result == "Pure video analysis success" + + +@pytest.mark.asyncio +async def test_video_analyzer_pure_direct_execution_failed(mock_ctx): + """Verify direct execution handles failed status properly.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Pure could not find element", "failed"), + ): + result = await video_analyzer_pure.execute( + ctx=mock_ctx, + time_description="from 10s to 15s", + purpose="Check button click", + ) + assert result == "Video analysis failed: Pure could not find element" + + +@pytest.mark.asyncio +async def test_video_analyzer_pure_callable_execution(mock_ctx): + """Verify invoking video_analyzer_pure directly as a callable.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Pure callable output", "success"), + ): + result = await video_analyzer_pure( + ctx=mock_ctx, + time_description="from 1s to 2s", + purpose="Callable check", + ) + assert result == "Pure callable output" + + +@pytest.mark.asyncio +async def test_video_analyzer_pure_exception_handling(mock_ctx): + """Verify VideoAnalyzerPureTool gracefully handles exceptions.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + side_effect=RuntimeError("Video processing error"), + ): + result = await video_analyzer_pure.execute( + ctx=mock_ctx, + time_description="from 0s to 5s", + purpose="Test error", + ) + assert "Error running video analyzer: Video processing error" in result + + +@pytest.mark.asyncio +async def test_get_video_analyzer_tool_pure_ainvoke(mock_ctx): + """Verify get_video_analyzer_tool_pure exports a LangChain tool named 'video_analyzer'.""" + pure_tool = get_video_analyzer_tool_pure(mock_ctx) + assert pure_tool.name == "video_analyzer" + + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Exported pure tool output", "success"), + ): + result = await pure_tool.ainvoke( + { + "time_description": "from 2s to 4s", + "purpose": "Pure invoke check", + } + ) + assert result == "Exported pure tool output" diff --git a/tests/unit/tools/test_video_analyzer_tool.py b/tests/unit/tools/test_video_analyzer_tool.py new file mode 100644 index 0000000..df280d6 --- /dev/null +++ b/tests/unit/tools/test_video_analyzer_tool.py @@ -0,0 +1,204 @@ +# 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 AsyncMock, MagicMock, patch + +from artemis.constants import VALIDATOR_MESSAGES_KEY +from artemis.context import ArtemisContext +from artemis.graph.state import State +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.video_tool import ( + DIAGNOSER_VIDEO_ANALYZER_DOCSTRING, + OPERATOR_VIDEO_ANALYZER_DOCSTRING, + VideoAnalyzerArgs, + VideoAnalyzerTool, + get_video_analyzer_tool, + get_video_analyzer_tool_pure, + video_analyzer, +) +from langgraph.types import Command +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + return ctx + + +@pytest.fixture +def mock_state(mock_ctx): + state = MagicMock(spec=State) + + async def _mock_asanitize_update(ctx, update, agent): + return update + + state.asanitize_update = AsyncMock(side_effect=_mock_asanitize_update) + return state + + +def test_video_analyzer_tool_subclass_and_registry(): + """Verify VideoAnalyzerTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(VideoAnalyzerTool, ArtemisTool) + assert isinstance(video_analyzer, ArtemisTool) + assert isinstance(video_analyzer, VideoAnalyzerTool) + + assert video_analyzer.name == "video_analyzer" + assert video_analyzer.category == "perception" + assert video_analyzer.args_schema == VideoAnalyzerArgs + + # Registry lookup + reg_tool = ToolRegistry.get("video_analyzer") + assert reg_tool is not None + assert isinstance(reg_tool, VideoAnalyzerTool) + + # GenAI FunctionDeclaration export + declaration = video_analyzer.to_genai_declaration() + assert declaration.name == "video_analyzer" + assert "time_description" in declaration.parameters.properties + assert "purpose" in declaration.parameters.properties + + +@pytest.mark.asyncio +async def test_video_analyzer_direct_execution_success(mock_ctx): + """Verify direct execution of VideoAnalyzerTool.execute returns outcome directly.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Video analysis success details", "success"), + ): + result = await video_analyzer.execute( + ctx=mock_ctx, + time_description="from 0s to 5s", + purpose="Verify video playback", + ) + assert result == "Video analysis success details" + + +@pytest.mark.asyncio +async def test_video_analyzer_direct_execution_failed(mock_ctx): + """Verify direct execution handles failed status properly.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Could not find target element", "failed"), + ): + result = await video_analyzer.execute( + ctx=mock_ctx, + time_description="from 10s to 15s", + purpose="Check button click", + ) + assert result == "Video analysis failed: Could not find target element" + + +@pytest.mark.asyncio +async def test_video_analyzer_with_state_command(mock_ctx, mock_state): + """Verify execute returns a Command with ToolMessage when state is provided.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Action verified in video", "success"), + ): + cmd = await video_analyzer.execute( + ctx=mock_ctx, + time_description="from 5s to 10s", + purpose="Verify action", + tool_call_id="call_video_123", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert len(messages) == 1 + assert messages[0].tool_call_id == "call_video_123" + assert messages[0].content == "Action verified in video" + assert messages[0].status == "success" + + +@pytest.mark.asyncio +async def test_video_analyzer_exception_handling(mock_ctx, mock_state): + """Verify execute gracefully handles exceptions.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + side_effect=RuntimeError("Video processing error"), + ): + # Without state + res = await video_analyzer.execute( + ctx=mock_ctx, + time_description="from 0s to 5s", + purpose="Test error", + ) + assert "Error running video analyzer: Video processing error" in res + + # With state + cmd = await video_analyzer.execute( + ctx=mock_ctx, + time_description="from 0s to 5s", + purpose="Test error", + tool_call_id="call_err", + state=mock_state, + ) + assert isinstance(cmd, Command) + assert VALIDATOR_MESSAGES_KEY in cmd.update + messages = cmd.update[VALIDATOR_MESSAGES_KEY] + assert messages[0].status == "error" + assert "Error running video analyzer" in messages[0].content + + +@pytest.mark.asyncio +async def test_video_analyzer_callable_execution(mock_ctx): + """Verify invoking video_analyzer directly as a callable.""" + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Callable output", "success"), + ): + result = await video_analyzer( + ctx=mock_ctx, + time_description="from 1s to 2s", + purpose="Callable check", + ) + assert result == "Callable output" + + +def test_get_video_analyzer_tool_roles(mock_ctx): + """Verify get_video_analyzer_tool returns tool with role-specific description.""" + op_tool = get_video_analyzer_tool(mock_ctx, role="operator") + assert op_tool.name == "video_analyzer" + assert op_tool.description.strip() == OPERATOR_VIDEO_ANALYZER_DOCSTRING.strip() + + diag_tool = get_video_analyzer_tool(mock_ctx, role="diagnoser") + assert diag_tool.name == "video_analyzer" + assert diag_tool.description.strip() == DIAGNOSER_VIDEO_ANALYZER_DOCSTRING.strip() + + +@pytest.mark.asyncio +async def test_get_video_analyzer_tool_pure_ainvoke(mock_ctx): + """Verify get_video_analyzer_tool_pure exports a LangChain tool.""" + pure_tool = get_video_analyzer_tool_pure(mock_ctx) + assert pure_tool.name == "video_analyzer" + + with patch( + "artemis.tools.video_tool.VideoAnalyzer.run", + new_callable=AsyncMock, + return_value=("Pure tool output", "success"), + ): + result = await pure_tool.ainvoke( + { + "time_description": "from 2s to 4s", + "purpose": "Pure invoke check", + } + ) + assert result == "Pure tool output" diff --git a/tests/unit/tools/test_wait_tool.py b/tests/unit/tools/test_wait_tool.py new file mode 100644 index 0000000..92a006e --- /dev/null +++ b/tests/unit/tools/test_wait_tool.py @@ -0,0 +1,114 @@ +# 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 +import time +from unittest.mock import AsyncMock, MagicMock + +from artemis.context import ArtemisContext +from artemis.tools.base import ArtemisTool, ToolRegistry +from artemis.tools.wait_tool import ( + Wait, + WaitArgs, + WaitTool, + get_wait_tool, + wait, + wait_wrapper, +) +import pytest + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock(spec=ArtemisContext) + return ctx + + +def test_wait_tool_subclass_and_registry(): + """Verify WaitTool is a subclass of ArtemisTool and properly registered.""" + assert issubclass(WaitTool, ArtemisTool) + assert issubclass(Wait, ArtemisTool) + assert isinstance(wait, ArtemisTool) + assert isinstance(wait, WaitTool) + + assert wait.name == "wait" + assert wait.category == "system" + assert wait.args_schema == WaitArgs + + # Registry lookup + reg_tool = ToolRegistry.get("wait") + assert reg_tool is not None + assert isinstance(reg_tool, WaitTool) + + # GenAI FunctionDeclaration export + declaration = wait.to_genai_declaration() + assert declaration.name == "wait" + assert "seconds" in declaration.parameters.properties + + # Wrapper check + assert wait_wrapper is not None + assert wait_wrapper.tool_fn_getter == get_wait_tool + + +@pytest.mark.asyncio +async def test_wait_direct_execution(mock_ctx): + """Verify direct execution of WaitTool.execute.""" + with pytest.MonkeyPatch.context() as mp: + mock_sleep = AsyncMock() + mp.setattr(asyncio, "sleep", mock_sleep) + + result = await wait.execute(ctx=mock_ctx, seconds=5) + mock_sleep.assert_called_once_with(5) + assert result == "Successfully waited for 5 seconds." + + +@pytest.mark.asyncio +async def test_wait_callable_execution(mock_ctx): + """Verify invoking wait directly as a callable.""" + with pytest.MonkeyPatch.context() as mp: + mock_sleep = AsyncMock() + mp.setattr(asyncio, "sleep", mock_sleep) + + result = await wait(ctx=mock_ctx, seconds=3) + mock_sleep.assert_called_once_with(3) + assert result == "Successfully waited for 3 seconds." + + +@pytest.mark.asyncio +async def test_wait_tool(mock_ctx): + wait_tool = get_wait_tool(mock_ctx) + + start_time = time.time() + result = await wait_tool.ainvoke({"seconds": 2}) + end_time = time.time() + + duration = end_time - start_time + assert duration >= 1.9 # Allow slight timing variations + assert "Successfully waited for 2 seconds" in result + + +@pytest.mark.asyncio +async def test_wait_tool_bounds(mock_ctx): + wait_tool = get_wait_tool(mock_ctx) + + with pytest.MonkeyPatch.context() as mp: + mock_sleep = AsyncMock() + mp.setattr(asyncio, "sleep", mock_sleep) + + await wait_tool.ainvoke({"seconds": 120}) + mock_sleep.assert_called_once_with(60) + + mock_sleep.reset_mock() + await wait_tool.ainvoke({"seconds": -5}) + mock_sleep.assert_called_once_with(1) diff --git a/tests/unit/utils/test_app_launch_utils.py b/tests/unit/utils/test_app_launch_utils.py new file mode 100644 index 0000000..84af94f --- /dev/null +++ b/tests/unit/utils/test_app_launch_utils.py @@ -0,0 +1,148 @@ +# 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 AsyncMock, Mock, patch + +from artemis.context import ArtemisContext +from artemis.utils.app_launch_utils import launch_app_with_retries +import pytest + + +@pytest.fixture +def mock_context(): + ctx = Mock(spec=ArtemisContext) + ctx.device = Mock() + ctx.device.mobile_platform = "android" + ctx.device.device_id = "emulator-5554" + ctx.data_engine = None + return ctx + + +@pytest.mark.asyncio +@patch("artemis.utils.app_launch_utils.UnifiedMobileController") +@patch("artemis.utils.app_launch_utils.get_current_foreground_package_async") +async def test_launch_app_success_immediate(mock_get_foreground, mock_controller_cls, mock_context): + """Test successful immediate app launch.""" + mock_controller = Mock() + mock_controller.launch_app = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller + + # App immediately in foreground + mock_get_foreground.return_value = "com.google.android.youtube" + + success, error_msg = await launch_app_with_retries( + mock_context, + "com.google.android.youtube", + max_retries=1, + max_poll_seconds=2, + ) + + assert success is True + assert error_msg is None + mock_controller.launch_app.assert_called_once_with("com.google.android.youtube") + + +@pytest.mark.asyncio +@patch("artemis.utils.app_launch_utils.get_focused_task_package") +@patch("artemis.utils.app_launch_utils.UnifiedMobileController") +@patch("artemis.utils.app_launch_utils.get_current_foreground_package_async") +async def test_launch_app_success_permission_overlay( + mock_get_foreground, + mock_controller_cls, + mock_get_focused_task, + mock_context, +): + """Test successful launch if overlayed by system permission controller and task is matching.""" + mock_controller = Mock() + mock_controller.launch_app = AsyncMock(return_value=True) + mock_controller_cls.return_value = mock_controller + + # System permission controller is focused foreground + mock_get_foreground.return_value = "com.google.android.permissioncontroller" + # Second layer check: Task stack shows com.google.android.youtube is focused + mock_get_focused_task.return_value = "com.google.android.youtube" + + success, error_msg = await launch_app_with_retries( + mock_context, + "com.google.android.youtube", + max_retries=1, + max_poll_seconds=2, + ) + + assert success is True + assert error_msg is None + mock_controller.launch_app.assert_called_once_with("com.google.android.youtube") + mock_get_focused_task.assert_called_once_with(mock_context) + + +@pytest.mark.asyncio +@patch("artemis.utils.app_launch_utils.get_focused_task_package") +@patch("artemis.utils.app_launch_utils.UnifiedMobileController") +@patch("artemis.utils.app_launch_utils.get_current_foreground_package_async") +async def test_launch_app_failure_permission_overlay_wrong_task( + mock_get_foreground, + mock_controller_cls, + mock_get_focused_task, + mock_context, +): + """Test launch failure if overlayed by system permission controller but task does not match.""" + mock_controller = Mock() + mock_controller.launch_app = AsyncMock(return_value=True) + mock_controller.terminate_app = AsyncMock() + mock_controller_cls.return_value = mock_controller + + # System permission controller is focused foreground + mock_get_foreground.return_value = "com.google.android.permissioncontroller" + # Second layer check: Task stack is some other app (e.g. com.android.settings) + mock_get_focused_task.return_value = "com.android.settings" + + success, error_msg = await launch_app_with_retries( + mock_context, + "com.google.android.youtube", + max_retries=1, + max_poll_seconds=1, + ) + + assert success is False + assert "Failed to launch com.google.android.youtube" in error_msg + mock_get_focused_task.assert_called_once_with(mock_context) + + +@pytest.mark.asyncio +@patch("artemis.utils.app_launch_utils.UnifiedMobileController") +@patch("artemis.utils.app_launch_utils.get_current_foreground_package_async") +async def test_launch_app_failure_wrong_app(mock_get_foreground, mock_controller_cls, mock_context): + """Test launch failure and retry when wrong app remains in foreground.""" + mock_controller = Mock() + mock_controller.launch_app = AsyncMock(return_value=True) + mock_controller.terminate_app = AsyncMock() + mock_controller_cls.return_value = mock_controller + + # A different app (e.g., settings or home screen) is in foreground + mock_get_foreground.return_value = "com.android.settings" + + success, error_msg = await launch_app_with_retries( + mock_context, + "com.google.android.youtube", + max_retries=2, + max_poll_seconds=1, + ) + + assert success is False + assert "Failed to launch com.google.android.youtube" in error_msg + + # Should have attempted launching twice + assert mock_controller.launch_app.call_count == 2 + # Should have attempted force-stopping once before retry + assert mock_controller.terminate_app.call_count == 1 diff --git a/tests/unit/utils/test_image_diff.py b/tests/unit/utils/test_image_diff.py new file mode 100644 index 0000000..72d76ba --- /dev/null +++ b/tests/unit/utils/test_image_diff.py @@ -0,0 +1,178 @@ +# 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 +import base64 +import cv2 +from artemis.utils.image_diff import check_ui_change +import numpy as np + + +def test_no_change(): + img = np.zeros((1000, 1000), dtype=np.uint8) + _, img_bytes = cv2.imencode(".jpg", img) + + action = {"action": "tap", "coordinates": [500, 500]} + assert not check_ui_change(img_bytes.tobytes(), img_bytes.tobytes(), action) + print("test_no_change passed") + + +def test_global_change(): + img1 = np.zeros((1000, 1000), dtype=np.uint8) + img2 = np.ones((1000, 1000), dtype=np.uint8) * 255 + + _, img1_bytes = cv2.imencode(".jpg", img1) + _, img2_bytes = cv2.imencode(".jpg", img2) + + action = {"action": "swipe", "coordinates": [500, 500, 500, 200]} + assert check_ui_change(img1_bytes.tobytes(), img2_bytes.tobytes(), action) + print("test_global_change passed") + + +def test_roi_change(): + img1 = np.zeros((1000, 1000), dtype=np.uint8) + img2 = img1.copy() + img2[480:520, 480:520] = 255 + + _, img1_bytes = cv2.imencode(".jpg", img1) + _, img2_bytes = cv2.imencode(".jpg", img2) + + action = {"action": "tap", "coordinates": [500, 500]} + assert check_ui_change(img1_bytes.tobytes(), img2_bytes.tobytes(), action) + print("test_roi_change passed") + + +def test_status_bar_ignore(): + img1 = np.zeros((1000, 1000), dtype=np.uint8) + img2 = img1.copy() + img2[0:50, 0:1000] = 255 + + _, img1_bytes = cv2.imencode(".jpg", img1) + _, img2_bytes = cv2.imencode(".jpg", img2) + + action = {"action": "tap", "coordinates": [500, 500]} + assert not check_ui_change(img1_bytes.tobytes(), img2_bytes.tobytes(), action) + print("test_status_bar_ignore passed") + + +def test_roi_threshold(): + # Use standard 1080x2400 resolution to keep scale_factor = 1.0 (roi_size = 50) + img1 = np.zeros((2400, 1080), dtype=np.uint8) + img2 = img1.copy() + # Center of 1080x2400 is (540, 1200). Draw 10x10 square at center. + img2[1195:1205, 535:545] = 255 + + _, img1_bytes = cv2.imencode(".jpg", img1) + _, img2_bytes = cv2.imencode(".jpg", img2) + + action = {"action": "tap", "coordinates": [540, 1200]} + assert not check_ui_change( + img1_bytes.tobytes(), + img2_bytes.tobytes(), + action, + full_screen_threshold=0.001, + roi_threshold=0.05, + ) + print("test_roi_threshold passed") + + +def test_wait_for_screen_stability(): + from artemis.utils.image_diff import wait_for_screen_stability + + img_static = np.zeros((1000, 1000), dtype=np.uint8) + _, static_bytes = cv2.imencode(".jpg", img_static) + static_b64 = base64.b64encode(static_bytes).decode("utf-8") + + # 1. Test immediate stability (already static) + calls_immediate = 0 + + async def mock_screenshot_immediate(): + nonlocal calls_immediate + calls_immediate += 1 + return static_b64 + + res_immediate = asyncio.run( + wait_for_screen_stability(mock_screenshot_immediate, max_timeout=1.0, interval=0.05) + ) + assert res_immediate == static_b64 + assert calls_immediate == 2 + print("test_wait_for_screen_stability: immediate stability subtest passed") + + # 2. Test gradual stability (settles after 3 moving frames) + calls_gradual = 0 + + async def mock_screenshot_gradual(): + nonlocal calls_gradual + calls_gradual += 1 + if calls_gradual <= 3: + img = np.zeros((1000, 1000), dtype=np.uint8) + img[500:, :] = calls_gradual * 50 + _, bytes_data = cv2.imencode(".jpg", img) + return base64.b64encode(bytes_data).decode("utf-8") + return static_b64 + + res_gradual = asyncio.run( + wait_for_screen_stability(mock_screenshot_gradual, max_timeout=1.0, interval=0.05) + ) + assert res_gradual == static_b64 + assert calls_gradual > 3 + print("test_wait_for_screen_stability: gradual stability subtest passed") + print("test_wait_for_screen_stability passed") + + +def test_wait_for_screen_data_stability(): + from artemis.utils.image_diff import wait_for_screen_data_stability + from artemis.controllers.device_controller import ScreenDataResponse + + img_static = np.zeros((1000, 1000), dtype=np.uint8) + _, static_bytes = cv2.imencode(".jpg", img_static) + static_b64 = base64.b64encode(static_bytes).decode("utf-8") + + # Test stability returning ScreenDataResponse + calls = 0 + + async def mock_get_screen_data(): + nonlocal calls + calls += 1 + return ScreenDataResponse( + base64=static_b64, + elements=[], + width=1000, + height=1000, + platform="android", + ) + + res = asyncio.run( + wait_for_screen_data_stability(mock_get_screen_data, max_timeout=1.0, interval=0.05) + ) + assert res.base64 == static_b64 + assert res.width == 1000 + assert calls == 2 + print("test_wait_for_screen_data_stability passed") + + +if __name__ == "__main__": + try: + test_no_change() + test_global_change() + test_roi_change() + test_status_bar_ignore() + test_roi_threshold() + test_wait_for_screen_stability() + test_wait_for_screen_data_stability() + print("All tests passed successfully!") + except AssertionError as e: + print(f"Test failed: {e}") + except Exception as e: + print(f"Error running tests: {e}") diff --git a/tests/unit/utils/test_notes.py b/tests/unit/utils/test_notes.py new file mode 100644 index 0000000..6671c6d --- /dev/null +++ b/tests/unit/utils/test_notes.py @@ -0,0 +1,165 @@ +# 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 artemis.utils.notes import ( + append_note_content, + list_notes_info, + read_note_content, + save_note_content, + update_note_content, +) +import pytest + + +def test_list_notes_info(tmp_path): + base_dir = tmp_path + + # Create some notes + save_note_content(base_dir, "note1", "line1\nline2\nline3") + save_note_content(base_dir, "note2", "line1\nline2") + + info = list_notes_info(base_dir) + assert info == {"note1": 3, "note2": 2} + + +def test_read_note_content_range(tmp_path): + base_dir = tmp_path + save_note_content(base_dir, "note1", "line1\nline2\nline3\nline4") + + # Read full + assert read_note_content(base_dir, "note1") == "line1\nline2\nline3\nline4" + + # Read range + assert read_note_content(base_dir, "note1", start_line=2, end_line=3) == "line2\nline3" + + # Read from start to line + assert read_note_content(base_dir, "note1", end_line=2) == "line1\nline2" + + # Read from line to end + assert read_note_content(base_dir, "note1", start_line=3) == "line3\nline4" + + +def test_update_note_exact_match(tmp_path): + base_dir = tmp_path + original_content = 'def demo():\n print("Hello, World!")\n return True\n' + save_note_content(base_dir, "task_plan", original_content) + + # Exact match + warning = update_note_content( + base_dir, + "task_plan", + ' print("Hello, World!")', + ' print("Hello, Jetski!")', + ) + assert warning is None + + updated_content = read_note_content(base_dir, "task_plan") + assert 'print("Hello, Jetski!")' in updated_content + assert 'print("Hello, World!")' not in updated_content + + +def test_update_note_relaxed_match(tmp_path): + base_dir = tmp_path + # Multi-line content with checkboxes and indentation + original_content = ( + "- [ ] Step 1: Research the issue\n - [ ] Step 2: Write code\n - [ ] Step 3: Run tests\n" + ) + save_note_content(base_dir, "task_plan", original_content) + + # Relaxed match: case difference, quote difference, checkbox bullet difference, trailing whitespace + target = " * [ ] step 2: write code " + replacement = " - [/] Step 2: In progress" + + warning = update_note_content(base_dir, "task_plan", target, replacement) + + # Warning should be returned + assert warning is not None + assert "We did our best to apply changes despite some inaccuracies" in warning + + updated_content = read_note_content(base_dir, "task_plan") + assert "Step 2: In progress" in updated_content + assert "Step 2: Write code" not in updated_content + # Check indentation is preserved from replacement + assert " - [/] Step 2: In progress\n" in updated_content + + +def test_update_note_fuzzy_match(tmp_path): + base_dir = tmp_path + original_content = 'print("Alpha message")\nprint("Gamma message")\nprint("Delta message")\n' + save_note_content(base_dir, "task_plan", original_content) + + # Fuzzy match: typo (Gemma vs Gamma) + target = 'print("Gemma message")' + replacement = 'print("Gamma updated!")' + + warning = update_note_content(base_dir, "task_plan", target, replacement) + assert warning is not None + assert "We did our best to apply changes despite some inaccuracies" in warning + + updated_content = read_note_content(base_dir, "task_plan") + assert 'print("Gamma updated!")' in updated_content + assert 'print("Gamma message")' not in updated_content + + +def test_update_note_ambiguous_match(tmp_path): + base_dir = tmp_path + # Multiple lines that could match target + original_content = 'print("Beta message")\nprint("Zeta message")\nprint("Eta message")\n' + save_note_content(base_dir, "task_plan", original_content) + + # Target is highly ambiguous (Xeta is almost equally close to Beta and Zeta) + target = 'print("Xeta message")' + replacement = 'print("Ambiguous match!")' + + # Should raise ValueError due to ambiguity or inability to find unique match + with pytest.raises(ValueError) as exc_info: + update_note_content(base_dir, "task_plan", target, replacement) + + assert "Target string 'print(\"Xeta message\")' not found" in str( + exc_info.value + ) or "not unique" in str(exc_info.value) + + +def test_update_note_not_found(tmp_path): + base_dir = tmp_path + original_content = 'print("Alpha message")\n' + save_note_content(base_dir, "task_plan", original_content) + + # Totally different target + target = 'print("Something totally different")' + replacement = 'print("Hello")' + + with pytest.raises(ValueError) as exc_info: + update_note_content(base_dir, "task_plan", target, replacement) + assert "not found" in str(exc_info.value) + + +def test_append_note_content(tmp_path): + base_dir = tmp_path + + # Case 1: File does not exist + append_note_content(base_dir, "new_note", "hello") + assert read_note_content(base_dir, "new_note") == "hello\n" + + # Case 2: File exists and ends with a newline + append_note_content(base_dir, "new_note", "world") + assert read_note_content(base_dir, "new_note") == "hello\nworld\n" + + # Case 3: File exists and does not end with a newline + file_path = base_dir / "notes" / "no_newline.md" + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text("no newline", encoding="utf-8") + + append_note_content(base_dir, "no_newline", "added text") + assert read_note_content(base_dir, "no_newline") == "no newline\nadded text\n" diff --git a/tests/unit/utils/test_ocr_xml_fusion.py b/tests/unit/utils/test_ocr_xml_fusion.py new file mode 100644 index 0000000..e99e60f --- /dev/null +++ b/tests/unit/utils/test_ocr_xml_fusion.py @@ -0,0 +1,248 @@ +# 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 base64 +from io import BytesIO +import unittest +from artemis.utils.ocr_xml_fusion import ( + _calculate_overlap_ratio, + _crop_image_remove_status_bar, + _detect_status_bar_height, + _is_low_value_text, + _map_coordinates_back, + _parse_ocr_position, + fuse_ocr_with_xml, +) +from PIL import Image + + +class TestOCRFusion(unittest.TestCase): + def test_is_low_value_text(self): + # Test length 1 + self.assertTrue(_is_low_value_text("a")) + self.assertFalse(_is_low_value_text("Yes")) + self.assertFalse(_is_low_value_text("1")) + + # Test placeholders + self.assertTrue(_is_low_value_text("search")) + + # Test default markers + self.assertTrue(_is_low_value_text("[Icon]")) + + # Test valid text + self.assertFalse(_is_low_value_text("Click Me")) + + def test_parse_ocr_position(self): + position = [ + {"x": 10, "y": 20}, + {"x": 50, "y": 20}, + {"x": 50, "y": 60}, + {"x": 10, "y": 60}, + ] + bounds = _parse_ocr_position(position) + self.assertEqual(bounds, {"left": 10, "top": 20, "right": 50, "bottom": 60}) + + def test_calculate_overlap_ratio(self): + ocr_bounds = {"left": 10, "top": 10, "right": 30, "bottom": 30} + xml_bounds = {"left": 0, "top": 0, "right": 40, "bottom": 40} + # OCR is fully inside XML + self.assertEqual(_calculate_overlap_ratio(ocr_bounds, xml_bounds), 1.0) + + xml_bounds = {"left": 20, "top": 20, "right": 40, "bottom": 40} + # Intersection is 10x10 = 100. OCR area is 20x20 = 400. Ratio = 0.25 + self.assertEqual(_calculate_overlap_ratio(ocr_bounds, xml_bounds), 0.25) + + def test_detect_status_bar_height(self): + xml = [ + {"package": "com.android.systemui", "bounds": "[0,0][1080,120]"}, + {"package": "com.example.app", "bounds": "[0,120][1080,2400]"}, + ] + # Pass screen_height=2400, should find 120 from XML + self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 120) + + # Fallback: should be 4% of 2400 = 96 + xml = [{"package": "com.example.app", "bounds": "[0,0][1080,2400]"}] + self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 96) + + def test_crop_image_remove_status_bar(self): + # Create a dummy image + img = Image.new("RGB", (100, 100), color="red") + buffered = BytesIO() + img.save(buffered, format="PNG") + img_b64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + + cropped_b64, w, h = _crop_image_remove_status_bar(img_b64, crop_height=20) + self.assertEqual(w, 100) + self.assertEqual(h, 100) + + # Verify cropped size + cropped_img = Image.open(BytesIO(base64.b64decode(cropped_b64))) + self.assertEqual(cropped_img.size, (100, 80)) + + def test_map_coordinates_back(self): + ocr_results = [ + { + "text": "test", + "position": [{"x": 10, "y": 10}, {"x": 20, "y": 20}], + } + ] + mapped = _map_coordinates_back(ocr_results, status_bar_height=100) + self.assertEqual(mapped[0]["position"][0]["y"], 110) + self.assertEqual(mapped[0]["position"][1]["y"], 120) + + def test_fuse_ocr_with_xml(self): + xml = [ + { + "class": "android.widget.Button", + "bounds": "[10,100][200,150]", + "text": "Submit", + }, + { + "class": "android.widget.TextView", + "bounds": "[10,200][200,250]", + "text": "", + }, + ] + ocr = [ + # Should be discarded (similar to Submit) + { + "text": "Submit", + "position": [{"x": 15, "y": 105}, {"x": 95, "y": 145}], + }, + # Multiple OCRs matching the same node (TextView at [10,200]) + # They are on the same line, should be merged in order + { + "text": "Result", + "position": [{"x": 15, "y": 205}, {"x": 95, "y": 245}], + }, + { + "text": "Found", + "position": [{"x": 105, "y": 205}, {"x": 185, "y": 245}], + }, + # Unmatched OCRs that are close to each other (should be merged into one virtual node) + { + "text": "Floating", + "position": [{"x": 15, "y": 305}, {"x": 95, "y": 345}], + }, + { + "text": "Text", + "position": [{"x": 105, "y": 305}, {"x": 185, "y": 345}], + }, + # Unmatched OCR far away (should be separate virtual node) + { + "text": "Far", + "position": [{"x": 15, "y": 405}, {"x": 95, "y": 445}], + }, + ] + + fused = fuse_ocr_with_xml(xml, ocr) + + # Check first node (Submit) - should NOT have ocr_text + self.assertNotIn("ocr_text", fused[0]) + + # Check second node (Result Found) - should have aggregated ocr_elements + self.assertEqual(fused[1]["ocr_elements"][0]["text"], "Result Found") + self.assertIn("[OCR]", fused[1]["class"]) + + # We expect 0 virtual nodes now as per new strategy. + # Total nodes should be just the original 2 XML nodes. + self.assertEqual(len(fused), 2) + + def test_banded_overlap_matching(self): + # Screen is 1080x2400 + # XML has a giant container and a small correct button. + xml = [ + # Giant container (FrameLayout, covers a lot of screen) + { + "class": "android.widget.FrameLayout", + "bounds": "[0,80][1080,1000]", + "text": "", + }, + # Small correct button + { + "class": "android.widget.Button", + "bounds": "[185,84][512,114]", + "text": "", + }, + ] + + # OCR Search is slightly shifted to the left of the Button. + # Button: [185,84][512,114] -> area 327 * 30 = 9810 + # OCR: [174,89][326,109] -> area 152 * 20 = 3040 + # Overlap with Button: + # Intersection: [185,89][326,109] -> area 141 * 20 = 2820 + # Ratio: 2820 / 3040 = 0.927 (Band 1) + # Overlap with FrameLayout: + # OCR is fully inside FrameLayout [0,80][1080,1000] + # Ratio: 1.0 (Band 1) + # Both are in Band 1 (>=0.9). + # The Button has MUCH smaller area than FrameLayout (9810 < 993600). + # So Button should win despite having lower overlap (0.927 < 1.0). + ocr = [ + { + "text": "Search", + "position": [ + {"x": 174, "y": 89}, + {"x": 326, "y": 89}, + {"x": 326, "y": 109}, + {"x": 174, "y": 109}, + ], + } + ] + + fused = fuse_ocr_with_xml(xml, ocr) + + # Check that OCR text "Search" is mounted on the Button node (index 1) + self.assertIn("ocr_elements", fused[1]) + self.assertEqual(fused[1]["ocr_elements"][0]["text"], "Search") + + # And NOT on the FrameLayout node (index 0) + self.assertNotIn("ocr_elements", fused[0]) + + def test_giant_node_exclusion(self): + # Screen is 1080x2400. Area = 2,592,000. + # 10% area = 259,200. + # FrameLayout is 1080x1000 -> area 1,080,000 (>10%). + # It should be excluded from matching entirely. + # Since there is no other node, the OCR should be discarded. + xml = [ + { + "class": "android.widget.FrameLayout", + "bounds": "[0,0][1080,1000]", + "text": "", + }, + ] + ocr = [ + # Some floating developer text "dX : 0.0" + { + "text": "dX : 0.0", + "position": [ + {"x": 144, "y": 55}, + {"x": 215, "y": 55}, + {"x": 215, "y": 63}, + {"x": 144, "y": 63}, + ], + } + ] + + fused = fuse_ocr_with_xml(xml, ocr) + + # The giant FrameLayout should NOT have ocr_elements + self.assertNotIn("ocr_elements", fused[0]) + # The class should NOT be updated with [OCR] + self.assertNotIn("[OCR]", fused[0].get("class", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/utils/test_task_tree.py b/tests/unit/utils/test_task_tree.py new file mode 100644 index 0000000..2d6c941 --- /dev/null +++ b/tests/unit/utils/test_task_tree.py @@ -0,0 +1,650 @@ +# 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 hashlib +from artemis.utils.task_tree import ( + build_plan_and_history, + get_active_subgoal_hashes, + get_recent_subgoal_hashes, +) + + +def test_build_plan_and_history_separated_layout(): + plan = """- [ ] Open Settings app +- [ ] Navigate to System settings""" + + # No steps executed + output = build_plan_and_history(plan, [], "default") + assert plan in output + assert "--- Execution History ---" in output + assert "No steps executed yet." in output + + # With steps + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Launched settings app", + "operator_raw_thinking": "Thought 1", + "action_taken": [{"action": "launch_app"}], + "last_execution_result": {"status": "success"}, + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + assert plan in output + assert "--- Execution History ---" in output + assert "- **Step 1 (Most Recent Step, Start: 2.5s)**" in output + assert " - Thought 1" in output + assert " * [Planned Action]: Launched app 'None'" in output + + +def test_build_plan_and_history_granularities(): + plan = """- [x] Open Settings app +- [/] Navigate to System settings +- [ ] Navigate to Languages & input""" + + subgoal_hash_1 = hashlib.md5(b"Open Settings app").hexdigest() + subgoal_hash_2 = hashlib.md5(b"Navigate to System settings").hexdigest() + + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Launched settings", + "operator_raw_thinking": "Thought 1", + "action_taken": [{"action": "launch_app"}], + "last_execution_result": {"status": "success"}, + "extra_metadata": {"subgoal_hash": subgoal_hash_1}, + }, + { + "step_id": "step_2", + "step_number": 2, + "relative_time": "8.0s", + "summary": "Swiped down", + "operator_raw_thinking": "Thought 2", + "action_taken": [{"action": "swipe"}], + "last_execution_result": {"status": "success"}, + "extra_metadata": {"subgoal_hash": subgoal_hash_2}, + }, + { + "step_id": "step_3", + "step_number": 3, + "relative_time": "15.0s", + "summary": "Clicked System settings", + "operator_raw_thinking": "Thought 3", + "action_taken": [{"action": "click"}], + "last_execution_result": {"status": "success"}, + "extra_metadata": {"subgoal_hash": subgoal_hash_2}, + }, + ] + + # Test last_n_detailed = 0 (high-level analysis context, all summarized) + output_0 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=0) + assert "- *Step 1 (Start: 2.5s): Launched settings*" in output_0 + assert "- *Step 2 (Start: 8.0s): Swiped down*" in output_0 + assert "- *Step 3 (Start: 15.0s): Clicked System settings*" in output_0 + assert "*Thought*: Thought 3" not in output_0 + + # Test last_n_detailed = 1 (normal execution context, only last detailed) + output_1 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=1) + assert "- *Step 1 (Start: 2.5s): Launched settings*" in output_1 + assert "- *Step 2 (Start: 8.0s): Swiped down*" in output_1 + assert "- **Step 3 (Most Recent Step, Start: 15.0s)**" in output_1 + assert " - Thought 3" in output_1 + + # Test last_n_detailed = 3 (committee context, last 3 detailed) + output_3 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=3) + assert "- **Step 1 (Start: 2.5s)**" in output_3 + assert " - Thought 1" in output_3 + assert "- **Step 2 (Start: 8.0s)**" in output_3 + assert " - Thought 2" in output_3 + assert "- **Step 3 (Most Recent Step, Start: 15.0s)**" in output_3 + assert " - Thought 3" in output_3 + + # Test keep_subgoal_hashes (milestone filtering for checker/diagnoser) + # Only keep steps for subgoal_hash_2 + output_filter = build_plan_and_history( + plan, steps, subgoal_hash_2, keep_subgoal_hashes={subgoal_hash_2} + ) + assert "Launched settings" not in output_filter # Step 1 is filtered out! + assert "Swiped down" in output_filter # Step 2 belongs to subgoal_hash_2, kept! + assert " - Thought 3" in output_filter # Step 3 is the last step, kept detailed! + + +def test_get_active_subgoal_hashes_fallback(): + # Scenario 1: Normal Active Subgoal (Level 1 [/] active) + plan_normal = """- [x] Open Settings app +- [/] Navigate to System settings +- [ ] Navigate to Languages & input""" + + h_settings = hashlib.md5(b"Open Settings app").hexdigest() + h_system = hashlib.md5(b"Navigate to System settings").hexdigest() + + parent, sub = get_active_subgoal_hashes(plan_normal) + assert parent == h_system + assert sub is None + + # Scenario 2: Indented Sub-subgoal Active (Level 2 [/] active, consolidated to parent) + plan_indented = """- [x] Open Settings app +- [/] Navigate to System settings + - [/] Scroll down to find System settings +- [ ] Navigate to Languages & input""" + + parent, sub = get_active_subgoal_hashes(plan_indented) + assert parent == h_system + assert sub is None + + # Scenario 3: Safe Fallback (All pending "[ ]", no active subgoal) + plan_fallback = """- [ ] Open Settings app +- [ ] Navigate to System settings +- [ ] Navigate to Languages & input""" + + parent, sub = get_active_subgoal_hashes(plan_fallback) + assert parent == h_settings # Successfully fell back to the first subgoal's hash! + assert sub is None + + # Scenario 4: All Completed (All "[x]", no active subgoal) + plan_all_done = """- [x] Open Settings app +- [x] Navigate to System settings +- [x] Navigate to Languages & input""" + + parent, sub = get_active_subgoal_hashes(plan_all_done) + assert parent == "default" + assert sub is None + + +def test_build_plan_and_history_sliding_window(): + plan = """- [x] Subgoal 1 +- [x] Subgoal 2 +- [/] Subgoal 3""" + + subgoal_hash_1 = hashlib.md5(b"Subgoal 1").hexdigest() + subgoal_hash_2 = hashlib.md5(b"Subgoal 2").hexdigest() + subgoal_hash_3 = hashlib.md5(b"Subgoal 3").hexdigest() + + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "summary": "Step 1 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_1}, + }, + { + "step_id": "step_2", + "step_number": 2, + "summary": "Step 2 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_1}, + }, + { + "step_id": "step_3", + "step_number": 3, + "summary": "Step 3 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_2}, + }, + { + "step_id": "step_4", + "step_number": 4, + "summary": "Step 4 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_3}, + }, + { + "step_id": "step_5", + "step_number": 5, + "summary": "Step 5 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_3}, + }, + { + "step_id": "step_6", + "step_number": 6, + "summary": "Step 6 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_3}, + }, + { + "step_id": "step_7", + "step_number": 7, + "summary": "Step 7 summary", + "extra_metadata": {"subgoal_hash": subgoal_hash_3}, + }, + ] + + # Case 1: min_summaries = 2 + # subgoal 3 has 4 steps (4, 5, 6, 7). This already exceeds min_summaries=2. + # So older steps (1, 2, 3) from completed subgoals should be compressed/hidden! + output_c1 = build_plan_and_history( + plan, steps, subgoal_hash_3, min_summaries=2, last_n_detailed=0 + ) + assert "Step 1 summary" not in output_c1 + assert "Step 2 summary" not in output_c1 + assert "Step 3 summary" not in output_c1 + assert "Step 4 summary" in output_c1 + assert "Step 5 summary" in output_c1 + assert "Step 6 summary" in output_c1 + assert "Step 7 summary" in output_c1 + + # Case 2: min_summaries = 5 + # subgoal 3 has 4 steps (4, 5, 6, 7), which translates to 3 non-last steps. + # To reach min_summaries=5 non-last steps, the sliding window slides back to keep Step 3 and Step 2 visible! + # But Step 1 should still be compressed/hidden. + output_c2 = build_plan_and_history( + plan, steps, subgoal_hash_3, min_summaries=5, last_n_detailed=0 + ) + assert "Step 1 summary" not in output_c2 + assert "Step 2 summary" in output_c2 # Kept visible by sliding window! + assert "Step 3 summary" in output_c2 # Kept visible by sliding window! + assert "Step 4 summary" in output_c2 + assert "Step 5 summary" in output_c2 + assert "Step 6 summary" in output_c2 + assert "Step 7 summary" in output_c2 + + +def test_build_plan_and_history_clean_success_result(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Swiped screen", + "action_taken": [{"action": "swipe"}], + "last_execution_result": { + "executed_actions": [{"action": "swipe"}], + "status": "success", + }, + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + + # The output should contain "status": "success" but NOT the redundant "executed_actions" + # Success results are stripped under Factual Realism + assert "* [Validator Execution Result]" not in output + assert "executed_actions" not in output + + +def test_build_plan_and_history_with_tool_calls(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Read note and swiped", + "action_taken": [{"action": "swipe"}], + "last_execution_result": {"status": "success"}, + "tool_calls": [ + { + "name": "read_note", + "payload": { + "args": {"key": "other_note"}, + "result": "- [ ] Milestone", + }, + "status": "success", + } + ], + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + + assert "* [Operator Decision Loop]:" in output + assert '`read_note({"key": "other_note"})` -> - [ ] Milestone' in output + + +def test_build_plan_and_history_with_interleaved_events(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Read note and thought", + "action_taken": [{"action": "swipe"}], + "last_execution_result": {"status": "success"}, + "interleaved_events": [ + {"type": "thought", "content": "I want to search for files."}, + { + "type": "tool_call", + "name": "list_notes", + "args": {"dir": "/notes"}, + "result": ["notes.txt"], + }, + {"type": "thought", "content": "I see notes.txt, let's read it."}, + { + "type": "tool_call", + "name": "read_note", + "args": {"file": "notes.txt"}, + "result": "Milestone plan", + }, + ], + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + + # It should render interleaved thoughts and tool calls in natural order + assert " - I want to search for files." in output + assert ' - [Tool Call]: `list_notes({"dir": "/notes"})` -> [\'notes.txt\']' in output + assert " - I see notes.txt, let's read it." in output + assert ' - [Tool Call]: `read_note({"file": "notes.txt"})` -> Milestone plan' in output + + # It should use the new section header + assert "* [Operator Decision Loop]:" in output + + +def test_build_plan_and_history_concatenates_thoughts(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Step with multiple thoughts", + "action_taken": [{"action": "click", "thought": "Action thought"}], + "operator_raw_thinking": "Raw thought", + "last_execution_result": {"status": "success"}, + "interleaved_events": [ + {"type": "thought", "content": "Event thought"}, + ], + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + + # Check that all thoughts are concatenated and visible in the output + assert " - Action thought" in output + assert " - Event thought" in output + assert " - Raw thought" in output + + +def test_build_plan_and_history_chronological_last_step(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Completed search", + "action_taken": [{"action": "click", "target_text": "Search"}], + "last_execution_result": {"status": "success"}, + "interleaved_events": [ + {"type": "thought", "content": "I should find search icon."}, + { + "type": "tool_call", + "name": "ask_explorer", + "args": {"query": "search icon"}, + "result": {"center": [500, 600]}, + }, + { + "type": "native_thought", + "content": "Clicking coordinates [500, 600] now.", + }, + ], + } + ] + output = build_plan_and_history( + plan, steps, "default", last_n_detailed=1, chronological_last_step=True + ) + + assert plan in output + assert "--- Execution History ---" in output + assert "- **Step 1 (Most Recent Step, Start: 2.5s)**" in output + assert "* [Operator Decision Loop]:" in output + assert " - I should find search icon." in output + assert ( + '- [Tool Call]: `ask_explorer({"query": "search icon"})` -> {"center":' + " [500, 600]}" in output + ) + assert " - Clicking coordinates [500, 600] now." in output + assert "* [Planned Action]: Tapped 'Search' at None" in output + assert "* [Validator Execution Result]" not in output + + +def test_build_plan_and_history_safety_net_and_failure_analyzer(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "5.0s", + "summary": "Repaired click search", + "action_taken": [{"action": "click", "target_text": "Search"}], + "last_execution_result": {"status": "success"}, + "interleaved_events": [ + {"type": "thought", "content": "Let's click search."}, + # Safety net failure + { + "type": "tool_call", + "name": "safety_net_validation", + "args": {"target": "Search"}, + "result": [ + False, + "TARGET_DISAPPEARED", + "Target button is not visible", + ], + }, + # Failure Analyzer intervention + { + "type": "failure_analyzer_thought", + "content": ("The search button is covered by keyboard, need to go BACK first."), + }, + { + "type": "tool_call", + "name": "_exec_press_key", + "args": {"key": "BACK", "state": ""}, + "result": ["Pressed key BACK", "", "/path/to/shot.jpg"], + }, + { + "type": "failure_analyzer_thought", + "content": "Keyboard is closed, now re-executing search tap.", + }, + { + "type": "tool_call", + "name": "_exec_click", + "args": {"target": [500, 600]}, + "result": ["Tapped search button successfully", ""], + }, + { + "type": "tool_call", + "name": "report_failure_analysis", + "args": { + "status": "fixed", + "analysis": "Closed keyboard and tapped search.", + }, + "result": "Success", + }, + ], + } + ] + output = build_plan_and_history( + plan, steps, "default", last_n_detailed=1, chronological_last_step=True + ) + + assert "- **Step 1 (Most Recent Step, Start: 5.0s)**" in output + assert "* [Operator Decision Loop]:" in output + assert " - Let's click search." in output + assert ( + "* [Planned Action]: Tapped 'Search' at None (Intercepted by" + " Pre-Execution Safety Net)" in output + ) + assert "* [Pre-Execution Safety Net]:" in output + assert "- [Safety Net Check]: (Action not executed: Target button is not visible)" in output + assert "* [Failure Analyzer Recovery Loop]:" in output + assert " - [Tool Call]: Pressed key 'BACK'" in output + assert " - [Tool Call]: Tapped element at [500, 600]" in output + + +def test_build_plan_and_history_repaired_result(): + plan = "- [ ] Subgoal 1" + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "2.5s", + "summary": "Tapped button with repair", + "action_taken": [{"action": "click"}], + "last_execution_result": { + "execution": [ + { + "action": "click", + "attempts": ["pre-validation failed"], + "repair": ("Tapped center to wake controls and then tapped target"), + } + ], + "status": "success", + "repair_status": "fixed", + }, + } + ] + output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) + assert " * [Validator Execution Result]: Action executed." not in output + assert " * [Result]: Repaired: Tapped center to wake controls and then tapped target" in output + + +def test_get_recent_subgoal_hashes_robust_milestone_exclusion(tmp_path): + # Scenario: + # 1. Milestone 1 is completed (hash_completed_1). + # 2. We had a failed attempt 'hash_failed_old' during Milestone 1. + # 3. Milestone 2 is completed (hash_completed_2). + # 4. We are now working on Milestone C (hash_active_c). + # 5. Milestone C had two previous failed renamed versions: Failed Attempt A and Failed Attempt B. + # 6. subgoal_hash_chain.json maps: A -> B -> C. + + base_dir = tmp_path + notes_dir = base_dir / "notes" + notes_dir.mkdir(parents=True, exist_ok=True) + chain_path = notes_dir / "subgoal_hash_chain.json" + + hash_completed_1 = hashlib.md5(b"Completed Milestone 1").hexdigest() + hash_completed_2 = hashlib.md5(b"Completed Milestone 2").hexdigest() + hash_active_c = hashlib.md5(b"Active Milestone C").hexdigest() + + hash_failed_old = hashlib.md5(b"Failed Attempt Old").hexdigest() + hash_failed_a = hashlib.md5(b"Failed Attempt A").hexdigest() + hash_failed_b = hashlib.md5(b"Failed Attempt B").hexdigest() + + # Write the hash chain representing: A -> B -> C + import json + + chain_data = {hash_failed_a: hash_failed_b, hash_failed_b: hash_active_c} + chain_path.write_text(json.dumps(chain_data), encoding="utf-8") + + steps = [ + # Steps from Completed Milestone 1 + {"step_id": "s1", "extra_metadata": {"subgoal_hash": hash_completed_1}}, + # Old failed attempt during Milestone 1 + {"step_id": "s2", "extra_metadata": {"subgoal_hash": hash_failed_old}}, + # Steps from Completed Milestone 2 (Most recent completed milestone) + {"step_id": "s3", "extra_metadata": {"subgoal_hash": hash_completed_2}}, + {"step_id": "s4", "extra_metadata": {"subgoal_hash": hash_completed_2}}, + # Steps from Failed Attempt A (Part of the active task slot's past renames) + {"step_id": "s5", "extra_metadata": {"subgoal_hash": hash_failed_a}}, + {"step_id": "s6", "extra_metadata": {"subgoal_hash": hash_failed_a}}, + # Steps from Failed Attempt B (Part of the active task slot's past renames) + {"step_id": "s7", "extra_metadata": {"subgoal_hash": hash_failed_b}}, + {"step_id": "s8", "extra_metadata": {"subgoal_hash": hash_failed_b}}, + # Steps from Active Milestone C + {"step_id": "s9", "extra_metadata": {"subgoal_hash": hash_active_c}}, + ] + + # Test raw transitive alias resolver + from artemis.utils.task_tree import get_all_subgoal_aliases + + aliases = get_all_subgoal_aliases(hash_active_c, base_dir) + assert aliases == {hash_active_c, hash_failed_b, hash_failed_a} + + keep_hashes = get_recent_subgoal_hashes(steps, hash_active_c, base_dir) + + # Should keep: + # 1. The current active subgoal (hash_active_c) + assert hash_active_c in keep_hashes + + # 2. The failed/renamed subgoals during active slot (hash_failed_b, hash_failed_a) - resolved transitively via chain + assert hash_failed_b in keep_hashes + assert hash_failed_a in keep_hashes + + # 3. The single most recent completed subgoal (hash_completed_2) for continuity context + assert hash_completed_2 in keep_hashes + + # Should prune: + # 4. Older completed subgoals (hash_completed_1) + assert hash_completed_1 not in keep_hashes + + # 5. Older failed subgoals from previously completed milestones (hash_failed_old) + assert hash_failed_old not in keep_hashes + + +def test_build_plan_and_history_for_failure_analyzer(): + plan_with_subgoals = """- [/] Goal 1 + - [x] Subtask 1.1 + - [/] Subtask 1.2""" + + steps = [ + { + "step_id": "step_1", + "step_number": 1, + "relative_time": "12.3s", + "summary": "Tapped full screen", + "action_taken": [{"action": "click", "target_text": "Fullscreen"}], + "operator_raw_thinking": "Let's tap it", + "operator_native_thinking": "Thinking to tap", + "last_execution_result": {"status": "failed"}, + "interleaved_events": [ + {"type": "thought", "content": "I should find fullscreen icon."}, + { + "type": "tool_call", + "name": "safety_net_validation", + "args": {"target": "Fullscreen"}, + "result": [ + False, + "TARGET_DISAPPEARED", + "Target button is not visible", + ], + }, + ], + } + ] + + # Test with subgoals and for_failure_analyzer=True + output_analyzer_with_plan = build_plan_and_history( + plan_with_subgoals, + steps, + "default", + last_n_detailed=1, + for_failure_analyzer=True, + ) + assert "--- Task Plan ---" in output_analyzer_with_plan + assert ( + "*(Note: Provided for context only. Do not execute the remaining" + " plan.)*" in output_analyzer_with_plan + ) + assert "--- Execution History ---" in output_analyzer_with_plan + assert ( + "- **Step 1 (Most Recent Step (Failed to execute, this is the step you" + " need to focus on and repair), Start: 12.3s)**" in output_analyzer_with_plan + ) + assert "[Operator Decision Loop]" in output_analyzer_with_plan + assert "I should find fullscreen icon." in output_analyzer_with_plan + assert ( + "* [Planned Action]: Tapped 'Fullscreen' at None (Intercepted by" + " Pre-Execution Safety Net)" in output_analyzer_with_plan + ) + + # Test with empty plan and for_failure_analyzer=True (should completely omit plan block) + output_analyzer_empty_plan = build_plan_and_history( + "", steps, "default", last_n_detailed=1, for_failure_analyzer=True + ) + assert "--- Task Plan ---" not in output_analyzer_empty_plan + assert ( + "*(Note: Provided for context only. Do not execute the remaining" + " plan.)*" not in output_analyzer_empty_plan + ) + assert "--- Execution History ---" in output_analyzer_empty_plan diff --git a/tests/unit/utils/test_ui_filter.py b/tests/unit/utils/test_ui_filter.py new file mode 100644 index 0000000..f762838 --- /dev/null +++ b/tests/unit/utils/test_ui_filter.py @@ -0,0 +1,349 @@ +# 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 artemis.utils.ui_filter import ( + _clip_bounds, + _is_semantic_empty, + filter_node, + filter_ui_hierarchy, +) + + +def test_is_semantic_empty(): + # Interactive or textual nodes are NOT empty + assert not _is_semantic_empty({"text": "Hello"}) + assert not _is_semantic_empty({"content-desc": "Button"}) + assert not _is_semantic_empty({"clickable": True}) + assert not _is_semantic_empty({"clickable": "true"}) + assert not _is_semantic_empty({"focusable": True}) + assert not _is_semantic_empty({"className": "android.widget.ImageView"}) + + # Empty containers ARE semantic empty + assert _is_semantic_empty( + { + "text": "", + "clickable": False, + "className": "android.widget.FrameLayout", + } + ) + assert _is_semantic_empty({}) + + +def test_clip_bounds(): + node = {"bounds": "[10,-10][1200,100]"} + bounds = {"left": 10, "top": -10, "right": 1200, "bottom": 100} + ancestor_bounds = {"left": 0, "top": 0, "right": 1080, "bottom": 2400} + clipped = _clip_bounds(node, bounds, ancestor_bounds) + + assert clipped["left"] == 10 + assert clipped["top"] == 0 + assert clipped["right"] == 1080 + assert clipped["bottom"] == 100 + assert node["bounds"] == "[10,0][1080,100]" + assert "is_clipped" not in node + + +def test_clip_bounds_fully_clipped(): + node = {"bounds": "[1200,2500][1300,2600]"} + bounds = {"left": 1200, "top": 2500, "right": 1300, "bottom": 2600} + ancestor_bounds = {"left": 0, "top": 0, "right": 1080, "bottom": 2400} + clipped = _clip_bounds(node, bounds, ancestor_bounds) + + assert clipped["left"] == 1080 + assert clipped["top"] == 2400 + assert clipped["right"] == 1080 + assert clipped["bottom"] == 2400 + assert node["is_clipped"] is True + assert node["bounds"] == "[1080,2400][1080,2400]" + + +def test_semantic_hoisting(): + hierarchy = [ + { + "bounds": "[0,0][100,100]", + "text": "", + "clickable": False, + "children": [ + { + "bounds": "[10,10][90,90]", + "text": "Child Button", + "clickable": True, + } + ], + } + ] + + # Mode: hoist + res_hoist = filter_ui_hierarchy(hierarchy, semantic_pruning="hoist") + assert len(res_hoist) == 1 + assert res_hoist[0]["text"] == "Child Button" + + # Mode: safe + res_safe = filter_ui_hierarchy(hierarchy, semantic_pruning="safe") + assert len(res_safe) == 1 + assert res_safe[0]["text"] == "" + assert len(res_safe[0]["children"]) == 1 + + +def test_dynamic_min_size(): + # Short side is 1000. 0.5% of 1000 is 5px. + # Element size is 4x4 -> filtered out. + node = {"bounds": "[0,0][4,4]", "text": "Tiny", "clickable": True} + res = filter_node(node, 1000, 2000, min_element_size=2, use_dynamic_min_size=True) + assert res is None + + # Element size is 6x6 -> kept. + node = {"bounds": "[0,0][6,6]", "text": "Good", "clickable": True} + res = filter_node(node, 1000, 2000, min_element_size=2, use_dynamic_min_size=True) + assert res is not None + + +def test_ancestor_bounds_propagation(): + hierarchy = { + "bounds": "[0,0][100,100]", + "text": "Parent", + "clickable": True, + "children": [{"bounds": "[50,50][150,150]", "text": "Child", "clickable": True}], + } + + # The child is at [50,50][150,150], but parent is [0,0][100,100]. + # So child should be clipped to [50,50][100,100]. + res = filter_node(hierarchy, 1000, 1000, clip_bounds=True) + assert res is not None + assert res["children"][0]["bounds"] == "[50,50][100,100]" + + +def test_sibling_merging(): + hierarchy = { + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.ImageView", + "bounds": "[10,10][50,50]", + "clickable": False, + "focusable": False, + }, + { + "className": "android.widget.TextView", + "text": "Settings", + "bounds": "[60,10][150,50]", + "clickable": False, + "focusable": False, + }, + ], + } + + # The parent is clickable, has 1 text child and 1 image child without text. + # They should be merged! + res = filter_node(hierarchy, 1000, 1000) + assert res is not None + assert "children" in res + assert len(res["children"]) == 1 + assert res["children"][0]["text"] == "Settings" + assert res["children"][0]["content-desc"] == "[Icon]" + assert res["children"][0]["bounds"] == "[10,10][150,50]" + + +def test_sibling_merging_not_triggered_if_child_clickable(): + hierarchy = { + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.ImageView", + "bounds": "[10,10][50,50]", + "clickable": True, # Child is clickable + "focusable": False, + }, + { + "className": "android.widget.TextView", + "text": "Settings", + "bounds": "[60,10][150,50]", + "clickable": False, + "focusable": False, + }, + ], + } + + res = filter_node(hierarchy, 1000, 1000) + assert res is not None + assert "children" in res + assert len(res["children"]) == 2 # No merge + + +def test_sibling_merging_not_triggered_if_form_control(): + hierarchy = { + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.CheckBox", # Form control + "bounds": "[10,10][50,50]", + "clickable": True, # Make it clickable so it's not pruned + "focusable": False, + }, + { + "className": "android.widget.TextView", + "text": "Enable", + "bounds": "[60,10][150,50]", + "clickable": False, + "focusable": False, + }, + ], + } + + res = filter_node(hierarchy, 1000, 1000) + assert res is not None + assert "children" in res + assert len(res["children"]) == 2 # No merge + + +def test_parent_child_redundancy(): + # Case 1: Redundant TextView removed + hierarchy = { + "text": "Confirm", + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.TextView", + "text": "Confirm", + "bounds": "[10,10][190,190]", + "clickable": False, + "focusable": False, + } + ], + } + res = filter_node(hierarchy, 1000, 1000) + assert res is not None + assert "children" in res + assert len(res["children"]) == 0 # Child removed + + # Case 2: Redundant CheckBox text cleared + hierarchy2 = { + "text": "Confirm", + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.CheckBox", + "text": "Confirm", + "bounds": "[10,10][190,190]", + "clickable": False, + "focusable": False, + } + ], + } + res2 = filter_node(hierarchy2, 1000, 1000) + assert res2 is not None + assert "children" in res2 + assert len(res2["children"]) == 1 + assert res2["children"][0]["text"] == "" + + # Case 3: Redundant interactive node text cleared + hierarchy3 = { + "text": "Confirm", + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.TextView", + "text": "Confirm", + "bounds": "[10,10][190,190]", + "clickable": True, + "focusable": False, + } + ], + } + res3 = filter_node(hierarchy3, 1000, 1000) + assert res3 is not None + assert "children" in res3 + assert len(res3["children"]) == 1 + assert res3["children"][0]["text"] == "" + + # Case 4: No match + hierarchy4 = { + "text": "Confirm", + "clickable": True, + "bounds": "[0,0][200,200]", + "children": [ + { + "className": "android.widget.TextView", + "text": "Cancel", + "bounds": "[10,10][190,190]", + "clickable": False, + "focusable": False, + } + ], + } + res4 = filter_node(hierarchy4, 1000, 1000) + assert res4 is not None + assert "children" in res4 + assert len(res4["children"]) == 1 + assert res4["children"][0]["text"] == "Cancel" + + +def test_fixed_system_bars_clamping_and_discarding(): + # Simulate a full-width fixed bottom navigation bar at [0, 2250][1080, 2400] + # and two candidates: one slightly occluded (should be safely clamped) and one mostly covered (should be discarded). + hierarchy = [ + { + "resource-id": "com.google.android.youtube:id/bottom_navigation", + "bounds": "[0,2250][1080,2400]", + "scrollable": False, + "clickable": False, + }, + { + "text": "Partially occluded video item", + "bounds": "[0,2100][1080,2300]", + "scrollable": False, + "clickable": True, + }, + { + "text": "Almost entirely covered sliver item", + "bounds": "[0,2245][1080,2300]", + "scrollable": False, + "clickable": True, + }, + ] + + filtered = filter_ui_hierarchy( + hierarchy, screen_width=1080, screen_height=2400, min_element_size=20 + ) + # The bottom_navigation bar itself is kept (or filtered depending on semantics, here kept if not empty / checked). + # Specifically check the clamped item and discarded item: + texts = [el.get("text") for el in filtered] + assert "Partially occluded video item" in texts + assert "Almost entirely covered sliver item" not in texts + + clamped_item = next(el for el in filtered if el.get("text") == "Partially occluded video item") + # Its original bottom was 2300, clamped safely to the bar's top at 2250 + assert clamped_item["bounds"] == "[0,2100][1080,2250]" + + +def test_mutual_occlusion_warning_injection(): + from artemis.utils.visualization import format_minimal_list_with_elements + + # Simulate two overlapping non-ancestor interactive items with >= 50% overlap + fused_xml = [ + {"text": "Search Video Result", "bounds": "[50,1800][1000,2000]"}, + {"text": "Create Floating Button", "bounds": "[750,1850][1000,2000]"}, + ] + + minimal_list, elements, labels = format_minimal_list_with_elements( + fused_xml, width=1080, height=2400 + ) + assert "WARNING: may overlap with [2], possible occlusion" in minimal_list + assert "WARNING: may overlap with [1], possible occlusion" in minimal_list diff --git a/tests/unit/utils/test_ui_hierarchy.py b/tests/unit/utils/test_ui_hierarchy.py new file mode 100644 index 0000000..9a8642a --- /dev/null +++ b/tests/unit/utils/test_ui_hierarchy.py @@ -0,0 +1,243 @@ +# 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 patch + +from artemis.utils.ui_hierarchy import ( + ElementBounds, + Point, + find_element_by_resource_id, + get_bounds_for_element, + get_element_text, + is_element_focused, + text_input_is_empty, +) + + +def test_text_input_is_empty(): + assert text_input_is_empty(text=None, hint_text=None) + assert text_input_is_empty(text="", hint_text=None) + assert text_input_is_empty(text="", hint_text="") + assert text_input_is_empty(text="text", hint_text="text") + + assert not text_input_is_empty(text="text", hint_text=None) + assert not text_input_is_empty(text="text", hint_text="") + + +def test_find_element_by_resource_id(): + ui_hierarchy = [ + { + "resourceId": "com.example:id/button1", + "text": "Button 1", + "children": [], + }, + { + "resourceId": "com.example:id/container", + "children": [ + { + "resourceId": "com.example:id/nested_button", + "text": "Nested Button", + "children": [], + } + ], + }, + ] + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button1") + assert result is not None + assert result["resourceId"] == "com.example:id/button1" + assert result["text"] == "Button 1" + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/nested_button") + assert result is not None + assert result["resourceId"] == "com.example:id/nested_button" + assert result["text"] == "Nested Button" + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/nonexistent") + assert result is None + + result = find_element_by_resource_id([], "com.example:id/button1") + assert result is None + + +def test_find_element_by_resource_id_with_index(): + ui_hierarchy = [ + { + "resourceId": "com.example:id/button", + "text": "Button 1", + "children": [], + }, + { + "resourceId": "com.example:id/button", + "text": "Button 2", + "children": [], + }, + { + "resourceId": "com.example:id/container", + "children": [ + { + "resourceId": "com.example:id/button", + "text": "Button 3", + "children": [], + } + ], + }, + ] + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=0) + assert result is not None + assert result["text"] == "Button 1" + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=1) + assert result is not None + assert result["text"] == "Button 2" + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=2) + assert result is not None + assert result["text"] == "Button 3" + + result = find_element_by_resource_id(ui_hierarchy, "com.example:id/button", index=3) + assert result is None + + +def test_find_element_by_resource_id_rich_hierarchy(): + rich_hierarchy = [ + { + "attributes": {"resource-id": "com.example:id/button1"}, + "children": [], + }, + { + "attributes": {"resource-id": "com.example:id/container"}, + "children": [ + { + "attributes": {"resource-id": "com.example:id/nested_button"}, + "children": [], + } + ], + }, + ] + + result = find_element_by_resource_id( + rich_hierarchy, "com.example:id/button1", is_rich_hierarchy=True + ) + assert result is not None + assert result["resource-id"] == "com.example:id/button1" + + result = find_element_by_resource_id( + rich_hierarchy, "com.example:id/nested_button", is_rich_hierarchy=True + ) + assert result is not None + assert result["resource-id"] == "com.example:id/nested_button" + + result = find_element_by_resource_id( + rich_hierarchy, "com.example:id/nonexistent", is_rich_hierarchy=True + ) + assert result is None + + +def test_is_element_focused(): + focused_element = {"focused": "true"} + assert is_element_focused(focused_element) + + non_focused_element = {"focused": "false"} + assert not is_element_focused(non_focused_element) + + no_focused_element = {"text": "some text"} + assert not is_element_focused(no_focused_element) + + none_focused_element = {"focused": None} + assert not is_element_focused(none_focused_element) + + +def test_get_element_text(): + element = {"text": "Button Text", "hintText": "Hint Text"} + assert get_element_text(element) == "Button Text" + assert get_element_text(element, hint_text=False) == "Button Text" + assert get_element_text(element, hint_text=True) == "Hint Text" + + element_no_text = {"hintText": "Hint Text"} + assert get_element_text(element_no_text) is None + assert get_element_text(element_no_text, hint_text=True) == "Hint Text" + element_no_hint = {"text": "Button Text"} + assert get_element_text(element_no_hint) == "Button Text" + assert get_element_text(element_no_hint, hint_text=True) is None + + empty_element = {} + assert get_element_text(empty_element) is None + assert get_element_text(empty_element, hint_text=True) is None + + +def test_get_bounds_for_element(): + element_with_bounds = {"bounds": {"x": 10, "y": 20, "width": 100, "height": 50}} + bounds = get_bounds_for_element(element_with_bounds) + assert bounds is not None + assert isinstance(bounds, ElementBounds) + assert bounds.x == 10 + assert bounds.y == 20 + assert bounds.width == 100 + assert bounds.height == 50 + + element_no_bounds = {"text": "Button"} + bounds = get_bounds_for_element(element_no_bounds) + assert bounds is None + + # Suppress logger output for the invalid bounds test case + with patch("artemis.utils.ui_hierarchy.logger.error"): + element_invalid_bounds = { + "bounds": { + "x": "invalid", # Should be int + "y": 20, + "width": 100, + "height": 50, + } + } + bounds = get_bounds_for_element(element_invalid_bounds) + assert bounds is None + + +def test_element_bounds(): + bounds = ElementBounds(x=10, y=20, width=100, height=50) + + center = bounds.get_center() + assert isinstance(center, Point) + assert center.x == 60 + assert center.y == 45 + + center_point = bounds.get_relative_point(0.5, 0.5) + assert isinstance(center_point, Point) + assert center_point.x == 60 + assert center_point.y == 45 + + top_left = bounds.get_relative_point(0.0, 0.0) + assert top_left.x == 10 + assert top_left.y == 20 + + bottom_right = bounds.get_relative_point(1.0, 1.0) + assert bottom_right.x == 110 + assert bottom_right.y == 70 + custom_point = bounds.get_relative_point(0.95, 0.95) + assert custom_point.x == 105 + assert custom_point.y == 67 + + +if __name__ == "__main__": + test_text_input_is_empty() + test_find_element_by_resource_id() + test_find_element_by_resource_id_with_index() + test_find_element_by_resource_id_rich_hierarchy() + test_is_element_focused() + test_get_element_text() + test_get_bounds_for_element() + test_element_bounds() + print("All tests passed") diff --git a/tests/unit/utils/test_verification.py b/tests/unit/utils/test_verification.py new file mode 100644 index 0000000..7a5b1cf --- /dev/null +++ b/tests/unit/utils/test_verification.py @@ -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. + +import json +from artemis.utils.verification import ( + append_verification_chat, + get_verification_chat_path, + get_verification_chat_rounds, + read_verification_chat, +) + + +def test_get_verification_chat_path(tmp_path): + subgoal_hash = "test_hash" + expected_path = tmp_path / "notes" / f"verification_chat_{subgoal_hash}.json" + path = get_verification_chat_path(tmp_path, subgoal_hash) + assert path == expected_path + + +def test_read_verification_chat_not_exists(tmp_path): + path = tmp_path / "non_existent.json" + turns = read_verification_chat(path) + assert turns == [] + + +def test_read_verification_chat_exists(tmp_path): + path = tmp_path / "chat.json" + expected_turns = [{"role": "operator", "round": 1, "content": "hello"}] + path.write_text(json.dumps(expected_turns), encoding="utf-8") + turns = read_verification_chat(path) + assert turns == expected_turns + + +def test_get_verification_chat_rounds(): + turns = [ + {"role": "operator", "round": 1, "content": "op1"}, + {"role": "checker", "round": 1, "content": "chk1"}, + {"role": "operator", "round": 2, "content": "op2"}, + ] + max_op, max_chk = get_verification_chat_rounds(turns) + assert max_op == 2 + assert max_chk == 1 + + +def test_get_verification_chat_rounds_empty(): + max_op, max_chk = get_verification_chat_rounds([]) + assert max_op == 0 + assert max_chk == 0 + + +def test_append_verification_chat(tmp_path): + path = tmp_path / "chat.json" + + # Append to empty/non-existent + success = append_verification_chat(path, "operator", "hello", 1) + assert success + assert path.exists() + turns = json.loads(path.read_text(encoding="utf-8")) + assert turns == [{"role": "operator", "round": 1, "content": "hello"}] + + # Append again + success = append_verification_chat(path, "checker", "hi", 2) + assert success + turns = json.loads(path.read_text(encoding="utf-8")) + assert len(turns) == 2 + assert turns[1] == {"role": "checker", "round": 2, "content": "hi"}