peachsim 0.1.1620 → 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 (185) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -6
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +39 -18
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +248 -156
  7. package/cli/cloud-client.ts +68 -57
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/box/search-command.ts +3 -2
  10. package/cli/commands/box.ts +10 -29
  11. package/cli/commands/control.ts +391 -92
  12. package/cli/commands/doctor.ts +17 -184
  13. package/cli/commands/flow.ts +79 -11
  14. package/cli/commands/inspect/core.ts +10 -5
  15. package/cli/commands/inspect/settling.ts +71 -22
  16. package/cli/commands/inspect/wait-ready.ts +12 -0
  17. package/cli/commands/inspect.ts +26 -3
  18. package/cli/commands/install-desktop.ts +1 -0
  19. package/cli/commands/maestro-compare.ts +105 -67
  20. package/cli/commands/maestro.ts +9 -1
  21. package/cli/commands/platform.ts +71 -36
  22. package/cli/commands/record.ts +21 -1
  23. package/cli/commands/screenshot.ts +23 -1
  24. package/cli/commands/serve.ts +7 -2
  25. package/cli/commands/setup.ts +19 -42
  26. package/cli/commands/swift.ts +5 -0
  27. package/cli/commands/upload.ts +51 -8
  28. package/cli/desktop-companion.ts +102 -17
  29. package/cli/dev-build-freshness.ts +37 -2
  30. package/cli/drivers/desktop.ts +14 -1
  31. package/cli/drivers/playwright-sim-host.ts +44 -8
  32. package/cli/drivers/playwright.ts +9 -8
  33. package/cli/drivers/types.ts +5 -0
  34. package/cli/maestro-accessibility.ts +53 -0
  35. package/cli/maestro-compare.ts +108 -12
  36. package/cli/main.ts +2 -1
  37. package/cli/outbound-endpoints.ts +35 -6
  38. package/cli/parent-pid.ts +0 -9
  39. package/cli/pause-aware-timeout.ts +102 -0
  40. package/cli/remote-simulators.ts +112 -0
  41. package/cli/remoteSimulatorContract.ts +21 -0
  42. package/cli/scene-video.ts +89 -0
  43. package/cli/ws-bridge.ts +23 -14
  44. package/detox/attempt-events.cjs +4 -0
  45. package/detox/expectations.ts +5 -1
  46. package/detox/index.ts +241 -181
  47. package/detox/motion-change.cjs +73 -0
  48. package/detox/motion-change.d.cts +21 -0
  49. package/detox/navigation-lifecycle.ts +22 -4
  50. package/dist-lib/agent-daemon-client.cjs +3 -2
  51. package/dist-lib/agent-events.cjs +1 -1
  52. package/dist-lib/attached-projects.cjs +1 -1
  53. package/dist-lib/auth/shared-session.cjs +3 -2
  54. package/dist-lib/backend-origin.cjs +1 -1
  55. package/dist-lib/beta.cjs +1 -1
  56. package/dist-lib/beta.mjs +1 -1
  57. package/dist-lib/bridge-constants.cjs +13 -1
  58. package/dist-lib/bridge-contract-input.cjs +1 -1
  59. package/dist-lib/bridge-contract-input.mjs +1 -1
  60. package/dist-lib/bridge-contract.cjs +14 -8
  61. package/dist-lib/bridge-contract.mjs +13 -7
  62. package/dist-lib/bundle-channel.cjs +10480 -7962
  63. package/dist-lib/capture-contract.cjs +1 -1
  64. package/dist-lib/capture-contract.mjs +1 -1
  65. package/dist-lib/cloud-contract.cjs +1 -1
  66. package/dist-lib/cloud-contract.mjs +1 -1
  67. package/dist-lib/cloud-test-args.cjs +1 -1
  68. package/dist-lib/cloud-test-args.mjs +1 -1
  69. package/dist-lib/cloud.cjs +3 -2
  70. package/dist-lib/cloud.mjs +3 -2
  71. package/dist-lib/config.cjs +10 -6
  72. package/dist-lib/config.mjs +10 -6
  73. package/dist-lib/detox/index.cjs +1854 -966
  74. package/dist-lib/dev-bundle-resolution.cjs +64 -24
  75. package/dist-lib/expo-swift-bundle.cjs +8715 -6271
  76. package/dist-lib/expo-swift-metadata.cjs +7 -4
  77. package/dist-lib/expo-swift-metadata.mjs +7 -4
  78. package/dist-lib/home-paths.cjs +32 -3
  79. package/dist-lib/host/bridge-host.cjs +10135 -7060
  80. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  81. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  82. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  83. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  84. package/dist-lib/host/websocket-proxy.cjs +1 -1
  85. package/dist-lib/index.cjs +1843 -408
  86. package/dist-lib/jump-to-source-babel.cjs +1 -1
  87. package/dist-lib/jump-to-source-native.cjs +1 -1
  88. package/dist-lib/kotlin-host.cjs +1 -1
  89. package/dist-lib/menu.cjs +3 -2
  90. package/dist-lib/menu.mjs +3 -2
  91. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  92. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  93. package/dist-lib/metro-production-bundle.cjs +5 -4
  94. package/dist-lib/metro-production-bundle.mjs +5 -4
  95. package/dist-lib/metro.cjs +1182 -418
  96. package/dist-lib/native-app-identity-project.cjs +782 -23
  97. package/dist-lib/native-app-identity-project.mjs +781 -23
  98. package/dist-lib/native-app-identity.cjs +276 -9
  99. package/dist-lib/native-app-identity.mjs +273 -8
  100. package/dist-lib/native-firebase-metadata.cjs +5 -4
  101. package/dist-lib/native-firebase-metadata.mjs +5 -4
  102. package/dist-lib/plist.cjs +1 -1
  103. package/dist-lib/plist.mjs +1 -1
  104. package/dist-lib/profiles.cjs +1 -1
  105. package/dist-lib/public-brand.cjs +3 -2
  106. package/dist-lib/react-native-host-modules.cjs +8 -1
  107. package/dist-lib/react-native-host-modules.mjs +8 -1
  108. package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
  109. package/dist-lib/sdk.cjs +115 -34
  110. package/dist-lib/sdk.mjs +115 -33
  111. package/dist-lib/skills.cjs +15274 -9904
  112. package/dist-lib/swift-compile.cjs +1165 -173
  113. package/dist-lib/swift-compile.mjs +1212 -203
  114. package/dist-lib/swift-package.cjs +12 -3
  115. package/dist-lib/swift-package.mjs +12 -3
  116. package/dist-lib/swift-submit.cjs +7 -1
  117. package/dist-lib/swift-submit.mjs +5 -1
  118. package/dist-lib/swift-toolchain.cjs +133 -18
  119. package/dist-lib/swift.cjs +1 -1
  120. package/dist-lib/vite-swift.cjs +2648 -862
  121. package/dist-lib/vite.cjs +70 -14
  122. package/dist-lib/worklet-executable.cjs +25 -2
  123. package/dist-lib/worklet-executable.mjs +25 -2
  124. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  125. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  126. package/package.json +2 -2
  127. package/scripts/dev-server-scanner.ts +35 -14
  128. package/skills/peach-perf/SKILL.md +11 -5
  129. package/skills/peach-setup/SKILL.md +16 -19
  130. package/src/admitted-mac-build.ts +34 -8
  131. package/src/android-app-identity.ts +146 -0
  132. package/src/android-autolinking.ts +75 -0
  133. package/src/android-string-value.ts +36 -0
  134. package/src/android-strings.ts +5 -36
  135. package/src/androidAutolinkingContract.ts +40 -0
  136. package/src/bridge-constants.ts +11 -0
  137. package/src/bridge-contract.ts +22 -34
  138. package/src/bundleGlobalFooter.ts +26 -7
  139. package/src/codex-client.ts +3 -2
  140. package/src/config.ts +15 -0
  141. package/src/desktop-host-delivery.ts +5 -1
  142. package/src/dev-bundle-resolution.ts +60 -26
  143. package/src/expo-kotlin-host.ts +6 -36
  144. package/src/expo-manifest-body.ts +13 -0
  145. package/src/expo-swift-bundle.ts +17 -12
  146. package/src/expo-swift-host.ts +640 -192
  147. package/src/expo-swift-ready.ts +78 -0
  148. package/src/expoSwiftContract.ts +19 -0
  149. package/src/home-paths.ts +48 -2
  150. package/src/host/bridge-host.ts +12 -57
  151. package/src/host/staged-host-files.ts +29 -2
  152. package/src/installed-package-versions.ts +54 -14
  153. package/src/launchMetadataContract.ts +24 -0
  154. package/src/metro-plugin.ts +37 -42
  155. package/src/native-app-identity-project.ts +142 -5
  156. package/src/native-app-identity.ts +176 -11
  157. package/src/native-firebase-config.ts +209 -13
  158. package/src/nitro-compile.ts +2 -5
  159. package/src/nitro-source.ts +147 -63
  160. package/src/process-group.ts +114 -61
  161. package/src/process-output.ts +0 -9
  162. package/src/public-brand.ts +1 -0
  163. package/src/react-native-bridge-modules.ts +416 -0
  164. package/src/react-native-host-modules.ts +7 -0
  165. package/src/sim-client.ts +6 -4
  166. package/src/swift-binary-modules.ts +2 -2
  167. package/src/swift-compile.ts +1007 -108
  168. package/src/swift-compiler-digest.ts +14 -0
  169. package/src/swift-distribution.lock.json +40 -12
  170. package/src/swift-distribution.ts +61 -9
  171. package/src/swift-host.ts +149 -56
  172. package/src/swift-icu.ts +14 -6
  173. package/src/swift-objc-sdk.ts +133 -10
  174. package/src/swift-objc-standin.ts +38 -3
  175. package/src/swift-package-manifest.ts +21 -6
  176. package/src/swift-package.ts +15 -1
  177. package/src/swift-packages.ts +119 -60
  178. package/src/swift-standin-emit.ts +57 -9
  179. package/src/swift-standins.ts +185 -25
  180. package/src/swift-submit.ts +10 -0
  181. package/src/swift-toolchain.ts +149 -23
  182. package/src/vite-plugin.ts +20 -20
  183. package/src/worklet-executable.ts +30 -3
  184. package/src/worklets-babel.ts +35 -14
  185. package/src/xcode-project.ts +10 -0
@@ -22,6 +22,10 @@ import { PERMISSION_KINDS } from './commands/permissions'
22
22
  import { saveCurrentSimId } from './current-sim'
23
23
  import { parseFlowFile } from './flow-file'
24
24
  import { FlowLiveStatusReporter } from './flow-live-status'
25
+ import {
26
+ accessibilityBounds,
27
+ sameNativeAccessibilityShape,
28
+ } from './maestro-accessibility'
25
29
  import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
26
30
  import { ensureCliRecordingEntitlement } from './recording-access'
27
31
  import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
@@ -43,6 +47,23 @@ const DEFAULT_TIMEOUT = 10000
43
47
  // how long maestro looks for a required assertion's element, counted from the
44
48
  // latest interaction (Orchestra lookupTimeoutMs and adjustedToLatestInteraction)
45
49
  const ASSERTION_LOOKUP_MS = 17_000
50
+ // commands Orchestra.executeCommand reports as not mutating the device, so
51
+ // they leave the lookup window running; every other command restarts it. the
52
+ // last four are Peach observations with no device effect.
53
+ const NON_MUTATING_STEPS = [
54
+ 'assertTrue',
55
+ 'assertVisible',
56
+ 'assertNotVisible',
57
+ 'extendedWaitUntil',
58
+ 'takeScreenshot',
59
+ 'setClipboard',
60
+ 'copyTextFrom',
61
+ 'clearKeychain',
62
+ 'waitFor',
63
+ 'wait',
64
+ 'dumpTree',
65
+ 'assertTreeContains',
66
+ ] as const satisfies readonly (keyof MaestroStep)[]
46
67
  const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
47
68
  const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
48
69
 
@@ -235,23 +256,6 @@ function accessibilityTextMatcher(
235
256
  )
236
257
  }
237
258
 
238
- // maestro reads ios frames as integer edges (AXElement.boundsString).
239
- function accessibilityBounds(node: PeachAccessibilityNode): {
240
- x: number
241
- y: number
242
- width: number
243
- height: number
244
- } {
245
- const left = Math.trunc(node.frame.x)
246
- const top = Math.trunc(node.frame.y)
247
- return {
248
- x: left,
249
- y: top,
250
- width: Math.trunc(node.frame.x + node.frame.width) - left,
251
- height: Math.trunc(node.frame.y + node.frame.height) - top,
252
- }
253
- }
254
-
255
259
  const ACCESSIBILITY_TRAIT_FILTERS: Record<string, AccessibilityNodePredicate> = {
256
260
  // IOSDriver sets attributes["text"] on every node, including empty text.
257
261
  TEXT: () => true,
@@ -355,7 +359,12 @@ function distinctAccessibilityNodes(
355
359
  node.children.length,
356
360
  ])
357
361
  const bucket = buckets.get(key)
358
- if (bucket?.some((other) => sameNativeAccessibilityShape(node, other))) return false
362
+ if (
363
+ bucket?.some((other) =>
364
+ sameNativeAccessibilityShape(node, other, accessibilityBounds),
365
+ )
366
+ )
367
+ return false
359
368
  if (bucket) bucket.push(node)
360
369
  else buckets.set(key, [node])
361
370
  return true
@@ -371,39 +380,6 @@ function accessibilityAnchorSelector(value: unknown): FlowTargetSelector {
371
380
  return isSelectorRecord(value) ? value : {}
372
381
  }
373
382
 
374
- // upstream Filters.containsChild compares the resolved child TreeNode against
375
- // a candidate's immediate children by structural equality: the native
376
- // attribute map, the boolean flags, and the children, recursively. the native
377
- // hintText attribute is the placeholder (accessibilityHint is not a TreeNode
378
- // attribute), and engine identity (elementId/nodeId) plus the projection's own
379
- // metadata (type, role, isAccessibilityElement, state) are not part of that
380
- // native shape.
381
- function sameNativeAccessibilityShape(
382
- a: PeachAccessibilityNode,
383
- b: PeachAccessibilityNode,
384
- ): boolean {
385
- if (
386
- (a.testID ?? '') !== (b.testID ?? '') ||
387
- (a.label ?? '') !== (b.label ?? '') ||
388
- (a.placeholder ?? '') !== (b.placeholder ?? '') ||
389
- (a.value ?? '') !== (b.value ?? '') ||
390
- a.enabled !== b.enabled ||
391
- a.selected !== b.selected ||
392
- a.checked !== b.checked ||
393
- a.clickable !== b.clickable ||
394
- (a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
395
- JSON.stringify(accessibilityBounds(a)) !== JSON.stringify(accessibilityBounds(b)) ||
396
- a.children.length !== b.children.length
397
- ) {
398
- return false
399
- }
400
- for (const [index, child] of a.children.entries()) {
401
- const other = b.children[index]
402
- if (!other || !sameNativeAccessibilityShape(child, other)) return false
403
- }
404
- return true
405
- }
406
-
407
383
  // Filters.containsDescendants: every selector must match inside a child
408
384
  // subtree; the node itself is excluded. A selector matches a subtree node by
409
385
  // the full filter applied to that single node (Filters' `matches`), which for
@@ -490,7 +466,7 @@ function filterAccessibilityNodesIn(
490
466
  candidates = child
491
467
  ? candidates.filter((node) =>
492
468
  node.children.some((candidate) =>
493
- sameNativeAccessibilityShape(candidate, child),
469
+ sameNativeAccessibilityShape(candidate, child, accessibilityBounds),
494
470
  ),
495
471
  )
496
472
  : []
@@ -679,6 +655,14 @@ export interface MaestroStep {
679
655
  optional?: boolean
680
656
  }
681
657
  waitForAnimationToEnd?: boolean | number | { timeout?: number }
658
+ advanceAnimationClock?:
659
+ | number
660
+ | number[]
661
+ | {
662
+ steps?: number | number[]
663
+ ms?: number | number[]
664
+ fps?: number
665
+ }
682
666
  back?: boolean
683
667
  hideKeyboard?: boolean
684
668
  launchApp?: any
@@ -1009,6 +993,8 @@ export class SootSimBridgeFlowRunner {
1009
993
 
1010
994
  private firstLaunchDone = false
1011
995
  private appStopped = false
996
+ private animationClockLocked = false
997
+ private flowDepth = 0
1012
998
  private profilingEnabled = false
1013
999
  private recordingEnabled = false
1014
1000
  private recordingAccessChecked = false
@@ -1901,7 +1887,7 @@ export class SootSimBridgeFlowRunner {
1901
1887
  await this.tap(point.x, point.y, point.target)
1902
1888
  await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
1903
1889
  }
1904
- await this.settleAfterInput()
1890
+ await this.waitForAppToSettle()
1905
1891
  return
1906
1892
  }
1907
1893
  // auto-wait like real maestro: if the target is not mounted yet, poll
@@ -1955,7 +1941,7 @@ export class SootSimBridgeFlowRunner {
1955
1941
  throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
1956
1942
  if (hit?.hit === true) {
1957
1943
  await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
1958
- await this.settleAfterInput()
1944
+ await this.waitForAppToSettle()
1959
1945
  if (!current.isTextInput) return
1960
1946
  // confirm focus landed on THIS text input; retry the tap if a
1961
1947
  // previous field still owns focus.
@@ -1977,7 +1963,7 @@ export class SootSimBridgeFlowRunner {
1977
1963
  )
1978
1964
  : null
1979
1965
  if (direct?.ok) {
1980
- await this.settleAfterInput()
1966
+ await this.waitForAppToSettle()
1981
1967
  return
1982
1968
  }
1983
1969
  throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
@@ -1991,12 +1977,12 @@ export class SootSimBridgeFlowRunner {
1991
1977
  if (result?.ok === false || result?.value === false) {
1992
1978
  throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
1993
1979
  }
1994
- await this.settleAfterInput()
1980
+ await this.waitForAppToSettle()
1995
1981
  }
1996
1982
 
1997
1983
  async tapAtCoords(x: number, y: number) {
1998
1984
  await this.tap(x, y)
1999
- await this.settleAfterInput()
1985
+ await this.waitForAppToSettle()
2000
1986
  }
2001
1987
 
2002
1988
  async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
@@ -2008,7 +1994,7 @@ export class SootSimBridgeFlowRunner {
2008
1994
  `doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
2009
1995
  )
2010
1996
  }
2011
- await this.settleAfterInput()
1997
+ await this.waitForAppToSettle()
2012
1998
  }
2013
1999
 
2014
2000
  async assertVisible(target: string | { id?: string; text?: string }) {
@@ -2189,48 +2175,58 @@ export class SootSimBridgeFlowRunner {
2189
2175
  await sleep(120)
2190
2176
  }
2191
2177
 
2178
+ // maestro's android driver sends Back. its ios driver never presses a key:
2179
+ // while the keyboard shows it swipes from the screen center up 3%, then
2180
+ // left 3%, and the app's own handling of those touches dismisses it, or
2181
+ // presses whatever sits under the center
2192
2182
  async hideKeyboard() {
2193
- // CLI flows need "dismiss keyboard" to mean "get the focused TextInput
2194
- // out of the way before the next tap", not just "ask shell chrome to
2195
- // animate down eventually". If the TextInput stays focused, the next tap
2196
- // can still be swallowed by keyboard-dismiss policy on ScrollViews.
2197
- try {
2198
- await this.callTest<boolean>('blurFocusedTextInput')
2199
- } catch {
2200
- // fall through to the shell-level dismiss below; older bridges may not
2201
- // expose the text-input helper yet.
2183
+ if (this.js.maestro.platform.toLowerCase() === 'android') {
2184
+ await this.back()
2185
+ await sleep(300)
2186
+ await this.waitForAppToSettle()
2187
+ return
2202
2188
  }
2189
+ if (!(await this.isKeyboardVisible())) return
2190
+ const screen = await this.screenBounds()
2191
+ const center = parsePoint('50%,50%', screen)
2192
+ await this.xctestSwipe(center, parsePoint('50%,47%', screen), 50)
2193
+ if (!(await this.isKeyboardVisible())) return
2194
+ await this.xctestSwipe(center, parsePoint('47%,50%', screen), 50)
2195
+ await this.waitForAppToSettle()
2196
+ }
2203
2197
 
2204
- await this.bridge.send({
2205
- type: 'keyboard',
2206
- simId: this.opts.simId,
2207
- action: 'dismiss',
2208
- })
2198
+ // the xctest runner's swipe: press, move to the end over its 100ms tap
2199
+ // duration, rest there for the requested duration, then lift, so the
2200
+ // finger leaves at rest rather than flinging
2201
+ private async xctestSwipe(
2202
+ from: { x: number; y: number },
2203
+ to: { x: number; y: number },
2204
+ durationMs: number,
2205
+ ) {
2206
+ const moves = 6
2207
+ const steps: PerformStep[] = [{ type: 'touchDown', x: from.x, y: from.y }]
2208
+ for (let i = 1; i <= moves; i++) {
2209
+ steps.push({ type: 'wait', ms: 100 / moves })
2210
+ steps.push({
2211
+ type: 'touchMove',
2212
+ x: from.x + ((to.x - from.x) * i) / moves,
2213
+ y: from.y + ((to.y - from.y) * i) / moves,
2214
+ })
2215
+ }
2216
+ steps.push({ type: 'wait', ms: durationMs })
2217
+ steps.push({ type: 'touchUp', x: to.x, y: to.y })
2218
+ const result = await this.perform(steps)
2219
+ if (!result?.ok) throw new Error(`swipe failed: ${result?.error ?? 'swipe missed'}`)
2220
+ await this.waitForAppToSettle()
2221
+ }
2209
2222
 
2210
- const deadline = Date.now() + 3000
2211
- do {
2212
- // the host test bridge proxies the tenant, so isTextInputFocused
2213
- // resolves a promise; reading it unawaited is always truthy.
2214
- const state = await this.evaluate<{
2215
- visible: boolean
2216
- focused: boolean
2217
- }>(`(async () => {
2218
- const keyboard =
2219
- window.__sootsimKeyboard ??
2220
- window.SootSim?.bridges?.keyboard ??
2221
- null
2222
- const test = window.__sootsimTest
2223
- return {
2224
- visible: !!keyboard?.isVisible?.(),
2225
- focused: !!(await test?.isTextInputFocused?.()),
2226
- }
2227
- })()`)
2228
- if (!state?.visible && !state?.focused) {
2229
- await sleep(80)
2230
- return
2231
- }
2232
- await sleep(80)
2233
- } while (Date.now() < deadline)
2223
+ private async isKeyboardVisible(): Promise<boolean> {
2224
+ const visible = await this.evaluate<boolean>(`(() => {
2225
+ const keyboard =
2226
+ window.__sootsimKeyboard ?? window.SootSim?.bridges?.keyboard ?? null
2227
+ return !!keyboard?.isVisible?.()
2228
+ })()`)
2229
+ return visible === true
2234
2230
  }
2235
2231
 
2236
2232
  async eraseText(count: number) {
@@ -2549,7 +2545,7 @@ export class SootSimBridgeFlowRunner {
2549
2545
  if (!result?.ok) {
2550
2546
  throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
2551
2547
  }
2552
- await this.settleAfterInput()
2548
+ await this.waitForAppToSettle()
2553
2549
  }
2554
2550
 
2555
2551
  async swipeCoords(start: string, end: string, duration = 400) {
@@ -2660,6 +2656,76 @@ export class SootSimBridgeFlowRunner {
2660
2656
  })
2661
2657
  }
2662
2658
 
2659
+ // step peach's lockstep animation clock by one or more explicit millisecond
2660
+ // deltas and hold the clock parked at the final frame so a subsequent
2661
+ // takeScreenshot or assertion reads that exact presentation phase.
2662
+ async advanceAnimationClock(spec: NonNullable<MaestroStep['advanceAnimationClock']>) {
2663
+ const rawSteps =
2664
+ typeof spec === 'number' || Array.isArray(spec) ? spec : (spec.steps ?? spec.ms)
2665
+ const steps = (Array.isArray(rawSteps) ? rawSteps : [rawSteps]).map((value) =>
2666
+ Number(value),
2667
+ )
2668
+ if (steps.length === 0 || steps.some((ms) => !Number.isFinite(ms) || ms <= 0)) {
2669
+ throw new Error(
2670
+ `advanceAnimationClock requires positive millisecond step(s): ${JSON.stringify(spec)}`,
2671
+ )
2672
+ }
2673
+ const fps =
2674
+ typeof spec === 'object' &&
2675
+ !Array.isArray(spec) &&
2676
+ typeof spec.fps === 'number' &&
2677
+ Number.isFinite(spec.fps) &&
2678
+ spec.fps > 0
2679
+ ? spec.fps
2680
+ : 60
2681
+ const result = await this.evaluate<{
2682
+ ok: boolean
2683
+ error?: string
2684
+ virtualTimestampMs?: number
2685
+ frameIndex?: number
2686
+ }>(
2687
+ `(async () => {
2688
+ const lockstep = window.SootSim?.bridges?.lockstepCapture
2689
+ if (!lockstep || typeof lockstep.begin !== 'function') {
2690
+ return { ok: false, error: 'lockstep capture bridge unavailable' }
2691
+ }
2692
+ let controller = window.__peachFlowAnimationClockController
2693
+ if (!controller) {
2694
+ controller = await lockstep.begin(${JSON.stringify(fps)})
2695
+ window.__peachFlowAnimationClockController = controller
2696
+ }
2697
+ let receipt = controller.initialFrame
2698
+ for (const ms of ${JSON.stringify(steps)}) {
2699
+ receipt = await controller.stepFrame(ms)
2700
+ }
2701
+ return {
2702
+ ok: true,
2703
+ virtualTimestampMs: receipt?.virtualTimestampMs,
2704
+ frameIndex: receipt?.frameIndex,
2705
+ }
2706
+ })()`,
2707
+ 60_000,
2708
+ )
2709
+ if (!result?.ok) {
2710
+ throw new Error(`advanceAnimationClock failed: ${result?.error ?? 'unknown error'}`)
2711
+ }
2712
+ this.animationClockLocked = true
2713
+ }
2714
+
2715
+ private async releaseAnimationClock() {
2716
+ if (!this.animationClockLocked) return
2717
+ this.animationClockLocked = false
2718
+ await this.evaluate(`(() => {
2719
+ const controller = window.__peachFlowAnimationClockController
2720
+ window.__peachFlowAnimationClockController = null
2721
+ if (controller && typeof controller.end === 'function') {
2722
+ controller.end()
2723
+ return
2724
+ }
2725
+ window.SootSim?.bridges?.lockstepCapture?.end?.()
2726
+ })()`).catch(() => {})
2727
+ }
2728
+
2663
2729
  async back() {
2664
2730
  const dispatched = await this.bridge.send({
2665
2731
  type: 'call',
@@ -2709,8 +2775,15 @@ export class SootSimBridgeFlowRunner {
2709
2775
  : { x: centerX, y: screen.height * 0.1 }
2710
2776
  const element = await this.findElement(target)
2711
2777
  if (element) {
2712
- const { x, y } = element.absolutePosition
2713
- const { width, height } = element.layout
2778
+ // maestro computes visibility from integer edges, as it does selection.
2779
+ const { x, y, width, height } = accessibilityBounds({
2780
+ frame: {
2781
+ x: element.absolutePosition.x,
2782
+ y: element.absolutePosition.y,
2783
+ width: element.layout.width,
2784
+ height: element.layout.height,
2785
+ },
2786
+ })
2714
2787
  const shownX = Math.max(0, Math.min(x + width, screen.width) - Math.max(x, 0))
2715
2788
  const shownY = Math.max(0, Math.min(y + height, screen.height) - Math.max(y, 0))
2716
2789
  const share = width * height > 0 ? (shownX * shownY) / (width * height) : 0
@@ -2836,6 +2909,12 @@ export class SootSimBridgeFlowRunner {
2836
2909
  const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
2837
2910
  const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
2838
2911
  const wantsForeground = isObjectRecord(opts) && opts.stopApp === false
2912
+ // maestro's launchApp clears the keychain, then app state, then sets
2913
+ // permissions and launches
2914
+ if (readBooleanKey(opts, 'clearKeychain')) {
2915
+ if (!this.firstLaunchDone && !this.appStopped) await this.waitForTree(120000)
2916
+ await this.clearKeychain()
2917
+ }
2839
2918
  await this.setLaunchPermissions(isObjectRecord(opts) ? opts.permissions : undefined)
2840
2919
 
2841
2920
  if (!this.firstLaunchDone) {
@@ -2926,9 +3005,7 @@ export class SootSimBridgeFlowRunner {
2926
3005
 
2927
3006
  private async setLaunchPermissions(permissions: unknown) {
2928
3007
  const statuses = maestroLaunchPermissions(permissions)
2929
- const state = await getShellState(this.bridge)
2930
- const appId = readStringKey(state, 'activeApp') || readRecentShellAppId(state)
2931
- if (!appId) throw new Error('launchApp: no simulator app to set permissions for')
3008
+ const appId = await this.callTest<string>('permissionGuestAppId')
2932
3009
  for (const [kind, status] of statuses) {
2933
3010
  await this.callTest('permissionSetAppPermission', appId, kind, {
2934
3011
  status,
@@ -2936,14 +3013,8 @@ export class SootSimBridgeFlowRunner {
2936
3013
  }
2937
3014
  }
2938
3015
 
2939
- // maestro waits around taps for the screen to stop changing, up to
2940
- // its ios driver's 3 s settle budget, comparing screenshots, so an action
2941
- // still in flight (a submit spinner, a sheet closing) finishes before the
2942
- // next command resolves its target. compare consecutive screen pixels,
2943
- // rather than requiring frame publications to stop for half a second:
2944
- // a focused input keeps repainting its native blinking caret.
2945
- // a screen that never stops proceeds when the budget runs out, as there,
2946
- // and names what kept it moving: every such step pays the whole budget.
3016
+ // ios checks pixels for three seconds, then compares native hierarchies.
3017
+ // the observer overlaps those reads to keep this command's existing budget.
2947
3018
  private async waitForAppToSettle() {
2948
3019
  try {
2949
3020
  const result = await waitForSootsimIdle({
@@ -2951,7 +3022,7 @@ export class SootSimBridgeFlowRunner {
2951
3022
  simId: this.opts.simId,
2952
3023
  maxMs: 3000,
2953
3024
  pollMs: 32,
2954
- strict: true,
3025
+ maestro: true,
2955
3026
  paintStill: true,
2956
3027
  })
2957
3028
  if (!result.settled) {
@@ -2968,11 +3039,6 @@ export class SootSimBridgeFlowRunner {
2968
3039
  }
2969
3040
  }
2970
3041
 
2971
- private async settleAfterInput() {
2972
- await this.waitForAppToSettle()
2973
- this.lastInteractionAt = Date.now()
2974
- }
2975
-
2976
3042
  private async waitForVisualSettle() {
2977
3043
  const result = await waitForSootsimIdle({
2978
3044
  bridge: this.bridge,
@@ -2980,19 +3046,10 @@ export class SootSimBridgeFlowRunner {
2980
3046
  maxMs: 10_000,
2981
3047
  pollMs: 32,
2982
3048
  stablePolls: 3,
2983
- strict: true,
3049
+ maestro: true,
2984
3050
  paintStill: true,
2985
3051
  })
2986
3052
  if (!result.settled) {
2987
- // an app loop that never ends (a pulsing status dot) leaves only the
2988
- // pixels moving. maestro on ios captures that frame and continues, so
2989
- // do the same; any other blocker is real unfinished work and still fails.
2990
- if (result.blockedBy.startsWith('screen pixels still changing over ')) {
2991
- console.warn(
2992
- ` [flow] screen never became still in ${result.elapsed}ms; capturing as maestro does`,
2993
- )
2994
- return
2995
- }
2996
3053
  throw new Error(
2997
3054
  `visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
2998
3055
  )
@@ -3373,8 +3430,6 @@ export class SootSimBridgeFlowRunner {
3373
3430
  step.extendedWaitUntil ||
3374
3431
  step.waitFor ||
3375
3432
  step.scrollUntilVisible ||
3376
- // deep links own the same bounded guest launch and tree waits.
3377
- step.openLink ||
3378
3433
  // runScript http calls are synchronous with a 5-minute upstream
3379
3434
  // timeout — the 10s watchdog would kill legitimate setup scripts.
3380
3435
  step.runScript ||
@@ -3386,6 +3441,7 @@ export class SootSimBridgeFlowRunner {
3386
3441
  // waitForAnimationToEnd ends at its own timeout, which a flow may set
3387
3442
  // past 10s, and continues as maestro does on a screen still moving.
3388
3443
  step.waitForAnimationToEnd ||
3444
+ step.advanceAnimationClock ||
3389
3445
  // assertions wait out maestro's lookup window, longer than 10s, and
3390
3446
  // every query they make times out on its own, so they report their
3391
3447
  // own failure instead of the watchdog's guess.
@@ -3399,7 +3455,9 @@ export class SootSimBridgeFlowRunner {
3399
3455
  // launchApp self-governs with an internal waitForTree (120s on a
3400
3456
  // clearState reload): a cache-cleared reload of a heavy bundle re-fetches
3401
3457
  // every chunk from metro and routinely needs more than the step ceiling.
3402
- if (isContainer || step.launchApp) {
3458
+ // openLink relaunches the guest through the same bounded reload and tree
3459
+ // wait, so it governs itself the same way.
3460
+ if (isContainer || step.launchApp || step.openLink) {
3403
3461
  const result = await body
3404
3462
  if (typeof result === 'string') screenshotPath = result
3405
3463
  } else {
@@ -3423,6 +3481,11 @@ export class SootSimBridgeFlowRunner {
3423
3481
  if (timer) clearTimeout(timer)
3424
3482
  }
3425
3483
  }
3484
+ // Orchestra.executeCommand restarts the lookup window after every
3485
+ // command that returns mutating; containers leave it to their children.
3486
+ if (!isContainer && !NON_MUTATING_STEPS.some((key) => step[key] !== undefined)) {
3487
+ this.lastInteractionAt = Date.now()
3488
+ }
3426
3489
  const isAssertion = !!(
3427
3490
  step.assertVisible ||
3428
3491
  step.assertNotVisible ||
@@ -3480,6 +3543,25 @@ export class SootSimBridgeFlowRunner {
3480
3543
  }
3481
3544
 
3482
3545
  private async runStepInner(step: MaestroStep) {
3546
+ if (
3547
+ this.animationClockLocked &&
3548
+ !step.advanceAnimationClock &&
3549
+ !step.takeScreenshot &&
3550
+ !step.assertVisible &&
3551
+ !step.assertNotVisible &&
3552
+ step.assertTrue === undefined &&
3553
+ !step.assertTreeContains &&
3554
+ !step.dumpTree &&
3555
+ step.copyTextFrom === undefined &&
3556
+ typeof step.evalScript !== 'string' &&
3557
+ !step.runScript &&
3558
+ step.setClipboard === undefined &&
3559
+ !step.runFlow &&
3560
+ !step.repeat &&
3561
+ !step.retry
3562
+ ) {
3563
+ await this.releaseAnimationClock()
3564
+ }
3483
3565
  if (step.tapOn) await this.tapOn(step.tapOn)
3484
3566
  else if (step.doubleTapOn) {
3485
3567
  // maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
@@ -3534,6 +3616,8 @@ export class SootSimBridgeFlowRunner {
3534
3616
  ? waitSpec.timeout
3535
3617
  : 2000,
3536
3618
  )
3619
+ } else if (step.advanceAnimationClock !== undefined) {
3620
+ await this.advanceAnimationClock(step.advanceAnimationClock)
3537
3621
  } else if (step.back) {
3538
3622
  await this.back()
3539
3623
  } else if (step.hideKeyboard) {
@@ -3766,41 +3850,49 @@ export class SootSimBridgeFlowRunner {
3766
3850
  async runFlow(steps: (MaestroStep | string)[], options: { skipPlan?: boolean } = {}) {
3767
3851
  const { onStart, onComplete, body } = this.extractFlowBody(steps)
3768
3852
  this.lastInteractionAt = Date.now()
3853
+ this.flowDepth++
3769
3854
 
3770
- if (!options.skipPlan && this.liveStatus) {
3771
- await this.planFlow(steps)
3772
- }
3855
+ try {
3856
+ if (!options.skipPlan && this.liveStatus) {
3857
+ await this.planFlow(steps)
3858
+ }
3773
3859
 
3774
- if (onStart.length > 0) {
3775
- console.log(`[flow] onFlowStart (${onStart.length} steps)`)
3776
- for (let i = 0; i < onStart.length; i++) {
3777
- await this.runStep(onStart[i], i)
3860
+ if (onStart.length > 0) {
3861
+ console.log(`[flow] onFlowStart (${onStart.length} steps)`)
3862
+ for (let i = 0; i < onStart.length; i++) {
3863
+ await this.runStep(onStart[i], i)
3864
+ }
3778
3865
  }
3779
- }
3780
3866
 
3781
- let caughtError: Error | null = null
3782
- try {
3783
- await this.runFlowBody(body)
3784
- } catch (err) {
3785
- caughtError = err instanceof Error ? err : new Error(String(err))
3786
- }
3867
+ let caughtError: Error | null = null
3868
+ try {
3869
+ await this.runFlowBody(body)
3870
+ } catch (err) {
3871
+ caughtError = err instanceof Error ? err : new Error(String(err))
3872
+ }
3787
3873
 
3788
- if (onComplete.length > 0) {
3789
- console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
3790
- for (let i = 0; i < onComplete.length; i++) {
3791
- try {
3792
- await this.runStep(onComplete[i], i)
3793
- } catch (hookErr) {
3794
- console.warn(
3795
- `[flow] onFlowComplete step ${i + 1} failed: ${
3796
- hookErr instanceof Error ? hookErr.message : hookErr
3797
- }`,
3798
- )
3874
+ if (onComplete.length > 0) {
3875
+ console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
3876
+ for (let i = 0; i < onComplete.length; i++) {
3877
+ try {
3878
+ await this.runStep(onComplete[i], i)
3879
+ } catch (hookErr) {
3880
+ console.warn(
3881
+ `[flow] onFlowComplete step ${i + 1} failed: ${
3882
+ hookErr instanceof Error ? hookErr.message : hookErr
3883
+ }`,
3884
+ )
3885
+ }
3799
3886
  }
3800
3887
  }
3801
- }
3802
3888
 
3803
- if (caughtError) throw caughtError
3889
+ if (caughtError) throw caughtError
3890
+ } finally {
3891
+ this.flowDepth = Math.max(0, this.flowDepth - 1)
3892
+ if (this.flowDepth === 0) {
3893
+ await this.releaseAnimationClock()
3894
+ }
3895
+ }
3804
3896
  }
3805
3897
 
3806
3898
  private async runFlowBody(steps: MaestroStep[]) {