mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
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
Android runs one instrumentation per package: a second `am instrument`
force-stops the first ("stop com.kuhy.a11ydump due to start instr"), and
so does `install -r`. The live two-session run on the Pixel caught two
sessions' dumps killing each other ("Process crashed"). dump_display now
runs install + instrument under a per-serial flock; a dump takes well
under a second, so queueing costs nothing noticeable.
After the fix the same 300 s run had 0 failures across 659 actions, and
the real screen and both apps stayed where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
232 lines
8.9 KiB
Python
232 lines
8.9 KiB
Python
"""Tests for the a11y dump helper: build caching, install, result parsing.
|
|
|
|
The SDK tools are faked: each fake writes the file the real tool would, so the
|
|
build pipeline's wiring (what feeds what, what gets cached) is what is tested.
|
|
The real build and a real dump were verified on the Pixel 6a on 2026-09-25.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from python_pkg.android_ui import _a11y_helper as helper
|
|
from python_pkg.android_ui._elements import UiAutomationError
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
MOD = "python_pkg.android_ui._a11y_helper"
|
|
|
|
|
|
@pytest.fixture
|
|
def source(tmp_path: Path) -> Iterator[Path]:
|
|
"""A tiny helper source: one Java file and its manifest."""
|
|
java = tmp_path / "A11yDump.java"
|
|
java.write_text("class A11yDump {}")
|
|
manifest = tmp_path / "a11y_dump_manifest.xml"
|
|
manifest.write_text("<manifest/>")
|
|
with patch(f"{MOD}._JAVA", java), patch(f"{MOD}._MANIFEST", manifest):
|
|
yield tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def sdk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A fake SDK layout, with the cache and HOME inside tmp_path."""
|
|
root = tmp_path / "sdk"
|
|
for d in (
|
|
"build-tools/35.0.0",
|
|
"build-tools/36.0.0",
|
|
"platforms/android-9",
|
|
"platforms/android-36",
|
|
"platforms/android-37.2",
|
|
):
|
|
(root / d).mkdir(parents=True)
|
|
monkeypatch.setenv("ANDROID_HOME", str(root))
|
|
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path / "cache"))
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
return root
|
|
|
|
|
|
def fake_tool(calls: list[list[str]]) -> MagicMock:
|
|
"""subprocess.run stand-in that produces each tool's output file."""
|
|
|
|
def run(cmd: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
|
calls.append(cmd)
|
|
name = Path(cmd[0]).name
|
|
if name == "d8":
|
|
out = Path(cmd[cmd.index("--output") + 1])
|
|
(out / "classes.dex").write_bytes(b"dex")
|
|
elif name == "aapt2":
|
|
with zipfile.ZipFile(cmd[cmd.index("-o") + 1], "w") as zf:
|
|
zf.writestr("AndroidManifest.xml", "m")
|
|
elif name == "zipalign":
|
|
shutil.copy(cmd[-2], cmd[-1])
|
|
elif name == "apksigner":
|
|
shutil.copy(cmd[-1], cmd[cmd.index("--out") + 1])
|
|
elif name == "keytool":
|
|
Path(cmd[cmd.index("-keystore") + 1]).parent.mkdir(parents=True)
|
|
Path(cmd[cmd.index("-keystore") + 1]).write_bytes(b"ks")
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
|
|
return MagicMock(side_effect=run)
|
|
|
|
|
|
class TestIdentity:
|
|
def test_hash_follows_the_source(self, source: Path) -> None:
|
|
first = helper.source_hash()
|
|
assert first == helper.source_hash()
|
|
(source / "a11y_dump_manifest.xml").write_text("<manifest x=''/>")
|
|
assert helper.source_hash() != first
|
|
|
|
def test_cache_dir_defaults_under_home(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("ANDROID_UI_CACHE", raising=False)
|
|
assert helper.cache_dir() == Path.home() / ".cache" / "android_ui"
|
|
|
|
|
|
class TestSdk:
|
|
def test_env_wins(self, sdk: Path) -> None:
|
|
assert helper.sdk_root() == sdk
|
|
|
|
def test_falls_back_to_home_sdk(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("ANDROID_HOME", raising=False)
|
|
monkeypatch.setenv("ANDROID_SDK_ROOT", str(tmp_path / "missing"))
|
|
fallback = tmp_path / "sdk" / "Android" / "Sdk"
|
|
fallback.mkdir(parents=True)
|
|
with patch(f"{MOD}.Path.home", return_value=tmp_path):
|
|
assert helper.sdk_root() == fallback
|
|
|
|
def test_no_sdk_raises(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("ANDROID_HOME", raising=False)
|
|
monkeypatch.delenv("ANDROID_SDK_ROOT", raising=False)
|
|
with (
|
|
patch(f"{MOD}.Path.home", return_value=tmp_path),
|
|
pytest.raises(UiAutomationError, match="no Android SDK"),
|
|
):
|
|
helper.sdk_root()
|
|
|
|
def test_build_tool_is_in_the_newest_build_tools(self, sdk: Path) -> None:
|
|
assert helper.build_tool("aapt2") == sdk / "build-tools" / "36.0.0" / "aapt2"
|
|
|
|
def test_newest_version_wins_and_empty_raises(self, sdk: Path) -> None:
|
|
assert helper._newest(sdk / "build-tools").name == "36.0.0"
|
|
assert helper._newest(sdk / "platforms", "android-").name == "android-37.2"
|
|
with pytest.raises(UiAutomationError, match="nothing matching"):
|
|
helper._newest(sdk / "platforms", "nope-")
|
|
|
|
|
|
class TestBuild:
|
|
def test_builds_signs_and_caches(self, source: Path, sdk: Path) -> None:
|
|
del source, sdk
|
|
calls: list[list[str]] = []
|
|
with patch(f"{MOD}.subprocess.run", fake_tool(calls)):
|
|
apk = helper.build()
|
|
assert apk.name == f"a11ydump-{helper.source_hash()}.apk"
|
|
with zipfile.ZipFile(apk) as zf:
|
|
assert "classes.dex" in zf.namelist()
|
|
tools = [Path(c[0]).name for c in calls]
|
|
assert tools == ["javac", "d8", "aapt2", "zipalign", "keytool", "apksigner"]
|
|
assert f"{Path.home()}/.android/debug.keystore" in calls[4]
|
|
calls.clear()
|
|
assert helper.build() == apk
|
|
assert calls == [], "a cached build runs no tool"
|
|
|
|
def test_an_existing_keystore_is_reused(self, source: Path, sdk: Path) -> None:
|
|
del source, sdk
|
|
keystore = Path.home() / ".android" / "debug.keystore"
|
|
keystore.parent.mkdir(parents=True)
|
|
keystore.write_bytes(b"ks")
|
|
calls: list[list[str]] = []
|
|
with patch(f"{MOD}.subprocess.run", fake_tool(calls)):
|
|
helper.build()
|
|
assert "keytool" not in [Path(c[0]).name for c in calls]
|
|
|
|
def test_a_failing_tool_is_reported(self, source: Path, sdk: Path) -> None:
|
|
del source, sdk
|
|
failed = subprocess.CompletedProcess(["javac"], 1, "", "boom")
|
|
with (
|
|
patch(f"{MOD}.subprocess.run", return_value=failed),
|
|
pytest.raises(UiAutomationError, match=r"javac\nboom"),
|
|
):
|
|
helper.build()
|
|
|
|
|
|
class TestDevice:
|
|
def test_skips_install_when_the_phone_has_this_build(self, source: Path) -> None:
|
|
del source
|
|
run = MagicMock(return_value=f"versionName={helper.source_hash()}\n")
|
|
helper.ensure_installed(run)
|
|
run.assert_called_once_with("shell", "dumpsys", "package", helper.PACKAGE)
|
|
|
|
def test_installs_when_missing_or_stale(self, source: Path, tmp_path: Path) -> None:
|
|
del source
|
|
run = MagicMock(return_value="versionName=old\n")
|
|
with patch(f"{MOD}.build", return_value=tmp_path / "a.apk"):
|
|
helper.ensure_installed(run)
|
|
run.assert_called_with(
|
|
"install", "-r", "-t", str(tmp_path / "a.apk"), timeout=120.0
|
|
)
|
|
|
|
def test_parse_result(self) -> None:
|
|
out = "INSTRUMENTATION_RESULT: xml=<hierarchy/>\nINSTRUMENTATION_CODE: -1\n"
|
|
assert helper.parse_result(out) == "<hierarchy/>"
|
|
with pytest.raises(UiAutomationError, match=r"failed: java\.lang\.X"):
|
|
helper.parse_result("INSTRUMENTATION_RESULT: error=java.lang.X\n")
|
|
with pytest.raises(UiAutomationError, match="no tree"):
|
|
helper.parse_result("INSTRUMENTATION_STATUS: nothing\n")
|
|
|
|
def test_dump_display(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path))
|
|
run = MagicMock(return_value="INSTRUMENTATION_RESULT: xml=<h/>\n")
|
|
with patch(f"{MOD}.ensure_installed") as ensure:
|
|
assert helper.dump_display(run, 9) == "<h/>"
|
|
ensure.assert_called_once_with(run)
|
|
run.assert_called_once_with(
|
|
"shell",
|
|
"am",
|
|
"instrument",
|
|
"-w",
|
|
"-e",
|
|
"display",
|
|
"9",
|
|
"com.kuhy.a11ydump/.A11yDump",
|
|
timeout=45.0,
|
|
)
|
|
|
|
def test_dumps_of_one_phone_queue_behind_each_other(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# A second `am instrument` force-stops the first, so the whole
|
|
# install + instrument must run under the phone's lock.
|
|
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path))
|
|
held: list[bool] = []
|
|
|
|
def run(*_args: str, **_kw: object) -> str:
|
|
with (tmp_path / "a11ydump-R58.lock").open("a") as other:
|
|
try:
|
|
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
held.append(True)
|
|
else:
|
|
held.append(False)
|
|
return "INSTRUMENTATION_RESULT: xml=<h/>\n"
|
|
|
|
with patch(f"{MOD}.ensure_installed", side_effect=lambda r: r()):
|
|
assert helper.dump_display(run, 9, "R58") == "<h/>"
|
|
assert held == [True, True]
|