testsAndMisc/python_pkg/android_ui/tests/test_cli.py
Krzysztof kuhy Rudnicki b73811c233
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Add android_ui: drive Android apps by element, never by coordinates
Shared across every repo with an Android surface (screen-locker's workout_app,
todo, dufs-cloud/app). Works against a plain release APK on a physical device
over adb — no root, no emulator, no Flutter debug connection, no app changes.

Driving an app by tapping pixel positions read off a screenshot fails in ways
that look like success. All observed in one session (2026-08-10) verifying
workout_app's Firebase restore:

* A tap aimed at a password field landed with the field unfocused. The typed
  password went nowhere and NOTHING errored — the next screenshot just showed
  an empty box. type_into/type_into_field now re-read the field and raise.
* Opening the soft keyboard moved a field from y=1572 to y=1319, so a
  coordinate captured one step earlier was already wrong. Every action
  re-resolves the element immediately before acting.
* A widget behind the keyboard reports its LAID-OUT position (1549) not its
  real one (1939) — a 390px error that taps a letter key and reports success.
  keyboard_is_up() distinguishes "absent" from "behind the keyboard", and tap
  closes the keyboard first.
* KEYCODE_BACK is a route pop in Flutter, not a keyboard dismiss: it navigated
  out of Settings and discarded typed credentials, twice. dismiss_keyboard
  tries ESCAPE then BACK and verifies via dumpsys input_method.
* `input text` inserts at the cursor, so typing over a filled field produced
  "canary@example.com321krzychu@gmail.com". Fields are cleared first.
* uiautomator returns ONLY the focused EditText nodes while a Flutter text
  field has focus, and stays that way across retries — so a naive retry loop
  never converges. Partial trees are retried and the best result kept.
* An EMPTY EditText has no text, content-desc or resource-id, so a
  "must be labelled" filter drops the very field you need to fill in. Editable
  nodes are kept regardless and addressable by position.

Ambiguity is an error rather than a silent first-match: `tap "Back"` correctly
refuses when "OFFLINE BACKUP" is also on screen.

Its own test suite caught a real bug in _escape: backslash sat inside the
escape loop, so "&" became "\\&" and a password containing shell
metacharacters would have been typed wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
2026-08-10 17:01:28 +02:00

155 lines
5.1 KiB
Python

"""Tests for :mod:`python_pkg.android_ui.cli`.
Every subcommand must exit non-zero and name the query when an element is
missing or ambiguous. A CLI that exits 0 on a failed tap is indistinguishable
from one that worked, which is the whole failure mode this package removes.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.android_ui import cli
from python_pkg.android_ui.driver import (
ElementNotFoundError,
UiElement,
)
if TYPE_CHECKING:
from collections.abc import Iterator
MOD = "python_pkg.android_ui.cli"
def _element(label: str = "Connect Firebase") -> UiElement:
"""Build a representative element."""
return UiElement(
text=label,
content_desc="",
resource_id="",
class_name="android.widget.Button",
bounds=(0, 0, 100, 50),
enabled=True,
focused=False,
)
@pytest.fixture
def ui() -> Iterator[MagicMock]:
"""Patch out the real device driver."""
with patch(f"{MOD}.AndroidUi") as factory:
instance = MagicMock()
factory.return_value = instance
yield instance
class TestDump:
def test_prints_every_element(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
ui.dump.return_value = [_element("A"), _element("B")]
assert cli.main(["dump"]) == 0
assert "'A'" in capsys.readouterr().out
class TestFind:
def test_prints_the_match(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
ui.find.return_value = _element()
assert cli.main(["find", "Connect"]) == 0
assert "Connect Firebase" in capsys.readouterr().out
def test_missing_element_exits_non_zero(self, ui: MagicMock) -> None:
ui.find.side_effect = ElementNotFoundError("no element matches 'Nope'")
assert cli.main(["find", "Nope"]) == 1
def test_exact_flag_is_forwarded(self, ui: MagicMock) -> None:
ui.find.return_value = _element()
cli.main(["--exact", "find", "Back"])
assert ui.find.call_args.kwargs["exact"] is True
class TestTap:
def test_reports_what_was_tapped(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
ui.tap.return_value = _element()
assert cli.main(["tap", "Connect"]) == 0
assert "tapped" in capsys.readouterr().out
def test_failure_exits_non_zero(self, ui: MagicMock) -> None:
ui.tap.side_effect = ElementNotFoundError("gone")
assert cli.main(["tap", "Connect"]) == 1
def test_timeout_is_forwarded(self, ui: MagicMock) -> None:
ui.tap.return_value = _element()
cli.main(["tap", "Connect", "--timeout", "42"])
assert ui.tap.call_args.kwargs["timeout"] == 42.0
class TestWait:
def test_prints_the_element_once_it_appears(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
ui.wait_for.return_value = _element("Connected.")
assert cli.main(["wait", "Connected."]) == 0
assert "Connected." in capsys.readouterr().out
def test_timeout_exits_non_zero(self, ui: MagicMock) -> None:
ui.wait_for.side_effect = ElementNotFoundError("waited 5s")
assert cli.main(["wait", "Nope"]) == 1
class TestType:
def test_confirms_the_text_was_verified(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["type", "Email", "a@b.c"]) == 0
assert "verified" in capsys.readouterr().out
def test_unverified_typing_exits_non_zero(self, ui: MagicMock) -> None:
ui.type_into.side_effect = ElementNotFoundError("did not change")
assert cli.main(["type", "Email", "a@b.c"]) == 1
class TestMisc:
def test_dismiss_keyboard(self, ui: MagicMock) -> None:
assert cli.main(["dismiss-keyboard"]) == 0
ui.dismiss_keyboard.assert_called_once_with()
def test_focus_prints_the_activity(
self, ui: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
ui.current_focus.return_value = "com.example/.Main"
assert cli.main(["focus"]) == 0
assert "com.example/.Main" in capsys.readouterr().out
def test_serial_is_forwarded_to_the_driver(self) -> None:
with patch(f"{MOD}.AndroidUi") as factory:
factory.return_value = MagicMock()
cli.main(["-s", "ABC123", "focus"])
assert factory.call_args.kwargs["serial"] == "ABC123"
def test_an_unhandled_command_still_exits_cleanly(
self, ui: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
# Guards the final elif's fall-through: a subcommand added to the
# parser but not to the dispatch chain must not crash.
real_parse = cli._build_parser().parse_args
def _parse(argv: list[str] | None = None) -> object:
args = real_parse(["focus"])
args.command = "not-wired-up"
return args
monkeypatch.setattr(cli, "_build_parser", lambda: MagicMock(parse_args=_parse))
assert cli.main(["focus"]) == 0
def test_a_missing_subcommand_is_rejected(self) -> None:
with pytest.raises(SystemExit):
cli.main([])