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