test_runner.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import importlib.util
  2. from pathlib import Path
  3. import subprocess
  4. import sys
  5. import tempfile
  6. import unittest
  7. from unittest import mock
  8. RUNNER_PATH = Path(__file__).parents[1] / "runner.py"
  9. SPEC = importlib.util.spec_from_file_location("legado_android_runner", RUNNER_PATH)
  10. runner = importlib.util.module_from_spec(SPEC)
  11. assert SPEC.loader is not None
  12. SPEC.loader.exec_module(runner)
  13. class ScenarioValidationTest(unittest.TestCase):
  14. def test_repository_scenario_is_valid(self):
  15. scenario = runner.load_scenario("reader/page-turn")
  16. self.assertEqual("long-chapter-v1", scenario["fixture"])
  17. def test_bookshelf_scenario_is_valid(self):
  18. scenario = runner.load_scenario("bookshelf/open-reader-back")
  19. self.assertEqual("bookshelf", scenario["entry"]["type"])
  20. def test_source_edit_scenario_is_valid(self):
  21. scenario = runner.load_scenario("source-edit/predictive-back")
  22. self.assertEqual("source_manage", scenario["entry"]["type"])
  23. def test_theme_save_baseline_scenario_is_valid(self):
  24. scenario = runner.load_scenario("theme/save-current-baseline")
  25. self.assertEqual("theme_config", scenario["entry"]["type"])
  26. def test_theme_export_baseline_scenario_is_valid(self):
  27. scenario = runner.load_scenario("theme/export-current-baseline")
  28. self.assertEqual("theme_config", scenario["entry"]["type"])
  29. def test_theme_background_scenarios_are_valid(self):
  30. for name in (
  31. "theme/select-day-background",
  32. "theme/save-background-after-restart",
  33. "theme/export-background-after-restart",
  34. ):
  35. with self.subTest(name=name):
  36. scenario = runner.load_scenario(name)
  37. self.assertEqual("theme_config", scenario["entry"]["type"])
  38. def test_theme_failure_scenarios_are_valid(self):
  39. for name in (
  40. "theme/save-missing-file",
  41. "theme/save-missing-content-uri",
  42. "theme/export-missing-file",
  43. "theme/export-missing-content-uri",
  44. ):
  45. with self.subTest(name=name):
  46. scenario = runner.load_scenario(name)
  47. self.assertIn(scenario["themeAsset"], {"missing-file", "missing-content-uri"})
  48. def test_deleted_applied_theme_export_scenario_is_valid(self):
  49. scenario = runner.load_scenario("theme/export-current-after-deleting-applied-theme")
  50. self.assertNotIn("expectThemeExportFailure", scenario)
  51. def test_rejects_unknown_action(self):
  52. scenario = {
  53. "schemaVersion": 1,
  54. "name": "reader/page-turn",
  55. "fixture": "long-chapter-v1",
  56. "entry": {"type": "reader"},
  57. "readyMarker": "marker",
  58. "actions": [{"type": "tap"}],
  59. "assertions": ["reader_ready"],
  60. }
  61. with self.assertRaises(runner.ScenarioError):
  62. runner.validate_scenario(scenario)
  63. def test_rejects_missing_fixture(self):
  64. with self.assertRaises(runner.ScenarioError):
  65. runner.validate_fixture("missing-v1")
  66. def test_rejects_invalid_fixture_before_device_work(self):
  67. fixture = {
  68. "schemaVersion": 1,
  69. "id": "broken-v1",
  70. "resetPolicy": "replace",
  71. "book": {
  72. "bookUrl": "debug://book",
  73. "name": "broken",
  74. "author": "debug",
  75. "pageMode": "simulation",
  76. "startChapter": 0,
  77. "startPosition": 0,
  78. },
  79. "chapters": [{"title": "chapter", "readyMarker": "ready", "paragraph": "text", "repeat": 0}],
  80. }
  81. with self.assertRaises(runner.ScenarioError):
  82. runner.validate_fixture_data(fixture, "broken-v1")
  83. class CapabilityTest(unittest.TestCase):
  84. def test_lists_registered_entries_actions_assertions(self):
  85. caps = runner.build_capabilities()
  86. self.assertEqual(["turn_page"], caps["entries"]["reader"]["allowedActions"])
  87. self.assertEqual(
  88. ["open_fixture_book", "press_back"],
  89. caps["entries"]["bookshelf"]["allowedActions"],
  90. )
  91. self.assertEqual(
  92. ["open_fixture_source", "predictive_back"],
  93. caps["entries"]["source_manage"]["allowedActions"],
  94. )
  95. self.assertEqual(
  96. [
  97. "apply_saved_theme",
  98. "delete_saved_theme",
  99. "export_current_theme",
  100. "open_theme_manage",
  101. "save_current_theme",
  102. "select_day_background",
  103. ],
  104. caps["entries"]["theme_config"]["allowedActions"],
  105. )
  106. self.assertIn("turn_page", caps["actions"])
  107. self.assertIn("no_fatal_exception", caps["assertions"])
  108. def test_list_command_returns_zero(self):
  109. self.assertEqual(0, runner.main(["list"]))
  110. def test_parses_skip_build_flag(self):
  111. args = runner.parse_args(["run", "reader/page-turn", "--skip-build"])
  112. self.assertTrue(args.skip_build)
  113. def test_run_without_skip_build_defaults_false(self):
  114. args = runner.parse_args(["run", "reader/page-turn"])
  115. self.assertFalse(args.skip_build)
  116. class PreferencesTest(unittest.TestCase):
  117. BASE = {
  118. "schemaVersion": 1,
  119. "name": "bookshelf/miuix-hidden-statusbar",
  120. "fixture": "long-chapter-v1",
  121. "entry": {"type": "bookshelf"},
  122. "readyMarker": "marker",
  123. "record": True,
  124. "actions": [{"type": "open_fixture_book"}, {"type": "press_back"}],
  125. "assertions": ["bookshelf_ready"],
  126. }
  127. def test_repository_miuix_scenario_is_valid(self):
  128. scenario = runner.load_scenario("bookshelf/miuix-hidden-statusbar")
  129. self.assertTrue(scenario["record"])
  130. self.assertEqual("miuix", scenario["preferences"]["composeEngine"])
  131. self.assertTrue(scenario["preferences"]["hideStatusBar"])
  132. def test_accepts_string_and_boolean_preferences(self):
  133. scenario = {**self.BASE, "preferences": {"composeEngine": "miuix", "hideStatusBar": True}}
  134. runner.validate_scenario(scenario)
  135. def test_rejects_non_primitive_preference_value(self):
  136. scenario = {**self.BASE, "preferences": {"menuAlpha": 50}}
  137. with self.assertRaises(runner.ScenarioError):
  138. runner.validate_scenario(scenario)
  139. def test_rejects_invalid_preference_key(self):
  140. scenario = {**self.BASE, "preferences": {"bad-key": "x"}}
  141. with self.assertRaises(runner.ScenarioError):
  142. runner.validate_scenario(scenario)
  143. def test_rejects_non_boolean_record(self):
  144. scenario = {**self.BASE, "record": "yes"}
  145. with self.assertRaises(runner.ScenarioError):
  146. runner.validate_scenario(scenario)
  147. def test_rejects_unknown_theme_asset_fixture(self):
  148. scenario = runner.load_scenario("theme/save-background-after-restart")
  149. scenario["themeAsset"] = "guess"
  150. with self.assertRaises(runner.ScenarioError):
  151. runner.validate_scenario(scenario)
  152. class FailureParsingTest(unittest.TestCase):
  153. def test_ignores_crash_from_another_package(self):
  154. logcat = """FATAL EXCEPTION: main
  155. Process: com.example.other, PID: 42
  156. java.lang.IllegalStateException: unrelated
  157. """
  158. self.assertEqual("", runner.extract_crash(logcat))
  159. def test_parses_app_frame_as_inference(self):
  160. crash = """FATAL EXCEPTION: main
  161. java.lang.IndexOutOfBoundsException: Index 18 out of bounds
  162. at io.legado.app.ui.book.read.ReadBookViewModel.loadChapter(ReadBookViewModel.kt:418)
  163. """
  164. failure = runner.parse_failure(crash)
  165. self.assertEqual("uncaught_exception", failure["category"])
  166. self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
  167. self.assertEqual("medium", failure["suspectedFrame"]["confidence"])
  168. self.assertEqual(418, failure["suspectedFrame"]["line"])
  169. def test_prefers_deepest_caused_by_as_root_cause_candidate(self):
  170. crash = """FATAL EXCEPTION: main
  171. java.lang.RuntimeException: wrapper
  172. Caused by: java.lang.IllegalStateException: middle
  173. Caused by: java.lang.IndexOutOfBoundsException: root
  174. """
  175. failure = runner.parse_failure(crash)
  176. self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
  177. self.assertEqual("root", failure["message"])
  178. self.assertEqual(["fatal_exception"], failure["detectedSignals"])
  179. def test_crash_has_priority_over_assertion_failure(self):
  180. scenario = runner.load_scenario("reader/page-turn")
  181. summary, exit_code = runner.make_summary(
  182. session_id="test",
  183. scenario=scenario,
  184. fixture="long-chapter-v1",
  185. build={"status": "passed"},
  186. ready=False,
  187. activity_visible=False,
  188. content_changed=False,
  189. action_count=0,
  190. failure={"category": "uncaught_exception", "stacktraceArtifact": "crash.txt"},
  191. )
  192. self.assertEqual(runner.EXIT_CRASH, exit_code)
  193. self.assertEqual(runner.EXIT_CRASH, summary["exitCode"])
  194. class CommandDecodingTest(unittest.TestCase):
  195. def test_replaces_invalid_utf8_in_command_output(self):
  196. result = runner.run_command(
  197. [sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'log\\xc0tail')"],
  198. )
  199. self.assertEqual("log\ufffdtail", result.stdout)
  200. @mock.patch.object(runner, "cleanup_device")
  201. @mock.patch.object(runner, "run_command")
  202. def test_install_retries_with_streaming_after_timeout(self, run_command, cleanup_device):
  203. run_command.side_effect = [
  204. subprocess.TimeoutExpired(["adb", "install"], 120),
  205. subprocess.CompletedProcess(["adb", "install"], 0, "Success\n"),
  206. ]
  207. with tempfile.TemporaryDirectory() as directory:
  208. mode = runner.install_apk("device", Path("app.apk"), Path(directory))
  209. install_log = (Path(directory) / "install.log").read_text(encoding="utf-8")
  210. self.assertEqual("streaming_retry", mode)
  211. self.assertIn("timeout=120s", install_log)
  212. cleanup_device.assert_called_once_with("device")
  213. class ReaderContentTest(unittest.TestCase):
  214. def test_extracts_reader_pages_from_logcat(self):
  215. logcat = (
  216. "07-21 23:52 26863 26863 I LegadoDebug: [session=x] FIXTURE_READY fixture=y entry=reader\n"
  217. "07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE LEGADO_DEBUG_LONG_CHAPTER_V1 page one\n"
  218. "07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE page two body\n"
  219. )
  220. self.assertEqual(
  221. ["LEGADO_DEBUG_LONG_CHAPTER_V1 page one", "page two body"],
  222. runner.extract_reader_pages(logcat),
  223. )
  224. def test_returns_empty_when_no_reader_page_logged(self):
  225. self.assertEqual([], runner.extract_reader_pages("nothing here\n"))
  226. @mock.patch.object(runner.time, "sleep")
  227. @mock.patch.object(runner, "reader_current_page")
  228. def test_waits_until_reader_content_changes(self, reader_current_page, _sleep):
  229. reader_current_page.side_effect = ["before", "after"]
  230. changed, latest, content = runner.wait_until_reader_content_changed("device", "before", 5)
  231. self.assertTrue(changed)
  232. self.assertEqual("after", content)
  233. self.assertEqual("after", latest)
  234. self.assertEqual(2, reader_current_page.call_count)
  235. def test_finds_clickable_bookshelf_item_and_center(self):
  236. dump = """<?xml version="1.0"?><hierarchy>
  237. <node class="android.view.View" package="io.legato.kazusa.debug" clickable="true"
  238. content-desc="调试夹具:长章节, Legado Debug" bounds="[100,200][300,600]" />
  239. </hierarchy>"""
  240. node = runner.find_node_by_content_prefix(dump, "调试夹具:长章节")
  241. self.assertIsNotNone(node)
  242. self.assertEqual((200, 400), runner.node_center(node))
  243. def test_finds_node_in_external_picker_package(self):
  244. dump = """<?xml version="1.0"?><hierarchy>
  245. <node package="com.google.android.photopicker" text="" content-desc="更多"
  246. bounds="[924,753][1068,897]" />
  247. </hierarchy>"""
  248. node = runner.find_node_by_content_prefix(
  249. dump,
  250. "更多",
  251. "com.google.android.photopicker",
  252. )
  253. self.assertEqual((996, 825), runner.node_center(node))
  254. def test_finds_edit_button_in_same_source_row(self):
  255. dump = """<?xml version="1.0"?><hierarchy>
  256. <node package="io.legato.kazusa.debug" text="Legado Debug Fixture"
  257. resource-id="io.legato.kazusa.debug:id/cb_book_source" bounds="[36,343][516,487]" />
  258. <node package="io.legato.kazusa.debug" text="" content-desc="编辑"
  259. resource-id="io.legato.kazusa.debug:id/iv_edit" bounds="[744,343][888,487]" />
  260. <node package="io.legato.kazusa.debug" text="" content-desc="编辑"
  261. resource-id="io.legato.kazusa.debug:id/iv_edit" bounds="[744,586][888,730]" />
  262. </hierarchy>"""
  263. source = runner.find_node_by_text(dump, "Legado Debug Fixture")
  264. edit = runner.find_node_in_same_row(
  265. dump,
  266. source,
  267. "io.legato.kazusa.debug:id/iv_edit",
  268. )
  269. self.assertEqual((816, 415), runner.node_center(edit))
  270. class EntryExecutionTest(unittest.TestCase):
  271. def make_context(self):
  272. ctx = mock.Mock()
  273. ctx.device = "device"
  274. ctx.scenario = {"readyTimeoutSeconds": 30}
  275. ctx.records = {}
  276. ctx.state = {}
  277. ctx.metadata = {}
  278. return ctx
  279. @mock.patch.object(runner, "find_node_in_same_row")
  280. @mock.patch.object(runner, "find_node_by_text")
  281. @mock.patch.object(runner, "ui_dump", return_value="source dump")
  282. @mock.patch.object(runner, "wait_until_activity_resumed", return_value=(True, "source component"))
  283. def test_source_manage_prepare_owns_its_state(
  284. self,
  285. _wait,
  286. _dump,
  287. find_text,
  288. find_row,
  289. ):
  290. find_text.return_value = {"bounds": "[1,1][2,2]"}
  291. find_row.return_value = {"bounds": "[3,3][4,4]"}
  292. ctx = self.make_context()
  293. runner.ENTRIES["source_manage"].prepare(ctx)
  294. self.assertTrue(ctx.records["source_manage_ready"])
  295. self.assertEqual("Legado Debug Fixture", ctx.state["source_name"])
  296. @mock.patch.object(runner, "wait_until_text_present", return_value=(True, "theme dump", {}))
  297. def test_theme_config_prepare_owns_its_state(self, _wait):
  298. ctx = self.make_context()
  299. runner.ENTRIES["theme_config"].prepare(ctx)
  300. self.assertTrue(ctx.records["theme_config_ready"])
  301. self.assertFalse(ctx.records["theme_save_succeeded"])
  302. self.assertEqual("theme dump", ctx.records["ui_before"])
  303. class SessionLogTest(unittest.TestCase):
  304. @mock.patch.object(runner, "adb")
  305. def test_collects_each_observed_application_pid(self, adb):
  306. adb.side_effect = [
  307. subprocess.CompletedProcess([], 0, "full log\n[session=s1] ready\n"),
  308. subprocess.CompletedProcess([], 0, "pid 10 log\n"),
  309. subprocess.CompletedProcess([], 0, "pid 11 log\n"),
  310. ]
  311. with tempfile.TemporaryDirectory() as directory:
  312. runner.collect_logcat("device", Path(directory), ["10", "11", "10"], "s1")
  313. session_log = (Path(directory) / "session-logcat.txt").read_text(encoding="utf-8")
  314. self.assertIn("--- pid=10 ---", session_log)
  315. self.assertIn("--- pid=11 ---", session_log)
  316. self.assertIn("[session=s1] ready", session_log)
  317. self.assertEqual(3, adb.call_count)
  318. class DeviceStateTest(unittest.TestCase):
  319. @mock.patch.object(runner, "adb")
  320. def test_detects_awake_device(self, adb):
  321. adb.return_value = subprocess.CompletedProcess([], 0, " mWakefulness=Awake\n")
  322. self.assertTrue(runner.device_is_awake("device"))
  323. @mock.patch.object(runner, "adb")
  324. def test_detects_visible_keyguard(self, adb):
  325. adb.return_value = subprocess.CompletedProcess([], 0, "KeyguardServiceDelegate\n showing=true\n")
  326. self.assertTrue(runner.keyguard_is_showing("device"))
  327. if __name__ == "__main__":
  328. unittest.main()