sootsim 0.1.295 → 0.1.297

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 (185) hide show
  1. package/README.md +13 -4
  2. package/detox/index.ts +380 -28
  3. package/detox/motion-change.cjs +109 -0
  4. package/detox/motion-change.d.cts +35 -0
  5. package/dist-cli/bin.js +5 -5
  6. package/dist-cli/chunks/{agent-ASAYE5YY.js → agent-EYT6T2IB.js} +2 -2
  7. package/dist-cli/chunks/{agent-wrapper-K2D4Q5AA.js → agent-wrapper-UU4WV5UG.js} +2 -2
  8. package/dist-cli/chunks/app-fonts-6SO36STZ.js +4 -0
  9. package/dist-cli/chunks/{assert-4FKHHDY7.js → assert-M4AVODJW.js} +2 -2
  10. package/dist-cli/chunks/{auth-BQPOPEFK.js → auth-JJNP2L7C.js} +2 -2
  11. package/dist-cli/chunks/{beta-3LNYONDF.js → beta-BIJ5MU6X.js} +2 -2
  12. package/dist-cli/chunks/{camera-Y5MZ5V4L.js → camera-FGQGSHG5.js} +2 -2
  13. package/dist-cli/chunks/{chunk-RV67EC7L.js → chunk-2JCUNWU2.js} +2 -2
  14. package/dist-cli/chunks/chunk-3QVML26F.js +4 -0
  15. package/dist-cli/chunks/{chunk-XCCDO6I2.js → chunk-445FUUZC.js} +1 -1
  16. package/dist-cli/chunks/{chunk-T2DHHOWE.js → chunk-4EAJWGB4.js} +2 -2
  17. package/dist-cli/chunks/chunk-63UBXHOB.js +4 -0
  18. package/dist-cli/chunks/{chunk-Y6FJ5FCY.js → chunk-6YIHNXMZ.js} +1 -1
  19. package/dist-cli/chunks/{chunk-DKS75M2I.js → chunk-74ATIHUI.js} +2 -2
  20. package/dist-cli/chunks/{chunk-UBIGBHUM.js → chunk-7O6HJRGX.js} +2 -2
  21. package/dist-cli/chunks/{chunk-UWFAWMPE.js → chunk-7XXIMTJR.js} +2 -2
  22. package/dist-cli/chunks/{chunk-I4QJVACX.js → chunk-AC2HNBEI.js} +1 -1
  23. package/dist-cli/chunks/chunk-BAFDYXCM.js +9 -0
  24. package/dist-cli/chunks/chunk-DMJN3NC3.js +12 -0
  25. package/dist-cli/chunks/{chunk-JPPGM6RZ.js → chunk-E4O5A2QP.js} +2 -2
  26. package/dist-cli/chunks/{chunk-FH7DQ2HB.js → chunk-EKA2DXOV.js} +2 -2
  27. package/dist-cli/chunks/{chunk-S6BEA6OC.js → chunk-ERRHAM2H.js} +1 -1
  28. package/dist-cli/chunks/{chunk-MEYVMDZR.js → chunk-FC2ULGKP.js} +2 -2
  29. package/dist-cli/chunks/{chunk-RNW36VM5.js → chunk-H6RQAFD5.js} +4 -4
  30. package/dist-cli/chunks/{chunk-2RI6RC6I.js → chunk-HNQ2FYAG.js} +2 -2
  31. package/dist-cli/chunks/{chunk-CVDC2ER2.js → chunk-HRPE3IND.js} +2 -2
  32. package/dist-cli/chunks/chunk-HTX6BHJZ.js +4 -0
  33. package/dist-cli/chunks/{chunk-6VF477QO.js → chunk-I4G2BKZB.js} +1 -1
  34. package/dist-cli/chunks/chunk-I6HFNMYG.js +125 -0
  35. package/dist-cli/chunks/{chunk-L7V46673.js → chunk-IKQSTS2U.js} +1 -1
  36. package/dist-cli/chunks/{chunk-7GZ6XF2B.js → chunk-IMXVXXWX.js} +2 -2
  37. package/dist-cli/chunks/{chunk-E6DZD6JD.js → chunk-IVV2KJTY.js} +1 -1
  38. package/dist-cli/chunks/{chunk-WFYQNZTT.js → chunk-K36T5764.js} +3 -3
  39. package/dist-cli/chunks/{chunk-SU5WQ4SQ.js → chunk-KQ7WHS3C.js} +1 -1
  40. package/dist-cli/chunks/{chunk-TNKZWHD2.js → chunk-LGU5JGT6.js} +1 -1
  41. package/dist-cli/chunks/{chunk-U2QPKFOY.js → chunk-O4IGQ7XT.js} +2 -2
  42. package/dist-cli/chunks/{chunk-UAZ3NVKO.js → chunk-OBKNUUIF.js} +2 -2
  43. package/dist-cli/chunks/chunk-OTYWESTV.js +4 -0
  44. package/dist-cli/chunks/chunk-QDL6YYL4.js +4 -0
  45. package/dist-cli/chunks/{chunk-XQRUWC3F.js → chunk-QRFC2ZZC.js} +2 -2
  46. package/dist-cli/chunks/chunk-RZQ7SBBQ.js +4 -0
  47. package/dist-cli/chunks/chunk-SDHGPSEC.js +4 -0
  48. package/dist-cli/chunks/{chunk-X5FTDIHQ.js → chunk-U37NEIQF.js} +1 -1
  49. package/dist-cli/chunks/chunk-V4N34AOX.js +15 -0
  50. package/dist-cli/chunks/{chunk-K7LQB4IF.js → chunk-VTYVJJHN.js} +1 -1
  51. package/dist-cli/chunks/chunk-VVSQCLBU.js +6 -0
  52. package/dist-cli/chunks/chunk-W2QIWNCW.js +6 -0
  53. package/dist-cli/chunks/{chunk-QPA44BZC.js → chunk-WDZ4PVKV.js} +11 -11
  54. package/dist-cli/chunks/chunk-WFXFYXUA.js +30 -0
  55. package/dist-cli/chunks/chunk-X5J5A63F.js +23 -0
  56. package/dist-cli/chunks/chunk-XKACQ3MD.js +4 -0
  57. package/dist-cli/chunks/{chunk-ETDZQUGI.js → chunk-XLT6Y654.js} +3 -3
  58. package/dist-cli/chunks/{chunk-4NJIXTVJ.js → chunk-Y7TQDOU5.js} +1 -1
  59. package/dist-cli/chunks/{chunk-7FYIMLU4.js → chunk-YNN2AELC.js} +17 -17
  60. package/dist-cli/chunks/{chunk-BX2NUJGR.js → chunk-YTTBKONB.js} +1 -1
  61. package/dist-cli/chunks/{chunk-3WQISFAK.js → chunk-Z3VP4WF6.js} +1 -1
  62. package/dist-cli/chunks/cleanup-Q4GUW4DU.js +25 -0
  63. package/dist-cli/chunks/{cli-version-I6BOBK57.js → cli-version-Y74RBK4R.js} +2 -2
  64. package/dist-cli/chunks/compat-CSFBMTVO.js +20 -0
  65. package/dist-cli/chunks/config-4ZW7VYBV.js +13 -0
  66. package/dist-cli/chunks/control-BFFHTY4I.js +4 -0
  67. package/dist-cli/chunks/daemon-746DWYWS.js +4 -0
  68. package/dist-cli/chunks/{debug-VG2JOF6H.js → debug-EEYUHHSR.js} +3 -3
  69. package/dist-cli/chunks/{desktop-GH2POW6X.js → desktop-Q4NXGDCY.js} +3 -3
  70. package/dist-cli/chunks/{detox-X6T265L5.js → detox-UMSQ33AB.js} +2 -2
  71. package/dist-cli/chunks/{device-VDOOSAXO.js → device-7STN6X5G.js} +2 -2
  72. package/dist-cli/chunks/{diagnose-6D6WNAGL.js → diagnose-7L67FGJY.js} +2 -2
  73. package/dist-cli/chunks/disk-cleanup-IISQYADA.js +4 -0
  74. package/dist-cli/chunks/drivers-RD6OW2D5.js +4 -0
  75. package/dist-cli/chunks/{film-UZWYISAB.js → film-2MP5BL6J.js} +3 -3
  76. package/dist-cli/chunks/flow-MKSEPFWS.js +4 -0
  77. package/dist-cli/chunks/help-4ADSAOGD.js +4 -0
  78. package/dist-cli/chunks/{hidden-runtime-alias-UGMJ4LJX.js → hidden-runtime-alias-HTJVTEHX.js} +2 -2
  79. package/dist-cli/chunks/home-paths-KOZFBUVS.js +4 -0
  80. package/dist-cli/chunks/inspect-6D7Q27PN.js +4 -0
  81. package/dist-cli/chunks/install-desktop-WZBAUSKT.js +4 -0
  82. package/dist-cli/chunks/{login-K43TRKUC.js → login-XWITDS34.js} +4 -4
  83. package/dist-cli/chunks/{logout-L43CR6M2.js → logout-C6EVSNGO.js} +2 -2
  84. package/dist-cli/chunks/{maestro-7VZFYLNS.js → maestro-Y3RDSX5X.js} +3 -3
  85. package/dist-cli/chunks/{maestro-generate-PMYHUVH4.js → maestro-generate-WHUYPFMX.js} +3 -3
  86. package/dist-cli/chunks/{mode-QQS5XFFX.js → mode-3D6W3Y2Y.js} +2 -2
  87. package/dist-cli/chunks/{optional-demo-registry-2V327HDG.js → optional-demo-registry-MRBZKL5R.js} +2 -2
  88. package/dist-cli/chunks/{perf-CL5P3JVP.js → perf-CRMSVQX4.js} +2 -2
  89. package/dist-cli/chunks/{permissions-RGVCEC2B.js → permissions-RFASUZIH.js} +2 -2
  90. package/dist-cli/chunks/{record-ASW6UFQT.js → record-SKX3SUNS.js} +3 -3
  91. package/dist-cli/chunks/report-issue-I7VXCIGQ.js +29 -0
  92. package/dist-cli/chunks/runtime-OJ26HJM7.js +4 -0
  93. package/dist-cli/chunks/{screenshot-command-FEJGQMQF.js → screenshot-command-PIMVRGAW.js} +3 -3
  94. package/dist-cli/chunks/{screenshots-capture-3JPZ5WMX.js → screenshots-capture-RRZUJDIO.js} +2 -2
  95. package/dist-cli/chunks/serve-JHBXNJ77.js +44 -0
  96. package/dist-cli/chunks/setup-A3I3R4FB.js +32 -0
  97. package/dist-cli/chunks/{skills-T6EEX5P6.js → skills-7KS6HD4G.js} +2 -2
  98. package/dist-cli/chunks/{storage-ZFUK4EQA.js → storage-H2ODISXF.js} +2 -2
  99. package/dist-cli/chunks/store-ARKALTX7.js +4 -0
  100. package/dist-cli/chunks/telemetry-ZMRUVMCZ.js +4 -0
  101. package/dist-cli/chunks/{timeline-EIURYQVC.js → timeline-5N5AUCOX.js} +2 -2
  102. package/dist-cli/chunks/upgrade-UM4K5PH4.js +7 -0
  103. package/dist-cli/chunks/upload-WGPI5XX6.js +4 -0
  104. package/dist-cli/chunks/version-B2MBTUMC.js +6 -0
  105. package/dist-cli/chunks/web-ZM5Y5GRE.js +4 -0
  106. package/dist-cli/chunks/{what-happened-L5ZN7S5G.js → what-happened-K443NB2H.js} +2 -2
  107. package/dist-lib/agent-daemon-client.cjs +1 -1
  108. package/dist-lib/agent-events.cjs +1 -1
  109. package/dist-lib/agent-identity.cjs +1 -1
  110. package/dist-lib/agent-sessions.cjs +1 -1
  111. package/dist-lib/attached-projects.cjs +1 -1
  112. package/dist-lib/auth/shared-session.cjs +1 -1
  113. package/dist-lib/backend-origin.cjs +1 -1
  114. package/dist-lib/beta.cjs +1 -1
  115. package/dist-lib/beta.mjs +1 -1
  116. package/dist-lib/bridge-constants.cjs +1 -1
  117. package/dist-lib/cli-constants.cjs +1 -1
  118. package/dist-lib/config.cjs +3 -2
  119. package/dist-lib/detox/index.cjs +312 -27
  120. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  121. package/dist-lib/home-paths.cjs +41 -14
  122. package/dist-lib/host/bridge-host.cjs +215 -84
  123. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  124. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  125. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  126. package/dist-lib/host/websocket-proxy.cjs +1 -1
  127. package/dist-lib/index.cjs +2 -2
  128. package/dist-lib/jump-to-source-babel.cjs +1 -1
  129. package/dist-lib/menu.cjs +2 -3
  130. package/dist-lib/menu.mjs +2 -3
  131. package/dist-lib/metro.cjs +1 -1
  132. package/dist-lib/profiles.cjs +1 -1
  133. package/dist-lib/render-mode.cjs +1 -1
  134. package/dist-lib/scripts/dev-server-scanner.cjs +2 -2
  135. package/dist-lib/sdk.cjs +1 -1
  136. package/dist-lib/sdk.mjs +1 -1
  137. package/dist-lib/skills.cjs +366 -154
  138. package/dist-lib/vite.cjs +2 -2
  139. package/package.json +2 -1
  140. package/scripts/postinstall.cjs +2 -78
  141. package/skills/contrast/SKILL.md +1 -0
  142. package/skills/sootsim-setup/SKILL.md +25 -0
  143. package/src/config.ts +4 -0
  144. package/src/disk-cleanup.ts +5 -26
  145. package/src/home-paths.ts +58 -23
  146. package/src/host/bridge-host.ts +152 -57
  147. package/src/menu.ts +0 -1
  148. package/src/runtime-assets.ts +35 -0
  149. package/src/runtime-delivery.ts +5 -8
  150. package/src/skills/builtin/compat-check.ts +32 -65
  151. package/dist-cli/chunks/app-fonts-36N7AVQ3.js +0 -4
  152. package/dist-cli/chunks/chunk-4ODQVUBH.js +0 -6
  153. package/dist-cli/chunks/chunk-4V2KYK5T.js +0 -4
  154. package/dist-cli/chunks/chunk-7GVBNEQ3.js +0 -9
  155. package/dist-cli/chunks/chunk-COEKUTWV.js +0 -4
  156. package/dist-cli/chunks/chunk-D2DU3ROC.js +0 -6
  157. package/dist-cli/chunks/chunk-DXCQP3VE.js +0 -12
  158. package/dist-cli/chunks/chunk-HT6WYHQL.js +0 -6
  159. package/dist-cli/chunks/chunk-I7AWRAXM.js +0 -125
  160. package/dist-cli/chunks/chunk-OQ7SSRYK.js +0 -4
  161. package/dist-cli/chunks/chunk-QKZVANQG.js +0 -31
  162. package/dist-cli/chunks/chunk-TGYZYSDB.js +0 -15
  163. package/dist-cli/chunks/chunk-TXURXDZN.js +0 -4
  164. package/dist-cli/chunks/chunk-ZJZYGLJ2.js +0 -4
  165. package/dist-cli/chunks/cleanup-SMCA44QC.js +0 -25
  166. package/dist-cli/chunks/compat-FCM2ZQLV.js +0 -35
  167. package/dist-cli/chunks/config-PZSANSAI.js +0 -13
  168. package/dist-cli/chunks/control-PM554MHE.js +0 -4
  169. package/dist-cli/chunks/daemon-5KXEZZLJ.js +0 -4
  170. package/dist-cli/chunks/disk-cleanup-HTOIDDKK.js +0 -4
  171. package/dist-cli/chunks/drivers-CKLBQDXG.js +0 -4
  172. package/dist-cli/chunks/flow-VOS3RKB3.js +0 -4
  173. package/dist-cli/chunks/help-LVMDERFQ.js +0 -4
  174. package/dist-cli/chunks/home-paths-3LYT47SY.js +0 -4
  175. package/dist-cli/chunks/inspect-FHCSOWGQ.js +0 -4
  176. package/dist-cli/chunks/install-desktop-KLMCVDGZ.js +0 -4
  177. package/dist-cli/chunks/runtime-GB3ZE27C.js +0 -4
  178. package/dist-cli/chunks/serve-SYXRHRQO.js +0 -44
  179. package/dist-cli/chunks/setup-QLGL6MC6.js +0 -99
  180. package/dist-cli/chunks/store-WCDTSWJC.js +0 -4
  181. package/dist-cli/chunks/telemetry-D7SL4WW7.js +0 -4
  182. package/dist-cli/chunks/upgrade-Q7ZGWMCJ.js +0 -6
  183. package/dist-cli/chunks/upload-7XEICYJX.js +0 -4
  184. package/dist-cli/chunks/version-PDG4V26R.js +0 -6
  185. package/dist-cli/chunks/web-AMW2TRWB.js +0 -4
package/README.md CHANGED
@@ -188,7 +188,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
188
188
  | SootSimBridgeHost (serve/daemon) | The bridge process (`sootsim serve`, or autostarted as the daemon via launchd/systemd). HTTP+WS hub on `:7668`: relays commands between CLI clients and registered sims; also serves runtime HTTP, `/__server-scan`, and fetch/WS proxies. | `src/host/bridge-host.ts`, `cli/commands/serve.ts`, `cli/commands/daemon.ts`, `src/home-paths.ts` |
189
189
  | AgentHost + sessions | Agent-routing extension of the host: owns the single FIFO reader per agent session and fans `agent:event`/`session-status` pushes to every WS subscriber. Backed by agent-sessions + attached-projects stores. | `src/host/agent-host.ts`, `src/agent-sessions.ts`, `src/attached-projects.ts`, `src/agent-daemon-client.ts` |
190
190
  | host proxies + dev-server scan | Host-side helpers so a cross-origin tenant worker can reach local dev servers and same-origin APIs: `/__server-scan` discovers running metro/expo/vxrn/one servers; fetch + websocket proxies relay guest-app network through the daemon. | `src/host/fetch-proxy-handler.ts`, `src/host/websocket-proxy.ts`, `scripts/dev-server-scanner.ts`, `src/dev-bundle-resolution.ts` |
191
- | runtime delivery + sootsim home | Versioned engine runtime management. The machinery (manifest fetch, sha256 verification, channels, pinning, auto-update) is product-agnostic and shared with the contrast CLI; `src/runtime-delivery.ts` binds it to sootsim's identity — the CDN origin (`https://contrast.dev`), the `SOOTSIM_*` env overrides, and `~/.sootsim/runtimes/<version>`. Active version, config, and the daemon lockfile live under the sootsim home. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts`, `scripts/postinstall.cjs` |
191
+ | runtime delivery + sootsim home | Versioned engine runtime management. The shared machinery owns manifest fetch, sha256 verification, channels, and auto-update. SootSim binds it to `https://contrast.dev` and `~/.sootsim/runtimes/<version>`. A repo can select `runtimeVersion` without changing the machine default. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts`, `scripts/postinstall.cjs` |
192
192
  | `sootsim/vite` + `sootsim/metro` (runtime serving) | Published bundler plugins. Both serve the installed engine runtime at `/__soot/` from `~/.sootsim/runtimes/<version>` so an app's own dev server can host the sim shell; the vite plugin can also launch the Electron app. | `src/vite-plugin-one.ts`, `src/metro-plugin.ts`, `src/runtime-assets.ts` |
193
193
  | `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, applies worklets/babel transforms, and instantiates `SootSimBridgeHost` in its dev server. Distinct from the published `sootsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/compat/src/stub-manifest.ts` |
194
194
  | skills registry | SootSim agent skills (`sootsim-setup`, `sootsim-debug`, `sootsim-test`, `sootsim-visual`, and the generated `contrast` index) installable with `sootsim skill`. | `skills/*.md`, `src/skills/registry.ts`, `src/skills/types.ts` |
@@ -261,6 +261,8 @@ sootsim open 8081 # load a running metro/expo dev server
261
261
  sootsim open /settings # route via React Native Linking
262
262
  sootsim describe # dump UI tree
263
263
  sootsim get errors 5 # recent runtime failures
264
+ sootsim compat # scan native package compatibility
265
+ sootsim report-issue "…" # preview an opt-in compatibility report
264
266
  sootsim find --testid cta # inspect one node deeply
265
267
  sootsim debug enable animated,layout
266
268
  sootsim debug trace shell on 240
@@ -450,7 +452,7 @@ verifies its sha256, and unpacks it under `~/.sootsim`:
450
452
  ├── runtimes/
451
453
  │ ├── <active>/ unpacked engine assets (served at /__soot/)
452
454
  │ └── <rollback>/ previous version for rollback
453
- ├── cache/ transient downloads; empty after extraction
455
+ ├── cache/ legacy downloads; empty after cleanup
454
456
  ├── profiles/ persistent named app storage, without browser caches
455
457
  ├── electron/userData/ desktop app storage, with bounded browser caches
456
458
  ├── daemon.json lockfile: pid, ports, active runtime, heartbeat
@@ -467,13 +469,20 @@ disabled for simulator profiles and globally capped for app chrome. Cookies,
467
469
  localStorage, IndexedDB, service workers, recordings, and captured frames are
468
470
  user-owned data and are not removed by the default cleanup command.
469
471
 
472
+ Per-repo configuration is optional. When `sootsim.config.ts` sets
473
+ `runtimeVersion`, `sootsim open` installs that version without changing the
474
+ machine default and serves it from a version-specific localhost origin. Running
475
+ from a monorepo root resolves the only React Native app from package workspaces,
476
+ then shares that app root across config, font, and splash discovery. The fallback
477
+ scan is bounded and prunes dependency, build, native, fixture, and example trees.
478
+
470
479
  The first interactive CLI command after this cleanup generation finishes its
471
480
  requested work, then launches the same safe default plan in a detached
472
481
  maintenance process. The app and terminal command are usable before the legacy
473
482
  filesystem scan begins. The worker prints the reclaimed size to stderr, records
474
483
  completion, and does not run the scan again. Open browser profiles and a runtime
475
- served by the live daemon are left untouched; the automatic pass retries on a
476
- later command after they close. The explicit `sootsim cleanup --aggressive`
484
+ served by a live daemon or development bridge are left untouched. The automatic
485
+ pass retries on a later command after they close. The explicit `sootsim cleanup --aggressive`
477
486
  command remains the only path that removes the rollback runtime or user-created
478
487
  recordings.
479
488
 
package/detox/index.ts CHANGED
@@ -15,6 +15,12 @@ import {
15
15
  waitForSootsimGestureHandler,
16
16
  } from './gestures'
17
17
  import { by, type Matcher } from './matchers'
18
+ import {
19
+ MOTION_CHANGE_DEFINITION,
20
+ measureMotionChange,
21
+ type MotionChangeDefinition,
22
+ type MotionChangeMeasurement,
23
+ } from './motion-change.cjs'
18
24
  import type { SootSimElement } from './element-types'
19
25
  import type { Browser, BrowserContext, Page } from 'playwright'
20
26
  import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
@@ -554,6 +560,32 @@ function decodePngDataUrl(dataUrl: string): Buffer {
554
560
  return Buffer.from(match[1], 'base64')
555
561
  }
556
562
 
563
+ type FastScreenshotData = {
564
+ label: string
565
+ offsetMs: number
566
+ actualMs: number
567
+ dataUrl: string
568
+ frameIndex?: number
569
+ virtualTimestampMs?: number
570
+ }
571
+
572
+ function writeFastScreenshotFrames<T extends FastScreenshotData>(
573
+ namePrefix: string,
574
+ frames: T[],
575
+ ): Array<Omit<T, 'dataUrl'> & { path: string }> {
576
+ ensureScreenshotDir()
577
+ return frames.map((frame) => {
578
+ const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
579
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
580
+ fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
581
+ const { dataUrl: _dataUrl, ...metadata } = frame
582
+ return {
583
+ ...metadata,
584
+ path: filePath,
585
+ }
586
+ })
587
+ }
588
+
557
589
  // single-shot canvas capture — no frame-stability poll. used for multi-stage
558
590
  // animation captures where we explicitly want to sample mid-transition. the
559
591
  // usual captureSootsimPng polls until two consecutive frames are byte-equal
@@ -1347,10 +1379,352 @@ export const device = {
1347
1379
  await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
1348
1380
  },
1349
1381
 
1350
- async setBottomTabAnimOverride(
1351
- value: { elapsedMs: number; fromIndex: number; toIndex: number } | null,
1352
- ): Promise<void> {
1353
- await dispatchSootsimEventInPage('sootsim:bottomTabAnimOverride', { value })
1382
+ // park every render clock before the interaction, detect the first changed
1383
+ // stepped frame inside the named component, then advance exact 60fps frames
1384
+ // to each requested offset. native extraction also chooses the first frame
1385
+ // at or after an offset, so both sides use the same rounding convention.
1386
+ async takeScreenshotStagesFromVisibleChangeFast(
1387
+ namePrefix: string,
1388
+ stages: Array<{ label: string; offsetMs: number }>,
1389
+ options: {
1390
+ detectionTimeoutMs?: number
1391
+ frame?: { height: number; width: number; x: number; y: number } | null
1392
+ rootFrame?: { height: number; width: number; x: number; y: number } | null
1393
+ trigger: () => unknown
1394
+ },
1395
+ ): Promise<{
1396
+ frames: Array<{
1397
+ label: string
1398
+ offsetMs: number
1399
+ actualMs: number
1400
+ path: string
1401
+ frameIndex: number
1402
+ virtualTimestampMs: number
1403
+ }>
1404
+ triggerCompletedMs: number
1405
+ triggerStartMs: number
1406
+ visibleChangeDetection: {
1407
+ actualMs: number
1408
+ changedPixelPercent: number
1409
+ changedPixels: number
1410
+ channelThreshold: number
1411
+ frame: { height: number; width: number; x: number; y: number } | null
1412
+ minimumChangePercent: number
1413
+ rootFrame: { height: number; width: number; x: number; y: number } | null
1414
+ totalPixels: number
1415
+ frameIndex: number
1416
+ virtualTimestampMs: number
1417
+ }
1418
+ }> {
1419
+ const page = getPage()
1420
+ const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
1421
+ await page.evaluate(measureMotionChange, {
1422
+ definition: MOTION_CHANGE_DEFINITION,
1423
+ installGlobal: true,
1424
+ })
1425
+ const capturePromise: Promise<{
1426
+ frames: Array<
1427
+ FastScreenshotData & {
1428
+ frameIndex: number
1429
+ virtualTimestampMs: number
1430
+ }
1431
+ >
1432
+ visibleChangeDetection: {
1433
+ actualMs: number
1434
+ changedPixelPercent: number
1435
+ changedPixels: number
1436
+ channelThreshold: number
1437
+ frameIndex: number
1438
+ minimumChangePercent: number
1439
+ totalPixels: number
1440
+ virtualTimestampMs: number
1441
+ }
1442
+ }> = page.evaluate(
1443
+ async (input) => {
1444
+ type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
1445
+ type CaptureState = {
1446
+ cancelled: boolean
1447
+ ready: boolean
1448
+ triggerCompleted: boolean
1449
+ }
1450
+ type CaptureWindow = Window & {
1451
+ __sootsimMeasureMotionChange?: (input: {
1452
+ baseline: Uint8ClampedArray
1453
+ candidate: Uint8ClampedArray
1454
+ definition: MotionChangeDefinition
1455
+ height: number
1456
+ label: string
1457
+ width: number
1458
+ }) => MotionChangeMeasurement
1459
+ __sootsimVisibleChangeCaptures?: Record<string, CaptureState>
1460
+ SootSim?: {
1461
+ bridges?: {
1462
+ liveComposite?: {
1463
+ captureFresh: () => Promise<{
1464
+ canvas: HTMLCanvasElement | null
1465
+ frameToken: number
1466
+ }>
1467
+ }
1468
+ lockstepCapture?: {
1469
+ begin: (fps: number) => Promise<{
1470
+ stepFrame: () => Promise<FrameReceipt>
1471
+ stepToOffset: (
1472
+ anchor: FrameReceipt,
1473
+ offsetMs: number,
1474
+ ) => Promise<FrameReceipt>
1475
+ end: () => void
1476
+ }>
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ type Snapshot = {
1482
+ actualMs: number
1483
+ bitmap: ImageBitmap
1484
+ label: string
1485
+ offsetMs: number
1486
+ frameIndex: number
1487
+ virtualTimestampMs: number
1488
+ }
1489
+
1490
+ const captureWindow = window as unknown as CaptureWindow
1491
+ const live = captureWindow.SootSim?.bridges?.liveComposite
1492
+ const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
1493
+ if (!live || typeof live.captureFresh !== 'function') {
1494
+ throw new Error('SootSim live composite bridge is not installed')
1495
+ }
1496
+ if (!lockstep || typeof lockstep.begin !== 'function') {
1497
+ throw new Error('SootSim lockstep capture bridge is not installed')
1498
+ }
1499
+ const registry =
1500
+ captureWindow.__sootsimVisibleChangeCaptures ??
1501
+ (captureWindow.__sootsimVisibleChangeCaptures = {})
1502
+ const state: CaptureState = {
1503
+ cancelled: false,
1504
+ ready: false,
1505
+ triggerCompleted: false,
1506
+ }
1507
+ registry[input.token] = state
1508
+ const measureChange = captureWindow.__sootsimMeasureMotionChange
1509
+ if (!measureChange) {
1510
+ throw new Error('SootSim motion change definition is not installed')
1511
+ }
1512
+
1513
+ const captureBitmap = async () => {
1514
+ const { canvas } = await live.captureFresh()
1515
+ if (!canvas) throw new Error('SootSim live composite produced no canvas')
1516
+ return createImageBitmap(canvas)
1517
+ }
1518
+ const boundsFor = (bitmap: ImageBitmap) => {
1519
+ const scale =
1520
+ input.rootFrame && input.rootFrame.width > 0
1521
+ ? bitmap.width / input.rootFrame.width
1522
+ : 1
1523
+ return {
1524
+ height: input.frame
1525
+ ? Math.max(
1526
+ 1,
1527
+ Math.min(
1528
+ bitmap.height,
1529
+ Math.ceil((input.frame.y + input.frame.height) * scale),
1530
+ ) - Math.max(0, Math.floor(input.frame.y * scale)),
1531
+ )
1532
+ : bitmap.height,
1533
+ left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
1534
+ top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
1535
+ width: input.frame
1536
+ ? Math.max(
1537
+ 1,
1538
+ Math.min(
1539
+ bitmap.width,
1540
+ Math.ceil((input.frame.x + input.frame.width) * scale),
1541
+ ) - Math.max(0, Math.floor(input.frame.x * scale)),
1542
+ )
1543
+ : bitmap.width,
1544
+ }
1545
+ }
1546
+ const regionPixels = (bitmap: ImageBitmap) => {
1547
+ const bounds = boundsFor(bitmap)
1548
+ const canvas = document.createElement('canvas')
1549
+ canvas.width = bounds.width
1550
+ canvas.height = bounds.height
1551
+ const context = canvas.getContext('2d', { willReadFrequently: true })
1552
+ if (!context) throw new Error('SootSim motion detector needs a 2d canvas')
1553
+ context.drawImage(
1554
+ bitmap,
1555
+ bounds.left,
1556
+ bounds.top,
1557
+ bounds.width,
1558
+ bounds.height,
1559
+ 0,
1560
+ 0,
1561
+ bounds.width,
1562
+ bounds.height,
1563
+ )
1564
+ return context.getImageData(0, 0, bounds.width, bounds.height).data
1565
+ }
1566
+ const encode = (snapshot: Snapshot) => {
1567
+ const canvas = document.createElement('canvas')
1568
+ canvas.width = snapshot.bitmap.width
1569
+ canvas.height = snapshot.bitmap.height
1570
+ const context = canvas.getContext('2d')
1571
+ if (!context) throw new Error('SootSim motion capture needs a 2d canvas')
1572
+ context.drawImage(snapshot.bitmap, 0, 0)
1573
+ snapshot.bitmap.close()
1574
+ return {
1575
+ actualMs: snapshot.actualMs,
1576
+ dataUrl: canvas.toDataURL('image/png'),
1577
+ label: snapshot.label,
1578
+ offsetMs: snapshot.offsetMs,
1579
+ frameIndex: snapshot.frameIndex,
1580
+ virtualTimestampMs: snapshot.virtualTimestampMs,
1581
+ }
1582
+ }
1583
+
1584
+ const controller = await lockstep.begin(60)
1585
+ try {
1586
+ const baselineBitmap = await captureBitmap()
1587
+ const baseline = regionPixels(baselineBitmap)
1588
+ baselineBitmap.close()
1589
+ state.ready = true
1590
+ while (!state.triggerCompleted) {
1591
+ if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
1592
+ await new Promise((resolve) => setTimeout(resolve, 0))
1593
+ }
1594
+
1595
+ let visibleStart: FrameReceipt | null = null
1596
+ let detectedMeasurement: MotionChangeMeasurement | null = null
1597
+ const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
1598
+ for (let frame = 0; frame < detectionFrameLimit; frame++) {
1599
+ if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
1600
+ const receipt = await controller.stepFrame()
1601
+ const candidateBitmap = await captureBitmap()
1602
+ const bounds = boundsFor(candidateBitmap)
1603
+ const measurement = measureChange({
1604
+ baseline,
1605
+ candidate: regionPixels(candidateBitmap),
1606
+ definition: input.motionChangeDefinition,
1607
+ height: bounds.height,
1608
+ label: 'SootSim visible-change detector',
1609
+ width: bounds.width,
1610
+ })
1611
+ candidateBitmap.close()
1612
+ if (measurement.passedMinimum) {
1613
+ visibleStart = receipt
1614
+ detectedMeasurement = measurement
1615
+ break
1616
+ }
1617
+ }
1618
+ if (!visibleStart || !detectedMeasurement) {
1619
+ throw new Error(
1620
+ `SootSim motion had no visible change before ${input.detectionTimeoutMs}ms`,
1621
+ )
1622
+ }
1623
+
1624
+ const snapshots: Snapshot[] = []
1625
+ for (const stage of input.stages) {
1626
+ if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
1627
+ const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
1628
+ snapshots.push({
1629
+ actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
1630
+ bitmap: await captureBitmap(),
1631
+ frameIndex: receipt.frameIndex,
1632
+ label: stage.label,
1633
+ offsetMs: stage.offsetMs,
1634
+ virtualTimestampMs: receipt.virtualTimestampMs,
1635
+ })
1636
+ }
1637
+ return {
1638
+ frames: snapshots.map(encode),
1639
+ visibleChangeDetection: {
1640
+ actualMs: (visibleStart.frameIndex * 1000) / 60,
1641
+ changedPixelPercent: detectedMeasurement.changePercent,
1642
+ changedPixels: detectedMeasurement.changePixels,
1643
+ channelThreshold: input.motionChangeDefinition.channelThreshold,
1644
+ minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
1645
+ totalPixels: detectedMeasurement.totalPixels,
1646
+ frameIndex: visibleStart.frameIndex,
1647
+ virtualTimestampMs: visibleStart.virtualTimestampMs,
1648
+ },
1649
+ }
1650
+ } finally {
1651
+ controller.end()
1652
+ delete registry[input.token]
1653
+ }
1654
+ },
1655
+ {
1656
+ detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
1657
+ frame: options.frame ?? null,
1658
+ motionChangeDefinition: MOTION_CHANGE_DEFINITION,
1659
+ rootFrame: options.rootFrame ?? null,
1660
+ stages,
1661
+ token,
1662
+ },
1663
+ )
1664
+
1665
+ await page.waitForFunction(
1666
+ (captureToken) => {
1667
+ const registry = (
1668
+ window as Window & {
1669
+ __sootsimVisibleChangeCaptures?: Record<
1670
+ string,
1671
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1672
+ >
1673
+ }
1674
+ ).__sootsimVisibleChangeCaptures
1675
+ return registry?.[captureToken]?.ready === true
1676
+ },
1677
+ token,
1678
+ { timeout: 5000 },
1679
+ )
1680
+
1681
+ const triggerStartMs = Date.now()
1682
+ let triggerCompletedMs = 0
1683
+ try {
1684
+ await Promise.resolve(options.trigger())
1685
+ triggerCompletedMs = Date.now()
1686
+ await page.evaluate((captureToken) => {
1687
+ const registry = (
1688
+ window as Window & {
1689
+ __sootsimVisibleChangeCaptures?: Record<
1690
+ string,
1691
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1692
+ >
1693
+ }
1694
+ ).__sootsimVisibleChangeCaptures
1695
+ const capture = registry?.[captureToken]
1696
+ if (!capture) throw new Error('SootSim motion capture disarmed before trigger')
1697
+ capture.triggerCompleted = true
1698
+ }, token)
1699
+ const capture = await capturePromise
1700
+ return {
1701
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
1702
+ triggerCompletedMs,
1703
+ triggerStartMs,
1704
+ visibleChangeDetection: {
1705
+ ...capture.visibleChangeDetection,
1706
+ frame: options.frame ?? null,
1707
+ rootFrame: options.rootFrame ?? null,
1708
+ },
1709
+ }
1710
+ } catch (error) {
1711
+ await page
1712
+ .evaluate((captureToken) => {
1713
+ const registry = (
1714
+ window as Window & {
1715
+ __sootsimVisibleChangeCaptures?: Record<
1716
+ string,
1717
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1718
+ >
1719
+ }
1720
+ ).__sootsimVisibleChangeCaptures
1721
+ const capture = registry?.[captureToken]
1722
+ if (capture) capture.cancelled = true
1723
+ }, token)
1724
+ .catch(() => {})
1725
+ await capturePromise.catch(() => {})
1726
+ throw error
1727
+ }
1354
1728
  },
1355
1729
 
1356
1730
  // FAST multi-stage capture for sub-second animation diffs. each stage
@@ -1490,18 +1864,7 @@ export const device = {
1490
1864
  },
1491
1865
  { stages, skipWarmup: options?.skipWarmup === true },
1492
1866
  )
1493
- ensureScreenshotDir()
1494
- return frames.map((f) => {
1495
- const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
1496
- const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
1497
- fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
1498
- return {
1499
- label: f.label,
1500
- offsetMs: f.offsetMs,
1501
- actualMs: f.actualMs,
1502
- path: filePath,
1503
- }
1504
- })
1867
+ return writeFastScreenshotFrames(namePrefix, frames)
1505
1868
  },
1506
1869
 
1507
1870
  // batched multi-stage capture for animation diffs. all N captures happen
@@ -1549,18 +1912,7 @@ export const device = {
1549
1912
  },
1550
1913
  { stages },
1551
1914
  )
1552
- ensureScreenshotDir()
1553
- return frames.map((f) => {
1554
- const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
1555
- const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
1556
- fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
1557
- return {
1558
- label: f.label,
1559
- offsetMs: f.offsetMs,
1560
- actualMs: f.actualMs,
1561
- path: filePath,
1562
- }
1563
- })
1915
+ return writeFastScreenshotFrames(namePrefix, frames)
1564
1916
  },
1565
1917
 
1566
1918
  async shake() {
@@ -0,0 +1,109 @@
1
+ // native recordings and SootSim live frames must use this exact definition.
2
+ // retained static Android crops measured as high as 0.0331% after the channel
3
+ // filter. the smallest corrected component transition with visible motion was
4
+ // 0.1748%, so a 0.1% floor separates every measured static and moving frame.
5
+ const MOTION_CHANGE_DEFINITION = Object.freeze({
6
+ channelThreshold: 24,
7
+ minimumChangePercent: 0.1,
8
+ })
9
+
10
+ function measureMotionChange(input) {
11
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
12
+ throw new Error('motion change measurement requires an input object')
13
+ }
14
+ if (input.installGlobal === true) {
15
+ globalThis.__sootsimMeasureMotionChange = measureMotionChange
16
+ return null
17
+ }
18
+
19
+ const definition = input.definition
20
+ if (
21
+ !definition ||
22
+ typeof definition !== 'object' ||
23
+ !Number.isInteger(definition.channelThreshold) ||
24
+ definition.channelThreshold < 0 ||
25
+ definition.channelThreshold > 255 ||
26
+ !Number.isFinite(definition.minimumChangePercent) ||
27
+ definition.minimumChangePercent <= 0 ||
28
+ definition.minimumChangePercent >= 100
29
+ ) {
30
+ throw new Error(
31
+ 'motion change definition needs a channel threshold from 0 to 255 and a minimum greater than 0% and less than 100%',
32
+ )
33
+ }
34
+
35
+ const label =
36
+ typeof input.label === 'string' && input.label.length > 0
37
+ ? input.label
38
+ : 'motion change'
39
+ let changePixels = input.changePixels
40
+ let totalPixels = input.totalPixels
41
+ if (input.baseline !== undefined || input.candidate !== undefined) {
42
+ const baseline = input.baseline
43
+ const candidate = input.candidate
44
+ const width = input.width
45
+ const height = input.height
46
+ if (
47
+ !baseline ||
48
+ !candidate ||
49
+ typeof baseline.length !== 'number' ||
50
+ typeof candidate.length !== 'number' ||
51
+ !Number.isInteger(width) ||
52
+ !Number.isInteger(height) ||
53
+ width <= 0 ||
54
+ height <= 0
55
+ ) {
56
+ throw new Error(`${label} needs two RGBA pixel arrays and positive dimensions`)
57
+ }
58
+ totalPixels = width * height
59
+ const expectedBytes = totalPixels * 4
60
+ if (baseline.length !== expectedBytes || candidate.length !== expectedBytes) {
61
+ throw new Error(
62
+ `${label} pixel data has ${baseline.length}/${candidate.length} bytes; expected ${expectedBytes}`,
63
+ )
64
+ }
65
+ changePixels = 0
66
+ for (let offset = 0; offset < expectedBytes; offset += 4) {
67
+ if (
68
+ Math.abs(baseline[offset] - candidate[offset]) > definition.channelThreshold ||
69
+ Math.abs(baseline[offset + 1] - candidate[offset + 1]) >
70
+ definition.channelThreshold ||
71
+ Math.abs(baseline[offset + 2] - candidate[offset + 2]) >
72
+ definition.channelThreshold ||
73
+ Math.abs(baseline[offset + 3] - candidate[offset + 3]) >
74
+ definition.channelThreshold
75
+ ) {
76
+ changePixels++
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!Number.isInteger(totalPixels) || totalPixels <= 0) {
82
+ throw new Error(`${label} has no positive measured region area`)
83
+ }
84
+ if (!Number.isInteger(changePixels) || changePixels < 0 || changePixels > totalPixels) {
85
+ throw new Error(
86
+ `${label} changed pixel count ${changePixels} is outside its ${totalPixels}px measured region`,
87
+ )
88
+ }
89
+ const changePercent = (changePixels / totalPixels) * 100
90
+ return {
91
+ changePercent,
92
+ changePixels,
93
+ passedMinimum: changePercent >= definition.minimumChangePercent,
94
+ totalPixels,
95
+ }
96
+ }
97
+
98
+ function measureProofMotionChange(input) {
99
+ return measureMotionChange({
100
+ ...input,
101
+ definition: MOTION_CHANGE_DEFINITION,
102
+ })
103
+ }
104
+
105
+ module.exports = {
106
+ MOTION_CHANGE_DEFINITION,
107
+ measureMotionChange,
108
+ measureProofMotionChange,
109
+ }
@@ -0,0 +1,35 @@
1
+ export type MotionChangeDefinition = {
2
+ channelThreshold: number
3
+ minimumChangePercent: number
4
+ }
5
+
6
+ type MotionChangePixels = ArrayLike<number>
7
+
8
+ export type MotionChangeInput = {
9
+ baseline?: MotionChangePixels
10
+ candidate?: MotionChangePixels
11
+ changePixels?: number
12
+ definition: MotionChangeDefinition
13
+ height?: number
14
+ installGlobal?: boolean
15
+ label?: string
16
+ totalPixels?: number
17
+ width?: number
18
+ }
19
+
20
+ export type MotionChangeMeasurement = {
21
+ changePercent: number
22
+ changePixels: number
23
+ passedMinimum: boolean
24
+ totalPixels: number
25
+ }
26
+
27
+ export const MOTION_CHANGE_DEFINITION: Readonly<MotionChangeDefinition>
28
+
29
+ export function measureMotionChange(
30
+ input: MotionChangeInput,
31
+ ): MotionChangeMeasurement | null
32
+
33
+ export function measureProofMotionChange(
34
+ input: Omit<MotionChangeInput, 'definition' | 'installGlobal'>,
35
+ ): MotionChangeMeasurement