Răsfoiți Sursa

feat: 增强阅读器场景,提取内容并优化安装流程

5151561 3 luni în urmă
părinte
comite
b476ecaac9

+ 5 - 0
tools/android/README.md

@@ -33,6 +33,11 @@ fixture 位于 `app/src/debug/assets/debug-fixtures/`,由只存在于 debug 
 `summary.json` 是主要机器输入。`failure.suspectedFrame` 仅在堆栈中成功解析到应用代码帧时出现,
 `summary.json` 是主要机器输入。`failure.suspectedFrame` 仅在堆栈中成功解析到应用代码帧时出现,
 并明确携带 `confidence`;`crash.txt` 始终是原始证据。
 并明确携带 `confidence`;`crash.txt` 始终是原始证据。
 
 
+阅读器的 `ReadView` 会把当前页正文写入 accessibility `contentDescription`。运行器只比较这个
+正文节点,而不是比较包含时间、焦点等噪声的整棵 UI 树。`session-logcat.txt` 只保存本次应用
+进程及 session 标记日志,`logcat.txt` 保留完整原始日志。场景结束后,无论成功或失败,运行器
+都会停止 debug 应用进程。
+
 运行器不会在每次场景后停止 Gradle daemon,以免破坏增量构建。开发机需要回收构建进程时,
 运行器不会在每次场景后停止 Gradle daemon,以免破坏增量构建。开发机需要回收构建进程时,
 在本轮工作结束后执行:
 在本轮工作结束后执行:
 
 

+ 133 - 27
tools/android/runner.py

@@ -14,6 +14,7 @@ import subprocess
 import sys
 import sys
 import time
 import time
 from typing import Any, Sequence
 from typing import Any, Sequence
+import xml.etree.ElementTree as ET
 
 
 
 
 EXIT_ASSERTION = 1
 EXIT_ASSERTION = 1
@@ -77,12 +78,15 @@ def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
     timeout = scenario.get("readyTimeoutSeconds", 30)
     timeout = scenario.get("readyTimeoutSeconds", 30)
     if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
     if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
         raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
         raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
+    entry = scenario.get("entry")
+    if not isinstance(entry, dict) or entry.get("type") != "reader":
+        raise ScenarioError("entry.type must be reader")
 
 
     actions = scenario.get("actions")
     actions = scenario.get("actions")
     if not isinstance(actions, list) or not actions:
     if not isinstance(actions, list) or not actions:
         raise ScenarioError("actions must be a non-empty array")
         raise ScenarioError("actions must be a non-empty array")
     for index, action in enumerate(actions):
     for index, action in enumerate(actions):
-        if not isinstance(action, dict) or action.get("type") not in {"open_reader", "turn_page"}:
+        if not isinstance(action, dict) or action.get("type") != "turn_page":
             raise ScenarioError(f"actions[{index}] has an unsupported type")
             raise ScenarioError(f"actions[{index}] has an unsupported type")
         if action["type"] == "turn_page":
         if action["type"] == "turn_page":
             if action.get("direction") not in {"next", "previous"}:
             if action.get("direction") not in {"next", "previous"}:
@@ -95,7 +99,7 @@ def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
     supported = {
     supported = {
         "reader_ready",
         "reader_ready",
         "reader_activity_visible",
         "reader_activity_visible",
-        "page_content_changed",
+        "reader_content_changed",
         "no_fatal_exception",
         "no_fatal_exception",
     }
     }
     if not isinstance(assertions, list) or not assertions:
     if not isinstance(assertions, list) or not assertions:
@@ -248,14 +252,35 @@ def build_and_install(device: str, run_dir: Path) -> tuple[dict[str, Any], Path]
     if result.returncode != 0:
     if result.returncode != 0:
         raise CommandError(command, result)
         raise CommandError(command, result)
     apk = find_debug_apk()
     apk = find_debug_apk()
-    install = adb(device, "install", "-r", "-t", str(apk), timeout=180, check=False)
-    (run_dir / "install.log").write_text(install.stdout, encoding="utf-8")
-    if install.returncode != 0:
-        raise CommandError(["adb", "install", str(apk)], install)
+    install_mode = install_apk(device, apk, run_dir)
     build["apk"] = str(apk.relative_to(PROJECT_ROOT))
     build["apk"] = str(apk.relative_to(PROJECT_ROOT))
+    build["installMode"] = install_mode
     return build, apk
     return build, apk
 
 
 
 
+def install_apk(device: str, apk: Path, run_dir: Path) -> str:
+    attempts = [
+        ("no_streaming", ["adb", "-s", device, "install", "--no-streaming", "-r", "-t", str(apk)], 120),
+        ("streaming_retry", ["adb", "-s", device, "install", "-r", "-t", str(apk)], 60),
+    ]
+    log_parts = []
+    last_error: Exception | None = None
+    for mode, command, timeout in attempts:
+        try:
+            result = run_command(command, timeout=timeout, check=False)
+            log_parts.append(f"[{mode}] exit={result.returncode}\n{result.stdout}")
+            if result.returncode == 0 and "Success" in result.stdout:
+                (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
+                return mode
+            last_error = CommandError(command, result)
+        except subprocess.TimeoutExpired as error:
+            log_parts.append(f"[{mode}] timeout={timeout}s\n")
+            last_error = error
+        cleanup_device(device)
+    (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
+    raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
+
+
 def launch_fixture(device: str, fixture: str, session_id: str) -> None:
 def launch_fixture(device: str, fixture: str, session_id: str) -> None:
     adb(device, "shell", "am", "force-stop", PACKAGE)
     adb(device, "shell", "am", "force-stop", PACKAGE)
     adb(device, "logcat", "-c")
     adb(device, "logcat", "-c")
@@ -289,15 +314,40 @@ def current_focus(device: str) -> str:
     return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
     return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
 
 
 
 
-def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str]:
+def extract_reader_content(dump: str) -> str:
+    start = dump.find("<?xml")
+    end = dump.rfind("</hierarchy>")
+    if start < 0 or end < 0:
+        return ""
+    try:
+        root = ET.fromstring(dump[start : end + len("</hierarchy>")])
+    except ET.ParseError:
+        return ""
+    contents = [
+        node.attrib.get("content-desc", "")
+        for node in root.iter("node")
+        if node.attrib.get("package") == PACKAGE
+        and node.attrib.get("class") == "io.legado.app.ui.book.read.page.ReadView"
+    ]
+    return max(contents, key=len, default="")
+
+
+def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
     deadline = time.monotonic() + timeout_seconds
     deadline = time.monotonic() + timeout_seconds
     latest = ""
     latest = ""
+    content = ""
     while time.monotonic() < deadline:
     while time.monotonic() < deadline:
         latest = ui_dump(device)
         latest = ui_dump(device)
-        if marker in latest and PACKAGE in current_focus(device):
-            return True, latest
+        content = extract_reader_content(latest)
+        if marker in content and PACKAGE in current_focus(device):
+            return True, latest, content
         time.sleep(0.5)
         time.sleep(0.5)
-    return False, latest
+    return False, latest, content
+
+
+def application_pid(device: str) -> str | None:
+    result = adb(device, "shell", "pidof", PACKAGE, timeout=10, check=False)
+    return result.stdout.strip().split()[0] if result.returncode == 0 and result.stdout.strip() else None
 
 
 
 
 def screen_size(device: str) -> tuple[int, int]:
 def screen_size(device: str) -> tuple[int, int]:
@@ -327,8 +377,6 @@ def perform_actions(device: str, scenario: dict[str, Any], action_repeat: int) -
     width, height = screen_size(device)
     width, height = screen_size(device)
     performed = 0
     performed = 0
     for action in scenario["actions"]:
     for action in scenario["actions"]:
-        if action["type"] == "open_reader":
-            continue
         direction = action["direction"]
         direction = action["direction"]
         count = action.get("count", 1) * action_repeat
         count = action.get("count", 1) * action_repeat
         x = round(width * (0.84 if direction == "next" else 0.16))
         x = round(width * (0.84 if direction == "next" else 0.16))
@@ -340,15 +388,38 @@ def perform_actions(device: str, scenario: dict[str, Any], action_repeat: int) -
     return performed
     return performed
 
 
 
 
-def collect_logcat(device: str, run_dir: Path) -> str:
+def collect_logcat(
+    device: str,
+    run_dir: Path,
+    app_pid: str | None = None,
+    session_id: str | None = None,
+) -> str:
     logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
     logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
     (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
     (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
-    crash = extract_crash(logcat, PACKAGE)
+    session_logcat = ""
+    if app_pid:
+        session_logcat = adb(
+            device,
+            "logcat",
+            "-d",
+            f"--pid={app_pid}",
+            "-v",
+            "threadtime",
+            timeout=45,
+            check=False,
+        ).stdout
+    if session_id:
+        session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
+        for line in session_lines:
+            if line not in session_logcat:
+                session_logcat += line + "\n"
+    (run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
+    crash = extract_crash(logcat, PACKAGE, app_pid)
     (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
     (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
     return logcat
     return logcat
 
 
 
 
-def extract_crash(logcat: str, package: str = PACKAGE) -> str:
+def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = None) -> str:
     lines = logcat.splitlines()
     lines = logcat.splitlines()
     chunks = []
     chunks = []
     for index, line in enumerate(lines):
     for index, line in enumerate(lines):
@@ -357,6 +428,8 @@ def extract_crash(logcat: str, package: str = PACKAGE) -> str:
             chunks.extend(chunk)
             chunks.extend(chunk)
         elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
         elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
             chunks.extend(chunk)
             chunks.extend(chunk)
+        elif "Fatal signal " in line and app_pid and re.search(rf"\b{re.escape(app_pid)}\b", line):
+            chunks.extend(chunk)
     if not chunks:
     if not chunks:
         return ""
         return ""
     return "\n".join(chunks).rstrip() + "\n"
     return "\n".join(chunks).rstrip() + "\n"
@@ -366,16 +439,35 @@ def parse_failure(crash: str) -> dict[str, Any] | None:
     if not crash:
     if not crash:
         return None
         return None
     if "ANR in" in crash:
     if "ANR in" in crash:
-        return {"category": "anr", "stacktraceArtifact": "crash.txt"}
-    exception = re.search(r"(?:Caused by: )?([\w.$]+(?:Exception|Error))(?:: ([^\n]+))?", crash)
+        return {
+            "category": "anr",
+            "confidence": "high",
+            "detectedSignals": ["anr"],
+            "stacktraceArtifact": "crash.txt",
+        }
+    if "Fatal signal " in crash:
+        return {
+            "category": "native_crash",
+            "confidence": "high",
+            "detectedSignals": ["fatal_signal"],
+            "stacktraceArtifact": "crash.txt",
+        }
+    exceptions = list(
+        re.finditer(r"(Caused by: )?([\w.$]+(?:Exception|Error))(?:: ([^\n]+))?", crash)
+    )
+    caused_by = [match for match in exceptions if match.group(1)]
+    exception = caused_by[-1] if caused_by else (exceptions[0] if exceptions else None)
     failure: dict[str, Any] = {
     failure: dict[str, Any] = {
         "category": "uncaught_exception",
         "category": "uncaught_exception",
+        "confidence": "high",
+        "detectedSignals": ["fatal_exception"],
         "stacktraceArtifact": "crash.txt",
         "stacktraceArtifact": "crash.txt",
     }
     }
     if exception:
     if exception:
-        failure["exceptionType"] = exception.group(1).split(".")[-1]
-        if exception.group(2):
-            failure["message"] = exception.group(2).strip()
+        failure["exceptionType"] = exception.group(2).split(".")[-1]
+        failure["exceptionConfidence"] = "medium" if exceptions else "low"
+        if exception.group(3):
+            failure["message"] = exception.group(3).strip()
     frame = re.search(
     frame = re.search(
         r"at (io\.legado\.app\.[\w.$]+)\.([\w$<>]+)\(([^():]+):(\d+)\)",
         r"at (io\.legado\.app\.[\w.$]+)\.([\w$<>]+)\(([^():]+):(\d+)\)",
         crash,
         crash,
@@ -386,7 +478,7 @@ def parse_failure(crash: str) -> dict[str, Any] | None:
             "method": frame.group(2),
             "method": frame.group(2),
             "file": frame.group(3),
             "file": frame.group(3),
             "line": int(frame.group(4)),
             "line": int(frame.group(4)),
-            "confidence": "high",
+            "confidence": "medium",
         }
         }
     return failure
     return failure
 
 
@@ -410,7 +502,7 @@ def make_summary(
     assertion_values = {
     assertion_values = {
         "reader_ready": ready,
         "reader_ready": ready,
         "reader_activity_visible": activity_visible,
         "reader_activity_visible": activity_visible,
-        "page_content_changed": content_changed,
+        "reader_content_changed": content_changed,
         "no_fatal_exception": failure is None,
         "no_fatal_exception": failure is None,
     }
     }
     assertions = [
     assertions = [
@@ -436,6 +528,7 @@ def make_summary(
         "artifacts": {
         "artifacts": {
             "metadata": "metadata.json",
             "metadata": "metadata.json",
             "logcat": "logcat.txt",
             "logcat": "logcat.txt",
+            "sessionLogcat": "session-logcat.txt",
             "crash": "crash.txt",
             "crash": "crash.txt",
             "beforeScreenshot": "before.png",
             "beforeScreenshot": "before.png",
             "afterScreenshot": "after.png",
             "afterScreenshot": "after.png",
@@ -470,6 +563,10 @@ def error_summary(
     return summary
     return summary
 
 
 
 
+def cleanup_device(device: str) -> None:
+    adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
+
+
 def execute(args: argparse.Namespace) -> int:
 def execute(args: argparse.Namespace) -> int:
     try:
     try:
         scenario = load_scenario(args.scenario)
         scenario = load_scenario(args.scenario)
@@ -504,13 +601,16 @@ def execute(args: argparse.Namespace) -> int:
                 "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
                 "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
             }
             }
         summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
         summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
+        cleanup_device(device)
         write_json(run_dir / "summary.json", summary)
         write_json(run_dir / "summary.json", summary)
         print(run_dir / "summary.json")
         print(run_dir / "summary.json")
         return EXIT_BUILD
         return EXIT_BUILD
 
 
+    app_pid: str | None = None
     try:
     try:
         launch_fixture(device, fixture, session_id)
         launch_fixture(device, fixture, session_id)
-        ready, before_dump = wait_until_reader_ready(
+        app_pid = application_pid(device)
+        ready, before_dump, before_content = wait_until_reader_ready(
             device,
             device,
             scenario["readyMarker"],
             scenario["readyMarker"],
             scenario.get("readyTimeoutSeconds", 30),
             scenario.get("readyTimeoutSeconds", 30),
@@ -519,18 +619,22 @@ def execute(args: argparse.Namespace) -> int:
         action_count = perform_actions(device, scenario, args.action_repeat) if ready else 0
         action_count = perform_actions(device, scenario, args.action_repeat) if ready else 0
         time.sleep(0.5)
         time.sleep(0.5)
         after_dump = ui_dump(device)
         after_dump = ui_dump(device)
+        after_content = extract_reader_content(after_dump)
         capture_screenshot(device, run_dir / "after.png")
         capture_screenshot(device, run_dir / "after.png")
         focus = current_focus(device)
         focus = current_focus(device)
         activity_visible = PACKAGE in focus and MAIN_ACTIVITY in focus
         activity_visible = PACKAGE in focus and MAIN_ACTIVITY in focus
-        content_changed = ready and sha256_text(before_dump) != sha256_text(after_dump)
-        logcat = collect_logcat(device, run_dir)
-        failure = parse_failure(extract_crash(logcat))
+        content_changed = ready and bool(after_content) and before_content != after_content
+        logcat = collect_logcat(device, run_dir, app_pid, session_id)
+        failure = parse_failure(extract_crash(logcat, PACKAGE, app_pid))
         metadata.update(
         metadata.update(
             {
             {
                 "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
                 "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
                 "actionRepeat": args.action_repeat,
                 "actionRepeat": args.action_repeat,
                 "uiBeforeSha256": sha256_text(before_dump),
                 "uiBeforeSha256": sha256_text(before_dump),
                 "uiAfterSha256": sha256_text(after_dump),
                 "uiAfterSha256": sha256_text(after_dump),
+                "readerContentBeforeSha256": sha256_text(before_content),
+                "readerContentAfterSha256": sha256_text(after_content),
+                "appPid": app_pid,
             }
             }
         )
         )
         write_json(run_dir / "metadata.json", metadata)
         write_json(run_dir / "metadata.json", metadata)
@@ -547,7 +651,7 @@ def execute(args: argparse.Namespace) -> int:
         )
         )
     except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
     except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
         try:
         try:
-            collect_logcat(device, run_dir)
+            collect_logcat(device, run_dir, app_pid, session_id)
         except Exception:
         except Exception:
             pass
             pass
         summary = error_summary(
         summary = error_summary(
@@ -560,6 +664,8 @@ def execute(args: argparse.Namespace) -> int:
             build,
             build,
         )
         )
         exit_code = EXIT_ENVIRONMENT
         exit_code = EXIT_ENVIRONMENT
+    finally:
+        cleanup_device(device)
     write_json(run_dir / "summary.json", summary)
     write_json(run_dir / "summary.json", summary)
     print(run_dir / "summary.json")
     print(run_dir / "summary.json")
     return exit_code
     return exit_code

+ 2 - 2
tools/android/scenarios/reader/page-turn.json

@@ -2,16 +2,16 @@
   "schemaVersion": 1,
   "schemaVersion": 1,
   "name": "reader/page-turn",
   "name": "reader/page-turn",
   "fixture": "long-chapter-v1",
   "fixture": "long-chapter-v1",
+  "entry": { "type": "reader" },
   "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
   "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
   "readyTimeoutSeconds": 30,
   "readyTimeoutSeconds": 30,
   "actions": [
   "actions": [
-    { "type": "open_reader" },
     { "type": "turn_page", "direction": "next", "count": 1 }
     { "type": "turn_page", "direction": "next", "count": 1 }
   ],
   ],
   "assertions": [
   "assertions": [
     "reader_ready",
     "reader_ready",
     "reader_activity_visible",
     "reader_activity_visible",
-    "page_content_changed",
+    "reader_content_changed",
     "no_fatal_exception"
     "no_fatal_exception"
   ]
   ]
 }
 }

+ 49 - 1
tools/android/tests/test_runner.py

@@ -1,7 +1,10 @@
 import importlib.util
 import importlib.util
 from pathlib import Path
 from pathlib import Path
+import subprocess
 import sys
 import sys
+import tempfile
 import unittest
 import unittest
+from unittest import mock
 
 
 
 
 RUNNER_PATH = Path(__file__).parents[1] / "runner.py"
 RUNNER_PATH = Path(__file__).parents[1] / "runner.py"
@@ -21,6 +24,7 @@ class ScenarioValidationTest(unittest.TestCase):
             "schemaVersion": 1,
             "schemaVersion": 1,
             "name": "reader/page-turn",
             "name": "reader/page-turn",
             "fixture": "long-chapter-v1",
             "fixture": "long-chapter-v1",
+            "entry": {"type": "reader"},
             "readyMarker": "marker",
             "readyMarker": "marker",
             "actions": [{"type": "tap"}],
             "actions": [{"type": "tap"}],
             "assertions": ["reader_ready"],
             "assertions": ["reader_ready"],
@@ -67,9 +71,20 @@ java.lang.IndexOutOfBoundsException: Index 18 out of bounds
         failure = runner.parse_failure(crash)
         failure = runner.parse_failure(crash)
         self.assertEqual("uncaught_exception", failure["category"])
         self.assertEqual("uncaught_exception", failure["category"])
         self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
         self.assertEqual("IndexOutOfBoundsException", failure["exceptionType"])
-        self.assertEqual("high", failure["suspectedFrame"]["confidence"])
+        self.assertEqual("medium", failure["suspectedFrame"]["confidence"])
         self.assertEqual(418, failure["suspectedFrame"]["line"])
         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):
     def test_crash_has_priority_over_assertion_failure(self):
         scenario = runner.load_scenario("reader/page-turn")
         scenario = runner.load_scenario("reader/page-turn")
         summary, exit_code = runner.make_summary(
         summary, exit_code = runner.make_summary(
@@ -94,6 +109,39 @@ class CommandDecodingTest(unittest.TestCase):
         )
         )
         self.assertEqual("log\ufffdtail", result.stdout)
         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 = """<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<hierarchy rotation="0">
+  <node class="android.widget.TextView" package="io.legato.kazusa.debug" content-desc="22:30" />
+  <node class="io.legado.app.ui.book.read.page.ReadView" package="io.legato.kazusa.debug" content-desc="LEGADO_DEBUG_LONG_CHAPTER_V1 page body" />
+</hierarchy>
+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 = """<?xml version="1.0"?><hierarchy><node class="android.view.View" /></hierarchy>"""
+        self.assertEqual("", runner.extract_reader_content(dump))
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()