peachsim 0.1.1620 → 0.1.1625

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 (138) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +184 -113
  4. package/cli/cloud-client.ts +68 -57
  5. package/cli/commands/box/search-command.ts +3 -2
  6. package/cli/commands/box.ts +10 -29
  7. package/cli/commands/control.ts +108 -43
  8. package/cli/commands/inspect/settling.ts +57 -20
  9. package/cli/commands/maestro-compare.ts +11 -32
  10. package/cli/commands/platform.ts +71 -36
  11. package/cli/commands/upload.ts +5 -1
  12. package/cli/dev-build-freshness.ts +37 -2
  13. package/cli/drivers/playwright-sim-host.ts +6 -5
  14. package/cli/maestro-accessibility.ts +53 -0
  15. package/cli/maestro-compare.ts +24 -0
  16. package/cli/outbound-endpoints.ts +22 -5
  17. package/cli/parent-pid.ts +0 -9
  18. package/cli/remote-simulators.ts +112 -0
  19. package/cli/remoteSimulatorContract.ts +21 -0
  20. package/detox/expectations.ts +5 -1
  21. package/detox/index.ts +133 -98
  22. package/detox/motion-change.cjs +37 -0
  23. package/detox/motion-change.d.cts +16 -0
  24. package/detox/navigation-lifecycle.ts +22 -4
  25. package/dist-lib/agent-daemon-client.cjs +3 -2
  26. package/dist-lib/agent-events.cjs +1 -1
  27. package/dist-lib/attached-projects.cjs +1 -1
  28. package/dist-lib/auth/shared-session.cjs +3 -2
  29. package/dist-lib/backend-origin.cjs +1 -1
  30. package/dist-lib/beta.cjs +1 -1
  31. package/dist-lib/beta.mjs +1 -1
  32. package/dist-lib/bridge-constants.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.mjs +1 -1
  35. package/dist-lib/bridge-contract.cjs +1 -1
  36. package/dist-lib/bridge-contract.mjs +1 -1
  37. package/dist-lib/bundle-channel.cjs +1520 -934
  38. package/dist-lib/capture-contract.cjs +1 -1
  39. package/dist-lib/capture-contract.mjs +1 -1
  40. package/dist-lib/cloud-contract.cjs +1 -1
  41. package/dist-lib/cloud-contract.mjs +1 -1
  42. package/dist-lib/cloud-test-args.cjs +1 -1
  43. package/dist-lib/cloud-test-args.mjs +1 -1
  44. package/dist-lib/cloud.cjs +3 -2
  45. package/dist-lib/cloud.mjs +3 -2
  46. package/dist-lib/config.cjs +5 -4
  47. package/dist-lib/config.mjs +5 -4
  48. package/dist-lib/detox/index.cjs +1021 -364
  49. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  50. package/dist-lib/expo-swift-bundle.cjs +1343 -801
  51. package/dist-lib/expo-swift-metadata.cjs +5 -4
  52. package/dist-lib/expo-swift-metadata.mjs +5 -4
  53. package/dist-lib/home-paths.cjs +1 -1
  54. package/dist-lib/host/bridge-host.cjs +2468 -1341
  55. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  56. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  58. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  59. package/dist-lib/host/websocket-proxy.cjs +1 -1
  60. package/dist-lib/index.cjs +1262 -300
  61. package/dist-lib/jump-to-source-babel.cjs +1 -1
  62. package/dist-lib/jump-to-source-native.cjs +1 -1
  63. package/dist-lib/kotlin-host.cjs +1 -1
  64. package/dist-lib/menu.cjs +3 -2
  65. package/dist-lib/menu.mjs +3 -2
  66. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  68. package/dist-lib/metro-production-bundle.cjs +5 -4
  69. package/dist-lib/metro-production-bundle.mjs +5 -4
  70. package/dist-lib/metro.cjs +799 -398
  71. package/dist-lib/native-app-identity-project.cjs +728 -22
  72. package/dist-lib/native-app-identity-project.mjs +727 -22
  73. package/dist-lib/native-app-identity.cjs +224 -9
  74. package/dist-lib/native-app-identity.mjs +222 -8
  75. package/dist-lib/native-firebase-metadata.cjs +5 -4
  76. package/dist-lib/native-firebase-metadata.mjs +5 -4
  77. package/dist-lib/plist.cjs +1 -1
  78. package/dist-lib/plist.mjs +1 -1
  79. package/dist-lib/profiles.cjs +1 -1
  80. package/dist-lib/public-brand.cjs +3 -2
  81. package/dist-lib/react-native-host-modules.cjs +4 -1
  82. package/dist-lib/react-native-host-modules.mjs +4 -1
  83. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  84. package/dist-lib/sdk.cjs +89 -24
  85. package/dist-lib/sdk.mjs +89 -24
  86. package/dist-lib/skills.cjs +4391 -2350
  87. package/dist-lib/swift-compile.cjs +328 -142
  88. package/dist-lib/swift-compile.mjs +342 -147
  89. package/dist-lib/swift-package.cjs +1 -1
  90. package/dist-lib/swift-package.mjs +1 -1
  91. package/dist-lib/swift-submit.cjs +1 -1
  92. package/dist-lib/swift-submit.mjs +1 -1
  93. package/dist-lib/swift-toolchain.cjs +1 -1
  94. package/dist-lib/swift.cjs +1 -1
  95. package/dist-lib/vite-swift.cjs +888 -559
  96. package/dist-lib/vite.cjs +68 -14
  97. package/dist-lib/worklet-executable.cjs +25 -2
  98. package/dist-lib/worklet-executable.mjs +25 -2
  99. package/package.json +2 -2
  100. package/scripts/dev-server-scanner.ts +33 -13
  101. package/src/admitted-mac-build.ts +34 -8
  102. package/src/android-app-identity.ts +146 -0
  103. package/src/android-autolinking.ts +75 -0
  104. package/src/android-string-value.ts +36 -0
  105. package/src/android-strings.ts +5 -36
  106. package/src/androidAutolinkingContract.ts +40 -0
  107. package/src/bundleGlobalFooter.ts +26 -7
  108. package/src/codex-client.ts +3 -2
  109. package/src/dev-bundle-resolution.ts +58 -25
  110. package/src/expo-kotlin-host.ts +6 -36
  111. package/src/expo-swift-bundle.ts +17 -12
  112. package/src/expo-swift-host.ts +477 -173
  113. package/src/expoSwiftContract.ts +6 -0
  114. package/src/host/bridge-host.ts +7 -55
  115. package/src/host/staged-host-files.ts +29 -2
  116. package/src/installed-package-versions.ts +54 -14
  117. package/src/launchMetadataContract.ts +24 -0
  118. package/src/metro-plugin.ts +37 -42
  119. package/src/native-app-identity-project.ts +116 -3
  120. package/src/native-app-identity.ts +86 -11
  121. package/src/nitro-compile.ts +2 -5
  122. package/src/nitro-source.ts +147 -63
  123. package/src/process-group.ts +90 -60
  124. package/src/process-output.ts +0 -9
  125. package/src/public-brand.ts +1 -0
  126. package/src/react-native-host-modules.ts +3 -0
  127. package/src/sim-client.ts +6 -4
  128. package/src/swift-compile.ts +176 -28
  129. package/src/swift-distribution.lock.json +37 -11
  130. package/src/swift-distribution.ts +61 -9
  131. package/src/swift-host.ts +58 -27
  132. package/src/swift-objc-standin.ts +13 -0
  133. package/src/swift-standin-emit.ts +1 -1
  134. package/src/swift-standins.ts +17 -5
  135. package/src/vite-plugin.ts +8 -19
  136. package/src/worklet-executable.ts +30 -3
  137. package/src/worklets-babel.ts +35 -14
  138. package/src/xcode-project.ts +10 -0
package/README.md CHANGED
@@ -231,6 +231,11 @@ actions discover the sim's owning world. Each world probe has one two-second
231
231
  wall deadline covering both connection and response. An inferred default port
232
232
  does not override the named sim's world.
233
233
 
234
+ Bridge sockets use TCP keepalive so a governor-stopped browser can resume an
235
+ in-flight render without losing its connection to an unanswered application
236
+ ping. Command deadlines still bound stalled work; idle leases and abandoned
237
+ sim cleanup remain separate from transport liveness.
238
+
234
239
  **The sim** is a browser/Electron *page* launched by a CLI driver. Inside that
235
240
  page run three workers: the **shell worker** owns device and native state, the
236
241
  **compositor worker** paints every visible CanvasKit surface, and the **tenant
@@ -279,7 +284,7 @@ therefore runs before assertions in each flow.
279
284
  | runtime delivery + Peach home | Versioned engine runtime management. The shared machinery owns manifest fetch, sha256 verification, channels, and auto-update. Peach binds it to `https://contrast.dev` and `~/.peach/runtimes/<version>`. A repo can select `runtimeVersion` without changing the machine default. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts` |
280
285
  | standalone CLI delivery | Cached stable-version checks, bounded interactive notices, and verified atomic executable replacement. | `cli/cli-update.ts`, `cli/commands/upgrade.ts`, `cli/commands/version.ts`, `src/cli-version.ts` |
281
286
  | `peachsim/vite` + `peachsim/metro` (runtime serving) | Published bundler plugins. Both serve the installed engine runtime at `/__peachsim/` from `~/.peach/runtimes/<version>` so an app's own dev server can host the sim shell; the Vite plugin can opt into a named Electron window. | `src/vite-plugin-one.ts`, `src/metro-plugin.ts`, `src/runtime-assets.ts` |
282
- | `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, applies worklets/babel transforms, and instantiates `SootSimBridgeHost` in its dev server. Distinct from the published `peachsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/peach-compat/src/stub-manifest.ts` |
287
+ | `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, and applies worklets/babel transforms. Distinct from the published `peachsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/peach-compat/src/stub-manifest.ts` |
283
288
  | skills registry | Peach agent skills (`peach-setup`, `peach-debug`, `peach-test`, `peach-visual`, and the generated `contrast` index) installable with `peach skill`. | `skills/*.md`, `src/skills/registry.ts`, `src/skills/types.ts` |
284
289
 
285
290
  ## Getting started
@@ -510,8 +515,8 @@ a supported input; pass Peach the JavaScript bundle produced before Hermes
510
515
  compilation.
511
516
 
512
517
  If an app has unusual dependencies or fingerprint inference fails, you can
513
- optionally wrap your Metro configuration with `withPeach(config)`. It takes no
514
- options and enables two integrations:
518
+ optionally wrap your Metro configuration with `withPeach(config)`. It enables
519
+ two integrations:
515
520
 
516
521
  1. **Deterministic module identity:** appends Metro's exact module identity map
517
522
  as a trailing `globalThis.__sootsimModulePaths = { ... }` footer to every
@@ -521,6 +526,23 @@ options and enables two integrations:
521
526
  2. **In-browser dev shell:** serves the Peach simulator shell at `/__peachsim/` on
522
527
  the Metro dev server.
523
528
 
529
+ Native app identity also travels in the bundle. Android permissions come only
530
+ from a completed app manifest merger output. A sole output under
531
+ `android/<module>/build/intermediates/merged_manifests/` is selected automatically;
532
+ select one explicitly when multiple variants exist:
533
+
534
+ ```js
535
+ module.exports = withPeach(config, {
536
+ androidMergedManifest:
537
+ 'android/app/build/intermediates/merged_manifests/debug/processDebugManifest/AndroidManifest.xml',
538
+ })
539
+ ```
540
+
541
+ Without resolved merged output, Android permissions contain only the explicit
542
+ Expo `android.permissions` set minus `android.blockedPermissions`. Defaults,
543
+ plugins and linked library additions require completed merged output. See the
544
+ [native identity contract](../../specs/sootsim-engine/native-app-identity.md).
545
+
524
546
  The URL helpers request ordinary Metro graphs: `dev=false&minify=true` for
525
547
  production and `dev=true&minify=false` for development.
526
548
 
@@ -571,6 +593,12 @@ and video recording create a new browser context; cold launches still use the
571
593
  supplied shell URL. When synchronization is enabled, URL delivery waits for
572
594
  existing work before dispatch and for navigation and painting afterward.
573
595
 
596
+ Element taps finish viewport measurement before resolving the selected node's
597
+ current center, visible frame, and relative offset in the browser task that
598
+ sends the physical tap. Stillness samples do not supply later tap coordinates:
599
+ startup layout can change while other bridge reads finish. A removed node
600
+ rejects the action instead of reusing its old geometry.
601
+
574
602
  See `../../docs/migrating-from-detox.md` for details.
575
603
 
576
604
  ## Coming from Maestro
@@ -751,8 +779,10 @@ fails instead of loading empty native configuration. Bare Metro, which has no
751
779
  Expo manifest project root, uses
752
780
  the listening development process's cwd inside that same resolver. The invoking
753
781
  CLI's cwd is never an asset/config fallback. Asset wires stay on each opened
754
- project URL, so one daemon can serve several attached projects without sharing
755
- font or splash state between them.
782
+ project URL. Font catalogs use content-addressed bridge asset references, so
783
+ the navigation request stays bounded as the number of fonts grows. Recordings
784
+ resolve these references before capturing the catalog and its assets. One daemon
785
+ can serve several attached projects without sharing font or splash state.
756
786
 
757
787
  Bare iOS projects carry default Firebase options from their selected app
758
788
  target's Google services build resource when the app delegate initializes
package/cli/app-fonts.ts CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import {
6
6
  encodeAppFontsWire,
7
+ resolveAppFontsWire,
7
8
  expoFontPathsFromPlugins,
8
9
  nativeLinkedFontSpecsFromPaths,
9
10
  metroFontSpecsFromPaths,
@@ -448,7 +449,7 @@ export async function fetchAppFontFiles(
448
449
  ): Promise<FetchedAppFont[]> {
449
450
  const out: FetchedAppFont[] = []
450
451
  const seen = new Set<string>()
451
- for (const url of appFontAssetUrlsFromWire(wire)) {
452
+ for (const url of appFontAssetUrlsFromWire(await resolveAppFontsWire(wire, fetch))) {
452
453
  const urlhash = createHash('sha256').update(url).digest('hex')
453
454
  if (seen.has(urlhash)) continue
454
455
  seen.add(urlhash)
@@ -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 }) {
@@ -2549,7 +2535,7 @@ export class SootSimBridgeFlowRunner {
2549
2535
  if (!result?.ok) {
2550
2536
  throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
2551
2537
  }
2552
- await this.settleAfterInput()
2538
+ await this.waitForAppToSettle()
2553
2539
  }
2554
2540
 
2555
2541
  async swipeCoords(start: string, end: string, duration = 400) {
@@ -2660,6 +2646,76 @@ export class SootSimBridgeFlowRunner {
2660
2646
  })
2661
2647
  }
2662
2648
 
2649
+ // step peach's lockstep animation clock by one or more explicit millisecond
2650
+ // deltas and hold the clock parked at the final frame so a subsequent
2651
+ // takeScreenshot or assertion reads that exact presentation phase.
2652
+ async advanceAnimationClock(spec: NonNullable<MaestroStep['advanceAnimationClock']>) {
2653
+ const rawSteps =
2654
+ typeof spec === 'number' || Array.isArray(spec) ? spec : (spec.steps ?? spec.ms)
2655
+ const steps = (Array.isArray(rawSteps) ? rawSteps : [rawSteps]).map((value) =>
2656
+ Number(value),
2657
+ )
2658
+ if (steps.length === 0 || steps.some((ms) => !Number.isFinite(ms) || ms <= 0)) {
2659
+ throw new Error(
2660
+ `advanceAnimationClock requires positive millisecond step(s): ${JSON.stringify(spec)}`,
2661
+ )
2662
+ }
2663
+ const fps =
2664
+ typeof spec === 'object' &&
2665
+ !Array.isArray(spec) &&
2666
+ typeof spec.fps === 'number' &&
2667
+ Number.isFinite(spec.fps) &&
2668
+ spec.fps > 0
2669
+ ? spec.fps
2670
+ : 60
2671
+ const result = await this.evaluate<{
2672
+ ok: boolean
2673
+ error?: string
2674
+ virtualTimestampMs?: number
2675
+ frameIndex?: number
2676
+ }>(
2677
+ `(async () => {
2678
+ const lockstep = window.SootSim?.bridges?.lockstepCapture
2679
+ if (!lockstep || typeof lockstep.begin !== 'function') {
2680
+ return { ok: false, error: 'lockstep capture bridge unavailable' }
2681
+ }
2682
+ let controller = window.__peachFlowAnimationClockController
2683
+ if (!controller) {
2684
+ controller = await lockstep.begin(${JSON.stringify(fps)})
2685
+ window.__peachFlowAnimationClockController = controller
2686
+ }
2687
+ let receipt = controller.initialFrame
2688
+ for (const ms of ${JSON.stringify(steps)}) {
2689
+ receipt = await controller.stepFrame(ms)
2690
+ }
2691
+ return {
2692
+ ok: true,
2693
+ virtualTimestampMs: receipt?.virtualTimestampMs,
2694
+ frameIndex: receipt?.frameIndex,
2695
+ }
2696
+ })()`,
2697
+ 60_000,
2698
+ )
2699
+ if (!result?.ok) {
2700
+ throw new Error(`advanceAnimationClock failed: ${result?.error ?? 'unknown error'}`)
2701
+ }
2702
+ this.animationClockLocked = true
2703
+ }
2704
+
2705
+ private async releaseAnimationClock() {
2706
+ if (!this.animationClockLocked) return
2707
+ this.animationClockLocked = false
2708
+ await this.evaluate(`(() => {
2709
+ const controller = window.__peachFlowAnimationClockController
2710
+ window.__peachFlowAnimationClockController = null
2711
+ if (controller && typeof controller.end === 'function') {
2712
+ controller.end()
2713
+ return
2714
+ }
2715
+ window.SootSim?.bridges?.lockstepCapture?.end?.()
2716
+ })()`).catch(() => {})
2717
+ }
2718
+
2663
2719
  async back() {
2664
2720
  const dispatched = await this.bridge.send({
2665
2721
  type: 'call',
@@ -2936,14 +2992,8 @@ export class SootSimBridgeFlowRunner {
2936
2992
  }
2937
2993
  }
2938
2994
 
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.
2995
+ // ios checks pixels for three seconds, then compares native hierarchies.
2996
+ // the observer overlaps those reads to keep this command's existing budget.
2947
2997
  private async waitForAppToSettle() {
2948
2998
  try {
2949
2999
  const result = await waitForSootsimIdle({
@@ -2951,7 +3001,7 @@ export class SootSimBridgeFlowRunner {
2951
3001
  simId: this.opts.simId,
2952
3002
  maxMs: 3000,
2953
3003
  pollMs: 32,
2954
- strict: true,
3004
+ maestro: true,
2955
3005
  paintStill: true,
2956
3006
  })
2957
3007
  if (!result.settled) {
@@ -2968,11 +3018,6 @@ export class SootSimBridgeFlowRunner {
2968
3018
  }
2969
3019
  }
2970
3020
 
2971
- private async settleAfterInput() {
2972
- await this.waitForAppToSettle()
2973
- this.lastInteractionAt = Date.now()
2974
- }
2975
-
2976
3021
  private async waitForVisualSettle() {
2977
3022
  const result = await waitForSootsimIdle({
2978
3023
  bridge: this.bridge,
@@ -2980,19 +3025,10 @@ export class SootSimBridgeFlowRunner {
2980
3025
  maxMs: 10_000,
2981
3026
  pollMs: 32,
2982
3027
  stablePolls: 3,
2983
- strict: true,
3028
+ maestro: true,
2984
3029
  paintStill: true,
2985
3030
  })
2986
3031
  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
3032
  throw new Error(
2997
3033
  `visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
2998
3034
  )
@@ -3373,8 +3409,6 @@ export class SootSimBridgeFlowRunner {
3373
3409
  step.extendedWaitUntil ||
3374
3410
  step.waitFor ||
3375
3411
  step.scrollUntilVisible ||
3376
- // deep links own the same bounded guest launch and tree waits.
3377
- step.openLink ||
3378
3412
  // runScript http calls are synchronous with a 5-minute upstream
3379
3413
  // timeout — the 10s watchdog would kill legitimate setup scripts.
3380
3414
  step.runScript ||
@@ -3386,6 +3420,7 @@ export class SootSimBridgeFlowRunner {
3386
3420
  // waitForAnimationToEnd ends at its own timeout, which a flow may set
3387
3421
  // past 10s, and continues as maestro does on a screen still moving.
3388
3422
  step.waitForAnimationToEnd ||
3423
+ step.advanceAnimationClock ||
3389
3424
  // assertions wait out maestro's lookup window, longer than 10s, and
3390
3425
  // every query they make times out on its own, so they report their
3391
3426
  // own failure instead of the watchdog's guess.
@@ -3399,7 +3434,9 @@ export class SootSimBridgeFlowRunner {
3399
3434
  // launchApp self-governs with an internal waitForTree (120s on a
3400
3435
  // clearState reload): a cache-cleared reload of a heavy bundle re-fetches
3401
3436
  // every chunk from metro and routinely needs more than the step ceiling.
3402
- if (isContainer || step.launchApp) {
3437
+ // openLink relaunches the guest through the same bounded reload and tree
3438
+ // wait, so it governs itself the same way.
3439
+ if (isContainer || step.launchApp || step.openLink) {
3403
3440
  const result = await body
3404
3441
  if (typeof result === 'string') screenshotPath = result
3405
3442
  } else {
@@ -3423,6 +3460,11 @@ export class SootSimBridgeFlowRunner {
3423
3460
  if (timer) clearTimeout(timer)
3424
3461
  }
3425
3462
  }
3463
+ // Orchestra.executeCommand restarts the lookup window after every
3464
+ // command that returns mutating; containers leave it to their children.
3465
+ if (!isContainer && !NON_MUTATING_STEPS.some((key) => step[key] !== undefined)) {
3466
+ this.lastInteractionAt = Date.now()
3467
+ }
3426
3468
  const isAssertion = !!(
3427
3469
  step.assertVisible ||
3428
3470
  step.assertNotVisible ||
@@ -3480,6 +3522,25 @@ export class SootSimBridgeFlowRunner {
3480
3522
  }
3481
3523
 
3482
3524
  private async runStepInner(step: MaestroStep) {
3525
+ if (
3526
+ this.animationClockLocked &&
3527
+ !step.advanceAnimationClock &&
3528
+ !step.takeScreenshot &&
3529
+ !step.assertVisible &&
3530
+ !step.assertNotVisible &&
3531
+ step.assertTrue === undefined &&
3532
+ !step.assertTreeContains &&
3533
+ !step.dumpTree &&
3534
+ step.copyTextFrom === undefined &&
3535
+ typeof step.evalScript !== 'string' &&
3536
+ !step.runScript &&
3537
+ step.setClipboard === undefined &&
3538
+ !step.runFlow &&
3539
+ !step.repeat &&
3540
+ !step.retry
3541
+ ) {
3542
+ await this.releaseAnimationClock()
3543
+ }
3483
3544
  if (step.tapOn) await this.tapOn(step.tapOn)
3484
3545
  else if (step.doubleTapOn) {
3485
3546
  // maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
@@ -3534,6 +3595,8 @@ export class SootSimBridgeFlowRunner {
3534
3595
  ? waitSpec.timeout
3535
3596
  : 2000,
3536
3597
  )
3598
+ } else if (step.advanceAnimationClock !== undefined) {
3599
+ await this.advanceAnimationClock(step.advanceAnimationClock)
3537
3600
  } else if (step.back) {
3538
3601
  await this.back()
3539
3602
  } else if (step.hideKeyboard) {
@@ -3766,41 +3829,49 @@ export class SootSimBridgeFlowRunner {
3766
3829
  async runFlow(steps: (MaestroStep | string)[], options: { skipPlan?: boolean } = {}) {
3767
3830
  const { onStart, onComplete, body } = this.extractFlowBody(steps)
3768
3831
  this.lastInteractionAt = Date.now()
3832
+ this.flowDepth++
3769
3833
 
3770
- if (!options.skipPlan && this.liveStatus) {
3771
- await this.planFlow(steps)
3772
- }
3834
+ try {
3835
+ if (!options.skipPlan && this.liveStatus) {
3836
+ await this.planFlow(steps)
3837
+ }
3773
3838
 
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)
3839
+ if (onStart.length > 0) {
3840
+ console.log(`[flow] onFlowStart (${onStart.length} steps)`)
3841
+ for (let i = 0; i < onStart.length; i++) {
3842
+ await this.runStep(onStart[i], i)
3843
+ }
3778
3844
  }
3779
- }
3780
3845
 
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
- }
3846
+ let caughtError: Error | null = null
3847
+ try {
3848
+ await this.runFlowBody(body)
3849
+ } catch (err) {
3850
+ caughtError = err instanceof Error ? err : new Error(String(err))
3851
+ }
3787
3852
 
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
- )
3853
+ if (onComplete.length > 0) {
3854
+ console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
3855
+ for (let i = 0; i < onComplete.length; i++) {
3856
+ try {
3857
+ await this.runStep(onComplete[i], i)
3858
+ } catch (hookErr) {
3859
+ console.warn(
3860
+ `[flow] onFlowComplete step ${i + 1} failed: ${
3861
+ hookErr instanceof Error ? hookErr.message : hookErr
3862
+ }`,
3863
+ )
3864
+ }
3799
3865
  }
3800
3866
  }
3801
- }
3802
3867
 
3803
- if (caughtError) throw caughtError
3868
+ if (caughtError) throw caughtError
3869
+ } finally {
3870
+ this.flowDepth = Math.max(0, this.flowDepth - 1)
3871
+ if (this.flowDepth === 0) {
3872
+ await this.releaseAnimationClock()
3873
+ }
3874
+ }
3804
3875
  }
3805
3876
 
3806
3877
  private async runFlowBody(steps: MaestroStep[]) {