peachsim 0.1.1635 → 0.1.1641

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 (142) hide show
  1. package/README.md +10 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +96 -22
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/cloud-session.ts +16 -0
  7. package/cli/commands/assert.ts +17 -4
  8. package/cli/commands/box.ts +33 -19
  9. package/cli/commands/doctor.ts +37 -20
  10. package/cli/commands/flow.ts +1 -1
  11. package/cli/commands/inspect/actions.ts +3 -2
  12. package/cli/commands/inspect/core.ts +0 -8
  13. package/cli/commands/inspect/shared.ts +9 -4
  14. package/cli/commands/inspect.ts +65 -61
  15. package/cli/commands/platform.ts +2 -1
  16. package/cli/commands/preview.ts +1 -1
  17. package/cli/commands/record.ts +154 -68
  18. package/cli/commands/swift.ts +18 -4
  19. package/cli/commands/three-mode.ts +4 -38
  20. package/cli/commands/upload.ts +55 -49
  21. package/cli/drivers/playwright-sim-host.ts +65 -42
  22. package/cli/flow-live-status.ts +0 -2
  23. package/cli/main.ts +2 -2
  24. package/cli/outbound-endpoints.ts +7 -5
  25. package/cli/pause-aware-timeout.ts +4 -0
  26. package/cli/ws-bridge.ts +19 -16
  27. package/detox/attempt-events.cjs +99 -6
  28. package/detox/attempt-events.d.cts +7 -0
  29. package/detox/expectations.ts +7 -3
  30. package/detox/index.ts +259 -139
  31. package/detox/jest-environment.cjs +5 -1
  32. package/detox/motion-change.cjs +170 -21
  33. package/detox/motion-change.d.cts +32 -0
  34. package/detox/navigation-lifecycle.ts +35 -3
  35. package/dist-lib/agent-daemon-client.cjs +1 -1
  36. package/dist-lib/agent-events.cjs +1 -1
  37. package/dist-lib/attached-projects.cjs +1 -1
  38. package/dist-lib/auth/shared-session.cjs +1 -1
  39. package/dist-lib/backend-origin.cjs +17 -7
  40. package/dist-lib/beta.cjs +1 -1
  41. package/dist-lib/beta.mjs +1 -1
  42. package/dist-lib/bridge-constants.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.cjs +1 -1
  44. package/dist-lib/bridge-contract-input.mjs +1 -1
  45. package/dist-lib/bridge-contract.cjs +1 -1
  46. package/dist-lib/bridge-contract.mjs +1 -1
  47. package/dist-lib/bundle-channel.cjs +5014 -2268
  48. package/dist-lib/capture-contract.cjs +1 -1
  49. package/dist-lib/capture-contract.mjs +1 -1
  50. package/dist-lib/cloud-contract.cjs +1 -1
  51. package/dist-lib/cloud-contract.mjs +1 -1
  52. package/dist-lib/cloud-test-args.cjs +1 -1
  53. package/dist-lib/cloud-test-args.mjs +1 -1
  54. package/dist-lib/cloud.cjs +1 -1
  55. package/dist-lib/cloud.mjs +1 -1
  56. package/dist-lib/config.cjs +1 -1
  57. package/dist-lib/config.mjs +1 -1
  58. package/dist-lib/detox/index.cjs +1036 -166
  59. package/dist-lib/detox/jest-environment.cjs +5 -1
  60. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  61. package/dist-lib/expo-swift-bundle.cjs +4613 -1963
  62. package/dist-lib/expo-swift-metadata.cjs +9 -7
  63. package/dist-lib/expo-swift-metadata.mjs +9 -7
  64. package/dist-lib/home-paths.cjs +1 -1
  65. package/dist-lib/host/bridge-host.cjs +58072 -16400
  66. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  67. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  68. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  69. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  70. package/dist-lib/host/websocket-proxy.cjs +1 -1
  71. package/dist-lib/index.cjs +2790 -143
  72. package/dist-lib/jump-to-source-babel.cjs +1 -1
  73. package/dist-lib/jump-to-source-native.cjs +1 -1
  74. package/dist-lib/kotlin-host.cjs +1 -1
  75. package/dist-lib/menu.cjs +1 -1
  76. package/dist-lib/menu.mjs +1 -1
  77. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  78. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  79. package/dist-lib/metro-production-bundle.cjs +1 -1
  80. package/dist-lib/metro-production-bundle.mjs +1 -1
  81. package/dist-lib/metro.cjs +2579 -95
  82. package/dist-lib/native-app-identity-project.cjs +243 -53
  83. package/dist-lib/native-app-identity-project.mjs +243 -53
  84. package/dist-lib/native-app-identity.cjs +29 -30
  85. package/dist-lib/native-app-identity.mjs +29 -30
  86. package/dist-lib/native-firebase-metadata.cjs +1 -1
  87. package/dist-lib/native-firebase-metadata.mjs +1 -1
  88. package/dist-lib/plist.cjs +1 -1
  89. package/dist-lib/plist.mjs +1 -1
  90. package/dist-lib/profiles.cjs +1 -1
  91. package/dist-lib/public-brand.cjs +1 -1
  92. package/dist-lib/react-native-host-modules.cjs +2 -2
  93. package/dist-lib/react-native-host-modules.mjs +2 -2
  94. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  95. package/dist-lib/sdk.cjs +4 -3
  96. package/dist-lib/sdk.mjs +4 -3
  97. package/dist-lib/skills.cjs +66475 -23097
  98. package/dist-lib/swift-compile.cjs +452 -260
  99. package/dist-lib/swift-compile.mjs +451 -252
  100. package/dist-lib/swift-package.cjs +1 -1
  101. package/dist-lib/swift-package.mjs +1 -1
  102. package/dist-lib/swift-submit.cjs +14 -7
  103. package/dist-lib/swift-submit.mjs +14 -7
  104. package/dist-lib/swift-toolchain.cjs +316 -41
  105. package/dist-lib/swift.cjs +1 -1
  106. package/dist-lib/vite-swift.cjs +8188 -5636
  107. package/dist-lib/vite.cjs +2857 -12
  108. package/dist-lib/worklet-executable.cjs +14 -2
  109. package/dist-lib/worklet-executable.mjs +14 -2
  110. package/package.json +1 -1
  111. package/src/backend-origin.ts +21 -6
  112. package/src/expo-swift-host.ts +5 -2
  113. package/src/expoKotlinContract.ts +4 -7
  114. package/src/expoSwiftContract.ts +4 -6
  115. package/src/host/staged-host-files.ts +9 -0
  116. package/src/native-app-identity-project.ts +1 -4
  117. package/src/native-app-identity.ts +1 -4
  118. package/src/nativeAppIconContract.ts +1 -4
  119. package/src/output-lock-stdio.ts +36 -0
  120. package/src/react-native-bridge-modules.ts +106 -78
  121. package/src/react-native-host-modules.ts +4 -1
  122. package/src/settings-bundle.ts +1 -4
  123. package/src/swift-compile.ts +224 -139
  124. package/src/swift-distribution.lock.json +23 -12
  125. package/src/swift-distribution.ts +103 -4
  126. package/src/swift-host.ts +95 -30
  127. package/src/swift-icu.ts +16 -10
  128. package/src/swift-objc-compile.ts +547 -0
  129. package/src/swift-objc-metadata.ts +204 -0
  130. package/src/swift-objc-sdk.ts +883 -19
  131. package/src/swift-objc-standin.ts +126 -14
  132. package/src/swift-package-manifest.ts +42 -1
  133. package/src/swift-packages.ts +35 -21
  134. package/src/swift-root-lock.ts +153 -0
  135. package/src/swift-standin-emit.ts +40 -7
  136. package/src/swift-standins.ts +404 -138
  137. package/src/swift-submit.ts +18 -7
  138. package/src/swift-toolchain.ts +378 -46
  139. package/src/swiftContract.ts +12 -0
  140. package/src/worker-system-chunks.ts +11 -12
  141. package/src/worklet-executable.ts +19 -1
  142. package/src/xcode-project.ts +280 -33
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
@@ -127,6 +133,7 @@ let _browserLaunch: Promise<void> | null = null
127
133
  let _browserLaunchAbort: AbortController | null = null
128
134
  let _reapBrowserNow: (() => void) | null = null
129
135
  let _context: BrowserContext | null = null
136
+ let _appStorageState: Awaited<ReturnType<BrowserContext['storageState']>> | undefined
130
137
  let _page: Page | null = null
131
138
  let _synchronizationEnabled = false
132
139
  let _proofFrameObservationActive = false
@@ -402,6 +409,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
402
409
  let readStartedAt: number | null = null
403
410
  let tenantAtRead = window.__sootsimRenderHost
404
411
  let shellAtRead = window.SootSim?.bridges?.shellHost
412
+ let peerProgress: import('@peach/globals').PeachPeerStatsProgress | null = null
405
413
  let deadlineTimerLateMs: number | null = null
406
414
 
407
415
  while (Date.now() < deadline) {
@@ -409,8 +417,13 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
409
417
  tenantAtRead = window.__sootsimRenderHost
410
418
  shellAtRead = window.SootSim?.bridges?.shellHost
411
419
  let timer: ReturnType<typeof setTimeout> | undefined
420
+ const peerRead = getPeerStats(false)
421
+ peerProgress =
422
+ peerRead && 'peachPeerProgress' in peerRead
423
+ ? (peerRead.peachPeerProgress ?? null)
424
+ : null
412
425
  const stats = await Promise.race([
413
- getPeerStats(false),
426
+ peerRead,
414
427
  new Promise<undefined>((resolve) => {
415
428
  timer = setTimeout(
416
429
  () => {
@@ -459,6 +472,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
459
472
  : {
460
473
  startedAtMs: readStartedAt - start,
461
474
  elapsedMs: Date.now() - readStartedAt,
475
+ peerProgress: peerProgress ? { ...peerProgress } : null,
462
476
  testBridgeChanged: window.__sootsimTest !== testBridge,
463
477
  tenant: {
464
478
  presentAtRequest: !!tenantAtRead,
@@ -484,8 +498,12 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
484
498
  )
485
499
  if (!result?.settled) {
486
500
  const pending = result?.pending ?? 'no peer stats reply'
487
- throw new Error(
488
- `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',
489
507
  )
490
508
  }
491
509
  }
@@ -553,7 +571,8 @@ async function quiesceContextMedia(context: BrowserContext): Promise<void> {
553
571
  }
554
572
  }
555
573
 
556
- async function closeContext() {
574
+ async function closeContext(preserveStorage: boolean) {
575
+ if (!preserveStorage) _appStorageState = undefined
557
576
  if (_browserCompromised) {
558
577
  throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
559
578
  }
@@ -561,6 +580,15 @@ async function closeContext() {
561
580
  const context = _context
562
581
  const closingContextId = contextIdentity
563
582
  try {
583
+ // process termination clears the app runtime, while its persistent stores
584
+ // remain installed until launchApp({ delete: true }) requests a fresh app.
585
+ if (preserveStorage) {
586
+ _appStorageState = await withLifecycleTimeout(
587
+ 'context.storageState()',
588
+ closingContextId,
589
+ () => context.storageState({ indexedDB: true }),
590
+ )
591
+ }
564
592
  await quiesceContextMedia(context)
565
593
  await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
566
594
  _context = null
@@ -575,7 +603,8 @@ async function closeContext() {
575
603
  }
576
604
  }
577
605
 
578
- async function closeBrowser() {
606
+ async function closeBrowser(preserveStorage: boolean) {
607
+ if (!preserveStorage) _appStorageState = undefined
579
608
  if (!_browser) return
580
609
  const browser = _browser
581
610
  const reapBrowserNow = _reapBrowserNow
@@ -585,7 +614,7 @@ async function closeBrowser() {
585
614
 
586
615
  let closeError: Error | null = null
587
616
  try {
588
- await closeContext()
617
+ await closeContext(preserveStorage)
589
618
  } catch (error) {
590
619
  closeError = error instanceof Error ? error : new Error(String(error))
591
620
  }
@@ -897,7 +926,7 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
897
926
  // would edit around a caret the tap then moves, so wait for its focus.
898
927
  await pollUntil(
899
928
  () => page.evaluate(async () => window.__sootsimTest!.getFocusedNode()),
900
- (focused) => focused !== null && focusIsWithin(focused, node.nodeId),
929
+ (focused) => focused !== null && focusIsWithin(focused, node),
901
930
  {
902
931
  timeoutMs: 5000,
903
932
  label: `SootSim text input ${node.nodeId} to focus`,
@@ -1441,11 +1470,12 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1441
1470
  normalizedStartingPointX = 0.5,
1442
1471
  normalizedStartingPointY = 0.5,
1443
1472
  ) {
1473
+ const page = getPage()
1474
+ if (_synchronizationEnabled) await waitForSootsimIdle(page, 3000, false, 0)
1444
1475
  const node = await findStableNodeByMatcher(matcher, 'swipe')
1445
1476
  if (!node)
1446
1477
  throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
1447
1478
 
1448
- const page = getPage()
1449
1479
  const { absolutePosition, layout } = node
1450
1480
 
1451
1481
  const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
@@ -1972,10 +2002,11 @@ export const device = {
1972
2002
  }
1973
2003
 
1974
2004
  if (!_page || newInstance || opts?.delete || opts?.recordVideoDir) {
1975
- await closeContext()
2005
+ await closeContext(!opts?.delete)
1976
2006
  const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
1977
2007
  hasTouch: true,
1978
2008
  viewport: { width: 500, height: 900 },
2009
+ storageState: _appStorageState,
1979
2010
  }
1980
2011
  if (opts?.recordVideoDir) {
1981
2012
  fs.mkdirSync(opts.recordVideoDir, { recursive: true })
@@ -2090,7 +2121,7 @@ export const device = {
2090
2121
  },
2091
2122
 
2092
2123
  async terminateApp() {
2093
- await closeBrowser()
2124
+ await closeBrowser(true)
2094
2125
  },
2095
2126
 
2096
2127
  async installApp() {
@@ -2098,7 +2129,7 @@ export const device = {
2098
2129
  },
2099
2130
 
2100
2131
  async uninstallApp() {
2101
- // no-op
2132
+ await closeBrowser(false)
2102
2133
  },
2103
2134
 
2104
2135
  async openURL(url: { url: string; sourceApp?: string }) {
@@ -2617,22 +2648,26 @@ export const device = {
2617
2648
  }>,
2618
2649
  options: {
2619
2650
  detectionTimeoutMs?: number
2651
+ workletFrameTimestampsSeconds?: number[]
2652
+ workletFrameTracePath?: string
2620
2653
  frame?: { height: number; width: number; x: number; y: number } | null
2621
2654
  // a pointer the capture itself presses at the trigger point and releases
2622
2655
  // once durationMs of lockstep time has elapsed, so held states (a lifted
2623
2656
  // control thumb, a pressed row) are painted mid-hold. moves drag the
2624
2657
  // held pointer to each point once afterMs of lockstep time has elapsed
2625
- // since the press, and the release lands at the last point reached.
2658
+ // since the press. an explicit release position preserves witnessed up coordinates.
2626
2659
  hold?: {
2627
2660
  durationMs: number
2628
- 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 }>
2629
2664
  x: number
2630
2665
  y: number
2631
2666
  } | null
2632
- // stages measure from the frame that delivers the hold's release rather
2633
- // than from the first visible change, the delivered touch-up anchor the
2634
- // native lane uses.
2635
- anchorAtRelease?: boolean
2667
+ // stages measure from the hold's press ('down') or the frame that
2668
+ // delivers its release ('up') rather than from the first visible
2669
+ // change, the delivered touch anchor the native lane uses.
2670
+ anchorAtTouch?: 'down' | 'up'
2636
2671
  rootFrame?: { height: number; width: number; x: number; y: number } | null
2637
2672
  settleMs?: number
2638
2673
  trigger?: () => unknown
@@ -2681,6 +2716,14 @@ export const device = {
2681
2716
  definition: MOTION_CHANGE_DEFINITION,
2682
2717
  installGlobal: true,
2683
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
+ }
2684
2727
  const capturePromise: Promise<{
2685
2728
  inputReceipts: LockstepInputReceipt[]
2686
2729
  anchors: Array<{
@@ -2843,82 +2886,94 @@ export const device = {
2843
2886
  if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
2844
2887
  throw new Error('peach interact bridge is not installed')
2845
2888
  }
2846
- const controller = await lockstep.begin(60)
2847
- // the hold's pointer goes down before the first stepped frame, so its
2848
- // start is one frame before the first receipt; each move and the release
2849
- // land on the first stepped frame at or past their time from there. the
2850
- // deadlines are frame indexes: comparing summed virtual timestamps
2851
- // against a duration lets rounding push an event past its frame.
2852
- const moves = input.hold?.moves ?? []
2853
- const holdState = {
2854
- nextMove: 0,
2855
- pressFrameIndex: null as number | null,
2856
- releaseReceipt: null as SootSimLockstepFrameReceipt | null,
2857
- released: false,
2858
- x: input.hold?.x ?? 0,
2859
- y: input.hold?.y ?? 0,
2860
- }
2861
- const holdEventFrameIndex = (afterMs: number) =>
2862
- (holdState.pressFrameIndex ?? 0) + controller.framesForOffset(afterMs)
2863
- const nextHoldEventFrameIndex = () => {
2864
- if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
2865
- return null
2866
- }
2867
- const move = moves[holdState.nextMove]
2868
- return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2869
- }
2870
- const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
2871
- if (!input.hold || !interact || holdState.released) return
2872
- if (holdState.pressFrameIndex === null) {
2873
- holdState.pressFrameIndex = receipt.frameIndex - 1
2874
- }
2875
- for (
2876
- let move = moves[holdState.nextMove];
2877
- move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs);
2878
- move = moves[holdState.nextMove]
2879
- ) {
2880
- holdState.nextMove++
2881
- holdState.x = move.x
2882
- holdState.y = move.y
2883
- // event time remains on the replay clock when delivery ceilings
2884
- // to a display tick or several native samples share one frame.
2885
- await interact.touchMove(
2886
- move.x,
2887
- move.y,
2888
- 999,
2889
- controller.initialFrame.virtualTimestampMs + move.afterMs,
2889
+ const pairedPeerStats = async (reset = false) => {
2890
+ const stats = await captureWindow.SootSim?.bridges?.test?.getPeerStats?.(reset)
2891
+ if (!stats || !Number.isSafeInteger(stats.replayedFrameCallbacks))
2892
+ throw new Error(
2893
+ 'paired worklets clock needs native frame delivery diagnostics',
2890
2894
  )
2891
- }
2892
- if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2893
- holdState.released = true
2894
- holdState.releaseReceipt = receipt
2895
- await interact.touchUp(
2896
- holdState.x,
2897
- holdState.y,
2898
- 999,
2899
- controller.initialFrame.virtualTimestampMs + input.hold.durationMs,
2900
- )
2895
+ return stats
2901
2896
  }
2897
+ if (input.workletFrameTimestampsSeconds) await pairedPeerStats(true)
2898
+ const controller = await lockstep.begin(60, {
2899
+ workletFrameTimestampsSeconds: input.workletFrameTimestampsSeconds,
2900
+ })
2901
+ let heldInput: Promise<void> | null = null
2902
+ const releaseReceipt = () =>
2903
+ controller.inputReceipts.find((receipt) => receipt.phase === 'up')
2902
2904
  // an anchor is its visible-change frame; its bitmap is kept so a 0ms
2903
2905
  // stage can still capture the anchor frame itself.
2904
- const keptFrames = new Map<number, ImageBitmap>()
2906
+ const keptFrames = new Map<
2907
+ number,
2908
+ { bitmap: ImageBitmap; receipt: SootSimLockstepFrameReceipt }
2909
+ >()
2910
+ const keepFrame = (receipt: SootSimLockstepFrameReceipt, bitmap: ImageBitmap) => {
2911
+ keptFrames.get(receipt.frameIndex)?.bitmap.close()
2912
+ keptFrames.set(receipt.frameIndex, { bitmap, receipt })
2913
+ }
2905
2914
  try {
2915
+ if (input.anchorAtTouch && !input.hold) {
2916
+ throw new Error('peach touch anchoring needs a hold')
2917
+ }
2906
2918
  const baselineBitmap = await captureBitmap()
2907
2919
  const baseline = regionPixels(baselineBitmap)
2908
- baselineBitmap.close()
2920
+ // the press lands before the first stepped frame, so the screen it
2921
+ // lands on is the baseline itself
2922
+ if (input.anchorAtTouch === 'down')
2923
+ keepFrame(controller.initialFrame, baselineBitmap)
2924
+ else baselineBitmap.close()
2925
+ if (
2926
+ input.workletFrameTimestampsSeconds &&
2927
+ (await pairedPeerStats()).replayedFrameCallbacks > 0
2928
+ )
2929
+ throw new Error('paired worklets callback preceded the Peach trigger')
2909
2930
  state.ready = true
2910
2931
  if (input.hold && interact) {
2911
- const hit = await interact.touchDown(
2912
- input.hold.x,
2913
- input.hold.y,
2914
- 999,
2915
- controller.initialFrame.virtualTimestampMs,
2916
- )
2917
- if (!hit) {
2918
- throw new Error(
2919
- `peach motion hold missed at ${input.hold.x},${input.hold.y}`,
2920
- )
2921
- }
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()
2922
2977
  } else {
2923
2978
  while (!state.triggerStarted) {
2924
2979
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
@@ -2947,7 +3002,7 @@ export const device = {
2947
3002
  if (measurement.passedMinimum) {
2948
3003
  visibleStart = receipt
2949
3004
  detectedMeasurement = measurement
2950
- keptFrames.set(visibleStart.frameIndex, candidateBitmap)
3005
+ keepFrame(visibleStart, candidateBitmap)
2951
3006
  } else {
2952
3007
  candidateBitmap.close()
2953
3008
  }
@@ -2956,11 +3011,14 @@ export const device = {
2956
3011
  for (let frame = 0; !visibleStart && frame < detectionFrameLimit; frame++) {
2957
3012
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
2958
3013
  const receipt = await controller.stepFrame()
2959
- await advanceHold(receipt)
2960
3014
  const candidateBitmap = await captureBitmap()
2961
- // a release anchor's frame may precede the first visible change
2962
- if (input.anchorAtRelease && holdState.releaseReceipt === receipt) {
2963
- keptFrames.set(receipt.frameIndex, await createImageBitmap(candidateBitmap))
3015
+ // a touch anchor's stages may precede the first visible change
3016
+ if (
3017
+ input.anchorAtTouch === 'down' ||
3018
+ (input.anchorAtTouch === 'up' &&
3019
+ releaseReceipt()?.frameIndex === receipt.frameIndex)
3020
+ ) {
3021
+ keepFrame(receipt, await createImageBitmap(candidateBitmap))
2964
3022
  }
2965
3023
  const bounds = boundsFor(candidateBitmap)
2966
3024
  const measurement = measureChange({
@@ -2976,8 +3034,7 @@ export const device = {
2976
3034
  previousBitmap = null
2977
3035
  visibleStart = receipt
2978
3036
  detectedMeasurement = measurement
2979
- keptFrames.get(receipt.frameIndex)?.close()
2980
- keptFrames.set(receipt.frameIndex, candidateBitmap)
3037
+ keepFrame(receipt, candidateBitmap)
2981
3038
  break
2982
3039
  }
2983
3040
  previousBitmap?.close()
@@ -2992,27 +3049,24 @@ export const device = {
2992
3049
  // stages on every platform measure from the first frame that
2993
3050
  // differs from baseline: native extraction anchors at its
2994
3051
  // detection frame's pts, so the drive starts here too. the
2995
- // preceding tick is still baseline on both legs. a release anchor
2996
- // instead starts the drive at the frame that delivers the touch-up.
3052
+ // preceding tick is still baseline on both legs. a touch anchor
3053
+ // instead starts the drive at the press or at the frame that
3054
+ // delivers the touch-up.
2997
3055
  let driveStart = visibleStart
2998
3056
  let currentReceipt = visibleStart
2999
- if (input.anchorAtRelease) {
3000
- if (!input.hold) {
3001
- throw new Error('peach release anchoring needs a hold')
3002
- }
3003
- while (!holdState.released) {
3057
+ if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
3058
+ if (input.anchorAtTouch === 'up') {
3059
+ while (!releaseReceipt()) {
3004
3060
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3005
3061
  const receipt = await controller.stepFrame()
3006
- await advanceHold(receipt)
3007
3062
  currentReceipt = receipt
3008
- if (holdState.released) {
3009
- keptFrames.set(receipt.frameIndex, await captureBitmap())
3010
- }
3063
+ if (releaseReceipt()) keepFrame(receipt, await captureBitmap())
3011
3064
  }
3012
- if (!holdState.releaseReceipt) {
3065
+ const released = releaseReceipt()
3066
+ if (!released) {
3013
3067
  throw new Error('peach release anchoring found no release frame')
3014
3068
  }
3015
- driveStart = holdState.releaseReceipt
3069
+ driveStart = { ...released, tenantFrame: null }
3016
3070
  }
3017
3071
  const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
3018
3072
  const anchorRecords: Array<{
@@ -3056,7 +3110,6 @@ export const device = {
3056
3110
  while (searchReceipt.virtualTimestampMs < searchStartVts) {
3057
3111
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3058
3112
  searchReceipt = await controller.stepFrame()
3059
- await advanceHold(searchReceipt)
3060
3113
  }
3061
3114
  currentReceipt = searchReceipt
3062
3115
  let anchorBitmap = await captureBitmap()
@@ -3072,7 +3125,6 @@ export const device = {
3072
3125
  for (let frame = 0; frame < detectionFrameLimit; frame++) {
3073
3126
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3074
3127
  const receipt = await controller.stepFrame()
3075
- await advanceHold(receipt)
3076
3128
  const candidateBitmap = await captureBitmap()
3077
3129
  const bounds = boundsFor(candidateBitmap)
3078
3130
  const candidate = regionPixels(candidateBitmap)
@@ -3109,8 +3161,7 @@ export const device = {
3109
3161
  `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`,
3110
3162
  )
3111
3163
  }
3112
- keptFrames.get(anchorReceipt.frameIndex)?.close()
3113
- keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
3164
+ keepFrame(anchorReceipt, anchorBitmap)
3114
3165
  resolvedAnchors.set(anchor.id, anchorReceipt)
3115
3166
  anchorRecords.push({
3116
3167
  changedPixelPercent: onsetMeasurement.changePercent,
@@ -3126,33 +3177,23 @@ export const device = {
3126
3177
  for (const stage of input.stages) {
3127
3178
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3128
3179
  const { anchorId, receipt: anchorReceipt } = await resolveAnchor(stage)
3129
- // step through every hold move and the release that fall before
3130
- // this stage one frame at a time, so the stage paints the pointer
3131
- // where it belongs rather than a hold the jump skipped past.
3132
3180
  const stageFrameIndex =
3133
3181
  anchorReceipt.frameIndex + controller.framesForOffset(stage.offsetMs)
3134
3182
  const kept = keptFrames.get(stageFrameIndex)
3135
3183
  if (kept && stageFrameIndex <= currentReceipt.frameIndex) {
3136
3184
  snapshots.push({
3137
- actualMs: 0,
3185
+ actualMs:
3186
+ kept.receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
3138
3187
  anchorId,
3139
- bitmap: await createImageBitmap(kept),
3140
- frameIndex: anchorReceipt.frameIndex,
3188
+ bitmap: await createImageBitmap(kept.bitmap),
3189
+ frameIndex: kept.receipt.frameIndex,
3141
3190
  label: stage.label,
3142
3191
  offsetMs: stage.offsetMs,
3143
- virtualTimestampMs: anchorReceipt.virtualTimestampMs,
3192
+ virtualTimestampMs: kept.receipt.virtualTimestampMs,
3144
3193
  })
3145
3194
  continue
3146
3195
  }
3147
- for (
3148
- let eventFrameIndex = nextHoldEventFrameIndex();
3149
- eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
3150
- eventFrameIndex = nextHoldEventFrameIndex()
3151
- ) {
3152
- await advanceHold(await controller.stepFrame())
3153
- }
3154
3196
  const receipt = await controller.stepToOffset(anchorReceipt, stage.offsetMs)
3155
- await advanceHold(receipt)
3156
3197
  currentReceipt = receipt
3157
3198
  snapshots.push({
3158
3199
  actualMs: receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
@@ -3164,15 +3205,18 @@ export const device = {
3164
3205
  virtualTimestampMs: receipt.virtualTimestampMs,
3165
3206
  })
3166
3207
  }
3208
+ // the native receipt was recorded through the last stage, not the
3209
+ // release and settle frames that follow it.
3210
+ if (input.workletFrameTimestampsSeconds) controller.endWorkletFrameReceipt()
3167
3211
  // a hold that outlasts the last stage still moves and releases on its
3168
3212
  // own frames; the settle jump below would carry the pointer past them
3169
3213
  // and leave it down. the settle then runs from that release.
3170
3214
  let releasedOffsetMs = 0
3171
- while (nextHoldEventFrameIndex() !== null) {
3215
+ while (input.hold && !releaseReceipt()) {
3172
3216
  const receipt = await controller.stepFrame()
3173
- await advanceHold(receipt)
3174
3217
  releasedOffsetMs = receipt.virtualTimestampMs - driveStart.virtualTimestampMs
3175
3218
  }
3219
+ await heldInput
3176
3220
  if (input.settleMs > 0) {
3177
3221
  const maximumPrimaryMs = snapshots.reduce(
3178
3222
  (maximum, snapshot) =>
@@ -3189,6 +3233,20 @@ export const device = {
3189
3233
  await controller.stepFrame()
3190
3234
  await new Promise((resolve) => setTimeout(resolve, 0))
3191
3235
  }
3236
+ if (input.workletFrameTimestampsSeconds) {
3237
+ const stats = await pairedPeerStats()
3238
+ const first = stats.nativeFrameFirstTimestampMs
3239
+ const firstFrameIndex =
3240
+ first === null
3241
+ ? null
3242
+ : Math.round(
3243
+ (first - controller.initialFrame.virtualTimestampMs) / (1000 / 60),
3244
+ )
3245
+ if (firstFrameIndex !== 1)
3246
+ throw new Error(
3247
+ `paired worklets first delivery did not match the next UI frame: ${firstFrameIndex}`,
3248
+ )
3249
+ }
3192
3250
  return {
3193
3251
  inputReceipts: controller.inputReceipts,
3194
3252
  anchors: anchorRecords,
@@ -3209,15 +3267,16 @@ export const device = {
3209
3267
  },
3210
3268
  }
3211
3269
  } finally {
3212
- for (const bitmap of keptFrames.values()) bitmap.close()
3270
+ for (const kept of keptFrames.values()) kept.bitmap.close()
3213
3271
  controller.end()
3214
3272
  delete registry[input.token]
3215
3273
  }
3216
3274
  },
3217
3275
  {
3276
+ workletFrameTimestampsSeconds: options.workletFrameTimestampsSeconds,
3218
3277
  detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
3219
3278
  frame: options.frame ?? null,
3220
- anchorAtRelease: options.anchorAtRelease ?? false,
3279
+ anchorAtTouch: options.anchorAtTouch ?? null,
3221
3280
  hold: options.hold ?? null,
3222
3281
  motionChangeDefinition: MOTION_CHANGE_DEFINITION,
3223
3282
  platform: device.getPlatform(),
@@ -3228,18 +3287,25 @@ export const device = {
3228
3287
  },
3229
3288
  )
3230
3289
 
3231
- await page.waitForFunction(
3232
- (captureToken) => {
3233
- const registry = window.__sootsimVisibleChangeCaptures
3234
- return registry?.[captureToken]?.ready === true
3235
- },
3236
- token,
3237
- { timeout: 5000 },
3238
- )
3239
-
3240
- const triggerStartMs = Date.now()
3290
+ let captureFailure: { error: unknown } | undefined
3291
+ let triggerStartMs = 0
3241
3292
  let triggerCompletedMs = 0
3242
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()
3243
3309
  const triggerPromise = (async () => {
3244
3310
  if (!options.hold) {
3245
3311
  if (typeof options.trigger !== 'function') {
@@ -3275,6 +3341,7 @@ export const device = {
3275
3341
  },
3276
3342
  }
3277
3343
  } catch (error) {
3344
+ captureFailure = { error }
3278
3345
  await page
3279
3346
  .evaluate((captureToken) => {
3280
3347
  const registry = window.__sootsimVisibleChangeCaptures
@@ -3284,6 +3351,59 @@ export const device = {
3284
3351
  .catch(() => {})
3285
3352
  await capturePromise.catch(() => {})
3286
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
+ }
3287
3407
  }
3288
3408
  },
3289
3409
 
@@ -3847,5 +3967,5 @@ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
3847
3967
  export async function cleanup() {
3848
3968
  _browserLaunchAbort?.abort(new Error('Peach driver cleaned up'))
3849
3969
  await _browserLaunch?.catch(() => {})
3850
- await closeBrowser()
3970
+ await closeBrowser(false)
3851
3971
  }