runner.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248
  1. #!/usr/bin/env python3
  2. """Deterministic Android debug scenario runner for Legado."""
  3. from __future__ import annotations
  4. import argparse
  5. import base64
  6. import datetime as dt
  7. import hashlib
  8. import json
  9. from pathlib import Path
  10. import re
  11. import shutil
  12. import subprocess
  13. import sys
  14. import time
  15. from typing import Any, Callable, Sequence
  16. import xml.etree.ElementTree as ET
  17. EXIT_ASSERTION = 1
  18. EXIT_CRASH = 2
  19. EXIT_BUILD = 3
  20. EXIT_ENVIRONMENT = 4
  21. EXIT_SCENARIO = 5
  22. PACKAGE = "io.legato.kazusa.debug"
  23. DEBUG_ACTIVITY = "io.legado.app.debug.DebugScenarioActivity"
  24. MAIN_ACTIVITY = "io.legado.app.ui.main.MainActivity"
  25. SCRIPT_DIR = Path(__file__).resolve().parent
  26. PROJECT_ROOT = SCRIPT_DIR.parent.parent
  27. SCENARIO_ROOT = SCRIPT_DIR / "scenarios"
  28. RUN_ROOT = PROJECT_ROOT / ".debug-runs"
  29. FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
  30. # Capability registries. New bug scenarios extend the tool by registering an
  31. # entry / action / assertion below, never by editing execute() or
  32. # validate_scenario(). See the classes near the end of this module.
  33. ENTRIES: dict[str, "Entry"] = {}
  34. ACTIONS: dict[str, "Action"] = {}
  35. ASSERTIONS: dict[str, Callable[["Context"], bool]] = {}
  36. class ScenarioError(ValueError):
  37. pass
  38. class CommandError(RuntimeError):
  39. def __init__(self, command: Sequence[str], result: subprocess.CompletedProcess[str]):
  40. self.command = list(command)
  41. self.result = result
  42. super().__init__(f"command failed ({result.returncode}): {' '.join(command)}")
  43. def load_scenario(name: str) -> dict[str, Any]:
  44. if not re.fullmatch(r"[a-z0-9][a-z0-9/_-]*", name):
  45. raise ScenarioError(f"invalid scenario name: {name}")
  46. path = (SCENARIO_ROOT / f"{name}.json").resolve()
  47. if SCENARIO_ROOT.resolve() not in path.parents or not path.is_file():
  48. raise ScenarioError(f"scenario not found: {name}")
  49. try:
  50. scenario = json.loads(path.read_text(encoding="utf-8"))
  51. except (OSError, json.JSONDecodeError) as error:
  52. raise ScenarioError(f"cannot read scenario {name}: {error}") from error
  53. validate_scenario(scenario, name)
  54. return scenario
  55. def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
  56. if not isinstance(scenario, dict):
  57. raise ScenarioError("scenario root must be an object")
  58. if scenario.get("schemaVersion") != 1:
  59. raise ScenarioError("schemaVersion must be 1")
  60. name = scenario.get("name")
  61. if not isinstance(name, str) or not name:
  62. raise ScenarioError("name must be a non-empty string")
  63. if requested_name is not None and name != requested_name:
  64. raise ScenarioError(f"scenario name mismatch: expected {requested_name}, got {name}")
  65. fixture = scenario.get("fixture")
  66. if not isinstance(fixture, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture):
  67. raise ScenarioError("fixture must be a lowercase ID")
  68. marker = scenario.get("readyMarker")
  69. if not isinstance(marker, str) or not marker.strip():
  70. raise ScenarioError("readyMarker must be a non-empty string")
  71. timeout = scenario.get("readyTimeoutSeconds", 30)
  72. if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
  73. raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
  74. change_timeout = scenario.get("contentChangeTimeoutSeconds", 5)
  75. if not isinstance(change_timeout, int) or change_timeout < 1 or change_timeout > 30:
  76. raise ScenarioError("contentChangeTimeoutSeconds must be between 1 and 30")
  77. record = scenario.get("record", False)
  78. if not isinstance(record, bool):
  79. raise ScenarioError("record must be a boolean")
  80. validate_preferences(scenario.get("preferences"))
  81. entry = scenario.get("entry")
  82. if not isinstance(entry, dict) or entry.get("type") not in ENTRIES:
  83. raise ScenarioError(f"entry.type must be one of: {', '.join(sorted(ENTRIES))}")
  84. entry_handler = ENTRIES[entry["type"]]
  85. actions = scenario.get("actions")
  86. if not isinstance(actions, list) or not actions:
  87. raise ScenarioError("actions must be a non-empty array")
  88. for index, action in enumerate(actions):
  89. if not isinstance(action, dict):
  90. raise ScenarioError(f"actions[{index}] must be an object")
  91. action_type = action.get("type")
  92. if action_type not in entry_handler.allowed_actions:
  93. raise ScenarioError(f"actions[{index}] has an unsupported type")
  94. ACTIONS[action_type].validate(action, index)
  95. assertions = scenario.get("assertions")
  96. if not isinstance(assertions, list) or not assertions:
  97. raise ScenarioError("assertions must be a non-empty array")
  98. unknown = set(assertions) - set(ASSERTIONS)
  99. if unknown:
  100. raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
  101. def validate_preferences(preferences: Any) -> None:
  102. # Scenario-declared app settings applied before launch (theme engine,
  103. # reader toggles, ...). Restricted to String/Boolean so the debug Activity
  104. # can apply them unambiguously via AppConfigStore.
  105. if preferences is None:
  106. return
  107. if not isinstance(preferences, dict) or not preferences:
  108. raise ScenarioError("preferences must be a non-empty object when present")
  109. for key, value in preferences.items():
  110. if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9]*", key):
  111. raise ScenarioError(f"invalid preference key: {key}")
  112. if not isinstance(value, (str, bool)):
  113. raise ScenarioError(f"preference {key} must be a string or boolean")
  114. def validate_fixture(fixture_id: str) -> dict[str, Any]:
  115. if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
  116. raise ScenarioError("fixture must be a lowercase ID")
  117. path = FIXTURE_ROOT / f"{fixture_id}.json"
  118. if not path.is_file():
  119. raise ScenarioError(f"fixture not found: {fixture_id}")
  120. try:
  121. fixture = json.loads(path.read_text(encoding="utf-8"))
  122. except (OSError, json.JSONDecodeError) as error:
  123. raise ScenarioError(f"cannot read fixture {fixture_id}: {error}") from error
  124. validate_fixture_data(fixture, fixture_id)
  125. return fixture
  126. def validate_fixture_data(fixture: Any, fixture_id: str) -> None:
  127. if not isinstance(fixture, dict):
  128. raise ScenarioError(f"fixture root must be an object: {fixture_id}")
  129. if fixture.get("schemaVersion") != 1 or fixture.get("id") != fixture_id:
  130. raise ScenarioError(f"invalid fixture metadata: {fixture_id}")
  131. if fixture.get("resetPolicy") != "replace":
  132. raise ScenarioError(f"unsupported fixture resetPolicy: {fixture_id}")
  133. book = fixture.get("book")
  134. if not isinstance(book, dict):
  135. raise ScenarioError(f"fixture book must be an object: {fixture_id}")
  136. required_strings = ("bookUrl", "name", "author")
  137. if any(not isinstance(book.get(key), str) or not book[key] for key in required_strings):
  138. raise ScenarioError(f"fixture book metadata is incomplete: {fixture_id}")
  139. if book.get("pageMode") != "simulation":
  140. raise ScenarioError(f"unsupported fixture pageMode: {fixture_id}")
  141. chapters = fixture.get("chapters")
  142. if not isinstance(chapters, list) or not chapters:
  143. raise ScenarioError(f"fixture must contain chapters: {fixture_id}")
  144. start_chapter = book.get("startChapter")
  145. start_position = book.get("startPosition")
  146. if not isinstance(start_chapter, int) or start_chapter not in range(len(chapters)):
  147. raise ScenarioError(f"fixture startChapter is invalid: {fixture_id}")
  148. if not isinstance(start_position, int) or start_position < 0:
  149. raise ScenarioError(f"fixture startPosition is invalid: {fixture_id}")
  150. for index, chapter in enumerate(chapters):
  151. if not isinstance(chapter, dict):
  152. raise ScenarioError(f"fixture chapters[{index}] must be an object")
  153. for key in ("title", "readyMarker", "paragraph"):
  154. if not isinstance(chapter.get(key), str) or not chapter[key]:
  155. raise ScenarioError(f"fixture chapters[{index}].{key} is required")
  156. repeat = chapter.get("repeat")
  157. if not isinstance(repeat, int) or repeat < 1 or repeat > 1000:
  158. raise ScenarioError(f"fixture chapters[{index}].repeat must be between 1 and 1000")
  159. def run_command(
  160. command: Sequence[str],
  161. *,
  162. timeout: int = 120,
  163. check: bool = True,
  164. cwd: Path = PROJECT_ROOT,
  165. ) -> subprocess.CompletedProcess[str]:
  166. result = subprocess.run(
  167. list(command),
  168. cwd=cwd,
  169. text=True,
  170. encoding="utf-8",
  171. errors="replace",
  172. stdout=subprocess.PIPE,
  173. stderr=subprocess.STDOUT,
  174. timeout=timeout,
  175. )
  176. if check and result.returncode != 0:
  177. raise CommandError(command, result)
  178. return result
  179. def adb(device: str, *arguments: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess[str]:
  180. return run_command(["adb", "-s", device, *arguments], timeout=timeout, check=check)
  181. def select_device(requested: str | None) -> str:
  182. if shutil.which("adb") is None:
  183. raise RuntimeError("adb is not available on PATH")
  184. result = run_command(["adb", "devices"], timeout=15)
  185. devices = []
  186. for line in result.stdout.splitlines()[1:]:
  187. fields = line.split()
  188. if len(fields) >= 2 and fields[1] == "device":
  189. devices.append(fields[0])
  190. if requested:
  191. if requested not in devices:
  192. raise RuntimeError(f"device is not ready: {requested}")
  193. return requested
  194. if len(devices) == 1:
  195. return devices[0]
  196. if not devices:
  197. raise RuntimeError("no ready Android device found")
  198. raise RuntimeError("multiple devices found; pass --device")
  199. def create_session(scenario_name: str, fixture: str, device: str | None) -> tuple[str, Path, dict[str, Any]]:
  200. now = dt.datetime.now().astimezone()
  201. session_id = now.strftime("%Y%m%d-%H%M%S-%f")[:-3]
  202. run_dir = RUN_ROOT / session_id
  203. run_dir.mkdir(parents=True, exist_ok=False)
  204. metadata = {
  205. "schemaVersion": 1,
  206. "sessionId": session_id,
  207. "scenario": scenario_name,
  208. "fixture": fixture,
  209. "device": device,
  210. "startedAt": now.isoformat(timespec="milliseconds"),
  211. "package": PACKAGE,
  212. }
  213. write_json(run_dir / "metadata.json", metadata)
  214. return session_id, run_dir, metadata
  215. def write_json(path: Path, value: Any) -> None:
  216. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  217. def find_debug_apk() -> Path:
  218. output_root = PROJECT_ROOT / "app" / "build" / "outputs" / "apk"
  219. candidates = [
  220. path
  221. for path in output_root.rglob("*.apk")
  222. if "androidTest" not in path.parts and "debug" in path.parts
  223. ]
  224. if not candidates:
  225. raise RuntimeError("debug APK was not produced")
  226. universal = [path for path in candidates if "universal" in path.name]
  227. return max(universal or candidates, key=lambda path: path.stat().st_mtime)
  228. def build_and_install(device: str, run_dir: Path) -> tuple[dict[str, Any], Path]:
  229. started = time.monotonic()
  230. command = ["./gradlew", ":app:assembleAppDebug", "-PenableAbiSplits=false"]
  231. result = run_command(command, timeout=900, check=False)
  232. (run_dir / "build.log").write_text(result.stdout, encoding="utf-8")
  233. build = {
  234. "status": "passed" if result.returncode == 0 else "failed",
  235. "durationMs": round((time.monotonic() - started) * 1000),
  236. "artifact": "build.log",
  237. }
  238. if result.returncode != 0:
  239. raise CommandError(command, result)
  240. apk = find_debug_apk()
  241. install_mode = install_apk(device, apk, run_dir)
  242. build["apk"] = str(apk.relative_to(PROJECT_ROOT))
  243. build["installMode"] = install_mode
  244. return build, apk
  245. def install_apk(device: str, apk: Path, run_dir: Path) -> str:
  246. attempts = [
  247. ("no_streaming", ["adb", "-s", device, "install", "--no-streaming", "-r", "-t", str(apk)], 120),
  248. ("streaming_retry", ["adb", "-s", device, "install", "-r", "-t", str(apk)], 60),
  249. ]
  250. log_parts = []
  251. last_error: Exception | None = None
  252. for mode, command, timeout in attempts:
  253. try:
  254. result = run_command(command, timeout=timeout, check=False)
  255. log_parts.append(f"[{mode}] exit={result.returncode}\n{result.stdout}")
  256. if result.returncode == 0 and "Success" in result.stdout:
  257. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  258. return mode
  259. last_error = CommandError(command, result)
  260. except subprocess.TimeoutExpired as error:
  261. log_parts.append(f"[{mode}] timeout={timeout}s\n")
  262. last_error = error
  263. cleanup_device(device)
  264. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  265. raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
  266. def launch_fixture(
  267. device: str,
  268. fixture: str,
  269. entry: str,
  270. session_id: str,
  271. preferences: dict[str, Any] | None = None,
  272. ) -> None:
  273. adb(device, "shell", "am", "force-stop", PACKAGE)
  274. adb(device, "logcat", "-c")
  275. component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
  276. extras = [
  277. "--es", "fixture", fixture,
  278. "--es", "session", session_id,
  279. "--es", "entry", entry,
  280. ]
  281. if preferences:
  282. # base64 so the JSON survives `adb shell am` re-parsing on the device
  283. # (raw JSON gets quote-stripped and brace-expanded by the device shell).
  284. payload = json.dumps(preferences, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
  285. extras += ["--es", "preferencesB64", base64.b64encode(payload).decode("ascii")]
  286. result = adb(
  287. device,
  288. "shell",
  289. "am",
  290. "start",
  291. "-W",
  292. "-n",
  293. component,
  294. *extras,
  295. timeout=60,
  296. check=False,
  297. )
  298. if result.returncode != 0 or "Error:" in result.stdout:
  299. raise CommandError(["adb", "shell", "am", "start", component], result)
  300. def ui_dump(device: str) -> str:
  301. return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
  302. def window_state(device: str) -> str:
  303. # Full `dumpsys window windows` output. Callers substring-match against it,
  304. # so a hit means the activity is present in the window stack, not strictly
  305. # the foreground window.
  306. return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
  307. def device_is_awake(device: str) -> bool:
  308. output = adb(device, "shell", "dumpsys", "power", timeout=20, check=False).stdout
  309. return "mWakefulness=Awake" in output
  310. def keyguard_is_showing(device: str) -> bool:
  311. output = adb(device, "shell", "dumpsys", "window", "policy", timeout=20, check=False).stdout
  312. match = re.search(r"^\s+showing=(true|false)\b", output, re.MULTILINE)
  313. return match is not None and match.group(1) == "true"
  314. def device_ready_for_ui(device: str) -> bool:
  315. return device_is_awake(device) and not keyguard_is_showing(device)
  316. def prepare_device(device: str) -> dict[str, Any]:
  317. state = {
  318. "wasAwake": device_is_awake(device),
  319. "wasKeyguardShowing": keyguard_is_showing(device),
  320. "stayOnWhilePluggedIn": adb(
  321. device,
  322. "shell",
  323. "settings",
  324. "get",
  325. "global",
  326. "stay_on_while_plugged_in",
  327. timeout=20,
  328. check=False,
  329. ).stdout.strip(),
  330. }
  331. adb(
  332. device,
  333. "shell",
  334. "settings",
  335. "put",
  336. "global",
  337. "stay_on_while_plugged_in",
  338. "2",
  339. timeout=20,
  340. )
  341. if not state["wasAwake"]:
  342. adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20)
  343. if keyguard_is_showing(device):
  344. adb(device, "shell", "wm", "dismiss-keyguard", timeout=20, check=False)
  345. time.sleep(0.2)
  346. if keyguard_is_showing(device):
  347. adb(device, "shell", "input", "keyevent", "82", timeout=20, check=False)
  348. time.sleep(0.5)
  349. if not device_ready_for_ui(device):
  350. restore_device(device, state)
  351. raise RuntimeError("device could not be awakened and unlocked for UI automation")
  352. return state
  353. def restore_device(device: str, state: dict[str, Any] | None) -> None:
  354. if state is None:
  355. return
  356. stay_on = state.get("stayOnWhilePluggedIn")
  357. if stay_on in {"", "null", None}:
  358. adb(
  359. device,
  360. "shell",
  361. "settings",
  362. "delete",
  363. "global",
  364. "stay_on_while_plugged_in",
  365. timeout=20,
  366. check=False,
  367. )
  368. else:
  369. adb(
  370. device,
  371. "shell",
  372. "settings",
  373. "put",
  374. "global",
  375. "stay_on_while_plugged_in",
  376. str(stay_on),
  377. timeout=20,
  378. check=False,
  379. )
  380. if state.get("wasKeyguardShowing"):
  381. adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
  382. if state.get("wasAwake"):
  383. adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20, check=False)
  384. elif not state.get("wasAwake"):
  385. adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
  386. def parse_ui_nodes(dump: str) -> list[dict[str, str]]:
  387. start = dump.find("<?xml")
  388. end = dump.rfind("</hierarchy>")
  389. if start < 0 or end < 0:
  390. return []
  391. try:
  392. root = ET.fromstring(dump[start : end + len("</hierarchy>")])
  393. except ET.ParseError:
  394. return []
  395. return [node.attrib for node in root.iter("node")]
  396. READER_PAGE_TAG = "READER_PAGE "
  397. def extract_reader_pages(logcat: str) -> list[str]:
  398. # ReadView logs "READER_PAGE <one-line page text>" on every render (debug
  399. # build only). This is the device-independent reader signal — uiautomator
  400. # does not expose the ReadView node on all devices.
  401. pages = []
  402. for line in logcat.splitlines():
  403. index = line.find(READER_PAGE_TAG)
  404. if index >= 0:
  405. pages.append(line[index + len(READER_PAGE_TAG):].strip())
  406. return pages
  407. def reader_current_page(device: str) -> str:
  408. logcat = adb(device, "logcat", "-d", "-s", "LegadoDebug:I", timeout=20, check=False).stdout
  409. pages = extract_reader_pages(logcat)
  410. return pages[-1] if pages else ""
  411. def find_node_by_content_prefix(dump: str, prefix: str) -> dict[str, str] | None:
  412. # The bookshelf item's label lives on a node whose clickable flag is false
  413. # (the clickable modifier sits on an ancestor). Match by content-desc and
  414. # tap the label's center; the clickable ancestor is under that point.
  415. candidates = [
  416. node
  417. for node in parse_ui_nodes(dump)
  418. if node.get("package") == PACKAGE
  419. and node.get("content-desc", "").startswith(prefix)
  420. ]
  421. return candidates[0] if candidates else None
  422. def node_center(node: dict[str, str]) -> tuple[int, int]:
  423. bounds = node.get("bounds", "")
  424. match = re.fullmatch(r"\[(\d+),(\d+)]\[(\d+),(\d+)]", bounds)
  425. if match is None:
  426. raise RuntimeError(f"invalid accessibility bounds: {bounds}")
  427. left, top, right, bottom = map(int, match.groups())
  428. return (left + right) // 2, (top + bottom) // 2
  429. def wait_until_node_present(
  430. device: str,
  431. content_prefix: str,
  432. timeout_seconds: int,
  433. ) -> tuple[bool, str, dict[str, str] | None]:
  434. deadline = time.monotonic() + timeout_seconds
  435. latest = ""
  436. node = None
  437. while time.monotonic() < deadline:
  438. latest = ui_dump(device)
  439. node = find_node_by_content_prefix(latest, content_prefix)
  440. if node is not None and device_ready_for_ui(device):
  441. return True, latest, node
  442. time.sleep(0.5)
  443. return False, latest, node
  444. def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
  445. deadline = time.monotonic() + timeout_seconds
  446. content = ""
  447. while time.monotonic() < deadline:
  448. content = reader_current_page(device)
  449. if marker in content and PACKAGE in window_state(device) and device_ready_for_ui(device):
  450. return True, content, content
  451. time.sleep(0.5)
  452. return False, content, content
  453. def wait_until_reader_content_changed(
  454. device: str,
  455. previous_content: str,
  456. timeout_seconds: int,
  457. ) -> tuple[bool, str, str]:
  458. deadline = time.monotonic() + timeout_seconds
  459. content = ""
  460. while time.monotonic() < deadline:
  461. content = reader_current_page(device)
  462. if content and content != previous_content:
  463. return True, content, content
  464. time.sleep(0.2)
  465. return False, content, content
  466. def application_pid(device: str) -> str | None:
  467. result = adb(device, "shell", "pidof", PACKAGE, timeout=10, check=False)
  468. return result.stdout.strip().split()[0] if result.returncode == 0 and result.stdout.strip() else None
  469. def screen_size(device: str) -> tuple[int, int]:
  470. output = adb(device, "shell", "wm", "size").stdout
  471. matches = re.findall(r"(\d+)x(\d+)", output)
  472. if not matches:
  473. raise RuntimeError(f"cannot determine screen size: {output.strip()}")
  474. width, height = matches[-1]
  475. return int(width), int(height)
  476. def capture_screenshot(device: str, path: Path) -> None:
  477. with path.open("wb") as output:
  478. result = subprocess.run(
  479. ["adb", "-s", device, "exec-out", "screencap", "-p"],
  480. cwd=PROJECT_ROOT,
  481. stdout=output,
  482. stderr=subprocess.PIPE,
  483. timeout=30,
  484. )
  485. if result.returncode != 0:
  486. path.unlink(missing_ok=True)
  487. raise RuntimeError(result.stderr.decode(errors="replace"))
  488. class ScreenRecorder:
  489. """Optional screenrecord wrapper for capturing transient UI (animations).
  490. Best-effort: any failure to start, stop, or pull leaves the run unaffected
  491. and simply produces no video artifact.
  492. """
  493. REMOTE = "/sdcard/legado-debug-transition.mp4"
  494. def __init__(self, device: str):
  495. self.device = device
  496. self.process: subprocess.Popen[bytes] | None = None
  497. self.saved: str | None = None
  498. self._stopped = False
  499. def start(self) -> None:
  500. adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
  501. self.process = subprocess.Popen(
  502. ["adb", "-s", self.device, "shell", "screenrecord", "--time-limit", "60", self.REMOTE],
  503. cwd=PROJECT_ROOT,
  504. stdout=subprocess.DEVNULL,
  505. stderr=subprocess.DEVNULL,
  506. )
  507. time.sleep(0.5)
  508. def stop(self, run_dir: Path, name: str = "transition.mp4") -> str | None:
  509. if self.process is None or self._stopped:
  510. return self.saved
  511. self._stopped = True
  512. # SIGINT lets screenrecord finalize a playable mp4 before it exits.
  513. pids = adb(self.device, "shell", "pidof", "screenrecord", timeout=10, check=False).stdout.split()
  514. for pid in pids:
  515. adb(self.device, "shell", "kill", "-INT", pid, timeout=10, check=False)
  516. try:
  517. self.process.wait(timeout=15)
  518. except subprocess.TimeoutExpired:
  519. self.process.kill()
  520. pull = subprocess.run(
  521. ["adb", "-s", self.device, "pull", self.REMOTE, str(run_dir / name)],
  522. cwd=PROJECT_ROOT,
  523. capture_output=True,
  524. text=True,
  525. timeout=60,
  526. )
  527. adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
  528. if pull.returncode == 0 and (run_dir / name).is_file():
  529. self.saved = name
  530. return self.saved
  531. def collect_logcat(
  532. device: str,
  533. run_dir: Path,
  534. app_pids: Sequence[str] = (),
  535. session_id: str | None = None,
  536. ) -> str:
  537. logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
  538. (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
  539. session_logcat = ""
  540. unique_pids = list(dict.fromkeys(pid for pid in app_pids if pid))
  541. for pid in unique_pids:
  542. pid_logcat = adb(
  543. device,
  544. "logcat",
  545. "-d",
  546. f"--pid={pid}",
  547. "-v",
  548. "threadtime",
  549. timeout=45,
  550. check=False,
  551. ).stdout
  552. session_logcat += f"--- pid={pid} ---\n{pid_logcat}"
  553. if session_id:
  554. session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
  555. for line in session_lines:
  556. if line not in session_logcat:
  557. session_logcat += line + "\n"
  558. (run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
  559. crash = extract_crash(logcat, PACKAGE, unique_pids)
  560. (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
  561. return logcat
  562. def extract_crash(
  563. logcat: str,
  564. package: str = PACKAGE,
  565. app_pids: Sequence[str] = (),
  566. ) -> str:
  567. lines = logcat.splitlines()
  568. chunks = []
  569. for index, line in enumerate(lines):
  570. chunk = lines[index : index + 100]
  571. if f"ANR in {package}" in line:
  572. chunks.extend(chunk)
  573. elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
  574. chunks.extend(chunk)
  575. elif "Fatal signal " in line and any(
  576. re.search(rf"\b{re.escape(pid)}\b", line) for pid in app_pids
  577. ):
  578. chunks.extend(chunk)
  579. if not chunks:
  580. return ""
  581. return "\n".join(chunks).rstrip() + "\n"
  582. def parse_failure(crash: str) -> dict[str, Any] | None:
  583. if not crash:
  584. return None
  585. if "ANR in" in crash:
  586. return {
  587. "category": "anr",
  588. "confidence": "high",
  589. "detectedSignals": ["anr"],
  590. "stacktraceArtifact": "crash.txt",
  591. }
  592. if "Fatal signal " in crash:
  593. return {
  594. "category": "native_crash",
  595. "confidence": "high",
  596. "detectedSignals": ["fatal_signal"],
  597. "stacktraceArtifact": "crash.txt",
  598. }
  599. exceptions = list(
  600. re.finditer(r"(Caused by: )?([\w.$]+(?:Exception|Error))(?:: ([^\n]+))?", crash)
  601. )
  602. caused_by = [match for match in exceptions if match.group(1)]
  603. exception = caused_by[-1] if caused_by else (exceptions[0] if exceptions else None)
  604. failure: dict[str, Any] = {
  605. "category": "uncaught_exception",
  606. "confidence": "high",
  607. "detectedSignals": ["fatal_exception"],
  608. "stacktraceArtifact": "crash.txt",
  609. }
  610. if exception:
  611. failure["exceptionType"] = exception.group(2).split(".")[-1]
  612. failure["exceptionConfidence"] = "medium" if exceptions else "low"
  613. if exception.group(3):
  614. failure["message"] = exception.group(3).strip()
  615. frame = re.search(
  616. r"at (io\.legado\.app\.[\w.$]+)\.([\w$<>]+)\(([^():]+):(\d+)\)",
  617. crash,
  618. )
  619. if frame:
  620. failure["suspectedFrame"] = {
  621. "class": frame.group(1),
  622. "method": frame.group(2),
  623. "file": frame.group(3),
  624. "line": int(frame.group(4)),
  625. "confidence": "medium",
  626. }
  627. return failure
  628. def sha256_text(value: str) -> str:
  629. return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
  630. def make_summary(
  631. *,
  632. session_id: str,
  633. scenario: dict[str, Any],
  634. fixture: str,
  635. build: dict[str, Any],
  636. ready: bool,
  637. activity_visible: bool,
  638. content_changed: bool,
  639. action_count: int,
  640. failure: dict[str, Any] | None,
  641. ) -> tuple[dict[str, Any], int]:
  642. assertion_values = {
  643. "reader_ready": ready,
  644. "reader_activity_visible": activity_visible,
  645. "reader_content_changed": content_changed,
  646. "no_fatal_exception": failure is None,
  647. }
  648. return make_summary_from_assertions(
  649. session_id=session_id,
  650. scenario=scenario,
  651. fixture=fixture,
  652. build=build,
  653. assertion_values=assertion_values,
  654. action_count=action_count,
  655. failure=failure,
  656. )
  657. def make_summary_from_assertions(
  658. *,
  659. session_id: str,
  660. scenario: dict[str, Any],
  661. fixture: str,
  662. build: dict[str, Any],
  663. assertion_values: dict[str, bool],
  664. action_count: int,
  665. failure: dict[str, Any] | None,
  666. extra_artifacts: dict[str, str] | None = None,
  667. ) -> tuple[dict[str, Any], int]:
  668. assertions = [
  669. {"name": name, "passed": assertion_values[name]}
  670. for name in scenario["assertions"]
  671. ]
  672. if failure is not None:
  673. status, exit_code = "failed", EXIT_CRASH
  674. elif not all(item["passed"] for item in assertions):
  675. status, exit_code = "failed", EXIT_ASSERTION
  676. else:
  677. status, exit_code = "passed", 0
  678. summary: dict[str, Any] = {
  679. "schemaVersion": 1,
  680. "status": status,
  681. "exitCode": exit_code,
  682. "sessionId": session_id,
  683. "scenario": scenario["name"],
  684. "fixture": fixture,
  685. "build": build,
  686. "actions": {"performed": action_count},
  687. "assertions": assertions,
  688. "artifacts": {
  689. "metadata": "metadata.json",
  690. "logcat": "logcat.txt",
  691. "sessionLogcat": "session-logcat.txt",
  692. "crash": "crash.txt",
  693. },
  694. }
  695. if failure is not None:
  696. summary["failure"] = failure
  697. if extra_artifacts:
  698. summary["artifacts"].update(extra_artifacts)
  699. return summary, exit_code
  700. def error_summary(
  701. session_id: str,
  702. scenario_name: str,
  703. fixture: str,
  704. exit_code: int,
  705. category: str,
  706. message: str,
  707. build: dict[str, Any] | None = None,
  708. ) -> dict[str, Any]:
  709. summary: dict[str, Any] = {
  710. "schemaVersion": 1,
  711. "status": "failed",
  712. "exitCode": exit_code,
  713. "sessionId": session_id,
  714. "scenario": scenario_name,
  715. "fixture": fixture,
  716. "failure": {"category": category, "message": message},
  717. "artifacts": {"metadata": "metadata.json"},
  718. }
  719. if build is not None:
  720. summary["build"] = build
  721. return summary
  722. def cleanup_device(device: str) -> None:
  723. adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
  724. # --------------------------------------------------------------------------- #
  725. # Capability registry: entries, actions, assertions.
  726. #
  727. # A scenario is fully described by data (JSON). Each domain action and each
  728. # assertion is a small handler registered here. Adding a bug scenario means
  729. # adding a handler + a JSON file, not editing execute() / validate_scenario().
  730. # Handlers only *record* raw observations into the Context; assertions are pure
  731. # reads of those observations.
  732. # --------------------------------------------------------------------------- #
  733. class Context:
  734. """Mutable per-run state shared across entry, actions, and assertions."""
  735. def __init__(
  736. self,
  737. device: str,
  738. run_dir: Path,
  739. scenario: dict[str, Any],
  740. fixture_data: dict[str, Any],
  741. metadata: dict[str, Any],
  742. action_repeat: int,
  743. ):
  744. self.device = device
  745. self.run_dir = run_dir
  746. self.scenario = scenario
  747. self.fixture_data = fixture_data
  748. self.metadata = metadata
  749. self.action_repeat = action_repeat
  750. self.app_pids: list[str] = []
  751. self.records: dict[str, Any] = {}
  752. self.state: dict[str, Any] = {}
  753. self.artifacts: dict[str, str] = {}
  754. self.action_count = 0
  755. self.assertion_values: dict[str, bool] = {}
  756. self.failure: dict[str, Any] | None = None
  757. def note_pid(self) -> None:
  758. pid = application_pid(self.device)
  759. if pid and pid not in self.app_pids:
  760. self.app_pids.append(pid)
  761. def screenshot(self, name: str, filename: str) -> None:
  762. capture_screenshot(self.device, self.run_dir / filename)
  763. self.artifacts[name] = filename
  764. class Entry:
  765. type: str = ""
  766. allowed_actions: set[str] = set()
  767. def prepare(self, ctx: Context) -> None:
  768. raise NotImplementedError
  769. def finalize(self, ctx: Context) -> None:
  770. raise NotImplementedError
  771. class Action:
  772. name: str = ""
  773. params: dict[str, Any] = {}
  774. def validate(self, action: dict[str, Any], index: int) -> None:
  775. pass
  776. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  777. raise NotImplementedError
  778. def register_entry(name: str) -> Callable[[type], type]:
  779. def decorate(cls: type) -> type:
  780. instance = cls()
  781. instance.type = name
  782. ENTRIES[name] = instance
  783. return cls
  784. return decorate
  785. def register_action(name: str) -> Callable[[type], type]:
  786. def decorate(cls: type) -> type:
  787. instance = cls()
  788. instance.name = name
  789. ACTIONS[name] = instance
  790. return cls
  791. return decorate
  792. @register_entry("reader")
  793. class ReaderEntry(Entry):
  794. allowed_actions = {"turn_page"}
  795. def prepare(self, ctx: Context) -> None:
  796. ready, before_dump, before_content = wait_until_reader_ready(
  797. ctx.device,
  798. ctx.scenario["readyMarker"],
  799. ctx.scenario.get("readyTimeoutSeconds", 30),
  800. )
  801. ctx.note_pid()
  802. ctx.screenshot("beforeScreenshot", "before.png")
  803. ctx.records["reader_ready"] = ready
  804. ctx.records["reader_content_changed"] = False
  805. ctx.records["content_change_duration_ms"] = 0
  806. ctx.records["ui_before"] = before_dump
  807. ctx.records["ui_after"] = before_dump
  808. ctx.records["reader_content_before"] = before_content
  809. ctx.records["reader_content_after"] = before_content
  810. ctx.state["reader_content"] = before_content
  811. def finalize(self, ctx: Context) -> None:
  812. ctx.screenshot("afterScreenshot", "after.png")
  813. windows = window_state(ctx.device)
  814. # Presence of MainActivity in the window stack; the reader runs as a
  815. # route inside MainActivity in this fork.
  816. visible = PACKAGE in windows and MAIN_ACTIVITY in windows and device_ready_for_ui(ctx.device)
  817. ctx.records["reader_activity_visible"] = visible
  818. ctx.metadata.update(
  819. {
  820. "actionRepeat": ctx.action_repeat,
  821. "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
  822. "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
  823. "readerContentBeforeSha256": sha256_text(ctx.records["reader_content_before"]),
  824. "readerContentAfterSha256": sha256_text(ctx.records["reader_content_after"]),
  825. "contentChangeDurationMs": ctx.records["content_change_duration_ms"],
  826. }
  827. )
  828. @register_entry("bookshelf")
  829. class BookshelfEntry(Entry):
  830. allowed_actions = {"open_fixture_book", "press_back"}
  831. def prepare(self, ctx: Context) -> None:
  832. book_name = ctx.fixture_data["book"]["name"]
  833. ready, before_dump, book_node = wait_until_node_present(
  834. ctx.device,
  835. book_name,
  836. ctx.scenario.get("readyTimeoutSeconds", 30),
  837. )
  838. ctx.note_pid()
  839. ctx.screenshot("beforeScreenshot", "before.png")
  840. ctx.records["bookshelf_ready"] = ready
  841. ctx.records["reader_opened"] = False
  842. ctx.records["returned_to_bookshelf"] = False
  843. ctx.records["ui_before"] = before_dump
  844. ctx.records["ui_after"] = before_dump
  845. ctx.records["reader_dump"] = ""
  846. ctx.records["reader_content"] = ""
  847. ctx.state["book_name"] = book_name
  848. ctx.state["book_node"] = book_node
  849. ctx.state["returned_node"] = None
  850. def finalize(self, ctx: Context) -> None:
  851. ctx.screenshot("afterScreenshot", "after.png")
  852. book_node = ctx.state.get("book_node")
  853. returned_node = ctx.state.get("returned_node")
  854. ctx.metadata.update(
  855. {
  856. "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
  857. "readerUiSha256": sha256_text(ctx.records["reader_dump"]),
  858. "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
  859. "readerContentSha256": sha256_text(ctx.records["reader_content"]),
  860. "bookshelfItemBefore": book_node.get("content-desc") if book_node else None,
  861. "bookshelfItemAfter": returned_node.get("content-desc") if returned_node else None,
  862. }
  863. )
  864. @register_action("turn_page")
  865. class TurnPage(Action):
  866. params = {"direction": ["next", "previous"], "count": "1-100"}
  867. def validate(self, action: dict[str, Any], index: int) -> None:
  868. if action.get("direction") not in {"next", "previous"}:
  869. raise ScenarioError(f"actions[{index}].direction must be next or previous")
  870. count = action.get("count", 1)
  871. if not isinstance(count, int) or count < 1 or count > 100:
  872. raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
  873. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  874. if not ctx.records.get("reader_ready"):
  875. return
  876. width, height = screen_size(ctx.device)
  877. direction = action["direction"]
  878. count = action.get("count", 1) * ctx.action_repeat
  879. x = round(width * (0.84 if direction == "next" else 0.16))
  880. y = round(height * 0.5)
  881. for _ in range(count):
  882. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  883. ctx.action_count += 1
  884. time.sleep(0.4)
  885. started = time.monotonic()
  886. changed, after_dump, after_content = wait_until_reader_content_changed(
  887. ctx.device,
  888. ctx.state["reader_content"],
  889. ctx.scenario.get("contentChangeTimeoutSeconds", 5),
  890. )
  891. ctx.records["content_change_duration_ms"] = round((time.monotonic() - started) * 1000)
  892. if changed:
  893. ctx.records["reader_content_changed"] = True
  894. ctx.state["reader_content"] = after_content
  895. ctx.records["reader_content_after"] = after_content
  896. ctx.records["ui_after"] = after_dump
  897. @register_action("open_fixture_book")
  898. class OpenFixtureBook(Action):
  899. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  900. book_node = ctx.state.get("book_node")
  901. if not (ctx.records.get("bookshelf_ready") and book_node is not None):
  902. return
  903. x, y = node_center(book_node)
  904. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  905. ctx.action_count += 1
  906. opened, reader_dump, reader_content = wait_until_reader_ready(
  907. ctx.device,
  908. ctx.scenario["readyMarker"],
  909. ctx.scenario.get("readyTimeoutSeconds", 30),
  910. )
  911. ctx.records["reader_opened"] = opened
  912. ctx.records["reader_dump"] = reader_dump
  913. ctx.records["reader_content"] = reader_content
  914. ctx.screenshot("readerScreenshot", "reader.png")
  915. @register_action("press_back")
  916. class PressBack(Action):
  917. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  918. if not ctx.records.get("reader_opened"):
  919. return
  920. adb(ctx.device, "shell", "input", "keyevent", "BACK", timeout=10)
  921. ctx.action_count += 1
  922. returned, after_dump, returned_node = wait_until_node_present(
  923. ctx.device,
  924. ctx.state["book_name"],
  925. ctx.scenario.get("readyTimeoutSeconds", 30),
  926. )
  927. ctx.records["returned_to_bookshelf"] = returned
  928. ctx.records["ui_after"] = after_dump
  929. ctx.state["returned_node"] = returned_node
  930. ASSERTIONS.update(
  931. {
  932. "reader_ready": lambda ctx: bool(ctx.records.get("reader_ready")),
  933. "reader_activity_visible": lambda ctx: bool(ctx.records.get("reader_activity_visible")),
  934. "reader_content_changed": lambda ctx: bool(ctx.records.get("reader_content_changed")),
  935. "bookshelf_ready": lambda ctx: bool(ctx.records.get("bookshelf_ready")),
  936. "reader_opened": lambda ctx: bool(ctx.records.get("reader_opened")),
  937. "returned_to_bookshelf": lambda ctx: bool(ctx.records.get("returned_to_bookshelf")),
  938. "no_fatal_exception": lambda ctx: ctx.failure is None,
  939. }
  940. )
  941. def build_capabilities() -> dict[str, Any]:
  942. return {
  943. "schemaVersion": 1,
  944. "entries": {
  945. name: {"allowedActions": sorted(handler.allowed_actions)}
  946. for name, handler in sorted(ENTRIES.items())
  947. },
  948. "actions": {
  949. name: {"params": handler.params}
  950. for name, handler in sorted(ACTIONS.items())
  951. },
  952. "assertions": sorted(ASSERTIONS),
  953. }
  954. def execute(args: argparse.Namespace) -> int:
  955. try:
  956. scenario = load_scenario(args.scenario)
  957. fixture = args.fixture or scenario["fixture"]
  958. fixture_data = validate_fixture(fixture)
  959. markers = {chapter["readyMarker"] for chapter in fixture_data["chapters"]}
  960. if scenario["readyMarker"] not in markers:
  961. raise ScenarioError("scenario readyMarker is not provided by the fixture")
  962. if args.action_repeat < 1 or args.action_repeat > 100:
  963. raise ScenarioError("--action-repeat must be between 1 and 100")
  964. if scenario["entry"]["type"] == "bookshelf" and args.action_repeat != 1:
  965. raise ScenarioError("--action-repeat is only supported by reader scenarios")
  966. except ScenarioError as error:
  967. print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
  968. return EXIT_SCENARIO
  969. try:
  970. device = select_device(args.device)
  971. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  972. session_id, run_dir, _ = create_session(args.scenario, fixture, args.device)
  973. summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
  974. write_json(run_dir / "summary.json", summary)
  975. print(run_dir / "summary.json")
  976. return EXIT_ENVIRONMENT
  977. session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
  978. device_state: dict[str, Any] | None = None
  979. try:
  980. device_state = prepare_device(device)
  981. metadata["deviceStateBefore"] = device_state
  982. write_json(run_dir / "metadata.json", metadata)
  983. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  984. summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
  985. write_json(run_dir / "summary.json", summary)
  986. print(run_dir / "summary.json")
  987. return EXIT_ENVIRONMENT
  988. build: dict[str, Any] = {"status": "not_started"}
  989. if args.skip_build:
  990. build = {"status": "skipped"}
  991. else:
  992. try:
  993. build, _ = build_and_install(device, run_dir)
  994. except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
  995. if isinstance(error, CommandError):
  996. build = {
  997. "status": "failed",
  998. "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
  999. }
  1000. summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
  1001. cleanup_device(device)
  1002. restore_device(device, device_state)
  1003. write_json(run_dir / "summary.json", summary)
  1004. print(run_dir / "summary.json")
  1005. return EXIT_BUILD
  1006. entry_handler = ENTRIES[scenario["entry"]["type"]]
  1007. ctx = Context(device, run_dir, scenario, fixture_data, metadata, args.action_repeat)
  1008. recorder = ScreenRecorder(device) if scenario.get("record") else None
  1009. try:
  1010. launch_fixture(device, fixture, entry_handler.type, session_id, scenario.get("preferences"))
  1011. ctx.note_pid()
  1012. entry_handler.prepare(ctx)
  1013. if recorder is not None:
  1014. recorder.start()
  1015. for action in scenario["actions"]:
  1016. ACTIONS[action["type"]].run(ctx, action)
  1017. if recorder is not None:
  1018. time.sleep(0.5) # tail so the settle after the last action is captured
  1019. video = recorder.stop(run_dir)
  1020. if video:
  1021. ctx.artifacts["transition"] = video
  1022. entry_handler.finalize(ctx)
  1023. ctx.note_pid()
  1024. logcat = collect_logcat(device, run_dir, ctx.app_pids, session_id)
  1025. ctx.failure = parse_failure(extract_crash(logcat, PACKAGE, ctx.app_pids))
  1026. for name in scenario["assertions"]:
  1027. ctx.assertion_values[name] = ASSERTIONS[name](ctx)
  1028. metadata.update(
  1029. {
  1030. "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
  1031. "appPids": ctx.app_pids,
  1032. }
  1033. )
  1034. write_json(run_dir / "metadata.json", metadata)
  1035. summary, exit_code = make_summary_from_assertions(
  1036. session_id=session_id,
  1037. scenario=scenario,
  1038. fixture=fixture,
  1039. build=build,
  1040. assertion_values=ctx.assertion_values,
  1041. action_count=ctx.action_count,
  1042. failure=ctx.failure,
  1043. extra_artifacts=ctx.artifacts,
  1044. )
  1045. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  1046. try:
  1047. ctx.note_pid()
  1048. collect_logcat(device, run_dir, ctx.app_pids, session_id)
  1049. except Exception:
  1050. pass
  1051. summary = error_summary(
  1052. session_id,
  1053. args.scenario,
  1054. fixture,
  1055. EXIT_ENVIRONMENT,
  1056. "environment",
  1057. str(error),
  1058. build,
  1059. )
  1060. exit_code = EXIT_ENVIRONMENT
  1061. finally:
  1062. if recorder is not None:
  1063. recorder.stop(run_dir)
  1064. cleanup_device(device)
  1065. restore_device(device, device_state)
  1066. write_json(run_dir / "summary.json", summary)
  1067. print(run_dir / "summary.json")
  1068. return exit_code
  1069. def parse_args(argv: Sequence[str]) -> argparse.Namespace:
  1070. parser = argparse.ArgumentParser(description=__doc__)
  1071. subparsers = parser.add_subparsers(dest="command", required=True)
  1072. run_parser = subparsers.add_parser("run", help="build, install, and run one scenario")
  1073. run_parser.add_argument("scenario", help="scenario ID, for example reader/page-turn")
  1074. run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
  1075. run_parser.add_argument("--fixture", help="override the scenario fixture ID")
  1076. run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
  1077. run_parser.add_argument(
  1078. "--skip-build",
  1079. action="store_true",
  1080. help="reuse the installed debug APK; skip Gradle build and install",
  1081. )
  1082. subparsers.add_parser("list", help="print supported entries, actions, and assertions")
  1083. return parser.parse_args(argv)
  1084. def main(argv: Sequence[str] | None = None) -> int:
  1085. args = parse_args(argv if argv is not None else sys.argv[1:])
  1086. if args.command == "run":
  1087. return execute(args)
  1088. if args.command == "list":
  1089. print(json.dumps(build_capabilities(), ensure_ascii=False, indent=2))
  1090. return 0
  1091. return EXIT_SCENARIO
  1092. if __name__ == "__main__":
  1093. raise SystemExit(main())