import importlib.util
import base64
import json
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_bookshelf_scenario_is_valid(self):
scenario = runner.load_scenario("bookshelf/open-reader-back")
self.assertEqual("bookshelf", scenario["entry"]["type"])
def test_source_edit_scenario_is_valid(self):
scenario = runner.load_scenario("source-edit/predictive-back")
self.assertEqual("source_manage", scenario["entry"]["type"])
def test_theme_save_baseline_scenario_is_valid(self):
scenario = runner.load_scenario("theme/save-current-baseline")
self.assertEqual("theme_config", scenario["entry"]["type"])
def test_theme_export_baseline_scenario_is_valid(self):
scenario = runner.load_scenario("theme/export-current-baseline")
self.assertEqual("theme_config", scenario["entry"]["type"])
def test_theme_background_scenarios_are_valid(self):
for name in (
"theme/select-day-background",
"theme/save-background-after-restart",
"theme/export-background-after-restart",
):
with self.subTest(name=name):
scenario = runner.load_scenario(name)
self.assertEqual("theme_config", scenario["entry"]["type"])
def test_theme_failure_scenarios_are_valid(self):
for name in (
"theme/save-missing-file",
"theme/save-missing-content-uri",
"theme/export-missing-file",
"theme/export-missing-content-uri",
):
with self.subTest(name=name):
scenario = runner.load_scenario(name)
self.assertIn(scenario["themeAsset"], {"missing-file", "missing-content-uri"})
def test_deleted_applied_theme_export_scenario_is_valid(self):
scenario = runner.load_scenario("theme/export-current-after-deleting-applied-theme")
self.assertNotIn("expectThemeExportFailure", scenario)
def test_rss_day_night_scenario_is_valid(self):
self.assertEqual(
"rss_read",
runner.load_scenario("rss/read-day-night")["entry"]["type"],
)
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 CapabilityTest(unittest.TestCase):
def test_lists_registered_entries_actions_assertions(self):
caps = runner.build_capabilities()
self.assertEqual(["turn_page"], caps["entries"]["reader"]["allowedActions"])
self.assertEqual(
["open_fixture_book", "press_back"],
caps["entries"]["bookshelf"]["allowedActions"],
)
self.assertEqual(
["open_fixture_source", "predictive_back"],
caps["entries"]["source_manage"]["allowedActions"],
)
self.assertEqual(
[
"apply_saved_theme",
"delete_saved_theme",
"export_current_theme",
"open_theme_manage",
"save_current_theme",
"select_day_background",
],
caps["entries"]["theme_config"]["allowedActions"],
)
self.assertIn("turn_page", caps["actions"])
self.assertIn("no_fatal_exception", caps["assertions"])
def test_list_command_returns_zero(self):
self.assertEqual(0, runner.main(["list"]))
def test_parses_skip_build_flag(self):
args = runner.parse_args(["run", "reader/page-turn", "--skip-build"])
self.assertTrue(args.skip_build)
def test_run_without_skip_build_defaults_false(self):
args = runner.parse_args(["run", "reader/page-turn"])
self.assertFalse(args.skip_build)
class PreferencesTest(unittest.TestCase):
BASE = {
"schemaVersion": 1,
"name": "bookshelf/miuix-hidden-statusbar",
"fixture": "long-chapter-v1",
"entry": {"type": "bookshelf"},
"readyMarker": "marker",
"record": True,
"actions": [{"type": "open_fixture_book"}, {"type": "press_back"}],
"assertions": ["bookshelf_ready"],
}
def test_repository_miuix_scenario_is_valid(self):
scenario = runner.load_scenario("bookshelf/miuix-hidden-statusbar")
self.assertTrue(scenario["record"])
self.assertEqual("miuix", scenario["preferences"]["composeEngine"])
self.assertTrue(scenario["preferences"]["hideStatusBar"])
def test_accepts_string_and_boolean_preferences(self):
scenario = {**self.BASE, "preferences": {"composeEngine": "miuix", "hideStatusBar": True}}
runner.validate_scenario(scenario)
def test_rejects_non_primitive_preference_value(self):
scenario = {**self.BASE, "preferences": {"menuAlpha": 50}}
with self.assertRaises(runner.ScenarioError):
runner.validate_scenario(scenario)
def test_rejects_invalid_preference_key(self):
scenario = {**self.BASE, "preferences": {"bad-key": "x"}}
with self.assertRaises(runner.ScenarioError):
runner.validate_scenario(scenario)
def test_rejects_non_boolean_record(self):
scenario = {**self.BASE, "record": "yes"}
with self.assertRaises(runner.ScenarioError):
runner.validate_scenario(scenario)
def test_rejects_unknown_theme_asset_fixture(self):
scenario = runner.load_scenario("theme/save-background-after-restart")
scenario["themeAsset"] = "guess"
with self.assertRaises(runner.ScenarioError):
runner.validate_scenario(scenario)
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):
@mock.patch.object(runner, "adb")
def test_decodes_debug_state_provider_response(self, adb):
payload = base64.b64encode(json.dumps({"themeMode": "2"}).encode()).decode()
adb.return_value = subprocess.CompletedProcess([], 0, f"Result: Bundle[{{resultB64={payload}}}]\n")
self.assertEqual({"themeMode": "2"}, runner.debug_state("device", "toggleTheme"))
def test_extracts_reader_pages_from_logcat(self):
logcat = (
"07-21 23:52 26863 26863 I LegadoDebug: [session=x] FIXTURE_READY fixture=y entry=reader\n"
"07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE LEGADO_DEBUG_LONG_CHAPTER_V1 page one\n"
"07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE page two body\n"
)
self.assertEqual(
["LEGADO_DEBUG_LONG_CHAPTER_V1 page one", "page two body"],
runner.extract_reader_pages(logcat),
)
def test_returns_empty_when_no_reader_page_logged(self):
self.assertEqual([], runner.extract_reader_pages("nothing here\n"))
@mock.patch.object(runner.time, "sleep")
@mock.patch.object(runner, "reader_current_page")
def test_waits_until_reader_content_changes(self, reader_current_page, _sleep):
reader_current_page.side_effect = ["before", "after"]
changed, latest, content = runner.wait_until_reader_content_changed("device", "before", 5)
self.assertTrue(changed)
self.assertEqual("after", content)
self.assertEqual("after", latest)
self.assertEqual(2, reader_current_page.call_count)
def test_finds_clickable_bookshelf_item_and_center(self):
dump = """
"""
node = runner.find_node_by_content_prefix(dump, "调试夹具:长章节")
self.assertIsNotNone(node)
self.assertEqual((200, 400), runner.node_center(node))
def test_finds_node_in_external_picker_package(self):
dump = """
"""
node = runner.find_node_by_content_prefix(
dump,
"更多",
"com.google.android.photopicker",
)
self.assertEqual((996, 825), runner.node_center(node))
def test_finds_edit_button_in_same_source_row(self):
dump = """
"""
source = runner.find_node_by_text(dump, "Legado Debug Fixture")
edit = runner.find_node_in_same_row(
dump,
source,
"io.legato.kazusa.debug:id/iv_edit",
)
self.assertEqual((816, 415), runner.node_center(edit))
class EntryExecutionTest(unittest.TestCase):
def make_context(self):
ctx = mock.Mock()
ctx.device = "device"
ctx.scenario = {"readyTimeoutSeconds": 30}
ctx.records = {}
ctx.state = {}
ctx.metadata = {}
return ctx
@mock.patch.object(runner, "find_node_in_same_row")
@mock.patch.object(runner, "find_node_by_text")
@mock.patch.object(runner, "ui_dump", return_value="source dump")
@mock.patch.object(runner, "wait_until_activity_resumed", return_value=(True, "source component"))
def test_source_manage_prepare_owns_its_state(
self,
_wait,
_dump,
find_text,
find_row,
):
find_text.return_value = {"bounds": "[1,1][2,2]"}
find_row.return_value = {"bounds": "[3,3][4,4]"}
ctx = self.make_context()
runner.ENTRIES["source_manage"].prepare(ctx)
self.assertTrue(ctx.records["source_manage_ready"])
self.assertEqual("Legado Debug Fixture", ctx.state["source_name"])
@mock.patch.object(runner, "wait_until_text_present", return_value=(True, "theme dump", {}))
def test_theme_config_prepare_owns_its_state(self, _wait):
ctx = self.make_context()
runner.ENTRIES["theme_config"].prepare(ctx)
self.assertTrue(ctx.records["theme_config_ready"])
self.assertFalse(ctx.records["theme_save_succeeded"])
self.assertEqual("theme dump", ctx.records["ui_before"])
class SessionLogTest(unittest.TestCase):
@mock.patch.object(runner, "adb")
def test_collects_each_observed_application_pid(self, adb):
adb.side_effect = [
subprocess.CompletedProcess([], 0, "full log\n[session=s1] ready\n"),
subprocess.CompletedProcess([], 0, "pid 10 log\n"),
subprocess.CompletedProcess([], 0, "pid 11 log\n"),
]
with tempfile.TemporaryDirectory() as directory:
runner.collect_logcat("device", Path(directory), ["10", "11", "10"], "s1")
session_log = (Path(directory) / "session-logcat.txt").read_text(encoding="utf-8")
self.assertIn("--- pid=10 ---", session_log)
self.assertIn("--- pid=11 ---", session_log)
self.assertIn("[session=s1] ready", session_log)
self.assertEqual(3, adb.call_count)
class DeviceStateTest(unittest.TestCase):
@mock.patch.object(runner, "adb")
def test_detects_awake_device(self, adb):
adb.return_value = subprocess.CompletedProcess([], 0, " mWakefulness=Awake\n")
self.assertTrue(runner.device_is_awake("device"))
@mock.patch.object(runner, "adb")
def test_detects_visible_keyguard(self, adb):
adb.return_value = subprocess.CompletedProcess([], 0, "KeyguardServiceDelegate\n showing=true\n")
self.assertTrue(runner.keyguard_is_showing("device"))
if __name__ == "__main__":
unittest.main()