test_runner.py 16 KB

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