test: add comprehensive unit, integration, and e2e test suites

Introduce complete test coverage for the ARTEMIS platform across multiple layers:
Unit tests: Add tests for agent nodes (Planner, Operator, Validator, Diagnoser, FlashRunner, Explorer), tools, utils (OCR/XML fusion, UI hierarchy), and data engine.
Integration tests: Add live tool verification, reactive runner, IME, and standalone workflow tests.
E2E tests: Add end-to-end scenario tests for media playback and failure recovery flows.
Fixtures & mocks: Add mock inputs (audio, video, screenshot) and test utilities.
This commit is contained in:
chiwanko11 committed 2026-08-13 12:18:32 -07:00
1 parent c1803fcb86
commit 5d2283deaa
115 files changed
+22813

No files matched your search

@@ -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"
+190
View File
@@ -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"
+158
View File
@@ -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
+603
View File
@@ -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"]
+222
View File
@@ -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()
+375
View File
@@ -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
@@ -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
+182
View File
@@ -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
+92
View File
@@ -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,
)
+208
View File
@@ -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
@@ -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
+158
View File
@@ -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
+115
View File
@@ -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
+116
View File
@@ -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
+162
View File
@@ -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
@@ -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
@@ -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"
+238
View File
@@ -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
+174
View File
@@ -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
@@ -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 = "<hierarchy><node text='Home'/></hierarchy>"
return ctx
@pytest.fixture
def mock_driver():
driver = MagicMock(spec=BaseDeviceDriver)
screen_data = MagicMock()
screen_data.ui_hierarchy_xml = "<hierarchy><node text='Settings'/></hierarchy>"
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 "<hierarchy><node text='Home'/></hierarchy>" 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 "<hierarchy><node text='Settings'/></hierarchy>" 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="<hierarchy><node text='DirectDriver'/></hierarchy>"
)
result = await get_ui_hierarchy.execute(driver=driver)
driver.get_ui_hierarchy.assert_called_once()
assert "<hierarchy><node text='DirectDriver'/></hierarchy>" 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 "<hierarchy><node text='Home'/></hierarchy>" 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 "<node text='Home'/>" 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 "<node text='Home'/>" in result
+140
View File
@@ -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"
@@ -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
+177
View File
@@ -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
@@ -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"
+180
View File
@@ -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"
+262
View File
@@ -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
+255
View File
@@ -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"
@@ -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"
+208
View File
@@ -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"
+457
View File
@@ -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'<hierarchy><node bounds="[0,0][1000,1000]"><node'
b' bounds="[0,0][100,100]"/></node></hierarchy>'
)
)
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__])
@@ -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"
@@ -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"
+114
View File
@@ -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)