import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
RUNNER_PATH = Path(__file__).parents[1] / "runner.py"
SPEC = importlib.util.spec_from_file_location("legado_android_runner", RUNNER_PATH)
runner = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(runner)
class ScenarioValidationTest(unittest.TestCase):
def test_repository_scenario_is_valid(self):
scenario = runner.load_scenario("reader/page-turn")
self.assertEqual("long-chapter-v1", scenario["fixture"])
def test_rejects_unknown_action(self):
scenario = {
"schemaVersion": 1,
"name": "reader/page-turn",
"fixture": "long-chapter-v1",
"entry": {"type": "reader"},
"readyMarker": "marker",
"actions": [{"type": "tap"}],
"assertions": ["reader_ready"],
}
with self.assertRaises(runner.ScenarioError):
runner.validate_scenario(scenario)
def test_rejects_missing_fixture(self):
with self.assertRaises(runner.ScenarioError):
runner.validate_fixture("missing-v1")
def test_rejects_invalid_fixture_before_device_work(self):
fixture = {
"schemaVersion": 1,
"id": "broken-v1",
"resetPolicy": "replace",
"book": {
"bookUrl": "debug://book",
"name": "broken",
"author": "debug",
"pageMode": "simulation",
"startChapter": 0,
"startPosition": 0,
},
"chapters": [{"title": "chapter", "readyMarker": "ready", "paragraph": "text", "repeat": 0}],
}
with self.assertRaises(runner.ScenarioError):
runner.validate_fixture_data(fixture, "broken-v1")
class FailureParsingTest(unittest.TestCase):
def test_ignores_crash_from_another_package(self):
logcat = """FATAL EXCEPTION: main
Process: com.example.other, PID: 42
java.lang.IllegalStateException: unrelated
"""
self.assertEqual("", runner.extract_crash(logcat))
def test_parses_app_frame_as_inference(self):
crash = """FATAL EXCEPTION: main
java.lang.IndexOutOfBoundsException: Index 18 out of bounds
at io.legado.app.ui.book.read.ReadBookViewModel.loadChapter(ReadBookViewModel.kt:418)
"""
failure = runner.parse_failure(crash)
self.assertEqual("uncaught_exception", failure["category"])
self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
self.assertEqual("medium", failure["suspectedFrame"]["confidence"])
self.assertEqual(418, failure["suspectedFrame"]["line"])
def test_prefers_deepest_caused_by_as_root_cause_candidate(self):
crash = """FATAL EXCEPTION: main
java.lang.RuntimeException: wrapper
Caused by: java.lang.IllegalStateException: middle
Caused by: java.lang.IndexOutOfBoundsException: root
"""
failure = runner.parse_failure(crash)
self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
self.assertEqual("root", failure["message"])
self.assertEqual(["fatal_exception"], failure["detectedSignals"])
def test_crash_has_priority_over_assertion_failure(self):
scenario = runner.load_scenario("reader/page-turn")
summary, exit_code = runner.make_summary(
session_id="test",
scenario=scenario,
fixture="long-chapter-v1",
build={"status": "passed"},
ready=False,
activity_visible=False,
content_changed=False,
action_count=0,
failure={"category": "uncaught_exception", "stacktraceArtifact": "crash.txt"},
)
self.assertEqual(runner.EXIT_CRASH, exit_code)
self.assertEqual(runner.EXIT_CRASH, summary["exitCode"])
class CommandDecodingTest(unittest.TestCase):
def test_replaces_invalid_utf8_in_command_output(self):
result = runner.run_command(
[sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'log\\xc0tail')"],
)
self.assertEqual("log\ufffdtail", result.stdout)
@mock.patch.object(runner, "cleanup_device")
@mock.patch.object(runner, "run_command")
def test_install_retries_with_streaming_after_timeout(self, run_command, cleanup_device):
run_command.side_effect = [
subprocess.TimeoutExpired(["adb", "install"], 120),
subprocess.CompletedProcess(["adb", "install"], 0, "Success\n"),
]
with tempfile.TemporaryDirectory() as directory:
mode = runner.install_apk("device", Path("app.apk"), Path(directory))
install_log = (Path(directory) / "install.log").read_text(encoding="utf-8")
self.assertEqual("streaming_retry", mode)
self.assertIn("timeout=120s", install_log)
cleanup_device.assert_called_once_with("device")
class ReaderContentTest(unittest.TestCase):
def test_extracts_read_view_content_only(self):
dump = """
UI hierarchy dumped to: /dev/tty
"""
self.assertEqual(
"LEGADO_DEBUG_LONG_CHAPTER_V1 page body",
runner.extract_reader_content(dump),
)
def test_returns_empty_when_read_view_is_absent(self):
dump = """"""
self.assertEqual("", runner.extract_reader_content(dump))
if __name__ == "__main__":
unittest.main()