peachsim 0.1.1625 → 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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
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
  }
@@ -119,6 +127,7 @@ let _browserLaunch: Promise<void> | null = null
119
127
  let _browserLaunchAbort: AbortController | null = null
120
128
  let _reapBrowserNow: (() => void) | null = null
121
129
  let _context: BrowserContext | null = null
130
+ let _appStorageState: Awaited<ReturnType<BrowserContext['storageState']>> | undefined
122
131
  let _page: Page | null = null
123
132
  let _synchronizationEnabled = false
124
133
  let _proofFrameObservationActive = false
@@ -378,7 +387,8 @@ export async function waitForSootsimShellReady(
378
387
  async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
379
388
  const result = await page.evaluate(
380
389
  async ({ timeout }) => {
381
- const getPeerStats = window.__sootsimTest?.getPeerStats
390
+ const testBridge = window.__sootsimTest
391
+ const getPeerStats = testBridge?.getPeerStats
382
392
  if (typeof getPeerStats !== 'function') {
383
393
  return { settled: true, skipped: true }
384
394
  }
@@ -390,14 +400,30 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
390
400
  let lastPending: number | null | undefined = null
391
401
  let completedReads = 0
392
402
  let lastReply: string | null = null
403
+ let readStartedAt: number | null = null
404
+ let tenantAtRead = window.__sootsimRenderHost
405
+ let shellAtRead = window.SootSim?.bridges?.shellHost
406
+ let peerProgress: import('@peach/globals').PeachPeerStatsProgress | null = null
407
+ let deadlineTimerLateMs: number | null = null
393
408
 
394
409
  while (Date.now() < deadline) {
410
+ readStartedAt = Date.now()
411
+ tenantAtRead = window.__sootsimRenderHost
412
+ shellAtRead = window.SootSim?.bridges?.shellHost
395
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
396
419
  const stats = await Promise.race([
397
- getPeerStats(false),
420
+ peerRead,
398
421
  new Promise<undefined>((resolve) => {
399
422
  timer = setTimeout(
400
- () => resolve(undefined),
423
+ () => {
424
+ deadlineTimerLateMs = Date.now() - deadline
425
+ resolve(undefined)
426
+ },
401
427
  Math.max(0, deadline - Date.now()),
402
428
  )
403
429
  }),
@@ -405,6 +431,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
405
431
  if (timer !== undefined) clearTimeout(timer)
406
432
  })
407
433
  if (stats === undefined) break
434
+ readStartedAt = null
408
435
  if (stats === null) return { settled: true, elapsed: Date.now() - start }
409
436
  completedReads++
410
437
  lastReply = JSON.stringify({ elapsedMs: Date.now() - start, stats })
@@ -426,77 +453,45 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
426
453
  }
427
454
 
428
455
  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
- })
456
+ // snapshot only the original read. a later request cannot explain which
457
+ // realm delayed this join, and an unchanged host does not prove readiness.
458
+ const tenantAtDeadline = window.__sootsimRenderHost
459
+ const shellAtDeadline = window.SootSim?.bridges?.shellHost
460
+ const observation = {
461
+ completedReads,
462
+ lastReply,
463
+ outstandingRead:
464
+ readStartedAt === null
465
+ ? null
466
+ : {
467
+ startedAtMs: readStartedAt - start,
468
+ elapsedMs: Date.now() - readStartedAt,
469
+ peerProgress: peerProgress ? { ...peerProgress } : null,
470
+ testBridgeChanged: window.__sootsimTest !== testBridge,
471
+ tenant: {
472
+ presentAtRequest: !!tenantAtRead,
473
+ presentAtDeadline: !!tenantAtDeadline,
474
+ changed: tenantAtRead !== tenantAtDeadline,
475
+ },
476
+ shell: {
477
+ presentAtRequest: !!shellAtRead,
478
+ presentAtDeadline: !!shellAtDeadline,
479
+ changed: shellAtRead !== shellAtDeadline,
480
+ },
481
+ },
482
+ deadlineTimerLateMs,
487
483
  }
488
484
  return {
489
485
  settled: false,
490
486
  elapsed: waited,
491
487
  pending: lastPending,
492
- realms,
493
488
  observation,
494
489
  }
495
490
  },
496
491
  { timeout },
497
492
  )
498
493
  if (!result?.settled) {
499
- const pending = result?.pending ?? `no peer stats reply; ${result?.realms}`
494
+ const pending = result?.pending ?? 'no peer stats reply'
500
495
  throw new Error(
501
496
  `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
502
497
  )
@@ -566,7 +561,8 @@ async function quiesceContextMedia(context: BrowserContext): Promise<void> {
566
561
  }
567
562
  }
568
563
 
569
- async function closeContext() {
564
+ async function closeContext(preserveStorage: boolean) {
565
+ if (!preserveStorage) _appStorageState = undefined
570
566
  if (_browserCompromised) {
571
567
  throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
572
568
  }
@@ -574,6 +570,15 @@ async function closeContext() {
574
570
  const context = _context
575
571
  const closingContextId = contextIdentity
576
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
+ }
577
582
  await quiesceContextMedia(context)
578
583
  await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
579
584
  _context = null
@@ -588,7 +593,8 @@ async function closeContext() {
588
593
  }
589
594
  }
590
595
 
591
- async function closeBrowser() {
596
+ async function closeBrowser(preserveStorage: boolean) {
597
+ if (!preserveStorage) _appStorageState = undefined
592
598
  if (!_browser) return
593
599
  const browser = _browser
594
600
  const reapBrowserNow = _reapBrowserNow
@@ -598,7 +604,7 @@ async function closeBrowser() {
598
604
 
599
605
  let closeError: Error | null = null
600
606
  try {
601
- await closeContext()
607
+ await closeContext(preserveStorage)
602
608
  } catch (error) {
603
609
  closeError = error instanceof Error ? error : new Error(String(error))
604
610
  }
@@ -851,6 +857,31 @@ async function dispatchTapAtSootsimPoint(page: Page, point: TapPoint, token?: st
851
857
  }
852
858
  }
853
859
  }
860
+ // detox creates the touch in the matched view's window and sends it
861
+ // through uiapplication, so system status-bar routing cannot take it.
862
+ // that window's hit test picks the receiver, so a covering view such as
863
+ // a box-only parent or a pointerEvents none sibling takes the press.
864
+ if (token) window.__sootsimAnchorTimedInput?.(token)
865
+ let result = await window.__sootsimTest!.activatePressAt(x, y, point.nodeId)
866
+ const refusal = result?.tap
867
+ const covering =
868
+ refusal &&
869
+ typeof refusal === 'object' &&
870
+ 'reason' in refusal &&
871
+ refusal.reason === 'target-covered' &&
872
+ 'coordinateTarget' in refusal
873
+ ? refusal.coordinateTarget
874
+ : null
875
+ if (covering && typeof covering === 'object' && 'nodeId' in covering) {
876
+ const { nodeId } = covering
877
+ if (typeof nodeId === 'number') {
878
+ result = await window.__sootsimTest!.activatePressAt(x, y, nodeId)
879
+ }
880
+ }
881
+ if (result?.ok !== true) {
882
+ throw new Error(`element tap was refused: ${JSON.stringify(result)}`)
883
+ }
884
+ return
854
885
  } else {
855
886
  ;({ x, y } = point)
856
887
  }
@@ -885,7 +916,7 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
885
916
  // would edit around a caret the tap then moves, so wait for its focus.
886
917
  await pollUntil(
887
918
  () => page.evaluate(async () => window.__sootsimTest!.getFocusedNode()),
888
- (focused) => focused !== null && focusIsWithin(focused, node.nodeId),
919
+ (focused) => focused !== null && focusIsWithin(focused, node),
889
920
  {
890
921
  timeoutMs: 5000,
891
922
  label: `SootSim text input ${node.nodeId} to focus`,
@@ -1429,11 +1460,12 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1429
1460
  normalizedStartingPointX = 0.5,
1430
1461
  normalizedStartingPointY = 0.5,
1431
1462
  ) {
1463
+ const page = getPage()
1464
+ if (_synchronizationEnabled) await waitForSootsimIdle(page, 3000, false, 0)
1432
1465
  const node = await findStableNodeByMatcher(matcher, 'swipe')
1433
1466
  if (!node)
1434
1467
  throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
1435
1468
 
1436
- const page = getPage()
1437
1469
  const { absolutePosition, layout } = node
1438
1470
 
1439
1471
  const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
@@ -1673,18 +1705,20 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1673
1705
  visible: node.layout.width > 0 && node.layout.height > 0,
1674
1706
  enabled: !node.accessibilityState?.disabled,
1675
1707
  ...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
1708
+ // native detox reads a gauge's accessibility path, which excludes
1709
+ // the empty space below an accessory arc. keep other frame contracts.
1678
1710
  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,
1711
+ node.nativeType === 'ExpoUI_GaugeView' && node.accessibilityFrame
1712
+ ? node.accessibilityFrame
1713
+ : node.absolutePosition?.width !== undefined &&
1714
+ node.absolutePosition.height !== undefined
1715
+ ? {
1716
+ x: node.absolutePosition.x,
1717
+ y: node.absolutePosition.y,
1718
+ width: node.absolutePosition.width,
1719
+ height: node.absolutePosition.height,
1720
+ }
1721
+ : node.frame,
1688
1722
  })
1689
1723
  // detox reads an unindexed pattern that selects several elements as a list
1690
1724
  if (matcher.regexFlags !== undefined && matcher.index === undefined) {
@@ -1958,10 +1992,11 @@ export const device = {
1958
1992
  }
1959
1993
 
1960
1994
  if (!_page || newInstance || opts?.delete || opts?.recordVideoDir) {
1961
- await closeContext()
1995
+ await closeContext(!opts?.delete)
1962
1996
  const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
1963
1997
  hasTouch: true,
1964
1998
  viewport: { width: 500, height: 900 },
1999
+ storageState: _appStorageState,
1965
2000
  }
1966
2001
  if (opts?.recordVideoDir) {
1967
2002
  fs.mkdirSync(opts.recordVideoDir, { recursive: true })
@@ -2019,6 +2054,12 @@ export const device = {
2019
2054
  reapOwnedBrowser()
2020
2055
  throw error
2021
2056
  }
2057
+ await automateWebAuthenticationSessions(
2058
+ _context!,
2059
+ '__peachWebAuthenticationHost',
2060
+ _page,
2061
+ false,
2062
+ )
2022
2063
  contextIdentity = newContextIdentity
2023
2064
  }
2024
2065
 
@@ -2070,7 +2111,7 @@ export const device = {
2070
2111
  },
2071
2112
 
2072
2113
  async terminateApp() {
2073
- await closeBrowser()
2114
+ await closeBrowser(true)
2074
2115
  },
2075
2116
 
2076
2117
  async installApp() {
@@ -2597,12 +2638,11 @@ export const device = {
2597
2638
  }>,
2598
2639
  options: {
2599
2640
  detectionTimeoutMs?: number
2641
+ workletFrameTimestampsSeconds?: number[]
2600
2642
  frame?: { height: number; width: number; x: number; y: number } | null
2601
2643
  // a pointer the capture itself presses at the trigger point and releases
2602
2644
  // 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
2645
+ // control thumb, a pressed row) are painted mid-hold. moves drag the
2606
2646
  // held pointer to each point once afterMs of lockstep time has elapsed
2607
2647
  // since the press, and the release lands at the last point reached.
2608
2648
  hold?: {
@@ -2611,11 +2651,16 @@ export const device = {
2611
2651
  x: number
2612
2652
  y: number
2613
2653
  } | null
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'
2614
2658
  rootFrame?: { height: number; width: number; x: number; y: number } | null
2615
2659
  settleMs?: number
2616
2660
  trigger?: () => unknown
2617
2661
  },
2618
2662
  ): Promise<{
2663
+ inputReceipts: LockstepInputReceipt[]
2619
2664
  anchors: Array<{
2620
2665
  changedPixelPercent: number
2621
2666
  frameIndex: number
@@ -2659,6 +2704,7 @@ export const device = {
2659
2704
  installGlobal: true,
2660
2705
  })
2661
2706
  const capturePromise: Promise<{
2707
+ inputReceipts: LockstepInputReceipt[]
2662
2708
  anchors: Array<{
2663
2709
  changedPixelPercent: number
2664
2710
  frameIndex: number
@@ -2686,12 +2732,6 @@ export const device = {
2686
2732
  }
2687
2733
  }> = page.evaluate(
2688
2734
  async (input) => {
2689
- type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
2690
- type CaptureState = {
2691
- cancelled: boolean
2692
- ready: boolean
2693
- triggerCompleted: boolean
2694
- }
2695
2735
  type CaptureWindow = Window & {
2696
2736
  __sootsimMeasureMotionChange?: (input: {
2697
2737
  baseline: Uint8ClampedArray
@@ -2701,7 +2741,6 @@ export const device = {
2701
2741
  label: string
2702
2742
  width: number
2703
2743
  }) => MotionChangeMeasurement
2704
- __sootsimVisibleChangeCaptures?: Record<string, CaptureState>
2705
2744
  SootSim?: {
2706
2745
  bridges?: {
2707
2746
  interact?: Required<
@@ -2713,19 +2752,6 @@ export const device = {
2713
2752
  frameToken: number
2714
2753
  }>
2715
2754
  }
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
2755
  }
2730
2756
  }
2731
2757
  }
@@ -2751,9 +2777,10 @@ export const device = {
2751
2777
  const registry =
2752
2778
  captureWindow.__sootsimVisibleChangeCaptures ??
2753
2779
  (captureWindow.__sootsimVisibleChangeCaptures = {})
2754
- const state: CaptureState = {
2780
+ const state: VisibleChangeCaptureState = {
2755
2781
  cancelled: false,
2756
2782
  ready: false,
2783
+ triggerStarted: false,
2757
2784
  triggerCompleted: false,
2758
2785
  }
2759
2786
  registry[input.token] = state
@@ -2838,7 +2865,18 @@ export const device = {
2838
2865
  if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
2839
2866
  throw new Error('peach interact bridge is not installed')
2840
2867
  }
2841
- 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
+ })
2842
2880
  // the hold's pointer goes down before the first stepped frame, so its
2843
2881
  // start is one frame before the first receipt; each move and the release
2844
2882
  // land on the first stepped frame at or past their time from there. the
@@ -2848,6 +2886,7 @@ export const device = {
2848
2886
  const holdState = {
2849
2887
  nextMove: 0,
2850
2888
  pressFrameIndex: null as number | null,
2889
+ releaseReceipt: null as SootSimLockstepFrameReceipt | null,
2851
2890
  released: false,
2852
2891
  x: input.hold?.x ?? 0,
2853
2892
  y: input.hold?.y ?? 0,
@@ -2861,7 +2900,7 @@ export const device = {
2861
2900
  const move = moves[holdState.nextMove]
2862
2901
  return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2863
2902
  }
2864
- const advanceHold = async (receipt: FrameReceipt) => {
2903
+ const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
2865
2904
  if (!input.hold || !interact || holdState.released) return
2866
2905
  if (holdState.pressFrameIndex === null) {
2867
2906
  holdState.pressFrameIndex = receipt.frameIndex - 1
@@ -2885,6 +2924,7 @@ export const device = {
2885
2924
  }
2886
2925
  if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2887
2926
  holdState.released = true
2927
+ holdState.releaseReceipt = receipt
2888
2928
  await interact.touchUp(
2889
2929
  holdState.x,
2890
2930
  holdState.y,
@@ -2894,11 +2934,30 @@ export const device = {
2894
2934
  }
2895
2935
  // an anchor is its visible-change frame; its bitmap is kept so a 0ms
2896
2936
  // stage can still capture the anchor frame itself.
2897
- 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
+ }
2898
2945
  try {
2946
+ if (input.anchorAtTouch && !input.hold) {
2947
+ throw new Error('peach touch anchoring needs a hold')
2948
+ }
2899
2949
  const baselineBitmap = await captureBitmap()
2900
2950
  const baseline = regionPixels(baselineBitmap)
2901
- 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')
2902
2961
  state.ready = true
2903
2962
  if (input.hold && interact) {
2904
2963
  const hit = await interact.touchDown(
@@ -2913,13 +2972,13 @@ export const device = {
2913
2972
  )
2914
2973
  }
2915
2974
  } else {
2916
- while (!state.triggerCompleted) {
2975
+ while (!state.triggerStarted) {
2917
2976
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
2918
2977
  await new Promise((resolve) => setTimeout(resolve, 0))
2919
2978
  }
2920
2979
  }
2921
2980
 
2922
- let visibleStart: FrameReceipt | null = null
2981
+ let visibleStart: SootSimLockstepFrameReceipt | null = null
2923
2982
  let detectedMeasurement: MotionChangeMeasurement | null = null
2924
2983
  let previousBitmap: ImageBitmap | null = null
2925
2984
  // android can publish mount layout at p=0 before a clock step.
@@ -2940,7 +2999,7 @@ export const device = {
2940
2999
  if (measurement.passedMinimum) {
2941
3000
  visibleStart = receipt
2942
3001
  detectedMeasurement = measurement
2943
- keptFrames.set(visibleStart.frameIndex, candidateBitmap)
3002
+ keepFrame(visibleStart, candidateBitmap)
2944
3003
  } else {
2945
3004
  candidateBitmap.close()
2946
3005
  }
@@ -2951,6 +3010,13 @@ export const device = {
2951
3010
  const receipt = await controller.stepFrame()
2952
3011
  await advanceHold(receipt)
2953
3012
  const candidateBitmap = await captureBitmap()
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))
3019
+ }
2954
3020
  const bounds = boundsFor(candidateBitmap)
2955
3021
  const measurement = measureChange({
2956
3022
  baseline,
@@ -2965,7 +3031,7 @@ export const device = {
2965
3031
  previousBitmap = null
2966
3032
  visibleStart = receipt
2967
3033
  detectedMeasurement = measurement
2968
- keptFrames.set(receipt.frameIndex, candidateBitmap)
3034
+ keepFrame(receipt, candidateBitmap)
2969
3035
  break
2970
3036
  }
2971
3037
  previousBitmap?.close()
@@ -2980,10 +3046,26 @@ export const device = {
2980
3046
  // stages on every platform measure from the first frame that
2981
3047
  // differs from baseline: native extraction anchors at its
2982
3048
  // detection frame's pts, so the drive starts here too. the
2983
- // preceding tick is still baseline on both legs.
2984
- const driveStart = visibleStart
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.
3052
+ let driveStart = visibleStart
2985
3053
  let currentReceipt = visibleStart
2986
- const resolvedAnchors = new Map<string, FrameReceipt>()
3054
+ if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
3055
+ if (input.anchorAtTouch === 'up') {
3056
+ while (!holdState.released) {
3057
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
3058
+ const receipt = await controller.stepFrame()
3059
+ await advanceHold(receipt)
3060
+ currentReceipt = receipt
3061
+ if (holdState.released) keepFrame(receipt, await captureBitmap())
3062
+ }
3063
+ if (!holdState.releaseReceipt) {
3064
+ throw new Error('peach release anchoring found no release frame')
3065
+ }
3066
+ driveStart = holdState.releaseReceipt
3067
+ }
3068
+ const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
2987
3069
  const anchorRecords: Array<{
2988
3070
  changedPixelPercent: number
2989
3071
  frameIndex: number
@@ -2997,7 +3079,7 @@ export const device = {
2997
3079
  const resolveAnchor = async (stage: {
2998
3080
  label: string
2999
3081
  anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
3000
- }): Promise<{ anchorId: string; receipt: FrameReceipt }> => {
3082
+ }): Promise<{ anchorId: string; receipt: SootSimLockstepFrameReceipt }> => {
3001
3083
  const anchor = stage.anchor
3002
3084
  if (!anchor) return { anchorId: 'transition', receipt: driveStart }
3003
3085
  const known = resolvedAnchors.get(anchor.id)
@@ -3078,8 +3160,7 @@ export const device = {
3078
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`,
3079
3161
  )
3080
3162
  }
3081
- keptFrames.get(anchorReceipt.frameIndex)?.close()
3082
- keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
3163
+ keepFrame(anchorReceipt, anchorBitmap)
3083
3164
  resolvedAnchors.set(anchor.id, anchorReceipt)
3084
3165
  anchorRecords.push({
3085
3166
  changedPixelPercent: onsetMeasurement.changePercent,
@@ -3103,13 +3184,14 @@ export const device = {
3103
3184
  const kept = keptFrames.get(stageFrameIndex)
3104
3185
  if (kept && stageFrameIndex <= currentReceipt.frameIndex) {
3105
3186
  snapshots.push({
3106
- actualMs: 0,
3187
+ actualMs:
3188
+ kept.receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
3107
3189
  anchorId,
3108
- bitmap: await createImageBitmap(kept),
3109
- frameIndex: anchorReceipt.frameIndex,
3190
+ bitmap: await createImageBitmap(kept.bitmap),
3191
+ frameIndex: kept.receipt.frameIndex,
3110
3192
  label: stage.label,
3111
3193
  offsetMs: stage.offsetMs,
3112
- virtualTimestampMs: anchorReceipt.virtualTimestampMs,
3194
+ virtualTimestampMs: kept.receipt.virtualTimestampMs,
3113
3195
  })
3114
3196
  continue
3115
3197
  }
@@ -3133,6 +3215,9 @@ export const device = {
3133
3215
  virtualTimestampMs: receipt.virtualTimestampMs,
3134
3216
  })
3135
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()
3136
3221
  // a hold that outlasts the last stage still moves and releases on its
3137
3222
  // own frames; the settle jump below would carry the pointer past them
3138
3223
  // and leave it down. the settle then runs from that release.
@@ -3153,7 +3238,27 @@ export const device = {
3153
3238
  )
3154
3239
  await controller.stepToOffset(driveStart, maximumPrimaryMs + input.settleMs)
3155
3240
  }
3241
+ while (!state.triggerCompleted) {
3242
+ if (state.cancelled) throw new Error('peach motion capture was cancelled')
3243
+ await controller.stepFrame()
3244
+ await new Promise((resolve) => setTimeout(resolve, 0))
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
+ }
3156
3260
  return {
3261
+ inputReceipts: controller.inputReceipts,
3157
3262
  anchors: anchorRecords,
3158
3263
  driveStart: {
3159
3264
  frameIndex: driveStart.frameIndex,
@@ -3172,14 +3277,16 @@ export const device = {
3172
3277
  },
3173
3278
  }
3174
3279
  } finally {
3175
- for (const bitmap of keptFrames.values()) bitmap.close()
3280
+ for (const kept of keptFrames.values()) kept.bitmap.close()
3176
3281
  controller.end()
3177
3282
  delete registry[input.token]
3178
3283
  }
3179
3284
  },
3180
3285
  {
3286
+ workletFrameTimestampsSeconds: options.workletFrameTimestampsSeconds,
3181
3287
  detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
3182
3288
  frame: options.frame ?? null,
3289
+ anchorAtTouch: options.anchorAtTouch ?? null,
3183
3290
  hold: options.hold ?? null,
3184
3291
  motionChangeDefinition: MOTION_CHANGE_DEFINITION,
3185
3292
  platform: device.getPlatform(),
@@ -3190,47 +3297,46 @@ export const device = {
3190
3297
  },
3191
3298
  )
3192
3299
 
3193
- await page.waitForFunction(
3194
- (captureToken) => {
3195
- const registry = (
3196
- window as Window & {
3197
- __sootsimVisibleChangeCaptures?: Record<
3198
- string,
3199
- { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
3200
- >
3201
- }
3202
- ).__sootsimVisibleChangeCaptures
3203
- return registry?.[captureToken]?.ready === true
3204
- },
3205
- token,
3206
- { timeout: 5000 },
3207
- )
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
+ ])
3208
3313
 
3209
3314
  const triggerStartMs = Date.now()
3210
3315
  let triggerCompletedMs = 0
3211
3316
  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
- >
3317
+ const triggerPromise = (async () => {
3318
+ if (!options.hold) {
3319
+ if (typeof options.trigger !== 'function') {
3320
+ throw new Error('peach motion capture needs a trigger or a hold')
3226
3321
  }
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
3322
+ const triggered = Promise.resolve().then(options.trigger)
3323
+ await page.evaluate((captureToken) => {
3324
+ const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
3325
+ if (!capture) throw new Error('peach motion capture disarmed before trigger')
3326
+ capture.triggerStarted = true
3327
+ }, token)
3328
+ await triggered
3329
+ }
3330
+ triggerCompletedMs = Date.now()
3331
+ await page.evaluate((captureToken) => {
3332
+ const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
3333
+ if (!capture) throw new Error('peach motion capture disarmed before trigger')
3334
+ capture.triggerCompleted = true
3335
+ }, token)
3336
+ })()
3337
+ const [capture] = await Promise.all([capturePromise, triggerPromise])
3233
3338
  return {
3339
+ inputReceipts: capture.inputReceipts,
3234
3340
  anchors: capture.anchors,
3235
3341
  driveStart: capture.driveStart,
3236
3342
  frames: writeFastScreenshotFrames(namePrefix, capture.frames),
@@ -3245,14 +3351,7 @@ export const device = {
3245
3351
  } catch (error) {
3246
3352
  await page
3247
3353
  .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
3354
+ const registry = window.__sootsimVisibleChangeCaptures
3256
3355
  const capture = registry?.[captureToken]
3257
3356
  if (capture) capture.cancelled = true
3258
3357
  }, token)
@@ -3822,5 +3921,5 @@ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
3822
3921
  export async function cleanup() {
3823
3922
  _browserLaunchAbort?.abort(new Error('Peach driver cleaned up'))
3824
3923
  await _browserLaunch?.catch(() => {})
3825
- await closeBrowser()
3924
+ await closeBrowser(false)
3826
3925
  }