test_runner.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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_rejects_unknown_action(self):
  21. scenario = {
  22. "schemaVersion": 1,
  23. "name": "reader/page-turn",
  24. "fixture": "long-chapter-v1",
  25. "entry": {"type": "reader"},
  26. "readyMarker": "marker",
  27. "actions": [{"type": "tap"}],
  28. "assertions": ["reader_ready"],
  29. }
  30. with self.assertRaises(runner.ScenarioError):
  31. runner.validate_scenario(scenario)
  32. def test_rejects_missing_fixture(self):
  33. with self.assertRaises(runner.ScenarioError):
  34. runner.validate_fixture("missing-v1")
  35. def test_rejects_invalid_fixture_before_device_work(self):
  36. fixture = {
  37. "schemaVersion": 1,
  38. "id": "broken-v1",
  39. "resetPolicy": "replace",
  40. "book": {
  41. "bookUrl": "debug://book",
  42. "name": "broken",
  43. "author": "debug",
  44. "pageMode": "simulation",
  45. "startChapter": 0,
  46. "startPosition": 0,
  47. },
  48. "chapters": [{"title": "chapter", "readyMarker": "ready", "paragraph": "text", "repeat": 0}],
  49. }
  50. with self.assertRaises(runner.ScenarioError):
  51. runner.validate_fixture_data(fixture, "broken-v1")
  52. class CapabilityTest(unittest.TestCase):
  53. def test_lists_registered_entries_actions_assertions(self):
  54. caps = runner.build_capabilities()
  55. self.assertEqual(["turn_page"], caps["entries"]["reader"]["allowedActions"])
  56. self.assertEqual(
  57. ["open_fixture_book", "press_back"],
  58. caps["entries"]["bookshelf"]["allowedActions"],
  59. )
  60. self.assertIn("turn_page", caps["actions"])
  61. self.assertIn("no_fatal_exception", caps["assertions"])
  62. def test_list_command_returns_zero(self):
  63. self.assertEqual(0, runner.main(["list"]))
  64. def test_parses_skip_build_flag(self):
  65. args = runner.parse_args(["run", "reader/page-turn", "--skip-build"])
  66. self.assertTrue(args.skip_build)
  67. def test_run_without_skip_build_defaults_false(self):
  68. args = runner.parse_args(["run", "reader/page-turn"])
  69. self.assertFalse(args.skip_build)
  70. class PreferencesTest(unittest.TestCase):
  71. BASE = {
  72. "schemaVersion": 1,
  73. "name": "bookshelf/miuix-hidden-statusbar",
  74. "fixture": "long-chapter-v1",
  75. "entry": {"type": "bookshelf"},
  76. "readyMarker": "marker",
  77. "record": True,
  78. "actions": [{"type": "open_fixture_book"}, {"type": "press_back"}],
  79. "assertions": ["bookshelf_ready"],
  80. }
  81. def test_repository_miuix_scenario_is_valid(self):
  82. scenario = runner.load_scenario("bookshelf/miuix-hidden-statusbar")
  83. self.assertTrue(scenario["record"])
  84. self.assertEqual("miuix", scenario["preferences"]["composeEngine"])
  85. self.assertTrue(scenario["preferences"]["hideStatusBar"])
  86. def test_accepts_string_and_boolean_preferences(self):
  87. scenario = {**self.BASE, "preferences": {"composeEngine": "miuix", "hideStatusBar": True}}
  88. runner.validate_scenario(scenario)
  89. def test_rejects_non_primitive_preference_value(self):
  90. scenario = {**self.BASE, "preferences": {"menuAlpha": 50}}
  91. with self.assertRaises(runner.ScenarioError):
  92. runner.validate_scenario(scenario)
  93. def test_rejects_invalid_preference_key(self):
  94. scenario = {**self.BASE, "preferences": {"bad-key": "x"}}
  95. with self.assertRaises(runner.ScenarioError):
  96. runner.validate_scenario(scenario)
  97. def test_rejects_non_boolean_record(self):
  98. scenario = {**self.BASE, "record": "yes"}
  99. with self.assertRaises(runner.ScenarioError):
  100. runner.validate_scenario(scenario)
  101. class FailureParsingTest(unittest.TestCase):
  102. def test_ignores_crash_from_another_package(self):
  103. logcat = """FATAL EXCEPTION: main
  104. Process: com.example.other, PID: 42
  105. java.lang.IllegalStateException: unrelated
  106. """
  107. self.assertEqual("", runner.extract_crash(logcat))
  108. def test_parses_app_frame_as_inference(self):
  109. crash = """FATAL EXCEPTION: main
  110. java.lang.IndexOutOfBoundsException: Index 18 out of bounds
  111. at io.legado.app.ui.book.read.ReadBookViewModel.loadChapter(ReadBookViewModel.kt:418)
  112. """
  113. failure = runner.parse_failure(crash)
  114. self.assertEqual("uncaught_exception", failure["category"])
  115. self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
  116. self.assertEqual("medium", failure["suspectedFrame"]["confidence"])
  117. self.assertEqual(418, failure["suspectedFrame"]["line"])
  118. def test_prefers_deepest_caused_by_as_root_cause_candidate(self):
  119. crash = """FATAL EXCEPTION: main
  120. java.lang.RuntimeException: wrapper
  121. Caused by: java.lang.IllegalStateException: middle
  122. Caused by: java.lang.IndexOutOfBoundsException: root
  123. """
  124. failure = runner.parse_failure(crash)
  125. self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
  126. self.assertEqual("root", failure["message"])
  127. self.assertEqual(["fatal_exception"], failure["detectedSignals"])
  128. def test_crash_has_priority_over_assertion_failure(self):
  129. scenario = runner.load_scenario("reader/page-turn")
  130. summary, exit_code = runner.make_summary(
  131. session_id="test",
  132. scenario=scenario,
  133. fixture="long-chapter-v1",
  134. build={"status": "passed"},
  135. ready=False,
  136. activity_visible=False,
  137. content_changed=False,
  138. action_count=0,
  139. failure={"category": "uncaught_exception", "stacktraceArtifact": "crash.txt"},
  140. )
  141. self.assertEqual(runner.EXIT_CRASH, exit_code)
  142. self.assertEqual(runner.EXIT_CRASH, summary["exitCode"])
  143. class CommandDecodingTest(unittest.TestCase):
  144. def test_replaces_invalid_utf8_in_command_output(self):
  145. result = runner.run_command(
  146. [sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'log\\xc0tail')"],
  147. )
  148. self.assertEqual("log\ufffdtail", result.stdout)
  149. @mock.patch.object(runner, "cleanup_device")
  150. @mock.patch.object(runner, "run_command")
  151. def test_install_retries_with_streaming_after_timeout(self, run_command, cleanup_device):
  152. run_command.side_effect = [
  153. subprocess.TimeoutExpired(["adb", "install"], 120),
  154. subprocess.CompletedProcess(["adb", "install"], 0, "Success\n"),
  155. ]
  156. with tempfile.TemporaryDirectory() as directory:
  157. mode = runner.install_apk("device", Path("app.apk"), Path(directory))
  158. install_log = (Path(directory) / "install.log").read_text(encoding="utf-8")
  159. self.assertEqual("streaming_retry", mode)
  160. self.assertIn("timeout=120s", install_log)
  161. cleanup_device.assert_called_once_with("device")
  162. class ReaderContentTest(unittest.TestCase):
  163. def test_extracts_reader_pages_from_logcat(self):
  164. logcat = (
  165. "07-21 23:52 26863 26863 I LegadoDebug: [session=x] FIXTURE_READY fixture=y entry=reader\n"
  166. "07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE LEGADO_DEBUG_LONG_CHAPTER_V1 page one\n"
  167. "07-21 23:52 26863 26863 I LegadoDebug: READER_PAGE page two body\n"
  168. )
  169. self.assertEqual(
  170. ["LEGADO_DEBUG_LONG_CHAPTER_V1 page one", "page two body"],
  171. runner.extract_reader_pages(logcat),
  172. )
  173. def test_returns_empty_when_no_reader_page_logged(self):
  174. self.assertEqual([], runner.extract_reader_pages("nothing here\n"))
  175. @mock.patch.object(runner.time, "sleep")
  176. @mock.patch.object(runner, "reader_current_page")
  177. def test_waits_until_reader_content_changes(self, reader_current_page, _sleep):
  178. reader_current_page.side_effect = ["before", "after"]
  179. changed, latest, content = runner.wait_until_reader_content_changed("device", "before", 5)
  180. self.assertTrue(changed)
  181. self.assertEqual("after", content)
  182. self.assertEqual("after", latest)
  183. self.assertEqual(2, reader_current_page.call_count)
  184. def test_finds_clickable_bookshelf_item_and_center(self):
  185. dump = """<?xml version="1.0"?><hierarchy>
  186. <node class="android.view.View" package="io.legato.kazusa.debug" clickable="true"
  187. content-desc="调试夹具:长章节, Legado Debug" bounds="[100,200][300,600]" />
  188. </hierarchy>"""
  189. node = runner.find_node_by_content_prefix(dump, "调试夹具:长章节")
  190. self.assertIsNotNone(node)
  191. self.assertEqual((200, 400), runner.node_center(node))
  192. class SessionLogTest(unittest.TestCase):
  193. @mock.patch.object(runner, "adb")
  194. def test_collects_each_observed_application_pid(self, adb):
  195. adb.side_effect = [
  196. subprocess.CompletedProcess([], 0, "full log\n[session=s1] ready\n"),
  197. subprocess.CompletedProcess([], 0, "pid 10 log\n"),
  198. subprocess.CompletedProcess([], 0, "pid 11 log\n"),
  199. ]
  200. with tempfile.TemporaryDirectory() as directory:
  201. runner.collect_logcat("device", Path(directory), ["10", "11", "10"], "s1")
  202. session_log = (Path(directory) / "session-logcat.txt").read_text(encoding="utf-8")
  203. self.assertIn("--- pid=10 ---", session_log)
  204. self.assertIn("--- pid=11 ---", session_log)
  205. self.assertIn("[session=s1] ready", session_log)
  206. self.assertEqual(3, adb.call_count)
  207. class DeviceStateTest(unittest.TestCase):
  208. @mock.patch.object(runner, "adb")
  209. def test_detects_awake_device(self, adb):
  210. adb.return_value = subprocess.CompletedProcess([], 0, " mWakefulness=Awake\n")
  211. self.assertTrue(runner.device_is_awake("device"))
  212. @mock.patch.object(runner, "adb")
  213. def test_detects_visible_keyguard(self, adb):
  214. adb.return_value = subprocess.CompletedProcess([], 0, "KeyguardServiceDelegate\n showing=true\n")
  215. self.assertTrue(runner.keyguard_is_showing("device"))
  216. if __name__ == "__main__":
  217. unittest.main()