peachsim 0.1.939 → 0.1.1399

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 (396) hide show
  1. package/README.md +28 -22
  2. package/cli/app-catalog.ts +29 -14
  3. package/cli/app-fonts.ts +5 -3
  4. package/cli/bridge-flow-runner.ts +40 -29
  5. package/cli/cloud-client.ts +57 -21
  6. package/cli/cloud-test-tunnel.ts +12 -18
  7. package/cli/commands/agent.ts +1 -1
  8. package/cli/commands/app-fonts.ts +3 -3
  9. package/cli/commands/assert.ts +8 -8
  10. package/cli/commands/auth.ts +8 -3
  11. package/cli/commands/box/bundle-plane.ts +13 -1
  12. package/cli/commands/box.ts +16 -5
  13. package/cli/commands/compat.ts +1 -1
  14. package/cli/commands/config.ts +2 -2
  15. package/cli/commands/control.ts +125 -71
  16. package/cli/commands/cpu-profile.ts +45 -1
  17. package/cli/commands/daemon.ts +131 -33
  18. package/cli/commands/debug.ts +24 -17
  19. package/cli/commands/desktop.ts +7 -1
  20. package/cli/commands/detect.ts +101 -0
  21. package/cli/commands/detox.ts +6 -6
  22. package/cli/commands/device.ts +12 -12
  23. package/cli/commands/electron.ts +32 -14
  24. package/cli/commands/flow.ts +49 -41
  25. package/cli/commands/guest-host.ts +39 -1
  26. package/cli/commands/inspect/core.ts +14 -8
  27. package/cli/commands/inspect/list.ts +1 -1
  28. package/cli/commands/inspect/settle.ts +5 -3
  29. package/cli/commands/inspect/settling.ts +229 -44
  30. package/cli/commands/inspect/wait-idle.ts +3 -2
  31. package/cli/commands/inspect.ts +105 -93
  32. package/cli/commands/install-cli.ts +36 -15
  33. package/cli/commands/install-desktop.ts +68 -195
  34. package/cli/commands/kotlin.ts +6 -5
  35. package/cli/commands/login.ts +5 -5
  36. package/cli/commands/maestro-compare.ts +554 -0
  37. package/cli/commands/maestro-generate.ts +6 -3
  38. package/cli/commands/maestro.ts +22 -0
  39. package/cli/commands/mode.ts +2 -2
  40. package/cli/commands/no-bridge-hint.ts +6 -2
  41. package/cli/commands/platform.ts +35 -6
  42. package/cli/commands/preview.ts +6 -0
  43. package/cli/commands/project-report.ts +10 -12
  44. package/cli/commands/record.ts +37 -37
  45. package/cli/commands/runtime.ts +9 -9
  46. package/cli/commands/screenshot-layers.ts +4 -4
  47. package/cli/commands/screenshot-mode.ts +2 -2
  48. package/cli/commands/screenshot.ts +8 -8
  49. package/cli/commands/screenshots-capture.ts +2 -2
  50. package/cli/commands/serve.ts +9 -12
  51. package/cli/commands/setup.ts +29 -14
  52. package/cli/commands/shell-boolean-mode.ts +2 -2
  53. package/cli/commands/show-touches-mode.ts +2 -2
  54. package/cli/commands/skills.ts +11 -11
  55. package/cli/commands/swift.ts +29 -11
  56. package/cli/commands/telemetry.ts +2 -2
  57. package/cli/commands/test.ts +5 -0
  58. package/cli/commands/three-mode.ts +3 -3
  59. package/cli/commands/timeline.ts +4 -4
  60. package/cli/commands/upgrade.ts +2 -2
  61. package/cli/commands/upload.ts +15 -15
  62. package/cli/commands/what-happened.ts +2 -2
  63. package/cli/current-sim.ts +6 -3
  64. package/cli/desktop-companion.ts +55 -149
  65. package/cli/dev-build-freshness.ts +2 -2
  66. package/cli/drivers/desktop.ts +17 -9
  67. package/cli/drivers/electron.ts +1 -2
  68. package/cli/drivers/playwright-sim-host.ts +11 -2
  69. package/cli/drivers/playwright.ts +35 -2
  70. package/cli/drivers/types.ts +8 -0
  71. package/cli/maestro-compare.ts +670 -0
  72. package/cli/outbound-endpoints.ts +46 -8
  73. package/cli/recording-access.ts +6 -6
  74. package/cli/run-registry.ts +1 -1
  75. package/cli/runtime-compat.ts +5 -1
  76. package/cli/send-to-box.ts +3 -3
  77. package/cli/shell-init.ts +2 -1
  78. package/cli/ws-bridge.ts +9 -5
  79. package/detox/element-types.ts +1 -0
  80. package/detox/gestures.ts +3 -3
  81. package/detox/index.ts +67 -38
  82. package/detox/navigation-lifecycle.ts +9 -6
  83. package/detox/proof-frame.cjs +6 -4
  84. package/dist-lib/agent-daemon-client.cjs +1 -1
  85. package/dist-lib/agent-events.cjs +1 -1
  86. package/dist-lib/attached-projects.cjs +1 -1
  87. package/dist-lib/auth/shared-session.cjs +1 -1
  88. package/dist-lib/backend-origin.cjs +1 -1
  89. package/dist-lib/beta.cjs +1 -1
  90. package/dist-lib/beta.mjs +1 -1
  91. package/dist-lib/bridge-constants.cjs +1 -1
  92. package/dist-lib/bridge-contract-input.cjs +42 -17
  93. package/dist-lib/bridge-contract-input.mjs +41 -16
  94. package/dist-lib/bridge-contract.cjs +1 -4
  95. package/dist-lib/bridge-contract.mjs +1 -3
  96. package/dist-lib/bundle-channel.cjs +2918 -550
  97. package/dist-lib/capture-contract.cjs +65 -24
  98. package/dist-lib/capture-contract.mjs +63 -24
  99. package/dist-lib/cloud-contract.cjs +2 -2
  100. package/dist-lib/cloud-contract.mjs +2 -2
  101. package/dist-lib/cloud-test-args.cjs +1 -1
  102. package/dist-lib/cloud-test-args.mjs +1 -1
  103. package/dist-lib/cloud.cjs +3 -3
  104. package/dist-lib/cloud.mjs +3 -3
  105. package/dist-lib/config.cjs +15 -5
  106. package/dist-lib/config.mjs +154 -0
  107. package/dist-lib/detox/index.cjs +495 -139
  108. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  109. package/dist-lib/expo-swift-bundle.cjs +1168 -238
  110. package/dist-lib/expo-swift-metadata.cjs +34 -23
  111. package/dist-lib/expo-swift-metadata.mjs +32 -23
  112. package/dist-lib/home-paths.cjs +2 -2
  113. package/dist-lib/host/bridge-host.cjs +2938 -654
  114. package/dist-lib/host/fetch-proxy-handler.cjs +14 -1
  115. package/dist-lib/host/fetch-proxy-overrides.cjs +7 -1
  116. package/dist-lib/host/fetch-proxy-overrides.mjs +5 -1
  117. package/dist-lib/host/replacement-module-handler.cjs +2 -2
  118. package/dist-lib/host/websocket-proxy.cjs +1 -1
  119. package/dist-lib/index.cjs +999 -353
  120. package/dist-lib/jump-to-source-babel.cjs +1 -1
  121. package/dist-lib/jump-to-source-native.cjs +1 -1
  122. package/dist-lib/kotlin-host.cjs +332 -19
  123. package/dist-lib/menu.cjs +1 -1
  124. package/dist-lib/menu.mjs +1 -1
  125. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  126. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  127. package/dist-lib/metro-production-bundle.cjs +7 -7
  128. package/dist-lib/metro-production-bundle.mjs +5 -5
  129. package/dist-lib/metro.cjs +688 -250
  130. package/dist-lib/native-app-identity-project.cjs +2647 -0
  131. package/dist-lib/native-app-identity-project.mjs +1257 -0
  132. package/dist-lib/native-app-identity.cjs +325 -25
  133. package/dist-lib/native-app-identity.mjs +323 -25
  134. package/dist-lib/native-firebase-metadata.cjs +1 -1
  135. package/dist-lib/native-firebase-metadata.mjs +1 -1
  136. package/dist-lib/plist.cjs +1 -1
  137. package/dist-lib/plist.mjs +1 -1
  138. package/dist-lib/profiles.cjs +1 -1
  139. package/dist-lib/public-brand.cjs +1 -1
  140. package/dist-lib/react-native-host-modules.cjs +5 -1
  141. package/dist-lib/react-native-host-modules.mjs +5 -1
  142. package/dist-lib/scripts/dev-server-scanner.cjs +57 -32
  143. package/dist-lib/sdk.cjs +231 -51
  144. package/dist-lib/sdk.mjs +231 -51
  145. package/dist-lib/skills.cjs +4107 -1374
  146. package/dist-lib/swift-compile.cjs +290 -30
  147. package/dist-lib/swift-compile.mjs +290 -30
  148. package/dist-lib/swift-package.cjs +143 -12
  149. package/dist-lib/swift-package.mjs +137 -11
  150. package/dist-lib/swift-submit.cjs +47 -5
  151. package/dist-lib/swift-submit.mjs +42 -5
  152. package/dist-lib/swift-toolchain.cjs +7 -2
  153. package/dist-lib/swift.cjs +1 -1
  154. package/dist-lib/vite-swift.cjs +1392 -458
  155. package/dist-lib/vite.cjs +13 -17
  156. package/dist-lib/worklet-executable.cjs +248 -33
  157. package/dist-lib/worklet-executable.mjs +248 -33
  158. package/install.sh +10 -1
  159. package/kotlin/{AppSources → AndroidViewSources}/android/content/res/Resources.kt +9 -9
  160. package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +20 -0
  161. package/kotlin/AndroidViewSources/android/view/View.kt +211 -0
  162. package/kotlin/AndroidViewSources/android/widget/FrameLayout.kt +61 -0
  163. package/kotlin/AndroidViewSources/android/widget/LinearLayout.kt +49 -0
  164. package/kotlin/AppSources/android/view/Window.kt +36 -0
  165. package/kotlin/AppSources/android/view/WindowInsets.kt +9 -0
  166. package/kotlin/AppSources/android/view/WindowInsetsController.kt +5 -0
  167. package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +48 -0
  168. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +20 -5
  169. package/kotlin/AppSources/androidx/compose/foundation/layout/WindowInsets.kt +15 -0
  170. package/kotlin/AppSources/androidx/compose/ui/platform/ComposeView.kt +2 -1
  171. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +42 -50
  172. package/kotlin/ComposeSources/PeachComposeHost.kt +200 -84
  173. package/kotlin/ExpoComposeSources/PeachAndroidView.kt +87 -0
  174. package/kotlin/ExpoComposeSources/PeachExpoViewMount.kt +24 -0
  175. package/kotlin/ExpoComposeSources/dev/peach/expo/PeachExpoCompose.kt +28 -0
  176. package/kotlin/ExpoComposeSources/expo/modules/kotlin/views/ExpoComposeView.kt +70 -0
  177. package/kotlin/ExpoSources/dev/peach/expo/PeachExpoRuntime.kt +513 -0
  178. package/kotlin/ExpoSources/dev/peach/expo/PeachExpoTypes.kt +173 -0
  179. package/kotlin/ExpoSources/expo/modules/kotlin/AppContext.kt +20 -0
  180. package/kotlin/ExpoSources/expo/modules/kotlin/Promise.kt +19 -0
  181. package/kotlin/ExpoSources/expo/modules/kotlin/classcomponent/ClassComponentBuilder.kt +198 -0
  182. package/kotlin/ExpoSources/expo/modules/kotlin/exception/CodedException.kt +126 -0
  183. package/kotlin/ExpoSources/expo/modules/kotlin/functions/Functions.kt +132 -0
  184. package/kotlin/ExpoSources/expo/modules/kotlin/jni/JavaScriptTypedArray.kt +81 -0
  185. package/kotlin/ExpoSources/expo/modules/kotlin/modules/Module.kt +98 -0
  186. package/kotlin/ExpoSources/expo/modules/kotlin/objects/ObjectDefinitionBuilder.kt +236 -0
  187. package/kotlin/ExpoSources/expo/modules/kotlin/records/Record.kt +12 -0
  188. package/kotlin/ExpoSources/expo/modules/kotlin/runtime/Runtime.kt +9 -0
  189. package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedObject.kt +50 -0
  190. package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedObjectRegistry.kt +57 -0
  191. package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedRef.kt +19 -0
  192. package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/ConcreteTypedArrays.kt +158 -0
  193. package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/GenericTypedArray.kt +8 -0
  194. package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/TypedArray.kt +29 -0
  195. package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/TypedArrayIterator.kt +9 -0
  196. package/kotlin/ExpoSources/expo/modules/kotlin/types/AnyType.kt +155 -0
  197. package/kotlin/ExpoSources/expo/modules/kotlin/types/Either.kt +91 -0
  198. package/kotlin/ExpoSources/expo/modules/kotlin/types/Enumerable.kt +6 -0
  199. package/kotlin/ExpoSources/expo/modules/kotlin/viewevent/ViewEvent.kt +54 -0
  200. package/kotlin/ExpoSources/expo/modules/kotlin/views/ExpoView.kt +23 -0
  201. package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewDefinitionBuilder.kt +71 -0
  202. package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewProps.kt +35 -0
  203. package/kotlin/README.md +13 -3
  204. package/kotlin/Sources/android/os/Build.kt +17 -0
  205. package/kotlin/Sources/android/os/Handler.kt +35 -0
  206. package/kotlin/Sources/android/os/Looper.kt +17 -0
  207. package/kotlin/Sources/android/os/MessageQueue.kt +56 -0
  208. package/kotlin/Sources/android/os/SystemClock.kt +14 -0
  209. package/kotlin/Sources/android/util/Log.kt +64 -0
  210. package/kotlin/Sources/java/security/MessageDigest.kt +114 -0
  211. package/kotlin/Sources/kotlin/text/StringFormat.kt +9 -0
  212. package/kotlin/Sources/org/json/JSON.kt +44 -0
  213. package/kotlin/fixtures/counter/Counter.d.kt.ts +1 -0
  214. package/kotlin/fixtures/counter/Counter.kt +10 -3
  215. package/kotlin/fixtures/counter/CounterApp.native.tsx +1 -1
  216. package/kotlin/fixtures/counter/vite.config.ts +2 -2
  217. package/kotlin/fixtures/headless-counter/Headless.kt +7 -6
  218. package/kotlin/fixtures/import-animation/Animation.d.kt.ts +7 -0
  219. package/kotlin/fixtures/import-animation/Animation.kt +80 -0
  220. package/kotlin/fixtures/import-animation/ImportAnimation.native.tsx +13 -0
  221. package/kotlin/fixtures/import-animation/README.md +24 -0
  222. package/kotlin/fixtures/import-animation/app/_layout.tsx +4 -0
  223. package/kotlin/fixtures/import-animation/app/index+spa.tsx +1 -0
  224. package/kotlin/fixtures/import-animation/app/routes.d.ts +18 -0
  225. package/kotlin/fixtures/import-animation/package.json +23 -0
  226. package/kotlin/fixtures/import-animation/tsconfig.json +20 -0
  227. package/kotlin/fixtures/import-animation/vite.config.ts +20 -0
  228. package/kotlin/fixtures/import-editor/Editor.d.kt.ts +7 -0
  229. package/kotlin/fixtures/import-editor/Editor.kt +59 -0
  230. package/kotlin/fixtures/import-editor/ImportEditor.native.tsx +23 -0
  231. package/kotlin/fixtures/import-editor/README.md +27 -0
  232. package/kotlin/fixtures/import-editor/app/_layout.tsx +4 -0
  233. package/kotlin/fixtures/import-editor/app/index+spa.tsx +1 -0
  234. package/kotlin/fixtures/import-editor/package.json +23 -0
  235. package/kotlin/fixtures/import-editor/tsconfig.json +20 -0
  236. package/kotlin/fixtures/import-editor/vite.config.ts +20 -0
  237. package/kotlin/fixtures/import-effects/Effects.d.kt.ts +7 -0
  238. package/kotlin/fixtures/import-effects/Effects.kt +87 -0
  239. package/kotlin/fixtures/import-effects/ImportEffects.native.tsx +13 -0
  240. package/kotlin/fixtures/import-effects/README.md +19 -0
  241. package/kotlin/fixtures/import-effects/app/_layout.tsx +4 -0
  242. package/kotlin/fixtures/import-effects/app/index+spa.tsx +1 -0
  243. package/kotlin/fixtures/import-effects/package.json +23 -0
  244. package/kotlin/fixtures/import-effects/tsconfig.json +20 -0
  245. package/kotlin/fixtures/import-effects/vite.config.ts +20 -0
  246. package/kotlin/fixtures/import-layout/ImportLayout.native.tsx +13 -0
  247. package/kotlin/fixtures/import-layout/Layout.d.kt.ts +7 -0
  248. package/kotlin/fixtures/import-layout/Layout.kt +72 -0
  249. package/kotlin/fixtures/import-layout/README.md +24 -0
  250. package/kotlin/fixtures/import-layout/app/_layout.tsx +4 -0
  251. package/kotlin/fixtures/import-layout/app/index+spa.tsx +1 -0
  252. package/kotlin/fixtures/import-layout/package.json +23 -0
  253. package/kotlin/fixtures/import-layout/tsconfig.json +20 -0
  254. package/kotlin/fixtures/import-layout/vite.config.ts +20 -0
  255. package/kotlin/fixtures/import-navigation/ImportNavigation.native.tsx +18 -0
  256. package/kotlin/fixtures/import-navigation/Navigation.d.kt.ts +7 -0
  257. package/kotlin/fixtures/import-navigation/Navigation.kt +104 -0
  258. package/kotlin/fixtures/import-navigation/README.md +31 -0
  259. package/kotlin/fixtures/import-navigation/app/_layout.tsx +4 -0
  260. package/kotlin/fixtures/import-navigation/app/index+spa.tsx +1 -0
  261. package/kotlin/fixtures/import-navigation/package.json +23 -0
  262. package/kotlin/fixtures/import-navigation/tsconfig.json +20 -0
  263. package/kotlin/fixtures/import-navigation/vite.config.ts +20 -0
  264. package/kotlin/fixtures/import-overlays/ImportOverlays.native.tsx +29 -0
  265. package/kotlin/fixtures/import-overlays/Overlays.d.kt.ts +7 -0
  266. package/kotlin/fixtures/import-overlays/Overlays.kt +93 -0
  267. package/kotlin/fixtures/import-overlays/README.md +34 -0
  268. package/kotlin/fixtures/import-overlays/app/_layout.tsx +4 -0
  269. package/kotlin/fixtures/import-overlays/app/index+spa.tsx +1 -0
  270. package/kotlin/fixtures/import-overlays/package.json +23 -0
  271. package/kotlin/fixtures/import-overlays/tsconfig.json +20 -0
  272. package/kotlin/fixtures/import-overlays/vite.config.ts +20 -0
  273. package/kotlin/fixtures/import-scroll/ImportScroll.native.tsx +13 -0
  274. package/kotlin/fixtures/import-scroll/README.md +23 -0
  275. package/kotlin/fixtures/import-scroll/Scroll.d.kt.ts +7 -0
  276. package/kotlin/fixtures/import-scroll/Scroll.kt +91 -0
  277. package/kotlin/fixtures/import-scroll/app/_layout.tsx +4 -0
  278. package/kotlin/fixtures/import-scroll/app/index+spa.tsx +1 -0
  279. package/kotlin/fixtures/import-scroll/package.json +23 -0
  280. package/kotlin/fixtures/import-scroll/tsconfig.json +20 -0
  281. package/kotlin/fixtures/import-scroll/vite.config.ts +20 -0
  282. package/kotlin/fixtures/import-selection/ImportSelection.native.tsx +44 -0
  283. package/kotlin/fixtures/import-selection/README.md +32 -0
  284. package/kotlin/fixtures/import-selection/Selection.d.kt.ts +8 -0
  285. package/kotlin/fixtures/import-selection/Selection.kt +78 -0
  286. package/kotlin/fixtures/import-selection/app/_layout.tsx +4 -0
  287. package/kotlin/fixtures/import-selection/app/index+spa.tsx +1 -0
  288. package/kotlin/fixtures/import-selection/package.json +23 -0
  289. package/kotlin/fixtures/import-selection/tsconfig.json +20 -0
  290. package/kotlin/fixtures/import-selection/vite.config.ts +20 -0
  291. package/kotlin/fixtures/import-surfaces/ImportSurfaces.native.tsx +13 -0
  292. package/kotlin/fixtures/import-surfaces/README.md +22 -0
  293. package/kotlin/fixtures/import-surfaces/Surfaces.d.kt.ts +7 -0
  294. package/kotlin/fixtures/import-surfaces/Surfaces.kt +64 -0
  295. package/kotlin/fixtures/import-surfaces/app/_layout.tsx +4 -0
  296. package/kotlin/fixtures/import-surfaces/app/index+spa.tsx +1 -0
  297. package/kotlin/fixtures/import-surfaces/package.json +23 -0
  298. package/kotlin/fixtures/import-surfaces/tsconfig.json +20 -0
  299. package/kotlin/fixtures/import-surfaces/vite.config.ts +20 -0
  300. package/kotlin/fixtures/import-values/ImportValues.native.tsx +35 -0
  301. package/kotlin/fixtures/import-values/README.md +23 -0
  302. package/kotlin/fixtures/import-values/Values.d.kt.ts +11 -0
  303. package/kotlin/fixtures/import-values/Values.kt +66 -0
  304. package/kotlin/fixtures/import-values/app/_layout.tsx +4 -0
  305. package/kotlin/fixtures/import-values/app/index+spa.tsx +1 -0
  306. package/kotlin/fixtures/import-values/package.json +23 -0
  307. package/kotlin/fixtures/import-values/tsconfig.json +20 -0
  308. package/kotlin/fixtures/import-values/vite.config.ts +20 -0
  309. package/kotlin/fixtures/kotlin-app-variants/BarBehavior.kt +38 -0
  310. package/kotlin/fixtures/kotlin-app-variants/BarVisibility.kt +36 -0
  311. package/kotlin/fixtures/kotlin-app-variants/Ime.kt +3 -1
  312. package/kotlin/fixtures/kotlin-app-variants/TransientInsets.kt +50 -0
  313. package/kotlin/fork/NOTICE +6 -1
  314. package/kotlin/fork/README.md +15 -7
  315. package/kotlin/fork/build.sh +2 -0
  316. package/kotlin/fork/compose-worker-globals.patch +186 -4
  317. package/kotlin/fork/fork.json +2 -2
  318. package/kotlin/fork/skiko-worker-clock.patch +12 -0
  319. package/kotlin/toolchain.json +12 -12
  320. package/kotlin/tools/expo-types.main.kts +115 -0
  321. package/package.json +17 -23
  322. package/scripts/dev-server-scanner.ts +92 -66
  323. package/skills/contrast/SKILL.md +2 -2
  324. package/skills/peach-debug/SKILL.md +1 -1
  325. package/skills/peach-perf/SKILL.md +1 -1
  326. package/skills/peach-setup/SKILL.md +2 -1
  327. package/skills/peach-test/SKILL.md +8 -0
  328. package/src/assetCatalogContract.ts +43 -1
  329. package/src/bridge-contract-input.ts +46 -19
  330. package/src/bridge-contract.ts +0 -1
  331. package/src/bundle-channel.mjs +197 -84
  332. package/src/bundleGlobalFooter.ts +38 -0
  333. package/src/capture-contract.ts +107 -33
  334. package/src/cloud-contract.ts +5 -1
  335. package/src/cloud.ts +5 -2
  336. package/src/config.ts +22 -3
  337. package/src/configured-bundle-producer.ts +1 -1
  338. package/src/desktop-host-delivery.ts +95 -1
  339. package/src/expo-kotlin-bundle.ts +29 -0
  340. package/src/expo-kotlin-host.ts +201 -0
  341. package/src/expo-swift-host.ts +96 -76
  342. package/src/expoKotlinContract.ts +70 -0
  343. package/src/expoSwiftContract.ts +4 -25
  344. package/src/home-paths.ts +7 -4
  345. package/src/host/bridge-host.ts +23 -3
  346. package/src/host/fetch-proxy-handler.ts +13 -0
  347. package/src/host/fetch-proxy-overrides.ts +11 -0
  348. package/src/host/replacement-module-handler.ts +5 -2
  349. package/src/index.ts +3 -1
  350. package/src/kotlin-host.ts +463 -19
  351. package/src/metro-plugin.ts +56 -28
  352. package/src/metro-production-bundle.ts +2 -2
  353. package/src/native-app-identity-project.ts +190 -0
  354. package/src/native-app-identity.ts +398 -34
  355. package/src/native-firebase-config.ts +69 -15
  356. package/src/native-seam-manifest.ts +6 -0
  357. package/src/one-widget-discovery.ts +2 -1
  358. package/src/openstep-plist.ts +107 -0
  359. package/src/react-native-host-modules.ts +4 -0
  360. package/src/runtime-assets.ts +2 -2
  361. package/src/sim-client.ts +2 -2
  362. package/src/swift-binary-modules.ts +3 -1
  363. package/src/swift-compile.ts +286 -38
  364. package/src/swift-distribution-contract.ts +4 -0
  365. package/src/swift-distribution.lock.json +123 -12
  366. package/src/swift-distribution.ts +7 -3
  367. package/src/swift-host.ts +298 -83
  368. package/src/swift-package-manifest.ts +29 -2
  369. package/src/swift-package.ts +180 -10
  370. package/src/swift-packages.ts +4 -0
  371. package/src/swift-standin-emit.ts +4 -1
  372. package/src/swift-standins.ts +274 -15
  373. package/src/swift-submit.ts +62 -7
  374. package/src/swift-toolchain.ts +6 -1
  375. package/src/swiftContract.ts +126 -3
  376. package/src/vite-plugin-one.ts +13 -16
  377. package/src/vite-plugin-swift.ts +4 -5
  378. package/src/vite-plugin.ts +360 -144
  379. package/src/vite.d.ts +1 -1
  380. package/src/worker-system-chunks.ts +221 -0
  381. package/src/worklet-executable.ts +348 -38
  382. package/src/worklets-babel.ts +1 -1
  383. package/src/xcode-project.ts +243 -127
  384. package/dist-lib/agent-identity.cjs +0 -39
  385. package/dist-lib/agent-sessions.cjs +0 -760
  386. package/dist-lib/cli-constants.cjs +0 -32
  387. package/dist-lib/render-mode.cjs +0 -44
  388. package/kotlin/AppSources/android/view/View.kt +0 -74
  389. package/kotlin/AppSources/android/widget/FrameLayout.kt +0 -6
  390. package/src/render-mode.ts +0 -15
  391. /package/kotlin/{AppSources → AndroidViewSources}/android/content/Context.kt +0 -0
  392. /package/kotlin/{AppSources → AndroidViewSources}/android/content/res/Configuration.kt +0 -0
  393. /package/kotlin/{AppSources → AndroidViewSources}/android/graphics/Color.kt +0 -0
  394. /package/kotlin/{AppSources → AndroidViewSources}/android/util/DisplayMetrics.kt +0 -0
  395. /package/kotlin/{AppSources → AndroidViewSources}/dev/peach/app/JavaFormat.kt +0 -0
  396. /package/kotlin/{AppSources → Sources}/java/lang/Runnable.kt +0 -0
@@ -0,0 +1,670 @@
1
+ // maestro compare: the same maestro flows run on the partner's own iOS
2
+ // simulator under real maestro and on peach, paired screen by screen. this
3
+ // file holds the pieces with no device: flow instrumentation, step timing
4
+ // from both commands.json shapes, the three-panel pixel diff, and the html
5
+ // report. commands/maestro-compare.ts runs the two sides.
6
+
7
+ import * as path from 'path'
8
+ import pixelmatch from 'pixelmatch'
9
+ import { PNG } from 'pngjs'
10
+ import yaml from 'yaml'
11
+
12
+ // injected captures live under this screenshot path on both sides, so they
13
+ // pair by name and never collide with the flow's own takeScreenshot names.
14
+ export const COMPARE_SHOT_DIR = 'peach-compare'
15
+
16
+ // steps that leave the screen as it was, or leave no app on screen: every
17
+ // other top-level step is followed by a capture. maestro's ios launchApp
18
+ // returns while the launch zoom still shows the home screen, so its capture
19
+ // would compare springboard to the app; the next step's capture is the first
20
+ // app frame.
21
+ const NO_CAPTURE_STEPS = new Set([
22
+ 'launchApp',
23
+ 'takeScreenshot',
24
+ 'runScript',
25
+ 'evalScript',
26
+ 'setClipboard',
27
+ 'assertTrue',
28
+ 'startRecording',
29
+ 'stopRecording',
30
+ 'addMedia',
31
+ 'setLocation',
32
+ 'travel',
33
+ 'stopApp',
34
+ 'killApp',
35
+ 'clearState',
36
+ 'clearKeychain',
37
+ 'setAirplaneMode',
38
+ 'toggleAirplaneMode',
39
+ ])
40
+
41
+ export interface CompareCapture {
42
+ // screenshot name both sides write (no extension)
43
+ name: string
44
+ // 1-based index of the flow's own top-level step the capture follows
45
+ step: number
46
+ label: string
47
+ }
48
+
49
+ function stepKey(step: unknown): string | null {
50
+ if (typeof step === 'string') return step
51
+ if (step && typeof step === 'object' && !Array.isArray(step)) {
52
+ return Object.keys(step)[0] ?? null
53
+ }
54
+ return null
55
+ }
56
+
57
+ function stepTarget(step: unknown): string {
58
+ if (!step || typeof step !== 'object' || Array.isArray(step)) return ''
59
+ const value = (step as Record<string, unknown>)[stepKey(step) ?? '']
60
+ if (typeof value === 'string' || typeof value === 'number') return String(value)
61
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
62
+ const v = value as Record<string, unknown>
63
+ for (const key of [
64
+ 'text',
65
+ 'id',
66
+ 'label',
67
+ 'file',
68
+ 'link',
69
+ 'url',
70
+ 'direction',
71
+ 'point',
72
+ ]) {
73
+ if (typeof v[key] === 'string' || typeof v[key] === 'number') return String(v[key])
74
+ }
75
+ if (v.visible) return stepTarget({ visible: v.visible })
76
+ }
77
+ return ''
78
+ }
79
+
80
+ export function stepLabel(step: unknown): string {
81
+ const key = stepKey(step) ?? 'step'
82
+ const target = stepTarget(step)
83
+ return target ? `${key} ${target}` : key
84
+ }
85
+
86
+ function slugify(value: string): string {
87
+ return (
88
+ value
89
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
90
+ .replace(/^-+|-+$/g, '')
91
+ .slice(0, 48) || 'step'
92
+ )
93
+ }
94
+
95
+ // relative subflow, script and media paths resolve against the flow file. the
96
+ // instrumented copy lives elsewhere, so they become absolute paths to the
97
+ // originals (both maestro and peach resolve an absolute path as is).
98
+ function absolutizeRefs(steps: unknown, flowDir: string): unknown {
99
+ if (!Array.isArray(steps)) return steps
100
+ const abs = (p: unknown) =>
101
+ typeof p === 'string' && !path.isAbsolute(p) && !p.includes('${')
102
+ ? path.resolve(flowDir, p)
103
+ : p
104
+ return steps.map((step) => {
105
+ if (!step || typeof step !== 'object' || Array.isArray(step)) return step
106
+ const out: Record<string, unknown> = { ...(step as Record<string, unknown>) }
107
+ for (const key of ['runFlow', 'runScript', 'retry', 'repeat']) {
108
+ const value = out[key]
109
+ if (key !== 'repeat' && key !== 'retry' && typeof value === 'string') {
110
+ out[key] = abs(value)
111
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
112
+ const v = { ...(value as Record<string, unknown>) }
113
+ if ('file' in v) v.file = abs(v.file)
114
+ if (Array.isArray(v.commands)) v.commands = absolutizeRefs(v.commands, flowDir)
115
+ out[key] = v
116
+ }
117
+ }
118
+ if (Array.isArray(out.addMedia)) out.addMedia = out.addMedia.map(abs)
119
+ return out
120
+ })
121
+ }
122
+
123
+ // insert one capture after every screen-changing top-level step. subflows run
124
+ // as one step and are captured once, after they return.
125
+ export function instrumentFlowSource(
126
+ source: string,
127
+ flowDir: string,
128
+ ): { source: string; captures: CompareCapture[] } {
129
+ const docs = yaml.parseAllDocuments(source).map((doc) => {
130
+ if (doc.errors.length > 0) throw new Error(doc.errors[0]!.message)
131
+ return doc.toJS() as unknown
132
+ })
133
+ let header: Record<string, unknown> | null = null
134
+ let commands: unknown[] | null = null
135
+ if (docs.length >= 2) {
136
+ header = (docs[0] as Record<string, unknown>) ?? {}
137
+ commands = Array.isArray(docs[1]) ? docs[1] : []
138
+ } else if (Array.isArray(docs[0])) {
139
+ commands = docs[0]
140
+ } else {
141
+ throw new Error('flow has no command list after its header')
142
+ }
143
+ if (header) {
144
+ header = { ...header }
145
+ for (const hook of ['onFlowStart', 'onFlowComplete']) {
146
+ if (Array.isArray(header[hook]))
147
+ header[hook] = absolutizeRefs(header[hook], flowDir)
148
+ }
149
+ }
150
+ const captures: CompareCapture[] = []
151
+ const out: unknown[] = []
152
+ for (const [index, step] of (
153
+ absolutizeRefs(commands, flowDir) as unknown[]
154
+ ).entries()) {
155
+ out.push(step)
156
+ const key = stepKey(step)
157
+ if (!key || NO_CAPTURE_STEPS.has(key)) continue
158
+ const label = stepLabel(step)
159
+ const name = `${COMPARE_SHOT_DIR}/${String(captures.length + 1).padStart(3, '0')}-${slugify(label)}`
160
+ captures.push({ name, step: index + 1, label })
161
+ out.push({ takeScreenshot: name })
162
+ }
163
+ const body = yaml.stringify(out, { lineWidth: 0 })
164
+ return {
165
+ source: header ? `${yaml.stringify(header, { lineWidth: 0 })}---\n${body}` : body,
166
+ captures,
167
+ }
168
+ }
169
+
170
+ export interface CompareStep {
171
+ label: string
172
+ status: 'passed' | 'failed' | 'skipped'
173
+ durationMs: number
174
+ error?: string
175
+ }
176
+
177
+ const NATIVE_COMMAND_NAMES: Record<string, string> = {
178
+ tapOnElement: 'tapOn',
179
+ tapOnPointV2: 'tapOn',
180
+ backPress: 'back',
181
+ inputText: 'inputText',
182
+ inputRandomText: 'inputRandomText',
183
+ eraseText: 'eraseText',
184
+ scrollUntilVisible: 'scrollUntilVisible',
185
+ }
186
+
187
+ function nativeSelectorText(value: unknown): string {
188
+ if (!value || typeof value !== 'object') return ''
189
+ const v = value as Record<string, unknown>
190
+ const selector = (v.selector ?? v.element ?? v.condition) as
191
+ | Record<string, unknown>
192
+ | undefined
193
+ const pick = (o: Record<string, unknown> | undefined): string => {
194
+ if (!o) return ''
195
+ for (const key of ['textRegex', 'idRegex', 'text', 'id']) {
196
+ if (typeof o[key] === 'string') return o[key] as string
197
+ }
198
+ for (const key of ['visible', 'notVisible']) {
199
+ const inner = pick(o[key] as Record<string, unknown> | undefined)
200
+ if (inner) return inner
201
+ }
202
+ return ''
203
+ }
204
+ return (
205
+ pick(selector) ||
206
+ ['text', 'path', 'appId', 'link', 'sourceDescription', 'direction']
207
+ .map((key) => (typeof v[key] === 'string' ? (v[key] as string) : ''))
208
+ .find(Boolean) ||
209
+ ''
210
+ )
211
+ }
212
+
213
+ export function describeNativeCommand(command: unknown): string {
214
+ if (!command || typeof command !== 'object') return 'step'
215
+ const key = Object.keys(command)[0] ?? 'step'
216
+ const base = key.replace(/Command$/, '')
217
+ const value = (command as Record<string, unknown>)[key] as
218
+ | Record<string, any>
219
+ | undefined
220
+ const name =
221
+ base === 'assertCondition'
222
+ ? value?.condition?.notVisible
223
+ ? 'assertNotVisible'
224
+ : 'assertVisible'
225
+ : (NATIVE_COMMAND_NAMES[base] ?? base)
226
+ const target = nativeSelectorText(value)
227
+ return target ? `${name} ${target}` : name
228
+ }
229
+
230
+ const NATIVE_BOOKKEEPING = new Set([
231
+ 'applyConfigurationCommand',
232
+ 'defineVariablesCommand',
233
+ ])
234
+
235
+ function isInjectedShot(label: string): boolean {
236
+ return label.includes(`${COMPARE_SHOT_DIR}/`)
237
+ }
238
+
239
+ // real maestro's per-flow commands.json: top-level commands only, without its
240
+ // config bookkeeping and without the injected captures that passed (a failed
241
+ // capture is a real failure of the run and stays).
242
+ export function nativeStepsFromCommandsJson(json: unknown): CompareStep[] {
243
+ if (!Array.isArray(json)) return []
244
+ const steps: CompareStep[] = []
245
+ for (const entry of json as Array<Record<string, any>>) {
246
+ const command = entry?.command
247
+ const metadata = entry?.metadata ?? {}
248
+ if (!command || typeof command !== 'object') continue
249
+ const key = Object.keys(command)[0] ?? ''
250
+ if (NATIVE_BOOKKEEPING.has(key)) continue
251
+ if (typeof metadata.depth === 'number' && metadata.depth > 0) continue
252
+ const raw = String(metadata.status ?? '')
253
+ const status = raw === 'FAILED' ? 'failed' : raw === 'SKIPPED' ? 'skipped' : 'passed'
254
+ const injected =
255
+ key === 'takeScreenshotCommand' && isInjectedShot(String(command[key]?.path ?? ''))
256
+ if (injected && status !== 'failed') continue
257
+ const label = describeNativeCommand(metadata.evaluatedCommand ?? command)
258
+ const error =
259
+ metadata.error?.message ??
260
+ (typeof metadata.error === 'string' ? metadata.error : undefined)
261
+ steps.push({
262
+ label,
263
+ status,
264
+ durationMs: typeof metadata.duration === 'number' ? metadata.duration : 0,
265
+ ...(error ? { error: String(error) } : {}),
266
+ })
267
+ }
268
+ return steps
269
+ }
270
+
271
+ // peach's commands.json (maestro-report.ts buildFlowCommandsJson)
272
+ export function peachStepsFromCommandsJson(json: unknown): CompareStep[] {
273
+ if (!Array.isArray(json)) return []
274
+ return (json as Array<Record<string, any>>)
275
+ .filter(
276
+ (entry) =>
277
+ entry?.status === 'failure' || !isInjectedShot(String(entry?.command ?? '')),
278
+ )
279
+ .map((entry) => ({
280
+ label: String(entry.command ?? 'step'),
281
+ status:
282
+ entry.status === 'failure'
283
+ ? 'failed'
284
+ : entry.status === 'skipped'
285
+ ? 'skipped'
286
+ : 'passed',
287
+ durationMs: typeof entry.durationMs === 'number' ? entry.durationMs : 0,
288
+ ...(entry.error ? { error: String(entry.error) } : {}),
289
+ }))
290
+ }
291
+
292
+ export interface ScreenDiff {
293
+ width: number
294
+ height: number
295
+ diffPixels: number
296
+ diffPercent: number
297
+ // peach's capture was resampled to the iOS capture's size
298
+ resized: boolean
299
+ // the two captures have different aspect ratios (different devices)
300
+ aspectMismatch: boolean
301
+ // rows at the top left out of the score: the status bar, which shows the
302
+ // system clock and the dynamic island rather than the app
303
+ maskedTopPx: number
304
+ }
305
+
306
+ function resample(src: PNG, width: number, height: number): PNG {
307
+ const out = new PNG({ width, height })
308
+ const sx = src.width / width
309
+ const sy = src.height / height
310
+ for (let y = 0; y < height; y++) {
311
+ const fy = Math.min(src.height - 1, (y + 0.5) * sy - 0.5)
312
+ const y0 = Math.max(0, Math.floor(fy))
313
+ const y1 = Math.min(src.height - 1, y0 + 1)
314
+ const wy = Math.max(0, fy - y0)
315
+ for (let x = 0; x < width; x++) {
316
+ const fx = Math.min(src.width - 1, (x + 0.5) * sx - 0.5)
317
+ const x0 = Math.max(0, Math.floor(fx))
318
+ const x1 = Math.min(src.width - 1, x0 + 1)
319
+ const wx = Math.max(0, fx - x0)
320
+ const o = (y * width + x) * 4
321
+ for (let c = 0; c < 4; c++) {
322
+ const a = src.data[(y0 * src.width + x0) * 4 + c]!
323
+ const b = src.data[(y0 * src.width + x1) * 4 + c]!
324
+ const d = src.data[(y1 * src.width + x0) * 4 + c]!
325
+ const e = src.data[(y1 * src.width + x1) * 4 + c]!
326
+ out.data[o + c] = Math.round(
327
+ (a * (1 - wx) + b * wx) * (1 - wy) + (d * (1 - wx) + e * wx) * wy,
328
+ )
329
+ }
330
+ }
331
+ }
332
+ return out
333
+ }
334
+
335
+ // integer box downscale into dst at (left, 0). diff panels keep any red diff
336
+ // pixel in a block, so a one-pixel difference survives the shrink.
337
+ function drawDownscaled(
338
+ dst: PNG,
339
+ src: PNG,
340
+ factor: number,
341
+ left: number,
342
+ keepRed: boolean,
343
+ ) {
344
+ const w = Math.floor(src.width / factor)
345
+ const h = Math.floor(src.height / factor)
346
+ for (let y = 0; y < h; y++) {
347
+ for (let x = 0; x < w; x++) {
348
+ let r = 0
349
+ let g = 0
350
+ let b = 0
351
+ let red = false
352
+ for (let dy = 0; dy < factor; dy++) {
353
+ for (let dx = 0; dx < factor; dx++) {
354
+ const i = ((y * factor + dy) * src.width + x * factor + dx) * 4
355
+ const pr = src.data[i]!
356
+ const pg = src.data[i + 1]!
357
+ const pb = src.data[i + 2]!
358
+ if (keepRed && pr === 255 && pg === 0 && pb === 0) red = true
359
+ r += pr
360
+ g += pg
361
+ b += pb
362
+ }
363
+ }
364
+ const n = factor * factor
365
+ const o = (y * dst.width + left + x) * 4
366
+ dst.data[o] = red ? 255 : Math.round(r / n)
367
+ dst.data[o + 1] = red ? 0 : Math.round(g / n)
368
+ dst.data[o + 2] = red ? 0 : Math.round(b / n)
369
+ dst.data[o + 3] = 255
370
+ }
371
+ }
372
+ }
373
+
374
+ export const TRIPTYCH_GAP = 24
375
+
376
+ // pixel diff at the iOS capture's full resolution, then one image holding the
377
+ // iOS capture, peach's capture and the diff side by side.
378
+ export function compareScreens(
379
+ nativePng: Buffer,
380
+ peachPng: Buffer,
381
+ opts: { maskTopPx?: number } = {},
382
+ ): { diff: ScreenDiff; triptych: Buffer } {
383
+ const native = PNG.sync.read(nativePng)
384
+ let peach: PNG = PNG.sync.read(peachPng)
385
+ const { width, height } = native
386
+ const aspectMismatch = Math.abs(peach.width / peach.height - width / height) > 0.01
387
+ const resized = peach.width !== width || peach.height !== height
388
+ if (resized) peach = resample(peach, width, height)
389
+ const maskedTopPx = Math.min(height, Math.max(0, Math.round(opts.maskTopPx ?? 0)))
390
+ const scored = height - maskedTopPx
391
+ const offset = maskedTopPx * width * 4
392
+ const diffImage = new PNG({ width, height })
393
+ // the masked band reads as plain light gray in the diff panel
394
+ diffImage.data.fill(235, 0, offset)
395
+ for (let i = 3; i < offset; i += 4) diffImage.data[i] = 255
396
+ const diffPixels =
397
+ scored > 0
398
+ ? pixelmatch(
399
+ native.data.subarray(offset),
400
+ peach.data.subarray(offset),
401
+ diffImage.data.subarray(offset),
402
+ width,
403
+ scored,
404
+ { threshold: 0.1 },
405
+ )
406
+ : 0
407
+ const factor = width >= 1000 ? 2 : 1
408
+ const panelWidth = Math.floor(width / factor)
409
+ const panelHeight = Math.floor(height / factor)
410
+ const out = new PNG({ width: panelWidth * 3 + TRIPTYCH_GAP * 2, height: panelHeight })
411
+ out.data.fill(255)
412
+ drawDownscaled(out, native, factor, 0, false)
413
+ drawDownscaled(out, peach, factor, panelWidth + TRIPTYCH_GAP, false)
414
+ drawDownscaled(out, diffImage, factor, (panelWidth + TRIPTYCH_GAP) * 2, true)
415
+ return {
416
+ diff: {
417
+ width,
418
+ height,
419
+ diffPixels,
420
+ diffPercent: scored > 0 ? (diffPixels / (width * scored)) * 100 : 0,
421
+ resized,
422
+ aspectMismatch,
423
+ maskedTopPx,
424
+ },
425
+ triptych: PNG.sync.write(out),
426
+ }
427
+ }
428
+
429
+ export interface CompareSide {
430
+ status: 'passed' | 'failed' | 'not run'
431
+ durationMs: number
432
+ steps: CompareStep[]
433
+ errors: string[]
434
+ }
435
+
436
+ export interface CompareScreen {
437
+ name: string
438
+ step: number
439
+ label: string
440
+ // report-relative path of the three-panel image
441
+ image?: string
442
+ // report-relative path of a capture only one side produced
443
+ nativeOnly?: string
444
+ peachOnly?: string
445
+ diff?: ScreenDiff
446
+ }
447
+
448
+ export interface CompareFlow {
449
+ name: string
450
+ file: string
451
+ native: CompareSide
452
+ peach: CompareSide
453
+ screens: CompareScreen[]
454
+ }
455
+
456
+ export interface CompareReport {
457
+ createdAt: string
458
+ device: string
459
+ thresholdPercent: number
460
+ flows: CompareFlow[]
461
+ }
462
+
463
+ export function screensOverThreshold(report: CompareReport) {
464
+ return report.flows.flatMap((flow) =>
465
+ flow.screens
466
+ .filter((s) => s.diff && s.diff.diffPercent > report.thresholdPercent)
467
+ .map((screen) => ({ flow, screen })),
468
+ )
469
+ }
470
+
471
+ // a compare passes when both sides pass every flow, every capture pairs, and
472
+ // no screen differs by more than the threshold.
473
+ export function comparePassed(report: CompareReport): boolean {
474
+ return (
475
+ report.flows.length > 0 &&
476
+ report.flows.every(
477
+ (flow) =>
478
+ flow.native.status === 'passed' &&
479
+ flow.peach.status === 'passed' &&
480
+ flow.screens.every((s) => s.diff),
481
+ ) &&
482
+ screensOverThreshold(report).length === 0
483
+ )
484
+ }
485
+
486
+ function esc(value: string): string {
487
+ return value
488
+ .replace(/&/g, '&amp;')
489
+ .replace(/</g, '&lt;')
490
+ .replace(/>/g, '&gt;')
491
+ .replace(/"/g, '&quot;')
492
+ }
493
+
494
+ function seconds(ms: number): string {
495
+ return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`
496
+ }
497
+
498
+ function percent(value: number): string {
499
+ return value === 0 ? '0%' : value < 0.01 ? '<0.01%' : `${value.toFixed(2)}%`
500
+ }
501
+
502
+ function statusPill(side: CompareSide): string {
503
+ const cls = side.status === 'passed' ? 'ok' : 'bad'
504
+ return `<span class="pill ${cls}">${esc(side.status)}</span>`
505
+ }
506
+
507
+ function deltaCell(native: number, peach: number): string {
508
+ if (!native && !peach) return '<td class="num"></td>'
509
+ const delta = peach - native
510
+ const cls = delta > 250 ? 'slower' : delta < -250 ? 'faster' : ''
511
+ return `<td class="num ${cls}">${delta >= 0 ? '+' : '−'}${seconds(Math.abs(delta))}</td>`
512
+ }
513
+
514
+ function timingTable(flow: CompareFlow): string {
515
+ const rows: string[] = []
516
+ const count = Math.max(flow.native.steps.length, flow.peach.steps.length)
517
+ for (let i = 0; i < count; i++) {
518
+ const n = flow.native.steps[i]
519
+ const p = flow.peach.steps[i]
520
+ const failed = n?.status === 'failed' || p?.status === 'failed'
521
+ rows.push(
522
+ `<tr${failed ? ' class="failed"' : ''}><td class="num">${i + 1}</td><td>${esc(p?.label ?? n?.label ?? '')}</td>` +
523
+ `<td class="num">${n ? seconds(n.durationMs) : '–'}</td><td class="num">${p ? seconds(p.durationMs) : '–'}</td>` +
524
+ `${n && p ? deltaCell(n.durationMs, p.durationMs) : '<td></td>'}</tr>`,
525
+ )
526
+ }
527
+ return `<table class="timing"><thead><tr><th>#</th><th>step</th><th>iOS</th><th>Peach</th><th>Peach vs iOS</th></tr></thead><tbody>${rows.join('')}</tbody>
528
+ <tfoot><tr><td></td><td>flow total</td><td class="num">${seconds(flow.native.durationMs)}</td><td class="num">${seconds(flow.peach.durationMs)}</td>${deltaCell(flow.native.durationMs, flow.peach.durationMs)}</tr></tfoot></table>`
529
+ }
530
+
531
+ function errorList(flow: CompareFlow): string {
532
+ const items = [
533
+ ...flow.native.errors.map((e) => ({ side: 'iOS', text: e })),
534
+ ...flow.peach.errors.map((e) => ({ side: 'Peach', text: e })),
535
+ ]
536
+ if (items.length === 0) return '<p class="quiet">No errors on either side.</p>'
537
+ return `<ul class="errors">${items
538
+ .map((e) => `<li><span class="side">${e.side}</span><pre>${esc(e.text)}</pre></li>`)
539
+ .join('')}</ul>`
540
+ }
541
+
542
+ function screenCard(screen: CompareScreen, threshold: number, flowIndex: number): string {
543
+ const id = `f${flowIndex}-${screen.name.split('/').pop()}`
544
+ const over = screen.diff ? screen.diff.diffPercent > threshold : true
545
+ const badge = screen.diff
546
+ ? `<span class="pill ${over ? 'bad' : 'ok'}">${percent(screen.diff.diffPercent)} differ</span>`
547
+ : `<span class="pill bad">${screen.nativeOnly ? 'missing on Peach' : screen.peachOnly ? 'missing on iOS' : 'missing on both'}</span>`
548
+ const notes = [
549
+ screen.diff?.aspectMismatch
550
+ ? 'different aspect ratio: the two runs used different devices'
551
+ : '',
552
+ screen.diff?.resized && !screen.diff.aspectMismatch
553
+ ? 'Peach capture resampled to the iOS size'
554
+ : '',
555
+ screen.diff?.maskedTopPx ? 'status bar left out of the score' : '',
556
+ ].filter(Boolean)
557
+ let body = ''
558
+ if (screen.image) {
559
+ body = `<div class="cols"><span>iOS simulator</span><span>Peach</span><span>pixel diff</span></div>
560
+ <a href="${esc(screen.image)}" target="_blank"><img loading="lazy" src="${esc(screen.image)}" alt="${esc(screen.label)}"></a>`
561
+ } else {
562
+ const only = screen.nativeOnly ?? screen.peachOnly
563
+ body = only
564
+ ? `<div class="cols one"><span>${screen.nativeOnly ? 'iOS simulator' : 'Peach'} only</span></div><a href="${esc(only)}" target="_blank"><img class="single" loading="lazy" src="${esc(only)}" alt="${esc(screen.label)}"></a>`
565
+ : '<p class="quiet">Neither side reached this step.</p>'
566
+ }
567
+ return `<figure class="screen${over ? ' over' : ''}" id="${esc(id)}"><figcaption><span class="step">after step ${screen.step}</span> ${esc(screen.label)} ${badge}${notes.map((n) => `<span class="note">${esc(n)}</span>`).join('')}</figcaption>${body}</figure>`
568
+ }
569
+
570
+ export function buildCompareHtml(report: CompareReport): string {
571
+ const screens = report.flows.flatMap((f) => f.screens)
572
+ const compared = screens.filter((s) => s.diff)
573
+ const over = screensOverThreshold(report).sort(
574
+ (a, b) => b.screen.diff!.diffPercent - a.screen.diff!.diffPercent,
575
+ )
576
+ const nativePassed = report.flows.filter((f) => f.native.status === 'passed').length
577
+ const peachPassed = report.flows.filter((f) => f.peach.status === 'passed').length
578
+ const nativeMs = report.flows.reduce((sum, f) => sum + f.native.durationMs, 0)
579
+ const peachMs = report.flows.reduce((sum, f) => sum + f.peach.durationMs, 0)
580
+ const peachErrors = report.flows.reduce((sum, f) => sum + f.peach.errors.length, 0)
581
+ const nativeErrors = report.flows.reduce((sum, f) => sum + f.native.errors.length, 0)
582
+ const verdict = comparePassed(report)
583
+ const flowIndex = new Map(report.flows.map((f, i) => [f, i]))
584
+ return `<!doctype html>
585
+ <html lang="en">
586
+ <head>
587
+ <meta charset="utf-8">
588
+ <meta name="viewport" content="width=device-width, initial-scale=1">
589
+ <title>Maestro compare</title>
590
+ <style>
591
+ :root { --bg: #fafaf9; --card: #fff; --ink: #1c1917; --quiet: #78716c; --line: #e7e5e4; --ok: #15803d; --ok-bg: #dcfce7; --bad: #b91c1c; --bad-bg: #fee2e2; --slower: #b45309; --faster: #15803d; }
592
+ @media (prefers-color-scheme: dark) { :root { --bg: #0c0a09; --card: #1c1917; --ink: #f5f5f4; --quiet: #a8a29e; --line: #292524; --ok: #4ade80; --ok-bg: #14532d; --bad: #fca5a5; --bad-bg: #7f1d1d; --slower: #fbbf24; --faster: #4ade80; } }
593
+ * { box-sizing: border-box; }
594
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; }
595
+ main { max-width: 1280px; margin: 0 auto; padding: 32px 16px 64px; }
596
+ h1 { font-size: 28px; margin: 0 0 4px; letter-spacing: -0.02em; }
597
+ h2 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
598
+ h3 { font-size: 15px; margin: 24px 0 8px; color: var(--quiet); font-weight: 600; }
599
+ .sub { color: var(--quiet); margin: 0 0 24px; }
600
+ .verdict { display: inline-block; font-weight: 600; padding: 4px 12px; border-radius: 999px; margin-bottom: 16px; }
601
+ .verdict.ok { background: var(--ok-bg); color: var(--ok); } .verdict.bad { background: var(--bad-bg); color: var(--bad); }
602
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 32px; }
603
+ .stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
604
+ .stat b { display: block; font-size: 22px; letter-spacing: -0.02em; } .stat span { color: var(--quiet); font-size: 13px; }
605
+ .worst { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 8px 16px; margin-bottom: 32px; }
606
+ .worst li { margin: 6px 0; } .worst a { color: inherit; }
607
+ section.flow { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; margin-bottom: 24px; }
608
+ .flowhead { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
609
+ .flowhead code { color: var(--quiet); font-size: 13px; }
610
+ .pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; vertical-align: middle; }
611
+ .pill.ok { background: var(--ok-bg); color: var(--ok); } .pill.bad { background: var(--bad-bg); color: var(--bad); }
612
+ .errors { list-style: none; padding: 0; margin: 0; } .errors li { display: flex; gap: 10px; margin: 6px 0; }
613
+ .errors .side { flex: none; width: 48px; font-size: 12px; font-weight: 600; color: var(--bad); padding-top: 3px; }
614
+ .errors pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
615
+ .quiet { color: var(--quiet); margin: 0; }
616
+ details summary { cursor: pointer; color: var(--quiet); font-weight: 600; margin: 24px 0 8px; }
617
+ table.timing { width: 100%; border-collapse: collapse; font-size: 13px; }
618
+ .timing th, .timing td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); }
619
+ .timing .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
620
+ .timing tr.failed td { color: var(--bad); } .timing tfoot td { font-weight: 600; border-bottom: none; }
621
+ .slower { color: var(--slower); } .faster { color: var(--faster); }
622
+ .screens { display: grid; gap: 20px; }
623
+ figure.screen { margin: 0; border-top: 1px solid var(--line); padding-top: 16px; }
624
+ figcaption { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-bottom: 10px; font-weight: 600; }
625
+ figcaption .step { color: var(--quiet); font-weight: 500; } figcaption .note { color: var(--quiet); font-weight: 400; font-size: 13px; }
626
+ .cols { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.33%; color: var(--quiet); font-size: 12px; text-align: center; margin-bottom: 4px; }
627
+ .cols.one { grid-template-columns: 1fr; text-align: left; }
628
+ figure img { display: block; width: 100%; height: auto; border-radius: 8px; }
629
+ figure img.single { width: 33%; }
630
+ </style>
631
+ </head>
632
+ <body>
633
+ <main>
634
+ <h1>Maestro compare</h1>
635
+ <p class="sub">${esc(new Date(report.createdAt).toUTCString())} · ${esc(report.device)} · screens over ${report.thresholdPercent}% are flagged</p>
636
+ <div class="verdict ${verdict ? 'ok' : 'bad'}">${verdict ? 'Peach matches iOS on every screen' : 'Peach differs from iOS: see below'}</div>
637
+ <div class="stats">
638
+ <div class="stat"><b>${nativePassed}/${report.flows.length} · ${peachPassed}/${report.flows.length}</b><span>flows passed on iOS · on Peach</span></div>
639
+ <div class="stat"><b>${compared.length}/${screens.length}</b><span>screens compared</span></div>
640
+ <div class="stat"><b>${over.length}</b><span>screens over ${report.thresholdPercent}%</span></div>
641
+ <div class="stat"><b>${seconds(nativeMs)} · ${seconds(peachMs)}</b><span>run time on iOS · on Peach</span></div>
642
+ <div class="stat"><b>${nativeErrors} · ${peachErrors}</b><span>errors on iOS · on Peach</span></div>
643
+ </div>
644
+ ${
645
+ over.length
646
+ ? `<h3>Screens that differ most</h3><ol class="worst">${over
647
+ .map(
648
+ ({ flow, screen }) =>
649
+ `<li><a href="#f${flowIndex.get(flow)}-${esc(screen.name.split('/').pop()!)}">${esc(flow.name)}: ${esc(screen.label)}</a> · ${percent(screen.diff!.diffPercent)}</li>`,
650
+ )
651
+ .join('')}</ol>`
652
+ : ''
653
+ }
654
+ ${report.flows
655
+ .map(
656
+ (flow, i) => `<section class="flow">
657
+ <div class="flowhead"><h2>${esc(flow.name)}</h2><span>iOS ${statusPill(flow.native)}</span><span>Peach ${statusPill(flow.peach)}</span><code>${esc(flow.file)}</code></div>
658
+ <h3>Errors</h3>
659
+ ${errorList(flow)}
660
+ <details><summary>Step timing</summary>${timingTable(flow)}</details>
661
+ <h3>Screens</h3>
662
+ <div class="screens">${flow.screens.map((s) => screenCard(s, report.thresholdPercent, i)).join('')}</div>
663
+ </section>`,
664
+ )
665
+ .join('\n')}
666
+ </main>
667
+ </body>
668
+ </html>
669
+ `
670
+ }