mobster-cli 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,952 @@
1
+ """result.json (``mobster.verify/1``) and report.html, which makes no requests and escapes every app string."""
2
+
3
+ import base64
4
+ import html
5
+ import os
6
+ import re
7
+ import time
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+
11
+ from .. import brand as tokens
12
+ from ..brand_fonts import FONT_FACES
13
+ from .overlay import pill_numbers
14
+
15
+ CSP = "default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'"
16
+
17
+
18
+ THUMB_WIDTH = 160
19
+ FRAME_WIDTH = 600 # px of the frames embedded at FRAME_CSS_WIDTH (2x)
20
+ VERDICTS = {"passed": ("Passed", "pass"), "failed": ("Failed", "fail"),
21
+ "needs_review": ("Needs review", "review"), "couldnt_run": ("Couldn't run", "none")}
22
+ MODES = {"smart": "Smart", "keyless": "Key-less", "launch": "Launch-only"}
23
+
24
+
25
+ FINDING_WORDS = {"warn": "Warning. ", "known": "In the baseline. "}
26
+
27
+
28
+ def _short(text, limit):
29
+ text = " ".join(str(text or "").split())
30
+ return text if len(text) <= limit else text[:limit - 1] + "…"
31
+
32
+
33
+ def _e(value):
34
+ return html.escape("" if value is None else str(value), quote=True)
35
+
36
+
37
+ def _png_url(path, limit=200_000):
38
+ """A small PNG the run saved (the app's icon) as a data URL, or None when there is none."""
39
+ try:
40
+ data = Path(path).read_bytes()
41
+ except OSError:
42
+ return None
43
+ return "data:image/png;base64," + base64.b64encode(data).decode() if data.startswith(b"\x89PNG") and len(
44
+ data) <= limit else None
45
+
46
+
47
+ def _data_url(path, max_width=None, quality=72):
48
+ path = Path(path)
49
+ try:
50
+ if max_width is None:
51
+ data = path.read_bytes()
52
+ else:
53
+ from PIL import Image
54
+ with Image.open(path) as image:
55
+ image = image.convert("RGB")
56
+ if image.width > max_width:
57
+ image = image.resize((max_width, max(1, round(image.height * max_width / image.width))),
58
+ Image.LANCZOS)
59
+ out = BytesIO()
60
+ image.save(out, "JPEG", quality=quality)
61
+ data = out.getvalue()
62
+ except (OSError, ValueError):
63
+ return None
64
+ return "data:image/jpeg;base64," + base64.b64encode(data).decode()
65
+
66
+
67
+ ICONS = {
68
+ "check": '<path d="M3.5 8.5l3 3 6-7"/>',
69
+ "cross": '<path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/>',
70
+ "review": '<path d="M8 4.5v4.25M8 11.4v.1"/>',
71
+ "dash": '<path d="M4.5 8h7"/>',
72
+ "phone": '<rect x="4.25" y="1.75" width="7.5" height="12.5" rx="2"/><path d="M7 4h2"/>',
73
+ "app": '<rect x="2.25" y="2.25" width="4.75" height="4.75" rx="1.4"/><rect x="9" y="2.25" width="4.75" '
74
+ 'height="4.75" rx="1.4"/><rect x="2.25" y="9" width="4.75" height="4.75" rx="1.4"/><rect x="9" y="9" '
75
+ 'width="4.75" height="4.75" rx="1.4"/>',
76
+ "sim": '<rect x="1.75" y="2.5" width="12.5" height="8.5" rx="1.5"/><path d="M5.5 13.5h5M8 11v2.5"/>',
77
+ "clock": '<circle cx="8" cy="8" r="6"/><path d="M8 4.75V8l2 1.5"/>',
78
+ "calendar": '<rect x="2.25" y="3" width="11.5" height="10.75" rx="2"/>'
79
+ '<path d="M2.25 6.5h11.5M5.5 1.5v3M10.5 1.5v3"/>',
80
+ "chevron": '<path d="M4.5 6.25l3.5 3.5 3.5-3.5"/>',
81
+ "coin": '<circle cx="8" cy="8" r="6"/><path d="M9.75 5.9C9.4 5.35 8.75 5 8 5c-1 0-1.75.6-1.75 1.4 0 1.9 3.5.9 3.5 '
82
+ '2.9 0 .85-.8 1.45-1.75 1.45-.8 0-1.5-.4-1.8-1M8 4v1M8 10.75v1"/>',
83
+ }
84
+ STATUS_ICON = {"pass": "check", "fail": "cross", "review": "review", "none": "dash"}
85
+ MARK_SVG = ('<span class="mark" aria-hidden="true">'
86
+ + "".join(tokens.MARK[theme].replace("<svg ", f'<svg class="mark-{theme}" ', 1)
87
+ for theme in ("light", "dark"))
88
+ + "</span>")
89
+
90
+
91
+ def icon(name, cls="icon"):
92
+ return (f'<svg class="{cls}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" '
93
+ f'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{ICONS[name]}</svg>')
94
+
95
+
96
+ def verdict_chip(word, tone):
97
+ return f'<span class="verdict {tone}">{icon(STATUS_ICON.get(tone, "dash"))}{_e(word)}</span>'
98
+
99
+
100
+ def brand(tool, ident=None):
101
+ return (f'<div class="brand">{MARK_SVG}<span class="brand-name">Mobster</span><span class="brand-tool">'
102
+ f'{_e(tool)}</span>' + (f'<span class="brand-id">{_e(ident)}</span>' if ident else "") + "</div>")
103
+
104
+
105
+ def pill(number, ok, on_frame=True):
106
+ tone = "pass" if ok else "fail"
107
+ mark = icon("check" if ok else "cross")
108
+ where = "" if on_frame else ", not outlined on the frame"
109
+ words = f"{number}, {'held' if ok else 'failed'}{where}"
110
+ return f'<span class="pill {tone}" role="img" aria-label="{_e(words)}">{mark}{_e(number)}</span>'
111
+
112
+
113
+ def has_node(item):
114
+ """Whether the overlay outlines an assertion's element: it matched one with an area (a suite's copy of the assertion
115
+ says so in ``outlined``)."""
116
+ if "outlined" in item:
117
+ return bool(item["outlined"])
118
+ return any(len(match.get("rect") or ()) == 4 and match["rect"][2] > 0 and match["rect"][3] > 0
119
+ for match in item.get("matches") or () if isinstance(match, dict))
120
+
121
+
122
+ def expectations(items, *, frame=True, quiet_held=False):
123
+ """The numbered list of assertions: ``items`` are result.json's assertions (index, text, ok, observed, matches)."""
124
+ numbered = pill_numbers([(item.get("ok"), has_node(item)) for item in items]) if frame else set()
125
+ rows = []
126
+ for position, item in enumerate(items):
127
+ ok = bool(item.get("ok"))
128
+ number = (item.get("index") if isinstance(item.get("index"), int) else position) + 1
129
+ observed = item.get("observed")
130
+ rows.append(f'<li class="{"held" if ok else "failed"}">{pill(number, ok, position in numbered)}'
131
+ f'<code class="assert">{_e(item.get("text"))}</code>'
132
+ + (f'<span class="observed">{_e(observed)}</span>' if observed and not (ok and quiet_held)
133
+ else "") + "</li>")
134
+ return '<ol class="expect">' + "".join(rows) + "</ol>"
135
+
136
+
137
+ def business_section(result):
138
+ business = result.get("business")
139
+ if not isinstance(business, dict):
140
+ return ""
141
+ expected = {item.get("field"): item.get("equals") for item in
142
+ (result.get("check") or {}).get("business", {}).get("expect", ()) if isinstance(item, dict)}
143
+
144
+ def shown(value):
145
+ return str(value).lower() if value is None or type(value) is bool else f"[{type(value).__name__} redacted]"
146
+
147
+ rows = []
148
+ for item in business.get("assertions", ()):
149
+ field = item.get("field")
150
+ status = "held" if item.get("ok") else "failed"
151
+ observed = shown(item.get("observed")) if item.get("present") else "missing"
152
+ rows.append(f'<li class="{status}"><code class="assert">{_e(field)}</code>'
153
+ f'<span class="observed">expected {_e(shown(expected.get(field)))}; '
154
+ f'observed {_e(observed)}</span></li>')
155
+ lifecycle = (f'Setup: {business.get("setup", "not_started")}; '
156
+ f'Observation: {business.get("status", "unavailable")}; '
157
+ f'Cleanup: {business.get("cleanup", "not_needed")}')
158
+ detail = f'<p>{_e(lifecycle)}</p>'
159
+ if business.get("error"):
160
+ detail += f'<p>{_e(business["error"])}</p>'
161
+ if business.get("primary"):
162
+ detail += f'<p>Primary result: {_e(business["primary"].get("verdict"))} '
163
+ detail += f'({_e(business["primary"].get("class"))}).</p>'
164
+ return ('<h2>Persisted app state</h2><div class="card">' + detail
165
+ + ('<ol class="expect">' + "".join(rows) + "</ol>" if rows else "") + "</div>")
166
+
167
+
168
+ EVIDENCE_TEXT = 300 # characters of a log line the page shows; logs/app.log has the whole line
169
+
170
+
171
+ def _seconds(ms):
172
+ return f"{(ms or 0) / 1000:.1f} s"
173
+
174
+
175
+ def crash_title(crash):
176
+ """'Crashed: EXC_BREAKPOINT (SIGTRAP)', or 'Crashed: SIGTRAP' before the report is in."""
177
+ what = " ".join(filter(None, [crash.get("type"), f"({crash['signal']})" if crash.get("type") and crash.get("signal")
178
+ else crash.get("signal")]))
179
+ return "Crashed" + (f": {what}" if what else "")
180
+
181
+
182
+ def evidence_pane(evidence, *, step=None):
183
+ from .crashes import frame_text
184
+ evidence = evidence if isinstance(evidence, dict) else {}
185
+ crash, window, log = evidence.get("crash"), evidence.get("window"), evidence.get("log")
186
+ if not crash and not window:
187
+ return ""
188
+ at = evidence.get("atMs")
189
+ after = f", step {step}" if step else ""
190
+ out = ['<div class="evidence">']
191
+ if crash:
192
+ link = (f'<a class="ev-file" href="{_e(crash["report"])}">Crash report</a>' if crash.get("report")
193
+ else "")
194
+ out.append(f'<div class="ev-part fail"><p class="ev-head"><strong>{_e(crash_title(crash))}</strong>'
195
+ f'<span>at {_e(_seconds(crash.get("atMs")))}{_e(after)}</span>{link}</p>')
196
+ if crash.get("message"):
197
+ out.append(f'<p class="ev-msg">{_e(crash["message"])}</p>')
198
+ frames = crash.get("frames") or []
199
+ if frames:
200
+ out.append('<ol class="frames" aria-label="The crashed thread\'s top frames">')
201
+ for frame in frames:
202
+ where = f"{frame['file']}:{frame['line']}" if frame.get("file") and frame.get("line") else ""
203
+ symbol = frame_text({**frame, "file": None}, image=False)
204
+ out.append(f'<li class="{"own" if frame.get("own") else ""}"><span class="fn">{_e(frame.get("index"))}'
205
+ f'</span><span class="fimg">{_e(frame.get("image"))}</span><span><code>{_e(symbol)}</code>'
206
+ + (f'<span class="floc">{_e(where)}</span>' if where else "") + "</span></li>")
207
+ out.append("</ol>")
208
+ if crash.get("symbolicated"):
209
+ out.append('<p class="ev-note">Your frames are named from the dSYM in .mobster/build.</p>')
210
+ else:
211
+ out.append('<p class="ev-note">The app exited on this signal. macOS hadn\'t written its crash report '
212
+ "when the run ended, so there is no stack here.</p>")
213
+ out.append("</div>")
214
+ if window is not None:
215
+ lines = window.get("lines") or []
216
+ link = f'<a class="ev-file" href="{_e(log["path"])}">Full log</a>' if log and log.get("path") else ""
217
+ count = f"{len(lines)} line{'' if len(lines) == 1 else 's'}" + (
218
+ f" of {log['lines']}" if log and log.get("lines") and log["lines"] > len(lines) else "")
219
+ out.append(f'<div class="ev-part"><p class="ev-head"><strong>Log, 10 s either side</strong><span>{_e(count)}'
220
+ f"</span>{link}</p>")
221
+ if lines:
222
+ out.append('<ol class="ev-lines">')
223
+ marked = False
224
+ for line in lines:
225
+ if not marked and at is not None and (line.get("atMs") or 0) > at:
226
+ out.append(_at_row(crash, at))
227
+ marked = True
228
+ level = line.get("level") or ""
229
+ text = str(line.get("text") or "")
230
+ text = text if len(text) <= EVIDENCE_TEXT else text[:EVIDENCE_TEXT] + "…"
231
+ out.append(f'<li class="{_e(level)}"><span class="t">{_e(f"{(line.get("atMs") or 0) / 1000:.3f}")}'
232
+ f'</span><span class="lv">{_e(level)}</span><span class="tx">{_e(text)}</span></li>')
233
+ if not marked and at is not None:
234
+ out.append(_at_row(crash, at))
235
+ out.append("</ol>")
236
+ else:
237
+ out.append('<p class="ev-note">The app logged nothing in those 20 seconds.</p>')
238
+ out.append("</div>")
239
+ out.append("</div>")
240
+ return "".join(out)
241
+
242
+
243
+ SIDES = {"app": "App problem", "check": "Check problem", "environment": "Environment problem"}
244
+
245
+
246
+ def cause_line(proof):
247
+ """'App problem: app crashed', for a run that didn't pass."""
248
+ cause = (proof or {}).get("cause")
249
+ if not isinstance(cause, dict) or not cause.get("line"):
250
+ return ""
251
+ return f'<p class="cause"><strong>{_e(SIDES.get(cause.get("side"), "Cause"))}</strong> {_e(cause["line"])}</p>'
252
+
253
+
254
+ def _clock(seconds):
255
+ seconds = int(seconds)
256
+ return f"{seconds // 60}:{seconds % 60:02d}"
257
+
258
+
259
+ def proof_pane(entry, *, clip=None, video=None, title="", opened=False):
260
+ """One step's proof: its clip (when the recording is known), the app's log lines in its span and the requests the
261
+ log shows. ``clip`` is (from, to) seconds into ``video``."""
262
+ if not isinstance(entry, dict):
263
+ return ""
264
+ log, network = entry.get("log") or {}, entry.get("network") or {}
265
+ lines, rows = log.get("lines") or [], network.get("rows") or []
266
+ if not lines and not rows and not (clip and video):
267
+ return ""
268
+ counts = [f"{log.get('count') or 0} log line{'' if log.get('count') == 1 else 's'}",
269
+ f"{network.get('count') or 0} request{'' if network.get('count') == 1 else 's'}"]
270
+ if clip and video:
271
+ counts.insert(0, f"clip {_clock(clip[0])} to {_clock(clip[1])}")
272
+ out = [f'<details class="proof"{" open" if opened else ""}><summary>{_e(title or "Proof")}'
273
+ f"<span>{_e(', '.join(counts))}</span></summary>"]
274
+ if clip and video:
275
+ out.append(f'<p class="ev-note"><a href="{_e(video)}#t={clip[0]},{clip[1]}">Play this step</a> '
276
+ f"({clip[0]} to {clip[1]} s of the recording)</p>")
277
+ if rows:
278
+ out.append('<table class="net"><tbody>')
279
+ for row in rows:
280
+ size = f"{row['bytes']:,} B" if isinstance(row.get("bytes"), int) else ""
281
+ took = f"{row['durationMs']} ms" if isinstance(row.get("durationMs"), int) else ""
282
+ out.append(f'<tr><td>{_e(row.get("method") or "")}</td><td>{_e(row.get("host"))}</td>'
283
+ f'<td>{_e(row.get("status") or "")}</td><td>{_e(took)}</td><td>{_e(size)}</td></tr>')
284
+ out.append("</tbody></table>")
285
+ if lines:
286
+ out.append('<ol class="ev-lines">')
287
+ for line in lines:
288
+ text = str(line.get("text") or "")
289
+ text = text if len(text) <= EVIDENCE_TEXT else text[:EVIDENCE_TEXT] + "…"
290
+ out.append(f'<li class="{_e(line.get("level") or "")}"><span class="t">'
291
+ f'{_e(f"{(line.get("atMs") or 0) / 1000:.3f}")}</span><span class="lv">'
292
+ f'{_e(line.get("level") or "")}</span><span class="tx">{_e(text)}</span></li>')
293
+ out.append("</ol>")
294
+ out.append("</details>")
295
+ return "".join(out)
296
+
297
+
298
+ def _at_row(crash, at):
299
+ words = ("Crash" if crash else "Failure") + f" at {at / 1000:.3f} s"
300
+ return f'<li class="at" role="separator">{_e(words)}</li>'
301
+
302
+
303
+ OVERLAY_LEGEND = ('<p class="legend" aria-hidden="true"><span class="key read"></span>Read by Mobster '
304
+ '<span class="key held"></span>Held <span class="key failed"></span>Failed</p>')
305
+
306
+
307
+ def command(text):
308
+ """A shell command, highlighted as on mobster.dev: flags violet, quoted strings green, arguments grey."""
309
+ if not text:
310
+ return ""
311
+ tokens = re.findall(r"""'[^']*'|"(?:\\.|[^"\\])*"|\S+|\s+""", str(text))
312
+ out, words = [], 0
313
+ for token in tokens:
314
+ if token.isspace():
315
+ out.append(_e(token))
316
+ continue
317
+ words += 1
318
+ if token.startswith("-"):
319
+ kind = "flag"
320
+ elif token[:1] in "'\"":
321
+ kind = "string"
322
+ elif words <= 2:
323
+ kind = "cmd"
324
+ else:
325
+ kind = "arg"
326
+ out.append(f'<span class="hl-{kind}">{_e(token)}</span>')
327
+ return ('<pre class="cmd"><span class="hl-prompt" aria-hidden="true">$ </span><code>' + "".join(out)
328
+ + "</code></pre>")
329
+
330
+
331
+ def dots(text):
332
+ """A device's label for the page: 'iPhone 17 Pro · iOS 26.4' reads 'iPhone 17 Pro, iOS 26.4'."""
333
+ # The suite keeps the dotted label (JUnit names it); the page has no middle dots.
334
+ return None if text is None else str(text).replace(" · ", ", ")
335
+
336
+
337
+ def home(text):
338
+ """Paths under the home folder as ~/…, so a shared report doesn't spell out whose Mac ran it."""
339
+ if not text:
340
+ return text
341
+ folder = str(Path.home())
342
+ # Not inside quotes: a shell doesn't expand a quoted ~.
343
+ return re.sub(r"(^|[\s=])" + re.escape(folder) + r"(?=/|$|\s)", lambda m: m.group(1) + "~", str(text))
344
+
345
+
346
+ def when(ms=None, run_id=None):
347
+ """'8 Oct 2026, 12:59' from epoch ms, else from a run id's leading YYYYMMDD-HHMMSS."""
348
+ stamp = None
349
+ if ms:
350
+ stamp = time.localtime(ms / 1000)
351
+ elif run_id:
352
+ match = re.match(r"(\d{8}-\d{6})", str(run_id))
353
+ if match:
354
+ try:
355
+ stamp = time.strptime(match.group(1), "%Y%m%d-%H%M%S")
356
+ except ValueError:
357
+ stamp = None
358
+ if stamp is None:
359
+ return None
360
+ return f"{stamp.tm_mday} {time.strftime('%b %Y, %H:%M', stamp)}"
361
+
362
+
363
+ def seconds_text(seconds):
364
+ seconds = float(seconds or 0)
365
+ if seconds < 60:
366
+ return f"{seconds:.1f} s" if seconds < 10 and seconds % 1 else f"{round(seconds)} s"
367
+ minutes, rest = divmod(round(seconds), 60)
368
+ return f"{minutes} min {rest:02d} s"
369
+
370
+
371
+ # Tokens only (test_brand: no colour literals); pills keep the light tones in both schemes, as the overlay does.
372
+ COMPONENTS = """
373
+ :root { --pass: var(--green); --fail: var(--coral); --review: var(--violet-text); --none: var(--ink-2);
374
+ --read: var(--violet); --bezel: var(--ink);
375
+ --fill-quiet: color-mix(in srgb, var(--ink) 7%, transparent);
376
+ --fill-input: color-mix(in srgb, var(--ink) 5%, transparent);
377
+ --hover: color-mix(in srgb, var(--ink) 6%, transparent);
378
+ --pass-solid: PASS_SOLID; --fail-solid: FAIL_SOLID; --review-solid: var(--violet-solid); --none-solid: var(--ink-3); }
379
+ @media (prefers-color-scheme: dark) { :root { --bezel: var(--line-strong); } }
380
+ * { box-sizing: border-box; }
381
+ html { background: var(--paper); -webkit-text-size-adjust: 100%; }
382
+ body { margin: 0; background: var(--paper); color: var(--ink); font: 400 14px/22px var(--font-sans);
383
+ font-synthesis: none; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
384
+ main { max-width: 1040px; margin: 0 auto; padding: 32px 24px 64px; }
385
+ :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
386
+ a:focus-visible { border-radius: var(--radius-xs); }
387
+ ::selection { background: var(--violet-wash); color: var(--ink); }
388
+ .sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
389
+ clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
390
+ .icon { width: 16px; height: 16px; flex: none; }
391
+ code, pre, .mono { font-family: var(--font-mono); }
392
+ a { color: inherit; text-decoration-line: underline; text-decoration-thickness: 1px; text-underline-offset: .2em;
393
+ text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
394
+ transition: text-decoration-color var(--duration-medium) var(--ease-standard); }
395
+ a:hover { text-decoration-color: currentColor; }
396
+ .pass { --tone: var(--pass); --solid: var(--pass-solid); } .fail { --tone: var(--fail); --solid: var(--fail-solid); }
397
+ .review { --tone: var(--review); --solid: var(--review-solid); } .none { --tone: var(--none); --solid: var(--none-solid); }
398
+ .warn { --tone: var(--amber); }
399
+
400
+ /* The page's head: the mark and the tool, the verdict, the one line that matters, then the facts. */
401
+ .brand { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; color: var(--ink-3);
402
+ min-width: 0; }
403
+ .mark { display: block; width: 22px; height: 22px; flex: none; }
404
+ .mark svg { display: block; width: 100%; height: 100%; }
405
+ .mark .mark-dark { display: none; }
406
+ @media (prefers-color-scheme: dark) { .mark .mark-light { display: none; } .mark .mark-dark { display: block; } }
407
+ .brand-name { margin-left: -2px; font-size: 15px; font-weight: var(--weight-medium); letter-spacing: -.01em;
408
+ color: var(--ink); }
409
+ .brand-tool::before { content: "/"; margin-right: 8px; color: var(--ink-4); }
410
+ .brand-id { margin-left: auto; font: 12px/18px var(--font-mono); color: var(--ink-3); overflow-wrap: anywhere;
411
+ text-align: right; }
412
+ header.head { padding-top: 32px; }
413
+ .chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
414
+ .verdict { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; line-height: 20px;
415
+ font-weight: var(--weight-medium); color: var(--tone); }
416
+ .verdict .icon { stroke-width: 2; }
417
+ .tag { display: inline-flex; align-items: center; gap: 6px; height: 20px; padding: 0 7px;
418
+ border-radius: var(--radius-xs); font-size: 12px; line-height: 18px; font-weight: var(--weight-medium);
419
+ color: var(--ink-2); background: var(--fill-quiet); white-space: nowrap; }
420
+ .tag.tone::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--tone); }
421
+ .tag .icon { width: 12px; height: 12px; stroke-width: 2; color: var(--tone, var(--ink-3)); }
422
+ h1 { font-size: 28px; line-height: 36px; letter-spacing: -.015em; font-weight: var(--weight-medium);
423
+ margin: 12px 0 4px; overflow-wrap: anywhere; text-wrap: balance; }
424
+ h1 .quiet { color: var(--ink-3); margin-left: 12px; }
425
+ .lede { margin: 0; font-size: 16px; line-height: 24px; color: var(--ink-2); overflow-wrap: anywhere; }
426
+ ul.meta { display: flex; flex-wrap: wrap; gap: 8px 20px; list-style: none; margin: 16px 0 0; padding: 0;
427
+ font-size: 13px; line-height: 20px; color: var(--ink-2); }
428
+ ul.meta li { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow-wrap: anywhere; }
429
+ ul.meta .icon { color: var(--ink-3); }
430
+ /* The app's own icon, read from its .app (sim/apps.py icon_png), in the squircle iOS draws it in, with the app's
431
+ hairline ring (10% ink) so a dark icon keeps its edge on Night as in the Mac app. */
432
+ ul.meta .app-icon { width: 16px; height: 16px; border-radius: 23%; flex: none;
433
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 10%, transparent); }
434
+ header.head pre.cmd { margin-top: 20px; }
435
+
436
+ /* Sections and cards: the app's flat card fill, no outline on Paper, a faint ring on Night. */
437
+ section { margin-top: 40px; }
438
+ h2 { display: flex; align-items: baseline; gap: 8px; margin: 0 0 12px; font-size: 16px; line-height: 24px;
439
+ font-weight: var(--weight-title); letter-spacing: -.012em; }
440
+ h2 .count { font-size: 14px; font-weight: var(--weight-body); color: var(--ink-3);
441
+ font-variant-numeric: tabular-nums; }
442
+ .card { background: var(--card); border-radius: var(--radius-card); box-shadow: var(--shadow-card); }
443
+ .empty { color: var(--ink-3); margin: 0; }
444
+
445
+ /* A shell command in a sunken well, highlighted like mobster.dev's code. */
446
+ pre.cmd { margin: 0; width: fit-content; max-width: 100%; padding: 10px 16px; background: var(--sunken);
447
+ border-radius: var(--radius-input); font: 13px/20px var(--font-mono); color: var(--ink); white-space: pre-wrap;
448
+ overflow-wrap: break-word; }
449
+ pre.cmd code { font: inherit; }
450
+ .hl-prompt { color: var(--ink-3); user-select: none; -webkit-user-select: none; }
451
+ .hl-flag { color: var(--violet-text); } .hl-string { color: var(--green); } .hl-arg { color: var(--ink-2); }
452
+ .hl-cmd { color: var(--ink); }
453
+
454
+ /* The numbered assertions, rows inside the card; the numbers are the overlay's pills. */
455
+ ol.expect { list-style: none; margin: 0; padding: 4px; }
456
+ ol.expect li { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; align-items: baseline;
457
+ padding: 12px 16px; border-radius: var(--radius-container); }
458
+ ol.expect li.failed { background: color-mix(in srgb, var(--fail) 7%, transparent); }
459
+ code.assert { font: 13px/20px var(--font-mono); color: var(--ink); overflow-wrap: break-word; min-width: 0; }
460
+ .observed { grid-column: 2; font-size: 13px; line-height: 20px; color: var(--ink-3); overflow-wrap: break-word; }
461
+ li.failed .observed { color: var(--ink-2); }
462
+ .pill { position: relative; top: -1px; display: inline-flex; align-items: center; gap: 3px; height: 20px;
463
+ min-width: 34px; padding: 0 8px 0 6px; border-radius: var(--radius-full); background: var(--solid); color: white;
464
+ font: var(--weight-medium) 12px/1 var(--font-sans); font-variant-numeric: tabular-nums;
465
+ print-color-adjust: exact; -webkit-print-color-adjust: exact; }
466
+ .pill .icon { width: 11px; height: 11px; stroke-width: 2.4; }
467
+
468
+ /* A frame, in a phone-shaped bezel (drawn hardware keeps its own radii). */
469
+ .phone { padding: 6px; border-radius: 46px; background: var(--bezel); box-shadow: var(--shadow-shot); }
470
+ .phone img { display: block; width: 100%; height: auto; border-radius: 40px; background: var(--sunken); }
471
+ .legend { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px 6px;
472
+ margin: 12px 0 0; font-size: 12px; line-height: 18px; color: var(--ink-3); }
473
+ .key { display: inline-block; width: 12px; height: 9px; border-radius: 3px; border: 1.5px solid; }
474
+ .legend .key:not(:first-child) { margin-left: 8px; }
475
+ .key.read { border-color: color-mix(in srgb, var(--read) 60%, transparent); }
476
+ .key.held { border-color: var(--pass-solid); background: color-mix(in srgb, var(--pass-solid) 14%, transparent); }
477
+ .key.failed { border-color: var(--fail-solid); background: color-mix(in srgb, var(--fail-solid) 14%, transparent); }
478
+
479
+ /* Facts, as a quiet grid. */
480
+ dl.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(176px, 1fr)); gap: 16px 24px; margin: 0;
481
+ padding: 20px; }
482
+ dl.facts div { min-width: 0; }
483
+ dl.facts div.wide { grid-column: 1 / -1; }
484
+ dl.facts dt { font-size: 12px; line-height: 18px; color: var(--ink-3); margin-bottom: 2px; }
485
+ dl.facts dd { margin: 0; font-size: 14px; line-height: 20px; overflow-wrap: break-word; }
486
+ dl.facts dd.mono { font-size: 13px; }
487
+ footer.foot { margin-top: 56px; font-size: 12px; line-height: 18px; color: var(--ink-3); display: flex;
488
+ flex-wrap: wrap; gap: 4px 16px; }
489
+
490
+ /* The replay core: the "Replayed in 4 s, $0" tag, and healed steps with their frames side by side. */
491
+ .heals { padding: 4px; }
492
+ .heal { padding: 16px; border-radius: var(--radius-container); }
493
+ .heal-title { margin: 0; font-weight: var(--weight-medium); overflow-wrap: anywhere; }
494
+ .heal-why { display: flex; flex-wrap: wrap; gap: 0 12px; margin: 2px 0 0; font-size: 13px; line-height: 20px;
495
+ color: var(--ink-3); overflow-wrap: anywhere; }
496
+ .heal-frames { display: grid; grid-template-columns: repeat(2, minmax(0, 180px)); gap: 16px; margin: 16px 0 0; }
497
+ .heal-frames figure { margin: 0; }
498
+ .heal-frames figcaption { margin-bottom: 8px; font-size: 12px; line-height: 18px; font-weight: var(--weight-medium);
499
+ color: var(--ink-3); }
500
+ .heal-frames .phone { padding: 4px; border-radius: 30px; }
501
+ .heal-frames .phone img { border-radius: 26px; }
502
+ .heal-review { display: grid; gap: 8px; margin: 16px 0 0; }
503
+ .heal-review div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; }
504
+ .heal-review .label { width: 96px; font-size: 13px; font-weight: var(--weight-medium); }
505
+ .heal-review pre.cmd { padding: 6px 12px; font-size: 12px; }
506
+
507
+ /* Failure evidence (evidence.py): the crash's stack and the app's log around the failure, beside the step. */
508
+ .evidence { display: grid; gap: 12px; min-width: 0; }
509
+ .ev-part { min-width: 0; padding: 12px 16px; border-radius: var(--radius-container); background: var(--sunken); }
510
+ .ev-part.fail { background: color-mix(in srgb, var(--fail) 7%, transparent); }
511
+ .ev-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin: 0; font-size: 14px;
512
+ line-height: 20px; }
513
+ .ev-head strong { font-weight: var(--weight-medium); color: var(--ink); overflow-wrap: anywhere; }
514
+ .ev-head span { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
515
+ .ev-head a { margin-left: auto; font-size: 13px; font-weight: var(--weight-medium); }
516
+ .ev-msg { margin: 4px 0 0; font: 13px/20px var(--font-mono); color: var(--ink); overflow-wrap: anywhere; }
517
+ .ev-note { margin: 8px 0 0; font-size: 13px; line-height: 20px; color: var(--ink-3); overflow-wrap: anywhere; }
518
+ ol.frames { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 2px; }
519
+ ol.frames li { display: grid; grid-template-columns: 20px minmax(96px, 168px) minmax(0, 1fr); gap: 12px;
520
+ align-items: baseline; font: 12px/18px var(--font-mono); color: var(--ink-3); }
521
+ ol.frames li .fn { text-align: right; font-variant-numeric: tabular-nums; }
522
+ ol.frames li .fimg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
523
+ ol.frames li code { color: var(--ink-2); overflow-wrap: anywhere; }
524
+ ol.frames li.own, ol.frames li.own code { color: var(--ink); }
525
+ ol.frames li .floc { display: block; color: var(--ink-3); }
526
+ .ev-lines { margin: 8px 0 0; padding: 0; list-style: none; display: grid; font: 12px/18px var(--font-mono); }
527
+ .ev-lines li { display: grid; grid-template-columns: 64px 44px minmax(0, 1fr); gap: 8px; color: var(--ink-2); }
528
+ .ev-lines li .t { text-align: right; color: var(--ink-3); font-variant-numeric: tabular-nums; }
529
+ .ev-lines li .lv { color: var(--ink-3); }
530
+ .ev-lines li.error .lv, .ev-lines li.fault .lv { color: var(--fail); }
531
+ .ev-lines li.error .tx, .ev-lines li.fault .tx { color: var(--ink); }
532
+ .ev-lines li .tx { overflow-wrap: anywhere; white-space: pre-wrap; }
533
+ .ev-lines li.at { display: flex; align-items: center; gap: 8px; margin: 4px 0; color: var(--fail);
534
+ font-family: var(--font-sans); font-weight: var(--weight-medium); }
535
+ .ev-lines li.at::after { content: ""; flex: 1; height: 1px; background: color-mix(in srgb, var(--fail) 40%, transparent); }
536
+ ol.steps > li.failing { background: color-mix(in srgb, var(--fail) 7%, transparent); }
537
+ ol.steps > li.ev-row { display: block; padding: 4px 12px 12px 76px; }
538
+ .cause { margin: 12px 0 0; font-size: 14px; line-height: 22px; color: var(--ink-2); }
539
+ .cause strong { margin-right: 8px; font-weight: var(--weight-medium); color: var(--ink); }
540
+ details.proof { min-width: 0; }
541
+ details.proof > summary { cursor: pointer; font-size: 13px; line-height: 20px; color: var(--ink-2); }
542
+ details.proof > summary span { margin-left: 12px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
543
+ table.net { margin: 8px 0 0; border-collapse: collapse; font: 12px/18px var(--font-mono); color: var(--ink-2); }
544
+ table.net td { padding: 0 16px 0 0; overflow-wrap: anywhere; }
545
+ .judged { margin: 8px 0 0; font-size: 13px; line-height: 20px; color: var(--ink-3); overflow-wrap: anywhere; }
546
+
547
+ @media (max-width: 640px) {
548
+ main { padding: 20px 16px 48px; }
549
+ .heal-review .label { width: auto; }
550
+ header.head { padding-top: 24px; }
551
+ h1 { font-size: 22px; line-height: 28px; }
552
+ .lede { font-size: 14px; line-height: 22px; }
553
+ ul.meta { gap: 6px 16px; }
554
+ ol.expect li { padding: 12px; }
555
+ dl.facts { padding: 16px; grid-template-columns: 1fr 1fr; gap: 16px; }
556
+ ol.steps > li.ev-row { padding: 4px 4px 12px; }
557
+ ol.frames li { grid-template-columns: 20px minmax(0, 1fr); }
558
+ ol.frames li .fimg { display: none; }
559
+ .ev-lines li { grid-template-columns: 56px minmax(0, 1fr); }
560
+ .ev-lines li .lv { display: none; }
561
+ .brand-id { display: none; }
562
+ }
563
+ @media (max-width: 480px) { dl.facts { grid-template-columns: minmax(0, 1fr); } }
564
+ @media print {
565
+ :root { --bezel: var(--ink); }
566
+ body { font-size: 12px; background: white; }
567
+ main { max-width: none; padding: 0; }
568
+ .mark .mark-light { display: block; } .mark .mark-dark { display: none; }
569
+ section, li, .result, .shot { break-inside: avoid; }
570
+ a { text-decoration: none; }
571
+ * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
572
+ }
573
+ """ # noqa: E501
574
+ BASE_STYLE = ("".join(FONT_FACES) + tokens.STYLESHEET + tokens.PRINT_STYLESHEET
575
+ + COMPONENTS.replace("PASS_SOLID", tokens.LIGHT["green"]).replace("FAIL_SOLID", tokens.LIGHT["coral"]))
576
+
577
+ VERIFY_STYLE = """
578
+ .layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 40px 48px; align-items: start;
579
+ margin-top: 40px; }
580
+ .layout > section { grid-column: 1; margin: 0; min-width: 0; }
581
+ .shot { grid-column: 2; position: sticky; top: 24px; width: 320px; }
582
+ .shot .phone { width: 320px; border-radius: 50px; }
583
+ .shot .phone img { border-radius: 44px; }
584
+ .snaps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 0; }
585
+ .snaps figure { margin: 0; display: grid; gap: 6px; }
586
+ .snaps img { width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--line); }
587
+ .snaps figcaption { font-size: 13px; line-height: 20px; color: var(--ink-2); }
588
+ .notes { margin: 12px 4px 0; display: grid; gap: 4px; }
589
+ .notes p { margin: 0; font-size: 13px; line-height: 20px; color: var(--ink-2); overflow-wrap: anywhere; }
590
+ .seg { display: flex; width: max-content; margin: 0 auto 12px; padding: 2px; gap: 2px;
591
+ border-radius: var(--radius-full); background: var(--fill-input); }
592
+ .seg label { display: inline-flex; align-items: center; height: 28px; padding: 0 12px;
593
+ border-radius: var(--radius-full); font-size: 13px; font-weight: var(--weight-medium); color: var(--ink-2);
594
+ cursor: pointer; user-select: none; -webkit-user-select: none;
595
+ transition: color var(--duration-short) var(--ease-standard), background-color var(--duration-short) var(--ease-standard); }
596
+ #frame-ax:checked ~ .seg label[for=frame-ax], #frame-plain:checked ~ .seg label[for=frame-plain] {
597
+ background: var(--surface); color: var(--ink); box-shadow: var(--shadow-thumb); }
598
+ #frame-ax:focus-visible ~ .seg label[for=frame-ax], #frame-plain:focus-visible ~ .seg label[for=frame-plain] {
599
+ outline: 2px solid var(--focus-ring); outline-offset: 1px; }
600
+ #frame-ax:checked ~ .phone .plain, #frame-plain:checked ~ .phone .ax { display: none; }
601
+ #frame-plain:checked ~ .legend { visibility: hidden; }
602
+ .callout { margin: 16px 0 0; max-width: 720px; padding: 12px 16px; border-radius: var(--radius-container);
603
+ font-size: 14px; line-height: 20px; background: color-mix(in srgb, var(--tone) 10%, transparent);
604
+ overflow-wrap: anywhere; }
605
+ .callout p { margin: 0; } .callout p + p { margin-top: 4px; color: var(--ink-2); }
606
+ ol.steps { list-style: none; margin: 0; padding: 4px; }
607
+ ol.steps > li { display: grid; grid-template-columns: 18px 40px minmax(0, 1fr) auto; gap: 14px; align-items: center;
608
+ padding: 8px 12px; border-radius: var(--radius-container); }
609
+ .step-n { font: 12px/1 var(--font-mono); color: var(--ink-3); text-align: right; }
610
+ .thumb { width: 40px; aspect-ratio: 3 / 4; border-radius: var(--radius-inner); overflow: hidden;
611
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 8%, transparent); background: var(--sunken); }
612
+ .thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; }
613
+ .op { font-weight: var(--weight-medium); overflow-wrap: anywhere; }
614
+ .step-meta { font-size: 13px; line-height: 20px; color: var(--ink-3); overflow-wrap: anywhere; }
615
+ .at { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
616
+ dl.facts dd .sub { display: block; margin-top: 2px; font-size: 12px; line-height: 18px; color: var(--ink-3); }
617
+ .note { margin: 0; padding: 16px 20px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink-2); }
618
+ .note-meta { display: block; margin-top: 8px; font-size: 13px; color: var(--ink-3); }
619
+ @media (max-width: 860px) {
620
+ .layout { grid-template-columns: minmax(0, 1fr); margin-top: 32px; }
621
+ .shot { grid-column: 1; grid-row: auto !important; position: static; justify-self: center; max-width: 100%; }
622
+ .shot .phone { max-width: 100%; }
623
+ }
624
+ @media (max-width: 640px) {
625
+ ol.steps > li { grid-template-columns: 40px minmax(0, 1fr) auto; gap: 12px; }
626
+ .step-n { display: none; }
627
+ }
628
+ @media print { .seg { display: none; } .layout { grid-template-columns: minmax(0, 1fr) 220px; gap: 28px 32px; }
629
+ .shot, .shot .phone { width: 220px; position: static; } }
630
+ """ # noqa: E501
631
+
632
+ STYLE = BASE_STYLE + VERIFY_STYLE
633
+
634
+
635
+ def replay_chip(replay, *, healed=0):
636
+ replay = replay if isinstance(replay, dict) else {}
637
+ mode = replay.get("mode")
638
+ if mode in ("replayed", "healed"):
639
+ cost = float(replay.get("costUsd") or 0)
640
+ words = f"Replayed in {seconds_text(replay.get('seconds'))}, " + (f"${cost:.2f}" if cost >= .005 else "$0")
641
+ chip = f'<span class="tag pass">{icon("check")}{_e(words)}</span>'
642
+ count = healed or replay.get("healed") or 0
643
+ if count:
644
+ chip += f'<span class="tag tone review">{_e(count)} step{"" if count == 1 else "s"} healed</span>'
645
+ return chip
646
+ if replay.get("saved"):
647
+ return '<span class="tag">Recorded for replay</span>'
648
+ return ""
649
+
650
+
651
+ def heal_title(item):
652
+ """'"Continue" is now "Next"' when one labelled step became another, else 'Tap "Continue" → Tap "Next"'."""
653
+ old, new = item.get("old") or [], item.get("new") or []
654
+ if len(old) == 1 and len(new) == 1 and old[0].get("label") and new[0].get("label") \
655
+ and old[0].get("op") == new[0].get("op"):
656
+ return f'"{old[0]["label"]}" is now "{new[0]["label"]}"'
657
+ if not old and new:
658
+ return f"Added: {item.get('to')}"
659
+ return f"{item.get('from')} → {item.get('to')}"
660
+
661
+
662
+ def heal_command(source):
663
+ """The name `mobster test heal` takes for a check: its file's stem in .mobster/checks, else its path."""
664
+ if not source:
665
+ return "CHECK"
666
+ path = Path(source)
667
+ return path.stem if path.parent.name == "checks" and path.parent.parent.name == ".mobster" else home(str(path))
668
+
669
+
670
+ def healed_section(healed, base_dir, source=None, *, heading="Healed steps", ident="healed"):
671
+ """The heal diff review: each healed step, why, its frames before and after, and how to accept or reject it."""
672
+ items = [item for item in healed or () if isinstance(item, dict)]
673
+ if not items:
674
+ return ""
675
+ name = heal_command(source)
676
+ out = [f'<section aria-labelledby="{_e(ident)}"><h2 id="{_e(ident)}">{_e(heading)}<span class="count">'
677
+ f'{len(items)}</span></h2><div class="card heals">']
678
+ for item in items:
679
+ out.append('<div class="heal">')
680
+ out.append(f'<p class="heal-title">Healed: {_e(heal_title(item))}</p>')
681
+ why = [f"Step {item.get('step')}" if item.get("step") else None, item.get("why")]
682
+ if any(why):
683
+ out.append('<p class="heal-why">' + "".join(f"<span>{_e(part)}</span>" for part in why if part) + "</p>")
684
+ frames = item.get("frames") or {}
685
+ shots = []
686
+ for label, key in (("Before", "before"), ("After", "after")):
687
+ path = frames.get(key)
688
+ url = _data_url(Path(base_dir) / path if path and not Path(path).is_absolute() else Path(path),
689
+ THUMB_WIDTH * 2) if path else None
690
+ if url:
691
+ shots.append(f'<figure><figcaption>{label}</figcaption><div class="phone"><img src="{url}" '
692
+ f'alt="The screen {label.lower()} the heal"></div></figure>')
693
+ if shots:
694
+ out.append('<div class="heal-frames">' + "".join(shots) + "</div>")
695
+ out.append('<div class="heal-review">'
696
+ f'<div><span class="label">Accept heal</span>{command(f"mobster test heal --accept {name}")}'
697
+ '</div>'
698
+ f'<div><span class="label">Keep failing</span>{command(f"mobster test heal --reject {name}")}'
699
+ "</div></div>")
700
+ out.append("</div>")
701
+ out.append("</div></section>")
702
+ return "".join(out)
703
+
704
+
705
+ SNAPSHOT_WORDS = {"matched": "matched the baseline", "changed": "changed", "size_changed": "has a different size",
706
+ "recorded": "baseline recorded", "updated": "baseline updated"}
707
+
708
+
709
+ def snapshot_section(snapshot, run_dir):
710
+ """The screenshot comparison: one line, then the baseline, the current screen and the diff side by side."""
711
+ if not isinstance(snapshot, dict) or not snapshot.get("status"):
712
+ return ""
713
+ line = f"{snapshot.get('name')} {SNAPSHOT_WORDS.get(snapshot['status'], snapshot['status'])}"
714
+ if snapshot["status"] in ("matched", "changed"):
715
+ line += (f": {snapshot.get('changedPercent', 0):.2f} % of the screen differs "
716
+ f"(limit {snapshot.get('thresholdPercent'):g} %)")
717
+ figures = []
718
+ for key, label in (("baseline", "Baseline"), ("current", "Current"), ("diff", "Diff")):
719
+ url = _data_url(Path(run_dir) / snapshot[key], FRAME_WIDTH, 80) if snapshot.get(key) else None
720
+ if url:
721
+ figures.append(f'<figure><img src="{url}" alt="{label}: {_e(snapshot.get("name"))}">'
722
+ f"<figcaption>{label}</figcaption></figure>")
723
+ return (f'<h2>Screenshot</h2><div class="card"><p class="lede">{_e(line)}</p>'
724
+ + (f'<div class="snaps">{"".join(figures)}</div>' if figures else "") + "</div>")
725
+
726
+
727
+ def render_html(result, run_dir):
728
+ """The report for ``result`` (§5.5), as one HTML string."""
729
+ run_dir = Path(run_dir)
730
+ verdict = result.get("verdict")
731
+ word, tone = VERDICTS.get(verdict, VERDICTS["couldnt_run"])
732
+ reason = result.get("reason") or {}
733
+ check = result.get("check") or {}
734
+ device = result.get("device") or {}
735
+ app = result.get("app") or {}
736
+ assertions = result.get("assertions") or []
737
+ held = sum(bool(item.get("ok")) for item in assertions)
738
+ parts = [
739
+ "<!doctype html>",
740
+ '<html lang="en"><head><meta charset="utf-8">',
741
+ f'<meta http-equiv="Content-Security-Policy" content="{CSP}">',
742
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
743
+ '<meta name="color-scheme" content="light dark">',
744
+ f"<title>{_e(VERDICTS.get(verdict, VERDICTS['couldnt_run'])[0])}: {_e(check.get('name'))}</title>",
745
+ f"<style>{STYLE}</style></head><body><main>",
746
+ brand("verify", result.get("run_id")),
747
+ '<header class="head"><div class="chips">', verdict_chip(word, tone),
748
+ replay_chip(result.get("replay"), healed=len(result.get("healed") or ())),
749
+ ]
750
+ parts.append(f'</div><h1>{_e(check.get("name"))}</h1>')
751
+ if assertions and reason.get("class") in (None, "assertion"):
752
+ lede = (f"All {len(assertions)} expectations held" if held == len(assertions) else
753
+ f"{len(assertions) - held} of {len(assertions)} expectations failed")
754
+ parts.append(f'<p class="lede">{_e(lede)}</p>')
755
+ # Why, when the assertions don't say (couldn't run, needs review, blocked), and the fix.
756
+ failing = [item for item in assertions if not item.get("ok")]
757
+ lines = []
758
+ if reason.get("class") not in (None, "assertion") or not assertions:
759
+ lines.append(reason.get("message") or result.get("summary"))
760
+ if result.get("summary") and result.get("summary") != reason.get("message") and not failing:
761
+ lines.append(result.get("summary"))
762
+ if reason.get("fix"):
763
+ lines.append(reason.get("fix"))
764
+ lines = [line for line in lines if line]
765
+ if lines:
766
+ parts.append(f'<div class="callout {tone}">' + "".join(f"<p>{_e(line)}</p>" for line in lines) + "</div>")
767
+ parts.append(cause_line(result.get("step_proof")))
768
+ device_line = ", ".join(filter(None, [device.get("type"), device.get("runtime")])) or dots(device.get("name"))
769
+ app_line = " ".join(filter(None, [app.get("name") or app.get("bundle_id"), app.get("version")]))
770
+ app_icon = _png_url(run_dir / "app-icon.png")
771
+ meta = [(icon("sim"), device_line),
772
+ (f'<img class="app-icon" src="{app_icon}" alt="">' if app_icon else icon("app"), app_line),
773
+ (icon("clock"), seconds_text(result.get("seconds")) if result.get("seconds") is not None else None),
774
+ (icon("calendar"), when(run_id=result.get("run_id"))), (icon("dash"), MODES.get(result.get("mode")))]
775
+ parts.append('<ul class="meta">' + "".join(f"<li>{mark}{_e(dots(value))}</li>" for mark, value in meta
776
+ if value) + "</ul></header>")
777
+
778
+ frames = result.get("frames") or []
779
+ ax = next((f for f in reversed(frames) if f.endswith("-verdict-ax.jpg")), None)
780
+ plain = next((f for f in reversed(frames) if f.endswith("-verdict.jpg")), None)
781
+ ax_url = _data_url(run_dir / ax, FRAME_WIDTH, 80) if ax else None
782
+ plain_url = _data_url(run_dir / plain, FRAME_WIDTH, 80) if plain else None
783
+
784
+ has_steps = bool(result.get("steps"))
785
+ healed = healed_section(result.get("healed"), run_dir, check.get("source"))
786
+ evidence = result.get("evidence") or {}
787
+ failing = evidence.get("step") if has_steps else None
788
+ pane = evidence_pane(evidence, step=failing)
789
+ alone = bool(pane) and not any(step.get("index") == failing for step in result.get("steps") or ())
790
+ sections = (2 + has_steps + bool(result.get("repro")) + bool(result.get("agent")) + bool(healed)
791
+ + alone)
792
+ parts.append('<div class="layout"><section aria-labelledby="expectations"><h2 id="expectations">Expectations'
793
+ "</h2>")
794
+ if assertions:
795
+ parts.append('<div class="card">' + expectations(assertions, frame=bool(ax_url)) + "</div>")
796
+ elif check.get("expect") and not result.get("findings"):
797
+ parts.append('<p class="empty">Not evaluated: the run ended before the verdict read.</p>')
798
+ elif result.get("business") is not None:
799
+ parts.append('<p class="empty">This check has no accessibility-tree expectations.</p>')
800
+ else:
801
+ parts.append('<p class="empty">The check has no expectations.</p>')
802
+ if result.get("business") is not None:
803
+ parts.append(business_section(result))
804
+ if result.get("findings"):
805
+ parts.append("<h2>Findings</h2>")
806
+ parts.append('<div class="card"><ol class="expect">' + "".join(
807
+ f'<li class="{"failed" if item.get("level") == "fail" else "review"}"><code class="assert">'
808
+ f'{_e((item.get("guideline") + " " if item.get("guideline") else "") + str(item.get("id")))}'
809
+ f'</code><span class="observed">{_e(FINDING_WORDS.get(item.get("level"), ""))}{_e(item.get("message"))}'
810
+ f' Evidence: {_e(item.get("evidence"))}</span></li>'
811
+ for item in result["findings"]) + "</ol></div>")
812
+ parts.append(snapshot_section(result.get("snapshot"), run_dir))
813
+ notes = []
814
+ if result.get("stable") is False:
815
+ notes.append("The screen was still changing when Mobster read it.")
816
+ if result.get("alert"):
817
+ notes.append(f"An alert was showing: {result.get('alert')}")
818
+ if notes:
819
+ parts.append('<div class="notes">' + "".join(f"<p>{_e(note)}</p>" for note in notes) + "</div>")
820
+ parts.append("</section>")
821
+ if alone:
822
+ parts.append(f'<section aria-labelledby="logs"><h2 id="logs">Logs and crash</h2>{pane}</section>')
823
+ if ax_url or plain_url:
824
+ parts.append(f'<aside class="shot" style="grid-row: 1 / span {sections}" aria-labelledby="frame">'
825
+ '<h2 class="sr" id="frame">The verdict frame</h2>')
826
+ if ax_url and plain_url:
827
+ parts.append('<input class="sr" type="radio" name="frame" id="frame-ax" checked '
828
+ 'aria-label="The frame with the overlay">'
829
+ '<input class="sr" type="radio" name="frame" id="frame-plain" aria-label="The plain frame">'
830
+ '<div class="seg" aria-hidden="true"><label for="frame-ax">Outlined</label>'
831
+ '<label for="frame-plain">Plain</label></div>')
832
+ parts.append('<div class="phone">')
833
+ if ax_url:
834
+ parts.append(f'<img class="ax" src="{ax_url}" alt="The screen at the verdict, each asserted element '
835
+ f'outlined and numbered as in the list: green held, red failed">')
836
+ if plain_url:
837
+ parts.append(f'<img class="plain" src="{plain_url}" alt="The screen at the verdict">')
838
+ judged = (result.get("step_proof") or {}).get("judged") or {}
839
+ caption = (f'<p class="judged">Judged on {_e(Path(judged["frame"]).stem)}'
840
+ + (f', screen {_e(judged["treeHash"][:12])}' if judged.get("treeHash") else "")
841
+ + (f", at {_e(_seconds(judged['verdictAtMs']))}" if judged.get("verdictAtMs") is not None else "")
842
+ + "</p>") if judged.get("frame") else ""
843
+ parts.append("</div>" + (OVERLAY_LEGEND if ax_url else "") + caption + "</aside>")
844
+
845
+ if healed:
846
+ parts.append(healed)
847
+ if has_steps:
848
+ parts.append('<section aria-labelledby="steps"><h2 id="steps">Steps</h2>')
849
+ launch = next((f for f in frames if f.endswith("-launch.jpg")), None)
850
+ rows = []
851
+ by_step = {entry.get("index"): entry for entry in (result.get("step_proof") or {}).get("steps") or ()}
852
+ if launch:
853
+ rows.append((launch, "Launched the app", None, None, None))
854
+ for step in result.get("steps") or []:
855
+ target = step.get("target") or {}
856
+ who = ", ".join(f"{key} {_short(value, 60)}" for key, value in (
857
+ ("id", target.get("id")), ("label", target.get("label")), ("role", target.get("role"))) if value)
858
+ changed = step.get("changed")
859
+ meta_line = ", ".join(filter(None, [who or None, None if changed is None else (
860
+ "the screen changed" if changed else "no visible change")]))
861
+ rows.append((step.get("frame"), step.get("text") or step.get("op"), meta_line,
862
+ f"{(step.get('at_ms') or 0) / 1000:.1f} s", step.get("index")))
863
+ if rows and has_steps:
864
+ parts.append('<div class="card"><ol class="steps">')
865
+ for number, (frame, text, meta_line, at, index) in enumerate(rows, 1):
866
+ url = _data_url(run_dir / frame, THUMB_WIDTH) if frame else None
867
+ image = (f'<div class="thumb"><img src="{url}" alt=""></div>' if url
868
+ else '<div class="thumb noimg"></div>')
869
+ here = bool(pane) and not alone and index == failing
870
+ mark = ' class="failing"' if here else ""
871
+ parts.append(f'<li{mark}><span class="step-n">{number}</span>{image}<div><div class="op">{_e(text)}</div>'
872
+ + (f'<div class="step-meta">{_e(meta_line)}</div>' if meta_line else "") + "</div>"
873
+ + (f'<span class="at">{_e(at)}</span>' if at else "<span></span>") + "</li>")
874
+ if here:
875
+ parts.append(f'<li class="ev-row" aria-label="Logs and crash">{pane}</li>')
876
+ detail = proof_pane(by_step.get(index), title="Proof for this step", opened=bool(here))
877
+ if detail:
878
+ parts.append(f'<li class="ev-row" aria-label="Proof for step {_e(index)}">{detail}</li>')
879
+ tail = proof_pane((result.get("step_proof") or {}).get("after"), title="Until the verdict")
880
+ if tail:
881
+ parts.append(f'<li class="ev-row" aria-label="Proof until the verdict">{tail}</li>')
882
+ parts.append("</ol></div></section>")
883
+
884
+ timing = result.get("timing") or {}
885
+ facts = [
886
+ ("App", " ".join(filter(None, [app.get("name"), app.get("version"), f"({app.get('bundle_id')})"
887
+ if app.get("bundle_id") else None])) or None, ""),
888
+ ("Simulator", device_line, ""),
889
+ ("Mode", {"smart": "Smart", "keyless": "Key-less (your coding agent drove)",
890
+ "launch": "Launch-only"}.get(result.get("mode"), result.get("mode")), ""),
891
+ ("Duration", f"{result.get('seconds', 0)} s", ("sub", ", ".join(
892
+ f"{key}\u00a0{value}\u00a0s" for key, value in timing.items()))),
893
+ ("Model cost", f"${result.get('cost_usd'):.4f}" if result.get("cost_usd") else "$0", ""),
894
+ ("Replay", _replay_fact(result.get("replay")), ""),
895
+ ("Stable read", {True: "Yes", False: "No, the screen was still changing", None: "Not read"}.get(
896
+ result.get("stable")), ""),
897
+ ("Run", result.get("run_id"), "mono"),
898
+ ("Mobster", (result.get("mobster") or {}).get("version"), ""),
899
+ ("App path", home(app.get("path")), "mono wide"),
900
+ ]
901
+ parts.append('<section aria-labelledby="run"><h2 id="run">Run</h2><div class="card"><dl class="facts">')
902
+ for name, value, kind in facts:
903
+ if value:
904
+ sub = f'<span class="sub">{_e(kind[1])}</span>' if isinstance(kind, tuple) and kind[1] else ""
905
+ kind = "" if isinstance(kind, tuple) else kind
906
+ wide = ' class="wide"' if "wide" in kind else ""
907
+ mono = ' class="mono"' if "mono" in kind else ""
908
+ parts.append(f"<div{wide}><dt>{_e(name)}</dt><dd{mono}>{_e(value)}{sub}</dd></div>")
909
+ parts.append("</dl></div></section>")
910
+
911
+ if result.get("repro"):
912
+ parts.append(f'<section aria-labelledby="again"><h2 id="again">Run it again</h2>'
913
+ f'{command(home(result["repro"]))}</section>')
914
+ agent = result.get("agent")
915
+ if agent:
916
+ ended = "Smart ended " + str(agent.get("status")) + (f" after {agent.get('turns')} turns"
917
+ if agent.get("turns") else "")
918
+ parts.append('<section aria-labelledby="note"><h2 id="note">The model\'s note (not the verdict)</h2>'
919
+ f'<div class="card"><p class="note">{_e(agent.get("note") or "No note.")}'
920
+ f'<span class="note-meta">{_e(ended)}</span></p></div></section>')
921
+ parts.append(f'</div><footer class="foot"><span>Mobster {_e((result.get("mobster") or {}).get("version"))}</span>'
922
+ f'<span>Run <span class="mono">{_e(result.get("run_id"))}</span></span></footer>')
923
+ parts.append("</main></body></html>")
924
+ return "\n".join(parts)
925
+
926
+
927
+ def _replay_fact(replay):
928
+ if not isinstance(replay, dict) or not replay:
929
+ return None
930
+ if replay.get("status") == "proposal_unavailable":
931
+ return replay.get("reason") or "The healed replay path could not be staged for review."
932
+ mode = replay.get("mode")
933
+ if mode in ("replayed", "healed"):
934
+ parts = [f"{replay.get('replayed') or 0} of {replay.get('steps') or 0} steps with no model"]
935
+ if replay.get("healed"):
936
+ parts.append(f"{replay['healed']} healed")
937
+ if replay.get("live"):
938
+ parts.append(f"{replay['live']} by the model")
939
+ return ", ".join(parts)
940
+ if replay.get("saved"):
941
+ return f"Recorded {replay.get('steps') or 0} steps; the next run replays them"
942
+ return f"Smart ran every step ({replay['reason']})" if replay.get("reason") else None
943
+
944
+
945
+ def write_report(run_dir, result, *, scrub=lambda text: text):
946
+ """report.html in ``run_dir`` for ``result``. result.json is written by the run itself."""
947
+ run_dir = Path(run_dir)
948
+ text = scrub(render_html(result, run_dir))
949
+ temporary = run_dir / "report.html.tmp"
950
+ temporary.write_text(text, encoding="utf-8")
951
+ os.replace(temporary, run_dir / "report.html")
952
+ return run_dir / "report.html"