runner.py 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279
  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. import zipfile
  18. EXIT_ASSERTION = 1
  19. EXIT_CRASH = 2
  20. EXIT_BUILD = 3
  21. EXIT_ENVIRONMENT = 4
  22. EXIT_SCENARIO = 5
  23. PACKAGE = "io.legato.kazusa.debug"
  24. DEBUG_ACTIVITY = "io.legado.app.debug.DebugScenarioActivity"
  25. MAIN_ACTIVITY = "io.legado.app.ui.main.MainActivity"
  26. SOURCE_EDIT_ACTIVITY = "io.legado.app.ui.book.source.edit.BookSourceEditActivity"
  27. SOURCE_MANAGE_ACTIVITY = "io.legado.app.ui.book.source.manage.BookSourceActivity"
  28. THEME_MANIFEST_PATH = "manifest.json"
  29. THEME_ASSET_REMOTE = f"/sdcard/Android/data/{PACKAGE}/files/debug-theme-assets/background.png"
  30. DEBUG_STATE_URI = f"content://{PACKAGE}.debug-state"
  31. SCRIPT_DIR = Path(__file__).resolve().parent
  32. PROJECT_ROOT = SCRIPT_DIR.parent.parent
  33. THEME_ASSET_FIXTURE = PROJECT_ROOT / "app" / "src" / "main" / "assets" / "web" / "uploadBook" / "img" / "close.png"
  34. SCENARIO_ROOT = SCRIPT_DIR / "scenarios"
  35. RUN_ROOT = PROJECT_ROOT / ".debug-runs"
  36. FIXTURE_ROOT = PROJECT_ROOT / "app" / "src" / "debug" / "assets" / "debug-fixtures"
  37. # Capability registries. New bug scenarios extend the tool by registering an
  38. # entry / action / assertion below, never by editing execute() or
  39. # validate_scenario(). See the classes near the end of this module.
  40. ENTRIES: dict[str, "Entry"] = {}
  41. ACTIONS: dict[str, "Action"] = {}
  42. ASSERTIONS: dict[str, Callable[["Context"], bool]] = {}
  43. class ScenarioError(ValueError):
  44. pass
  45. class CommandError(RuntimeError):
  46. def __init__(self, command: Sequence[str], result: subprocess.CompletedProcess[str]):
  47. self.command = list(command)
  48. self.result = result
  49. super().__init__(f"command failed ({result.returncode}): {' '.join(command)}")
  50. def load_scenario(name: str) -> dict[str, Any]:
  51. if not re.fullmatch(r"[a-z0-9][a-z0-9/_-]*", name):
  52. raise ScenarioError(f"invalid scenario name: {name}")
  53. path = (SCENARIO_ROOT / f"{name}.json").resolve()
  54. if SCENARIO_ROOT.resolve() not in path.parents or not path.is_file():
  55. raise ScenarioError(f"scenario not found: {name}")
  56. try:
  57. scenario = json.loads(path.read_text(encoding="utf-8"))
  58. except (OSError, json.JSONDecodeError) as error:
  59. raise ScenarioError(f"cannot read scenario {name}: {error}") from error
  60. validate_scenario(scenario, name)
  61. return scenario
  62. def validate_scenario(scenario: Any, requested_name: str | None = None) -> None:
  63. if not isinstance(scenario, dict):
  64. raise ScenarioError("scenario root must be an object")
  65. if scenario.get("schemaVersion") != 1:
  66. raise ScenarioError("schemaVersion must be 1")
  67. name = scenario.get("name")
  68. if not isinstance(name, str) or not name:
  69. raise ScenarioError("name must be a non-empty string")
  70. if requested_name is not None and name != requested_name:
  71. raise ScenarioError(f"scenario name mismatch: expected {requested_name}, got {name}")
  72. fixture = scenario.get("fixture")
  73. if not isinstance(fixture, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture):
  74. raise ScenarioError("fixture must be a lowercase ID")
  75. marker = scenario.get("readyMarker")
  76. if not isinstance(marker, str) or not marker.strip():
  77. raise ScenarioError("readyMarker must be a non-empty string")
  78. timeout = scenario.get("readyTimeoutSeconds", 30)
  79. if not isinstance(timeout, int) or timeout < 1 or timeout > 300:
  80. raise ScenarioError("readyTimeoutSeconds must be between 1 and 300")
  81. change_timeout = scenario.get("contentChangeTimeoutSeconds", 5)
  82. if not isinstance(change_timeout, int) or change_timeout < 1 or change_timeout > 30:
  83. raise ScenarioError("contentChangeTimeoutSeconds must be between 1 and 30")
  84. record = scenario.get("record", False)
  85. if not isinstance(record, bool):
  86. raise ScenarioError("record must be a boolean")
  87. validate_preferences(scenario.get("preferences"))
  88. entry = scenario.get("entry")
  89. if not isinstance(entry, dict) or entry.get("type") not in ENTRIES:
  90. raise ScenarioError(f"entry.type must be one of: {', '.join(sorted(ENTRIES))}")
  91. entry_handler = ENTRIES[entry["type"]]
  92. theme_asset = scenario.get("themeAsset")
  93. if theme_asset is not None:
  94. if entry["type"] != "theme_config":
  95. raise ScenarioError("themeAsset is only supported by theme_config scenarios")
  96. if theme_asset not in {"valid-file", "missing-file", "missing-content-uri"}:
  97. raise ScenarioError("themeAsset must be valid-file, missing-file, or missing-content-uri")
  98. expect_export_failure = scenario.get("expectThemeExportFailure", False)
  99. if not isinstance(expect_export_failure, bool):
  100. raise ScenarioError("expectThemeExportFailure must be a boolean")
  101. actions = scenario.get("actions")
  102. if not isinstance(actions, list) or not actions:
  103. raise ScenarioError("actions must be a non-empty array")
  104. for index, action in enumerate(actions):
  105. if not isinstance(action, dict):
  106. raise ScenarioError(f"actions[{index}] must be an object")
  107. action_type = action.get("type")
  108. if action_type not in entry_handler.allowed_actions:
  109. raise ScenarioError(f"actions[{index}] has an unsupported type")
  110. ACTIONS[action_type].validate(action, index)
  111. assertions = scenario.get("assertions")
  112. if not isinstance(assertions, list) or not assertions:
  113. raise ScenarioError("assertions must be a non-empty array")
  114. unknown = set(assertions) - set(ASSERTIONS)
  115. if unknown:
  116. raise ScenarioError(f"unsupported assertions: {', '.join(sorted(unknown))}")
  117. def validate_preferences(preferences: Any) -> None:
  118. # Scenario-declared app settings applied before launch (theme engine,
  119. # reader toggles, ...). Restricted to String/Boolean so the debug Activity
  120. # can apply them unambiguously via AppConfigStore.
  121. if preferences is None:
  122. return
  123. if not isinstance(preferences, dict) or not preferences:
  124. raise ScenarioError("preferences must be a non-empty object when present")
  125. for key, value in preferences.items():
  126. if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9]*", key):
  127. raise ScenarioError(f"invalid preference key: {key}")
  128. if not isinstance(value, (str, bool)):
  129. raise ScenarioError(f"preference {key} must be a string or boolean")
  130. def validate_fixture(fixture_id: str) -> dict[str, Any]:
  131. if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", fixture_id):
  132. raise ScenarioError("fixture must be a lowercase ID")
  133. path = FIXTURE_ROOT / f"{fixture_id}.json"
  134. if not path.is_file():
  135. raise ScenarioError(f"fixture not found: {fixture_id}")
  136. try:
  137. fixture = json.loads(path.read_text(encoding="utf-8"))
  138. except (OSError, json.JSONDecodeError) as error:
  139. raise ScenarioError(f"cannot read fixture {fixture_id}: {error}") from error
  140. validate_fixture_data(fixture, fixture_id)
  141. return fixture
  142. def validate_fixture_data(fixture: Any, fixture_id: str) -> None:
  143. if not isinstance(fixture, dict):
  144. raise ScenarioError(f"fixture root must be an object: {fixture_id}")
  145. if fixture.get("schemaVersion") != 1 or fixture.get("id") != fixture_id:
  146. raise ScenarioError(f"invalid fixture metadata: {fixture_id}")
  147. if fixture.get("resetPolicy") != "replace":
  148. raise ScenarioError(f"unsupported fixture resetPolicy: {fixture_id}")
  149. book = fixture.get("book")
  150. if not isinstance(book, dict):
  151. raise ScenarioError(f"fixture book must be an object: {fixture_id}")
  152. required_strings = ("bookUrl", "name", "author")
  153. if any(not isinstance(book.get(key), str) or not book[key] for key in required_strings):
  154. raise ScenarioError(f"fixture book metadata is incomplete: {fixture_id}")
  155. if book.get("pageMode") != "simulation":
  156. raise ScenarioError(f"unsupported fixture pageMode: {fixture_id}")
  157. chapters = fixture.get("chapters")
  158. if not isinstance(chapters, list) or not chapters:
  159. raise ScenarioError(f"fixture must contain chapters: {fixture_id}")
  160. start_chapter = book.get("startChapter")
  161. start_position = book.get("startPosition")
  162. if not isinstance(start_chapter, int) or start_chapter not in range(len(chapters)):
  163. raise ScenarioError(f"fixture startChapter is invalid: {fixture_id}")
  164. if not isinstance(start_position, int) or start_position < 0:
  165. raise ScenarioError(f"fixture startPosition is invalid: {fixture_id}")
  166. for index, chapter in enumerate(chapters):
  167. if not isinstance(chapter, dict):
  168. raise ScenarioError(f"fixture chapters[{index}] must be an object")
  169. for key in ("title", "readyMarker", "paragraph"):
  170. if not isinstance(chapter.get(key), str) or not chapter[key]:
  171. raise ScenarioError(f"fixture chapters[{index}].{key} is required")
  172. repeat = chapter.get("repeat")
  173. if not isinstance(repeat, int) or repeat < 1 or repeat > 1000:
  174. raise ScenarioError(f"fixture chapters[{index}].repeat must be between 1 and 1000")
  175. def run_command(
  176. command: Sequence[str],
  177. *,
  178. timeout: int = 120,
  179. check: bool = True,
  180. cwd: Path = PROJECT_ROOT,
  181. ) -> subprocess.CompletedProcess[str]:
  182. result = subprocess.run(
  183. list(command),
  184. cwd=cwd,
  185. text=True,
  186. encoding="utf-8",
  187. errors="replace",
  188. stdout=subprocess.PIPE,
  189. stderr=subprocess.STDOUT,
  190. timeout=timeout,
  191. )
  192. if check and result.returncode != 0:
  193. raise CommandError(command, result)
  194. return result
  195. def adb(device: str, *arguments: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess[str]:
  196. return run_command(["adb", "-s", device, *arguments], timeout=timeout, check=check)
  197. def prepare_theme_asset(device: str, scenario: dict[str, Any]) -> dict[str, Any] | None:
  198. preferences = dict(scenario.get("preferences") or {})
  199. mode = scenario.get("themeAsset")
  200. if mode is None:
  201. return preferences or None
  202. remote_folder = THEME_ASSET_REMOTE.rsplit("/", 1)[0]
  203. adb(device, "shell", "rm", "-rf", remote_folder, timeout=10, check=False)
  204. if mode == "valid-file":
  205. adb(device, "shell", "mkdir", "-p", remote_folder, timeout=10)
  206. run_command(
  207. ["adb", "-s", device, "push", str(THEME_ASSET_FIXTURE), THEME_ASSET_REMOTE],
  208. timeout=30,
  209. )
  210. background = THEME_ASSET_REMOTE
  211. elif mode == "missing-file":
  212. background = f"{remote_folder}/missing-background.png"
  213. else:
  214. background = f"content://{PACKAGE}.debug-theme-assets/missing-background.png"
  215. preferences["backgroundImage"] = background
  216. preferences["backgroundImageNight"] = ""
  217. return preferences
  218. def arm_theme_content_failure(device: str) -> None:
  219. adb(
  220. device,
  221. "shell",
  222. "content",
  223. "call",
  224. "--uri",
  225. f"content://{PACKAGE}.debug-theme-assets",
  226. "--method",
  227. "armFailure",
  228. timeout=15,
  229. )
  230. def select_device(requested: str | None) -> str:
  231. if shutil.which("adb") is None:
  232. raise RuntimeError("adb is not available on PATH")
  233. result = run_command(["adb", "devices"], timeout=15)
  234. devices = []
  235. for line in result.stdout.splitlines()[1:]:
  236. fields = line.split()
  237. if len(fields) >= 2 and fields[1] == "device":
  238. devices.append(fields[0])
  239. if requested:
  240. if requested not in devices:
  241. raise RuntimeError(f"device is not ready: {requested}")
  242. return requested
  243. if len(devices) == 1:
  244. return devices[0]
  245. if not devices:
  246. raise RuntimeError("no ready Android device found")
  247. raise RuntimeError("multiple devices found; pass --device")
  248. def create_session(scenario_name: str, fixture: str, device: str | None) -> tuple[str, Path, dict[str, Any]]:
  249. now = dt.datetime.now().astimezone()
  250. session_id = now.strftime("%Y%m%d-%H%M%S-%f")[:-3]
  251. run_dir = RUN_ROOT / session_id
  252. run_dir.mkdir(parents=True, exist_ok=False)
  253. metadata = {
  254. "schemaVersion": 1,
  255. "sessionId": session_id,
  256. "scenario": scenario_name,
  257. "fixture": fixture,
  258. "device": device,
  259. "startedAt": now.isoformat(timespec="milliseconds"),
  260. "package": PACKAGE,
  261. }
  262. write_json(run_dir / "metadata.json", metadata)
  263. return session_id, run_dir, metadata
  264. def write_json(path: Path, value: Any) -> None:
  265. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  266. def find_debug_apk() -> Path:
  267. output_root = PROJECT_ROOT / "app" / "build" / "outputs" / "apk"
  268. candidates = [
  269. path
  270. for path in output_root.rglob("*.apk")
  271. if "androidTest" not in path.parts and "debug" in path.parts
  272. ]
  273. if not candidates:
  274. raise RuntimeError("debug APK was not produced")
  275. universal = [path for path in candidates if "universal" in path.name]
  276. return max(universal or candidates, key=lambda path: path.stat().st_mtime)
  277. def build_and_install(device: str, run_dir: Path) -> tuple[dict[str, Any], Path]:
  278. started = time.monotonic()
  279. command = ["./gradlew", ":app:assembleAppDebug", "-PenableAbiSplits=false"]
  280. result = run_command(command, timeout=900, check=False)
  281. (run_dir / "build.log").write_text(result.stdout, encoding="utf-8")
  282. build = {
  283. "status": "passed" if result.returncode == 0 else "failed",
  284. "durationMs": round((time.monotonic() - started) * 1000),
  285. "artifact": "build.log",
  286. }
  287. if result.returncode != 0:
  288. raise CommandError(command, result)
  289. apk = find_debug_apk()
  290. install_mode = install_apk(device, apk, run_dir)
  291. build["apk"] = str(apk.relative_to(PROJECT_ROOT))
  292. build["installMode"] = install_mode
  293. return build, apk
  294. def install_apk(device: str, apk: Path, run_dir: Path) -> str:
  295. attempts = [
  296. ("no_streaming", ["adb", "-s", device, "install", "--no-streaming", "-r", "-t", str(apk)], 120),
  297. ("streaming_retry", ["adb", "-s", device, "install", "-r", "-t", str(apk)], 60),
  298. ]
  299. log_parts = []
  300. last_error: Exception | None = None
  301. for mode, command, timeout in attempts:
  302. try:
  303. result = run_command(command, timeout=timeout, check=False)
  304. log_parts.append(f"[{mode}] exit={result.returncode}\n{result.stdout}")
  305. if result.returncode == 0 and "Success" in result.stdout:
  306. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  307. return mode
  308. last_error = CommandError(command, result)
  309. except subprocess.TimeoutExpired as error:
  310. log_parts.append(f"[{mode}] timeout={timeout}s\n")
  311. last_error = error
  312. cleanup_device(device)
  313. (run_dir / "install.log").write_text("\n".join(log_parts), encoding="utf-8")
  314. raise RuntimeError(f"APK install failed after {len(attempts)} attempts: {last_error}")
  315. def launch_fixture(
  316. device: str,
  317. fixture: str,
  318. entry: str,
  319. session_id: str,
  320. preferences: dict[str, Any] | None = None,
  321. ) -> None:
  322. adb(device, "shell", "am", "force-stop", PACKAGE)
  323. adb(device, "logcat", "-c")
  324. component = f"{PACKAGE}/{DEBUG_ACTIVITY}"
  325. extras = [
  326. "--es", "fixture", fixture,
  327. "--es", "session", session_id,
  328. "--es", "entry", entry,
  329. ]
  330. if preferences:
  331. # base64 so the JSON survives `adb shell am` re-parsing on the device
  332. # (raw JSON gets quote-stripped and brace-expanded by the device shell).
  333. payload = json.dumps(preferences, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
  334. extras += ["--es", "preferencesB64", base64.b64encode(payload).decode("ascii")]
  335. result = adb(
  336. device,
  337. "shell",
  338. "am",
  339. "start",
  340. "-W",
  341. "-n",
  342. component,
  343. *extras,
  344. timeout=60,
  345. check=False,
  346. )
  347. if result.returncode != 0 or "Error:" in result.stdout:
  348. raise CommandError(["adb", "shell", "am", "start", component], result)
  349. def ui_dump(device: str) -> str:
  350. return adb(device, "exec-out", "uiautomator", "dump", "/dev/tty", timeout=20, check=False).stdout
  351. def window_state(device: str) -> str:
  352. # Full `dumpsys window windows` output. Callers substring-match against it,
  353. # so a hit means the activity is present in the window stack, not strictly
  354. # the foreground window.
  355. return adb(device, "shell", "dumpsys", "window", "windows", timeout=20, check=False).stdout
  356. def device_is_awake(device: str) -> bool:
  357. output = adb(device, "shell", "dumpsys", "power", timeout=20, check=False).stdout
  358. return "mWakefulness=Awake" in output
  359. def keyguard_is_showing(device: str) -> bool:
  360. output = adb(device, "shell", "dumpsys", "window", "policy", timeout=20, check=False).stdout
  361. match = re.search(r"^\s+showing=(true|false)\b", output, re.MULTILINE)
  362. return match is not None and match.group(1) == "true"
  363. def device_ready_for_ui(device: str) -> bool:
  364. return device_is_awake(device) and not keyguard_is_showing(device)
  365. def prepare_device(device: str) -> dict[str, Any]:
  366. state = {
  367. "wasAwake": device_is_awake(device),
  368. "wasKeyguardShowing": keyguard_is_showing(device),
  369. "stayOnWhilePluggedIn": adb(
  370. device,
  371. "shell",
  372. "settings",
  373. "get",
  374. "global",
  375. "stay_on_while_plugged_in",
  376. timeout=20,
  377. check=False,
  378. ).stdout.strip(),
  379. }
  380. adb(
  381. device,
  382. "shell",
  383. "settings",
  384. "put",
  385. "global",
  386. "stay_on_while_plugged_in",
  387. "2",
  388. timeout=20,
  389. )
  390. if not state["wasAwake"]:
  391. adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20)
  392. if keyguard_is_showing(device):
  393. adb(device, "shell", "wm", "dismiss-keyguard", timeout=20, check=False)
  394. time.sleep(0.2)
  395. if keyguard_is_showing(device):
  396. adb(device, "shell", "input", "keyevent", "82", timeout=20, check=False)
  397. time.sleep(0.5)
  398. if not device_ready_for_ui(device):
  399. restore_device(device, state)
  400. raise RuntimeError("device could not be awakened and unlocked for UI automation")
  401. return state
  402. def restore_device(device: str, state: dict[str, Any] | None) -> None:
  403. if state is None:
  404. return
  405. stay_on = state.get("stayOnWhilePluggedIn")
  406. if stay_on in {"", "null", None}:
  407. adb(
  408. device,
  409. "shell",
  410. "settings",
  411. "delete",
  412. "global",
  413. "stay_on_while_plugged_in",
  414. timeout=20,
  415. check=False,
  416. )
  417. else:
  418. adb(
  419. device,
  420. "shell",
  421. "settings",
  422. "put",
  423. "global",
  424. "stay_on_while_plugged_in",
  425. str(stay_on),
  426. timeout=20,
  427. check=False,
  428. )
  429. if state.get("wasKeyguardShowing"):
  430. adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
  431. if state.get("wasAwake"):
  432. adb(device, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=20, check=False)
  433. elif not state.get("wasAwake"):
  434. adb(device, "shell", "input", "keyevent", "KEYCODE_SLEEP", timeout=20, check=False)
  435. def parse_ui_nodes(dump: str) -> list[dict[str, str]]:
  436. start = dump.find("<?xml")
  437. end = dump.rfind("</hierarchy>")
  438. if start < 0 or end < 0:
  439. return []
  440. try:
  441. root = ET.fromstring(dump[start : end + len("</hierarchy>")])
  442. except ET.ParseError:
  443. return []
  444. return [node.attrib for node in root.iter("node")]
  445. READER_PAGE_TAG = "READER_PAGE "
  446. def extract_reader_pages(logcat: str) -> list[str]:
  447. # ReadView logs "READER_PAGE <one-line page text>" on every render (debug
  448. # build only). This is the device-independent reader signal — uiautomator
  449. # does not expose the ReadView node on all devices.
  450. pages = []
  451. for line in logcat.splitlines():
  452. index = line.find(READER_PAGE_TAG)
  453. if index >= 0:
  454. pages.append(line[index + len(READER_PAGE_TAG):].strip())
  455. return pages
  456. def reader_current_page(device: str) -> str:
  457. logcat = adb(device, "logcat", "-d", "-s", "LegadoDebug:I", timeout=20, check=False).stdout
  458. pages = extract_reader_pages(logcat)
  459. return pages[-1] if pages else ""
  460. def find_node_by_content_prefix(
  461. dump: str,
  462. prefix: str,
  463. package: str = PACKAGE,
  464. ) -> dict[str, str] | None:
  465. # The bookshelf item's label lives on a node whose clickable flag is false
  466. # (the clickable modifier sits on an ancestor). Match by content-desc and
  467. # tap the label's center; the clickable ancestor is under that point.
  468. candidates = [
  469. node
  470. for node in parse_ui_nodes(dump)
  471. if node.get("package") == package
  472. and node.get("content-desc", "").startswith(prefix)
  473. ]
  474. return candidates[0] if candidates else None
  475. def find_node_by_text(
  476. dump: str,
  477. text: str,
  478. package: str = PACKAGE,
  479. ) -> dict[str, str] | None:
  480. candidates = [
  481. node
  482. for node in parse_ui_nodes(dump)
  483. if node.get("package") == package and node.get("text") == text
  484. ]
  485. return candidates[0] if candidates else None
  486. def debug_state(device: str, method: str) -> dict[str, Any]:
  487. result = adb(
  488. device,
  489. "shell",
  490. "content",
  491. "call",
  492. "--uri",
  493. DEBUG_STATE_URI,
  494. "--method",
  495. method,
  496. timeout=20,
  497. )
  498. match = re.search(r"resultB64=([A-Za-z0-9+/=]+)", result.stdout)
  499. if match is None:
  500. raise RuntimeError(f"debug state response is invalid: {result.stdout.strip()}")
  501. return json.loads(base64.b64decode(match.group(1)).decode("utf-8"))
  502. def find_node_in_same_row(
  503. dump: str,
  504. anchor: dict[str, str],
  505. resource_id: str,
  506. ) -> dict[str, str] | None:
  507. anchor_bounds = parse_bounds(anchor)
  508. if anchor_bounds is None:
  509. return None
  510. _, anchor_top, _, anchor_bottom = anchor_bounds
  511. candidates = []
  512. for node in parse_ui_nodes(dump):
  513. if node.get("package") != PACKAGE or node.get("resource-id") != resource_id:
  514. continue
  515. bounds = parse_bounds(node)
  516. if bounds is None:
  517. continue
  518. _, top, _, bottom = bounds
  519. overlap = min(anchor_bottom, bottom) - max(anchor_top, top)
  520. if overlap > 0:
  521. candidates.append((overlap, node))
  522. return max(candidates, key=lambda item: item[0])[1] if candidates else None
  523. def parse_bounds(node: dict[str, str]) -> tuple[int, int, int, int] | None:
  524. match = re.fullmatch(r"\[(\d+),(\d+)]\[(\d+),(\d+)]", node.get("bounds", ""))
  525. return tuple(map(int, match.groups())) if match else None
  526. def node_center(node: dict[str, str]) -> tuple[int, int]:
  527. bounds = parse_bounds(node)
  528. if bounds is None:
  529. raise RuntimeError(f"invalid accessibility bounds: {node.get('bounds', '')}")
  530. left, top, right, bottom = bounds
  531. return (left + right) // 2, (top + bottom) // 2
  532. def wait_until_node_present(
  533. device: str,
  534. content_prefix: str,
  535. timeout_seconds: int,
  536. ) -> tuple[bool, str, dict[str, str] | None]:
  537. deadline = time.monotonic() + timeout_seconds
  538. latest = ""
  539. node = None
  540. while time.monotonic() < deadline:
  541. latest = ui_dump(device)
  542. node = find_node_by_content_prefix(latest, content_prefix)
  543. if node is not None and device_ready_for_ui(device):
  544. return True, latest, node
  545. time.sleep(0.5)
  546. return False, latest, node
  547. def wait_until_text_present(
  548. device: str,
  549. text: str,
  550. timeout_seconds: int,
  551. ) -> tuple[bool, str, dict[str, str] | None]:
  552. deadline = time.monotonic() + timeout_seconds
  553. latest = ""
  554. node = None
  555. while time.monotonic() < deadline:
  556. latest = ui_dump(device)
  557. node = find_node_by_text(latest, text)
  558. if node is not None and device_ready_for_ui(device):
  559. return True, latest, node
  560. time.sleep(0.25)
  561. return False, latest, node
  562. def find_node_by_class(dump: str, class_name: str) -> dict[str, str] | None:
  563. candidates = [
  564. node
  565. for node in parse_ui_nodes(dump)
  566. if node.get("package") == PACKAGE and node.get("class") == class_name
  567. ]
  568. return candidates[0] if candidates else None
  569. def find_node_containing_text(dump: str, text: str) -> dict[str, str] | None:
  570. candidates = [
  571. node
  572. for node in parse_ui_nodes(dump)
  573. if text in node.get("text", "")
  574. ]
  575. return candidates[0] if candidates else None
  576. def find_node_by_content_desc_near(
  577. dump: str,
  578. content_desc: str,
  579. anchor: dict[str, str],
  580. ) -> dict[str, str] | None:
  581. anchor_x, anchor_y = node_center(anchor)
  582. candidates = [
  583. node
  584. for node in parse_ui_nodes(dump)
  585. if node.get("package") == PACKAGE
  586. and node.get("content-desc") == content_desc
  587. and parse_bounds(node) is not None
  588. ]
  589. return min(
  590. candidates,
  591. key=lambda node: (
  592. abs(node_center(node)[0] - anchor_x) > 360,
  593. abs(node_center(node)[1] - anchor_y),
  594. abs(node_center(node)[0] - anchor_x),
  595. ),
  596. default=None,
  597. )
  598. def saved_theme_manifest(device: str, theme_name: str) -> tuple[str, dict[str, Any]] | None:
  599. result = adb(
  600. device,
  601. "exec-out",
  602. "run-as",
  603. PACKAGE,
  604. "find",
  605. "files/saved_themes",
  606. "-name",
  607. "manifest.json",
  608. "-type",
  609. "f",
  610. timeout=20,
  611. check=False,
  612. )
  613. for path in result.stdout.splitlines():
  614. manifest = adb(
  615. device,
  616. "exec-out",
  617. "run-as",
  618. PACKAGE,
  619. "cat",
  620. path.strip(),
  621. timeout=20,
  622. check=False,
  623. ).stdout
  624. try:
  625. data = json.loads(manifest)
  626. if data.get("name") == theme_name:
  627. return path.strip(), data
  628. except json.JSONDecodeError:
  629. continue
  630. return None
  631. def saved_theme_exists(device: str, theme_name: str) -> bool:
  632. return saved_theme_manifest(device, theme_name) is not None
  633. def saved_theme_has_fixture_asset(device: str, theme_name: str) -> bool:
  634. saved = saved_theme_manifest(device, theme_name)
  635. if saved is None:
  636. return False
  637. manifest_path, manifest = saved
  638. entry = (manifest.get("assets") or {}).get("background.light")
  639. if not isinstance(entry, str) or not entry:
  640. return False
  641. asset_path = f"{manifest_path.rsplit('/', 1)[0]}/{entry}"
  642. result = adb(
  643. device,
  644. "exec-out",
  645. "run-as",
  646. PACKAGE,
  647. "sha256sum",
  648. asset_path,
  649. timeout=20,
  650. check=False,
  651. )
  652. digest = result.stdout.split()
  653. expected = hashlib.sha256(THEME_ASSET_FIXTURE.read_bytes()).hexdigest()
  654. return bool(digest) and digest[0] == expected
  655. def wait_until_reader_ready(device: str, marker: str, timeout_seconds: int) -> tuple[bool, str, str]:
  656. deadline = time.monotonic() + timeout_seconds
  657. content = ""
  658. while time.monotonic() < deadline:
  659. content = reader_current_page(device)
  660. if marker in content and PACKAGE in window_state(device) and device_ready_for_ui(device):
  661. return True, content, content
  662. time.sleep(0.5)
  663. return False, content, content
  664. def wait_until_reader_content_changed(
  665. device: str,
  666. previous_content: str,
  667. timeout_seconds: int,
  668. ) -> tuple[bool, str, str]:
  669. deadline = time.monotonic() + timeout_seconds
  670. content = ""
  671. while time.monotonic() < deadline:
  672. content = reader_current_page(device)
  673. if content and content != previous_content:
  674. return True, content, content
  675. time.sleep(0.2)
  676. return False, content, content
  677. def resumed_activity(device: str) -> str:
  678. output = adb(device, "shell", "dumpsys", "activity", "activities", timeout=20, check=False).stdout
  679. match = re.search(r"topResumedActivity=.*?\s([\w.]+/[\w.$]+)\s", output)
  680. return match.group(1) if match else ""
  681. def wait_until_activity_resumed(
  682. device: str,
  683. activity: str,
  684. timeout_seconds: int,
  685. ) -> tuple[bool, str]:
  686. deadline = time.monotonic() + timeout_seconds
  687. resumed = ""
  688. component = f"{PACKAGE}/{activity}"
  689. while time.monotonic() < deadline:
  690. resumed = resumed_activity(device)
  691. if resumed == component and device_ready_for_ui(device):
  692. return True, resumed
  693. time.sleep(0.2)
  694. return False, resumed
  695. def application_pid(device: str) -> str | None:
  696. result = adb(device, "shell", "pidof", PACKAGE, timeout=10, check=False)
  697. return result.stdout.strip().split()[0] if result.returncode == 0 and result.stdout.strip() else None
  698. def screen_size(device: str) -> tuple[int, int]:
  699. output = adb(device, "shell", "wm", "size").stdout
  700. matches = re.findall(r"(\d+)x(\d+)", output)
  701. if not matches:
  702. raise RuntimeError(f"cannot determine screen size: {output.strip()}")
  703. width, height = matches[-1]
  704. return int(width), int(height)
  705. def capture_screenshot(device: str, path: Path) -> None:
  706. with path.open("wb") as output:
  707. result = subprocess.run(
  708. ["adb", "-s", device, "exec-out", "screencap", "-p"],
  709. cwd=PROJECT_ROOT,
  710. stdout=output,
  711. stderr=subprocess.PIPE,
  712. timeout=30,
  713. )
  714. if result.returncode != 0:
  715. path.unlink(missing_ok=True)
  716. raise RuntimeError(result.stderr.decode(errors="replace"))
  717. class ScreenRecorder:
  718. """Optional screenrecord wrapper for capturing transient UI (animations).
  719. Best-effort: any failure to start, stop, or pull leaves the run unaffected
  720. and simply produces no video artifact.
  721. """
  722. REMOTE = "/sdcard/legado-debug-transition.mp4"
  723. def __init__(self, device: str):
  724. self.device = device
  725. self.process: subprocess.Popen[bytes] | None = None
  726. self.saved: str | None = None
  727. self._stopped = False
  728. def start(self) -> None:
  729. adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
  730. self.process = subprocess.Popen(
  731. ["adb", "-s", self.device, "shell", "screenrecord", "--time-limit", "60", self.REMOTE],
  732. cwd=PROJECT_ROOT,
  733. stdout=subprocess.DEVNULL,
  734. stderr=subprocess.DEVNULL,
  735. )
  736. time.sleep(0.5)
  737. def stop(self, run_dir: Path, name: str = "transition.mp4") -> str | None:
  738. if self.process is None or self._stopped:
  739. return self.saved
  740. self._stopped = True
  741. # SIGINT lets screenrecord finalize a playable mp4 before it exits.
  742. pids = adb(self.device, "shell", "pidof", "screenrecord", timeout=10, check=False).stdout.split()
  743. for pid in pids:
  744. adb(self.device, "shell", "kill", "-INT", pid, timeout=10, check=False)
  745. try:
  746. self.process.wait(timeout=15)
  747. except subprocess.TimeoutExpired:
  748. self.process.kill()
  749. pull = subprocess.run(
  750. ["adb", "-s", self.device, "pull", self.REMOTE, str(run_dir / name)],
  751. cwd=PROJECT_ROOT,
  752. capture_output=True,
  753. text=True,
  754. timeout=60,
  755. )
  756. adb(self.device, "shell", "rm", "-f", self.REMOTE, timeout=15, check=False)
  757. if pull.returncode == 0 and (run_dir / name).is_file():
  758. self.saved = name
  759. return self.saved
  760. def collect_logcat(
  761. device: str,
  762. run_dir: Path,
  763. app_pids: Sequence[str] = (),
  764. session_id: str | None = None,
  765. ) -> str:
  766. logcat = adb(device, "logcat", "-d", "-v", "threadtime", timeout=45, check=False).stdout
  767. (run_dir / "logcat.txt").write_text(logcat, encoding="utf-8")
  768. session_logcat = ""
  769. unique_pids = list(dict.fromkeys(pid for pid in app_pids if pid))
  770. for pid in unique_pids:
  771. pid_logcat = adb(
  772. device,
  773. "logcat",
  774. "-d",
  775. f"--pid={pid}",
  776. "-v",
  777. "threadtime",
  778. timeout=45,
  779. check=False,
  780. ).stdout
  781. session_logcat += f"--- pid={pid} ---\n{pid_logcat}"
  782. if session_id:
  783. session_lines = [line for line in logcat.splitlines() if f"[session={session_id}]" in line]
  784. for line in session_lines:
  785. if line not in session_logcat:
  786. session_logcat += line + "\n"
  787. (run_dir / "session-logcat.txt").write_text(session_logcat, encoding="utf-8")
  788. crash = extract_crash(logcat, PACKAGE, unique_pids)
  789. (run_dir / "crash.txt").write_text(crash, encoding="utf-8")
  790. return logcat
  791. def extract_crash(
  792. logcat: str,
  793. package: str = PACKAGE,
  794. app_pids: Sequence[str] = (),
  795. ) -> str:
  796. lines = logcat.splitlines()
  797. chunks = []
  798. for index, line in enumerate(lines):
  799. chunk = lines[index : index + 100]
  800. if f"ANR in {package}" in line:
  801. chunks.extend(chunk)
  802. elif "FATAL EXCEPTION:" in line and any(package in item for item in chunk[:10]):
  803. chunks.extend(chunk)
  804. elif "Fatal signal " in line and any(
  805. re.search(rf"\b{re.escape(pid)}\b", line) for pid in app_pids
  806. ):
  807. chunks.extend(chunk)
  808. if not chunks:
  809. return ""
  810. return "\n".join(chunks).rstrip() + "\n"
  811. def parse_failure(crash: str) -> dict[str, Any] | None:
  812. if not crash:
  813. return None
  814. if "ANR in" in crash:
  815. return {
  816. "category": "anr",
  817. "confidence": "high",
  818. "detectedSignals": ["anr"],
  819. "stacktraceArtifact": "crash.txt",
  820. }
  821. if "Fatal signal " in crash:
  822. return {
  823. "category": "native_crash",
  824. "confidence": "high",
  825. "detectedSignals": ["fatal_signal"],
  826. "stacktraceArtifact": "crash.txt",
  827. }
  828. exceptions = list(
  829. re.finditer(r"(Caused by: )?([\w.$]+(?:Exception|Error))(?:: ([^\n]+))?", crash)
  830. )
  831. caused_by = [match for match in exceptions if match.group(1)]
  832. exception = caused_by[-1] if caused_by else (exceptions[0] if exceptions else None)
  833. failure: dict[str, Any] = {
  834. "category": "uncaught_exception",
  835. "confidence": "high",
  836. "detectedSignals": ["fatal_exception"],
  837. "stacktraceArtifact": "crash.txt",
  838. }
  839. if exception:
  840. failure["exceptionType"] = exception.group(2).split(".")[-1]
  841. failure["exceptionConfidence"] = "medium" if exceptions else "low"
  842. if exception.group(3):
  843. failure["message"] = exception.group(3).strip()
  844. frame = re.search(
  845. r"at (io\.legado\.app\.[\w.$]+)\.([\w$<>]+)\(([^():]+):(\d+)\)",
  846. crash,
  847. )
  848. if frame:
  849. failure["suspectedFrame"] = {
  850. "class": frame.group(1),
  851. "method": frame.group(2),
  852. "file": frame.group(3),
  853. "line": int(frame.group(4)),
  854. "confidence": "medium",
  855. }
  856. return failure
  857. def sha256_text(value: str) -> str:
  858. return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
  859. def make_summary(
  860. *,
  861. session_id: str,
  862. scenario: dict[str, Any],
  863. fixture: str,
  864. build: dict[str, Any],
  865. ready: bool,
  866. activity_visible: bool,
  867. content_changed: bool,
  868. action_count: int,
  869. failure: dict[str, Any] | None,
  870. ) -> tuple[dict[str, Any], int]:
  871. assertion_values = {
  872. "reader_ready": ready,
  873. "reader_activity_visible": activity_visible,
  874. "reader_content_changed": content_changed,
  875. "no_fatal_exception": failure is None,
  876. }
  877. return make_summary_from_assertions(
  878. session_id=session_id,
  879. scenario=scenario,
  880. fixture=fixture,
  881. build=build,
  882. assertion_values=assertion_values,
  883. action_count=action_count,
  884. failure=failure,
  885. )
  886. def make_summary_from_assertions(
  887. *,
  888. session_id: str,
  889. scenario: dict[str, Any],
  890. fixture: str,
  891. build: dict[str, Any],
  892. assertion_values: dict[str, bool],
  893. action_count: int,
  894. failure: dict[str, Any] | None,
  895. extra_artifacts: dict[str, str] | None = None,
  896. ) -> tuple[dict[str, Any], int]:
  897. assertions = [
  898. {"name": name, "passed": assertion_values[name]}
  899. for name in scenario["assertions"]
  900. ]
  901. if failure is not None:
  902. status, exit_code = "failed", EXIT_CRASH
  903. elif not all(item["passed"] for item in assertions):
  904. status, exit_code = "failed", EXIT_ASSERTION
  905. else:
  906. status, exit_code = "passed", 0
  907. summary: dict[str, Any] = {
  908. "schemaVersion": 1,
  909. "status": status,
  910. "exitCode": exit_code,
  911. "sessionId": session_id,
  912. "scenario": scenario["name"],
  913. "fixture": fixture,
  914. "build": build,
  915. "actions": {"performed": action_count},
  916. "assertions": assertions,
  917. "artifacts": {
  918. "metadata": "metadata.json",
  919. "logcat": "logcat.txt",
  920. "sessionLogcat": "session-logcat.txt",
  921. "crash": "crash.txt",
  922. },
  923. }
  924. if failure is not None:
  925. summary["failure"] = failure
  926. if extra_artifacts:
  927. summary["artifacts"].update(extra_artifacts)
  928. return summary, exit_code
  929. def error_summary(
  930. session_id: str,
  931. scenario_name: str,
  932. fixture: str,
  933. exit_code: int,
  934. category: str,
  935. message: str,
  936. build: dict[str, Any] | None = None,
  937. ) -> dict[str, Any]:
  938. summary: dict[str, Any] = {
  939. "schemaVersion": 1,
  940. "status": "failed",
  941. "exitCode": exit_code,
  942. "sessionId": session_id,
  943. "scenario": scenario_name,
  944. "fixture": fixture,
  945. "failure": {"category": category, "message": message},
  946. "artifacts": {"metadata": "metadata.json"},
  947. }
  948. if build is not None:
  949. summary["build"] = build
  950. return summary
  951. def cleanup_device(device: str) -> None:
  952. adb(device, "shell", "am", "force-stop", PACKAGE, timeout=20, check=False)
  953. # --------------------------------------------------------------------------- #
  954. # Capability registry: entries, actions, assertions.
  955. #
  956. # A scenario is fully described by data (JSON). Each domain action and each
  957. # assertion is a small handler registered here. Adding a bug scenario means
  958. # adding a handler + a JSON file, not editing execute() / validate_scenario().
  959. # Handlers only *record* raw observations into the Context; assertions are pure
  960. # reads of those observations.
  961. # --------------------------------------------------------------------------- #
  962. class Context:
  963. """Mutable per-run state shared across entry, actions, and assertions."""
  964. def __init__(
  965. self,
  966. device: str,
  967. run_dir: Path,
  968. scenario: dict[str, Any],
  969. fixture_data: dict[str, Any],
  970. metadata: dict[str, Any],
  971. action_repeat: int,
  972. ):
  973. self.device = device
  974. self.run_dir = run_dir
  975. self.scenario = scenario
  976. self.fixture_data = fixture_data
  977. self.metadata = metadata
  978. self.action_repeat = action_repeat
  979. self.app_pids: list[str] = []
  980. self.records: dict[str, Any] = {}
  981. self.state: dict[str, Any] = {}
  982. self.artifacts: dict[str, str] = {}
  983. self.action_count = 0
  984. self.assertion_values: dict[str, bool] = {}
  985. self.failure: dict[str, Any] | None = None
  986. def note_pid(self) -> None:
  987. pid = application_pid(self.device)
  988. if pid and pid not in self.app_pids:
  989. self.app_pids.append(pid)
  990. def screenshot(self, name: str, filename: str) -> None:
  991. capture_screenshot(self.device, self.run_dir / filename)
  992. self.artifacts[name] = filename
  993. class Entry:
  994. type: str = ""
  995. allowed_actions: set[str] = set()
  996. def prepare(self, ctx: Context) -> None:
  997. raise NotImplementedError
  998. def finalize(self, ctx: Context) -> None:
  999. raise NotImplementedError
  1000. class Action:
  1001. name: str = ""
  1002. params: dict[str, Any] = {}
  1003. def validate(self, action: dict[str, Any], index: int) -> None:
  1004. pass
  1005. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1006. raise NotImplementedError
  1007. def register_entry(name: str) -> Callable[[type], type]:
  1008. def decorate(cls: type) -> type:
  1009. instance = cls()
  1010. instance.type = name
  1011. ENTRIES[name] = instance
  1012. return cls
  1013. return decorate
  1014. def register_action(name: str) -> Callable[[type], type]:
  1015. def decorate(cls: type) -> type:
  1016. instance = cls()
  1017. instance.name = name
  1018. ACTIONS[name] = instance
  1019. return cls
  1020. return decorate
  1021. @register_entry("reader")
  1022. class ReaderEntry(Entry):
  1023. allowed_actions = {"turn_page"}
  1024. def prepare(self, ctx: Context) -> None:
  1025. ready, before_dump, before_content = wait_until_reader_ready(
  1026. ctx.device,
  1027. ctx.scenario["readyMarker"],
  1028. ctx.scenario.get("readyTimeoutSeconds", 30),
  1029. )
  1030. ctx.note_pid()
  1031. ctx.screenshot("beforeScreenshot", "before.png")
  1032. ctx.records["reader_ready"] = ready
  1033. ctx.records["reader_content_changed"] = False
  1034. ctx.records["content_change_duration_ms"] = 0
  1035. ctx.records["ui_before"] = before_dump
  1036. ctx.records["ui_after"] = before_dump
  1037. ctx.records["reader_content_before"] = before_content
  1038. ctx.records["reader_content_after"] = before_content
  1039. ctx.state["reader_content"] = before_content
  1040. def finalize(self, ctx: Context) -> None:
  1041. ctx.screenshot("afterScreenshot", "after.png")
  1042. windows = window_state(ctx.device)
  1043. # Presence of MainActivity in the window stack; the reader runs as a
  1044. # route inside MainActivity in this fork.
  1045. visible = PACKAGE in windows and MAIN_ACTIVITY in windows and device_ready_for_ui(ctx.device)
  1046. ctx.records["reader_activity_visible"] = visible
  1047. ctx.metadata.update(
  1048. {
  1049. "actionRepeat": ctx.action_repeat,
  1050. "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
  1051. "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
  1052. "readerContentBeforeSha256": sha256_text(ctx.records["reader_content_before"]),
  1053. "readerContentAfterSha256": sha256_text(ctx.records["reader_content_after"]),
  1054. "contentChangeDurationMs": ctx.records["content_change_duration_ms"],
  1055. }
  1056. )
  1057. @register_entry("rss_read")
  1058. class RssReadEntry(Entry):
  1059. allowed_actions = {"toggle_rss_day_night"}
  1060. def prepare(self, ctx: Context) -> None:
  1061. ready, dump, _ = wait_until_text_present(
  1062. ctx.device,
  1063. "DEBUG RSS 日夜切换",
  1064. ctx.scenario.get("readyTimeoutSeconds", 30),
  1065. )
  1066. ctx.note_pid()
  1067. ctx.screenshot("beforeScreenshot", "before.png")
  1068. ctx.records["rss_read_ready"] = ready
  1069. ctx.records["rss_theme_changed"] = False
  1070. ctx.records["rss_pid_before"] = application_pid(ctx.device)
  1071. ctx.records["ui_before"] = dump
  1072. def finalize(self, ctx: Context) -> None:
  1073. ctx.screenshot("afterScreenshot", "after.png")
  1074. ctx.metadata.update(
  1075. {
  1076. "rssPidBefore": ctx.records["rss_pid_before"],
  1077. "rssPidAfter": application_pid(ctx.device),
  1078. }
  1079. )
  1080. @register_entry("bookshelf")
  1081. class BookshelfEntry(Entry):
  1082. allowed_actions = {"open_fixture_book", "press_back"}
  1083. def prepare(self, ctx: Context) -> None:
  1084. book_name = ctx.fixture_data["book"]["name"]
  1085. ready, before_dump, book_node = wait_until_node_present(
  1086. ctx.device,
  1087. book_name,
  1088. ctx.scenario.get("readyTimeoutSeconds", 30),
  1089. )
  1090. ctx.note_pid()
  1091. ctx.screenshot("beforeScreenshot", "before.png")
  1092. ctx.records["bookshelf_ready"] = ready
  1093. ctx.records["reader_opened"] = False
  1094. ctx.records["returned_to_bookshelf"] = False
  1095. ctx.records["ui_before"] = before_dump
  1096. ctx.records["ui_after"] = before_dump
  1097. ctx.records["reader_dump"] = ""
  1098. ctx.records["reader_content"] = ""
  1099. ctx.state["book_name"] = book_name
  1100. ctx.state["book_node"] = book_node
  1101. ctx.state["returned_node"] = None
  1102. def finalize(self, ctx: Context) -> None:
  1103. ctx.screenshot("afterScreenshot", "after.png")
  1104. book_node = ctx.state.get("book_node")
  1105. returned_node = ctx.state.get("returned_node")
  1106. ctx.metadata.update(
  1107. {
  1108. "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
  1109. "readerUiSha256": sha256_text(ctx.records["reader_dump"]),
  1110. "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
  1111. "readerContentSha256": sha256_text(ctx.records["reader_content"]),
  1112. "bookshelfItemBefore": book_node.get("content-desc") if book_node else None,
  1113. "bookshelfItemAfter": returned_node.get("content-desc") if returned_node else None,
  1114. }
  1115. )
  1116. @register_entry("source_manage")
  1117. class SourceManageEntry(Entry):
  1118. allowed_actions = {"open_fixture_source", "predictive_back"}
  1119. def prepare(self, ctx: Context) -> None:
  1120. activity_ready, resumed = wait_until_activity_resumed(
  1121. ctx.device,
  1122. SOURCE_MANAGE_ACTIVITY,
  1123. ctx.scenario.get("readyTimeoutSeconds", 30),
  1124. )
  1125. source_name = "Legado Debug Fixture"
  1126. dump = ui_dump(ctx.device)
  1127. source_node = find_node_by_text(dump, source_name)
  1128. edit_node = find_node_in_same_row(
  1129. dump,
  1130. source_node,
  1131. f"{PACKAGE}:id/iv_edit",
  1132. ) if source_node else None
  1133. ctx.note_pid()
  1134. ctx.screenshot("beforeScreenshot", "before.png")
  1135. ctx.records["source_manage_ready"] = activity_ready and source_node is not None and edit_node is not None
  1136. ctx.records["source_edit_opened"] = False
  1137. ctx.records["returned_to_source_manage"] = False
  1138. ctx.records["resumed_before"] = resumed
  1139. ctx.records["resumed_after"] = resumed
  1140. ctx.state["source_name"] = source_name
  1141. ctx.state["edit_node"] = edit_node
  1142. def finalize(self, ctx: Context) -> None:
  1143. ctx.screenshot("afterScreenshot", "after.png")
  1144. ctx.metadata.update(
  1145. {
  1146. "resumedBefore": ctx.records["resumed_before"],
  1147. "resumedAfter": ctx.records["resumed_after"],
  1148. }
  1149. )
  1150. @register_entry("theme_config")
  1151. class ThemeConfigEntry(Entry):
  1152. allowed_actions = {
  1153. "export_current_theme",
  1154. "open_theme_manage",
  1155. "save_current_theme",
  1156. "select_day_background",
  1157. "apply_saved_theme",
  1158. "delete_saved_theme",
  1159. }
  1160. def prepare(self, ctx: Context) -> None:
  1161. ready, dump, _ = wait_until_text_present(
  1162. ctx.device,
  1163. "主题模式",
  1164. ctx.scenario.get("readyTimeoutSeconds", 30),
  1165. )
  1166. ctx.note_pid()
  1167. ctx.screenshot("beforeScreenshot", "before.png")
  1168. ctx.records["theme_config_ready"] = ready
  1169. ctx.records["theme_manage_opened"] = False
  1170. ctx.records["theme_save_succeeded"] = False
  1171. ctx.records["theme_export_succeeded"] = False
  1172. ctx.records["theme_save_failed"] = False
  1173. ctx.records["theme_export_failed"] = False
  1174. ctx.records["theme_failure_feedback"] = False
  1175. ctx.records["theme_save_asset_included"] = False
  1176. ctx.records["theme_export_asset_included"] = False
  1177. ctx.records["theme_day_background_copied"] = False
  1178. ctx.records["theme_saved_theme_applied"] = False
  1179. ctx.records["theme_saved_theme_deleted"] = False
  1180. ctx.records["ui_before"] = dump
  1181. ctx.records["ui_after"] = dump
  1182. def finalize(self, ctx: Context) -> None:
  1183. ctx.screenshot("afterScreenshot", "after.png")
  1184. ctx.metadata.update(
  1185. {
  1186. "uiBeforeSha256": sha256_text(ctx.records["ui_before"]),
  1187. "uiAfterSha256": sha256_text(ctx.records["ui_after"]),
  1188. "savedThemeName": ctx.state.get("theme_name"),
  1189. "exportedThemeName": ctx.state.get("export_name"),
  1190. }
  1191. )
  1192. @register_action("turn_page")
  1193. class TurnPage(Action):
  1194. params = {"direction": ["next", "previous"], "count": "1-100"}
  1195. def validate(self, action: dict[str, Any], index: int) -> None:
  1196. if action.get("direction") not in {"next", "previous"}:
  1197. raise ScenarioError(f"actions[{index}].direction must be next or previous")
  1198. count = action.get("count", 1)
  1199. if not isinstance(count, int) or count < 1 or count > 100:
  1200. raise ScenarioError(f"actions[{index}].count must be between 1 and 100")
  1201. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1202. if not ctx.records.get("reader_ready"):
  1203. return
  1204. width, height = screen_size(ctx.device)
  1205. direction = action["direction"]
  1206. count = action.get("count", 1) * ctx.action_repeat
  1207. x = round(width * (0.84 if direction == "next" else 0.16))
  1208. y = round(height * 0.5)
  1209. for _ in range(count):
  1210. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1211. ctx.action_count += 1
  1212. time.sleep(0.4)
  1213. started = time.monotonic()
  1214. changed, after_dump, after_content = wait_until_reader_content_changed(
  1215. ctx.device,
  1216. ctx.state["reader_content"],
  1217. ctx.scenario.get("contentChangeTimeoutSeconds", 5),
  1218. )
  1219. ctx.records["content_change_duration_ms"] = round((time.monotonic() - started) * 1000)
  1220. if changed:
  1221. ctx.records["reader_content_changed"] = True
  1222. ctx.state["reader_content"] = after_content
  1223. ctx.records["reader_content_after"] = after_content
  1224. ctx.records["ui_after"] = after_dump
  1225. @register_action("toggle_rss_day_night")
  1226. class ToggleRssDayNight(Action):
  1227. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1228. if not ctx.records.get("rss_read_ready"):
  1229. return
  1230. before_pid = application_pid(ctx.device)
  1231. debug_state(ctx.device, "toggleReaderDayNight")
  1232. ctx.action_count += 1
  1233. time.sleep(1.5)
  1234. after_pid = application_pid(ctx.device)
  1235. ctx.records["rss_theme_changed"] = before_pid is not None and before_pid == after_pid
  1236. @register_action("open_fixture_book")
  1237. class OpenFixtureBook(Action):
  1238. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1239. book_node = ctx.state.get("book_node")
  1240. if not (ctx.records.get("bookshelf_ready") and book_node is not None):
  1241. return
  1242. x, y = node_center(book_node)
  1243. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1244. ctx.action_count += 1
  1245. opened, reader_dump, reader_content = wait_until_reader_ready(
  1246. ctx.device,
  1247. ctx.scenario["readyMarker"],
  1248. ctx.scenario.get("readyTimeoutSeconds", 30),
  1249. )
  1250. ctx.records["reader_opened"] = opened
  1251. ctx.records["reader_dump"] = reader_dump
  1252. ctx.records["reader_content"] = reader_content
  1253. ctx.screenshot("readerScreenshot", "reader.png")
  1254. @register_action("press_back")
  1255. class PressBack(Action):
  1256. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1257. if not ctx.records.get("reader_opened"):
  1258. return
  1259. adb(ctx.device, "shell", "input", "keyevent", "BACK", timeout=10)
  1260. ctx.action_count += 1
  1261. returned, after_dump, returned_node = wait_until_node_present(
  1262. ctx.device,
  1263. ctx.state["book_name"],
  1264. ctx.scenario.get("readyTimeoutSeconds", 30),
  1265. )
  1266. ctx.records["returned_to_bookshelf"] = returned
  1267. ctx.records["ui_after"] = after_dump
  1268. ctx.state["returned_node"] = returned_node
  1269. @register_action("predictive_back")
  1270. class PredictiveBack(Action):
  1271. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1272. if not ctx.records.get("source_edit_opened"):
  1273. return
  1274. width, height = screen_size(ctx.device)
  1275. y = round(height * 0.5)
  1276. end_x = round(width * 0.45)
  1277. adb(
  1278. ctx.device,
  1279. "shell",
  1280. "input",
  1281. "swipe",
  1282. "1",
  1283. str(y),
  1284. str(end_x),
  1285. str(y),
  1286. "300",
  1287. timeout=10,
  1288. )
  1289. ctx.action_count += 1
  1290. returned, resumed = wait_until_activity_resumed(
  1291. ctx.device,
  1292. SOURCE_MANAGE_ACTIVITY,
  1293. ctx.scenario.get("readyTimeoutSeconds", 30),
  1294. )
  1295. dump = ui_dump(ctx.device)
  1296. source_present = find_node_by_text(dump, ctx.state["source_name"]) is not None
  1297. ctx.records["returned_to_source_manage"] = returned and source_present
  1298. ctx.records["resumed_after"] = resumed
  1299. @register_action("open_fixture_source")
  1300. class OpenFixtureSource(Action):
  1301. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1302. edit_node = ctx.state.get("edit_node")
  1303. if not (ctx.records.get("source_manage_ready") and edit_node is not None):
  1304. return
  1305. x, y = node_center(edit_node)
  1306. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1307. ctx.action_count += 1
  1308. opened, _ = wait_until_activity_resumed(
  1309. ctx.device,
  1310. SOURCE_EDIT_ACTIVITY,
  1311. ctx.scenario.get("readyTimeoutSeconds", 30),
  1312. )
  1313. ctx.records["source_edit_opened"] = opened
  1314. ctx.screenshot("editScreenshot", "edit.png")
  1315. @register_action("open_theme_manage")
  1316. class OpenThemeManage(Action):
  1317. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1318. if not ctx.records.get("theme_config_ready"):
  1319. return
  1320. node = None
  1321. dump = ""
  1322. width, height = screen_size(ctx.device)
  1323. for _ in range(6):
  1324. dump = ui_dump(ctx.device)
  1325. node = find_node_by_text(dump, "主题管理")
  1326. if node is not None:
  1327. break
  1328. adb(
  1329. ctx.device,
  1330. "shell",
  1331. "input",
  1332. "swipe",
  1333. str(width // 2),
  1334. str(round(height * 0.84)),
  1335. str(width // 2),
  1336. str(round(height * 0.48)),
  1337. "300",
  1338. timeout=10,
  1339. )
  1340. time.sleep(0.4)
  1341. if node is None:
  1342. ctx.records["ui_after"] = dump
  1343. return
  1344. x, y = node_center(node)
  1345. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1346. ctx.action_count += 1
  1347. opened, dump, _ = wait_until_text_present(
  1348. ctx.device,
  1349. "保存当前设置",
  1350. ctx.scenario.get("readyTimeoutSeconds", 30),
  1351. )
  1352. ctx.records["theme_manage_opened"] = opened
  1353. ctx.records["ui_after"] = dump
  1354. ctx.screenshot("manageScreenshot", "manage.png")
  1355. @register_action("select_day_background")
  1356. class SelectDayBackground(Action):
  1357. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1358. if not ctx.records.get("theme_config_ready"):
  1359. return
  1360. fixture = PROJECT_ROOT / "app" / "src" / "main" / "assets" / "web" / "uploadBook" / "img" / "close.png"
  1361. remote_fixture = "/sdcard/Download/DEBUG_THEME_BG.png"
  1362. remote_folder = f"/sdcard/Android/data/{PACKAGE}/files/backgroundImage"
  1363. adb(ctx.device, "shell", "rm", "-rf", remote_folder, timeout=10, check=False)
  1364. run_command(["adb", "-s", ctx.device, "push", str(fixture), remote_fixture], timeout=30)
  1365. adb(
  1366. ctx.device,
  1367. "shell",
  1368. "am",
  1369. "broadcast",
  1370. "-a",
  1371. "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
  1372. "-d",
  1373. f"file://{remote_fixture}",
  1374. timeout=15,
  1375. check=False,
  1376. )
  1377. day_node = None
  1378. dump = ""
  1379. width, height = screen_size(ctx.device)
  1380. for _ in range(8):
  1381. dump = ui_dump(ctx.device)
  1382. day_node = find_node_by_text(dump, "白天")
  1383. if day_node is not None:
  1384. break
  1385. adb(
  1386. ctx.device,
  1387. "shell",
  1388. "input",
  1389. "swipe",
  1390. str(width // 2),
  1391. str(round(height * 0.84)),
  1392. str(width // 2),
  1393. str(round(height * 0.48)),
  1394. "300",
  1395. timeout=10,
  1396. )
  1397. time.sleep(0.4)
  1398. if day_node is None:
  1399. ctx.records["ui_after"] = dump
  1400. return
  1401. x, y = node_center(day_node)
  1402. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1403. ctx.action_count += 1
  1404. deadline = time.monotonic() + 10
  1405. add_node = None
  1406. while time.monotonic() < deadline:
  1407. dump = ui_dump(ctx.device)
  1408. add_node = find_node_by_content_prefix(dump, "添加")
  1409. if add_node is not None:
  1410. break
  1411. time.sleep(0.2)
  1412. if add_node is None:
  1413. ctx.records["ui_after"] = dump
  1414. return
  1415. x, y = node_center(add_node)
  1416. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1417. ctx.action_count += 1
  1418. filename_node = None
  1419. deadline = time.monotonic() + 5
  1420. while time.monotonic() < deadline:
  1421. dump = ui_dump(ctx.device)
  1422. filename_node = find_node_by_text(dump, "DEBUG_THEME_BG.png")
  1423. if filename_node is not None:
  1424. break
  1425. more_node = find_node_by_content_prefix(
  1426. dump,
  1427. "更多",
  1428. "com.google.android.photopicker",
  1429. )
  1430. if more_node is not None:
  1431. x, y = node_center(more_node)
  1432. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1433. browse_node = None
  1434. browse_deadline = time.monotonic() + 5
  1435. while time.monotonic() < browse_deadline:
  1436. dump = ui_dump(ctx.device)
  1437. browse_node = find_node_by_text(
  1438. dump,
  1439. "浏览…",
  1440. "com.google.android.photopicker",
  1441. )
  1442. if browse_node is not None:
  1443. break
  1444. time.sleep(0.2)
  1445. if browse_node is not None:
  1446. x, y = node_center(browse_node)
  1447. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1448. break
  1449. time.sleep(0.25)
  1450. if filename_node is None:
  1451. deadline = time.monotonic() + 15
  1452. download_opened = False
  1453. while time.monotonic() < deadline:
  1454. dump = ui_dump(ctx.device)
  1455. filename_node = find_node_by_text(
  1456. dump,
  1457. "DEBUG_THEME_BG.png",
  1458. "com.google.android.documentsui",
  1459. )
  1460. if filename_node is not None:
  1461. break
  1462. download_node = find_node_by_text(
  1463. dump,
  1464. "下载",
  1465. "com.google.android.documentsui",
  1466. )
  1467. if download_node is not None and not download_opened:
  1468. x, y = node_center(download_node)
  1469. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1470. download_opened = True
  1471. elif download_opened:
  1472. adb(
  1473. ctx.device,
  1474. "shell",
  1475. "input",
  1476. "swipe",
  1477. str(width // 2),
  1478. str(round(height * 0.82)),
  1479. str(width // 2),
  1480. str(round(height * 0.40)),
  1481. "250",
  1482. timeout=10,
  1483. )
  1484. time.sleep(0.4)
  1485. if filename_node is None:
  1486. ctx.records["ui_after"] = dump
  1487. return
  1488. x, y = node_center(filename_node)
  1489. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1490. ctx.action_count += 1
  1491. wait_until_activity_resumed(
  1492. ctx.device,
  1493. MAIN_ACTIVITY,
  1494. ctx.scenario.get("readyTimeoutSeconds", 30),
  1495. )
  1496. expected_hash = hashlib.sha256(fixture.read_bytes()).hexdigest()
  1497. copied_path = ""
  1498. deadline = time.monotonic() + ctx.scenario.get("readyTimeoutSeconds", 30)
  1499. while time.monotonic() < deadline:
  1500. result = adb(
  1501. ctx.device,
  1502. "shell",
  1503. "find",
  1504. remote_folder,
  1505. "-type",
  1506. "f",
  1507. timeout=10,
  1508. check=False,
  1509. )
  1510. candidates = [line.strip() for line in result.stdout.splitlines() if line.strip()]
  1511. for candidate in candidates:
  1512. digest = adb(
  1513. ctx.device,
  1514. "shell",
  1515. "sha256sum",
  1516. candidate,
  1517. timeout=10,
  1518. check=False,
  1519. ).stdout.split()
  1520. if digest and digest[0] == expected_hash:
  1521. copied_path = candidate
  1522. break
  1523. if copied_path:
  1524. break
  1525. time.sleep(0.25)
  1526. adb(ctx.device, "shell", "rm", "-f", remote_fixture, timeout=10, check=False)
  1527. ctx.records["theme_day_background_copied"] = bool(copied_path)
  1528. ctx.state["day_background_path"] = copied_path
  1529. ctx.records["ui_after"] = ui_dump(ctx.device)
  1530. ctx.screenshot("backgroundScreenshot", "background.png")
  1531. @register_action("save_current_theme")
  1532. class SaveCurrentTheme(Action):
  1533. params = {"namePrefix": "non-empty ASCII string"}
  1534. def validate(self, action: dict[str, Any], index: int) -> None:
  1535. prefix = action.get("namePrefix")
  1536. if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", prefix):
  1537. raise ScenarioError(f"actions[{index}].namePrefix must be non-empty ASCII")
  1538. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1539. if not ctx.records.get("theme_manage_opened"):
  1540. return
  1541. theme_name = f"{action['namePrefix']}_{ctx.metadata['sessionId']}"
  1542. ctx.state["theme_name"] = theme_name
  1543. ready, _, save_current = wait_until_text_present(ctx.device, "保存当前设置", 10)
  1544. if not (ready and save_current):
  1545. return
  1546. x, y = node_center(save_current)
  1547. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1548. ctx.action_count += 1
  1549. deadline = time.monotonic() + 10
  1550. edit = None
  1551. dump = ""
  1552. while time.monotonic() < deadline:
  1553. dump = ui_dump(ctx.device)
  1554. edit = find_node_by_class(dump, "android.widget.EditText")
  1555. if edit is not None:
  1556. break
  1557. time.sleep(0.2)
  1558. if edit is None:
  1559. ctx.records["ui_after"] = dump
  1560. return
  1561. x, y = node_center(edit)
  1562. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1563. adb(ctx.device, "shell", "input", "text", theme_name, timeout=10)
  1564. dump = ui_dump(ctx.device)
  1565. confirm = find_node_by_text(dump, "保存")
  1566. if confirm is None:
  1567. ctx.records["ui_after"] = dump
  1568. return
  1569. if ctx.scenario.get("themeAsset") == "missing-content-uri":
  1570. arm_theme_content_failure(ctx.device)
  1571. x, y = node_center(confirm)
  1572. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1573. ctx.action_count += 1
  1574. deadline = time.monotonic() + ctx.scenario.get("readyTimeoutSeconds", 30)
  1575. succeeded = False
  1576. failure_feedback = ""
  1577. while time.monotonic() < deadline:
  1578. if saved_theme_exists(ctx.device, theme_name):
  1579. succeeded = True
  1580. break
  1581. dump = ui_dump(ctx.device)
  1582. failure_node = find_node_containing_text(dump, "主题保存失败")
  1583. if failure_node is not None:
  1584. failure_feedback = failure_node.get("text", "")
  1585. break
  1586. time.sleep(0.25)
  1587. ctx.records["theme_save_succeeded"] = succeeded
  1588. ctx.records["theme_save_failed"] = not succeeded and bool(failure_feedback)
  1589. ctx.records["theme_failure_feedback"] = bool(failure_feedback)
  1590. if failure_feedback:
  1591. ctx.state["theme_failure_feedback"] = failure_feedback
  1592. if succeeded and ctx.scenario.get("themeAsset") == "valid-file":
  1593. ctx.records["theme_save_asset_included"] = saved_theme_has_fixture_asset(
  1594. ctx.device,
  1595. theme_name,
  1596. )
  1597. ctx.records["ui_after"] = ui_dump(ctx.device)
  1598. @register_action("apply_saved_theme")
  1599. class ApplySavedTheme(Action):
  1600. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1601. theme_name = ctx.state.get("theme_name")
  1602. if not theme_name:
  1603. return
  1604. width, height = screen_size(ctx.device)
  1605. name_node = None
  1606. dump = ""
  1607. for _ in range(20):
  1608. dump = ui_dump(ctx.device)
  1609. name_node = find_node_by_text(dump, theme_name)
  1610. if name_node is not None:
  1611. break
  1612. adb(
  1613. ctx.device,
  1614. "shell",
  1615. "input",
  1616. "swipe",
  1617. str(width // 2),
  1618. str(round(height * 0.82)),
  1619. str(width // 2),
  1620. str(round(height * 0.38)),
  1621. "250",
  1622. timeout=10,
  1623. )
  1624. time.sleep(0.3)
  1625. if name_node is None:
  1626. ctx.records["ui_after"] = dump
  1627. return
  1628. x, y = node_center(name_node)
  1629. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1630. ctx.action_count += 1
  1631. dialog_ready, dump, confirm = wait_until_text_present(ctx.device, "应用", 10)
  1632. if not (dialog_ready and confirm):
  1633. ctx.records["ui_after"] = dump
  1634. return
  1635. x, y = node_center(confirm)
  1636. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1637. ctx.action_count += 1
  1638. deadline = time.monotonic() + 15
  1639. while time.monotonic() < deadline:
  1640. dump = ui_dump(ctx.device)
  1641. if find_node_by_text(dump, theme_name) is not None and find_node_by_text(dump, "应用") is None:
  1642. ctx.records["theme_saved_theme_applied"] = True
  1643. break
  1644. time.sleep(0.25)
  1645. ctx.records["ui_after"] = dump
  1646. @register_action("delete_saved_theme")
  1647. class DeleteSavedTheme(Action):
  1648. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1649. theme_name = ctx.state.get("theme_name")
  1650. if not (theme_name and ctx.records.get("theme_saved_theme_applied")):
  1651. return
  1652. ready, dump, name_node = wait_until_text_present(ctx.device, theme_name, 15)
  1653. if not (ready and name_node):
  1654. ctx.records["ui_after"] = dump
  1655. return
  1656. delete_node = find_node_by_content_desc_near(dump, "删除", name_node)
  1657. if delete_node is None:
  1658. ctx.records["ui_after"] = dump
  1659. return
  1660. x, y = node_center(delete_node)
  1661. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1662. ctx.action_count += 1
  1663. dialog_ready, dump, confirm = wait_until_text_present(ctx.device, "删除", 10)
  1664. if not (dialog_ready and confirm):
  1665. ctx.records["ui_after"] = dump
  1666. return
  1667. x, y = node_center(confirm)
  1668. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1669. ctx.action_count += 1
  1670. deadline = time.monotonic() + 15
  1671. while time.monotonic() < deadline:
  1672. if not saved_theme_exists(ctx.device, theme_name):
  1673. ctx.records["theme_saved_theme_deleted"] = True
  1674. break
  1675. time.sleep(0.25)
  1676. ctx.records["ui_after"] = ui_dump(ctx.device)
  1677. @register_action("export_current_theme")
  1678. class ExportCurrentTheme(Action):
  1679. params = {"namePrefix": "non-empty ASCII string"}
  1680. def validate(self, action: dict[str, Any], index: int) -> None:
  1681. prefix = action.get("namePrefix")
  1682. if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", prefix):
  1683. raise ScenarioError(f"actions[{index}].namePrefix must be non-empty ASCII")
  1684. def run(self, ctx: Context, action: dict[str, Any]) -> None:
  1685. if not ctx.records.get("theme_manage_opened"):
  1686. return
  1687. export_name = f"{action['namePrefix']}_{ctx.metadata['sessionId']}.zip"
  1688. ctx.state["export_name"] = export_name
  1689. remote_path = f"/sdcard/Download/{export_name}"
  1690. adb(ctx.device, "shell", "rm", "-f", remote_path, timeout=10, check=False)
  1691. export_current = None
  1692. width, height = screen_size(ctx.device)
  1693. for _ in range(15):
  1694. dump = ui_dump(ctx.device)
  1695. export_current = find_node_by_text(dump, "导出当前主题")
  1696. if export_current is not None:
  1697. break
  1698. adb(
  1699. ctx.device,
  1700. "shell",
  1701. "input",
  1702. "swipe",
  1703. str(width // 2),
  1704. str(round(height * 0.38)),
  1705. str(width // 2),
  1706. str(round(height * 0.82)),
  1707. "250",
  1708. timeout=10,
  1709. )
  1710. time.sleep(0.3)
  1711. if export_current is None:
  1712. return
  1713. x, y = node_center(export_current)
  1714. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1715. ctx.action_count += 1
  1716. deadline = time.monotonic() + 15
  1717. filename_node = None
  1718. while time.monotonic() < deadline:
  1719. dump = ui_dump(ctx.device)
  1720. filename_node = next(
  1721. (
  1722. node
  1723. for node in parse_ui_nodes(dump)
  1724. if node.get("package") == "com.google.android.documentsui"
  1725. and node.get("class") == "android.widget.EditText"
  1726. ),
  1727. None,
  1728. )
  1729. if filename_node is not None:
  1730. break
  1731. time.sleep(0.25)
  1732. if filename_node is None:
  1733. return
  1734. x, y = node_center(filename_node)
  1735. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1736. adb(ctx.device, "shell", "input", "keyevent", "KEYCODE_MOVE_END", timeout=10)
  1737. for _ in range(80):
  1738. adb(ctx.device, "shell", "input", "keyevent", "KEYCODE_DEL", timeout=10)
  1739. adb(ctx.device, "shell", "input", "text", export_name, timeout=10)
  1740. dump = ui_dump(ctx.device)
  1741. save_node = next(
  1742. (
  1743. node
  1744. for node in parse_ui_nodes(dump)
  1745. if node.get("package") == "com.google.android.documentsui"
  1746. and node.get("resource-id") == "android:id/button1"
  1747. ),
  1748. None,
  1749. )
  1750. if save_node is None:
  1751. ctx.records["ui_after"] = dump
  1752. return
  1753. if ctx.scenario.get("themeAsset") == "missing-content-uri":
  1754. arm_theme_content_failure(ctx.device)
  1755. x, y = node_center(save_node)
  1756. adb(ctx.device, "shell", "input", "tap", str(x), str(y), timeout=10)
  1757. ctx.action_count += 1
  1758. local_path = ctx.run_dir / "export.zip"
  1759. deadline = time.monotonic() + ctx.scenario.get("readyTimeoutSeconds", 30)
  1760. pulled = False
  1761. wait_until_activity_resumed(
  1762. ctx.device,
  1763. MAIN_ACTIVITY,
  1764. ctx.scenario.get("readyTimeoutSeconds", 30),
  1765. )
  1766. last_size = -1
  1767. stable_reads = 0
  1768. failure_feedback = ""
  1769. while time.monotonic() < deadline:
  1770. dump = ui_dump(ctx.device)
  1771. failure_node = find_node_containing_text(dump, "主题导出失败")
  1772. if failure_node is not None:
  1773. failure_feedback = failure_node.get("text", "")
  1774. size_result = adb(
  1775. ctx.device,
  1776. "shell",
  1777. "stat",
  1778. "-c",
  1779. "%s",
  1780. remote_path,
  1781. timeout=10,
  1782. check=False,
  1783. )
  1784. try:
  1785. size = int(size_result.stdout.strip())
  1786. except ValueError:
  1787. size = 0
  1788. stable_reads = stable_reads + 1 if size > 0 and size == last_size else 0
  1789. last_size = size
  1790. if stable_reads < 2:
  1791. time.sleep(0.25)
  1792. continue
  1793. pull = run_command(
  1794. ["adb", "-s", ctx.device, "pull", remote_path, str(local_path)],
  1795. timeout=30,
  1796. check=False,
  1797. )
  1798. if pull.returncode == 0 and local_path.is_file() and local_path.stat().st_size > 0:
  1799. pulled = True
  1800. break
  1801. time.sleep(0.5)
  1802. valid = False
  1803. asset_included = False
  1804. if pulled:
  1805. try:
  1806. with zipfile.ZipFile(local_path) as archive:
  1807. manifest = json.loads(archive.read(THEME_MANIFEST_PATH).decode("utf-8"))
  1808. valid = manifest.get("formatVersion") == 1 and isinstance(manifest.get("config"), dict)
  1809. entry = (manifest.get("assets") or {}).get("background.light")
  1810. if isinstance(entry, str) and entry in archive.namelist():
  1811. expected = hashlib.sha256(THEME_ASSET_FIXTURE.read_bytes()).hexdigest()
  1812. asset_included = hashlib.sha256(archive.read(entry)).hexdigest() == expected
  1813. except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile):
  1814. valid = False
  1815. adb(ctx.device, "shell", "rm", "-f", remote_path, timeout=10, check=False)
  1816. if valid:
  1817. ctx.artifacts["exportedTheme"] = "export.zip"
  1818. ctx.records["theme_export_succeeded"] = valid
  1819. ctx.records["theme_export_failed"] = not valid and bool(failure_feedback)
  1820. ctx.records["theme_failure_feedback"] = bool(failure_feedback)
  1821. if failure_feedback:
  1822. ctx.state["theme_failure_feedback"] = failure_feedback
  1823. ctx.records["theme_export_asset_included"] = asset_included
  1824. ctx.records["ui_after"] = ui_dump(ctx.device)
  1825. ASSERTIONS.update(
  1826. {
  1827. "reader_ready": lambda ctx: bool(ctx.records.get("reader_ready")),
  1828. "reader_activity_visible": lambda ctx: bool(ctx.records.get("reader_activity_visible")),
  1829. "reader_content_changed": lambda ctx: bool(ctx.records.get("reader_content_changed")),
  1830. "rss_read_ready": lambda ctx: bool(ctx.records.get("rss_read_ready")),
  1831. "rss_theme_changed": lambda ctx: bool(ctx.records.get("rss_theme_changed")),
  1832. "bookshelf_ready": lambda ctx: bool(ctx.records.get("bookshelf_ready")),
  1833. "reader_opened": lambda ctx: bool(ctx.records.get("reader_opened")),
  1834. "returned_to_bookshelf": lambda ctx: bool(ctx.records.get("returned_to_bookshelf")),
  1835. "source_manage_ready": lambda ctx: bool(ctx.records.get("source_manage_ready")),
  1836. "source_edit_opened": lambda ctx: bool(ctx.records.get("source_edit_opened")),
  1837. "returned_to_source_manage": lambda ctx: bool(ctx.records.get("returned_to_source_manage")),
  1838. "theme_config_ready": lambda ctx: bool(ctx.records.get("theme_config_ready")),
  1839. "theme_manage_opened": lambda ctx: bool(ctx.records.get("theme_manage_opened")),
  1840. "theme_save_succeeded": lambda ctx: bool(ctx.records.get("theme_save_succeeded")),
  1841. "theme_export_succeeded": lambda ctx: bool(ctx.records.get("theme_export_succeeded")),
  1842. "theme_day_background_copied": lambda ctx: bool(ctx.records.get("theme_day_background_copied")),
  1843. "theme_save_asset_included": lambda ctx: bool(ctx.records.get("theme_save_asset_included")),
  1844. "theme_export_asset_included": lambda ctx: bool(ctx.records.get("theme_export_asset_included")),
  1845. "theme_save_failed": lambda ctx: bool(ctx.records.get("theme_save_failed")),
  1846. "theme_export_failed": lambda ctx: bool(ctx.records.get("theme_export_failed")),
  1847. "theme_failure_feedback": lambda ctx: bool(ctx.records.get("theme_failure_feedback")),
  1848. "theme_open_input_stream_reached": lambda ctx: bool(ctx.records.get("theme_open_input_stream_reached")),
  1849. "theme_content_failure_armed": lambda ctx: bool(ctx.records.get("theme_content_failure_armed")),
  1850. "theme_saved_theme_applied": lambda ctx: bool(ctx.records.get("theme_saved_theme_applied")),
  1851. "theme_saved_theme_deleted": lambda ctx: bool(ctx.records.get("theme_saved_theme_deleted")),
  1852. "no_fatal_exception": lambda ctx: ctx.failure is None,
  1853. }
  1854. )
  1855. def build_capabilities() -> dict[str, Any]:
  1856. return {
  1857. "schemaVersion": 1,
  1858. "entries": {
  1859. name: {"allowedActions": sorted(handler.allowed_actions)}
  1860. for name, handler in sorted(ENTRIES.items())
  1861. },
  1862. "actions": {
  1863. name: {"params": handler.params}
  1864. for name, handler in sorted(ACTIONS.items())
  1865. },
  1866. "assertions": sorted(ASSERTIONS),
  1867. }
  1868. def execute(args: argparse.Namespace) -> int:
  1869. try:
  1870. scenario = load_scenario(args.scenario)
  1871. fixture = args.fixture or scenario["fixture"]
  1872. fixture_data = validate_fixture(fixture)
  1873. markers = {chapter["readyMarker"] for chapter in fixture_data["chapters"]}
  1874. if scenario["readyMarker"] not in markers:
  1875. raise ScenarioError("scenario readyMarker is not provided by the fixture")
  1876. if args.action_repeat < 1 or args.action_repeat > 100:
  1877. raise ScenarioError("--action-repeat must be between 1 and 100")
  1878. if scenario["entry"]["type"] == "bookshelf" and args.action_repeat != 1:
  1879. raise ScenarioError("--action-repeat is only supported by reader scenarios")
  1880. except ScenarioError as error:
  1881. print(json.dumps({"status": "failed", "exitCode": EXIT_SCENARIO, "error": str(error)}, ensure_ascii=False))
  1882. return EXIT_SCENARIO
  1883. try:
  1884. device = select_device(args.device)
  1885. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  1886. session_id, run_dir, _ = create_session(args.scenario, fixture, args.device)
  1887. summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
  1888. write_json(run_dir / "summary.json", summary)
  1889. print(run_dir / "summary.json")
  1890. return EXIT_ENVIRONMENT
  1891. session_id, run_dir, metadata = create_session(args.scenario, fixture, device)
  1892. device_state: dict[str, Any] | None = None
  1893. try:
  1894. device_state = prepare_device(device)
  1895. metadata["deviceStateBefore"] = device_state
  1896. write_json(run_dir / "metadata.json", metadata)
  1897. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  1898. summary = error_summary(session_id, args.scenario, fixture, EXIT_ENVIRONMENT, "environment", str(error))
  1899. write_json(run_dir / "summary.json", summary)
  1900. print(run_dir / "summary.json")
  1901. return EXIT_ENVIRONMENT
  1902. build: dict[str, Any] = {"status": "not_started"}
  1903. if args.skip_build:
  1904. build = {"status": "skipped"}
  1905. else:
  1906. try:
  1907. build, _ = build_and_install(device, run_dir)
  1908. except (RuntimeError, CommandError, subprocess.TimeoutExpired) as error:
  1909. if isinstance(error, CommandError):
  1910. build = {
  1911. "status": "failed",
  1912. "artifact": "build.log" if (run_dir / "build.log").exists() else "install.log",
  1913. }
  1914. summary = error_summary(session_id, args.scenario, fixture, EXIT_BUILD, "build_or_install", str(error), build)
  1915. cleanup_device(device)
  1916. restore_device(device, device_state)
  1917. write_json(run_dir / "summary.json", summary)
  1918. print(run_dir / "summary.json")
  1919. return EXIT_BUILD
  1920. entry_handler = ENTRIES[scenario["entry"]["type"]]
  1921. ctx = Context(device, run_dir, scenario, fixture_data, metadata, args.action_repeat)
  1922. recorder = ScreenRecorder(device) if scenario.get("record") else None
  1923. try:
  1924. if entry_handler.type == "theme_config":
  1925. # Activity result contracts can leave their external picker task
  1926. # above Legado after a failed run. Close both implementations used
  1927. # by this device before launching a deterministic theme scenario.
  1928. for picker_package in (
  1929. "com.google.android.documentsui",
  1930. "com.google.android.photopicker",
  1931. ):
  1932. adb(
  1933. device,
  1934. "shell",
  1935. "am",
  1936. "force-stop",
  1937. picker_package,
  1938. timeout=20,
  1939. check=False,
  1940. )
  1941. preferences = prepare_theme_asset(device, scenario) if entry_handler.type == "theme_config" else scenario.get("preferences")
  1942. launch_fixture(device, fixture, entry_handler.type, session_id, preferences)
  1943. ctx.note_pid()
  1944. entry_handler.prepare(ctx)
  1945. if recorder is not None:
  1946. recorder.start()
  1947. for action in scenario["actions"]:
  1948. ACTIONS[action["type"]].run(ctx, action)
  1949. if recorder is not None:
  1950. time.sleep(0.5) # tail so the settle after the last action is captured
  1951. video = recorder.stop(run_dir)
  1952. if video:
  1953. ctx.artifacts["transition"] = video
  1954. entry_handler.finalize(ctx)
  1955. ctx.note_pid()
  1956. logcat = collect_logcat(device, run_dir, ctx.app_pids, session_id)
  1957. ctx.failure = parse_failure(extract_crash(logcat, PACKAGE, ctx.app_pids))
  1958. toast_signal = (
  1959. f"Toast already killed. pkg={PACKAGE}" in logcat
  1960. or f"pkg={PACKAGE}" in logcat and "enqueueToast" in logcat
  1961. )
  1962. open_input_stream_reached = "INJECTED_THEME_ASSET_OPEN" in logcat
  1963. failure_armed = "INJECTED_THEME_ASSET_FAILURE_ARMED" in logcat
  1964. ctx.records["theme_open_input_stream_reached"] = open_input_stream_reached
  1965. ctx.records["theme_content_failure_armed"] = failure_armed
  1966. if (
  1967. scenario.get("themeAsset") in {"missing-file", "missing-content-uri"}
  1968. or scenario.get("expectThemeExportFailure")
  1969. ):
  1970. ctx.records["theme_failure_feedback"] = bool(
  1971. ctx.records.get("theme_failure_feedback") or toast_signal
  1972. )
  1973. if any(action["type"] == "save_current_theme" for action in scenario["actions"]):
  1974. ctx.records["theme_save_failed"] = (
  1975. not ctx.records.get("theme_save_succeeded")
  1976. and ctx.records["theme_failure_feedback"]
  1977. )
  1978. if any(action["type"] == "export_current_theme" for action in scenario["actions"]):
  1979. ctx.records["theme_export_failed"] = (
  1980. not ctx.records.get("theme_export_succeeded")
  1981. and ctx.records["theme_failure_feedback"]
  1982. )
  1983. metadata["themeOpenInputStreamReached"] = open_input_stream_reached
  1984. metadata["themeContentFailureArmed"] = failure_armed
  1985. metadata["themeFailureFeedbackObserved"] = bool(ctx.records.get("theme_failure_feedback"))
  1986. for name in scenario["assertions"]:
  1987. ctx.assertion_values[name] = ASSERTIONS[name](ctx)
  1988. metadata.update(
  1989. {
  1990. "finishedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
  1991. "appPids": ctx.app_pids,
  1992. }
  1993. )
  1994. write_json(run_dir / "metadata.json", metadata)
  1995. summary, exit_code = make_summary_from_assertions(
  1996. session_id=session_id,
  1997. scenario=scenario,
  1998. fixture=fixture,
  1999. build=build,
  2000. assertion_values=ctx.assertion_values,
  2001. action_count=ctx.action_count,
  2002. failure=ctx.failure,
  2003. extra_artifacts=ctx.artifacts,
  2004. )
  2005. except (RuntimeError, CommandError, subprocess.TimeoutExpired, OSError) as error:
  2006. try:
  2007. ctx.note_pid()
  2008. collect_logcat(device, run_dir, ctx.app_pids, session_id)
  2009. except Exception:
  2010. pass
  2011. summary = error_summary(
  2012. session_id,
  2013. args.scenario,
  2014. fixture,
  2015. EXIT_ENVIRONMENT,
  2016. "environment",
  2017. str(error),
  2018. build,
  2019. )
  2020. exit_code = EXIT_ENVIRONMENT
  2021. finally:
  2022. if recorder is not None:
  2023. recorder.stop(run_dir)
  2024. cleanup_device(device)
  2025. restore_device(device, device_state)
  2026. write_json(run_dir / "summary.json", summary)
  2027. print(run_dir / "summary.json")
  2028. return exit_code
  2029. def parse_args(argv: Sequence[str]) -> argparse.Namespace:
  2030. parser = argparse.ArgumentParser(description=__doc__)
  2031. subparsers = parser.add_subparsers(dest="command", required=True)
  2032. run_parser = subparsers.add_parser("run", help="build, install, and run one scenario")
  2033. run_parser.add_argument("scenario", help="scenario ID, for example reader/page-turn")
  2034. run_parser.add_argument("--device", help="adb device serial; optional when exactly one is ready")
  2035. run_parser.add_argument("--fixture", help="override the scenario fixture ID")
  2036. run_parser.add_argument("--action-repeat", type=int, default=1, help="repeat each domain action (default: 1)")
  2037. run_parser.add_argument(
  2038. "--skip-build",
  2039. action="store_true",
  2040. help="reuse the installed debug APK; skip Gradle build and install",
  2041. )
  2042. subparsers.add_parser("list", help="print supported entries, actions, and assertions")
  2043. return parser.parse_args(argv)
  2044. def main(argv: Sequence[str] | None = None) -> int:
  2045. args = parse_args(argv if argv is not None else sys.argv[1:])
  2046. if args.command == "run":
  2047. return execute(args)
  2048. if args.command == "list":
  2049. print(json.dumps(build_capabilities(), ensure_ascii=False, indent=2))
  2050. return 0
  2051. return EXIT_SCENARIO
  2052. if __name__ == "__main__":
  2053. raise SystemExit(main())