Preserve user guidance through history compression, stabilize Flash index targets, and retire withdrawn checks without applying stale verdicts. Improve runtime diagnostics, tool responses, and history rendering. Simplify documentation and comments. Validation: 2076 tests passed in the full suite; 381 affected tests and 26 frontend tests passed after cleanup. Ruff and the quality ratchet passed.
365 lines
12 KiB
Python
365 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Unit tests for the end-to-end device observation smoke test (no real device)."""
|
|
|
|
import asyncio
|
|
import base64
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from artemis.core.diagnostics import smoke_test_device
|
|
from artemis.core.diagnostics import device_smoke
|
|
from artemis.runtime.device_lock import DeviceExecutionLock, DeviceLockOwner
|
|
|
|
SERIAL = "59100DLCR0033X"
|
|
FAKE_JPEG = b"\xff\xd8\xff" + bytes(range(256)) * 8 + b"\xff\xd9"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def idle_devices(monkeypatch):
|
|
"""No live lock owners unless a test installs some."""
|
|
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", classmethod(lambda cls: {}))
|
|
monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False)
|
|
monkeypatch.delenv("ADB_DEVICE_SERIAL", raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_controller(monkeypatch):
|
|
"""Install a fake ``_get_controller`` and return the controller it hands out."""
|
|
|
|
def _install(get_screen_data=None, factory=None):
|
|
controller = MagicMock()
|
|
controller.ctx = SimpleNamespace(device=SimpleNamespace(device_id=SERIAL))
|
|
if get_screen_data is not None:
|
|
controller.get_screen_data = get_screen_data
|
|
calls: list[str | None] = []
|
|
|
|
def _fake_get_controller(device_serial=None):
|
|
calls.append(device_serial)
|
|
if factory is not None:
|
|
return factory(device_serial)
|
|
return controller
|
|
|
|
monkeypatch.setattr("artemis.mcp.adb_server._get_controller", _fake_get_controller)
|
|
controller.calls = calls
|
|
return controller
|
|
|
|
return _install
|
|
|
|
|
|
def _screen_data(elements, jpeg=FAKE_JPEG):
|
|
return SimpleNamespace(
|
|
base64=base64.b64encode(jpeg).decode("ascii"),
|
|
elements=elements,
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_reports_bytes_elements_serial_and_elapsed(patch_controller):
|
|
elements = [{"class": "android.widget.FrameLayout"}, {"text": "Hello"}, {"text": "World"}]
|
|
controller = patch_controller(AsyncMock(return_value=_screen_data(elements)))
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL, timeout_seconds=5)
|
|
|
|
assert result["ok"] is True
|
|
assert result["error"] is None
|
|
assert result["fix"] == []
|
|
assert result["serial"] == SERIAL
|
|
assert result["screenshot_bytes"] == len(FAKE_JPEG)
|
|
assert result["element_count"] == 3
|
|
assert isinstance(result["elapsed_seconds"], float)
|
|
assert result["elapsed_seconds"] >= 0.0
|
|
assert controller.calls == [SERIAL]
|
|
controller.get_screen_data.assert_awaited_once()
|
|
assert set(result) == {
|
|
"ok",
|
|
"serial",
|
|
"elapsed_seconds",
|
|
"screenshot_bytes",
|
|
"element_count",
|
|
"error",
|
|
"fix",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serial_comes_from_controller_when_not_requested(patch_controller):
|
|
patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}])))
|
|
|
|
result = await smoke_test_device()
|
|
|
|
assert result["ok"] is True
|
|
assert result["serial"] == SERIAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_xml_string_elements_are_counted_by_node(patch_controller):
|
|
xml = '<hierarchy><node class="a"><node class="b"/><node class="c"/></node></hierarchy>'
|
|
patch_controller(AsyncMock(return_value=_screen_data(xml)))
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is True
|
|
assert result["element_count"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_failure_no_devices_points_to_launch_avd(patch_controller):
|
|
def _boom(device_serial):
|
|
raise Exception("No Android devices found at localhost:5037")
|
|
|
|
patch_controller(factory=_boom)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL, timeout_seconds=5)
|
|
|
|
assert result["ok"] is False
|
|
assert result["serial"] == SERIAL
|
|
assert result["screenshot_bytes"] is None
|
|
assert result["element_count"] is None
|
|
assert "No Android devices found" in result["error"]
|
|
assert any("launch_avd" in step for step in result["fix"])
|
|
assert any("mobile_diagnose" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_failure_target_not_found_points_to_launch_avd(patch_controller):
|
|
def _boom(device_serial):
|
|
raise Exception(
|
|
f"Target Android device '{device_serial}' not found at localhost:5037 among available devices."
|
|
)
|
|
|
|
patch_controller(factory=_boom)
|
|
|
|
result = await smoke_test_device(device_serial="emulator-5554")
|
|
|
|
assert result["ok"] is False
|
|
assert result["serial"] == "emulator-5554"
|
|
assert any("launch_avd" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screen_data_timeout_reports_timeout_and_force_stop(patch_controller):
|
|
async def _hang():
|
|
await asyncio.sleep(3)
|
|
return _screen_data([{"text": "late"}])
|
|
|
|
patch_controller(_hang)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL, timeout_seconds=0.3)
|
|
|
|
assert result["ok"] is False
|
|
assert result["serial"] == SERIAL
|
|
assert result["error"] == "UIAutomator/screen capture did not respond within 0.3s"
|
|
assert result["elapsed_seconds"] < 2.5
|
|
joined = "\n".join(result["fix"])
|
|
assert f"adb -s {SERIAL} shell am force-stop com.github.uiautomator" in joined
|
|
assert "uiautomator2 init" in joined
|
|
assert "screen on" in joined
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_timeout_is_reported_without_blocking(patch_controller):
|
|
import time
|
|
|
|
def _slow(device_serial):
|
|
time.sleep(2)
|
|
return MagicMock()
|
|
|
|
patch_controller(factory=_slow)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL, timeout_seconds=0.3)
|
|
|
|
assert result["ok"] is False
|
|
assert "did not respond within 0.3s" in result["error"]
|
|
assert result["elapsed_seconds"] < 1.5
|
|
assert any("force-stop" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_screen_data_exception_falls_back_to_manual_dump(patch_controller):
|
|
patch_controller(AsyncMock(side_effect=RuntimeError("weird failure 42")))
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert result["error"] == "Screen capture failed: weird failure 42"
|
|
assert any("uiautomator dump" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_exception_from_uiautomator2_is_captured_not_raised(patch_controller):
|
|
class HTTPTimeoutError(BaseException):
|
|
"""uiautomator2 errors derive from BaseException."""
|
|
|
|
patch_controller(
|
|
AsyncMock(side_effect=HTTPTimeoutError("HTTP request timeout: read timed out"))
|
|
)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert "HTTPTimeoutError" in result["error"]
|
|
assert any("force-stop" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthorized_and_offline_guidance(patch_controller):
|
|
def _unauth(device_serial):
|
|
raise Exception("device unauthorized. This adb server's $ADB_VENDOR_KEYS is not set")
|
|
|
|
patch_controller(factory=_unauth)
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
assert result["ok"] is False
|
|
assert any("USB debugging" in step for step in result["fix"])
|
|
|
|
def _offline(device_serial):
|
|
raise Exception("device offline")
|
|
|
|
patch_controller(factory=_offline)
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
assert result["ok"] is False
|
|
assert any("adb reconnect" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_hierarchy_is_not_ok(patch_controller):
|
|
patch_controller(AsyncMock(return_value=_screen_data([])))
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert result["screenshot_bytes"] == len(FAKE_JPEG)
|
|
assert result["element_count"] == 0
|
|
assert "no UI elements" in result["error"]
|
|
assert any("force-stop" in step for step in result["fix"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_placeholder_screenshot_is_not_ok(patch_controller):
|
|
one_pixel_png = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
)
|
|
patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}], jpeg=one_pixel_png)))
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert result["screenshot_bytes"] == len(one_pixel_png)
|
|
assert "placeholder" in result["error"]
|
|
|
|
|
|
def _owner(device_id: str, description: str = "task-1") -> DeviceLockOwner:
|
|
return DeviceLockOwner(
|
|
pid=4242,
|
|
process_created_at=1.0,
|
|
token="tok",
|
|
device_id=device_id,
|
|
description=description,
|
|
acquired_at="2026-09-09T00:00:00Z",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_device_is_reported_without_touching_the_controller(
|
|
patch_controller, monkeypatch
|
|
):
|
|
controller = patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}])))
|
|
monkeypatch.setattr(
|
|
DeviceExecutionLock,
|
|
"get_active_owners",
|
|
classmethod(lambda cls: {SERIAL: _owner(SERIAL, "youtube smoke")}),
|
|
)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert result["serial"] == SERIAL
|
|
assert "busy" in result["error"]
|
|
assert "youtube smoke" in result["error"]
|
|
assert "4242" in result["error"]
|
|
assert any("mobile_manage_task" in step for step in result["fix"])
|
|
assert controller.calls == []
|
|
controller.get_screen_data.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_guard_matches_scoped_lock_keys(patch_controller, monkeypatch):
|
|
controller = patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}])))
|
|
monkeypatch.setattr(
|
|
DeviceExecutionLock,
|
|
"get_active_owners",
|
|
classmethod(lambda cls: {f"endpoint1__{SERIAL}": _owner(SERIAL)}),
|
|
)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is False
|
|
assert "busy" in result["error"]
|
|
assert controller.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_guard_ignores_other_devices(patch_controller, monkeypatch):
|
|
controller = patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}])))
|
|
monkeypatch.setattr(
|
|
DeviceExecutionLock,
|
|
"get_active_owners",
|
|
classmethod(lambda cls: {"emulator-5554": _owner("emulator-5554")}),
|
|
)
|
|
|
|
result = await smoke_test_device(device_serial=SERIAL)
|
|
|
|
assert result["ok"] is True
|
|
assert controller.calls == [SERIAL]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_guard_without_target_serial_treats_any_owner_as_busy(
|
|
patch_controller, monkeypatch
|
|
):
|
|
controller = patch_controller(AsyncMock(return_value=_screen_data([{"text": "x"}])))
|
|
monkeypatch.setattr(
|
|
DeviceExecutionLock,
|
|
"get_active_owners",
|
|
classmethod(lambda cls: {"emulator-5554": _owner("emulator-5554")}),
|
|
)
|
|
|
|
result = await smoke_test_device()
|
|
|
|
assert result["ok"] is False
|
|
assert result["serial"] == "emulator-5554"
|
|
assert "busy" in result["error"]
|
|
assert controller.calls == []
|
|
|
|
|
|
def test_fix_for_error_generic_fallback_uses_serial():
|
|
fix = device_smoke.fix_for_error("something odd", SERIAL)
|
|
assert fix
|
|
assert all(SERIAL in step for step in fix)
|
|
assert any("uiautomator dump" in step for step in fix)
|
|
assert device_smoke.fix_for_error(None, SERIAL) == []
|
|
assert "<serial>" in device_smoke.fix_for_error("timeout", None)[1]
|
|
|
|
|
|
def test_smoke_test_is_exported_from_diagnostics_package():
|
|
import artemis.core.diagnostics as diagnostics
|
|
|
|
assert "smoke_test_device" in diagnostics.__all__
|
|
assert diagnostics.smoke_test_device is device_smoke.smoke_test_device
|