From 07ffd6746fb08838a0c50e2f6853f21cddc1d5c9 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Mon, 13 Jul 2026 08:27:51 +0400 Subject: [PATCH] feat(qa): adb interaction tools for QA agents (screenshot/input/logcat) Second slice of the device & GUI verification roadmap (track A2) - the Android analog of the Electron MCP toolset: - android_screenshot: adb exec-out screencap, downscaled/JPEG-encoded via Pillow to fit the SDK's 1MB message buffer (PNG passthrough with a clear error fallback when Pillow is missing) - android_input: tap/swipe/text/keyevent via adb shell input; text is rejected when it carries device-shell metacharacters, keyevents are validated against KEYCODE_*/numeric form - android_logcat: recent lines with substring filtering Tools are registered only for Android Gradle projects (com.android in build files), keeping tool context clean elsewhere, and are available to qa_reviewer/qa_fixer. All adb calls use fixed argv (no shell) with timeouts. Pillow added to backend requirements. Co-Authored-By: Claude Fable 5 Signed-off-by: Oleg Miagkov --- apps/backend/agents/tools_pkg/models.py | 11 + apps/backend/agents/tools_pkg/registry.py | 2 + .../agents/tools_pkg/tools/__init__.py | 2 + .../agents/tools_pkg/tools/android_harness.py | 183 +++++++++++++ apps/backend/core/android_device.py | 232 ++++++++++++++++ apps/backend/requirements.txt | 5 + tests/test_android_device.py | 256 ++++++++++++++++++ 7 files changed, 691 insertions(+) create mode 100644 apps/backend/agents/tools_pkg/tools/android_harness.py create mode 100644 apps/backend/core/android_device.py create mode 100644 tests/test_android_device.py diff --git a/apps/backend/agents/tools_pkg/models.py b/apps/backend/agents/tools_pkg/models.py index c95541749..8d29c90fd 100644 --- a/apps/backend/agents/tools_pkg/models.py +++ b/apps/backend/agents/tools_pkg/models.py @@ -51,6 +51,11 @@ # CLI E2E harness (pseudo-terminal runner for terminal apps) TOOL_RUN_CLI_SESSION = "mcp__auto-claude__run_cli_session" +# Android QA harness (adb-driven; registered for Android projects only) +TOOL_ANDROID_SCREENSHOT = "mcp__auto-claude__android_screenshot" +TOOL_ANDROID_INPUT = "mcp__auto-claude__android_input" +TOOL_ANDROID_LOGCAT = "mcp__auto-claude__android_logcat" + # ============================================================================= # External MCP Tools # ============================================================================= @@ -286,6 +291,9 @@ def is_electron_mcp_enabled() -> bool: TOOL_GET_SESSION_CONTEXT, TOOL_SEARCH_TEAM_DOCS, # Verify against team standards TOOL_RUN_CLI_SESSION, # E2E-verify terminal apps + TOOL_ANDROID_SCREENSHOT, # Android E2E (registered only for Android projects) + TOOL_ANDROID_INPUT, + TOOL_ANDROID_LOGCAT, ], "thinking_default": "high", }, @@ -299,6 +307,9 @@ def is_electron_mcp_enabled() -> bool: TOOL_UPDATE_QA_STATUS, TOOL_RECORD_GOTCHA, TOOL_RUN_CLI_SESSION, # Verify CLI fixes end-to-end + TOOL_ANDROID_SCREENSHOT, # Android E2E (registered only for Android projects) + TOOL_ANDROID_INPUT, + TOOL_ANDROID_LOGCAT, ], "thinking_default": "medium", }, diff --git a/apps/backend/agents/tools_pkg/registry.py b/apps/backend/agents/tools_pkg/registry.py index 83298e1dd..7b49b58aa 100644 --- a/apps/backend/agents/tools_pkg/registry.py +++ b/apps/backend/agents/tools_pkg/registry.py @@ -16,6 +16,7 @@ create_sdk_mcp_server = None from .tools import ( + create_android_tools, create_background_task_tools, create_cli_harness_tools, create_debugging_tools, @@ -52,6 +53,7 @@ def create_all_tools(spec_dir: Path, project_dir: Path) -> list: all_tools.extend(create_statistics_tools(spec_dir, project_dir)) all_tools.extend(create_background_task_tools(spec_dir, project_dir)) all_tools.extend(create_cli_harness_tools(spec_dir, project_dir)) + all_tools.extend(create_android_tools(spec_dir, project_dir)) all_tools.extend(create_debugging_tools(spec_dir, project_dir)) all_tools.extend(create_knowledge_base_tools(spec_dir, project_dir)) diff --git a/apps/backend/agents/tools_pkg/tools/__init__.py b/apps/backend/agents/tools_pkg/tools/__init__.py index adf6d9b92..f7c192f9b 100644 --- a/apps/backend/agents/tools_pkg/tools/__init__.py +++ b/apps/backend/agents/tools_pkg/tools/__init__.py @@ -5,6 +5,7 @@ Individual tool implementations organized by functionality. """ +from .android_harness import create_android_tools from .background_task import create_background_task_tools from .cli_harness import create_cli_harness_tools from .debugging import create_debugging_tools @@ -21,6 +22,7 @@ "create_memory_tools", "create_qa_tools", "create_statistics_tools", + "create_android_tools", "create_background_task_tools", "create_cli_harness_tools", "create_debugging_tools", diff --git a/apps/backend/agents/tools_pkg/tools/android_harness.py b/apps/backend/agents/tools_pkg/tools/android_harness.py new file mode 100644 index 000000000..00818535d --- /dev/null +++ b/apps/backend/agents/tools_pkg/tools/android_harness.py @@ -0,0 +1,183 @@ +""" +Android QA Harness Tools +======================== + +MCP tools that let QA agents drive an Android app on an emulator or +attached device via adb: screenshots, input injection, and logcat reads. +The Android analog of the Electron MCP toolset. + +Only registered when the project is an Android Gradle project, keeping +tool context clean for everything else. +""" + +import base64 +import logging +from pathlib import Path +from typing import Any + +try: + from claude_agent_sdk import tool + + SDK_AVAILABLE = True +except ImportError: + SDK_AVAILABLE = False + +LOGCAT_TAIL_CHARS = 8_000 + + +def create_android_tools(spec_dir: Path, project_dir: Path) -> list: + """ + Create Android device tools for QA agents. + + Args: + spec_dir: Path to the spec directory (unused, kept for registry symmetry) + project_dir: Path to the project root; gates registration + + Returns: + List of tool functions (empty for non-Android projects) + """ + if not SDK_AVAILABLE: + return [] + + try: + from core.android_device import is_android_project + except ImportError: + return [] + + if not is_android_project(project_dir): + return [] + + tools = [] + + @tool( + "android_screenshot", + "Take a screenshot of the Android emulator or attached device via " + "adb. Use it to visually verify UI state after launching the app or " + "interacting with it. Optionally pass a device serial when several " + "devices are connected.", + {"serial": str}, + ) + async def android_screenshot(args: dict[str, Any]) -> dict[str, Any]: + """Capture and return a device screenshot.""" + from core.android_device import take_screenshot + + try: + image, mime_or_error = take_screenshot( + serial=(args.get("serial") or "").strip() or None + ) + except Exception as e: + logging.exception("Error during android_screenshot") + return _text_result(f"Error taking screenshot: {e}") + + if image is None: + return _text_result(f"Screenshot failed: {mime_or_error}") + + return { + "content": [ + { + "type": "image", + "data": base64.b64encode(image).decode("ascii"), + "mimeType": mime_or_error, + } + ] + } + + tools.append(android_screenshot) + + @tool( + "android_input", + "Send input to the Android emulator or attached device via adb. " + "Actions: 'tap' (x, y), 'swipe' (x, y, x2, y2, optional " + "duration_ms), 'text' (text typed into the focused field), " + "'keyevent' (key: KEYCODE_* name or numeric code, e.g. KEYCODE_BACK). " + "Use android_screenshot before and after to verify the effect.", + { + "action": str, + "x": int, + "y": int, + "x2": int, + "y2": int, + "duration_ms": int, + "text": str, + "key": str, + "serial": str, + }, + ) + async def android_input(args: dict[str, Any]) -> dict[str, Any]: + """Inject a tap, swipe, text, or key event.""" + from core.android_device import ( + send_keyevent, + send_swipe, + send_tap, + send_text, + ) + + action = (args.get("action") or "").strip().lower() + serial = (args.get("serial") or "").strip() or None + + try: + if action == "tap": + result = send_tap(args.get("x") or 0, args.get("y") or 0, serial) + elif action == "swipe": + result = send_swipe( + args.get("x") or 0, + args.get("y") or 0, + args.get("x2") or 0, + args.get("y2") or 0, + args.get("duration_ms") or 300, + serial, + ) + elif action == "text": + result = send_text(args.get("text") or "", serial) + elif action == "keyevent": + result = send_keyevent(args.get("key") or "", serial) + else: + return _text_result("Unknown action: use tap, swipe, text, or keyevent") + except Exception as e: + logging.exception("Error during android_input") + return _text_result(f"Error sending input: {e}") + + status = "OK" if result.ok else "FAILED" + detail = result.output.strip() + return _text_result(f"{status}: {action}" + (f"\n{detail}" if detail else "")) + + tools.append(android_input) + + @tool( + "android_logcat", + "Read recent Android log output (adb logcat) for debugging. " + "Optionally filter lines by a substring (e.g. your app's tag or " + "package name) and limit the number of recent lines.", + {"lines": int, "filter_text": str, "serial": str}, + ) + async def android_logcat(args: dict[str, Any]) -> dict[str, Any]: + """Fetch recent logcat lines.""" + from core.android_device import read_logcat + + try: + result = read_logcat( + lines=args.get("lines") or 200, + filter_text=(args.get("filter_text") or "").strip() or None, + serial=(args.get("serial") or "").strip() or None, + ) + except Exception as e: + logging.exception("Error during android_logcat") + return _text_result(f"Error reading logcat: {e}") + + if not result.ok: + return _text_result(f"logcat failed: {result.output}") + + output = result.output[-LOGCAT_TAIL_CHARS:] + return _text_result(output if output.strip() else "(no matching log lines)") + + tools.append(android_logcat) + + return tools + + +def _text_result(text: str) -> dict[str, Any]: + """Wrap text in the MCP tool result envelope.""" + return {"content": [{"type": "text", "text": text}]} + + +__all__ = ["create_android_tools"] diff --git a/apps/backend/core/android_device.py b/apps/backend/core/android_device.py new file mode 100644 index 000000000..244e9d4ed --- /dev/null +++ b/apps/backend/core/android_device.py @@ -0,0 +1,232 @@ +""" +Android Device Access +===================== + +Thin adb wrapper used by the QA agents' Android tools: screenshots, +input injection, and logcat reads. Mirrors the Electron MCP capabilities +for native Android apps running on an emulator or attached device. + +All functions shell out to adb with fixed argv structures (never through +a shell) and degrade to clear error strings when adb or a device is +missing. +""" + +import base64 +import io +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +ADB_TIMEOUT_SECONDS = 30 + +# Screenshots must stay under the Claude SDK's 1MB JSON message buffer +MAX_SCREENSHOT_BASE64_BYTES = 700_000 +SCREENSHOT_MAX_WIDTH = 1280 +SCREENSHOT_JPEG_QUALITY = 60 + +# adb `input text` is evaluated by the device shell, so shell +# metacharacters (; | & $ ` quotes parentheses) must be rejected +_SAFE_TEXT = re.compile(r"^[\w\s.,:@%+=/?!-]*$") +_KEYEVENT = re.compile(r"^(KEYCODE_[A-Z0-9_]+|\d{1,4})$") + +_ANDROID_BUILD_FILES = ( + "build.gradle", + "build.gradle.kts", + "app/build.gradle", + "app/build.gradle.kts", +) + + +@dataclass +class AdbResult: + """Outcome of an adb invocation.""" + + ok: bool + output: str + + +def is_android_project(project_dir: Path) -> bool: + """Check if the project is an Android Gradle project.""" + for build_file in _ANDROID_BUILD_FILES: + path = Path(project_dir) / build_file + if not path.exists(): + continue + try: + if "com.android" in path.read_text(encoding="utf-8"): + return True + except (OSError, UnicodeDecodeError): + continue + return False + + +def _adb(args: list[str], serial: str | None) -> list[str]: + """Build an adb argv, optionally targeting a specific device.""" + argv = ["adb"] + if serial: + argv += ["-s", serial] + return argv + args + + +def _run_adb_text(args: list[str], serial: str | None) -> AdbResult: + """Run adb and capture text output.""" + try: + proc = subprocess.run( + _adb(args, serial), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=ADB_TIMEOUT_SECONDS, + ) + except FileNotFoundError: + return AdbResult(False, "adb not found - is the Android SDK installed?") + except subprocess.TimeoutExpired: + return AdbResult(False, "adb timed out") + + if proc.returncode != 0: + return AdbResult(False, (proc.stderr or proc.stdout or "adb failed").strip()) + return AdbResult(True, proc.stdout) + + +def take_screenshot(serial: str | None = None) -> tuple[bytes | None, str]: + """ + Capture a screenshot from the device. + + Returns: + (image_bytes, mime_type) on success, (None, error_message) on failure + """ + try: + proc = subprocess.run( + _adb(["exec-out", "screencap", "-p"], serial), + capture_output=True, + timeout=ADB_TIMEOUT_SECONDS, + ) + except FileNotFoundError: + return None, "adb not found - is the Android SDK installed?" + except subprocess.TimeoutExpired: + return None, "adb screencap timed out" + + if proc.returncode != 0 or not proc.stdout: + stderr = (proc.stderr or b"").decode("utf-8", errors="replace").strip() + return None, stderr or "screencap produced no output (is a device running?)" + + return _fit_screenshot(proc.stdout) + + +def _fit_screenshot(png_bytes: bytes) -> tuple[bytes | None, str]: + """Compress the screenshot to fit the SDK message limit.""" + compressed = _compress_with_pillow(png_bytes) + if compressed is not None: + return compressed, "image/jpeg" + + # Pillow unavailable: pass the PNG through if it is small enough + if len(base64.b64encode(png_bytes)) <= MAX_SCREENSHOT_BASE64_BYTES: + return png_bytes, "image/png" + return None, ( + "Screenshot too large for the SDK message limit and Pillow is not " + "installed - install Pillow to enable compression" + ) + + +def _compress_with_pillow(png_bytes: bytes) -> bytes | None: + """Downscale and JPEG-encode the screenshot; None if Pillow is missing.""" + try: + from PIL import Image + except ImportError: + return None + + try: + image = Image.open(io.BytesIO(png_bytes)).convert("RGB") + if image.width > SCREENSHOT_MAX_WIDTH: + ratio = SCREENSHOT_MAX_WIDTH / image.width + image = image.resize( + (SCREENSHOT_MAX_WIDTH, max(1, int(image.height * ratio))) + ) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=SCREENSHOT_JPEG_QUALITY) + return buffer.getvalue() + except OSError: + return None + + +def send_tap(x: int, y: int, serial: str | None = None) -> AdbResult: + """Tap at screen coordinates.""" + return _run_adb_text(["shell", "input", "tap", str(int(x)), str(int(y))], serial) + + +def send_swipe( + x1: int, + y1: int, + x2: int, + y2: int, + duration_ms: int = 300, + serial: str | None = None, +) -> AdbResult: + """Swipe between two coordinates.""" + return _run_adb_text( + [ + "shell", + "input", + "swipe", + str(int(x1)), + str(int(y1)), + str(int(x2)), + str(int(y2)), + str(int(duration_ms)), + ], + serial, + ) + + +def send_text(text: str, serial: str | None = None) -> AdbResult: + """Type text into the focused field.""" + if not _SAFE_TEXT.match(text): + return AdbResult(False, "Text contains characters adb input cannot send safely") + # adb `input text` requires spaces encoded as %s + return _run_adb_text(["shell", "input", "text", text.replace(" ", "%s")], serial) + + +def send_keyevent(key: str, serial: str | None = None) -> AdbResult: + """Send a key event (KEYCODE_* name or numeric code).""" + key = key.strip().upper() + if not _KEYEVENT.match(key): + return AdbResult(False, "Key must be a KEYCODE_* name or a numeric key code") + return _run_adb_text(["shell", "input", "keyevent", key], serial) + + +def read_logcat( + lines: int = 200, + filter_text: str | None = None, + serial: str | None = None, +) -> AdbResult: + """ + Read recent logcat output without blocking. + + Args: + lines: Number of most recent lines to fetch + filter_text: Optional substring filter applied to the output + serial: Optional device serial + + Returns: + AdbResult with the (optionally filtered) log lines + """ + lines = max(1, min(int(lines), 2000)) + result = _run_adb_text(["logcat", "-d", "-t", str(lines)], serial) + if not result.ok or not filter_text: + return result + + matching = [ln for ln in result.output.splitlines() if filter_text in ln] + return AdbResult(True, "\n".join(matching)) + + +__all__ = [ + "AdbResult", + "is_android_project", + "take_screenshot", + "send_tap", + "send_swipe", + "send_text", + "send_keyevent", + "read_logcat", +] diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index fe4805b3d..035f4bb04 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -74,3 +74,8 @@ memory-profiler>=0.61.0 # uvloop: Ultra fast asyncio event loop for Unix/Linux/macOS # Drop-in replacement for asyncio's event loop (2-4x faster) uvloop>=0.22.1; sys_platform != "win32" + +# Image processing +# Pillow: screenshot compression for QA device tools (Android adb screencap +# must fit the Claude SDK's 1MB message buffer) +Pillow>=10.0.0 diff --git a/tests/test_android_device.py b/tests/test_android_device.py new file mode 100644 index 000000000..bc1fd816a --- /dev/null +++ b/tests/test_android_device.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +Tests for the Android device access module and QA tool registration. + +Tests cover: +- Android project detection +- adb argv construction and error handling (mocked subprocess) +- Input sanitization (text, keyevents) +- Logcat filtering +- Screenshot size handling +- Android-only tool registration gating +""" + +import base64 + +# Add auto-claude to path for imports +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend")) + +from core.android_device import ( + MAX_SCREENSHOT_BASE64_BYTES, + is_android_project, + read_logcat, + send_keyevent, + send_tap, + send_text, + take_screenshot, +) + + +class TestAndroidProjectDetection: + """Tests for is_android_project.""" + + def test_detects_root_gradle(self, tmp_path): + (tmp_path / "build.gradle").write_text( + "apply plugin: 'com.android.application'" + ) + assert is_android_project(tmp_path) is True + + def test_detects_app_module_kts(self, tmp_path): + app = tmp_path / "app" + app.mkdir() + (app / "build.gradle.kts").write_text('plugins { id("com.android.library") }') + assert is_android_project(tmp_path) is True + + def test_plain_jvm_project_is_not_android(self, tmp_path): + (tmp_path / "build.gradle").write_text("plugins { id 'java' }") + assert is_android_project(tmp_path) is False + + def test_non_gradle_project_is_not_android(self, tmp_path): + (tmp_path / "package.json").write_text("{}") + assert is_android_project(tmp_path) is False + + +class TestAdbCommands: + """Tests for adb command construction and error handling.""" + + @patch("core.android_device.subprocess.run") + def test_tap_builds_correct_argv(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = send_tap(100, 250) + + assert result.ok is True + argv = mock_run.call_args[0][0] + assert argv == ["adb", "shell", "input", "tap", "100", "250"] + + @patch("core.android_device.subprocess.run") + def test_serial_is_passed(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + send_tap(1, 2, serial="emulator-5554") + + argv = mock_run.call_args[0][0] + assert argv[:3] == ["adb", "-s", "emulator-5554"] + + @patch("core.android_device.subprocess.run") + def test_missing_adb_is_reported(self, mock_run): + mock_run.side_effect = FileNotFoundError() + + result = send_tap(1, 2) + + assert result.ok is False + assert "adb not found" in result.output + + @patch("core.android_device.subprocess.run") + def test_adb_failure_returns_stderr(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="error: no devices found" + ) + + result = send_tap(1, 2) + + assert result.ok is False + assert "no devices" in result.output + + +class TestInputSanitization: + """Tests for input text and keyevent validation.""" + + @patch("core.android_device.subprocess.run") + def test_text_spaces_encoded(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = send_text("hello world") + + assert result.ok is True + argv = mock_run.call_args[0][0] + assert argv[-1] == "hello%sworld" + + def test_unsafe_text_rejected(self): + result = send_text("hello; rm -rf /") + + assert result.ok is False + assert "cannot send safely" in result.output + + def test_quotes_rejected(self): + result = send_text('say "hi" $(reboot)') + + assert result.ok is False + + @patch("core.android_device.subprocess.run") + def test_keyevent_name_accepted(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = send_keyevent("KEYCODE_BACK") + + assert result.ok is True + assert mock_run.call_args[0][0][-1] == "KEYCODE_BACK" + + def test_arbitrary_keyevent_rejected(self): + result = send_keyevent("$(reboot)") + + assert result.ok is False + + +class TestLogcat: + """Tests for logcat reads.""" + + @patch("core.android_device.subprocess.run") + def test_filter_applied(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout="I/MyApp: started\nW/Other: noise\nE/MyApp: crash\n", + stderr="", + ) + + result = read_logcat(filter_text="MyApp") + + assert result.ok is True + assert "started" in result.output + assert "crash" in result.output + assert "noise" not in result.output + + @patch("core.android_device.subprocess.run") + def test_line_count_clamped(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + read_logcat(lines=999999) + + argv = mock_run.call_args[0][0] + assert argv[argv.index("-t") + 1] == "2000" + + +class TestScreenshot: + """Tests for screenshot capture and size handling.""" + + @patch("core.android_device.subprocess.run") + def test_no_device_reports_error(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, stdout=b"", stderr=b"error: no devices/emulators found" + ) + + image, error = take_screenshot() + + assert image is None + assert "no devices" in error + + @patch("core.android_device._compress_with_pillow", return_value=None) + @patch("core.android_device.subprocess.run") + def test_small_png_passthrough_without_pillow(self, mock_run, _mock_pil): + png = b"\x89PNG small image" + mock_run.return_value = MagicMock(returncode=0, stdout=png, stderr=b"") + + image, mime = take_screenshot() + + assert image == png + assert mime == "image/png" + + @patch("core.android_device._compress_with_pillow", return_value=None) + @patch("core.android_device.subprocess.run") + def test_oversized_png_without_pillow_errors(self, mock_run, _mock_pil): + huge = b"x" * (MAX_SCREENSHOT_BASE64_BYTES + 1) + mock_run.return_value = MagicMock(returncode=0, stdout=huge, stderr=b"") + + image, error = take_screenshot() + + assert image is None + assert "Pillow" in error + + @patch("core.android_device._compress_with_pillow", return_value=b"jpegdata") + @patch("core.android_device.subprocess.run") + def test_compressed_screenshot_is_jpeg(self, mock_run, _mock_pil): + mock_run.return_value = MagicMock( + returncode=0, stdout=b"\x89PNG raw", stderr=b"" + ) + + image, mime = take_screenshot() + + assert image == b"jpegdata" + assert mime == "image/jpeg" + assert base64.b64encode(image) # encodable for the MCP envelope + + +class TestToolRegistration: + """Tests for Android-only tool registration.""" + + def test_android_project_gets_tools(self, tmp_path): + from agents.tools_pkg.tools.android_harness import create_android_tools + + (tmp_path / "build.gradle").write_text( + "apply plugin: 'com.android.application'" + ) + + tools = create_android_tools(tmp_path, tmp_path) + + assert len(tools) == 3 + + def test_non_android_project_gets_no_tools(self, tmp_path): + from agents.tools_pkg.tools.android_harness import create_android_tools + + (tmp_path / "package.json").write_text("{}") + + tools = create_android_tools(tmp_path, tmp_path) + + assert tools == [] + + def test_qa_agents_have_android_tools(self): + from agents.tools_pkg.models import ( + TOOL_ANDROID_INPUT, + TOOL_ANDROID_LOGCAT, + TOOL_ANDROID_SCREENSHOT, + get_agent_config, + ) + + for agent_type in ("qa_reviewer", "qa_fixer"): + config = get_agent_config(agent_type) + for tool_name in ( + TOOL_ANDROID_SCREENSHOT, + TOOL_ANDROID_INPUT, + TOOL_ANDROID_LOGCAT, + ): + assert tool_name in config["auto_claude_tools"]