Просмотр исходного кода

feat(debug-tools): 场景注册表化、配置注入、录屏与真机可靠的阅读页检测

- runner 引入 ENTRIES/ACTIONS/ASSERTIONS 注册表,去掉 reader/bookshelf 巨型分支;
  新增场景 = 注册 handler + 写 JSON,不改 execute()/validate_scenario()
- 加 --skip-build(复用已装 APK)与 list 命令
- 场景支持 preferences(base64 传输,避免 adb shell 吃掉 JSON)与 record(screenrecord)
- DebugScenarioActivity 在拉起界面前用 AppConfigStore 注入设置(用参数复现前置状态)
- 阅读页就绪/翻页检测改用 ReadView 的 READER_PAGE logcat 信号,不再刮 uiautomator
  (One UI 等设备不暴露 ReadView 节点);书架侧仍用 uiautomator
- 新增 bookshelf/miuix-hidden-statusbar 场景;27 个单测全绿
5151561 3 месяцев назад
Родитель
Сommit
ddae944155

+ 52 - 3
app/src/debug/java/io/legado/app/debug/DebugScenarioActivity.kt

@@ -8,13 +8,17 @@ import androidx.lifecycle.lifecycleScope
 import androidx.room.withTransaction
 import io.legado.app.constant.BookType
 import io.legado.app.constant.PageAnim
+import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.help.book.BookHelp
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.ui.main.MainIntent
 import io.legado.app.utils.GSON
+import com.google.gson.reflect.TypeToken
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -25,19 +29,35 @@ class DebugScenarioActivity : AppCompatActivity() {
         super.onCreate(savedInstanceState)
         val fixtureId = intent.getStringExtra(EXTRA_FIXTURE)
         val sessionId = intent.getStringExtra(EXTRA_SESSION).orEmpty()
+        val entry = intent.getStringExtra(EXTRA_ENTRY) ?: ENTRY_READER
+        val preferencesJson = intent.getStringExtra(EXTRA_PREFERENCES_B64)?.let {
+            String(android.util.Base64.decode(it, android.util.Base64.DEFAULT), Charsets.UTF_8)
+        }
         if (fixtureId == null || !FIXTURE_ID.matches(fixtureId)) {
             fail(sessionId, "invalid fixture ID")
             return
         }
+        if (entry !in SUPPORTED_ENTRIES) {
+            fail(sessionId, "unsupported entry: $entry")
+            return
+        }
 
         lifecycleScope.launch {
             runCatching {
                 withContext(Dispatchers.IO) { installFixture(fixtureId) }
             }.onSuccess { bookUrl ->
-                Log.i(LOG_TAG, "[session=$sessionId] FIXTURE_READY fixture=$fixtureId")
+                applyPreferences(preferencesJson, sessionId)
+                Log.i(LOG_TAG, "[session=$sessionId] FIXTURE_READY fixture=$fixtureId entry=$entry")
+                val targetIntent = when (entry) {
+                    ENTRY_BOOKSHELF -> {
+                        AppConfigStore.putString(PreferKey.defaultHomePage, ENTRY_BOOKSHELF)
+                        AppConfigStore.putLong(PreferKey.saveTabPosition, BookGroup.IdAll)
+                        MainIntent.createHomeIntent(this@DebugScenarioActivity)
+                    }
+                    else -> MainIntent.createReadBookIntent(this@DebugScenarioActivity, bookUrl)
+                }
                 startActivity(
-                    MainIntent.createReadBookIntent(this@DebugScenarioActivity, bookUrl)
-                        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+                    targetIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
                 )
             }.onFailure { error ->
                 fail(sessionId, "fixture import failed", error)
@@ -114,6 +134,30 @@ class DebugScenarioActivity : AppCompatActivity() {
         return book.bookUrl
     }
 
+    /**
+     * Apply scenario-declared settings before launching the target UI so bug
+     * preconditions (theme engine, reader toggles, ...) are reproduced by
+     * parameter instead of manual UI toggling. Only String/Boolean values are
+     * accepted; they land in the AppConfigStore overlay and are read
+     * synchronously by the relaunched Activity in this process.
+     */
+    private fun applyPreferences(json: String?, sessionId: String) {
+        if (json.isNullOrBlank()) return
+        val type = object : TypeToken<Map<String, Any?>>() {}.type
+        val preferences: Map<String, Any?> = GSON.fromJson(json, type) ?: emptyMap()
+        val applied = HashMap<String, Any>()
+        preferences.forEach { (key, value) ->
+            when (value) {
+                is String, is Boolean -> applied[key] = value
+                else -> error("unsupported preference type for $key")
+            }
+        }
+        if (applied.isNotEmpty()) {
+            AppConfigStore.putAll(applied)
+            Log.i(LOG_TAG, "[session=$sessionId] PREFERENCES_APPLIED ${applied.keys}")
+        }
+    }
+
     private fun fail(sessionId: String, message: String, error: Throwable? = null) {
         Log.e(LOG_TAG, "[session=$sessionId] FIXTURE_ERROR $message", error)
         finishAffinity()
@@ -146,8 +190,13 @@ class DebugScenarioActivity : AppCompatActivity() {
     private companion object {
         const val EXTRA_FIXTURE = "fixture"
         const val EXTRA_SESSION = "session"
+        const val EXTRA_ENTRY = "entry"
+        const val EXTRA_PREFERENCES_B64 = "preferencesB64"
         const val LOG_TAG = "LegadoDebug"
         const val DEBUG_ORIGIN = "legado-debug://fixture-source"
+        const val ENTRY_READER = "reader"
+        const val ENTRY_BOOKSHELF = "bookshelf"
+        val SUPPORTED_ENTRIES = setOf(ENTRY_READER, ENTRY_BOOKSHELF)
         val FIXTURE_ID = Regex("[a-z0-9][a-z0-9-]*")
     }
 }

+ 10 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -6,12 +6,14 @@ import android.graphics.RectF
 import android.os.Build
 import android.os.Bundle
 import android.util.AttributeSet
+import android.util.Log
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
+import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.data.entities.BookProgress
@@ -632,7 +634,14 @@ class ReadView(
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
-            contentDescription = pageFactory.curPage.text
+            val text = pageFactory.curPage.text
+            contentDescription = text
+            if (BuildConfig.DEBUG) {
+                // Device-independent readiness/page-change signal for the debug
+                // scenario runner (uiautomator does not expose ReadView on all
+                // devices). See tools/android/runner.py.
+                Log.i("LegadoDebug", "READER_PAGE " + text.replace("\n", " "))
+            }
         }
         if (isScroll && !isAutoPage) {
             if (relativePosition == 0) {

+ 63 - 6
tools/android/README.md

@@ -3,7 +3,8 @@
 项目级 Android 调试入口。它负责构建、安装、恢复 fixture、执行领域动作,并将结果写入
 `.debug-runs/<session-id>/summary.json`。
 
-当前只提供首个端到端场景:
+当前提供三个端到端场景(`reader/page-turn`、`bookshelf/open-reader-back`、
+`bookshelf/miuix-hidden-statusbar`):
 
 ```bash
 tools/android/debug.sh run reader/page-turn \
@@ -12,9 +13,56 @@ tools/android/debug.sh run reader/page-turn \
   --action-repeat 20
 ```
 
+第二个场景验证从书架打开固定书籍,再返回同一书架:
+
+```bash
+tools/android/debug.sh run bookshelf/open-reader-back --device emulator-5554
+```
+
+它通过书籍的 accessibility 标签定位 fixture,不依赖固定坐标;阅读页 ready 使用 `ReadView`
+的 `READER_PAGE` logcat 信号(见下),返回后再次确认同一书籍节点可见。
+
+## 用参数复现 bug 前置状态
+
+有些 bug 依赖特定设置(主题引擎、阅读器开关等)。场景可以声明 `preferences`,由
+`DebugScenarioActivity` 在拉起界面前写入 `AppConfigStore`,从而**用参数复现,而不是手动去 UI
+里开开关**。只接受字符串/布尔值。配合 `record: true` 会在动作阶段用 `adb screenrecord` 录一段,
+拉回 `.debug-runs/<session>/transition.mp4`,用于判断翻页/返回这类瞬时动画类 bug(静态截图抓不到)。
+
+例如“miuix 主题 + 阅读器隐藏状态栏时,返回书架有下移动画”:
+
+```json
+{
+  "record": true,
+  "preferences": { "composeEngine": "miuix", "hideStatusBar": true },
+  "actions": [ { "type": "open_fixture_book" }, { "type": "press_back" } ]
+}
+```
+
+```bash
+tools/android/debug.sh run bookshelf/miuix-hidden-statusbar
+```
+
+修改 `DebugScenarioActivity`(如新增可注入的设置)后第一次要全量构建,之后同一 APK 反复调用
+`--skip-build` 即可秒级复跑。
+
 当只有一台 ready 状态的设备时可以省略 `--device`。`--action-repeat` 表示在同一次应用启动中
 重复领域动作;它不等同于完整重置后多次运行场景。
 
+fixture 是在 `am start` 时由 `DebugScenarioActivity` 现装的,所以只要 debug APK 没变,换场景/
+换 fixture 都不需要重新构建安装。迭代时加 `--skip-build` 复用设备上已装的 APK,跳过 Gradle
+构建与安装(此时 `summary.build.status` 为 `skipped`),把一轮从分钟级降到秒级:
+
+```bash
+tools/android/debug.sh run reader/page-turn --skip-build
+```
+
+查看当前支持的 entry、action、assertion(含参数):
+
+```bash
+tools/android/debug.sh list
+```
+
 退出码:
 
 | 退出码 | 含义 |
@@ -27,16 +75,25 @@ tools/android/debug.sh run reader/page-turn \
 | 5 | 场景定义错误 |
 
 fixture 位于 `app/src/debug/assets/debug-fixtures/`,由只存在于 debug 包中的
-`DebugScenarioActivity` 导入。场景只使用 `open_reader` 和 `turn_page` 两个领域动作;这里刻意
-不提供坐标级通用 DSL。
+`DebugScenarioActivity` 导入。当前的领域动作是 `reader` entry 的 `turn_page`,以及 `bookshelf`
+entry 的 `open_fixture_book` / `press_back`;这里刻意不提供坐标级通用 DSL。
+
+entry、action、assertion 通过 `runner.py` 里的 `ENTRIES` / `ACTIONS` / `ASSERTIONS` 注册表扩展:
+新增一个 bug 场景 = 注册一个 handler(`@register_action` / `@register_entry`)再写一个场景 JSON,
+不需要改 `execute()` 或 `validate_scenario()`。handler 只把原始观测写进 `Context`,assertion 是
+对这些观测的纯读取。用 `list` 命令可以随时查到已注册的能力。
 
 `summary.json` 是主要机器输入。`failure.suspectedFrame` 仅在堆栈中成功解析到应用代码帧时出现,
 并明确携带 `confidence`;`crash.txt` 始终是原始证据。
 
-阅读器的 `ReadView` 会把当前页正文写入 accessibility `contentDescription`。运行器只比较这个
-正文节点,而不是比较包含时间、焦点等噪声的整棵 UI 树。`session-logcat.txt` 只保存本次应用
+阅读页的观测走 logcat 而非 uiautomator:`ReadView.upContent` 在 debug 包(`BuildConfig.DEBUG`)
+下每次渲染都打一条 `READER_PAGE <当前页正文>`,运行器 grep `logcat -s LegadoDebug:I` 取最后一条
+判就绪与翻页变化。这样跨设备可靠——某些设备(如 One UI)的 uiautomator 根本不暴露 `ReadView`
+节点。书架侧仍用 uiautomator 定位(Compose 节点可正常 dump)。翻页后在场景限定时间内轮询该信号,
+不依赖固定动画等待。`session-logcat.txt` 只保存本次应用
 进程及 session 标记日志,`logcat.txt` 保留完整原始日志。场景结束后,无论成功或失败,运行器
-都会停止 debug 应用进程。
+都会停止 debug 应用进程。运行器还会在场景前唤醒并解锁设备、临时保持 USB 供电时亮屏,
+结束后恢复原始亮屏与 Keyguard 状态。
 
 运行器不会在每次场景后停止 Gradle daemon,以免破坏增量构建。开发机需要回收构建进程时,
 在本轮工作结束后执行:

+ 660 - 105
tools/android/runner.py

@@ -4,6 +4,7 @@
 from __future__ import annotations
 
 import argparse
+import base64
 import datetime as dt
 import hashlib
 import json
@@ -13,7 +14,7 @@ import shutil
 import subprocess
 import sys
 import time
-from typing import Any, Sequence
+from typing import Any, Callable, Sequence
 import xml.etree.ElementTree as ET
 
 
@@ -33,6 +34,13 @@ SCENARIO_ROOT = SCRIPT_DIR / "scenarios"
 RUN_ROOT = PROJECT_ROOT / ".debug-runs"
 FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
 
+# Capability registries. New bug scenarios extend the tool by registering an
+# entry / action / assertion below, never by editing execute() or
+# validate_scenario(). See the classes near the end of this module.
+ENTRIES: dict[str, "Entry"] = {}
+ACTIONS: dict[str, "Action"] = {}
+ASSERTIONS: dict[str, Callable[["Context"], bool]] = {}
+
 
 class ScenarioError(ValueError):
     pass
@@ -78,37 +86,52 @@ def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
     timeout = scenario.get("readyTimeoutSeconds", 30)
     if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
         raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
+    change_timeout = scenario.get("contentChangeTimeoutSeconds", 5)
+    if not isinstance(change_timeout, int) or change_timeout < 1 or change_timeout > 30:
+        raise ScenarioError("contentChangeTimeoutSeconds must be between 1 and 30")
+    record = scenario.get("record", False)
+    if not isinstance(record, bool):
+        raise ScenarioError("record must be a boolean")
+    validate_preferences(scenario.get("preferences"))
     entry = scenario.get("entry")
-    if not isinstance(entry, dict) or entry.get("type") != "reader":
-        raise ScenarioError("entry.type must be reader")
+    if not isinstance(entry, dict) or entry.get("type") not in ENTRIES:
+        raise ScenarioError(f"entry.type must be one of: {', '.join(sorted(ENTRIES))}")
+    entry_handler = ENTRIES[entry["type"]]
 
     actions = scenario.get("actions")
     if not isinstance(actions, list) or not actions:
         raise ScenarioError("actions must be a non-empty array")
     for index, action in enumerate(actions):
-        if not isinstance(action, dict) or action.get("type") != "turn_page":
+        if not isinstance(action, dict):
+            raise ScenarioError(f"actions[{index}] must be an object")
+        action_type = action.get("type")
+        if action_type not in entry_handler.allowed_actions:
             raise ScenarioError(f"actions[{index}] has an unsupported type")
-        if action["type"] == "turn_page":
-            if action.get("direction") not in {"next", "previous"}:
-                raise ScenarioError(f"actions[{index}].direction must be next or previous")
-            count = action.get("count", 1)
-            if not isinstance(count, int) or count < 1 or count > 100:
-                raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
+        ACTIONS[action_type].validate(action, index)
 
     assertions = scenario.get("assertions")
-    supported = {
-        "reader_ready",
-        "reader_activity_visible",
-        "reader_content_changed",
-        "no_fatal_exception",
-    }
     if not isinstance(assertions, list) or not assertions:
         raise ScenarioError("assertions must be a non-empty array")
-    unknown = set(assertions) - supported
+    unknown = set(assertions) - set(ASSERTIONS)
     if unknown:
         raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
 
 
+def validate_preferences(preferences: Any) -> None:
+    # Scenario-declared app settings applied before launch (theme engine,
+    # reader toggles, ...). Restricted to String/Boolean so the debug Activity
+    # can apply them unambiguously via AppConfigStore.
+    if preferences is None:
+        return
+    if not isinstance(preferences, dict) or not preferences:
+        raise ScenarioError("preferences must be a non-empty object when present")
+    for key, value in preferences.items():
+        if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9]*", key):
+            raise ScenarioError(f"invalid preference key: {key}")
+        if not isinstance(value, (str, bool)):
+            raise ScenarioError(f"preference {key} must be a string or boolean")
+
+
 def validate_fixture(fixture_id: str) -> dict[str, Any]:
     if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
         raise ScenarioError("fixture must be a lowercase ID")
@@ -281,10 +304,26 @@ def install_apk(device: str, apk: Path, run_dir: Path) -> str:
     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,
+    entry: str,
+    session_id: str,
+    preferences: dict[str, Any] | None = None,
+) -> None:
     adb(device, "shell", "am", "force-stop", PACKAGE)
     adb(device, "logcat", "-c")
     component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
+    extras = [
+        "--es", "fixture", fixture,
+        "--es", "session", session_id,
+        "--es", "entry", entry,
+    ]
+    if preferences:
+        # base64 so the JSON survives `adb shell am` re-parsing on the device
+        # (raw JSON gets quote-stripped and brace-expanded by the device shell).
+        payload = json.dumps(preferences, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+        extras += ["--es", "preferencesB64", base64.b64encode(payload).decode("ascii")]
     result = adb(
         device,
         "shell",
@@ -293,12 +332,7 @@ def launch_fixture(device: str, fixture: str, session_id: str) -> None:
         "-W",
         "-n",
         component,
-        "--es",
-        "fixture",
-        fixture,
-        "--es",
-        "session",
-        session_id,
+        *extras,
         timeout=60,
         check=False,
     )
@@ -310,39 +344,198 @@ def ui_dump(device: str) -> str:
     return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
 
 
-def current_focus(device: str) -> str:
+def window_state(device: str) -> str:
+    # Full `dumpsys window windows` output. Callers substring-match against it,
+    # so a hit means the activity is present in the window stack, not strictly
+    # the foreground window.
     return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
 
 
-def extract_reader_content(dump: str) -> str:
+def device_is_awake(device: str) -> bool:
+    output = adb(device, "shell", "dumpsys", "power", timeout=20, check=False).stdout
+    return "mWakefulness=Awake" in output
+
+
+def keyguard_is_showing(device: str) -> bool:
+    output = adb(device, "shell", "dumpsys", "window", "policy", timeout=20, check=False).stdout
+    match = re.search(r"^\s+showing=(true|false)\b", output, re.MULTILINE)
+    return match is not None and match.group(1) == "true"
+
+
+def device_ready_for_ui(device: str) -> bool:
+    return device_is_awake(device) and not keyguard_is_showing(device)
+
+
+def prepare_device(device: str) -> dict[str, Any]:
+    state = {
+        "wasAwake": device_is_awake(device),
+        "wasKeyguardShowing": keyguard_is_showing(device),
+        "stayOnWhilePluggedIn": adb(
+            device,
+            "shell",
+            "settings",
+            "get",
+            "global",
+            "stay_on_while_plugged_in",
+            timeout=20,
+            check=False,
+        ).stdout.strip(),
+    }
+    adb(
+        device,
+        "shell",
+        "settings",
+        "put",
+        "global",
+        "stay_on_while_plugged_in",
+        "2",
+        timeout=20,
+    )
+    if not state["wasAwake"]:
+        adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20)
+    if keyguard_is_showing(device):
+        adb(device, "shell", "wm", "dismiss-keyguard", timeout=20, check=False)
+        time.sleep(0.2)
+    if keyguard_is_showing(device):
+        adb(device, "shell", "input", "keyevent", "82", timeout=20, check=False)
+        time.sleep(0.5)
+    if not device_ready_for_ui(device):
+        restore_device(device, state)
+        raise RuntimeError("device could not be awakened and unlocked for UI automation")
+    return state
+
+
+def restore_device(device: str, state: dict[str, Any] | None) -> None:
+    if state is None:
+        return
+    stay_on = state.get("stayOnWhilePluggedIn")
+    if stay_on in {"", "null", None}:
+        adb(
+            device,
+            "shell",
+            "settings",
+            "delete",
+            "global",
+            "stay_on_while_plugged_in",
+            timeout=20,
+            check=False,
+        )
+    else:
+        adb(
+            device,
+            "shell",
+            "settings",
+            "put",
+            "global",
+            "stay_on_while_plugged_in",
+            str(stay_on),
+            timeout=20,
+            check=False,
+        )
+    if state.get("wasKeyguardShowing"):
+        adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
+        if state.get("wasAwake"):
+            adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20, check=False)
+    elif not state.get("wasAwake"):
+        adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
+
+
+def parse_ui_nodes(dump: str) -> list[dict[str, str]]:
     start = dump.find("<?xml")
     end = dump.rfind("</hierarchy>")
     if start < 0 or end < 0:
-        return ""
+        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 []
+    return [node.attrib for node in root.iter("node")]
+
+
+READER_PAGE_TAG = "READER_PAGE "
+
+
+def extract_reader_pages(logcat: str) -> list[str]:
+    # ReadView logs "READER_PAGE <one-line page text>" on every render (debug
+    # build only). This is the device-independent reader signal — uiautomator
+    # does not expose the ReadView node on all devices.
+    pages = []
+    for line in logcat.splitlines():
+        index = line.find(READER_PAGE_TAG)
+        if index >= 0:
+            pages.append(line[index + len(READER_PAGE_TAG):].strip())
+    return pages
+
+
+def reader_current_page(device: str) -> str:
+    logcat = adb(device, "logcat", "-d", "-s", "LegadoDebug:I", timeout=20, check=False).stdout
+    pages = extract_reader_pages(logcat)
+    return pages[-1] if pages else ""
+
+
+def find_node_by_content_prefix(dump: str, prefix: str) -> dict[str, str] | None:
+    # The bookshelf item's label lives on a node whose clickable flag is false
+    # (the clickable modifier sits on an ancestor). Match by content-desc and
+    # tap the label's center; the clickable ancestor is under that point.
+    candidates = [
+        node
+        for node in parse_ui_nodes(dump)
+        if node.get("package") == PACKAGE
+        and node.get("content-desc", "").startswith(prefix)
     ]
-    return max(contents, key=len, default="")
+    return candidates[0] if candidates else None
 
 
-def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
+def node_center(node: dict[str, str]) -> tuple[int, int]:
+    bounds = node.get("bounds", "")
+    match = re.fullmatch(r"\[(\d+),(\d+)]\[(\d+),(\d+)]", bounds)
+    if match is None:
+        raise RuntimeError(f"invalid accessibility bounds: {bounds}")
+    left, top, right, bottom = map(int, match.groups())
+    return (left + right) // 2, (top + bottom) // 2
+
+
+def wait_until_node_present(
+    device: str,
+    content_prefix: str,
+    timeout_seconds: int,
+) -> tuple[bool, str, dict[str, str] | None]:
     deadline = time.monotonic() + timeout_seconds
     latest = ""
-    content = ""
+    node = None
     while time.monotonic() < deadline:
         latest = ui_dump(device)
-        content = extract_reader_content(latest)
-        if marker in content and PACKAGE in current_focus(device):
-            return True, latest, content
+        node = find_node_by_content_prefix(latest, content_prefix)
+        if node is not None and device_ready_for_ui(device):
+            return True, latest, node
+        time.sleep(0.5)
+    return False, latest, node
+
+
+def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
+    deadline = time.monotonic() + timeout_seconds
+    content = ""
+    while time.monotonic() < deadline:
+        content = reader_current_page(device)
+        if marker in content and PACKAGE in window_state(device) and device_ready_for_ui(device):
+            return True, content, content
         time.sleep(0.5)
-    return False, latest, content
+    return False, content, content
+
+
+def wait_until_reader_content_changed(
+    device: str,
+    previous_content: str,
+    timeout_seconds: int,
+) -> tuple[bool, str, str]:
+    deadline = time.monotonic() + timeout_seconds
+    content = ""
+    while time.monotonic() < deadline:
+        content = reader_current_page(device)
+        if content and content != previous_content:
+            return True, content, content
+        time.sleep(0.2)
+    return False, content, content
 
 
 def application_pid(device: str) -> str | None:
@@ -373,53 +566,94 @@ def capture_screenshot(device: str, path: Path) -> None:
         raise RuntimeError(result.stderr.decode(errors="replace"))
 
 
-def perform_actions(device: str, scenario: dict[str, Any], action_repeat: int) -> int:
-    width, height = screen_size(device)
-    performed = 0
-    for action in scenario["actions"]:
-        direction = action["direction"]
-        count = action.get("count", 1) * action_repeat
-        x = round(width * (0.84 if direction == "next" else 0.16))
-        y = round(height * 0.5)
-        for _ in range(count):
-            adb(device, "shell", "input", "tap", str(x), str(y), timeout=10)
-            performed += 1
-            time.sleep(0.4)
-    return performed
+class ScreenRecorder:
+    """Optional screenrecord wrapper for capturing transient UI (animations).
+
+    Best-effort: any failure to start, stop, or pull leaves the run unaffected
+    and simply produces no video artifact.
+    """
+
+    REMOTE = "/sdcard/legado-debug-transition.mp4"
+
+    def __init__(self, device: str):
+        self.device = device
+        self.process: subprocess.Popen[bytes] | None = None
+        self.saved: str | None = None
+        self._stopped = False
+
+    def start(self) -> None:
+        adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
+        self.process = subprocess.Popen(
+            ["adb", "-s", self.device, "shell", "screenrecord", "--time-limit", "60", self.REMOTE],
+            cwd=PROJECT_ROOT,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+        time.sleep(0.5)
+
+    def stop(self, run_dir: Path, name: str = "transition.mp4") -> str | None:
+        if self.process is None or self._stopped:
+            return self.saved
+        self._stopped = True
+        # SIGINT lets screenrecord finalize a playable mp4 before it exits.
+        pids = adb(self.device, "shell", "pidof", "screenrecord", timeout=10, check=False).stdout.split()
+        for pid in pids:
+            adb(self.device, "shell", "kill", "-INT", pid, timeout=10, check=False)
+        try:
+            self.process.wait(timeout=15)
+        except subprocess.TimeoutExpired:
+            self.process.kill()
+        pull = subprocess.run(
+            ["adb", "-s", self.device, "pull", self.REMOTE, str(run_dir / name)],
+            cwd=PROJECT_ROOT,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
+        if pull.returncode == 0 and (run_dir / name).is_file():
+            self.saved = name
+        return self.saved
 
 
 def collect_logcat(
     device: str,
     run_dir: Path,
-    app_pid: str | None = None,
+    app_pids: Sequence[str] = (),
     session_id: str | None = None,
 ) -> str:
     logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
     (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
     session_logcat = ""
-    if app_pid:
-        session_logcat = adb(
+    unique_pids = list(dict.fromkeys(pid for pid in app_pids if pid))
+    for pid in unique_pids:
+        pid_logcat = adb(
             device,
             "logcat",
             "-d",
-            f"--pid={app_pid}",
+            f"--pid={pid}",
             "-v",
             "threadtime",
             timeout=45,
             check=False,
         ).stdout
+        session_logcat += f"--- pid={pid} ---\n{pid_logcat}"
     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)
+    crash = extract_crash(logcat, PACKAGE, unique_pids)
     (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
     return logcat
 
 
-def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = None) -> str:
+def extract_crash(
+    logcat: str,
+    package: str = PACKAGE,
+    app_pids: Sequence[str] = (),
+) -> str:
     lines = logcat.splitlines()
     chunks = []
     for index, line in enumerate(lines):
@@ -428,7 +662,9 @@ def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = Non
             chunks.extend(chunk)
         elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
             chunks.extend(chunk)
-        elif "Fatal signal " in line and app_pid and re.search(rf"\b{re.escape(app_pid)}\b", line):
+        elif "Fatal signal " in line and any(
+            re.search(rf"\b{re.escape(pid)}\b", line) for pid in app_pids
+        ):
             chunks.extend(chunk)
     if not chunks:
         return ""
@@ -505,6 +741,28 @@ def make_summary(
         "reader_content_changed": content_changed,
         "no_fatal_exception": failure is None,
     }
+    return make_summary_from_assertions(
+        session_id=session_id,
+        scenario=scenario,
+        fixture=fixture,
+        build=build,
+        assertion_values=assertion_values,
+        action_count=action_count,
+        failure=failure,
+    )
+
+
+def make_summary_from_assertions(
+    *,
+    session_id: str,
+    scenario: dict[str, Any],
+    fixture: str,
+    build: dict[str, Any],
+    assertion_values: dict[str, bool],
+    action_count: int,
+    failure: dict[str, Any] | None,
+    extra_artifacts: dict[str, str] | None = None,
+) -> tuple[dict[str, Any], int]:
     assertions = [
         {"name": name, "passed": assertion_values[name]}
         for name in scenario["assertions"]
@@ -530,12 +788,12 @@ def make_summary(
             "logcat": "logcat.txt",
             "sessionLogcat": "session-logcat.txt",
             "crash": "crash.txt",
-            "beforeScreenshot": "before.png",
-            "afterScreenshot": "after.png",
         },
     }
     if failure is not None:
         summary["failure"] = failure
+    if extra_artifacts:
+        summary["artifacts"].update(extra_artifacts)
     return summary, exit_code
 
 
@@ -567,6 +825,277 @@ def cleanup_device(device: str) -> None:
     adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
 
 
+# --------------------------------------------------------------------------- #
+# Capability registry: entries, actions, assertions.
+#
+# A scenario is fully described by data (JSON). Each domain action and each
+# assertion is a small handler registered here. Adding a bug scenario means
+# adding a handler + a JSON file, not editing execute() / validate_scenario().
+# Handlers only *record* raw observations into the Context; assertions are pure
+# reads of those observations.
+# --------------------------------------------------------------------------- #
+
+
+class Context:
+    """Mutable per-run state shared across entry, actions, and assertions."""
+
+    def __init__(
+        self,
+        device: str,
+        run_dir: Path,
+        scenario: dict[str, Any],
+        fixture_data: dict[str, Any],
+        metadata: dict[str, Any],
+        action_repeat: int,
+    ):
+        self.device = device
+        self.run_dir = run_dir
+        self.scenario = scenario
+        self.fixture_data = fixture_data
+        self.metadata = metadata
+        self.action_repeat = action_repeat
+        self.app_pids: list[str] = []
+        self.records: dict[str, Any] = {}
+        self.state: dict[str, Any] = {}
+        self.artifacts: dict[str, str] = {}
+        self.action_count = 0
+        self.assertion_values: dict[str, bool] = {}
+        self.failure: dict[str, Any] | None = None
+
+    def note_pid(self) -> None:
+        pid = application_pid(self.device)
+        if pid and pid not in self.app_pids:
+            self.app_pids.append(pid)
+
+    def screenshot(self, name: str, filename: str) -> None:
+        capture_screenshot(self.device, self.run_dir / filename)
+        self.artifacts[name] = filename
+
+
+class Entry:
+    type: str = ""
+    allowed_actions: set[str] = set()
+
+    def prepare(self, ctx: Context) -> None:
+        raise NotImplementedError
+
+    def finalize(self, ctx: Context) -> None:
+        raise NotImplementedError
+
+
+class Action:
+    name: str = ""
+    params: dict[str, Any] = {}
+
+    def validate(self, action: dict[str, Any], index: int) -> None:
+        pass
+
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        raise NotImplementedError
+
+
+def register_entry(name: str) -> Callable[[type], type]:
+    def decorate(cls: type) -> type:
+        instance = cls()
+        instance.type = name
+        ENTRIES[name] = instance
+        return cls
+
+    return decorate
+
+
+def register_action(name: str) -> Callable[[type], type]:
+    def decorate(cls: type) -> type:
+        instance = cls()
+        instance.name = name
+        ACTIONS[name] = instance
+        return cls
+
+    return decorate
+
+
+@register_entry("reader")
+class ReaderEntry(Entry):
+    allowed_actions = {"turn_page"}
+
+    def prepare(self, ctx: Context) -> None:
+        ready, before_dump, before_content = wait_until_reader_ready(
+            ctx.device,
+            ctx.scenario["readyMarker"],
+            ctx.scenario.get("readyTimeoutSeconds", 30),
+        )
+        ctx.note_pid()
+        ctx.screenshot("beforeScreenshot", "before.png")
+        ctx.records["reader_ready"] = ready
+        ctx.records["reader_content_changed"] = False
+        ctx.records["content_change_duration_ms"] = 0
+        ctx.records["ui_before"] = before_dump
+        ctx.records["ui_after"] = before_dump
+        ctx.records["reader_content_before"] = before_content
+        ctx.records["reader_content_after"] = before_content
+        ctx.state["reader_content"] = before_content
+
+    def finalize(self, ctx: Context) -> None:
+        ctx.screenshot("afterScreenshot", "after.png")
+        windows = window_state(ctx.device)
+        # Presence of MainActivity in the window stack; the reader runs as a
+        # route inside MainActivity in this fork.
+        visible = PACKAGE in windows and MAIN_ACTIVITY in windows and device_ready_for_ui(ctx.device)
+        ctx.records["reader_activity_visible"] = visible
+        ctx.metadata.update(
+            {
+                "actionRepeat": ctx.action_repeat,
+                "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
+                "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
+                "readerContentBeforeSha256": sha256_text(ctx.records["reader_content_before"]),
+                "readerContentAfterSha256": sha256_text(ctx.records["reader_content_after"]),
+                "contentChangeDurationMs": ctx.records["content_change_duration_ms"],
+            }
+        )
+
+
+@register_entry("bookshelf")
+class BookshelfEntry(Entry):
+    allowed_actions = {"open_fixture_book", "press_back"}
+
+    def prepare(self, ctx: Context) -> None:
+        book_name = ctx.fixture_data["book"]["name"]
+        ready, before_dump, book_node = wait_until_node_present(
+            ctx.device,
+            book_name,
+            ctx.scenario.get("readyTimeoutSeconds", 30),
+        )
+        ctx.note_pid()
+        ctx.screenshot("beforeScreenshot", "before.png")
+        ctx.records["bookshelf_ready"] = ready
+        ctx.records["reader_opened"] = False
+        ctx.records["returned_to_bookshelf"] = False
+        ctx.records["ui_before"] = before_dump
+        ctx.records["ui_after"] = before_dump
+        ctx.records["reader_dump"] = ""
+        ctx.records["reader_content"] = ""
+        ctx.state["book_name"] = book_name
+        ctx.state["book_node"] = book_node
+        ctx.state["returned_node"] = None
+
+    def finalize(self, ctx: Context) -> None:
+        ctx.screenshot("afterScreenshot", "after.png")
+        book_node = ctx.state.get("book_node")
+        returned_node = ctx.state.get("returned_node")
+        ctx.metadata.update(
+            {
+                "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
+                "readerUiSha256": sha256_text(ctx.records["reader_dump"]),
+                "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
+                "readerContentSha256": sha256_text(ctx.records["reader_content"]),
+                "bookshelfItemBefore": book_node.get("content-desc") if book_node else None,
+                "bookshelfItemAfter": returned_node.get("content-desc") if returned_node else None,
+            }
+        )
+
+
+@register_action("turn_page")
+class TurnPage(Action):
+    params = {"direction": ["next", "previous"], "count": "1-100"}
+
+    def validate(self, action: dict[str, Any], index: int) -> None:
+        if action.get("direction") not in {"next", "previous"}:
+            raise ScenarioError(f"actions[{index}].direction must be next or previous")
+        count = action.get("count", 1)
+        if not isinstance(count, int) or count < 1 or count > 100:
+            raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
+
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        if not ctx.records.get("reader_ready"):
+            return
+        width, height = screen_size(ctx.device)
+        direction = action["direction"]
+        count = action.get("count", 1) * ctx.action_repeat
+        x = round(width * (0.84 if direction == "next" else 0.16))
+        y = round(height * 0.5)
+        for _ in range(count):
+            adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+            ctx.action_count += 1
+            time.sleep(0.4)
+        started = time.monotonic()
+        changed, after_dump, after_content = wait_until_reader_content_changed(
+            ctx.device,
+            ctx.state["reader_content"],
+            ctx.scenario.get("contentChangeTimeoutSeconds", 5),
+        )
+        ctx.records["content_change_duration_ms"] = round((time.monotonic() - started) * 1000)
+        if changed:
+            ctx.records["reader_content_changed"] = True
+            ctx.state["reader_content"] = after_content
+        ctx.records["reader_content_after"] = after_content
+        ctx.records["ui_after"] = after_dump
+
+
+@register_action("open_fixture_book")
+class OpenFixtureBook(Action):
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        book_node = ctx.state.get("book_node")
+        if not (ctx.records.get("bookshelf_ready") and book_node is not None):
+            return
+        x, y = node_center(book_node)
+        adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
+        ctx.action_count += 1
+        opened, reader_dump, reader_content = wait_until_reader_ready(
+            ctx.device,
+            ctx.scenario["readyMarker"],
+            ctx.scenario.get("readyTimeoutSeconds", 30),
+        )
+        ctx.records["reader_opened"] = opened
+        ctx.records["reader_dump"] = reader_dump
+        ctx.records["reader_content"] = reader_content
+        ctx.screenshot("readerScreenshot", "reader.png")
+
+
+@register_action("press_back")
+class PressBack(Action):
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        if not ctx.records.get("reader_opened"):
+            return
+        adb(ctx.device, "shell", "input", "keyevent", "BACK", timeout=10)
+        ctx.action_count += 1
+        returned, after_dump, returned_node = wait_until_node_present(
+            ctx.device,
+            ctx.state["book_name"],
+            ctx.scenario.get("readyTimeoutSeconds", 30),
+        )
+        ctx.records["returned_to_bookshelf"] = returned
+        ctx.records["ui_after"] = after_dump
+        ctx.state["returned_node"] = returned_node
+
+
+ASSERTIONS.update(
+    {
+        "reader_ready": lambda ctx: bool(ctx.records.get("reader_ready")),
+        "reader_activity_visible": lambda ctx: bool(ctx.records.get("reader_activity_visible")),
+        "reader_content_changed": lambda ctx: bool(ctx.records.get("reader_content_changed")),
+        "bookshelf_ready": lambda ctx: bool(ctx.records.get("bookshelf_ready")),
+        "reader_opened": lambda ctx: bool(ctx.records.get("reader_opened")),
+        "returned_to_bookshelf": lambda ctx: bool(ctx.records.get("returned_to_bookshelf")),
+        "no_fatal_exception": lambda ctx: ctx.failure is None,
+    }
+)
+
+
+def build_capabilities() -> dict[str, Any]:
+    return {
+        "schemaVersion": 1,
+        "entries": {
+            name: {"allowedActions": sorted(handler.allowed_actions)}
+            for name, handler in sorted(ENTRIES.items())
+        },
+        "actions": {
+            name: {"params": handler.params}
+            for name, handler in sorted(ACTIONS.items())
+        },
+        "assertions": sorted(ASSERTIONS),
+    }
+
+
 def execute(args: argparse.Namespace) -> int:
     try:
         scenario = load_scenario(args.scenario)
@@ -577,6 +1106,8 @@ def execute(args: argparse.Namespace) -> int:
             raise ScenarioError("scenario readyMarker is not provided by the fixture")
         if args.action_repeat < 1 or args.action_repeat > 100:
             raise ScenarioError("--action-repeat must be between 1 and 100")
+        if scenario["entry"]["type"] == "bookshelf" and args.action_repeat != 1:
+            raise ScenarioError("--action-repeat is only supported by reader scenarios")
     except ScenarioError as error:
         print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
         return EXIT_SCENARIO
@@ -591,67 +1122,79 @@ def execute(args: argparse.Namespace) -> int:
         return EXIT_ENVIRONMENT
 
     session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
-    build: dict[str, Any] = {"status": "not_started"}
+    device_state: dict[str, Any] | None = None
     try:
-        build, _ = build_and_install(device, run_dir)
-    except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
-        if isinstance(error, CommandError):
-            build = {
-                "status": "failed",
-                "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)
-        cleanup_device(device)
+        device_state = prepare_device(device)
+        metadata["deviceStateBefore"] = device_state
+        write_json(run_dir / "metadata.json", metadata)
+    except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
+        summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
         write_json(run_dir / "summary.json", summary)
         print(run_dir / "summary.json")
-        return EXIT_BUILD
+        return EXIT_ENVIRONMENT
 
-    app_pid: str | None = None
+    build: dict[str, Any] = {"status": "not_started"}
+    if args.skip_build:
+        build = {"status": "skipped"}
+    else:
+        try:
+            build, _ = build_and_install(device, run_dir)
+        except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
+            if isinstance(error, CommandError):
+                build = {
+                    "status": "failed",
+                    "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)
+            cleanup_device(device)
+            restore_device(device, device_state)
+            write_json(run_dir / "summary.json", summary)
+            print(run_dir / "summary.json")
+            return EXIT_BUILD
+
+    entry_handler = ENTRIES[scenario["entry"]["type"]]
+    ctx = Context(device, run_dir, scenario, fixture_data, metadata, args.action_repeat)
+    recorder = ScreenRecorder(device) if scenario.get("record") else None
     try:
-        launch_fixture(device, fixture, session_id)
-        app_pid = application_pid(device)
-        ready, before_dump, before_content = wait_until_reader_ready(
-            device,
-            scenario["readyMarker"],
-            scenario.get("readyTimeoutSeconds", 30),
-        )
-        capture_screenshot(device, run_dir / "before.png")
-        action_count = perform_actions(device, scenario, args.action_repeat) if ready else 0
-        time.sleep(0.5)
-        after_dump = ui_dump(device)
-        after_content = extract_reader_content(after_dump)
-        capture_screenshot(device, run_dir / "after.png")
-        focus = current_focus(device)
-        activity_visible = PACKAGE in focus and MAIN_ACTIVITY in focus
-        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))
+        launch_fixture(device, fixture, entry_handler.type, session_id, scenario.get("preferences"))
+        ctx.note_pid()
+        entry_handler.prepare(ctx)
+        if recorder is not None:
+            recorder.start()
+        for action in scenario["actions"]:
+            ACTIONS[action["type"]].run(ctx, action)
+        if recorder is not None:
+            time.sleep(0.5)  # tail so the settle after the last action is captured
+            video = recorder.stop(run_dir)
+            if video:
+                ctx.artifacts["transition"] = video
+        entry_handler.finalize(ctx)
+        ctx.note_pid()
+        logcat = collect_logcat(device, run_dir, ctx.app_pids, session_id)
+        ctx.failure = parse_failure(extract_crash(logcat, PACKAGE, ctx.app_pids))
+        for name in scenario["assertions"]:
+            ctx.assertion_values[name] = ASSERTIONS[name](ctx)
         metadata.update(
             {
                 "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
-                "actionRepeat": args.action_repeat,
-                "uiBeforeSha256": sha256_text(before_dump),
-                "uiAfterSha256": sha256_text(after_dump),
-                "readerContentBeforeSha256": sha256_text(before_content),
-                "readerContentAfterSha256": sha256_text(after_content),
-                "appPid": app_pid,
+                "appPids": ctx.app_pids,
             }
         )
         write_json(run_dir / "metadata.json", metadata)
-        summary, exit_code = make_summary(
+        summary, exit_code = make_summary_from_assertions(
             session_id=session_id,
             scenario=scenario,
             fixture=fixture,
             build=build,
-            ready=ready,
-            activity_visible=activity_visible,
-            content_changed=content_changed,
-            action_count=action_count,
-            failure=failure,
+            assertion_values=ctx.assertion_values,
+            action_count=ctx.action_count,
+            failure=ctx.failure,
+            extra_artifacts=ctx.artifacts,
         )
     except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
         try:
-            collect_logcat(device, run_dir, app_pid, session_id)
+            ctx.note_pid()
+            collect_logcat(device, run_dir, ctx.app_pids, session_id)
         except Exception:
             pass
         summary = error_summary(
@@ -665,7 +1208,10 @@ def execute(args: argparse.Namespace) -> int:
         )
         exit_code = EXIT_ENVIRONMENT
     finally:
+        if recorder is not None:
+            recorder.stop(run_dir)
         cleanup_device(device)
+        restore_device(device, device_state)
     write_json(run_dir / "summary.json", summary)
     print(run_dir / "summary.json")
     return exit_code
@@ -679,6 +1225,12 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
     run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
     run_parser.add_argument("--fixture", help="override the scenario fixture ID")
     run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
+    run_parser.add_argument(
+        "--skip-build",
+        action="store_true",
+        help="reuse the installed debug APK; skip Gradle build and install",
+    )
+    subparsers.add_parser("list", help="print supported entries, actions, and assertions")
     return parser.parse_args(argv)
 
 
@@ -686,6 +1238,9 @@ def main(argv: Sequence[str] | None = None) -> int:
     args = parse_args(argv if argv is not None else sys.argv[1:])
     if args.command == "run":
         return execute(args)
+    if args.command == "list":
+        print(json.dumps(build_capabilities(), ensure_ascii=False, indent=2))
+        return 0
     return EXIT_SCENARIO
 
 

+ 23 - 0
tools/android/scenarios/bookshelf/miuix-hidden-statusbar.json

@@ -0,0 +1,23 @@
+{
+  "schemaVersion": 1,
+  "name": "bookshelf/miuix-hidden-statusbar",
+  "fixture": "long-chapter-v1",
+  "entry": { "type": "bookshelf" },
+  "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+  "readyTimeoutSeconds": 30,
+  "record": true,
+  "preferences": {
+    "composeEngine": "miuix",
+    "hideStatusBar": true
+  },
+  "actions": [
+    { "type": "open_fixture_book" },
+    { "type": "press_back" }
+  ],
+  "assertions": [
+    "bookshelf_ready",
+    "reader_opened",
+    "returned_to_bookshelf",
+    "no_fatal_exception"
+  ]
+}

+ 18 - 0
tools/android/scenarios/bookshelf/open-reader-back.json

@@ -0,0 +1,18 @@
+{
+  "schemaVersion": 1,
+  "name": "bookshelf/open-reader-back",
+  "fixture": "long-chapter-v1",
+  "entry": { "type": "bookshelf" },
+  "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+  "readyTimeoutSeconds": 30,
+  "actions": [
+    { "type": "open_fixture_book" },
+    { "type": "press_back" }
+  ],
+  "assertions": [
+    "bookshelf_ready",
+    "reader_opened",
+    "returned_to_bookshelf",
+    "no_fatal_exception"
+  ]
+}

+ 1 - 0
tools/android/scenarios/reader/page-turn.json

@@ -5,6 +5,7 @@
   "entry": { "type": "reader" },
   "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
   "readyTimeoutSeconds": 30,
+  "contentChangeTimeoutSeconds": 5,
   "actions": [
     { "type": "turn_page", "direction": "next", "count": 1 }
   ],

+ 123 - 13
tools/android/tests/test_runner.py

@@ -19,6 +19,10 @@ class ScenarioValidationTest(unittest.TestCase):
         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_rejects_unknown_action(self):
         scenario = {
             "schemaVersion": 1,
@@ -55,6 +59,67 @@ class ScenarioValidationTest(unittest.TestCase):
             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.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)
+
+
 class FailureParsingTest(unittest.TestCase):
     def test_ignores_crash_from_another_package(self):
         logcat = """FATAL EXCEPTION: main
@@ -125,22 +190,67 @@ class CommandDecodingTest(unittest.TestCase):
 
 
 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
-"""
+    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 body",
-            runner.extract_reader_content(dump),
+            ["LEGADO_DEBUG_LONG_CHAPTER_V1 page one", "page two body"],
+            runner.extract_reader_pages(logcat),
         )
 
-    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))
+    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 = """<?xml version="1.0"?><hierarchy>
+<node class="android.view.View" package="io.legato.kazusa.debug" clickable="true"
+ content-desc="调试夹具:长章节, Legado Debug" bounds="[100,200][300,600]" />
+</hierarchy>"""
+        node = runner.find_node_by_content_prefix(dump, "调试夹具:长章节")
+        self.assertIsNotNone(node)
+        self.assertEqual((200, 400), runner.node_center(node))
+
+
+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__":