runner.py 22 KB

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