peachsim 0.1.1625 → 0.1.1635

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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
package/detox/index.ts CHANGED
@@ -1,8 +1,7 @@
1
+ import { randomBytes } from 'crypto'
1
2
  // detox-compatible test driver for sootsim
2
3
  // drop-in replacement for `import { by, device, element, expect, waitFor } from 'detox'`
3
4
  // uses playwright to control a headless browser running sootsim
4
-
5
- import { randomBytes } from 'crypto'
6
5
  import * as fs from 'fs'
7
6
  import { execFile } from 'node:child_process'
8
7
  import { createRequire } from 'node:module'
@@ -14,6 +13,8 @@ import {
14
13
  reserveChromiumAdmission,
15
14
  type ChromiumLaunchAdmission,
16
15
  } from '../../../scripts/lib/reap-browser'
16
+ import { automateWebAuthenticationSessions } from '../../peach-engine/src/web-authentication-automation-host'
17
+ import type { LockstepInputReceipt, SootSimLockstepFrameReceipt } from '@peach/globals'
17
18
  export { reserveChromiumAdmission } from '../../../scripts/lib/reap-browser'
18
19
  import {
19
20
  readBrowserCompositeCaptureRequest,
@@ -87,10 +88,17 @@ type DiscreteObserverState = {
87
88
  finish: () => DiscreteFrameTrace
88
89
  close: () => void
89
90
  }
91
+ type VisibleChangeCaptureState = {
92
+ cancelled: boolean
93
+ ready: boolean
94
+ triggerStarted: boolean
95
+ triggerCompleted: boolean
96
+ }
90
97
  declare global {
91
98
  var __peachChromiumAdmission: ChromiumLaunchAdmission | undefined
92
99
  var __peachDetoxCleanup: (() => Promise<void>) | undefined
93
100
  interface Window {
101
+ __sootsimVisibleChangeCaptures?: Record<string, VisibleChangeCaptureState>
94
102
  __peachClassifyDiscreteFrame?: typeof classifyDiscreteFrame
95
103
  __peachDiscreteObserver?: DiscreteObserverState
96
104
  }
@@ -378,7 +386,8 @@ export async function waitForSootsimShellReady(
378
386
  async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
379
387
  const result = await page.evaluate(
380
388
  async ({ timeout }) => {
381
- const getPeerStats = window.__sootsimTest?.getPeerStats
389
+ const testBridge = window.__sootsimTest
390
+ const getPeerStats = testBridge?.getPeerStats
382
391
  if (typeof getPeerStats !== 'function') {
383
392
  return { settled: true, skipped: true }
384
393
  }
@@ -390,14 +399,24 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
390
399
  let lastPending: number | null | undefined = null
391
400
  let completedReads = 0
392
401
  let lastReply: string | null = null
402
+ let readStartedAt: number | null = null
403
+ let tenantAtRead = window.__sootsimRenderHost
404
+ let shellAtRead = window.SootSim?.bridges?.shellHost
405
+ let deadlineTimerLateMs: number | null = null
393
406
 
394
407
  while (Date.now() < deadline) {
408
+ readStartedAt = Date.now()
409
+ tenantAtRead = window.__sootsimRenderHost
410
+ shellAtRead = window.SootSim?.bridges?.shellHost
395
411
  let timer: ReturnType<typeof setTimeout> | undefined
396
412
  const stats = await Promise.race([
397
413
  getPeerStats(false),
398
414
  new Promise<undefined>((resolve) => {
399
415
  timer = setTimeout(
400
- () => resolve(undefined),
416
+ () => {
417
+ deadlineTimerLateMs = Date.now() - deadline
418
+ resolve(undefined)
419
+ },
401
420
  Math.max(0, deadline - Date.now()),
402
421
  )
403
422
  }),
@@ -405,6 +424,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
405
424
  if (timer !== undefined) clearTimeout(timer)
406
425
  })
407
426
  if (stats === undefined) break
427
+ readStartedAt = null
408
428
  if (stats === null) return { settled: true, elapsed: Date.now() - start }
409
429
  completedReads++
410
430
  lastReply = JSON.stringify({ elapsedMs: Date.now() - start, stats })
@@ -426,77 +446,44 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
426
446
  }
427
447
 
428
448
  const waited = Date.now() - start
429
- // freeze the completed observations before the separate realm probes.
430
- // a later reply cannot replace what the original deadline observed.
431
- const observation = { completedReads, lastReply }
432
- // no reply at all: ask each realm alone, and time this page's own timer,
433
- // so a silent tenant, a silent shell and a starved page read differently
434
- let realms: string | null = null
435
- if (lastPending === null) {
436
- const probe = async (bridge: unknown) => {
437
- if (
438
- !bridge ||
439
- typeof bridge !== 'object' ||
440
- !('callTestBridge' in bridge) ||
441
- typeof bridge.callTestBridge !== 'function'
442
- ) {
443
- return { status: 'missing-bridge' }
444
- }
445
- const callTestBridge = bridge.callTestBridge.bind(bridge)
446
- const asked = Date.now()
447
- let timer: ReturnType<typeof setTimeout> | undefined
448
- try {
449
- return await Promise.race([
450
- Promise.resolve()
451
- .then(() => callTestBridge('getPeerStats', false))
452
- .then(
453
- (stats) => ({
454
- status: 'answered',
455
- elapsedMs: Date.now() - asked,
456
- stats,
457
- }),
458
- (error: unknown) => ({
459
- status: 'rejected',
460
- elapsedMs: Date.now() - asked,
461
- error: error instanceof Error ? error.message : String(error),
462
- }),
463
- ),
464
- new Promise<{ status: 'no-reply'; elapsedMs: number }>((resolve) => {
465
- timer = setTimeout(
466
- () => resolve({ status: 'no-reply', elapsedMs: Date.now() - asked }),
467
- 1000,
468
- )
469
- }),
470
- ])
471
- } finally {
472
- if (timer !== undefined) clearTimeout(timer)
473
- }
474
- }
475
- const timerAsked = Date.now()
476
- const [tenant, shell, pageTimerLateMs] = await Promise.all([
477
- probe(window.__sootsimRenderHost),
478
- probe(window.SootSim?.bridges?.shellHost),
479
- sleep(0).then(() => Date.now() - timerAsked),
480
- ])
481
- realms = JSON.stringify({
482
- phase: 'after-deadline',
483
- tenant,
484
- shell,
485
- pageTimerLateMs,
486
- })
449
+ // snapshot only the original read. a later request cannot explain which
450
+ // realm delayed this join, and an unchanged host does not prove readiness.
451
+ const tenantAtDeadline = window.__sootsimRenderHost
452
+ const shellAtDeadline = window.SootSim?.bridges?.shellHost
453
+ const observation = {
454
+ completedReads,
455
+ lastReply,
456
+ outstandingRead:
457
+ readStartedAt === null
458
+ ? null
459
+ : {
460
+ startedAtMs: readStartedAt - start,
461
+ elapsedMs: Date.now() - readStartedAt,
462
+ testBridgeChanged: window.__sootsimTest !== testBridge,
463
+ tenant: {
464
+ presentAtRequest: !!tenantAtRead,
465
+ presentAtDeadline: !!tenantAtDeadline,
466
+ changed: tenantAtRead !== tenantAtDeadline,
467
+ },
468
+ shell: {
469
+ presentAtRequest: !!shellAtRead,
470
+ presentAtDeadline: !!shellAtDeadline,
471
+ changed: shellAtRead !== shellAtDeadline,
472
+ },
473
+ },
474
+ deadlineTimerLateMs,
487
475
  }
488
476
  return {
489
477
  settled: false,
490
478
  elapsed: waited,
491
479
  pending: lastPending,
492
- realms,
493
480
  observation,
494
481
  }
495
482
  },
496
483
  { timeout },
497
484
  )
498
485
  if (!result?.settled) {
499
- const pending = result?.pending ?? `no peer stats reply; ${result?.realms}`
486
+ const pending = result?.pending ?? 'no peer stats reply'
500
487
  throw new Error(
501
488
  `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
502
489
  )
@@ -851,6 +838,31 @@ async function dispatchTapAtSootsimPoint(page: Page, point: TapPoint, token?: st
851
838
  }
852
839
  }
853
840
  }
841
+ // detox creates the touch in the matched view's window and sends it
842
+ // through uiapplication, so system status-bar routing cannot take it.
843
+ // that window's hit test picks the receiver, so a covering view such as
844
+ // a box-only parent or a pointerEvents none sibling takes the press.
845
+ if (token) window.__sootsimAnchorTimedInput?.(token)
846
+ let result = await window.__sootsimTest!.activatePressAt(x, y, point.nodeId)
847
+ const refusal = result?.tap
848
+ const covering =
849
+ refusal &&
850
+ typeof refusal === 'object' &&
851
+ 'reason' in refusal &&
852
+ refusal.reason === 'target-covered' &&
853
+ 'coordinateTarget' in refusal
854
+ ? refusal.coordinateTarget
855
+ : null
856
+ if (covering && typeof covering === 'object' && 'nodeId' in covering) {
857
+ const { nodeId } = covering
858
+ if (typeof nodeId === 'number') {
859
+ result = await window.__sootsimTest!.activatePressAt(x, y, nodeId)
860
+ }
861
+ }
862
+ if (result?.ok !== true) {
863
+ throw new Error(`element tap was refused: ${JSON.stringify(result)}`)
864
+ }
865
+ return
854
866
  } else {
855
867
  ;({ x, y } = point)
856
868
  }
@@ -1673,18 +1685,20 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1673
1685
  visible: node.layout.width > 0 && node.layout.height > 0,
1674
1686
  enabled: !node.accessibilityState?.disabled,
1675
1687
  ...node,
1676
- // the transformed rect when the node reports one; a node resolved
1677
- // without a size (x and y only) keeps its own frame
1688
+ // native detox reads a gauge's accessibility path, which excludes
1689
+ // the empty space below an accessory arc. keep other frame contracts.
1678
1690
  frame:
1679
- node.absolutePosition?.width !== undefined &&
1680
- node.absolutePosition.height !== undefined
1681
- ? {
1682
- x: node.absolutePosition.x,
1683
- y: node.absolutePosition.y,
1684
- width: node.absolutePosition.width,
1685
- height: node.absolutePosition.height,
1686
- }
1687
- : node.frame,
1691
+ node.nativeType === 'ExpoUI_GaugeView' && node.accessibilityFrame
1692
+ ? node.accessibilityFrame
1693
+ : node.absolutePosition?.width !== undefined &&
1694
+ node.absolutePosition.height !== undefined
1695
+ ? {
1696
+ x: node.absolutePosition.x,
1697
+ y: node.absolutePosition.y,
1698
+ width: node.absolutePosition.width,
1699
+ height: node.absolutePosition.height,
1700
+ }
1701
+ : node.frame,
1688
1702
  })
1689
1703
  // detox reads an unindexed pattern that selects several elements as a list
1690
1704
  if (matcher.regexFlags !== undefined && matcher.index === undefined) {
@@ -2019,6 +2033,12 @@ export const device = {
2019
2033
  reapOwnedBrowser()
2020
2034
  throw error
2021
2035
  }
2036
+ await automateWebAuthenticationSessions(
2037
+ _context!,
2038
+ '__peachWebAuthenticationHost',
2039
+ _page,
2040
+ false,
2041
+ )
2022
2042
  contextIdentity = newContextIdentity
2023
2043
  }
2024
2044
 
@@ -2600,9 +2620,7 @@ export const device = {
2600
2620
  frame?: { height: number; width: number; x: number; y: number } | null
2601
2621
  // a pointer the capture itself presses at the trigger point and releases
2602
2622
  // once durationMs of lockstep time has elapsed, so held states (a lifted
2603
- // control thumb, a pressed row) are painted mid-hold. an external trigger
2604
- // that holds and releases before returning never paints a frame in
2605
- // between, because no lockstep frame steps while it runs. moves drag the
2623
+ // control thumb, a pressed row) are painted mid-hold. moves drag the
2606
2624
  // held pointer to each point once afterMs of lockstep time has elapsed
2607
2625
  // since the press, and the release lands at the last point reached.
2608
2626
  hold?: {
@@ -2611,11 +2629,16 @@ export const device = {
2611
2629
  x: number
2612
2630
  y: number
2613
2631
  } | 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
2614
2636
  rootFrame?: { height: number; width: number; x: number; y: number } | null
2615
2637
  settleMs?: number
2616
2638
  trigger?: () => unknown
2617
2639
  },
2618
2640
  ): Promise<{
2641
+ inputReceipts: LockstepInputReceipt[]
2619
2642
  anchors: Array<{
2620
2643
  changedPixelPercent: number
2621
2644
  frameIndex: number
@@ -2659,6 +2682,7 @@ export const device = {
2659
2682
  installGlobal: true,
2660
2683
  })
2661
2684
  const capturePromise: Promise<{
2685
+ inputReceipts: LockstepInputReceipt[]
2662
2686
  anchors: Array<{
2663
2687
  changedPixelPercent: number
2664
2688
  frameIndex: number
@@ -2686,12 +2710,6 @@ export const device = {
2686
2710
  }
2687
2711
  }> = page.evaluate(
2688
2712
  async (input) => {
2689
- type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
2690
- type CaptureState = {
2691
- cancelled: boolean
2692
- ready: boolean
2693
- triggerCompleted: boolean
2694
- }
2695
2713
  type CaptureWindow = Window & {
2696
2714
  __sootsimMeasureMotionChange?: (input: {
2697
2715
  baseline: Uint8ClampedArray
@@ -2701,7 +2719,6 @@ export const device = {
2701
2719
  label: string
2702
2720
  width: number
2703
2721
  }) => MotionChangeMeasurement
2704
- __sootsimVisibleChangeCaptures?: Record<string, CaptureState>
2705
2722
  SootSim?: {
2706
2723
  bridges?: {
2707
2724
  interact?: Required<
@@ -2713,19 +2730,6 @@ export const device = {
2713
2730
  frameToken: number
2714
2731
  }>
2715
2732
  }
2716
- lockstepCapture?: {
2717
- begin: (fps: number) => Promise<{
2718
- readonly initialFrame: FrameReceipt
2719
- flushFrame: () => Promise<FrameReceipt>
2720
- stepFrame: () => Promise<FrameReceipt>
2721
- framesForOffset: (offsetMs: number) => number
2722
- stepToOffset: (
2723
- anchor: FrameReceipt,
2724
- offsetMs: number,
2725
- ) => Promise<FrameReceipt>
2726
- end: () => void
2727
- }>
2728
- }
2729
2733
  }
2730
2734
  }
2731
2735
  }
@@ -2751,9 +2755,10 @@ export const device = {
2751
2755
  const registry =
2752
2756
  captureWindow.__sootsimVisibleChangeCaptures ??
2753
2757
  (captureWindow.__sootsimVisibleChangeCaptures = {})
2754
- const state: CaptureState = {
2758
+ const state: VisibleChangeCaptureState = {
2755
2759
  cancelled: false,
2756
2760
  ready: false,
2761
+ triggerStarted: false,
2757
2762
  triggerCompleted: false,
2758
2763
  }
2759
2764
  registry[input.token] = state
@@ -2848,6 +2853,7 @@ export const device = {
2848
2853
  const holdState = {
2849
2854
  nextMove: 0,
2850
2855
  pressFrameIndex: null as number | null,
2856
+ releaseReceipt: null as SootSimLockstepFrameReceipt | null,
2851
2857
  released: false,
2852
2858
  x: input.hold?.x ?? 0,
2853
2859
  y: input.hold?.y ?? 0,
@@ -2861,7 +2867,7 @@ export const device = {
2861
2867
  const move = moves[holdState.nextMove]
2862
2868
  return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2863
2869
  }
2864
- const advanceHold = async (receipt: FrameReceipt) => {
2870
+ const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
2865
2871
  if (!input.hold || !interact || holdState.released) return
2866
2872
  if (holdState.pressFrameIndex === null) {
2867
2873
  holdState.pressFrameIndex = receipt.frameIndex - 1
@@ -2885,6 +2891,7 @@ export const device = {
2885
2891
  }
2886
2892
  if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2887
2893
  holdState.released = true
2894
+ holdState.releaseReceipt = receipt
2888
2895
  await interact.touchUp(
2889
2896
  holdState.x,
2890
2897
  holdState.y,
@@ -2913,13 +2920,13 @@ export const device = {
2913
2920
  )
2914
2921
  }
2915
2922
  } else {
2916
- while (!state.triggerCompleted) {
2923
+ while (!state.triggerStarted) {
2917
2924
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
2918
2925
  await new Promise((resolve) => setTimeout(resolve, 0))
2919
2926
  }
2920
2927
  }
2921
2928
 
2922
- let visibleStart: FrameReceipt | null = null
2929
+ let visibleStart: SootSimLockstepFrameReceipt | null = null
2923
2930
  let detectedMeasurement: MotionChangeMeasurement | null = null
2924
2931
  let previousBitmap: ImageBitmap | null = null
2925
2932
  // android can publish mount layout at p=0 before a clock step.
@@ -2951,6 +2958,10 @@ export const device = {
2951
2958
  const receipt = await controller.stepFrame()
2952
2959
  await advanceHold(receipt)
2953
2960
  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))
2964
+ }
2954
2965
  const bounds = boundsFor(candidateBitmap)
2955
2966
  const measurement = measureChange({
2956
2967
  baseline,
@@ -2965,6 +2976,7 @@ export const device = {
2965
2976
  previousBitmap = null
2966
2977
  visibleStart = receipt
2967
2978
  detectedMeasurement = measurement
2979
+ keptFrames.get(receipt.frameIndex)?.close()
2968
2980
  keptFrames.set(receipt.frameIndex, candidateBitmap)
2969
2981
  break
2970
2982
  }
@@ -2980,10 +2992,29 @@ export const device = {
2980
2992
  // stages on every platform measure from the first frame that
2981
2993
  // differs from baseline: native extraction anchors at its
2982
2994
  // detection frame's pts, so the drive starts here too. the
2983
- // preceding tick is still baseline on both legs.
2984
- const driveStart = visibleStart
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.
2997
+ let driveStart = visibleStart
2985
2998
  let currentReceipt = visibleStart
2986
- const resolvedAnchors = new Map<string, FrameReceipt>()
2999
+ if (input.anchorAtRelease) {
3000
+ if (!input.hold) {
3001
+ throw new Error('peach release anchoring needs a hold')
3002
+ }
3003
+ while (!holdState.released) {
3004
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
3005
+ const receipt = await controller.stepFrame()
3006
+ await advanceHold(receipt)
3007
+ currentReceipt = receipt
3008
+ if (holdState.released) {
3009
+ keptFrames.set(receipt.frameIndex, await captureBitmap())
3010
+ }
3011
+ }
3012
+ if (!holdState.releaseReceipt) {
3013
+ throw new Error('peach release anchoring found no release frame')
3014
+ }
3015
+ driveStart = holdState.releaseReceipt
3016
+ }
3017
+ const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
2987
3018
  const anchorRecords: Array<{
2988
3019
  changedPixelPercent: number
2989
3020
  frameIndex: number
@@ -2997,7 +3028,7 @@ export const device = {
2997
3028
  const resolveAnchor = async (stage: {
2998
3029
  label: string
2999
3030
  anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
3000
- }): Promise<{ anchorId: string; receipt: FrameReceipt }> => {
3031
+ }): Promise<{ anchorId: string; receipt: SootSimLockstepFrameReceipt }> => {
3001
3032
  const anchor = stage.anchor
3002
3033
  if (!anchor) return { anchorId: 'transition', receipt: driveStart }
3003
3034
  const known = resolvedAnchors.get(anchor.id)
@@ -3153,7 +3184,13 @@ export const device = {
3153
3184
  )
3154
3185
  await controller.stepToOffset(driveStart, maximumPrimaryMs + input.settleMs)
3155
3186
  }
3187
+ while (!state.triggerCompleted) {
3188
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
3189
+ await controller.stepFrame()
3190
+ await new Promise((resolve) => setTimeout(resolve, 0))
3191
+ }
3156
3192
  return {
3193
+ inputReceipts: controller.inputReceipts,
3157
3194
  anchors: anchorRecords,
3158
3195
  driveStart: {
3159
3196
  frameIndex: driveStart.frameIndex,
@@ -3180,6 +3217,7 @@ export const device = {
3180
3217
  {
3181
3218
  detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
3182
3219
  frame: options.frame ?? null,
3220
+ anchorAtRelease: options.anchorAtRelease ?? false,
3183
3221
  hold: options.hold ?? null,
3184
3222
  motionChangeDefinition: MOTION_CHANGE_DEFINITION,
3185
3223
  platform: device.getPlatform(),
@@ -3192,14 +3230,7 @@ export const device = {
3192
3230
 
3193
3231
  await page.waitForFunction(
3194
3232
  (captureToken) => {
3195
- const registry = (
3196
- window as Window & {
3197
- __sootsimVisibleChangeCaptures?: Record<
3198
- string,
3199
- { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
3200
- >
3201
- }
3202
- ).__sootsimVisibleChangeCaptures
3233
+ const registry = window.__sootsimVisibleChangeCaptures
3203
3234
  return registry?.[captureToken]?.ready === true
3204
3235
  },
3205
3236
  token,
@@ -3209,28 +3240,29 @@ export const device = {
3209
3240
  const triggerStartMs = Date.now()
3210
3241
  let triggerCompletedMs = 0
3211
3242
  try {
3212
- if (!options.hold) {
3213
- if (typeof options.trigger !== 'function') {
3214
- throw new Error('peach motion capture needs a trigger or a hold')
3215
- }
3216
- await Promise.resolve(options.trigger())
3217
- }
3218
- triggerCompletedMs = Date.now()
3219
- await page.evaluate((captureToken) => {
3220
- const registry = (
3221
- window as Window & {
3222
- __sootsimVisibleChangeCaptures?: Record<
3223
- string,
3224
- { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
3225
- >
3243
+ const triggerPromise = (async () => {
3244
+ if (!options.hold) {
3245
+ if (typeof options.trigger !== 'function') {
3246
+ throw new Error('peach motion capture needs a trigger or a hold')
3226
3247
  }
3227
- ).__sootsimVisibleChangeCaptures
3228
- const capture = registry?.[captureToken]
3229
- if (!capture) throw new Error('peach motion capture disarmed before trigger')
3230
- capture.triggerCompleted = true
3231
- }, token)
3232
- const capture = await capturePromise
3248
+ const triggered = Promise.resolve().then(options.trigger)
3249
+ await page.evaluate((captureToken) => {
3250
+ const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
3251
+ if (!capture) throw new Error('peach motion capture disarmed before trigger')
3252
+ capture.triggerStarted = true
3253
+ }, token)
3254
+ await triggered
3255
+ }
3256
+ triggerCompletedMs = Date.now()
3257
+ await page.evaluate((captureToken) => {
3258
+ const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
3259
+ if (!capture) throw new Error('peach motion capture disarmed before trigger')
3260
+ capture.triggerCompleted = true
3261
+ }, token)
3262
+ })()
3263
+ const [capture] = await Promise.all([capturePromise, triggerPromise])
3233
3264
  return {
3265
+ inputReceipts: capture.inputReceipts,
3234
3266
  anchors: capture.anchors,
3235
3267
  driveStart: capture.driveStart,
3236
3268
  frames: writeFastScreenshotFrames(namePrefix, capture.frames),
@@ -3245,14 +3277,7 @@ export const device = {
3245
3277
  } catch (error) {
3246
3278
  await page
3247
3279
  .evaluate((captureToken) => {
3248
- const registry = (
3249
- window as Window & {
3250
- __sootsimVisibleChangeCaptures?: Record<
3251
- string,
3252
- { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
3253
- >
3254
- }
3255
- ).__sootsimVisibleChangeCaptures
3280
+ const registry = window.__sootsimVisibleChangeCaptures
3256
3281
  const capture = registry?.[captureToken]
3257
3282
  if (capture) capture.cancelled = true
3258
3283
  }, token)
@@ -102,16 +102,34 @@ function measureProofMotionChange(input) {
102
102
  })
103
103
  }
104
104
 
105
- // a target sampled at every display frame (each gap at most one 60fps interval)
106
- // legitimately includes held input and settled frames whose pairs show no
107
- // change. such a sequence proves motion only when at least six pairs (100ms of
108
- // display frames) clear the minimum: a one-frame flash moves two pairs and a
109
- // static capture none, and both fail. a sparser sequence samples distinct
110
- // stages, so each pair must clear it.
111
- const DENSE_SAMPLE_MAX_GAP_MS = 1000 / 60 + 1
112
- const DENSE_SAMPLE_MINIMUM_MOVING_PAIRS = 6
105
+ // native recordings drift 0.1% to 0.2% between real changes where sootsim holds
106
+ // exactly still, so only oracle changes of at least this much need a sootsim
107
+ // match.
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.
111
+ const SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT = 0.6
113
112
 
114
- function evaluateMotionTransitions(transitions) {
113
+ // a present or dismiss records one second (60 transitions); a gesture ladder
114
+ // records four. a gesture jumps at its touch cadence and each capture phases
115
+ // 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.
118
+ const GESTURE_MOTION_MIN_TRANSITIONS = 120
119
+ const GESTURE_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.8, max: 1.25 })
120
+
121
+ // a sampled motion proves movement as a pair: the oracle moves in at least one
122
+ // transition. a one second motion also needs sootsim to move within one frame
123
+ // of every transition where the oracle changed by the paired minimum; a hold
124
+ // where the oracle holds needs no sootsim movement, while a static sootsim
125
+ // 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
+ function evaluateMotionTransitions(transitions, oracleTarget) {
129
+ const key = (transition) => `${transition.fromLabel}\u0000${transition.toLabel}`
130
+ const oracle = transitions.filter((transition) => transition.target === oracleTarget)
131
+ const sootsim = transitions.filter((transition) => transition.target === 'sootsim')
132
+ const sootsimByKey = new Map(sootsim.map((transition) => [key(transition), transition]))
115
133
  const measured = transitions.every(
116
134
  (transition) =>
117
135
  transition.dimensionsMatch &&
@@ -119,22 +137,40 @@ function evaluateMotionTransitions(transitions) {
119
137
  transition.changePixels !== null &&
120
138
  transition.totalPixels !== null,
121
139
  )
122
- const moving = transitions.filter((transition) => transition.passedMinimum).length
123
- const dense =
124
- transitions.length > DENSE_SAMPLE_MINIMUM_MOVING_PAIRS &&
125
- transitions.every(
126
- (transition) =>
127
- transition.toOffsetMs - transition.fromOffsetMs <= DENSE_SAMPLE_MAX_GAP_MS,
140
+ const moving = oracle.filter((transition) => transition.passedMinimum)
141
+ const travel = (list) =>
142
+ list
143
+ .filter(
144
+ (transition) => transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT,
145
+ )
146
+ .reduce((total, transition) => total + transition.changePercent, 0)
147
+ const sootsimMovesNear = (index) =>
148
+ [index - 1, index, index + 1].some(
149
+ (neighbour) => sootsim[neighbour]?.passedMinimum === true,
128
150
  )
151
+ const isSettleBlip = (transition, index) =>
152
+ transition.changePercent < SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT &&
153
+ !(oracle[index - 1]?.passedMinimum ?? true)
154
+ const within = (actual, expected, range) =>
155
+ actual >= expected * range.min && actual <= expected * range.max
156
+ const follows =
157
+ oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
158
+ ? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE)
159
+ : oracle.every(
160
+ (transition, index) =>
161
+ !transition.passedMinimum ||
162
+ transition.changePercent < PAIRED_MOTION_MINIMUM_CHANGE_PERCENT ||
163
+ isSettleBlip(transition, index) ||
164
+ (sootsimByKey.has(key(transition)) && sootsimMovesNear(index)),
165
+ )
129
166
  return {
130
- dense,
131
- moving,
167
+ moving: moving.length,
132
168
  passed:
133
- transitions.length > 0 &&
134
169
  measured &&
135
- (dense
136
- ? moving >= DENSE_SAMPLE_MINIMUM_MOVING_PAIRS
137
- : moving === transitions.length),
170
+ oracle.length > 0 &&
171
+ sootsim.length === oracle.length &&
172
+ moving.length > 0 &&
173
+ follows,
138
174
  }
139
175
  }
140
176
 
@@ -35,6 +35,9 @@ export function measureProofMotionChange(
35
35
  ): MotionChangeMeasurement
36
36
 
37
37
  export type MotionTransitionSample = {
38
+ fromLabel: string
39
+ toLabel: string
40
+ target: string
38
41
  changePercent: number | null
39
42
  changePixels: number | null
40
43
  dimensionsMatch: boolean
@@ -44,8 +47,10 @@ export type MotionTransitionSample = {
44
47
  totalPixels: number | null
45
48
  }
46
49
 
47
- export function evaluateMotionTransitions(transitions: MotionTransitionSample[]): {
48
- dense: boolean
50
+ export function evaluateMotionTransitions(
51
+ transitions: MotionTransitionSample[],
52
+ oracleTarget: string,
53
+ ): {
49
54
  moving: number
50
55
  passed: boolean
51
56
  }