peachsim 0.1.1619 → 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 (140) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +222 -175
  4. package/cli/cloud-client.ts +81 -48
  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 +74 -17
  11. package/cli/commands/record.ts +20 -15
  12. package/cli/commands/upload.ts +5 -1
  13. package/cli/dev-build-freshness.ts +37 -2
  14. package/cli/drivers/playwright-sim-host.ts +18 -6
  15. package/cli/maestro-accessibility.ts +53 -0
  16. package/cli/maestro-compare.ts +24 -0
  17. package/cli/outbound-endpoints.ts +27 -8
  18. package/cli/parent-pid.ts +0 -9
  19. package/cli/remote-simulators.ts +112 -0
  20. package/cli/remoteSimulatorContract.ts +21 -0
  21. package/detox/expectations.ts +5 -1
  22. package/detox/index.ts +133 -98
  23. package/detox/motion-change.cjs +37 -0
  24. package/detox/motion-change.d.cts +16 -0
  25. package/detox/navigation-lifecycle.ts +22 -4
  26. package/dist-lib/agent-daemon-client.cjs +3 -2
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +3 -2
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +1528 -937
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +4 -3
  46. package/dist-lib/cloud.mjs +4 -3
  47. package/dist-lib/config.cjs +5 -4
  48. package/dist-lib/config.mjs +5 -4
  49. package/dist-lib/detox/index.cjs +1021 -364
  50. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  51. package/dist-lib/expo-swift-bundle.cjs +1351 -804
  52. package/dist-lib/expo-swift-metadata.cjs +5 -4
  53. package/dist-lib/expo-swift-metadata.mjs +5 -4
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +2475 -1343
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +1269 -300
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +3 -2
  66. package/dist-lib/menu.mjs +3 -2
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +5 -4
  70. package/dist-lib/metro-production-bundle.mjs +5 -4
  71. package/dist-lib/metro.cjs +806 -398
  72. package/dist-lib/native-app-identity-project.cjs +729 -21
  73. package/dist-lib/native-app-identity-project.mjs +728 -21
  74. package/dist-lib/native-app-identity.cjs +232 -9
  75. package/dist-lib/native-app-identity.mjs +230 -8
  76. package/dist-lib/native-firebase-metadata.cjs +5 -4
  77. package/dist-lib/native-firebase-metadata.mjs +5 -4
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +3 -2
  82. package/dist-lib/react-native-host-modules.cjs +4 -1
  83. package/dist-lib/react-native-host-modules.mjs +4 -1
  84. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  85. package/dist-lib/sdk.cjs +89 -24
  86. package/dist-lib/sdk.mjs +89 -24
  87. package/dist-lib/skills.cjs +4471 -2394
  88. package/dist-lib/swift-compile.cjs +336 -145
  89. package/dist-lib/swift-compile.mjs +350 -150
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +1 -1
  93. package/dist-lib/swift-submit.mjs +1 -1
  94. package/dist-lib/swift-toolchain.cjs +1 -1
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +891 -557
  97. package/dist-lib/vite.cjs +68 -14
  98. package/dist-lib/worklet-executable.cjs +25 -2
  99. package/dist-lib/worklet-executable.mjs +25 -2
  100. package/package.json +2 -2
  101. package/scripts/dev-server-scanner.ts +33 -13
  102. package/src/admitted-mac-build.ts +34 -8
  103. package/src/android-app-identity.ts +146 -0
  104. package/src/android-autolinking.ts +75 -0
  105. package/src/android-string-value.ts +36 -0
  106. package/src/android-strings.ts +5 -36
  107. package/src/androidAutolinkingContract.ts +40 -0
  108. package/src/bundleGlobalFooter.ts +26 -7
  109. package/src/cloud.ts +2 -2
  110. package/src/codex-client.ts +3 -2
  111. package/src/dev-bundle-resolution.ts +58 -25
  112. package/src/expo-kotlin-host.ts +6 -36
  113. package/src/expo-swift-bundle.ts +17 -12
  114. package/src/expo-swift-host.ts +477 -173
  115. package/src/expoSwiftContract.ts +6 -0
  116. package/src/host/bridge-host.ts +7 -55
  117. package/src/host/staged-host-files.ts +29 -2
  118. package/src/installed-package-versions.ts +54 -14
  119. package/src/launchMetadataContract.ts +24 -0
  120. package/src/metro-plugin.ts +37 -42
  121. package/src/native-app-identity-project.ts +118 -3
  122. package/src/native-app-identity.ts +103 -11
  123. package/src/nitro-compile.ts +2 -5
  124. package/src/nitro-source.ts +147 -63
  125. package/src/process-group.ts +90 -60
  126. package/src/process-output.ts +0 -9
  127. package/src/public-brand.ts +1 -0
  128. package/src/react-native-host-modules.ts +3 -0
  129. package/src/sim-client.ts +6 -4
  130. package/src/swift-compile.ts +185 -31
  131. package/src/swift-distribution.lock.json +37 -11
  132. package/src/swift-distribution.ts +61 -9
  133. package/src/swift-host.ts +58 -27
  134. package/src/swift-objc-standin.ts +13 -0
  135. package/src/swift-standin-emit.ts +1 -1
  136. package/src/swift-standins.ts +17 -5
  137. package/src/vite-plugin.ts +8 -19
  138. package/src/worklet-executable.ts +30 -3
  139. package/src/worklets-babel.ts +35 -14
  140. 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,8 +47,42 @@ 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)
69
+
70
+ // the ceiling backing a bounded step must never fire before the step's own
71
+ // deadline. waitFor and extendedWaitUntil scale their declared timeout by
72
+ // SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too.
73
+ function stepCeilingMs(step: MaestroStep): number {
74
+ const waitForTimeout =
75
+ typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
76
+ const extendedWaitTimeout =
77
+ typeof step.extendedWaitUntil?.timeout === 'number'
78
+ ? step.extendedWaitUntil.timeout
79
+ : 0
80
+ return Math.max(
81
+ 60_000,
82
+ scaledTimeout(waitForTimeout) + 15_000,
83
+ scaledTimeout(extendedWaitTimeout) + 15_000,
84
+ )
85
+ }
48
86
  type FlowScreenshotPathMode = 'dir' | 'flow'
49
87
  type ReplayTapTarget = SootSimReplayTarget
50
88
 
@@ -218,23 +256,6 @@ function accessibilityTextMatcher(
218
256
  )
219
257
  }
220
258
 
221
- // maestro reads ios frames as integer edges (AXElement.boundsString).
222
- function accessibilityBounds(node: PeachAccessibilityNode): {
223
- x: number
224
- y: number
225
- width: number
226
- height: number
227
- } {
228
- const left = Math.trunc(node.frame.x)
229
- const top = Math.trunc(node.frame.y)
230
- return {
231
- x: left,
232
- y: top,
233
- width: Math.trunc(node.frame.x + node.frame.width) - left,
234
- height: Math.trunc(node.frame.y + node.frame.height) - top,
235
- }
236
- }
237
-
238
259
  const ACCESSIBILITY_TRAIT_FILTERS: Record<string, AccessibilityNodePredicate> = {
239
260
  // IOSDriver sets attributes["text"] on every node, including empty text.
240
261
  TEXT: () => true,
@@ -338,7 +359,12 @@ function distinctAccessibilityNodes(
338
359
  node.children.length,
339
360
  ])
340
361
  const bucket = buckets.get(key)
341
- 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
342
368
  if (bucket) bucket.push(node)
343
369
  else buckets.set(key, [node])
344
370
  return true
@@ -354,39 +380,6 @@ function accessibilityAnchorSelector(value: unknown): FlowTargetSelector {
354
380
  return isSelectorRecord(value) ? value : {}
355
381
  }
356
382
 
357
- // upstream Filters.containsChild compares the resolved child TreeNode against
358
- // a candidate's immediate children by structural equality: the native
359
- // attribute map, the boolean flags, and the children, recursively. the native
360
- // hintText attribute is the placeholder (accessibilityHint is not a TreeNode
361
- // attribute), and engine identity (elementId/nodeId) plus the projection's own
362
- // metadata (type, role, isAccessibilityElement, state) are not part of that
363
- // native shape.
364
- function sameNativeAccessibilityShape(
365
- a: PeachAccessibilityNode,
366
- b: PeachAccessibilityNode,
367
- ): boolean {
368
- if (
369
- (a.testID ?? '') !== (b.testID ?? '') ||
370
- (a.label ?? '') !== (b.label ?? '') ||
371
- (a.placeholder ?? '') !== (b.placeholder ?? '') ||
372
- (a.value ?? '') !== (b.value ?? '') ||
373
- a.enabled !== b.enabled ||
374
- a.selected !== b.selected ||
375
- a.checked !== b.checked ||
376
- a.clickable !== b.clickable ||
377
- (a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
378
- JSON.stringify(accessibilityBounds(a)) !== JSON.stringify(accessibilityBounds(b)) ||
379
- a.children.length !== b.children.length
380
- ) {
381
- return false
382
- }
383
- for (const [index, child] of a.children.entries()) {
384
- const other = b.children[index]
385
- if (!other || !sameNativeAccessibilityShape(child, other)) return false
386
- }
387
- return true
388
- }
389
-
390
383
  // Filters.containsDescendants: every selector must match inside a child
391
384
  // subtree; the node itself is excluded. A selector matches a subtree node by
392
385
  // the full filter applied to that single node (Filters' `matches`), which for
@@ -473,7 +466,7 @@ function filterAccessibilityNodesIn(
473
466
  candidates = child
474
467
  ? candidates.filter((node) =>
475
468
  node.children.some((candidate) =>
476
- sameNativeAccessibilityShape(candidate, child),
469
+ sameNativeAccessibilityShape(candidate, child, accessibilityBounds),
477
470
  ),
478
471
  )
479
472
  : []
@@ -662,6 +655,14 @@ export interface MaestroStep {
662
655
  optional?: boolean
663
656
  }
664
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
+ }
665
666
  back?: boolean
666
667
  hideKeyboard?: boolean
667
668
  launchApp?: any
@@ -992,6 +993,8 @@ export class SootSimBridgeFlowRunner {
992
993
 
993
994
  private firstLaunchDone = false
994
995
  private appStopped = false
996
+ private animationClockLocked = false
997
+ private flowDepth = 0
995
998
  private profilingEnabled = false
996
999
  private recordingEnabled = false
997
1000
  private recordingAccessChecked = false
@@ -1884,7 +1887,7 @@ export class SootSimBridgeFlowRunner {
1884
1887
  await this.tap(point.x, point.y, point.target)
1885
1888
  await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
1886
1889
  }
1887
- await this.settleAfterInput()
1890
+ await this.waitForAppToSettle()
1888
1891
  return
1889
1892
  }
1890
1893
  // auto-wait like real maestro: if the target is not mounted yet, poll
@@ -1938,7 +1941,7 @@ export class SootSimBridgeFlowRunner {
1938
1941
  throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
1939
1942
  if (hit?.hit === true) {
1940
1943
  await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
1941
- await this.settleAfterInput()
1944
+ await this.waitForAppToSettle()
1942
1945
  if (!current.isTextInput) return
1943
1946
  // confirm focus landed on THIS text input; retry the tap if a
1944
1947
  // previous field still owns focus.
@@ -1960,7 +1963,7 @@ export class SootSimBridgeFlowRunner {
1960
1963
  )
1961
1964
  : null
1962
1965
  if (direct?.ok) {
1963
- await this.settleAfterInput()
1966
+ await this.waitForAppToSettle()
1964
1967
  return
1965
1968
  }
1966
1969
  throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
@@ -1974,12 +1977,12 @@ export class SootSimBridgeFlowRunner {
1974
1977
  if (result?.ok === false || result?.value === false) {
1975
1978
  throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
1976
1979
  }
1977
- await this.settleAfterInput()
1980
+ await this.waitForAppToSettle()
1978
1981
  }
1979
1982
 
1980
1983
  async tapAtCoords(x: number, y: number) {
1981
1984
  await this.tap(x, y)
1982
- await this.settleAfterInput()
1985
+ await this.waitForAppToSettle()
1983
1986
  }
1984
1987
 
1985
1988
  async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
@@ -1991,7 +1994,7 @@ export class SootSimBridgeFlowRunner {
1991
1994
  `doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
1992
1995
  )
1993
1996
  }
1994
- await this.settleAfterInput()
1997
+ await this.waitForAppToSettle()
1995
1998
  }
1996
1999
 
1997
2000
  async assertVisible(target: string | { id?: string; text?: string }) {
@@ -2532,7 +2535,7 @@ export class SootSimBridgeFlowRunner {
2532
2535
  if (!result?.ok) {
2533
2536
  throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
2534
2537
  }
2535
- await this.settleAfterInput()
2538
+ await this.waitForAppToSettle()
2536
2539
  }
2537
2540
 
2538
2541
  async swipeCoords(start: string, end: string, duration = 400) {
@@ -2643,6 +2646,76 @@ export class SootSimBridgeFlowRunner {
2643
2646
  })
2644
2647
  }
2645
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
+
2646
2719
  async back() {
2647
2720
  const dispatched = await this.bridge.send({
2648
2721
  type: 'call',
@@ -2919,14 +2992,8 @@ export class SootSimBridgeFlowRunner {
2919
2992
  }
2920
2993
  }
2921
2994
 
2922
- // maestro waits around taps for the screen to stop changing, up to
2923
- // its ios driver's 3 s settle budget, comparing screenshots, so an action
2924
- // still in flight (a submit spinner, a sheet closing) finishes before the
2925
- // next command resolves its target. compare consecutive screen pixels,
2926
- // rather than requiring frame publications to stop for half a second:
2927
- // a focused input keeps repainting its native blinking caret.
2928
- // a screen that never stops proceeds when the budget runs out, as there,
2929
- // 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.
2930
2997
  private async waitForAppToSettle() {
2931
2998
  try {
2932
2999
  const result = await waitForSootsimIdle({
@@ -2934,7 +3001,7 @@ export class SootSimBridgeFlowRunner {
2934
3001
  simId: this.opts.simId,
2935
3002
  maxMs: 3000,
2936
3003
  pollMs: 32,
2937
- strict: true,
3004
+ maestro: true,
2938
3005
  paintStill: true,
2939
3006
  })
2940
3007
  if (!result.settled) {
@@ -2951,11 +3018,6 @@ export class SootSimBridgeFlowRunner {
2951
3018
  }
2952
3019
  }
2953
3020
 
2954
- private async settleAfterInput() {
2955
- await this.waitForAppToSettle()
2956
- this.lastInteractionAt = Date.now()
2957
- }
2958
-
2959
3021
  private async waitForVisualSettle() {
2960
3022
  const result = await waitForSootsimIdle({
2961
3023
  bridge: this.bridge,
@@ -2963,19 +3025,10 @@ export class SootSimBridgeFlowRunner {
2963
3025
  maxMs: 10_000,
2964
3026
  pollMs: 32,
2965
3027
  stablePolls: 3,
2966
- strict: true,
3028
+ maestro: true,
2967
3029
  paintStill: true,
2968
3030
  })
2969
3031
  if (!result.settled) {
2970
- // an app loop that never ends (a pulsing status dot) leaves only the
2971
- // pixels moving. maestro on ios captures that frame and continues, so
2972
- // do the same; any other blocker is real unfinished work and still fails.
2973
- if (result.blockedBy.startsWith('screen pixels still changing over ')) {
2974
- console.warn(
2975
- ` [flow] screen never became still in ${result.elapsed}ms; capturing as maestro does`,
2976
- )
2977
- return
2978
- }
2979
3032
  throw new Error(
2980
3033
  `visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
2981
3034
  )
@@ -3322,6 +3375,10 @@ export class SootSimBridgeFlowRunner {
3322
3375
  }
3323
3376
  }
3324
3377
 
3378
+ // containers run each child through runStep, so every child keeps its own
3379
+ // deadline and failure. the container has no clock of its own: a parent
3380
+ // clock fails the parent while its children are still making progress.
3381
+ const isContainer = !!(step.runFlow || step.repeat || step.retry)
3325
3382
  try {
3326
3383
  // containers defer to their executed children: a nested launch must finish
3327
3384
  // resetting the guest before the first playable frame is recorded.
@@ -3329,9 +3386,7 @@ export class SootSimBridgeFlowRunner {
3329
3386
  this.recordingEnabled &&
3330
3387
  this.recordingStartedAtMs == null &&
3331
3388
  !step.launchApp &&
3332
- !step.runFlow &&
3333
- !step.repeat &&
3334
- !step.retry
3389
+ !isContainer
3335
3390
  ) {
3336
3391
  await this.ensureRecordingStarted()
3337
3392
  }
@@ -3341,7 +3396,8 @@ export class SootSimBridgeFlowRunner {
3341
3396
  startedAtMs = Date.now()
3342
3397
  // simple commands have a wall-clock watchdog. bounded operations keep
3343
3398
  // their own lookup, settle and bridge deadlines, so the watchdog cannot
3344
- // report a completed step while its action continues in the background.
3399
+ // report a completed step while its action continues in the background;
3400
+ // the step ceiling only backs those deadlines up.
3345
3401
  const hasBoundedWait = !!(
3346
3402
  // taps combine bounded lookup, settling and delivery. a wall-clock
3347
3403
  // watchdog can finish the step while that action is still running.
@@ -3353,14 +3409,6 @@ export class SootSimBridgeFlowRunner {
3353
3409
  step.extendedWaitUntil ||
3354
3410
  step.waitFor ||
3355
3411
  step.scrollUntilVisible ||
3356
- step.launchApp ||
3357
- // deep links own the same bounded guest launch and tree waits.
3358
- step.openLink ||
3359
- step.runFlow ||
3360
- // retry reruns its body on failure, so its wall time is the body's
3361
- // times the retry count — the 10s no-progress watchdog is the wrong
3362
- // budget and would report "bridge probably hung" for a slow retry.
3363
- step.retry ||
3364
3412
  // runScript http calls are synchronous with a 5-minute upstream
3365
3413
  // timeout — the 10s watchdog would kill legitimate setup scripts.
3366
3414
  step.runScript ||
@@ -3372,6 +3420,7 @@ export class SootSimBridgeFlowRunner {
3372
3420
  // waitForAnimationToEnd ends at its own timeout, which a flow may set
3373
3421
  // past 10s, and continues as maestro does on a screen still moving.
3374
3422
  step.waitForAnimationToEnd ||
3423
+ step.advanceAnimationClock ||
3375
3424
  // assertions wait out maestro's lookup window, longer than 10s, and
3376
3425
  // every query they make times out on its own, so they report their
3377
3426
  // own failure instead of the watchdog's guess.
@@ -3382,27 +3431,40 @@ export class SootSimBridgeFlowRunner {
3382
3431
  const STEP_WATCHDOG_MS = 10_000
3383
3432
  const body = this.runStepInner(step)
3384
3433
  let screenshotPath: string | undefined
3385
- if (hasBoundedWait) {
3434
+ // launchApp self-governs with an internal waitForTree (120s on a
3435
+ // clearState reload): a cache-cleared reload of a heavy bundle re-fetches
3436
+ // every chunk from metro and routinely needs more than the step ceiling.
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) {
3386
3440
  const result = await body
3387
3441
  if (typeof result === 'string') screenshotPath = result
3388
3442
  } else {
3443
+ const deadlineMs = hasBoundedWait ? stepCeilingMs(step) : STEP_WATCHDOG_MS
3389
3444
  let timer: ReturnType<typeof setTimeout> | null = null
3390
- const watchdog = new Promise<never>((_, reject) => {
3445
+ const deadline = new Promise<never>((_, reject) => {
3391
3446
  timer = setTimeout(() => {
3392
3447
  reject(
3393
3448
  new Error(
3394
- `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
3449
+ hasBoundedWait
3450
+ ? `step ${stepIndex + 1} (${stepName}) exceeded ${deadlineMs}ms deadline`
3451
+ : `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
3395
3452
  ),
3396
3453
  )
3397
- }, STEP_WATCHDOG_MS)
3454
+ }, deadlineMs)
3398
3455
  })
3399
3456
  try {
3400
- const result = await Promise.race([body, watchdog])
3457
+ const result = await Promise.race([body, deadline])
3401
3458
  if (typeof result === 'string') screenshotPath = result
3402
3459
  } finally {
3403
3460
  if (timer) clearTimeout(timer)
3404
3461
  }
3405
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
+ }
3406
3468
  const isAssertion = !!(
3407
3469
  step.assertVisible ||
3408
3470
  step.assertNotVisible ||
@@ -3460,6 +3522,25 @@ export class SootSimBridgeFlowRunner {
3460
3522
  }
3461
3523
 
3462
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
+ }
3463
3544
  if (step.tapOn) await this.tapOn(step.tapOn)
3464
3545
  else if (step.doubleTapOn) {
3465
3546
  // maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
@@ -3514,6 +3595,8 @@ export class SootSimBridgeFlowRunner {
3514
3595
  ? waitSpec.timeout
3515
3596
  : 2000,
3516
3597
  )
3598
+ } else if (step.advanceAnimationClock !== undefined) {
3599
+ await this.advanceAnimationClock(step.advanceAnimationClock)
3517
3600
  } else if (step.back) {
3518
3601
  await this.back()
3519
3602
  } else if (step.hideKeyboard) {
@@ -3746,67 +3829,54 @@ export class SootSimBridgeFlowRunner {
3746
3829
  async runFlow(steps: (MaestroStep | string)[], options: { skipPlan?: boolean } = {}) {
3747
3830
  const { onStart, onComplete, body } = this.extractFlowBody(steps)
3748
3831
  this.lastInteractionAt = Date.now()
3832
+ this.flowDepth++
3749
3833
 
3750
- if (!options.skipPlan && this.liveStatus) {
3751
- await this.planFlow(steps)
3752
- }
3834
+ try {
3835
+ if (!options.skipPlan && this.liveStatus) {
3836
+ await this.planFlow(steps)
3837
+ }
3753
3838
 
3754
- if (onStart.length > 0) {
3755
- console.log(`[flow] onFlowStart (${onStart.length} steps)`)
3756
- for (let i = 0; i < onStart.length; i++) {
3757
- 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
+ }
3758
3844
  }
3759
- }
3760
3845
 
3761
- let caughtError: Error | null = null
3762
- try {
3763
- await this.runFlowBody(body)
3764
- } catch (err) {
3765
- caughtError = err instanceof Error ? err : new Error(String(err))
3766
- }
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
+ }
3767
3852
 
3768
- if (onComplete.length > 0) {
3769
- console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
3770
- for (let i = 0; i < onComplete.length; i++) {
3771
- try {
3772
- await this.runStep(onComplete[i], i)
3773
- } catch (hookErr) {
3774
- console.warn(
3775
- `[flow] onFlowComplete step ${i + 1} failed: ${
3776
- hookErr instanceof Error ? hookErr.message : hookErr
3777
- }`,
3778
- )
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
+ }
3779
3865
  }
3780
3866
  }
3781
- }
3782
3867
 
3783
- 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
+ }
3784
3875
  }
3785
3876
 
3786
3877
  private async runFlowBody(steps: MaestroStep[]) {
3787
3878
  for (let i = 0; i < steps.length; i++) {
3788
3879
  const step = steps[i]
3789
- // the ceiling must never fire before the step's own deadline. waitFor and
3790
- // extendedWaitUntil both scale their declared timeout by
3791
- // SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to
3792
- // count extendedWaitUntil at all. Missing that, a flow declaring
3793
- // `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while
3794
- // runStep was still waiting, so runStep never reached its own catch and the
3795
- // step was never recorded: a failed run whose every recorded step is green.
3796
- const explicitWait = typeof step.wait === 'number' ? step.wait : 0
3797
- const waitForTimeout =
3798
- typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
3799
- const extendedWaitTimeout =
3800
- typeof step.extendedWaitUntil?.timeout === 'number'
3801
- ? step.extendedWaitUntil.timeout
3802
- : 0
3803
- const stepCeiling = Math.max(
3804
- 60_000,
3805
- explicitWait + 15_000,
3806
- scaledTimeout(waitForTimeout) + 15_000,
3807
- scaledTimeout(extendedWaitTimeout) + 15_000,
3808
- )
3809
-
3810
3880
  // pause gate + live rail: checked between top-level steps only, so a
3811
3881
  // devtools pause never interrupts a step mid-gesture.
3812
3882
  if (this.liveStatus) {
@@ -3843,34 +3913,11 @@ export class SootSimBridgeFlowRunner {
3843
3913
  }
3844
3914
 
3845
3915
  try {
3846
- // runFlow runs its own sub-steps (each separately deadlined) and
3847
- // launchApp self-governs with an internal waitForTree (120s on a
3848
- // clearState reload) — a cache-cleared reload of a heavy bundle (bluesky)
3849
- // re-fetches every chunk from metro and routinely needs >60s, so the
3850
- // outer per-step race would kill a launch that's legitimately still
3851
- // booting. let both govern their own timing.
3852
- if (step.runFlow || step.launchApp) {
3853
- await this.runStep(step, i, onStepStart)
3854
- } else {
3855
- await Promise.race([
3856
- this.runStep(step, i, onStepStart),
3857
- new Promise<never>((_, reject) =>
3858
- setTimeout(
3859
- () =>
3860
- reject(
3861
- new Error(
3862
- `step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`,
3863
- ),
3864
- ),
3865
- stepCeiling,
3866
- ),
3867
- ),
3868
- ])
3869
- }
3916
+ // runStep owns every step's deadline, nested or top level.
3917
+ await this.runStep(step, i, onStepStart)
3870
3918
  } catch (error) {
3871
- // a ceiling hit rejects the race while runStep is still awaiting, so
3872
- // runStep never records the step itself. record it here so the trace
3873
- // always names the step that failed.
3919
+ // a when predicate that throws fails before runStep records the step.
3920
+ // record it here so the trace always names the step that failed.
3874
3921
  const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
3875
3922
  if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) {
3876
3923
  const stepName = Object.keys(step)[0]