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

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

No files matched your search

+603
View File
@@ -0,0 +1,603 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from artemis.context import ArtemisContext
from artemis.tools.command_tool import (
_BACKGROUND_TASKS,
_PERSISTENT_ENVIRONMENTS,
get_manage_task_tool,
get_run_adb_command_tool,
get_run_short_adb_command_tool,
)
import pytest
class MockProcess:
def __init__(
self,
output_bytes: bytes,
exit_code: int = 0,
pid: int = 12345,
delay: float = 0,
):
self.output_bytes = output_bytes
self.exit_code = exit_code
self.pid = pid
self.returncode = None
self._read_offset = 0
self.delay = delay
self.stdin = MagicMock()
self.stdin.write = MagicMock()
self.stdin.drain = AsyncMock()
self.stdin.close = MagicMock()
self.stdout = MagicMock()
self.stdout.readline = AsyncMock(side_effect=self._readline)
async def _readline(self) -> bytes:
if self.delay > 0:
await asyncio.sleep(self.delay)
self.delay = 0 # Only delay the first read
if self._read_offset >= len(self.output_bytes):
self.returncode = self.exit_code
return b""
idx = self.output_bytes.find(b"\n", self._read_offset)
if idx == -1:
line = self.output_bytes[self._read_offset :]
self._read_offset = len(self.output_bytes)
else:
line = self.output_bytes[self._read_offset : idx + 1]
self._read_offset = idx + 1
return line
async def wait(self) -> int:
self.returncode = self.exit_code
return self.exit_code
def terminate(self):
self.returncode = -15
self.exit_code = -15
async def communicate(self) -> tuple[bytes, bytes]:
lines = []
while True:
line = await self._readline()
if not line:
break
lines.append(line)
self.returncode = self.exit_code
return b"".join(lines), b""
@pytest.fixture
def mock_ctx():
ctx = MagicMock(spec=ArtemisContext)
ctx.data_engine = None
ctx.device = MagicMock()
ctx.device.device_id = "test_device_1234"
return ctx
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_command_sync(mock_exec, mock_ctx):
# Mock subprocess output
mock_process = MockProcess(
output_bytes=b"hello world\n===EXIT_CODE===0\n===ENV_START===\n",
exit_code=0,
)
mock_exec.return_value = mock_process
run_command = get_run_adb_command_tool(mock_ctx)
result = await run_command.ainvoke(
{
"CommandLine": "echo 'hello world'",
"Cwd": "/data/local/tmp",
"RunPersistent": False,
"WaitMsBeforeAsync": 500,
}
)
assert "completed with exit code 0" in result
assert "hello world" in result
mock_exec.assert_called_once()
# Check that adb was called with device ID and "shell"
args, kwargs = mock_exec.call_args
assert args[0] == "adb"
assert args[1] == "-s"
assert args[2] == "test_device_1234"
assert args[3] == "shell"
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_command_persistent_env(mock_exec, mock_ctx):
run_command = get_run_adb_command_tool(mock_ctx)
terminal_id = "test_term_android"
# 1. Set environment variable in persistent terminal
mock_process_1 = MockProcess(
output_bytes=(b"===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n"),
exit_code=0,
)
mock_exec.return_value = mock_process_1
res1 = await run_command.ainvoke(
{
"CommandLine": "export ARTEMIS_TEST_ENV=coffee",
"Cwd": "/data/local/tmp",
"RunPersistent": True,
"RequestedTerminalID": terminal_id,
"WaitMsBeforeAsync": 500,
}
)
assert "completed with exit code 0" in res1
assert terminal_id in _PERSISTENT_ENVIRONMENTS
assert _PERSISTENT_ENVIRONMENTS[terminal_id].get("ARTEMIS_TEST_ENV") == "coffee"
# 2. Query environment variable in subsequent command on same terminal
mock_process_2 = MockProcess(
output_bytes=b"coffee\n===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n",
exit_code=0,
)
mock_exec.return_value = mock_process_2
res2 = await run_command.ainvoke(
{
"CommandLine": "echo $ARTEMIS_TEST_ENV",
"Cwd": "/data/local/tmp",
"RunPersistent": True,
"RequestedTerminalID": terminal_id,
"WaitMsBeforeAsync": 500,
}
)
assert "completed with exit code 0" in res2
assert "coffee" in res2
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_command_async_and_status(mock_exec, mock_ctx):
# Mock a long running command by delaying stdout response
mock_process = MockProcess(
output_bytes=(b"background task finished\n===EXIT_CODE===0\n===ENV_START===\n"),
exit_code=0,
delay=1.0, # 1.0s delay will trigger timeout
)
mock_exec.return_value = mock_process
run_command = get_run_adb_command_tool(mock_ctx)
manage_task = get_manage_task_tool(mock_ctx)
_BACKGROUND_TASKS.clear()
res = await run_command.ainvoke(
{
"CommandLine": "sleep 2 && echo 'background task finished'",
"Cwd": "/data/local/tmp",
"RunPersistent": False,
"WaitMsBeforeAsync": 100, # 100ms wait threshold
}
)
assert "sent to the background as a task" in res
assert "TaskId: task_" in res
lines = res.splitlines()
task_id_line = [line for line in lines if line.startswith("TaskId: ")][0]
task_id = task_id_line.split(": ")[1]
assert task_id in _BACKGROUND_TASKS
# Check status (should be running)
status_res = await manage_task.ainvoke(
{
"Action": "status",
"TaskId": task_id,
}
)
assert "Status: running" in status_res
# Wait for the background listener task to finish reading delayed output
await asyncio.sleep(1.2)
# Status should be completed (and task removed from active map)
assert task_id not in _BACKGROUND_TASKS
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_command_kill(mock_exec, mock_ctx):
mock_process = MockProcess(output_bytes=b"", exit_code=0, delay=5.0)
mock_exec.return_value = mock_process
run_command = get_run_adb_command_tool(mock_ctx)
manage_task = get_manage_task_tool(mock_ctx)
_BACKGROUND_TASKS.clear()
res = await run_command.ainvoke(
{
"CommandLine": "sleep 10",
"Cwd": "/data/local/tmp",
"RunPersistent": False,
"WaitMsBeforeAsync": 100,
}
)
lines = res.splitlines()
task_id_line = [line for line in lines if line.startswith("TaskId: ")][0]
task_id = task_id_line.split(": ")[1]
# Kill the task
kill_res = await manage_task.ainvoke(
{
"Action": "kill",
"TaskId": task_id,
}
)
assert f"Task {task_id} successfully terminated" in kill_res
assert task_id not in _BACKGROUND_TASKS
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_short_command_success(mock_exec, mock_ctx):
mock_process = MockProcess(output_bytes=b"short command success output", exit_code=0)
mock_exec.return_value = mock_process
run_short_command = get_run_short_adb_command_tool(mock_ctx)
result = await run_short_command.ainvoke(
{
"CommandLine": "echo 'short command'",
}
)
assert "completed with exit code 0" in result
assert "short command success output" in result
mock_exec.assert_called_once()
args, kwargs = mock_exec.call_args
assert args[0] == "adb"
assert args[1] == "-s"
assert args[2] == "test_device_1234"
assert args[3] == "shell"
# Verify it cd'd to /data/local/tmp by default
assert "cd /data/local/tmp" in args[4]
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_short_command_timeout(mock_exec, mock_ctx):
mock_process = MockProcess(output_bytes=b"some output", exit_code=0, delay=5.0)
mock_exec.return_value = mock_process
run_short_command = get_run_short_adb_command_tool(mock_ctx)
result = await run_short_command.ainvoke(
{
"CommandLine": "sleep 5",
}
)
assert "Error: Command timed out after 3 seconds." in result
@pytest.mark.asyncio
@patch("asyncio.create_subprocess_exec")
async def test_run_command_sync_long_output(mock_exec, mock_ctx):
# Generate long output (> 500 lines)
long_output = "\n".join(f"Output line {i}" for i in range(1, 600))
mock_process = MockProcess(
output_bytes=long_output.encode() + b"\n===EXIT_CODE===0\n===ENV_START===\n",
exit_code=0,
)
mock_exec.return_value = mock_process
run_command = get_run_adb_command_tool(mock_ctx)
result = await run_command.ainvoke(
{
"CommandLine": "generate_long_output",
"Cwd": "/data/local/tmp",
"RunPersistent": False,
"WaitMsBeforeAsync": 500,
}
)
assert "ADB command completed with exit code 0." in result
assert "has been truncated" in result
assert "Output line 599" in result
assert "Output line 1" not in result # since it was truncated to last 200 lines
assert "analyze_task_output" in result
assert "TaskId: task_sync_" in result
@pytest.mark.asyncio
@patch("artemis.agents.log_analyzer.output_analyzer.get_llm")
async def test_analyze_task_output_tool(mock_get_llm, mock_ctx):
# Set up mock LLM
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "This is the analysis result: everything looks good."
mock_response.tool_calls = []
mock_llm.bind_tools.return_value = mock_llm
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
mock_get_llm.return_value = mock_llm
# Set up finished tasks logs cache manually
from artemis.tools.command_tool import (
_FINISHED_TASKS_LOGS,
_register_finished_task,
get_analyze_task_output_tool,
)
task_id = "test_task_long_output"
_register_finished_task(
task_id=task_id,
command="cat long_log.txt",
cwd="/data/local/tmp",
terminal_id=None,
status="completed",
exit_code=0,
output="line1\nline2\n" + "\n".join(f"line{i}" for i in range(3, 50)),
)
analyze_tool = get_analyze_task_output_tool(mock_ctx)
result = await analyze_tool.ainvoke({"TaskId": task_id, "Query": "Summarize the log"})
assert "Analysis result for Task test_task_long_output:" in result
assert "This is the analysis result: everything looks good." in result
mock_get_llm.assert_called_once_with(ctx=mock_ctx, name="output_analyzer")
# Clean up registry
if task_id in _FINISHED_TASKS_LOGS:
del _FINISHED_TASKS_LOGS[task_id]
def test_run_adb_command_tool_subclass_and_registry():
"""Verify RunAdbCommandTool is a subclass of ArtemisTool and properly registered."""
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.command_tool import (
RunAdbCommand,
RunAdbCommandArgs,
RunAdbCommandTool,
run_adb_command,
)
assert issubclass(RunAdbCommandTool, ArtemisTool)
assert issubclass(RunAdbCommand, ArtemisTool)
assert isinstance(run_adb_command, ArtemisTool)
assert isinstance(run_adb_command, RunAdbCommandTool)
assert run_adb_command.name == "run_adb_command"
assert run_adb_command.category == "system"
assert run_adb_command.args_schema == RunAdbCommandArgs
# Registry lookup
reg_tool = ToolRegistry.get("run_adb_command")
assert reg_tool is not None
assert isinstance(reg_tool, RunAdbCommandTool)
# GenAI FunctionDeclaration export
declaration = run_adb_command.to_genai_declaration()
assert declaration.name == "run_adb_command"
assert "CommandLine" in declaration.parameters.properties
@pytest.mark.asyncio
async def test_run_adb_command_tool_mock_driver_execute():
"""Verify RunAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None."""
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.tools.command_tool import RunAdbCommandTool
driver = MockDeviceDriver(width=1080, height=2400)
tool_inst = RunAdbCommandTool()
result = await tool_inst.execute(
driver=driver,
ctx=None,
CommandLine="pm list packages",
)
assert "mock_output: pm list packages" in result
assert "pm list packages" in driver.action_history[-1]["command"]
def test_manage_task_tool_subclass_and_registry():
"""Verify ManageTaskTool is a subclass of ArtemisTool and properly registered."""
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.command_tool import (
ManageTask,
ManageTaskArgs,
ManageTaskTool,
manage_task,
)
assert issubclass(ManageTaskTool, ArtemisTool)
assert issubclass(ManageTask, ArtemisTool)
assert isinstance(manage_task, ArtemisTool)
assert isinstance(manage_task, ManageTaskTool)
assert manage_task.name == "manage_task"
assert manage_task.category == "system"
assert manage_task.args_schema == ManageTaskArgs
# Registry lookup
reg_tool = ToolRegistry.get("manage_task")
assert reg_tool is not None
assert isinstance(reg_tool, ManageTaskTool)
# GenAI FunctionDeclaration export
declaration = manage_task.to_genai_declaration()
assert declaration.name == "manage_task"
assert "Action" in declaration.parameters.properties
@pytest.mark.asyncio
async def test_manage_task_tool_direct_execute(mock_ctx):
"""Verify direct ManageTaskTool.execute execution."""
from artemis.tools.command_tool import (
_BACKGROUND_TASKS,
BackgroundTask,
manage_task,
)
_BACKGROUND_TASKS.clear()
list_res = await manage_task.execute(ctx=mock_ctx, Action="list")
assert "No active background tasks" in list_res
# Create dummy task
dummy_proc = MagicMock()
bg_task = BackgroundTask(
task_id="dummy_task_99",
command="sleep 100",
process=dummy_proc,
terminal_id="term_1",
cwd="/data/local/tmp",
)
_BACKGROUND_TASKS["dummy_task_99"] = bg_task
list_res2 = await manage_task.execute(ctx=mock_ctx, Action="list")
assert "dummy_task_99" in list_res2
assert "sleep 100" in list_res2
# Status check
status_res = await manage_task.execute(ctx=mock_ctx, Action="status", TaskId="dummy_task_99")
assert "Status: running" in status_res
assert "dummy_task_99" in status_res
_BACKGROUND_TASKS.clear()
def test_analyze_task_output_tool_subclass_and_registry():
"""Verify AnalyzeTaskOutputTool is a subclass of ArtemisTool and properly registered."""
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.command_tool import (
AnalyzeTaskOutput,
AnalyzeTaskOutputArgs,
AnalyzeTaskOutputTool,
analyze_task_output,
)
assert issubclass(AnalyzeTaskOutputTool, ArtemisTool)
assert issubclass(AnalyzeTaskOutput, ArtemisTool)
assert isinstance(analyze_task_output, ArtemisTool)
assert isinstance(analyze_task_output, AnalyzeTaskOutputTool)
assert analyze_task_output.name == "analyze_task_output"
assert analyze_task_output.category == "system"
assert analyze_task_output.args_schema == AnalyzeTaskOutputArgs
# Registry lookup
reg_tool = ToolRegistry.get("analyze_task_output")
assert reg_tool is not None
assert isinstance(reg_tool, AnalyzeTaskOutputTool)
# GenAI FunctionDeclaration export
declaration = analyze_task_output.to_genai_declaration()
assert declaration.name == "analyze_task_output"
assert "TaskId" in declaration.parameters.properties
assert "Query" in declaration.parameters.properties
@pytest.mark.asyncio
@patch("artemis.agents.log_analyzer.output_analyzer.get_llm")
async def test_analyze_task_output_tool_direct_execute(mock_get_llm, mock_ctx):
"""Verify direct AnalyzeTaskOutputTool.execute execution."""
from artemis.tools.command_tool import (
_FINISHED_TASKS_LOGS,
_register_finished_task,
analyze_task_output,
)
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "Summary of task output."
mock_response.tool_calls = []
mock_llm.bind_tools.return_value = mock_llm
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
mock_get_llm.return_value = mock_llm
task_id = "test_direct_analyze"
_register_finished_task(
task_id=task_id,
command="cat file.txt",
cwd="/data/local/tmp",
terminal_id=None,
status="completed",
exit_code=0,
output="Sample log content",
)
result = await analyze_task_output.execute(
ctx=mock_ctx, TaskId=task_id, Query="What is the content?"
)
assert "Analysis result for Task test_direct_analyze:" in result
assert "Summary of task output." in result
if task_id in _FINISHED_TASKS_LOGS:
del _FINISHED_TASKS_LOGS[task_id]
def test_run_short_adb_command_tool_subclass_and_registry():
"""Verify RunShortAdbCommandTool is a subclass of ArtemisTool and properly registered."""
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.command_tool import (
RunShortAdbCommand,
RunShortAdbCommandArgs,
RunShortAdbCommandTool,
run_short_adb_command,
)
assert issubclass(RunShortAdbCommandTool, ArtemisTool)
assert issubclass(RunShortAdbCommand, ArtemisTool)
assert isinstance(run_short_adb_command, ArtemisTool)
assert isinstance(run_short_adb_command, RunShortAdbCommandTool)
assert run_short_adb_command.name == "run_short_adb_command"
assert run_short_adb_command.category == "system"
assert run_short_adb_command.args_schema == RunShortAdbCommandArgs
# Registry lookup
reg_tool = ToolRegistry.get("run_short_adb_command")
assert reg_tool is not None
assert isinstance(reg_tool, RunShortAdbCommandTool)
# GenAI FunctionDeclaration export
declaration = run_short_adb_command.to_genai_declaration()
assert declaration.name == "run_short_adb_command"
assert "CommandLine" in declaration.parameters.properties
@pytest.mark.asyncio
async def test_run_short_adb_command_tool_mock_driver_execute():
"""Verify RunShortAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None."""
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.tools.command_tool import RunShortAdbCommandTool
driver = MockDeviceDriver(width=1080, height=2400)
tool_inst = RunShortAdbCommandTool()
result = await tool_inst.execute(
driver=driver,
ctx=None,
CommandLine="getprop ro.build.version.release",
)
assert "mock_output: getprop ro.build.version.release" in result
assert "getprop ro.build.version.release" in driver.action_history[-1]["command"]