peachsim 0.1.1640 → 0.1.1644

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 (125) hide show
  1. package/README.md +5 -0
  2. package/cli/app-bundle-resources.ts +57 -0
  3. package/cli/app-stubs.ts +38 -13
  4. package/cli/cloud-session.ts +16 -0
  5. package/cli/commands/assert.ts +17 -4
  6. package/cli/commands/box.ts +33 -19
  7. package/cli/commands/control.ts +12 -0
  8. package/cli/commands/doctor.ts +24 -15
  9. package/cli/commands/inspect/actions.ts +3 -2
  10. package/cli/commands/inspect/core.ts +0 -8
  11. package/cli/commands/inspect.ts +0 -14
  12. package/cli/commands/platform.ts +2 -1
  13. package/cli/commands/record.ts +64 -28
  14. package/cli/commands/swift.ts +10 -2
  15. package/cli/commands/three-mode.ts +4 -38
  16. package/cli/commands/upload.ts +8 -8
  17. package/cli/drivers/playwright.ts +39 -0
  18. package/cli/flow-live-status.ts +0 -2
  19. package/cli/main.ts +1 -1
  20. package/cli/outbound-endpoints.ts +4 -2
  21. package/cli/ws-bridge.ts +0 -6
  22. package/detox/attempt-events.cjs +41 -5
  23. package/detox/index.ts +155 -109
  24. package/detox/jest-environment.cjs +5 -1
  25. package/detox/motion-change.cjs +7 -5
  26. package/detox/motion-change.d.cts +1 -0
  27. package/detox/motion-leg-starts.cjs +20 -0
  28. package/detox/motion-leg-starts.d.cts +8 -0
  29. package/dist-lib/agent-daemon-client.cjs +1 -1
  30. package/dist-lib/agent-events.cjs +1 -1
  31. package/dist-lib/attached-projects.cjs +1 -1
  32. package/dist-lib/auth/shared-session.cjs +1 -1
  33. package/dist-lib/backend-origin.cjs +12 -3
  34. package/dist-lib/beta.cjs +1 -1
  35. package/dist-lib/beta.mjs +1 -1
  36. package/dist-lib/bridge-constants.cjs +1 -1
  37. package/dist-lib/bridge-contract-input.cjs +1 -1
  38. package/dist-lib/bridge-contract-input.mjs +1 -1
  39. package/dist-lib/bridge-contract.cjs +1 -1
  40. package/dist-lib/bridge-contract.mjs +1 -1
  41. package/dist-lib/bundle-channel.cjs +2671 -1833
  42. package/dist-lib/capture-contract.cjs +1 -1
  43. package/dist-lib/capture-contract.mjs +1 -1
  44. package/dist-lib/cloud-contract.cjs +1 -1
  45. package/dist-lib/cloud-contract.mjs +1 -1
  46. package/dist-lib/cloud-test-args.cjs +1 -1
  47. package/dist-lib/cloud-test-args.mjs +1 -1
  48. package/dist-lib/cloud.cjs +1 -1
  49. package/dist-lib/cloud.mjs +1 -1
  50. package/dist-lib/config.cjs +1 -1
  51. package/dist-lib/config.mjs +1 -1
  52. package/dist-lib/detox/index.cjs +578 -130
  53. package/dist-lib/detox/jest-environment.cjs +5 -1
  54. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  55. package/dist-lib/expo-swift-bundle.cjs +2374 -1554
  56. package/dist-lib/expo-swift-metadata.cjs +1 -1
  57. package/dist-lib/expo-swift-metadata.mjs +1 -1
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +2968 -2141
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +132 -57
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/kotlin-host.cjs +1 -1
  69. package/dist-lib/menu.cjs +14 -6
  70. package/dist-lib/menu.mjs +14 -6
  71. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  72. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  73. package/dist-lib/metro-production-bundle.cjs +1 -1
  74. package/dist-lib/metro-production-bundle.mjs +1 -1
  75. package/dist-lib/metro.cjs +120 -53
  76. package/dist-lib/native-app-identity-project.cjs +96 -44
  77. package/dist-lib/native-app-identity-project.mjs +95 -44
  78. package/dist-lib/native-app-identity.cjs +1 -1
  79. package/dist-lib/native-app-identity.mjs +1 -1
  80. package/dist-lib/native-firebase-metadata.cjs +1 -1
  81. package/dist-lib/native-firebase-metadata.mjs +1 -1
  82. package/dist-lib/plist.cjs +1 -1
  83. package/dist-lib/plist.mjs +1 -1
  84. package/dist-lib/profiles.cjs +1 -1
  85. package/dist-lib/public-brand.cjs +1 -1
  86. package/dist-lib/react-native-host-modules.cjs +1 -1
  87. package/dist-lib/react-native-host-modules.mjs +1 -1
  88. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  89. package/dist-lib/sdk.cjs +5 -3
  90. package/dist-lib/sdk.mjs +5 -3
  91. package/dist-lib/skills.cjs +3932 -2868
  92. package/dist-lib/swift-compile.cjs +587 -374
  93. package/dist-lib/swift-compile.mjs +595 -369
  94. package/dist-lib/swift-package.cjs +1 -1
  95. package/dist-lib/swift-package.mjs +1 -1
  96. package/dist-lib/swift-submit.cjs +8 -3
  97. package/dist-lib/swift-submit.mjs +8 -3
  98. package/dist-lib/swift-toolchain.cjs +1 -1
  99. package/dist-lib/swift.cjs +1 -1
  100. package/dist-lib/vite-swift.cjs +2263 -1467
  101. package/dist-lib/vite.cjs +1 -1
  102. package/dist-lib/worklet-executable.cjs +1 -1
  103. package/dist-lib/worklet-executable.mjs +1 -1
  104. package/package.json +1 -1
  105. package/src/appBundleResourcesContract.ts +51 -0
  106. package/src/backend-origin.ts +11 -1
  107. package/src/host/bridge-host.ts +17 -11
  108. package/src/host/staged-host-files.ts +28 -4
  109. package/src/launchMetadataContract.ts +1 -0
  110. package/src/menu.ts +12 -4
  111. package/src/native-app-identity-project.ts +47 -38
  112. package/src/react-native-bridge-modules.ts +89 -76
  113. package/src/runtime-delivery.ts +6 -7
  114. package/src/swift-compile.ts +150 -112
  115. package/src/swift-compiler-digest.ts +5 -3
  116. package/src/swift-distribution.lock.json +9 -8
  117. package/src/swift-host.ts +6 -4
  118. package/src/swift-objc-compile.ts +416 -194
  119. package/src/swift-objc-metadata.ts +204 -0
  120. package/src/swift-objc-sdk.ts +195 -2
  121. package/src/swift-package-manifest.ts +6 -3
  122. package/src/swift-root-lock.ts +302 -0
  123. package/src/swift-submit.ts +15 -2
  124. package/src/swiftContract.ts +12 -0
  125. package/src/xcode-project.ts +77 -7
package/detox/index.ts CHANGED
@@ -14,7 +14,11 @@ import {
14
14
  type ChromiumLaunchAdmission,
15
15
  } from '../../../scripts/lib/reap-browser'
16
16
  import { automateWebAuthenticationSessions } from '../../peach-engine/src/web-authentication-automation-host'
17
- import type { LockstepInputReceipt, SootSimLockstepFrameReceipt } from '@peach/globals'
17
+ import type {
18
+ LockstepInputReceipt,
19
+ LockstepScheduledInput,
20
+ SootSimLockstepFrameReceipt,
21
+ } from '@peach/globals'
18
22
  export { reserveChromiumAdmission } from '../../../scripts/lib/reap-browser'
19
23
  import {
20
24
  readBrowserCompositeCaptureRequest,
@@ -72,6 +76,8 @@ import type { DiscreteProofContext } from './attempt-events.cjs'
72
76
  import type { SootSimElement } from './element-types'
73
77
  import type { Browser, BrowserContext, Page } from 'playwright-core'
74
78
 
79
+ const WORKLET_FRAME_TRACE_LIMIT = 16384
80
+
75
81
  type DiscreteStageFrame = {
76
82
  label: string
77
83
  offsetMs: number
@@ -492,8 +498,12 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
492
498
  )
493
499
  if (!result?.settled) {
494
500
  const pending = result?.pending ?? 'no peer stats reply'
495
- throw new Error(
496
- `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
501
+ throw await describeSootsimBridgeFailure(
502
+ page,
503
+ new Error(
504
+ `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
505
+ ),
506
+ 'sootsim worklet runtime did not settle',
497
507
  )
498
508
  }
499
509
  }
@@ -2119,7 +2129,7 @@ export const device = {
2119
2129
  },
2120
2130
 
2121
2131
  async uninstallApp() {
2122
- // no-op
2132
+ await closeBrowser(false)
2123
2133
  },
2124
2134
 
2125
2135
  async openURL(url: { url: string; sourceApp?: string }) {
@@ -2639,15 +2649,18 @@ export const device = {
2639
2649
  options: {
2640
2650
  detectionTimeoutMs?: number
2641
2651
  workletFrameTimestampsSeconds?: number[]
2652
+ workletFrameTracePath?: string
2642
2653
  frame?: { height: number; width: number; x: number; y: number } | null
2643
2654
  // a pointer the capture itself presses at the trigger point and releases
2644
2655
  // once durationMs of lockstep time has elapsed, so held states (a lifted
2645
2656
  // control thumb, a pressed row) are painted mid-hold. moves drag the
2646
2657
  // held pointer to each point once afterMs of lockstep time has elapsed
2647
- // since the press, and the release lands at the last point reached.
2658
+ // since the press. an explicit release position preserves witnessed up coordinates.
2648
2659
  hold?: {
2649
2660
  durationMs: number
2650
- moves?: Array<{ afterMs: number; x: number; y: number }>
2661
+ eventDurationMs?: number
2662
+ release?: { x: number; y: number }
2663
+ moves?: Array<{ afterMs: number; eventAfterMs?: number; x: number; y: number }>
2651
2664
  x: number
2652
2665
  y: number
2653
2666
  } | null
@@ -2703,6 +2716,14 @@ export const device = {
2703
2716
  definition: MOTION_CHANGE_DEFINITION,
2704
2717
  installGlobal: true,
2705
2718
  })
2719
+ if (options.workletFrameTracePath) {
2720
+ await page.evaluate(async (limit) => {
2721
+ const enable = window.SootSim?.bridges?.test?.setShellReanimatedTraceEnabled
2722
+ if (typeof enable !== 'function')
2723
+ throw new Error('worklet trace bridge is absent')
2724
+ await enable(true, true, limit)
2725
+ }, WORKLET_FRAME_TRACE_LIMIT)
2726
+ }
2706
2727
  const capturePromise: Promise<{
2707
2728
  inputReceipts: LockstepInputReceipt[]
2708
2729
  anchors: Array<{
@@ -2877,61 +2898,9 @@ export const device = {
2877
2898
  const controller = await lockstep.begin(60, {
2878
2899
  workletFrameTimestampsSeconds: input.workletFrameTimestampsSeconds,
2879
2900
  })
2880
- // the hold's pointer goes down before the first stepped frame, so its
2881
- // start is one frame before the first receipt; each move and the release
2882
- // land on the first stepped frame at or past their time from there. the
2883
- // deadlines are frame indexes: comparing summed virtual timestamps
2884
- // against a duration lets rounding push an event past its frame.
2885
- const moves = input.hold?.moves ?? []
2886
- const holdState = {
2887
- nextMove: 0,
2888
- pressFrameIndex: null as number | null,
2889
- releaseReceipt: null as SootSimLockstepFrameReceipt | null,
2890
- released: false,
2891
- x: input.hold?.x ?? 0,
2892
- y: input.hold?.y ?? 0,
2893
- }
2894
- const holdEventFrameIndex = (afterMs: number) =>
2895
- (holdState.pressFrameIndex ?? 0) + controller.framesForOffset(afterMs)
2896
- const nextHoldEventFrameIndex = () => {
2897
- if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
2898
- return null
2899
- }
2900
- const move = moves[holdState.nextMove]
2901
- return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2902
- }
2903
- const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
2904
- if (!input.hold || !interact || holdState.released) return
2905
- if (holdState.pressFrameIndex === null) {
2906
- holdState.pressFrameIndex = receipt.frameIndex - 1
2907
- }
2908
- for (
2909
- let move = moves[holdState.nextMove];
2910
- move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs);
2911
- move = moves[holdState.nextMove]
2912
- ) {
2913
- holdState.nextMove++
2914
- holdState.x = move.x
2915
- holdState.y = move.y
2916
- // event time remains on the replay clock when delivery ceilings
2917
- // to a display tick or several native samples share one frame.
2918
- await interact.touchMove(
2919
- move.x,
2920
- move.y,
2921
- 999,
2922
- controller.initialFrame.virtualTimestampMs + move.afterMs,
2923
- )
2924
- }
2925
- if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2926
- holdState.released = true
2927
- holdState.releaseReceipt = receipt
2928
- await interact.touchUp(
2929
- holdState.x,
2930
- holdState.y,
2931
- 999,
2932
- controller.initialFrame.virtualTimestampMs + input.hold.durationMs,
2933
- )
2934
- }
2901
+ let heldInput: Promise<void> | null = null
2902
+ const releaseReceipt = () =>
2903
+ controller.inputReceipts.find((receipt) => receipt.phase === 'up')
2935
2904
  // an anchor is its visible-change frame; its bitmap is kept so a 0ms
2936
2905
  // stage can still capture the anchor frame itself.
2937
2906
  const keptFrames = new Map<
@@ -2960,17 +2929,51 @@ export const device = {
2960
2929
  throw new Error('paired worklets callback preceded the Peach trigger')
2961
2930
  state.ready = true
2962
2931
  if (input.hold && interact) {
2963
- const hit = await interact.touchDown(
2964
- input.hold.x,
2965
- input.hold.y,
2966
- 999,
2967
- controller.initialFrame.virtualTimestampMs,
2968
- )
2969
- if (!hit) {
2970
- throw new Error(
2971
- `peach motion hold missed at ${input.hold.x},${input.hold.y}`,
2972
- )
2973
- }
2932
+ const hold = input.hold
2933
+ const moves = hold.moves ?? []
2934
+ const release = hold.release ?? moves.at(-1) ?? hold
2935
+ const events: LockstepScheduledInput[] = [
2936
+ {
2937
+ phase: 'down',
2938
+ x: hold.x,
2939
+ y: hold.y,
2940
+ pointerId: 999,
2941
+ afterMs: 0,
2942
+ async deliver(timestamp) {
2943
+ const hit = await interact.touchDown(hold.x, hold.y, 999, timestamp)
2944
+ if (!hit)
2945
+ throw new Error(`peach motion hold missed at ${hold.x},${hold.y}`)
2946
+ },
2947
+ },
2948
+ ...moves.map(
2949
+ (move): LockstepScheduledInput => ({
2950
+ phase: 'move',
2951
+ x: move.x,
2952
+ y: move.y,
2953
+ pointerId: 999,
2954
+ afterMs: move.afterMs,
2955
+ eventAfterMs: move.eventAfterMs,
2956
+ async deliver(timestamp: number) {
2957
+ await interact.touchMove(move.x, move.y, 999, timestamp)
2958
+ },
2959
+ }),
2960
+ ),
2961
+ {
2962
+ phase: 'up',
2963
+ x: release.x,
2964
+ y: release.y,
2965
+ pointerId: 999,
2966
+ afterMs: hold.durationMs,
2967
+ eventAfterMs: hold.eventDurationMs,
2968
+ async deliver(timestamp) {
2969
+ await interact.touchUp(release.x, release.y, 999, timestamp)
2970
+ },
2971
+ },
2972
+ ]
2973
+ heldInput = controller.scheduleInput(events)
2974
+ // rendering propagates delivery errors; also observe cancellation rejection.
2975
+ heldInput.catch(() => {})
2976
+ await controller.flushFrame()
2974
2977
  } else {
2975
2978
  while (!state.triggerStarted) {
2976
2979
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
@@ -3008,12 +3011,12 @@ export const device = {
3008
3011
  for (let frame = 0; !visibleStart && frame < detectionFrameLimit; frame++) {
3009
3012
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3010
3013
  const receipt = await controller.stepFrame()
3011
- await advanceHold(receipt)
3012
3014
  const candidateBitmap = await captureBitmap()
3013
3015
  // a touch anchor's stages may precede the first visible change
3014
3016
  if (
3015
3017
  input.anchorAtTouch === 'down' ||
3016
- (input.anchorAtTouch === 'up' && holdState.releaseReceipt === receipt)
3018
+ (input.anchorAtTouch === 'up' &&
3019
+ releaseReceipt()?.frameIndex === receipt.frameIndex)
3017
3020
  ) {
3018
3021
  keepFrame(receipt, await createImageBitmap(candidateBitmap))
3019
3022
  }
@@ -3053,17 +3056,17 @@ export const device = {
3053
3056
  let currentReceipt = visibleStart
3054
3057
  if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
3055
3058
  if (input.anchorAtTouch === 'up') {
3056
- while (!holdState.released) {
3059
+ while (!releaseReceipt()) {
3057
3060
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3058
3061
  const receipt = await controller.stepFrame()
3059
- await advanceHold(receipt)
3060
3062
  currentReceipt = receipt
3061
- if (holdState.released) keepFrame(receipt, await captureBitmap())
3063
+ if (releaseReceipt()) keepFrame(receipt, await captureBitmap())
3062
3064
  }
3063
- if (!holdState.releaseReceipt) {
3065
+ const released = releaseReceipt()
3066
+ if (!released) {
3064
3067
  throw new Error('peach release anchoring found no release frame')
3065
3068
  }
3066
- driveStart = holdState.releaseReceipt
3069
+ driveStart = { ...released, tenantFrame: null }
3067
3070
  }
3068
3071
  const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
3069
3072
  const anchorRecords: Array<{
@@ -3107,7 +3110,6 @@ export const device = {
3107
3110
  while (searchReceipt.virtualTimestampMs < searchStartVts) {
3108
3111
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3109
3112
  searchReceipt = await controller.stepFrame()
3110
- await advanceHold(searchReceipt)
3111
3113
  }
3112
3114
  currentReceipt = searchReceipt
3113
3115
  let anchorBitmap = await captureBitmap()
@@ -3123,7 +3125,6 @@ export const device = {
3123
3125
  for (let frame = 0; frame < detectionFrameLimit; frame++) {
3124
3126
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3125
3127
  const receipt = await controller.stepFrame()
3126
- await advanceHold(receipt)
3127
3128
  const candidateBitmap = await captureBitmap()
3128
3129
  const bounds = boundsFor(candidateBitmap)
3129
3130
  const candidate = regionPixels(candidateBitmap)
@@ -3176,9 +3177,6 @@ export const device = {
3176
3177
  for (const stage of input.stages) {
3177
3178
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3178
3179
  const { anchorId, receipt: anchorReceipt } = await resolveAnchor(stage)
3179
- // step through every hold move and the release that fall before
3180
- // this stage one frame at a time, so the stage paints the pointer
3181
- // where it belongs rather than a hold the jump skipped past.
3182
3180
  const stageFrameIndex =
3183
3181
  anchorReceipt.frameIndex + controller.framesForOffset(stage.offsetMs)
3184
3182
  const kept = keptFrames.get(stageFrameIndex)
@@ -3195,15 +3193,7 @@ export const device = {
3195
3193
  })
3196
3194
  continue
3197
3195
  }
3198
- for (
3199
- let eventFrameIndex = nextHoldEventFrameIndex();
3200
- eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
3201
- eventFrameIndex = nextHoldEventFrameIndex()
3202
- ) {
3203
- await advanceHold(await controller.stepFrame())
3204
- }
3205
3196
  const receipt = await controller.stepToOffset(anchorReceipt, stage.offsetMs)
3206
- await advanceHold(receipt)
3207
3197
  currentReceipt = receipt
3208
3198
  snapshots.push({
3209
3199
  actualMs: receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
@@ -3222,11 +3212,11 @@ export const device = {
3222
3212
  // own frames; the settle jump below would carry the pointer past them
3223
3213
  // and leave it down. the settle then runs from that release.
3224
3214
  let releasedOffsetMs = 0
3225
- while (nextHoldEventFrameIndex() !== null) {
3215
+ while (input.hold && !releaseReceipt()) {
3226
3216
  const receipt = await controller.stepFrame()
3227
- await advanceHold(receipt)
3228
3217
  releasedOffsetMs = receipt.virtualTimestampMs - driveStart.virtualTimestampMs
3229
3218
  }
3219
+ await heldInput
3230
3220
  if (input.settleMs > 0) {
3231
3221
  const maximumPrimaryMs = snapshots.reduce(
3232
3222
  (maximum, snapshot) =>
@@ -3297,23 +3287,25 @@ export const device = {
3297
3287
  },
3298
3288
  )
3299
3289
 
3300
- await Promise.race([
3301
- page.waitForFunction(
3302
- (captureToken) => {
3303
- const registry = window.__sootsimVisibleChangeCaptures
3304
- return registry?.[captureToken]?.ready === true
3305
- },
3306
- token,
3307
- { timeout: 5000 },
3308
- ),
3309
- capturePromise.then(() => {
3310
- throw new Error('peach motion capture completed before its trigger')
3311
- }),
3312
- ])
3313
-
3314
- const triggerStartMs = Date.now()
3290
+ let captureFailure: { error: unknown } | undefined
3291
+ let triggerStartMs = 0
3315
3292
  let triggerCompletedMs = 0
3316
3293
  try {
3294
+ await Promise.race([
3295
+ page.waitForFunction(
3296
+ (captureToken) => {
3297
+ const registry = window.__sootsimVisibleChangeCaptures
3298
+ return registry?.[captureToken]?.ready === true
3299
+ },
3300
+ token,
3301
+ { timeout: 5000 },
3302
+ ),
3303
+ capturePromise.then(() => {
3304
+ throw new Error('peach motion capture completed before its trigger')
3305
+ }),
3306
+ ])
3307
+
3308
+ triggerStartMs = Date.now()
3317
3309
  const triggerPromise = (async () => {
3318
3310
  if (!options.hold) {
3319
3311
  if (typeof options.trigger !== 'function') {
@@ -3349,6 +3341,7 @@ export const device = {
3349
3341
  },
3350
3342
  }
3351
3343
  } catch (error) {
3344
+ captureFailure = { error }
3352
3345
  await page
3353
3346
  .evaluate((captureToken) => {
3354
3347
  const registry = window.__sootsimVisibleChangeCaptures
@@ -3358,6 +3351,59 @@ export const device = {
3358
3351
  .catch(() => {})
3359
3352
  await capturePromise.catch(() => {})
3360
3353
  throw error
3354
+ } finally {
3355
+ if (options.workletFrameTracePath) {
3356
+ try {
3357
+ const events = await page.evaluate(async () => {
3358
+ const test = window.SootSim?.bridges?.test
3359
+ const read = test?.getShellReanimatedTrace
3360
+ const enable = test?.setShellReanimatedTraceEnabled
3361
+ if (typeof read !== 'function' || typeof enable !== 'function')
3362
+ throw new Error('worklet trace bridge is absent')
3363
+ try {
3364
+ return await read()
3365
+ } finally {
3366
+ await enable(false, false)
3367
+ }
3368
+ })
3369
+ fs.writeFileSync(
3370
+ options.workletFrameTracePath,
3371
+ JSON.stringify(
3372
+ {
3373
+ source: 'peach-native-frame-queue',
3374
+ limit: WORKLET_FRAME_TRACE_LIMIT,
3375
+ events,
3376
+ },
3377
+ null,
3378
+ 2,
3379
+ ),
3380
+ )
3381
+ } catch (traceError) {
3382
+ if (!captureFailure) throw traceError
3383
+ const primary = captureFailure.error
3384
+ if (
3385
+ primary &&
3386
+ (typeof primary === 'object' || typeof primary === 'function') &&
3387
+ Object.isExtensible(primary)
3388
+ ) {
3389
+ try {
3390
+ Object.defineProperty(primary, 'workletFrameTraceError', {
3391
+ value: traceError,
3392
+ configurable: true,
3393
+ })
3394
+ } catch (attachmentError) {
3395
+ console.error(
3396
+ '[peach motion capture] could not attach trace failure:',
3397
+ attachmentError,
3398
+ )
3399
+ }
3400
+ }
3401
+ console.error(
3402
+ '[peach motion capture] secondary worklet trace failure:',
3403
+ traceError,
3404
+ )
3405
+ }
3406
+ }
3361
3407
  }
3362
3408
  },
3363
3409
 
@@ -50,11 +50,15 @@ module.exports = class PeachEnvironment extends TestEnvironment {
50
50
  }
51
51
  }
52
52
 
53
- async handleTestEvent(event) {
53
+ async handleTestEvent(event, state) {
54
54
  if (event.name === 'hook_failure') {
55
55
  this.controller.abort(new Error('Peach test hook failed'))
56
56
  try {
57
57
  await this.global.__peachDetoxCleanup?.()
58
+ } catch (error) {
59
+ // circus already recorded the hook failure. a cleanup failure belongs
60
+ // beside it, rather than escaping this handler and replacing the result.
61
+ state.unhandledErrors.push(error)
58
62
  } finally {
59
63
  this.releaseAdmission?.()
60
64
  this.releaseAdmission = null
@@ -112,8 +112,10 @@ const PAIRED_MOTION_MINIMUM_CHANGE_PERCENT = 0.3
112
112
  const SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT = 0.6
113
113
 
114
114
  // a present or dismiss records one second (60 transitions); a gesture ladder
115
- // records four. a gesture jumps at its touch cadence and each capture phases
116
- // that cadence differently, so a gesture is graded by total travel instead of
115
+ // records four. repeated animation ladders can also exceed that length, but
116
+ // their declared cycles retain frame pairing. a gesture jumps at its touch
117
+ // cadence and each capture phases that cadence differently, so it is graded by
118
+ // total travel instead of
117
119
  // by frame. travel is judged over the whole gesture and again over each quarter
118
120
  // so one jump or a lagging release cannot pass on its total.
119
121
  const GESTURE_MOTION_MIN_TRANSITIONS = 120
@@ -127,7 +129,7 @@ const GESTURE_QUARTER_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.5, max: 2 })
127
129
  // against a moving oracle fails. a gesture needs sootsim's total travel within
128
130
  // a range of the oracle's. both lanes hold the same unique stage-label
129
131
  // transitions in the same order; every measurement must exist.
130
- function evaluateMotionTransitions(transitions, oracleTarget) {
132
+ function evaluateMotionTransitions(transitions, oracleTarget, repeating) {
131
133
  const key = (transition) => `${transition.fromLabel}\u0000${transition.toLabel}`
132
134
  const oracle = transitions.filter((transition) => transition.target === oracleTarget)
133
135
  const sootsim = transitions.filter((transition) => transition.target === 'sootsim')
@@ -175,7 +177,7 @@ function evaluateMotionTransitions(transitions, oracleTarget) {
175
177
  Math.floor((list.length * (index + 1)) / 4),
176
178
  )
177
179
  const follows =
178
- oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
180
+ !repeating && oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
179
181
  ? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE) &&
180
182
  [0, 1, 2, 3].every((index) =>
181
183
  within(
@@ -568,7 +570,7 @@ function evaluateMotionPublication(stages, transitions, oracleTarget, cycleOnset
568
570
  last.passedThreshold &&
569
571
  (!needsIntermediates || movingPassed >= PUBLICATION_MIN_INTERMEDIATE_PASSES) &&
570
572
  cyclePeriods.every((period) => period.passed) &&
571
- evaluateMotionTransitions(transitions, oracleTarget).passed,
573
+ evaluateMotionTransitions(transitions, oracleTarget, cyclePeriods.length > 0).passed,
572
574
  cyclePeriods,
573
575
  intermediateCount: intermediates.length,
574
576
  intermediatePassed,
@@ -50,6 +50,7 @@ export type MotionTransitionSample = {
50
50
  export function evaluateMotionTransitions(
51
51
  transitions: MotionTransitionSample[],
52
52
  oracleTarget: string,
53
+ repeating: boolean,
53
54
  ): {
54
55
  moving: number
55
56
  passed: boolean
@@ -0,0 +1,20 @@
1
+ // animation start witnesses are diagnostics; they never replace pixel or cycle grading.
2
+ function animationLegStarts(value) {
3
+ if (!Array.isArray(value) || value.length === 0)
4
+ throw new Error('animation leg starts must be a non-empty array')
5
+ return value.map((row) => {
6
+ if (
7
+ !row ||
8
+ typeof row.track !== 'string' ||
9
+ !row.track ||
10
+ typeof row.leg !== 'string' ||
11
+ !row.leg ||
12
+ !Number.isFinite(row.atMs) ||
13
+ !Number.isFinite(row.startValue)
14
+ )
15
+ throw new Error('invalid animation leg start witness')
16
+ return { track: row.track, leg: row.leg, atMs: row.atMs, startValue: row.startValue }
17
+ })
18
+ }
19
+
20
+ module.exports = { animationLegStarts }
@@ -0,0 +1,8 @@
1
+ export type AnimationLegStart = {
2
+ track: string
3
+ leg: string
4
+ atMs: number
5
+ startValue: number
6
+ }
7
+
8
+ export function animationLegStarts(value: unknown): AnimationLegStart[]
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -31,6 +31,7 @@ __export(backend_origin_exports, {
31
31
  isContrastBackendHost: () => isContrastBackendHost,
32
32
  isContrastBackendOrigin: () => isContrastBackendOrigin,
33
33
  isLoopbackHost: () => isLoopbackHost,
34
+ isLoopbackOrigin: () => isLoopbackOrigin,
34
35
  isTrustedBridgeRequest: () => isTrustedBridgeRequest,
35
36
  loopbackOriginOf: () => loopbackOriginOf,
36
37
  normalizeHostname: () => normalizeHostname,
@@ -38,7 +39,7 @@ __export(backend_origin_exports, {
38
39
  resolveContrastBackendOrigin: () => resolveContrastBackendOrigin
39
40
  });
40
41
  module.exports = __toCommonJS(backend_origin_exports);
41
- var DEFAULT_CONTRAST_BACKEND_ORIGIN = "https://contrast.dev";
42
+ var DEFAULT_CONTRAST_BACKEND_ORIGIN = "https://peachsim.com";
42
43
  var LOOPBACK_PROBE_HOSTNAMES = ["127.0.0.1", "::1"];
43
44
  function normalizeHostname(hostname) {
44
45
  return hostname.replace(/^\[|\]$/g, "").toLowerCase();
@@ -52,6 +53,13 @@ function isLoopbackHost(hostname) {
52
53
  const normalized = normalizeHostname(hostname);
53
54
  return normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "0.0.0.0" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized);
54
55
  }
56
+ function isLoopbackOrigin(origin) {
57
+ try {
58
+ return isLoopbackHost(new URL(origin).hostname);
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
55
63
  function isTrustedBridgeRequest(headers) {
56
64
  const origin = headers.origin;
57
65
  if (origin === void 0) return true;
@@ -92,7 +100,7 @@ function isBrowserAssetProxyUrl(value) {
92
100
  var PEACHSIM_NATIVE_HOST = "native.peachsim.com";
93
101
  function isContrastBackendHost(hostname) {
94
102
  const normalized = normalizeHostname(hostname);
95
- return isLoopbackHost(normalized) || normalized === "contrast.dev" || normalized.endsWith(".contrast.dev") || normalized === PEACHSIM_NATIVE_HOST;
103
+ return isLoopbackHost(normalized) || normalized === "contrast.dev" || normalized.endsWith(".contrast.dev") || normalized === "peachsim.com" || normalized === "www.peachsim.com" || normalized === PEACHSIM_NATIVE_HOST;
96
104
  }
97
105
  function isContrastBackendOrigin(origin) {
98
106
  try {
@@ -122,6 +130,7 @@ function resolveContrastBackendOrigin(currentOrigin, search) {
122
130
  isContrastBackendHost,
123
131
  isContrastBackendOrigin,
124
132
  isLoopbackHost,
133
+ isLoopbackOrigin,
125
134
  isTrustedBridgeRequest,
126
135
  loopbackOriginOf,
127
136
  normalizeHostname,
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_MAX_MS = 5e3;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1644 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var REFUSED_PERFORM_STEP_TYPES = [