runner.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. #!/usr/bin/env python3
  2. """Deterministic Android debug scenario runner for Legado."""
  3. from __future__ import annotations
  4. import argparse
  5. import datetime as dt
  6. import hashlib
  7. import json
  8. from pathlib import Path
  9. import re
  10. import shutil
  11. import subprocess
  12. import sys
  13. import time
  14. from typing import Any, Sequence
  15. import xml.etree.ElementTree as ET
  16. EXIT_ASSERTION = 1
  17. EXIT_CRASH = 2
  18. EXIT_BUILD = 3
  19. EXIT_ENVIRONMENT = 4
  20. EXIT_SCENARIO = 5
  21. PACKAGE = "io.legato.kazusa.debug"
  22. DEBUG_ACTIVITY = "io.legado.app.debug.DebugScenarioActivity"
  23. MAIN_ACTIVITY = "io.legado.app.ui.main.MainActivity"
  24. SCRIPT_DIR = Path(__file__).resolve().parent
  25. PROJECT_ROOT = SCRIPT_DIR.parent.parent
  26. SCENARIO_ROOT = SCRIPT_DIR / "scenarios"
  27. RUN_ROOT = PROJECT_ROOT / ".debug-runs"
  28. FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
  29. class ScenarioError(ValueError):
  30. pass
  31. class CommandError(RuntimeError):
  32. def __init__(self, command: Sequence[str], result: subprocess.CompletedProcess[str]):
  33. self.command = list(command)
  34. self.result = result
  35. super().__init__(f"command failed ({result.returncode}): {' '.join(command)}")
  36. def load_scenario(name: str) -> dict[str, Any]:
  37. if not re.fullmatch(r"[a-z0-9][a-z0-9/_-]*", name):
  38. raise ScenarioError(f"invalid scenario name: {name}")
  39. path = (SCENARIO_ROOT / f"{name}.json").resolve()
  40. if SCENARIO_ROOT.resolve() not in path.parents or not path.is_file():
  41. raise ScenarioError(f"scenario not found: {name}")
  42. try:
  43. scenario = json.loads(path.read_text(encoding="utf-8"))
  44. except (OSError, json.JSONDecodeError) as error:
  45. raise ScenarioError(f"cannot read scenario {name}: {error}") from error
  46. validate_scenario(scenario, name)
  47. return scenario
  48. def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
  49. if not isinstance(scenario, dict):
  50. raise ScenarioError("scenario root must be an object")
  51. if scenario.get("schemaVersion") != 1:
  52. raise ScenarioError("schemaVersion must be 1")
  53. name = scenario.get("name")
  54. if not isinstance(name, str) or not name:
  55. raise ScenarioError("name must be a non-empty string")
  56. if requested_name is not None and name != requested_name:
  57. raise ScenarioError(f"scenario name mismatch: expected {requested_name}, got {name}")
  58. fixture = scenario.get("fixture")
  59. if not isinstance(fixture, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture):
  60. raise ScenarioError("fixture must be a lowercase ID")
  61. marker = scenario.get("readyMarker")
  62. if not isinstance(marker, str) or not marker.strip():
  63. raise ScenarioError("readyMarker must be a non-empty string")
  64. timeout = scenario.get("readyTimeoutSeconds", 30)
  65. if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
  66. raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
  67. entry = scenario.get("entry")
  68. if not isinstance(entry, dict) or entry.get("type") != "reader":
  69. raise ScenarioError("entry.type must be reader")
  70. actions = scenario.get("actions")
  71. if not isinstance(actions, list) or not actions:
  72. raise ScenarioError("actions must be a non-empty array")
  73. for index, action in enumerate(actions):
  74. if not isinstance(action, dict) or action.get("type") != "turn_page":
  75. raise ScenarioError(f"actions[{index}] has an unsupported type")
  76. if action["type"] == "turn_page":
  77. if action.get("direction") not in {"next", "previous"}:
  78. raise ScenarioError(f"actions[{index}].direction must be next or previous")
  79. count = action.get("count", 1)
  80. if not isinstance(count, int) or count < 1 or count > 100:
  81. raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
  82. assertions = scenario.get("assertions")
  83. supported = {
  84. "reader_ready",
  85. "reader_activity_visible",
  86. "reader_content_changed",
  87. "no_fatal_exception",
  88. }
  89. if not isinstance(assertions, list) or not assertions:
  90. raise ScenarioError("assertions must be a non-empty array")
  91. unknown = set(assertions) - supported
  92. if unknown:
  93. raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
  94. def validate_fixture(fixture_id: str) -> dict[str, Any]:
  95. if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
  96. raise ScenarioError("fixture must be a lowercase ID")
  97. path = FIXTURE_ROOT / f"{fixture_id}.json"
  98. if not path.is_file():
  99. raise ScenarioError(f"fixture not found: {fixture_id}")
  100. try:
  101. fixture = json.loads(path.read_text(encoding="utf-8"))
  102. except (OSError, json.JSONDecodeError) as error:
  103. raise ScenarioError(f"cannot read fixture {fixture_id}: {error}") from error
  104. validate_fixture_data(fixture, fixture_id)
  105. return fixture
  106. def validate_fixture_data(fixture: Any, fixture_id: str) -> None:
  107. if not isinstance(fixture, dict):
  108. raise ScenarioError(f"fixture root must be an object: {fixture_id}")
  109. if fixture.get("schemaVersion") != 1 or fixture.get("id") != fixture_id:
  110. raise ScenarioError(f"invalid fixture metadata: {fixture_id}")
  111. if fixture.get("resetPolicy") != "replace":
  112. raise ScenarioError(f"unsupported fixture resetPolicy: {fixture_id}")
  113. book = fixture.get("book")
  114. if not isinstance(book, dict):
  115. raise ScenarioError(f"fixture book must be an object: {fixture_id}")
  116. required_strings = ("bookUrl", "name", "author")
  117. if any(not isinstance(book.get(key), str) or not book[key] for key in required_strings):
  118. raise ScenarioError(f"fixture book metadata is incomplete: {fixture_id}")
  119. if book.get("pageMode") != "simulation":
  120. raise ScenarioError(f"unsupported fixture pageMode: {fixture_id}")
  121. chapters = fixture.get("chapters")
  122. if not isinstance(chapters, list) or not chapters:
  123. raise ScenarioError(f"fixture must contain chapters: {fixture_id}")
  124. start_chapter = book.get("startChapter")
  125. start_position = book.get("startPosition")
  126. if not isinstance(start_chapter, int) or start_chapter not in range(len(chapters)):
  127. raise ScenarioError(f"fixture startChapter is invalid: {fixture_id}")
  128. if not isinstance(start_position, int) or start_position < 0:
  129. raise ScenarioError(f"fixture startPosition is invalid: {fixture_id}")
  130. for index, chapter in enumerate(chapters):
  131. if not isinstance(chapter, dict):
  132. raise ScenarioError(f"fixture chapters[{index}] must be an object")
  133. for key in ("title", "readyMarker", "paragraph"):
  134. if not isinstance(chapter.get(key), str) or not chapter[key]:
  135. raise ScenarioError(f"fixture chapters[{index}].{key} is required")
  136. repeat = chapter.get("repeat")
  137. if not isinstance(repeat, int) or repeat < 1 or repeat > 1000:
  138. raise ScenarioError(f"fixture chapters[{index}].repeat must be between 1 and 1000")
  139. def run_command(
  140. command: Sequence[str],
  141. *,
  142. timeout: int = 120,
  143. check: bool = True,
  144. cwd: Path = PROJECT_ROOT,
  145. ) -> subprocess.CompletedProcess[str]:
  146. result = subprocess.run(
  147. list(command),
  148. cwd=cwd,
  149. text=True,
  150. encoding="utf-8",
  151. errors="replace",
  152. stdout=subprocess.PIPE,
  153. stderr=subprocess.STDOUT,
  154. timeout=timeout,
  155. )
  156. if check and result.returncode != 0:
  157. raise CommandError(command, result)
  158. return result
  159. def adb(device: str, *arguments: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess[str]:
  160. return run_command(["adb", "-s", device, *arguments], timeout=timeout, check=check)
  161. def select_device(requested: str | None) -> str:
  162. if shutil.which("adb") is None:
  163. raise RuntimeError("adb is not available on PATH")
  164. result = run_command(["adb", "devices"], timeout=15)
  165. devices = []
  166. for line in result.stdout.splitlines()[1:]:
  167. fields = line.split()
  168. if len(fields) >= 2 and fields[1] == "device":
  169. devices.append(fields[0])
  170. if requested:
  171. if requested not in devices:
  172. raise RuntimeError(f"device is not ready: {requested}")
  173. return requested
  174. if len(devices) == 1:
  175. return devices[0]
  176. if not devices:
  177. raise RuntimeError("no ready Android device found")
  178. raise RuntimeError("multiple devices found; pass --device")
  179. def create_session(scenario_name: str, fixture: str, device: str | None) -> tuple[str, Path, dict[str, Any]]:
  180. now = dt.datetime.now().astimezone()
  181. session_id = now.strftime("%Y%m%d-%H%M%S-%f")[:-3]
  182. run_dir = RUN_ROOT / session_id
  183. run_dir.mkdir(parents=True, exist_ok=False)
  184. metadata = {
  185. "schemaVersion": 1,
  186. "sessionId": session_id,
  187. "scenario": scenario_name,
  188. "fixture": fixture,
  189. "device": device,
  190. "startedAt": now.isoformat(timespec="milliseconds"),
  191. "package": PACKAGE,
  192. }
  193. write_json(run_dir / "metadata.json", metadata)
  194. return session_id, run_dir, metadata
  195. def write_json(path: Path, value: Any) -> None:
  196. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  197. def find_debug_apk() -> Path:
  198. output_root = PROJECT_ROOT / "app" / "build" / "outputs" / "apk"
  199. candidates = [
  200. path
  201. for path in output_root.rglob("*.apk")
  202. if "androidTest" not in path.parts and "debug" in path.parts
  203. ]
  204. if not candidates:
  205. raise RuntimeError("debug APK was not produced")
  206. universal = [path for path in candidates if "universal" in path.name]
  207. return max(universal or candidates, key=lambda path: path.stat().st_mtime)
  208. def build_and_install(device: str, run_dir: Path) -> tuple[dict[str, Any], Path]:
  209. started = time.monotonic()
  210. command = ["./gradlew", ":app:assembleAppDebug", "-PenableAbiSplits=false"]
  211. result = run_command(command, timeout=900, check=False)
  212. (run_dir / "build.log").write_text(result.stdout, encoding="utf-8")
  213. build = {
  214. "status": "passed" if result.returncode == 0 else "failed",
  215. "durationMs": round((time.monotonic() - started) * 1000),
  216. "artifact": "build.log",
  217. }
  218. if result.returncode != 0:
  219. raise CommandError(command, result)
  220. apk = find_debug_apk()
  221. install_mode = install_apk(device, apk, run_dir)
  222. build["apk"] = str(apk.relative_to(PROJECT_ROOT))
  223. build["installMode"] = install_mode
  224. return build, apk
  225. def install_apk(device: str, apk: Path, run_dir: Path) -> str:
  226. attempts = [
  227. ("no_streaming", ["adb", "-s", device, "install", "--no-streaming", "-r", "-t", str(apk)], 120),
  228. ("streaming_retry", ["adb", "-s", device, "install", "-r", "-t", str(apk)], 60),
  229. ]
  230. log_parts = []
  231. last_error: Exception | None = None
  232. for mode, command, timeout in attempts:
  233. try:
  234. result = run_command(command, timeout=timeout, check=False)
  235. log_parts.append(f"[{mode}] exit={result.returncode}\n{result.stdout}")
  236. if result.returncode == 0 and "Success" in result.stdout:
  237. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  238. return mode
  239. last_error = CommandError(command, result)
  240. except subprocess.TimeoutExpired as error:
  241. log_parts.append(f"[{mode}] timeout={timeout}s\n")
  242. last_error = error
  243. cleanup_device(device)
  244. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  245. raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
  246. def launch_fixture(device: str, fixture: str, session_id: str) -> None:
  247. adb(device, "shell", "am", "force-stop", PACKAGE)
  248. adb(device, "logcat", "-c")
  249. component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
  250. result = adb(
  251. device,
  252. "shell",
  253. "am",
  254. "start",
  255. "-W",
  256. "-n",
  257. component,
  258. "--es",
  259. "fixture",
  260. fixture,
  261. "--es",
  262. "session",
  263. session_id,
  264. timeout=60,
  265. check=False,
  266. )
  267. if result.returncode != 0 or "Error:" in result.stdout:
  268. raise CommandError(["adb", "shell", "am", "start", component], result)
  269. def ui_dump(device: str) -> str:
  270. return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
  271. def current_focus(device: str) -> str:
  272. return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
  273. def extract_reader_content(dump: str) -> str:
  274. start = dump.find("<?xml")
  275. end = dump.rfind("</hierarchy>")
  276. if start < 0 or end < 0:
  277. return ""
  278. try:
  279. root = ET.fromstring(dump[start : end + len("</hierarchy>")])
  280. except ET.ParseError:
  281. return ""
  282. contents = [
  283. node.attrib.get("content-desc", "")
  284. for node in root.iter("node")
  285. if node.attrib.get("package") == PACKAGE
  286. and node.attrib.get("class") == "io.legado.app.ui.book.read.page.ReadView"
  287. ]
  288. return max(contents, key=len, default="")
  289. def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
  290. deadline = time.monotonic() + timeout_seconds
  291. latest = ""
  292. content = ""
  293. while time.monotonic() < deadline:
  294. latest = ui_dump(device)
  295. content = extract_reader_content(latest)
  296. if marker in content and PACKAGE in current_focus(device):
  297. return True, latest, content
  298. time.sleep(0.5)
  299. return False, latest, content
  300. def application_pid(device: str) -> str | None:
  301. result = adb(device, "shell", "pidof", PACKAGE, timeout=10, check=False)
  302. return result.stdout.strip().split()[0] if result.returncode == 0 and result.stdout.strip() else None
  303. def screen_size(device: str) -> tuple[int, int]:
  304. output = adb(device, "shell", "wm", "size").stdout
  305. matches = re.findall(r"(\d+)x(\d+)", output)
  306. if not matches:
  307. raise RuntimeError(f"cannot determine screen size: {output.strip()}")
  308. width, height = matches[-1]
  309. return int(width), int(height)
  310. def capture_screenshot(device: str, path: Path) -> None:
  311. with path.open("wb") as output:
  312. result = subprocess.run(
  313. ["adb", "-s", device, "exec-out", "screencap", "-p"],
  314. cwd=PROJECT_ROOT,
  315. stdout=output,
  316. stderr=subprocess.PIPE,
  317. timeout=30,
  318. )
  319. if result.returncode != 0:
  320. path.unlink(missing_ok=True)
  321. raise RuntimeError(result.stderr.decode(errors="replace"))
  322. def perform_actions(device: str, scenario: dict[str, Any], action_repeat: int) -> int:
  323. width, height = screen_size(device)
  324. performed = 0
  325. for action in scenario["actions"]:
  326. direction = action["direction"]
  327. count = action.get("count", 1) * action_repeat
  328. x = round(width * (0.84 if direction == "next" else 0.16))
  329. y = round(height * 0.5)
  330. for _ in range(count):
  331. adb(device, "shell", "input", "tap", str(x), str(y), timeout=10)
  332. performed += 1
  333. time.sleep(0.4)
  334. return performed
  335. def collect_logcat(
  336. device: str,
  337. run_dir: Path,
  338. app_pid: str | None = None,
  339. session_id: str | None = None,
  340. ) -> str:
  341. logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
  342. (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
  343. session_logcat = ""
  344. if app_pid:
  345. session_logcat = adb(
  346. device,
  347. "logcat",
  348. "-d",
  349. f"--pid={app_pid}",
  350. "-v",
  351. "threadtime",
  352. timeout=45,
  353. check=False,
  354. ).stdout
  355. if session_id:
  356. session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
  357. for line in session_lines:
  358. if line not in session_logcat:
  359. session_logcat += line + "\n"
  360. (run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
  361. crash = extract_crash(logcat, PACKAGE, app_pid)
  362. (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
  363. return logcat
  364. def extract_crash(logcat: str, package: str = PACKAGE, app_pid: str | None = None) -> str:
  365. lines = logcat.splitlines()
  366. chunks = []
  367. for index, line in enumerate(lines):
  368. chunk = lines[index : index + 100]
  369. if f"ANR in {package}" in line:
  370. chunks.extend(chunk)
  371. elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
  372. chunks.extend(chunk)
  373. elif "Fatal signal " in line and app_pid and re.search(rf"\b{re.escape(app_pid)}\b", line):
  374. chunks.extend(chunk)
  375. if not chunks:
  376. return ""
  377. return "\n".join(chunks).rstrip() + "\n"
  378. def parse_failure(crash: str) -> dict[str, Any] | None:
  379. if not crash:
  380. return None
  381. if "ANR in" in crash:
  382. return {
  383. "category": "anr",
  384. "confidence": "high",
  385. "detectedSignals": ["anr"],
  386. "stacktraceArtifact": "crash.txt",
  387. }
  388. if "Fatal signal " in crash:
  389. return {
  390. "category": "native_crash",
  391. "confidence": "high",
  392. "detectedSignals": ["fatal_signal"],
  393. "stacktraceArtifact": "crash.txt",
  394. }
  395. exceptions = list(
  396. re.finditer(r"(Caused by: )?([\w.$]+(?:Exception|Error))(?:: ([^\n]+))?", crash)
  397. )
  398. caused_by = [match for match in exceptions if match.group(1)]
  399. exception = caused_by[-1] if caused_by else (exceptions[0] if exceptions else None)
  400. failure: dict[str, Any] = {
  401. "category": "uncaught_exception",
  402. "confidence": "high",
  403. "detectedSignals": ["fatal_exception"],
  404. "stacktraceArtifact": "crash.txt",
  405. }
  406. if exception:
  407. failure["exceptionType"] = exception.group(2).split(".")[-1]
  408. failure["exceptionConfidence"] = "medium" if exceptions else "low"
  409. if exception.group(3):
  410. failure["message"] = exception.group(3).strip()
  411. frame = re.search(
  412. r"at (io\.legado\.app\.[\w.$]+)\.([\w$<>]+)\(([^():]+):(\d+)\)",
  413. crash,
  414. )
  415. if frame:
  416. failure["suspectedFrame"] = {
  417. "class": frame.group(1),
  418. "method": frame.group(2),
  419. "file": frame.group(3),
  420. "line": int(frame.group(4)),
  421. "confidence": "medium",
  422. }
  423. return failure
  424. def sha256_text(value: str) -> str:
  425. return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
  426. def make_summary(
  427. *,
  428. session_id: str,
  429. scenario: dict[str, Any],
  430. fixture: str,
  431. build: dict[str, Any],
  432. ready: bool,
  433. activity_visible: bool,
  434. content_changed: bool,
  435. action_count: int,
  436. failure: dict[str, Any] | None,
  437. ) -> tuple[dict[str, Any], int]:
  438. assertion_values = {
  439. "reader_ready": ready,
  440. "reader_activity_visible": activity_visible,
  441. "reader_content_changed": content_changed,
  442. "no_fatal_exception": failure is None,
  443. }
  444. assertions = [
  445. {"name": name, "passed": assertion_values[name]}
  446. for name in scenario["assertions"]
  447. ]
  448. if failure is not None:
  449. status, exit_code = "failed", EXIT_CRASH
  450. elif not all(item["passed"] for item in assertions):
  451. status, exit_code = "failed", EXIT_ASSERTION
  452. else:
  453. status, exit_code = "passed", 0
  454. summary: dict[str, Any] = {
  455. "schemaVersion": 1,
  456. "status": status,
  457. "exitCode": exit_code,
  458. "sessionId": session_id,
  459. "scenario": scenario["name"],
  460. "fixture": fixture,
  461. "build": build,
  462. "actions": {"performed": action_count},
  463. "assertions": assertions,
  464. "artifacts": {
  465. "metadata": "metadata.json",
  466. "logcat": "logcat.txt",
  467. "sessionLogcat": "session-logcat.txt",
  468. "crash": "crash.txt",
  469. "beforeScreenshot": "before.png",
  470. "afterScreenshot": "after.png",
  471. },
  472. }
  473. if failure is not None:
  474. summary["failure"] = failure
  475. return summary, exit_code
  476. def error_summary(
  477. session_id: str,
  478. scenario_name: str,
  479. fixture: str,
  480. exit_code: int,
  481. category: str,
  482. message: str,
  483. build: dict[str, Any] | None = None,
  484. ) -> dict[str, Any]:
  485. summary: dict[str, Any] = {
  486. "schemaVersion": 1,
  487. "status": "failed",
  488. "exitCode": exit_code,
  489. "sessionId": session_id,
  490. "scenario": scenario_name,
  491. "fixture": fixture,
  492. "failure": {"category": category, "message": message},
  493. "artifacts": {"metadata": "metadata.json"},
  494. }
  495. if build is not None:
  496. summary["build"] = build
  497. return summary
  498. def cleanup_device(device: str) -> None:
  499. adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
  500. def execute(args: argparse.Namespace) -> int:
  501. try:
  502. scenario = load_scenario(args.scenario)
  503. fixture = args.fixture or scenario["fixture"]
  504. fixture_data = validate_fixture(fixture)
  505. markers = {chapter["readyMarker"] for chapter in fixture_data["chapters"]}
  506. if scenario["readyMarker"] not in markers:
  507. raise ScenarioError("scenario readyMarker is not provided by the fixture")
  508. if args.action_repeat < 1 or args.action_repeat > 100:
  509. raise ScenarioError("--action-repeat must be between 1 and 100")
  510. except ScenarioError as error:
  511. print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
  512. return EXIT_SCENARIO
  513. try:
  514. device = select_device(args.device)
  515. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  516. session_id, run_dir, _ = create_session(args.scenario, fixture, args.device)
  517. summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
  518. write_json(run_dir / "summary.json", summary)
  519. print(run_dir / "summary.json")
  520. return EXIT_ENVIRONMENT
  521. session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
  522. build: dict[str, Any] = {"status": "not_started"}
  523. try:
  524. build, _ = build_and_install(device, run_dir)
  525. except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
  526. if isinstance(error, CommandError):
  527. build = {
  528. "status": "failed",
  529. "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
  530. }
  531. summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
  532. cleanup_device(device)
  533. write_json(run_dir / "summary.json", summary)
  534. print(run_dir / "summary.json")
  535. return EXIT_BUILD
  536. app_pid: str | None = None
  537. try:
  538. launch_fixture(device, fixture, session_id)
  539. app_pid = application_pid(device)
  540. ready, before_dump, before_content = wait_until_reader_ready(
  541. device,
  542. scenario["readyMarker"],
  543. scenario.get("readyTimeoutSeconds", 30),
  544. )
  545. capture_screenshot(device, run_dir / "before.png")
  546. action_count = perform_actions(device, scenario, args.action_repeat) if ready else 0
  547. time.sleep(0.5)
  548. after_dump = ui_dump(device)
  549. after_content = extract_reader_content(after_dump)
  550. capture_screenshot(device, run_dir / "after.png")
  551. focus = current_focus(device)
  552. activity_visible = PACKAGE in focus and MAIN_ACTIVITY in focus
  553. content_changed = ready and bool(after_content) and before_content != after_content
  554. logcat = collect_logcat(device, run_dir, app_pid, session_id)
  555. failure = parse_failure(extract_crash(logcat, PACKAGE, app_pid))
  556. metadata.update(
  557. {
  558. "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
  559. "actionRepeat": args.action_repeat,
  560. "uiBeforeSha256": sha256_text(before_dump),
  561. "uiAfterSha256": sha256_text(after_dump),
  562. "readerContentBeforeSha256": sha256_text(before_content),
  563. "readerContentAfterSha256": sha256_text(after_content),
  564. "appPid": app_pid,
  565. }
  566. )
  567. write_json(run_dir / "metadata.json", metadata)
  568. summary, exit_code = make_summary(
  569. session_id=session_id,
  570. scenario=scenario,
  571. fixture=fixture,
  572. build=build,
  573. ready=ready,
  574. activity_visible=activity_visible,
  575. content_changed=content_changed,
  576. action_count=action_count,
  577. failure=failure,
  578. )
  579. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  580. try:
  581. collect_logcat(device, run_dir, app_pid, session_id)
  582. except Exception:
  583. pass
  584. summary = error_summary(
  585. session_id,
  586. args.scenario,
  587. fixture,
  588. EXIT_ENVIRONMENT,
  589. "environment",
  590. str(error),
  591. build,
  592. )
  593. exit_code = EXIT_ENVIRONMENT
  594. finally:
  595. cleanup_device(device)
  596. write_json(run_dir / "summary.json", summary)
  597. print(run_dir / "summary.json")
  598. return exit_code
  599. def parse_args(argv: Sequence[str]) -> argparse.Namespace:
  600. parser = argparse.ArgumentParser(description=__doc__)
  601. subparsers = parser.add_subparsers(dest="command", required=True)
  602. run_parser = subparsers.add_parser("run", help="build, install, and run one scenario")
  603. run_parser.add_argument("scenario", help="scenario ID, for example reader/page-turn")
  604. run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
  605. run_parser.add_argument("--fixture", help="override the scenario fixture ID")
  606. run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
  607. return parser.parse_args(argv)
  608. def main(argv: Sequence[str] | None = None) -> int:
  609. args = parse_args(argv if argv is not None else sys.argv[1:])
  610. if args.command == "run":
  611. return execute(args)
  612. return EXIT_SCENARIO
  613. if __name__ == "__main__":
  614. raise SystemExit(main())