peachsim 0.1.577 → 0.1.579

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 (178) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -9
  3. package/cli/app-catalog.ts +77 -0
  4. package/cli/app-fonts.ts +4 -20
  5. package/cli/app-splash.ts +2 -18
  6. package/cli/bridge-flow-runner.ts +505 -139
  7. package/cli/cloud-client.ts +1 -4
  8. package/cli/cloud-session.ts +1 -4
  9. package/cli/cloud-test-result-files.ts +3 -0
  10. package/cli/cloud-test-tunnel.ts +2 -5
  11. package/cli/command-registry.ts +2 -0
  12. package/cli/commands/agent.ts +149 -23
  13. package/cli/commands/assert.ts +1 -4
  14. package/cli/commands/box/peach-command.ts +62 -0
  15. package/cli/commands/box/plane-share.ts +0 -70
  16. package/cli/commands/box/rnx-command.ts +1 -60
  17. package/cli/commands/box/search-command.ts +14 -0
  18. package/cli/commands/box.ts +2 -2
  19. package/cli/commands/control.ts +121 -27
  20. package/cli/commands/cpu-profile.ts +23 -10
  21. package/cli/commands/daemon.ts +1 -9
  22. package/cli/commands/detox.ts +26 -18
  23. package/cli/commands/device.ts +129 -1
  24. package/cli/commands/film.ts +25 -16
  25. package/cli/commands/flow.ts +9 -2
  26. package/cli/commands/inspect/actions.ts +75 -1
  27. package/cli/commands/inspect/core.ts +81 -2
  28. package/cli/commands/inspect/describe.ts +2 -6
  29. package/cli/commands/inspect/env.ts +1 -1
  30. package/cli/commands/inspect/settling.ts +20 -3
  31. package/cli/commands/inspect/shared.ts +1 -4
  32. package/cli/commands/inspect/sleep.ts +1 -1
  33. package/cli/commands/inspect.ts +16 -49
  34. package/cli/commands/install-desktop.ts +8 -6
  35. package/cli/commands/login.ts +3 -5
  36. package/cli/commands/maestro-generate.ts +1 -4
  37. package/cli/commands/maestro.ts +38 -21
  38. package/cli/commands/no-bridge-hint.ts +0 -14
  39. package/cli/commands/permissions.ts +2 -2
  40. package/cli/commands/platform.ts +13 -6
  41. package/cli/commands/screenshot.ts +6 -1
  42. package/cli/commands/skills.ts +1 -1
  43. package/cli/commands/test.ts +6 -2
  44. package/cli/commands/three-mode.ts +162 -19
  45. package/cli/commands/upload.ts +3 -18
  46. package/cli/current-sim.ts +0 -1
  47. package/cli/desktop-companion.ts +40 -8
  48. package/cli/drivers/playwright-provisioning.ts +1 -4
  49. package/cli/flow-export.ts +1 -8
  50. package/cli/flow-session.ts +0 -4
  51. package/cli/hints.ts +1 -16
  52. package/cli/inspect-notice-state.ts +0 -4
  53. package/cli/main.ts +2 -4
  54. package/cli/outbound-endpoints.ts +1 -24
  55. package/cli/parse-args.ts +1 -2
  56. package/cli/rnx-in-process.ts +3 -1
  57. package/cli/run-rnx-core.ts +4 -2
  58. package/cli/run-rnx.ts +18 -10
  59. package/cli/send-to-box.ts +1 -4
  60. package/cli/telemetry.ts +1 -1
  61. package/cli/ws-bridge.ts +1 -5
  62. package/detox/gestures.ts +20 -6
  63. package/detox/index.ts +379 -123
  64. package/detox/navigation-lifecycle.ts +22 -13
  65. package/dist-lib/agent-daemon-client.cjs +21 -15
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +3 -17
  68. package/dist-lib/agent-sessions.cjs +5 -4
  69. package/dist-lib/attached-projects.cjs +1 -77
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +11 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +7 -5
  76. package/dist-lib/bridge-contract-input.mjs +7 -5
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +30 -7
  80. package/dist-lib/capture-contract.mjs +30 -7
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +1 -1
  85. package/dist-lib/cloud-test-args.mjs +1 -1
  86. package/dist-lib/cloud.cjs +17 -11
  87. package/dist-lib/cloud.mjs +17 -11
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +321 -122
  90. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  91. package/dist-lib/home-paths.cjs +1 -1
  92. package/dist-lib/host/bridge-host.cjs +427 -212
  93. package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
  94. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  95. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  96. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  97. package/dist-lib/host/websocket-proxy.cjs +1 -1
  98. package/dist-lib/index.cjs +315 -58
  99. package/dist-lib/jump-to-source-babel.cjs +1 -1
  100. package/dist-lib/jump-to-source-native.cjs +1 -1
  101. package/dist-lib/menu.cjs +67 -31
  102. package/dist-lib/menu.mjs +64 -28
  103. package/dist-lib/metro-fingerprint-registry.cjs +8 -4
  104. package/dist-lib/metro-fingerprint-registry.mjs +6 -4
  105. package/dist-lib/metro-production-bundle.cjs +2 -2
  106. package/dist-lib/metro-production-bundle.mjs +2 -2
  107. package/dist-lib/metro.cjs +8 -4
  108. package/dist-lib/profiles.cjs +1 -1
  109. package/dist-lib/public-brand.cjs +7 -4
  110. package/dist-lib/react-native-host-modules.cjs +1 -1
  111. package/dist-lib/react-native-host-modules.mjs +1 -1
  112. package/dist-lib/render-mode.cjs +3 -13
  113. package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
  114. package/dist-lib/sdk.cjs +129 -10
  115. package/dist-lib/sdk.mjs +127 -10
  116. package/dist-lib/skills.cjs +1635 -1121
  117. package/dist-lib/swift-compile.cjs +277 -70
  118. package/dist-lib/swift-compile.mjs +275 -70
  119. package/dist-lib/swift-package.cjs +17 -5
  120. package/dist-lib/swift-package.mjs +15 -4
  121. package/dist-lib/swift-submit.cjs +32 -8
  122. package/dist-lib/swift-submit.mjs +29 -7
  123. package/dist-lib/swift.cjs +30912 -577
  124. package/dist-lib/vite-kotlin.cjs +356 -0
  125. package/dist-lib/vite-swift.cjs +1743 -0
  126. package/dist-lib/vite.cjs +208 -1039
  127. package/kotlin/README.md +18 -0
  128. package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
  129. package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
  130. package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
  131. package/kotlin/Sources/org/json/JSON.kt +244 -0
  132. package/kotlin/toolchain.json +19 -0
  133. package/package.json +14 -2
  134. package/scripts/dev-server-scanner.ts +0 -18
  135. package/scripts/optional-demo-registry.ts +0 -17
  136. package/skills/contrast/SKILL.md +2 -2
  137. package/skills/peach-setup/SKILL.md +16 -0
  138. package/src/agent-daemon-client.ts +36 -22
  139. package/src/agent-identity.ts +0 -15
  140. package/src/agent-prompt.ts +12 -0
  141. package/src/attached-projects.ts +0 -72
  142. package/src/backend-origin.ts +10 -0
  143. package/src/bridge-contract-input.ts +2 -5
  144. package/src/bridge-contract.ts +2 -0
  145. package/src/capture-contract.ts +53 -11
  146. package/src/cloud.ts +4 -9
  147. package/src/dev-server-open.ts +0 -99
  148. package/src/home-paths.ts +2 -3
  149. package/src/host/agent-host.ts +82 -36
  150. package/src/host/bridge-host.ts +5 -11
  151. package/src/host/fetch-proxy-handler.ts +19 -43
  152. package/src/is-record.ts +4 -0
  153. package/src/menu.ts +82 -37
  154. package/src/metro-fingerprint-registry.ts +1 -4
  155. package/src/metro-production-bundle.ts +1 -1
  156. package/src/plist.ts +1 -4
  157. package/src/public-brand.ts +5 -3
  158. package/src/render-mode.ts +0 -12
  159. package/src/runtime-assets.ts +9 -2
  160. package/src/screenshots/compose.ts +7 -1
  161. package/src/screenshots/frame-compose.ts +21 -10
  162. package/src/screenshots/schema.ts +1 -4
  163. package/src/sim-client.ts +2 -4
  164. package/src/skills/builtin/maestro-test.ts +1 -4
  165. package/src/sleep.ts +4 -0
  166. package/src/swift-compile.ts +209 -47
  167. package/src/swift-icu.ts +140 -0
  168. package/src/swift-package.ts +26 -2
  169. package/src/swift-submit.ts +84 -4
  170. package/src/swift.ts +2 -0
  171. package/src/vite-plugin-kotlin.ts +376 -0
  172. package/src/vite-plugin-one.ts +224 -36
  173. package/src/vite-plugin-swift.ts +382 -96
  174. package/src/vite-plugin.ts +121 -134
  175. package/src/vite.d.ts +10 -0
  176. package/cli/resolve-assets.ts +0 -63
  177. package/src/rolldown-plugin-swift.ts +0 -244
  178. package/src/team-machine-prompt.ts +0 -73
package/detox/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  resolveBrowserCompositeCdpClip,
12
12
  } from '../../peach-engine/src/capture/browser-composite'
13
13
  import { isAppBootTargetUrl } from '../../peach-engine/src/render-worker/app-load'
14
+ import { SOOTSIM_SHELL_COMMAND_NAME } from '../src/menu'
14
15
  import { pollUntil } from '../src/poll-until'
15
16
  import {
16
17
  createExpect,
@@ -20,6 +21,7 @@ import {
20
21
  } from './expectations'
21
22
  import {
22
23
  dispatchTwoFingerGesture,
24
+ DETOX_PLATFORM,
23
25
  dragScrollNode,
24
26
  readSootsimInteractiveViewport,
25
27
  readSootsimViewport,
@@ -46,8 +48,6 @@ const BASE_URL = process.env.PEACH_URL || 'http://localhost:5173'
46
48
  const SCREENSHOT_DIR =
47
49
  process.env.PEACH_SCREENSHOT_DIR ||
48
50
  path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
49
- const DETOX_PLATFORM =
50
- process.env.PEACH_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
51
51
 
52
52
  // shared state -- playwright page and browser
53
53
  let _browser: Browser | null = null
@@ -187,116 +187,117 @@ export async function waitForSootsimShellReady(
187
187
  // the compositor and shellHost.firstContentPainted never settles. classify
188
188
  // with the engine's own boot-target predicate — the same one the shell
189
189
  // worker and SootSimCanvas agree on — and await the matching boundary:
190
- // shell first paint for home boots, the latched boot-stage painted
191
- // receipt (stamped once tenant, shell, and compositor all present) for
190
+ // shell first paint for home boots, compositor app-surface first paint for
192
191
  // app boots.
193
192
  // exported for the focused regression test.
194
193
  const expectsInitialAppPaint = isAppBootTargetUrl(page.url())
195
194
  try {
196
- await page.evaluate(async ({ timeoutMs, expectsInitialAppPaint }) => {
197
- type ShellHostReadyBridge = {
198
- ready?: Promise<unknown>
199
- firstContentPainted?: Promise<unknown>
200
- }
195
+ await page.evaluate(
196
+ async ({ timeoutMs, expectsInitialAppPaint }) => {
197
+ type ShellHostReadyBridge = {
198
+ ready?: Promise<unknown>
199
+ firstContentPainted?: Promise<unknown>
200
+ }
201
201
 
202
- const start = Date.now()
203
- const deadline = start + timeoutMs
204
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
205
- const remaining = () => Math.max(1, deadline - Date.now())
206
- const withTimeout = async <T>(label: string, promise: Promise<T>) => {
207
- let timer: ReturnType<typeof setTimeout> | null = null
208
- try {
209
- await Promise.race([
210
- promise,
211
- new Promise((_, reject) => {
212
- timer = setTimeout(
213
- () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
214
- remaining(),
215
- )
216
- }),
217
- ])
218
- } finally {
219
- if (timer) clearTimeout(timer)
202
+ let deadline = Date.now() + timeoutMs
203
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
204
+ const remaining = () => Math.max(1, deadline - Date.now())
205
+ const withTimeout = async <T>(label: string, promise: Promise<T>) => {
206
+ let timer: ReturnType<typeof setTimeout> | null = null
207
+ try {
208
+ await Promise.race([
209
+ promise,
210
+ new Promise((_, reject) => {
211
+ timer = setTimeout(
212
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
213
+ remaining(),
214
+ )
215
+ }),
216
+ ])
217
+ } finally {
218
+ if (timer) clearTimeout(timer)
219
+ }
220
220
  }
221
- }
222
221
 
223
- const isTenantOnly =
224
- new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only'
225
- let shellHost: ShellHostReadyBridge | null = null
226
- while (Date.now() < deadline) {
227
- const candidate = (window as any).SootSim?.bridges?.shellHost
228
- if (candidate || isTenantOnly) {
229
- shellHost = candidate ?? null
230
- break
222
+ const isTenantOnly =
223
+ new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only'
224
+ let shellHost: ShellHostReadyBridge | null = null
225
+ while (Date.now() < deadline) {
226
+ const candidate = (window as any).SootSim?.bridges?.shellHost
227
+ if (candidate || isTenantOnly) {
228
+ shellHost = candidate ?? null
229
+ break
230
+ }
231
+ await sleep(25)
231
232
  }
232
- await sleep(25)
233
- }
234
233
 
235
- if (!shellHost) {
236
- if (isTenantOnly) return
237
- throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`)
238
- }
234
+ if (!shellHost) {
235
+ if (isTenantOnly) return
236
+ throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`)
237
+ }
239
238
 
240
- if (shellHost.ready && typeof shellHost.ready.then === 'function') {
241
- await withTimeout('sootsim shell ready', shellHost.ready)
242
- }
243
- // the tenant restarts into its matching worker artifact when a bundle's
244
- // actual mode differs from the booted one (production bundle, no url
245
- // mode params), and any in-flight tenant call then rejects with
246
- // 'render host disposed'. the receipt poll below is restart-immune
247
- // (pure dom read), but the transitions drain is a tenant call, so run
248
- // the paint boundary plus drain together and re-run them when a
249
- // restart lands mid-sequence. paint timeouts and transition timeouts
250
- // are genuine failures and are never retried.
251
- const isRenderHostDisposed = (error: unknown): boolean =>
252
- error instanceof Error && error.message.includes('render host disposed')
253
- for (;;) {
254
- try {
255
- if (expectsInitialAppPaint) {
256
- while (Date.now() < deadline) {
257
- if (
258
- document.documentElement.dataset.sootsimBootStage === 'painted'
259
- ) {
260
- break
261
- }
262
- await sleep(50)
263
- }
264
- if (document.documentElement.dataset.sootsimBootStage !== 'painted') {
265
- throw new Error(
266
- `sootsim app first content paint timed out after ${timeoutMs}ms`,
267
- )
268
- }
269
- } else if (
270
- shellHost.firstContentPainted &&
271
- typeof shellHost.firstContentPainted.then === 'function'
272
- ) {
273
- await withTimeout(
274
- 'sootsim shell first content paint',
275
- shellHost.firstContentPainted,
276
- )
239
+ if (shellHost.ready && typeof shellHost.ready.then === 'function') {
240
+ await withTimeout('sootsim shell ready', shellHost.ready)
241
+ }
242
+ // the shell's boot-stage receipt can advance on an auxiliary paint.
243
+ // the compositor promise resolves only when app:one has actually
244
+ // painted. the enclosing jest test bounds a genuinely hung boot.
245
+ if (expectsInitialAppPaint) {
246
+ const compositorHost: { firstContentPainted?: Promise<unknown> } | undefined =
247
+ Reflect.get(window, '__sootsimCompositor')
248
+ if (typeof compositorHost?.firstContentPainted?.then !== 'function') {
249
+ throw new Error('sootsim app compositor unavailable at boot')
277
250
  }
251
+ await compositorHost.firstContentPainted
252
+ deadline = Date.now() + 3000
253
+ }
278
254
 
279
- const waitForScreenTransitions = (window as any).__sootsimTest
280
- ?.waitForScreenTransitions
281
- if (typeof waitForScreenTransitions === 'function') {
282
- const transitionResult = await waitForScreenTransitions({
283
- timeoutMs: Math.min(remaining(), 3000),
284
- settleMs: 64,
285
- startWindowMs: 700,
286
- })
287
- if (transitionResult?.timedOut === true) {
288
- throw new Error(
289
- `sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`,
255
+ // the tenant restarts into its matching worker artifact when a bundle's
256
+ // actual mode differs from the booted one (production bundle, no url
257
+ // mode params), and any in-flight tenant call then rejects with
258
+ // 'render host disposed'. the compositor paint promise is stable
259
+ // across tenant restarts, but the transitions drain is a tenant call,
260
+ // so run the drain after paint and re-run it when a
261
+ // restart lands mid-sequence. transition timeouts are genuine failures
262
+ // and are never retried.
263
+ const isRenderHostDisposed = (error: unknown): boolean =>
264
+ error instanceof Error && error.message.includes('render host disposed')
265
+ for (;;) {
266
+ try {
267
+ if (
268
+ !expectsInitialAppPaint &&
269
+ shellHost.firstContentPainted &&
270
+ typeof shellHost.firstContentPainted.then === 'function'
271
+ ) {
272
+ await withTimeout(
273
+ 'sootsim shell first content paint',
274
+ shellHost.firstContentPainted,
290
275
  )
291
276
  }
277
+
278
+ const waitForScreenTransitions = (window as any).__sootsimTest
279
+ ?.waitForScreenTransitions
280
+ if (typeof waitForScreenTransitions === 'function') {
281
+ const transitionResult = await waitForScreenTransitions({
282
+ timeoutMs: Math.min(remaining(), 3000),
283
+ settleMs: 64,
284
+ startWindowMs: 700,
285
+ })
286
+ if (transitionResult?.timedOut === true) {
287
+ throw new Error(
288
+ `sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`,
289
+ )
290
+ }
291
+ }
292
+ break
293
+ } catch (error) {
294
+ if (!isRenderHostDisposed(error) || Date.now() >= deadline) throw error
295
+ await sleep(100)
292
296
  }
293
- break
294
- } catch (error) {
295
- if (!isRenderHostDisposed(error) || Date.now() >= deadline) throw error
296
- await sleep(100)
297
297
  }
298
- }
299
- }, { timeoutMs: timeout, expectsInitialAppPaint })
298
+ },
299
+ { timeoutMs: timeout, expectsInitialAppPaint },
300
+ )
300
301
  } catch (error) {
301
302
  throw await describeSootsimBridgeFailure(page, error)
302
303
  }
@@ -697,7 +698,7 @@ async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
697
698
 
698
699
  async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
699
700
  const focused = await page.evaluate(
700
- () => window.__sootsimTest!.getFocusedNode?.() ?? null,
701
+ async () => (await window.__sootsimTest!.getFocusedNode?.()) ?? null,
701
702
  )
702
703
  return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
703
704
  }
@@ -710,7 +711,17 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
710
711
  }
711
712
  if (await isFocusedTextInputNode(page, node)) return
712
713
  await el.tap()
713
- await page.waitForTimeout(100)
714
+ // the tap reaches the worker on its own channel. keys pressed before it lands
715
+ // would edit around a caret the tap then moves, so wait for its focus.
716
+ await pollUntil(
717
+ () =>
718
+ page.evaluate(async () => (await window.__sootsimTest!.getFocusedNode?.())?.nodeId ?? null),
719
+ (focusedNodeId) => focusedNodeId === node.nodeId,
720
+ {
721
+ timeoutMs: 5000,
722
+ label: `SootSim text input ${node.nodeId} to focus`,
723
+ },
724
+ )
714
725
  }
715
726
 
716
727
  function ensureScreenshotDir() {
@@ -851,6 +862,31 @@ async function dispatchStatusBarOverride(config: StatusBarConfig) {
851
862
  await page.waitForTimeout(50)
852
863
  }
853
864
 
865
+ // rotate the simulated device through the same shell command the rotate
866
+ // control dispatches, so a conformance rotation runs the engine's own
867
+ // orientation switch and geometry republish rather than a driver-side resize.
868
+ // detox's ios server requests UIInterfaceOrientationMask.landscapeRight for
869
+ // 'landscape' (DetoxManager.swift setOrientation), so ios turns to landscape
870
+ // right; android keeps the engine's default landscape.
871
+ async function dispatchDeviceOrientation(orientation: 'portrait' | 'landscape') {
872
+ const page = getPage()
873
+ await page.evaluate(
874
+ ({ eventType, next, landscapeSide }) => {
875
+ window.dispatchEvent(
876
+ new CustomEvent(eventType, {
877
+ detail: { type: 'set-device-orientation', orientation: next, landscapeSide },
878
+ }),
879
+ )
880
+ },
881
+ {
882
+ eventType: SOOTSIM_SHELL_COMMAND_NAME,
883
+ next: orientation,
884
+ landscapeSide: DETOX_PLATFORM === 'ios' ? 'right' : 'left',
885
+ },
886
+ )
887
+ await page.waitForTimeout(50)
888
+ }
889
+
854
890
  const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0
855
891
 
856
892
  async function waitForFrameClock(page: Page, minMs = 25): Promise<void> {
@@ -1035,10 +1071,7 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1035
1071
  if (typeof longPress !== 'function') {
1036
1072
  throw new Error('sootsim longPress bridge is not installed')
1037
1073
  }
1038
- const hit = await longPress(x, y, duration)
1039
- if (!hit) {
1040
- throw new Error(`sootsim longPress missed at ${x},${y}`)
1041
- }
1074
+ await longPress(x, y, duration)
1042
1075
  },
1043
1076
  { duration, x: target.x, y: target.y },
1044
1077
  )
@@ -1083,19 +1116,31 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1083
1116
 
1084
1117
  const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
1085
1118
  const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
1086
- const steps = speed === 'slow' ? 20 : 10
1119
+ // Detox's dtx_longPressAtPoint:...thenDragToElement: presses for
1120
+ // `duration`, then moves one point per display frame at its pan
1121
+ // velocity (25pt per event, times 0.1 slow or 0.5 fast), adds one more
1122
+ // point past the end at the same pace, holds `holdDuration` before that
1123
+ // last point and lifts there
1124
+ const pace = 25 * (speed === 'slow' ? 0.1 : 0.5)
1125
+ const steps = Math.max(
1126
+ 1,
1127
+ Math.round(
1128
+ Math.max(
1129
+ Math.abs(endSootSimX - startSootSimX),
1130
+ Math.abs(endSootSimY - startSootSimY),
1131
+ ) / pace,
1132
+ ),
1133
+ )
1134
+ const frameMs = 1000 / 60
1087
1135
 
1088
- // press at start position
1089
1136
  await page.mouse.move(startPage.x, startPage.y)
1090
1137
  await page.mouse.down()
1091
- await page.waitForTimeout(Math.max(duration, holdDuration))
1092
-
1093
- // drag to end position
1138
+ await page.waitForTimeout(duration)
1094
1139
  const dx = (endPage.x - startPage.x) / steps
1095
1140
  const dy = (endPage.y - startPage.y) / steps
1096
- for (let i = 1; i <= steps; i++) {
1141
+ for (let i = 1; i <= steps + 1; i++) {
1142
+ await page.waitForTimeout(i === steps + 1 ? holdDuration : frameMs)
1097
1143
  await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
1098
- await page.waitForTimeout(speed === 'slow' ? 30 : 10)
1099
1144
  }
1100
1145
 
1101
1146
  await page.mouse.up()
@@ -1127,6 +1172,8 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1127
1172
  if (replaced === true) return
1128
1173
  }
1129
1174
  await focusElementForTextEntry(el)
1175
+ // detox replaces the whole value wherever the focusing tap left the caret
1176
+ await page.keyboard.press('End')
1130
1177
  const value = typeof node?.text === 'string' ? node.text : ''
1131
1178
  for (let i = 0; i < value.length; i++) {
1132
1179
  await page.keyboard.press('Backspace')
@@ -1137,6 +1184,7 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1137
1184
  async clearText() {
1138
1185
  const page = getPage()
1139
1186
  await focusElementForTextEntry(el)
1187
+ await page.keyboard.press('End')
1140
1188
  const node = await findNodeByMatcher(matcher)
1141
1189
  const value = typeof node?.text === 'string' ? node.text : ''
1142
1190
  for (let i = 0; i < value.length; i++) {
@@ -1428,10 +1476,10 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1428
1476
 
1429
1477
  async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
1430
1478
  // two fingers held on a horizontal axis around the node center, then
1431
- // both rotated to `radians` around the same center. positive radians
1432
- // rotate counter-clockwise to match RNGH RotationGesture's reported
1433
- // sign (its 'rotation' delta is positive for a counter-clockwise
1434
- // motion of the rear finger relative to the front finger).
1479
+ // both rotated to `radians` around the same center. on a y-down
1480
+ // screen positive radians drive the left finger up and the right
1481
+ // finger down, which is clockwise, so RNGH reports a positive
1482
+ // 'rotation' delta (clockwise reads positive on both natives).
1435
1483
  const page = getPage()
1436
1484
  const node = await findStableNodeByMatcher(matcher, 'rotate')
1437
1485
  if (!node)
@@ -1814,7 +1862,11 @@ export const device = {
1814
1862
  // at or after an offset, so both sides use the same rounding convention.
1815
1863
  async takeScreenshotStagesFromVisibleChangeFast(
1816
1864
  namePrefix: string,
1817
- stages: Array<{ label: string; offsetMs: number }>,
1865
+ stages: Array<{
1866
+ label: string
1867
+ offsetMs: number
1868
+ anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
1869
+ }>,
1818
1870
  options: {
1819
1871
  detectionTimeoutMs?: number
1820
1872
  frame?: { height: number; width: number; x: number; y: number } | null
@@ -1836,10 +1888,19 @@ export const device = {
1836
1888
  trigger?: () => unknown
1837
1889
  },
1838
1890
  ): Promise<{
1891
+ anchors: Array<{
1892
+ changedPixelPercent: number
1893
+ frameIndex: number
1894
+ id: string
1895
+ notBeforeMs: number
1896
+ virtualTimestampMs: number
1897
+ }>
1898
+ driveStart: { frameIndex: number; virtualTimestampMs: number }
1839
1899
  frames: Array<{
1840
1900
  label: string
1841
1901
  offsetMs: number
1842
1902
  actualMs: number
1903
+ anchorId: string
1843
1904
  path: string
1844
1905
  frameIndex: number
1845
1906
  virtualTimestampMs: number
@@ -1870,8 +1931,17 @@ export const device = {
1870
1931
  installGlobal: true,
1871
1932
  })
1872
1933
  const capturePromise: Promise<{
1934
+ anchors: Array<{
1935
+ changedPixelPercent: number
1936
+ frameIndex: number
1937
+ id: string
1938
+ notBeforeMs: number
1939
+ virtualTimestampMs: number
1940
+ }>
1941
+ driveStart: { frameIndex: number; virtualTimestampMs: number }
1873
1942
  frames: Array<
1874
1943
  FastScreenshotData & {
1944
+ anchorId: string
1875
1945
  frameIndex: number
1876
1946
  virtualTimestampMs: number
1877
1947
  }
@@ -1932,6 +2002,7 @@ export const device = {
1932
2002
  }
1933
2003
  type Snapshot = {
1934
2004
  actualMs: number
2005
+ anchorId: string
1935
2006
  bitmap: ImageBitmap
1936
2007
  label: string
1937
2008
  offsetMs: number
@@ -2025,6 +2096,7 @@ export const device = {
2025
2096
  snapshot.bitmap.close()
2026
2097
  return {
2027
2098
  actualMs: snapshot.actualMs,
2099
+ anchorId: snapshot.anchorId,
2028
2100
  dataUrl: canvas.toDataURL('image/png'),
2029
2101
  label: snapshot.label,
2030
2102
  offsetMs: snapshot.offsetMs,
@@ -2080,6 +2152,10 @@ export const device = {
2080
2152
  holdState.released = true
2081
2153
  await interact.touchUp(holdState.x, holdState.y)
2082
2154
  }
2155
+ // an anchor is the frame before its visible change, which the detector
2156
+ // has already stepped past; its bitmap is kept so a 0ms stage can still
2157
+ // capture the anchor frame itself.
2158
+ const keptFrames = new Map<number, ImageBitmap>()
2083
2159
  try {
2084
2160
  const baselineBitmap = await captureBitmap()
2085
2161
  const baseline = regionPixels(baselineBitmap)
@@ -2088,7 +2164,9 @@ export const device = {
2088
2164
  if (input.hold && interact) {
2089
2165
  const hit = await interact.touchDown(input.hold.x, input.hold.y)
2090
2166
  if (!hit) {
2091
- throw new Error(`peach motion hold missed at ${input.hold.x},${input.hold.y}`)
2167
+ throw new Error(
2168
+ `peach motion hold missed at ${input.hold.x},${input.hold.y}`,
2169
+ )
2092
2170
  }
2093
2171
  } else {
2094
2172
  while (!state.triggerCompleted) {
@@ -2099,6 +2177,8 @@ export const device = {
2099
2177
 
2100
2178
  let visibleStart: FrameReceipt | null = null
2101
2179
  let detectedMeasurement: MotionChangeMeasurement | null = null
2180
+ let previousReceipt: FrameReceipt | null = null
2181
+ let previousBitmap: ImageBitmap | null = null
2102
2182
  const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
2103
2183
  for (let frame = 0; frame < detectionFrameLimit; frame++) {
2104
2184
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
@@ -2114,27 +2194,165 @@ export const device = {
2114
2194
  label: 'SootSim visible-change detector',
2115
2195
  width: bounds.width,
2116
2196
  })
2117
- candidateBitmap.close()
2118
2197
  if (measurement.passedMinimum) {
2198
+ candidateBitmap.close()
2119
2199
  visibleStart = receipt
2120
2200
  detectedMeasurement = measurement
2121
2201
  break
2122
2202
  }
2203
+ previousBitmap?.close()
2204
+ previousBitmap = candidateBitmap
2205
+ previousReceipt = receipt
2206
+ }
2207
+ if (previousReceipt && previousBitmap) {
2208
+ keptFrames.set(previousReceipt.frameIndex, previousBitmap)
2123
2209
  }
2124
2210
  if (!visibleStart || !detectedMeasurement) {
2125
2211
  throw new Error(
2126
2212
  `peach motion had no visible change before ${input.detectionTimeoutMs}ms`,
2127
2213
  )
2128
2214
  }
2215
+ // the drive started on the tick the tap landed, which renders no
2216
+ // visible change: the transition opens at p=0 with the snapshot
2217
+ // identical to the source view still underneath it, so the gate
2218
+ // cannot trip before the morph's first tick. stages measure from
2219
+ // that tap tick (or the change itself when the first stepped frame
2220
+ // already moves). native's tap tick trips the gate on the touch
2221
+ // highlight, so its onset frame is the same event tick: same
2222
+ // event, per-leg rule, not same gate side.
2223
+ const driveStart = previousReceipt ?? visibleStart
2224
+ let currentReceipt = visibleStart
2225
+ const resolvedAnchors = new Map<string, FrameReceipt>()
2226
+ const anchorRecords: Array<{
2227
+ changedPixelPercent: number
2228
+ frameIndex: number
2229
+ id: string
2230
+ notBeforeMs: number
2231
+ virtualTimestampMs: number
2232
+ }> = []
2233
+ // a second drive (the pop after a swipe's pan) re-detects from the
2234
+ // search start: the release tick renders no change (the phase flip
2235
+ // lands the frame after), so the new drive is the tick before the
2236
+ // first consecutive pair past the gate, mirroring the drive rule.
2237
+ const resolveAnchor = async (stage: {
2238
+ label: string
2239
+ anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
2240
+ }): Promise<{ anchorId: string; receipt: FrameReceipt }> => {
2241
+ const anchor = stage.anchor
2242
+ if (!anchor) return { anchorId: 'transition', receipt: driveStart }
2243
+ const known = resolvedAnchors.get(anchor.id)
2244
+ if (known) return { anchorId: anchor.id, receipt: known }
2245
+ if (
2246
+ typeof anchor.notBeforeMs !== 'number' ||
2247
+ !Number.isFinite(anchor.notBeforeMs) ||
2248
+ anchor.notBeforeMs < 0
2249
+ ) {
2250
+ throw new Error(
2251
+ `peach motion stage ${stage.label} anchor ${anchor.id} needs a non-negative notBeforeMs`,
2252
+ )
2253
+ }
2254
+ // proof.js owns the default and fills it; this backstop only
2255
+ // serves direct shim callers.
2256
+ const onsetThresholdPercent = anchor.onsetThresholdPercent ?? 5
2257
+ const searchStartVts = driveStart.virtualTimestampMs + anchor.notBeforeMs
2258
+ if (currentReceipt.virtualTimestampMs > searchStartVts) {
2259
+ throw new Error(
2260
+ `peach motion anchor ${anchor.id} searches from ${anchor.notBeforeMs}ms but the clock already sits past it: the ladder is unexecutable`,
2261
+ )
2262
+ }
2263
+ // step, never jump, to the search start so hold moves land.
2264
+ let searchReceipt = currentReceipt
2265
+ while (searchReceipt.virtualTimestampMs < searchStartVts) {
2266
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
2267
+ searchReceipt = await controller.stepFrame()
2268
+ await advanceHold(searchReceipt)
2269
+ }
2270
+ currentReceipt = searchReceipt
2271
+ let anchorBitmap = await captureBitmap()
2272
+ let previousPixels = regionPixels(anchorBitmap)
2273
+ let anchorReceipt = searchReceipt
2274
+ let onsetMeasurement: MotionChangeMeasurement | null = null
2275
+ // a missed second drive is a wrong gate or search start, never
2276
+ // jitter: the capture is lockstep-deterministic. report both
2277
+ // peaks so the failure calibrates the gate (the early window is
2278
+ // pan-only on every swipe lane: both releases land past it).
2279
+ let panPeakPercent = 0
2280
+ let latePeakPercent = 0
2281
+ for (let frame = 0; frame < detectionFrameLimit; frame++) {
2282
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
2283
+ const receipt = await controller.stepFrame()
2284
+ await advanceHold(receipt)
2285
+ const candidateBitmap = await captureBitmap()
2286
+ const bounds = boundsFor(candidateBitmap)
2287
+ const candidate = regionPixels(candidateBitmap)
2288
+ const measurement = measureChange({
2289
+ baseline: previousPixels,
2290
+ candidate,
2291
+ definition: {
2292
+ ...input.motionChangeDefinition,
2293
+ minimumChangePercent: onsetThresholdPercent,
2294
+ },
2295
+ height: bounds.height,
2296
+ label: `SootSim second-drive detector ${anchor.id}`,
2297
+ width: bounds.width,
2298
+ })
2299
+ previousPixels = candidate
2300
+ currentReceipt = receipt
2301
+ if (receipt.virtualTimestampMs - searchStartVts < 250) {
2302
+ panPeakPercent = Math.max(panPeakPercent, measurement.changePercent)
2303
+ } else {
2304
+ latePeakPercent = Math.max(latePeakPercent, measurement.changePercent)
2305
+ }
2306
+ if (measurement.passedMinimum) {
2307
+ candidateBitmap.close()
2308
+ onsetMeasurement = measurement
2309
+ break
2310
+ }
2311
+ anchorBitmap.close()
2312
+ anchorBitmap = candidateBitmap
2313
+ anchorReceipt = receipt
2314
+ }
2315
+ if (!onsetMeasurement) {
2316
+ anchorBitmap.close()
2317
+ throw new Error(
2318
+ `peach motion anchor ${anchor.id} found no onset past ${onsetThresholdPercent}% within ${input.detectionTimeoutMs}ms of ${anchor.notBeforeMs}ms (pan peak ${panPeakPercent.toFixed(2)}%, later peak ${latePeakPercent.toFixed(2)}%): the gate or the search start is wrong for this surface`,
2319
+ )
2320
+ }
2321
+ keptFrames.get(anchorReceipt.frameIndex)?.close()
2322
+ keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
2323
+ resolvedAnchors.set(anchor.id, anchorReceipt)
2324
+ anchorRecords.push({
2325
+ changedPixelPercent: onsetMeasurement.changePercent,
2326
+ frameIndex: anchorReceipt.frameIndex,
2327
+ id: anchor.id,
2328
+ notBeforeMs: anchor.notBeforeMs,
2329
+ virtualTimestampMs: anchorReceipt.virtualTimestampMs,
2330
+ })
2331
+ return { anchorId: anchor.id, receipt: anchorReceipt }
2332
+ }
2129
2333
 
2130
2334
  const snapshots: Snapshot[] = []
2131
2335
  for (const stage of input.stages) {
2132
2336
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
2337
+ const { anchorId, receipt: anchorReceipt } = await resolveAnchor(stage)
2133
2338
  // step through every hold move and the release that fall before
2134
2339
  // this stage one frame at a time, so the stage paints the pointer
2135
2340
  // where it belongs rather than a hold the jump skipped past.
2136
2341
  const stageFrameIndex =
2137
- visibleStart.frameIndex + Math.ceil(stage.offsetMs / frameMs)
2342
+ anchorReceipt.frameIndex + Math.ceil(stage.offsetMs / frameMs)
2343
+ const kept = keptFrames.get(stageFrameIndex)
2344
+ if (kept && stageFrameIndex < currentReceipt.frameIndex) {
2345
+ snapshots.push({
2346
+ actualMs: 0,
2347
+ anchorId,
2348
+ bitmap: await createImageBitmap(kept),
2349
+ frameIndex: anchorReceipt.frameIndex,
2350
+ label: stage.label,
2351
+ offsetMs: stage.offsetMs,
2352
+ virtualTimestampMs: anchorReceipt.virtualTimestampMs,
2353
+ })
2354
+ continue
2355
+ }
2138
2356
  for (
2139
2357
  let eventFrameIndex = nextHoldEventFrameIndex();
2140
2358
  eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
@@ -2142,10 +2360,12 @@ export const device = {
2142
2360
  ) {
2143
2361
  await advanceHold(await controller.stepFrame())
2144
2362
  }
2145
- const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
2363
+ const receipt = await controller.stepToOffset(anchorReceipt, stage.offsetMs)
2146
2364
  await advanceHold(receipt)
2365
+ currentReceipt = receipt
2147
2366
  snapshots.push({
2148
- actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
2367
+ actualMs: receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
2368
+ anchorId,
2149
2369
  bitmap: await captureBitmap(),
2150
2370
  frameIndex: receipt.frameIndex,
2151
2371
  label: stage.label,
@@ -2153,14 +2373,32 @@ export const device = {
2153
2373
  virtualTimestampMs: receipt.virtualTimestampMs,
2154
2374
  })
2155
2375
  }
2376
+ // a hold that outlasts the last stage still moves and releases on its
2377
+ // own frames; the settle jump below would carry the pointer past them
2378
+ // and leave it down. the settle then runs from that release.
2379
+ let releasedOffsetMs = 0
2380
+ while (nextHoldEventFrameIndex() !== null) {
2381
+ const receipt = await controller.stepFrame()
2382
+ await advanceHold(receipt)
2383
+ releasedOffsetMs = receipt.virtualTimestampMs - driveStart.virtualTimestampMs
2384
+ }
2156
2385
  if (input.settleMs > 0) {
2157
- const maximumOffsetMs = input.stages.reduce(
2158
- (maximum, stage) => Math.max(maximum, stage.offsetMs),
2159
- 0,
2386
+ const maximumPrimaryMs = snapshots.reduce(
2387
+ (maximum, snapshot) =>
2388
+ Math.max(
2389
+ maximum,
2390
+ snapshot.virtualTimestampMs - driveStart.virtualTimestampMs,
2391
+ ),
2392
+ releasedOffsetMs,
2160
2393
  )
2161
- await controller.stepToOffset(visibleStart, maximumOffsetMs + input.settleMs)
2394
+ await controller.stepToOffset(driveStart, maximumPrimaryMs + input.settleMs)
2162
2395
  }
2163
2396
  return {
2397
+ anchors: anchorRecords,
2398
+ driveStart: {
2399
+ frameIndex: driveStart.frameIndex,
2400
+ virtualTimestampMs: driveStart.virtualTimestampMs,
2401
+ },
2164
2402
  frames: snapshots.map(encode),
2165
2403
  visibleChangeDetection: {
2166
2404
  actualMs: (visibleStart.frameIndex * 1000) / 60,
@@ -2174,6 +2412,7 @@ export const device = {
2174
2412
  },
2175
2413
  }
2176
2414
  } finally {
2415
+ for (const bitmap of keptFrames.values()) bitmap.close()
2177
2416
  controller.end()
2178
2417
  delete registry[input.token]
2179
2418
  }
@@ -2231,6 +2470,8 @@ export const device = {
2231
2470
  }, token)
2232
2471
  const capture = await capturePromise
2233
2472
  return {
2473
+ anchors: capture.anchors,
2474
+ driveStart: capture.driveStart,
2234
2475
  frames: writeFastScreenshotFrames(namePrefix, capture.frames),
2235
2476
  triggerCompletedMs,
2236
2477
  triggerStartMs,
@@ -2599,6 +2840,21 @@ export const device = {
2599
2840
  await dispatchStatusBarOverride(config)
2600
2841
  },
2601
2842
 
2843
+ async setOrientation(orientation: string) {
2844
+ const next =
2845
+ orientation === 'portrait'
2846
+ ? 'portrait'
2847
+ : orientation === 'landscape'
2848
+ ? 'landscape'
2849
+ : null
2850
+ if (!next) {
2851
+ throw new Error(
2852
+ `sootsim device.setOrientation supports portrait and landscape; got ${orientation}`,
2853
+ )
2854
+ }
2855
+ await dispatchDeviceOrientation(next)
2856
+ },
2857
+
2602
2858
  async setURLBlacklist(urls: string[]) {
2603
2859
  // no-op
2604
2860
  },