peachsim 0.1.1635 → 0.1.1640

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 (129) hide show
  1. package/README.md +5 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +62 -13
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/commands/doctor.ts +13 -5
  7. package/cli/commands/flow.ts +1 -1
  8. package/cli/commands/inspect/shared.ts +9 -4
  9. package/cli/commands/inspect.ts +65 -47
  10. package/cli/commands/preview.ts +1 -1
  11. package/cli/commands/record.ts +97 -47
  12. package/cli/commands/swift.ts +9 -3
  13. package/cli/commands/upload.ts +48 -42
  14. package/cli/drivers/playwright-sim-host.ts +65 -42
  15. package/cli/main.ts +1 -1
  16. package/cli/outbound-endpoints.ts +7 -5
  17. package/cli/pause-aware-timeout.ts +4 -0
  18. package/cli/ws-bridge.ts +19 -10
  19. package/detox/attempt-events.cjs +62 -5
  20. package/detox/attempt-events.d.cts +7 -0
  21. package/detox/expectations.ts +7 -3
  22. package/detox/index.ts +121 -47
  23. package/detox/motion-change.cjs +170 -21
  24. package/detox/motion-change.d.cts +32 -0
  25. package/detox/navigation-lifecycle.ts +35 -3
  26. package/dist-lib/agent-daemon-client.cjs +1 -1
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +1 -1
  30. package/dist-lib/backend-origin.cjs +8 -7
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +4038 -1983
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +1 -1
  46. package/dist-lib/cloud.mjs +1 -1
  47. package/dist-lib/config.cjs +1 -1
  48. package/dist-lib/config.mjs +1 -1
  49. package/dist-lib/detox/index.cjs +509 -86
  50. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  51. package/dist-lib/expo-swift-bundle.cjs +3551 -1574
  52. package/dist-lib/expo-swift-metadata.cjs +9 -7
  53. package/dist-lib/expo-swift-metadata.mjs +9 -7
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +43633 -2635
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +2748 -128
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +1 -1
  66. package/dist-lib/menu.mjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +1 -1
  70. package/dist-lib/metro-production-bundle.mjs +1 -1
  71. package/dist-lib/metro.cjs +2547 -83
  72. package/dist-lib/native-app-identity-project.cjs +237 -50
  73. package/dist-lib/native-app-identity-project.mjs +237 -50
  74. package/dist-lib/native-app-identity.cjs +29 -30
  75. package/dist-lib/native-app-identity.mjs +29 -30
  76. package/dist-lib/native-firebase-metadata.cjs +1 -1
  77. package/dist-lib/native-firebase-metadata.mjs +1 -1
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +1 -1
  82. package/dist-lib/react-native-host-modules.cjs +2 -2
  83. package/dist-lib/react-native-host-modules.mjs +2 -2
  84. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  85. package/dist-lib/sdk.cjs +1 -1
  86. package/dist-lib/sdk.mjs +1 -1
  87. package/dist-lib/skills.cjs +65403 -22739
  88. package/dist-lib/swift-compile.cjs +93 -32
  89. package/dist-lib/swift-compile.mjs +93 -32
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +7 -5
  93. package/dist-lib/swift-submit.mjs +7 -5
  94. package/dist-lib/swift-toolchain.cjs +316 -41
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +7335 -5432
  97. package/dist-lib/vite.cjs +2857 -12
  98. package/dist-lib/worklet-executable.cjs +14 -2
  99. package/dist-lib/worklet-executable.mjs +14 -2
  100. package/package.json +1 -1
  101. package/src/backend-origin.ts +13 -6
  102. package/src/expo-swift-host.ts +5 -2
  103. package/src/expoKotlinContract.ts +4 -7
  104. package/src/expoSwiftContract.ts +4 -6
  105. package/src/host/staged-host-files.ts +9 -0
  106. package/src/native-app-identity-project.ts +1 -4
  107. package/src/native-app-identity.ts +1 -4
  108. package/src/nativeAppIconContract.ts +1 -4
  109. package/src/output-lock-stdio.ts +36 -0
  110. package/src/react-native-bridge-modules.ts +21 -6
  111. package/src/react-native-host-modules.ts +4 -1
  112. package/src/settings-bundle.ts +1 -4
  113. package/src/swift-compile.ts +91 -33
  114. package/src/swift-distribution.lock.json +22 -12
  115. package/src/swift-distribution.ts +103 -4
  116. package/src/swift-host.ts +89 -26
  117. package/src/swift-icu.ts +16 -10
  118. package/src/swift-objc-compile.ts +355 -0
  119. package/src/swift-objc-sdk.ts +689 -18
  120. package/src/swift-objc-standin.ts +126 -14
  121. package/src/swift-package-manifest.ts +39 -1
  122. package/src/swift-packages.ts +35 -21
  123. package/src/swift-standin-emit.ts +40 -7
  124. package/src/swift-standins.ts +404 -138
  125. package/src/swift-submit.ts +3 -5
  126. package/src/swift-toolchain.ts +378 -46
  127. package/src/worker-system-chunks.ts +11 -12
  128. package/src/worklet-executable.ts +19 -1
  129. package/src/xcode-project.ts +270 -30
@@ -104,3 +104,10 @@ export function writeDiscreteFrameReceipt(
104
104
  context: DiscreteProofContext,
105
105
  trace: DiscreteFrameTrace,
106
106
  ): DiscreteProofReference
107
+
108
+ export function boundText(
109
+ root: string,
110
+ relative: string,
111
+ sha256: string,
112
+ label: string,
113
+ ): string
@@ -54,8 +54,12 @@ export function isNodeVisible(node: SootSimNodeInfo | null | undefined): boolean
54
54
  }
55
55
 
56
56
  // a native host is focused when its editor child holds focus.
57
- export function focusIsWithin(focused: SootSimFocusedNodeInfo, nodeId: number): boolean {
58
- return focused.nodeId === nodeId || focused.ancestorNodeIds.includes(nodeId)
57
+ export function focusIsWithin(
58
+ focused: SootSimFocusedNodeInfo,
59
+ node: SootSimNodeInfo,
60
+ ): boolean {
61
+ const nativeNodeId = node.nativeTag ?? node.nodeId
62
+ return focused.nodeId === nativeNodeId || focused.ancestorNodeIds.includes(nativeNodeId)
59
63
  }
60
64
 
61
65
  // the engine owns focus for canvas inputs and hosted native editors.
@@ -64,7 +68,7 @@ export async function isTextInputNodeFocused(
64
68
  page: Page,
65
69
  ): Promise<boolean> {
66
70
  const focused = await page.evaluate(async () => window.__sootsimTest!.getFocusedNode())
67
- return focused !== null && focusIsWithin(focused, node.nodeId)
71
+ return focused !== null && focusIsWithin(focused, node)
68
72
  }
69
73
 
70
74
  async function isNodeFocused(node: SootSimNodeInfo, page: Page): Promise<boolean> {
package/detox/index.ts CHANGED
@@ -127,6 +127,7 @@ let _browserLaunch: Promise<void> | null = null
127
127
  let _browserLaunchAbort: AbortController | null = null
128
128
  let _reapBrowserNow: (() => void) | null = null
129
129
  let _context: BrowserContext | null = null
130
+ let _appStorageState: Awaited<ReturnType<BrowserContext['storageState']>> | undefined
130
131
  let _page: Page | null = null
131
132
  let _synchronizationEnabled = false
132
133
  let _proofFrameObservationActive = false
@@ -402,6 +403,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
402
403
  let readStartedAt: number | null = null
403
404
  let tenantAtRead = window.__sootsimRenderHost
404
405
  let shellAtRead = window.SootSim?.bridges?.shellHost
406
+ let peerProgress: import('@peach/globals').PeachPeerStatsProgress | null = null
405
407
  let deadlineTimerLateMs: number | null = null
406
408
 
407
409
  while (Date.now() < deadline) {
@@ -409,8 +411,13 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
409
411
  tenantAtRead = window.__sootsimRenderHost
410
412
  shellAtRead = window.SootSim?.bridges?.shellHost
411
413
  let timer: ReturnType<typeof setTimeout> | undefined
414
+ const peerRead = getPeerStats(false)
415
+ peerProgress =
416
+ peerRead && 'peachPeerProgress' in peerRead
417
+ ? (peerRead.peachPeerProgress ?? null)
418
+ : null
412
419
  const stats = await Promise.race([
413
- getPeerStats(false),
420
+ peerRead,
414
421
  new Promise<undefined>((resolve) => {
415
422
  timer = setTimeout(
416
423
  () => {
@@ -459,6 +466,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
459
466
  : {
460
467
  startedAtMs: readStartedAt - start,
461
468
  elapsedMs: Date.now() - readStartedAt,
469
+ peerProgress: peerProgress ? { ...peerProgress } : null,
462
470
  testBridgeChanged: window.__sootsimTest !== testBridge,
463
471
  tenant: {
464
472
  presentAtRequest: !!tenantAtRead,
@@ -553,7 +561,8 @@ async function quiesceContextMedia(context: BrowserContext): Promise<void> {
553
561
  }
554
562
  }
555
563
 
556
- async function closeContext() {
564
+ async function closeContext(preserveStorage: boolean) {
565
+ if (!preserveStorage) _appStorageState = undefined
557
566
  if (_browserCompromised) {
558
567
  throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
559
568
  }
@@ -561,6 +570,15 @@ async function closeContext() {
561
570
  const context = _context
562
571
  const closingContextId = contextIdentity
563
572
  try {
573
+ // process termination clears the app runtime, while its persistent stores
574
+ // remain installed until launchApp({ delete: true }) requests a fresh app.
575
+ if (preserveStorage) {
576
+ _appStorageState = await withLifecycleTimeout(
577
+ 'context.storageState()',
578
+ closingContextId,
579
+ () => context.storageState({ indexedDB: true }),
580
+ )
581
+ }
564
582
  await quiesceContextMedia(context)
565
583
  await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
566
584
  _context = null
@@ -575,7 +593,8 @@ async function closeContext() {
575
593
  }
576
594
  }
577
595
 
578
- async function closeBrowser() {
596
+ async function closeBrowser(preserveStorage: boolean) {
597
+ if (!preserveStorage) _appStorageState = undefined
579
598
  if (!_browser) return
580
599
  const browser = _browser
581
600
  const reapBrowserNow = _reapBrowserNow
@@ -585,7 +604,7 @@ async function closeBrowser() {
585
604
 
586
605
  let closeError: Error | null = null
587
606
  try {
588
- await closeContext()
607
+ await closeContext(preserveStorage)
589
608
  } catch (error) {
590
609
  closeError = error instanceof Error ? error : new Error(String(error))
591
610
  }
@@ -897,7 +916,7 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
897
916
  // would edit around a caret the tap then moves, so wait for its focus.
898
917
  await pollUntil(
899
918
  () => page.evaluate(async () => window.__sootsimTest!.getFocusedNode()),
900
- (focused) => focused !== null && focusIsWithin(focused, node.nodeId),
919
+ (focused) => focused !== null && focusIsWithin(focused, node),
901
920
  {
902
921
  timeoutMs: 5000,
903
922
  label: `SootSim text input ${node.nodeId} to focus`,
@@ -1441,11 +1460,12 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1441
1460
  normalizedStartingPointX = 0.5,
1442
1461
  normalizedStartingPointY = 0.5,
1443
1462
  ) {
1463
+ const page = getPage()
1464
+ if (_synchronizationEnabled) await waitForSootsimIdle(page, 3000, false, 0)
1444
1465
  const node = await findStableNodeByMatcher(matcher, 'swipe')
1445
1466
  if (!node)
1446
1467
  throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
1447
1468
 
1448
- const page = getPage()
1449
1469
  const { absolutePosition, layout } = node
1450
1470
 
1451
1471
  const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
@@ -1972,10 +1992,11 @@ export const device = {
1972
1992
  }
1973
1993
 
1974
1994
  if (!_page || newInstance || opts?.delete || opts?.recordVideoDir) {
1975
- await closeContext()
1995
+ await closeContext(!opts?.delete)
1976
1996
  const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
1977
1997
  hasTouch: true,
1978
1998
  viewport: { width: 500, height: 900 },
1999
+ storageState: _appStorageState,
1979
2000
  }
1980
2001
  if (opts?.recordVideoDir) {
1981
2002
  fs.mkdirSync(opts.recordVideoDir, { recursive: true })
@@ -2090,7 +2111,7 @@ export const device = {
2090
2111
  },
2091
2112
 
2092
2113
  async terminateApp() {
2093
- await closeBrowser()
2114
+ await closeBrowser(true)
2094
2115
  },
2095
2116
 
2096
2117
  async installApp() {
@@ -2617,6 +2638,7 @@ export const device = {
2617
2638
  }>,
2618
2639
  options: {
2619
2640
  detectionTimeoutMs?: number
2641
+ workletFrameTimestampsSeconds?: number[]
2620
2642
  frame?: { height: number; width: number; x: number; y: number } | null
2621
2643
  // a pointer the capture itself presses at the trigger point and releases
2622
2644
  // once durationMs of lockstep time has elapsed, so held states (a lifted
@@ -2629,10 +2651,10 @@ export const device = {
2629
2651
  x: number
2630
2652
  y: number
2631
2653
  } | 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
2654
+ // stages measure from the hold's press ('down') or the frame that
2655
+ // delivers its release ('up') rather than from the first visible
2656
+ // change, the delivered touch anchor the native lane uses.
2657
+ anchorAtTouch?: 'down' | 'up'
2636
2658
  rootFrame?: { height: number; width: number; x: number; y: number } | null
2637
2659
  settleMs?: number
2638
2660
  trigger?: () => unknown
@@ -2843,7 +2865,18 @@ export const device = {
2843
2865
  if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
2844
2866
  throw new Error('peach interact bridge is not installed')
2845
2867
  }
2846
- const controller = await lockstep.begin(60)
2868
+ const pairedPeerStats = async (reset = false) => {
2869
+ const stats = await captureWindow.SootSim?.bridges?.test?.getPeerStats?.(reset)
2870
+ if (!stats || !Number.isSafeInteger(stats.replayedFrameCallbacks))
2871
+ throw new Error(
2872
+ 'paired worklets clock needs native frame delivery diagnostics',
2873
+ )
2874
+ return stats
2875
+ }
2876
+ if (input.workletFrameTimestampsSeconds) await pairedPeerStats(true)
2877
+ const controller = await lockstep.begin(60, {
2878
+ workletFrameTimestampsSeconds: input.workletFrameTimestampsSeconds,
2879
+ })
2847
2880
  // the hold's pointer goes down before the first stepped frame, so its
2848
2881
  // start is one frame before the first receipt; each move and the release
2849
2882
  // land on the first stepped frame at or past their time from there. the
@@ -2901,11 +2934,30 @@ export const device = {
2901
2934
  }
2902
2935
  // an anchor is its visible-change frame; its bitmap is kept so a 0ms
2903
2936
  // stage can still capture the anchor frame itself.
2904
- const keptFrames = new Map<number, ImageBitmap>()
2937
+ const keptFrames = new Map<
2938
+ number,
2939
+ { bitmap: ImageBitmap; receipt: SootSimLockstepFrameReceipt }
2940
+ >()
2941
+ const keepFrame = (receipt: SootSimLockstepFrameReceipt, bitmap: ImageBitmap) => {
2942
+ keptFrames.get(receipt.frameIndex)?.bitmap.close()
2943
+ keptFrames.set(receipt.frameIndex, { bitmap, receipt })
2944
+ }
2905
2945
  try {
2946
+ if (input.anchorAtTouch && !input.hold) {
2947
+ throw new Error('peach touch anchoring needs a hold')
2948
+ }
2906
2949
  const baselineBitmap = await captureBitmap()
2907
2950
  const baseline = regionPixels(baselineBitmap)
2908
- baselineBitmap.close()
2951
+ // the press lands before the first stepped frame, so the screen it
2952
+ // lands on is the baseline itself
2953
+ if (input.anchorAtTouch === 'down')
2954
+ keepFrame(controller.initialFrame, baselineBitmap)
2955
+ else baselineBitmap.close()
2956
+ if (
2957
+ input.workletFrameTimestampsSeconds &&
2958
+ (await pairedPeerStats()).replayedFrameCallbacks > 0
2959
+ )
2960
+ throw new Error('paired worklets callback preceded the Peach trigger')
2909
2961
  state.ready = true
2910
2962
  if (input.hold && interact) {
2911
2963
  const hit = await interact.touchDown(
@@ -2947,7 +2999,7 @@ export const device = {
2947
2999
  if (measurement.passedMinimum) {
2948
3000
  visibleStart = receipt
2949
3001
  detectedMeasurement = measurement
2950
- keptFrames.set(visibleStart.frameIndex, candidateBitmap)
3002
+ keepFrame(visibleStart, candidateBitmap)
2951
3003
  } else {
2952
3004
  candidateBitmap.close()
2953
3005
  }
@@ -2958,9 +3010,12 @@ export const device = {
2958
3010
  const receipt = await controller.stepFrame()
2959
3011
  await advanceHold(receipt)
2960
3012
  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))
3013
+ // a touch anchor's stages may precede the first visible change
3014
+ if (
3015
+ input.anchorAtTouch === 'down' ||
3016
+ (input.anchorAtTouch === 'up' && holdState.releaseReceipt === receipt)
3017
+ ) {
3018
+ keepFrame(receipt, await createImageBitmap(candidateBitmap))
2964
3019
  }
2965
3020
  const bounds = boundsFor(candidateBitmap)
2966
3021
  const measurement = measureChange({
@@ -2976,8 +3031,7 @@ export const device = {
2976
3031
  previousBitmap = null
2977
3032
  visibleStart = receipt
2978
3033
  detectedMeasurement = measurement
2979
- keptFrames.get(receipt.frameIndex)?.close()
2980
- keptFrames.set(receipt.frameIndex, candidateBitmap)
3034
+ keepFrame(receipt, candidateBitmap)
2981
3035
  break
2982
3036
  }
2983
3037
  previousBitmap?.close()
@@ -2992,22 +3046,19 @@ export const device = {
2992
3046
  // stages on every platform measure from the first frame that
2993
3047
  // differs from baseline: native extraction anchors at its
2994
3048
  // 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.
3049
+ // preceding tick is still baseline on both legs. a touch anchor
3050
+ // instead starts the drive at the press or at the frame that
3051
+ // delivers the touch-up.
2997
3052
  let driveStart = visibleStart
2998
3053
  let currentReceipt = visibleStart
2999
- if (input.anchorAtRelease) {
3000
- if (!input.hold) {
3001
- throw new Error('peach release anchoring needs a hold')
3002
- }
3054
+ if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
3055
+ if (input.anchorAtTouch === 'up') {
3003
3056
  while (!holdState.released) {
3004
3057
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3005
3058
  const receipt = await controller.stepFrame()
3006
3059
  await advanceHold(receipt)
3007
3060
  currentReceipt = receipt
3008
- if (holdState.released) {
3009
- keptFrames.set(receipt.frameIndex, await captureBitmap())
3010
- }
3061
+ if (holdState.released) keepFrame(receipt, await captureBitmap())
3011
3062
  }
3012
3063
  if (!holdState.releaseReceipt) {
3013
3064
  throw new Error('peach release anchoring found no release frame')
@@ -3109,8 +3160,7 @@ export const device = {
3109
3160
  `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
3161
  )
3111
3162
  }
3112
- keptFrames.get(anchorReceipt.frameIndex)?.close()
3113
- keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
3163
+ keepFrame(anchorReceipt, anchorBitmap)
3114
3164
  resolvedAnchors.set(anchor.id, anchorReceipt)
3115
3165
  anchorRecords.push({
3116
3166
  changedPixelPercent: onsetMeasurement.changePercent,
@@ -3134,13 +3184,14 @@ export const device = {
3134
3184
  const kept = keptFrames.get(stageFrameIndex)
3135
3185
  if (kept && stageFrameIndex <= currentReceipt.frameIndex) {
3136
3186
  snapshots.push({
3137
- actualMs: 0,
3187
+ actualMs:
3188
+ kept.receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
3138
3189
  anchorId,
3139
- bitmap: await createImageBitmap(kept),
3140
- frameIndex: anchorReceipt.frameIndex,
3190
+ bitmap: await createImageBitmap(kept.bitmap),
3191
+ frameIndex: kept.receipt.frameIndex,
3141
3192
  label: stage.label,
3142
3193
  offsetMs: stage.offsetMs,
3143
- virtualTimestampMs: anchorReceipt.virtualTimestampMs,
3194
+ virtualTimestampMs: kept.receipt.virtualTimestampMs,
3144
3195
  })
3145
3196
  continue
3146
3197
  }
@@ -3164,6 +3215,9 @@ export const device = {
3164
3215
  virtualTimestampMs: receipt.virtualTimestampMs,
3165
3216
  })
3166
3217
  }
3218
+ // the native receipt was recorded through the last stage, not the
3219
+ // release and settle frames that follow it.
3220
+ if (input.workletFrameTimestampsSeconds) controller.endWorkletFrameReceipt()
3167
3221
  // a hold that outlasts the last stage still moves and releases on its
3168
3222
  // own frames; the settle jump below would carry the pointer past them
3169
3223
  // and leave it down. the settle then runs from that release.
@@ -3189,6 +3243,20 @@ export const device = {
3189
3243
  await controller.stepFrame()
3190
3244
  await new Promise((resolve) => setTimeout(resolve, 0))
3191
3245
  }
3246
+ if (input.workletFrameTimestampsSeconds) {
3247
+ const stats = await pairedPeerStats()
3248
+ const first = stats.nativeFrameFirstTimestampMs
3249
+ const firstFrameIndex =
3250
+ first === null
3251
+ ? null
3252
+ : Math.round(
3253
+ (first - controller.initialFrame.virtualTimestampMs) / (1000 / 60),
3254
+ )
3255
+ if (firstFrameIndex !== 1)
3256
+ throw new Error(
3257
+ `paired worklets first delivery did not match the next UI frame: ${firstFrameIndex}`,
3258
+ )
3259
+ }
3192
3260
  return {
3193
3261
  inputReceipts: controller.inputReceipts,
3194
3262
  anchors: anchorRecords,
@@ -3209,15 +3277,16 @@ export const device = {
3209
3277
  },
3210
3278
  }
3211
3279
  } finally {
3212
- for (const bitmap of keptFrames.values()) bitmap.close()
3280
+ for (const kept of keptFrames.values()) kept.bitmap.close()
3213
3281
  controller.end()
3214
3282
  delete registry[input.token]
3215
3283
  }
3216
3284
  },
3217
3285
  {
3286
+ workletFrameTimestampsSeconds: options.workletFrameTimestampsSeconds,
3218
3287
  detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
3219
3288
  frame: options.frame ?? null,
3220
- anchorAtRelease: options.anchorAtRelease ?? false,
3289
+ anchorAtTouch: options.anchorAtTouch ?? null,
3221
3290
  hold: options.hold ?? null,
3222
3291
  motionChangeDefinition: MOTION_CHANGE_DEFINITION,
3223
3292
  platform: device.getPlatform(),
@@ -3228,14 +3297,19 @@ export const device = {
3228
3297
  },
3229
3298
  )
3230
3299
 
3231
- await page.waitForFunction(
3232
- (captureToken) => {
3233
- const registry = window.__sootsimVisibleChangeCaptures
3234
- return registry?.[captureToken]?.ready === true
3235
- },
3236
- token,
3237
- { timeout: 5000 },
3238
- )
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
+ ])
3239
3313
 
3240
3314
  const triggerStartMs = Date.now()
3241
3315
  let triggerCompletedMs = 0
@@ -3847,5 +3921,5 @@ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
3847
3921
  export async function cleanup() {
3848
3922
  _browserLaunchAbort?.abort(new Error('Peach driver cleaned up'))
3849
3923
  await _browserLaunch?.catch(() => {})
3850
- await closeBrowser()
3924
+ await closeBrowser(false)
3851
3925
  }
@@ -106,30 +106,35 @@ function measureProofMotionChange(input) {
106
106
  // exactly still, so only oracle changes of at least this much need a sootsim
107
107
  // match.
108
108
  const PAIRED_MOTION_MINIMUM_CHANGE_PERCENT = 0.3
109
- // a lone change below this after a still transition is a settle blip, which
110
- // the two targets land a frame or two apart, so it needs no match.
109
+ // the last oracle change, when it is below this and follows a still
110
+ // transition, is a settle blip, which the two targets land a frame or two
111
+ // apart, so it needs no match. only that one blip is exempt.
111
112
  const SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT = 0.6
112
113
 
113
114
  // a present or dismiss records one second (60 transitions); a gesture ladder
114
115
  // records four. a gesture jumps at its touch cadence and each capture phases
115
116
  // that cadence differently, so a gesture is graded by total travel instead of
116
- // by frame, and the native driver plays the finger path up to ten frames ahead
117
- // of the sootsim one, so the whole gesture's travel is judged, not its timing.
117
+ // by frame. travel is judged over the whole gesture and again over each quarter
118
+ // so one jump or a lagging release cannot pass on its total.
118
119
  const GESTURE_MOTION_MIN_TRANSITIONS = 120
119
120
  const GESTURE_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.8, max: 1.25 })
121
+ const GESTURE_QUARTER_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.5, max: 2 })
120
122
 
121
123
  // a sampled motion proves movement as a pair: the oracle moves in at least one
122
124
  // transition. a one second motion also needs sootsim to move within one frame
123
125
  // of every transition where the oracle changed by the paired minimum; a hold
124
126
  // where the oracle holds needs no sootsim movement, while a static sootsim
125
127
  // against a moving oracle fails. a gesture needs sootsim's total travel within
126
- // a range of the oracle's. transitions pair by their stage labels; every
127
- // measurement must exist.
128
+ // a range of the oracle's. both lanes hold the same unique stage-label
129
+ // transitions in the same order; every measurement must exist.
128
130
  function evaluateMotionTransitions(transitions, oracleTarget) {
129
131
  const key = (transition) => `${transition.fromLabel}\u0000${transition.toLabel}`
130
132
  const oracle = transitions.filter((transition) => transition.target === oracleTarget)
131
133
  const sootsim = transitions.filter((transition) => transition.target === 'sootsim')
132
- const sootsimByKey = new Map(sootsim.map((transition) => [key(transition), transition]))
134
+ const paired =
135
+ oracle.length === sootsim.length &&
136
+ new Set(oracle.map(key)).size === oracle.length &&
137
+ oracle.every((transition, index) => key(transition) === key(sootsim[index]))
133
138
  const measured = transitions.every(
134
139
  (transition) =>
135
140
  transition.dimensionsMatch &&
@@ -137,38 +142,64 @@ function evaluateMotionTransitions(transitions, oracleTarget) {
137
142
  transition.changePixels !== null &&
138
143
  transition.totalPixels !== null,
139
144
  )
140
- const moving = oracle.filter((transition) => transition.passedMinimum)
141
145
  const travel = (list) =>
142
146
  list
143
147
  .filter(
144
148
  (transition) => transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT,
145
149
  )
146
150
  .reduce((total, transition) => total + transition.changePercent, 0)
147
- const sootsimMovesNear = (index) =>
148
- [index - 1, index, index + 1].some(
149
- (neighbour) => sootsim[neighbour]?.passedMinimum === true,
150
- )
151
- const isSettleBlip = (transition, index) =>
152
- transition.changePercent < SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT &&
153
- !(oracle[index - 1]?.passedMinimum ?? true)
151
+ const movesNear = (list, index) =>
152
+ [index - 1, index, index + 1].some((neighbour) => list[neighbour]?.passedMinimum === true)
153
+ const pairedChange = (transition) =>
154
+ transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT
155
+ const settleBlip = (list) => {
156
+ const index = list.findLastIndex(pairedChange)
157
+ return index >= 0 &&
158
+ list[index].changePercent < SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT &&
159
+ !(list[index - 1]?.passedMinimum ?? true)
160
+ ? index
161
+ : -1
162
+ }
163
+ const oracleBlip = settleBlip(oracle)
164
+ const sootsimBlip = settleBlip(sootsim)
165
+ // the oracle moves only where it changed by the paired minimum; drift and
166
+ // its settle blip alone are not a motion.
167
+ const moving = oracle.filter(
168
+ (transition, index) => pairedChange(transition) && index !== oracleBlip,
169
+ )
154
170
  const within = (actual, expected, range) =>
155
171
  actual >= expected * range.min && actual <= expected * range.max
172
+ const quarter = (list, index) =>
173
+ list.slice(
174
+ Math.floor((list.length * index) / 4),
175
+ Math.floor((list.length * (index + 1)) / 4),
176
+ )
156
177
  const follows =
157
178
  oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
158
- ? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE)
179
+ ? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE) &&
180
+ [0, 1, 2, 3].every((index) =>
181
+ within(
182
+ travel(quarter(sootsim, index)),
183
+ travel(quarter(oracle, index)),
184
+ GESTURE_QUARTER_TRAVEL_RATIO_RANGE,
185
+ ),
186
+ )
159
187
  : oracle.every(
160
188
  (transition, index) =>
161
- !transition.passedMinimum ||
162
- transition.changePercent < PAIRED_MOTION_MINIMUM_CHANGE_PERCENT ||
163
- isSettleBlip(transition, index) ||
164
- (sootsimByKey.has(key(transition)) && sootsimMovesNear(index)),
189
+ !pairedChange(transition) ||
190
+ index === oracleBlip ||
191
+ movesNear(sootsim, index),
192
+ ) &&
193
+ sootsim.every(
194
+ (transition, index) =>
195
+ !pairedChange(transition) || index === sootsimBlip || movesNear(oracle, index),
165
196
  )
166
197
  return {
167
198
  moving: moving.length,
168
199
  passed:
169
200
  measured &&
170
201
  oracle.length > 0 &&
171
- sootsim.length === oracle.length &&
202
+ paired &&
172
203
  moving.length > 0 &&
173
204
  follows,
174
205
  }
@@ -429,8 +460,126 @@ function evaluateDiscreteFrameTrace(input) {
429
460
  return { mode: 'playing', anchorMs: anchor.atMs, holds }
430
461
  }
431
462
 
463
+ // a motion publishes when its transitions pair and its pixels agree at the
464
+ // ends and at a few places in between. the first and last stage must pass their
465
+ // region budget; a ladder of at least ten stages also needs two intermediate
466
+ // stages that pass while the oracle is still moving into them. dense samples
467
+ // may move less than the pairing bar; filtered pixel change away from both
468
+ // endpoint images still proves intermediate motion. a settled tail frame
469
+ // proves nothing about the motion and one frame of timing shift
470
+ // fails a stage outright. the remaining intermediate stages are reported as
471
+ // room to improve, never as a failure. a sparse ladder samples too coarsely to
472
+ // ask for intermediates. a repeating motion also needs every cycle's period
473
+ // within one frame of the oracle's, since re-anchoring each cycle would
474
+ // otherwise hide a lane that runs fast or slow.
475
+ const PUBLICATION_DENSE_LADDER_MIN_STAGES = 10
476
+ const PUBLICATION_MIN_INTERMEDIATE_PASSES = 2
477
+
478
+ // one onset per cycle of a repeating animation on the lane's own clock: the
479
+ // transition drive, then each `cycle` anchor in declaration order. ios reports
480
+ // recording seconds (the transition's triggerInVideoSec, each anchor's
481
+ // detection.atSec), android its producer clock (triggerProducerSeconds, each
482
+ // anchor's producerSeconds), and peach virtual clock milliseconds (driveStart,
483
+ // each anchor's own receipt).
484
+ function motionCycleOnsets(event, eventPath) {
485
+ if (!Array.isArray(event.anchors)) return []
486
+ const cycles = event.anchors.filter(
487
+ (anchor) => typeof anchor === 'object' && anchor !== null && anchor.cycle === true,
488
+ )
489
+ if (cycles.length === 0) return []
490
+ const finite = (value, label) => {
491
+ if (typeof value !== 'number' || !Number.isFinite(value))
492
+ throw new Error(`motion event cycle ${label} has no onset time: ${eventPath}`)
493
+ return value
494
+ }
495
+ const android = event.captureMode === 'android-screenrecord-sequence'
496
+ const peach = typeof event.driveStart === 'object' && event.driveStart !== null
497
+ const basisMs = peach
498
+ ? finite(event.driveStart.virtualTimestampMs, 'transition')
499
+ : android
500
+ ? finite(event.triggerProducerSeconds, 'transition') * 1000
501
+ : finite(event.triggerInVideoSec, 'transition') * 1000
502
+ return [
503
+ { id: 'transition', atMs: basisMs },
504
+ ...cycles.map((anchor) => {
505
+ if (typeof anchor.id !== 'string')
506
+ throw new Error(`motion event cycle anchor has no id: ${eventPath}`)
507
+ const atMs = peach
508
+ ? finite(anchor.virtualTimestampMs, anchor.id)
509
+ : android
510
+ ? finite(anchor.producerSeconds, anchor.id) * 1000
511
+ : finite(anchor.detection?.atSec, anchor.id) * 1000
512
+ return { id: anchor.id, atMs }
513
+ }),
514
+ ]
515
+ }
516
+
517
+ // one display frame: the period a native repeat cycle may differ from
518
+ // peach's before re-anchoring each cycle would hide real drift
519
+ const MOTION_CYCLE_PERIOD_TOLERANCE_MS = 1000 / 60
520
+
521
+ function motionCyclePeriods(reference, sootsim) {
522
+ const ids = (onsets) => onsets.map((onset) => onset.id).join(',')
523
+ if (ids(reference) !== ids(sootsim))
524
+ throw new Error(
525
+ `motion cycle anchors differ between lanes: reference [${ids(reference)}], sootsim [${ids(sootsim)}]`,
526
+ )
527
+ return reference.slice(1).map((onset, index) => {
528
+ const referenceMs = onset.atMs - reference[index].atMs
529
+ const sootsimMs = sootsim[index + 1].atMs - sootsim[index].atMs
530
+ return {
531
+ id: onset.id,
532
+ passed: Math.abs(referenceMs - sootsimMs) <= MOTION_CYCLE_PERIOD_TOLERANCE_MS,
533
+ referenceMs,
534
+ sootsimMs,
535
+ }
536
+ })
537
+ }
538
+
539
+ function evaluateMotionPublication(stages, transitions, oracleTarget, cycleOnsets) {
540
+ const cyclePeriods = motionCyclePeriods(cycleOnsets.reference, cycleOnsets.sootsim)
541
+ const intermediates = stages.slice(1, -1)
542
+ const intermediatePassed = intermediates.filter((stage) => stage.passedThreshold).length
543
+ const stagesByLabel = new Map(stages.map((stage) => [stage.label, stage]))
544
+ const oracleMovingInto = new Set(
545
+ transitions
546
+ .filter(
547
+ (transition) =>
548
+ transition.target === oracleTarget &&
549
+ (transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT ||
550
+ (transition.changePixels > 0 &&
551
+ stagesByLabel.get(transition.toLabel)?.oracleFromFirstPercent >=
552
+ PAIRED_MOTION_MINIMUM_CHANGE_PERCENT &&
553
+ stagesByLabel.get(transition.toLabel)?.oracleFromLastPercent >=
554
+ PAIRED_MOTION_MINIMUM_CHANGE_PERCENT)),
555
+ )
556
+ .map((transition) => transition.toLabel),
557
+ )
558
+ const movingPassed = intermediates.filter(
559
+ (stage) => stage.passedThreshold && oracleMovingInto.has(stage.label),
560
+ ).length
561
+ const needsIntermediates = stages.length >= PUBLICATION_DENSE_LADDER_MIN_STAGES
562
+ const first = stages[0]
563
+ const last = stages[stages.length - 1]
564
+ return {
565
+ passed:
566
+ stages.length > 0 &&
567
+ first.passedThreshold &&
568
+ last.passedThreshold &&
569
+ (!needsIntermediates || movingPassed >= PUBLICATION_MIN_INTERMEDIATE_PASSES) &&
570
+ cyclePeriods.every((period) => period.passed) &&
571
+ evaluateMotionTransitions(transitions, oracleTarget).passed,
572
+ cyclePeriods,
573
+ intermediateCount: intermediates.length,
574
+ intermediatePassed,
575
+ movingPassed,
576
+ }
577
+ }
578
+
432
579
  module.exports = {
433
580
  MOTION_CHANGE_DEFINITION,
581
+ evaluateMotionPublication,
582
+ motionCycleOnsets,
434
583
  measureMotionChange,
435
584
  measureProofMotionChange,
436
585
  evaluateMotionTransitions,