peachsim 0.1.578 → 0.1.580

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 (178) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -9
  3. package/cli/app-catalog.ts +77 -0
  4. package/cli/app-fonts.ts +4 -20
  5. package/cli/app-splash.ts +2 -18
  6. package/cli/bridge-flow-runner.ts +505 -139
  7. package/cli/cloud-client.ts +1 -4
  8. package/cli/cloud-session.ts +1 -4
  9. package/cli/cloud-test-result-files.ts +3 -0
  10. package/cli/cloud-test-tunnel.ts +2 -5
  11. package/cli/command-registry.ts +2 -0
  12. package/cli/commands/agent.ts +149 -23
  13. package/cli/commands/assert.ts +1 -4
  14. package/cli/commands/box/peach-command.ts +62 -0
  15. package/cli/commands/box/plane-share.ts +0 -70
  16. package/cli/commands/box/rnx-command.ts +1 -60
  17. package/cli/commands/box/search-command.ts +14 -0
  18. package/cli/commands/box.ts +2 -2
  19. package/cli/commands/control.ts +121 -27
  20. package/cli/commands/cpu-profile.ts +23 -10
  21. package/cli/commands/daemon.ts +1 -9
  22. package/cli/commands/detox.ts +26 -18
  23. package/cli/commands/device.ts +129 -1
  24. package/cli/commands/film.ts +25 -16
  25. package/cli/commands/flow.ts +9 -2
  26. package/cli/commands/inspect/actions.ts +75 -1
  27. package/cli/commands/inspect/core.ts +81 -2
  28. package/cli/commands/inspect/describe.ts +2 -6
  29. package/cli/commands/inspect/env.ts +1 -1
  30. package/cli/commands/inspect/settling.ts +20 -3
  31. package/cli/commands/inspect/shared.ts +1 -4
  32. package/cli/commands/inspect/sleep.ts +1 -1
  33. package/cli/commands/inspect.ts +16 -49
  34. package/cli/commands/install-desktop.ts +8 -6
  35. package/cli/commands/login.ts +3 -5
  36. package/cli/commands/maestro-generate.ts +1 -4
  37. package/cli/commands/maestro.ts +38 -21
  38. package/cli/commands/no-bridge-hint.ts +0 -14
  39. package/cli/commands/permissions.ts +2 -2
  40. package/cli/commands/platform.ts +13 -6
  41. package/cli/commands/screenshot.ts +6 -1
  42. package/cli/commands/skills.ts +1 -1
  43. package/cli/commands/test.ts +6 -2
  44. package/cli/commands/three-mode.ts +162 -19
  45. package/cli/commands/upload.ts +3 -18
  46. package/cli/current-sim.ts +0 -1
  47. package/cli/desktop-companion.ts +40 -8
  48. package/cli/drivers/playwright-provisioning.ts +1 -4
  49. package/cli/flow-export.ts +1 -8
  50. package/cli/flow-session.ts +0 -4
  51. package/cli/hints.ts +1 -16
  52. package/cli/inspect-notice-state.ts +0 -4
  53. package/cli/main.ts +2 -4
  54. package/cli/outbound-endpoints.ts +1 -24
  55. package/cli/parse-args.ts +1 -2
  56. package/cli/rnx-in-process.ts +3 -1
  57. package/cli/run-rnx-core.ts +4 -2
  58. package/cli/run-rnx.ts +18 -10
  59. package/cli/send-to-box.ts +1 -4
  60. package/cli/telemetry.ts +1 -1
  61. package/cli/ws-bridge.ts +1 -5
  62. package/detox/gestures.ts +20 -6
  63. package/detox/index.ts +379 -123
  64. package/detox/navigation-lifecycle.ts +22 -13
  65. package/dist-lib/agent-daemon-client.cjs +21 -15
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +3 -17
  68. package/dist-lib/agent-sessions.cjs +5 -4
  69. package/dist-lib/attached-projects.cjs +1 -77
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +11 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +7 -5
  76. package/dist-lib/bridge-contract-input.mjs +7 -5
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +30 -7
  80. package/dist-lib/capture-contract.mjs +30 -7
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +1 -1
  85. package/dist-lib/cloud-test-args.mjs +1 -1
  86. package/dist-lib/cloud.cjs +17 -11
  87. package/dist-lib/cloud.mjs +17 -11
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +321 -122
  90. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  91. package/dist-lib/home-paths.cjs +1 -1
  92. package/dist-lib/host/bridge-host.cjs +427 -212
  93. package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
  94. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  95. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  96. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  97. package/dist-lib/host/websocket-proxy.cjs +1 -1
  98. package/dist-lib/index.cjs +315 -58
  99. package/dist-lib/jump-to-source-babel.cjs +1 -1
  100. package/dist-lib/jump-to-source-native.cjs +1 -1
  101. package/dist-lib/menu.cjs +67 -31
  102. package/dist-lib/menu.mjs +64 -28
  103. package/dist-lib/metro-fingerprint-registry.cjs +8 -4
  104. package/dist-lib/metro-fingerprint-registry.mjs +6 -4
  105. package/dist-lib/metro-production-bundle.cjs +2 -2
  106. package/dist-lib/metro-production-bundle.mjs +2 -2
  107. package/dist-lib/metro.cjs +8 -4
  108. package/dist-lib/profiles.cjs +1 -1
  109. package/dist-lib/public-brand.cjs +7 -4
  110. package/dist-lib/react-native-host-modules.cjs +1 -1
  111. package/dist-lib/react-native-host-modules.mjs +1 -1
  112. package/dist-lib/render-mode.cjs +3 -13
  113. package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
  114. package/dist-lib/sdk.cjs +129 -10
  115. package/dist-lib/sdk.mjs +127 -10
  116. package/dist-lib/skills.cjs +1635 -1121
  117. package/dist-lib/swift-compile.cjs +277 -70
  118. package/dist-lib/swift-compile.mjs +275 -70
  119. package/dist-lib/swift-package.cjs +17 -5
  120. package/dist-lib/swift-package.mjs +15 -4
  121. package/dist-lib/swift-submit.cjs +32 -8
  122. package/dist-lib/swift-submit.mjs +29 -7
  123. package/dist-lib/swift.cjs +30912 -577
  124. package/dist-lib/vite-kotlin.cjs +356 -0
  125. package/dist-lib/vite-swift.cjs +1743 -0
  126. package/dist-lib/vite.cjs +208 -1039
  127. package/kotlin/README.md +18 -0
  128. package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
  129. package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
  130. package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
  131. package/kotlin/Sources/org/json/JSON.kt +244 -0
  132. package/kotlin/toolchain.json +19 -0
  133. package/package.json +14 -2
  134. package/scripts/dev-server-scanner.ts +0 -18
  135. package/scripts/optional-demo-registry.ts +0 -17
  136. package/skills/contrast/SKILL.md +2 -2
  137. package/skills/peach-setup/SKILL.md +16 -0
  138. package/src/agent-daemon-client.ts +36 -22
  139. package/src/agent-identity.ts +0 -15
  140. package/src/agent-prompt.ts +12 -0
  141. package/src/attached-projects.ts +0 -72
  142. package/src/backend-origin.ts +10 -0
  143. package/src/bridge-contract-input.ts +2 -5
  144. package/src/bridge-contract.ts +2 -0
  145. package/src/capture-contract.ts +53 -11
  146. package/src/cloud.ts +4 -9
  147. package/src/dev-server-open.ts +0 -99
  148. package/src/home-paths.ts +2 -3
  149. package/src/host/agent-host.ts +82 -36
  150. package/src/host/bridge-host.ts +5 -11
  151. package/src/host/fetch-proxy-handler.ts +19 -43
  152. package/src/is-record.ts +4 -0
  153. package/src/menu.ts +82 -37
  154. package/src/metro-fingerprint-registry.ts +1 -4
  155. package/src/metro-production-bundle.ts +1 -1
  156. package/src/plist.ts +1 -4
  157. package/src/public-brand.ts +5 -3
  158. package/src/render-mode.ts +0 -12
  159. package/src/runtime-assets.ts +9 -2
  160. package/src/screenshots/compose.ts +7 -1
  161. package/src/screenshots/frame-compose.ts +21 -10
  162. package/src/screenshots/schema.ts +1 -4
  163. package/src/sim-client.ts +2 -4
  164. package/src/skills/builtin/maestro-test.ts +1 -4
  165. package/src/sleep.ts +4 -0
  166. package/src/swift-compile.ts +209 -47
  167. package/src/swift-icu.ts +140 -0
  168. package/src/swift-package.ts +26 -2
  169. package/src/swift-submit.ts +84 -4
  170. package/src/swift.ts +2 -0
  171. package/src/vite-plugin-kotlin.ts +376 -0
  172. package/src/vite-plugin-one.ts +224 -36
  173. package/src/vite-plugin-swift.ts +382 -96
  174. package/src/vite-plugin.ts +121 -134
  175. package/src/vite.d.ts +10 -0
  176. package/cli/resolve-assets.ts +0 -63
  177. package/src/rolldown-plugin-swift.ts +0 -244
  178. package/src/team-machine-prompt.ts +0 -73
@@ -5,9 +5,11 @@ import yaml from 'yaml'
5
5
  import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
6
6
  import { flowTimeoutScale } from '../src/flow-timeout-scale'
7
7
  import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
8
+ import { sleep } from '../src/sleep'
8
9
  import { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
9
10
  import { waitForSootsimIdle } from './commands/inspect/settling'
10
11
  import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
12
+ import { PERMISSION_KINDS } from './commands/permissions'
11
13
  import { saveCurrentSimId } from './current-sim'
12
14
  import { FlowLiveStatusReporter } from './flow-live-status'
13
15
  import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
@@ -18,9 +20,14 @@ import type { SootSimReplayTarget, SootSimTapResult } from '@peach/globals'
18
20
  import type {
19
21
  SootSimExternalAppLifecycleOptions,
20
22
  SootSimLaunchArguments,
23
+ SystemPermissionKind,
24
+ SystemPermissionStatus,
21
25
  } from '@peach/globals'
22
26
 
23
27
  const DEFAULT_TIMEOUT = 10000
28
+ // how long maestro looks for a required assertion's element, counted from the
29
+ // latest interaction (Orchestra lookupTimeoutMs and adjustedToLatestInteraction)
30
+ const ASSERTION_LOOKUP_MS = 17_000
24
31
  const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
25
32
  const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
26
33
  const SCREEN_W = 393
@@ -28,14 +35,101 @@ const SCREEN_H = 852
28
35
  type FlowScreenshotPathMode = 'dir' | 'flow'
29
36
  type ReplayTapTarget = SootSimReplayTarget
30
37
 
38
+ // maestro YamlElementSelector; a nested selector is a map or bare text.
31
39
  type FlowTargetSelector = {
32
40
  id?: string
33
41
  text?: string
34
- index?: number
35
- childOf?: { id?: string; text?: string }
42
+ index?: number | string
43
+ childOf?: FlowTargetSelector
44
+ width?: number
45
+ height?: number
46
+ tolerance?: number
47
+ traits?: string
48
+ enabled?: boolean
49
+ selected?: boolean
50
+ checked?: boolean
51
+ below?: string | FlowTargetSelector
52
+ above?: string | FlowTargetSelector
53
+ leftOf?: string | FlowTargetSelector
54
+ rightOf?: string | FlowTargetSelector
36
55
  }
37
56
  type FlowPointTarget = FlowTargetSelector & {
38
57
  point?: string
58
+ // maestro TapRepeat: `repeat` taps, each padded out to `delay` ms
59
+ repeat?: number
60
+ delay?: number
61
+ }
62
+ // every key maestro's YamlElementSelector parses; maestro rejects any other
63
+ // key when it reads the flow ("Unknown Property"), so a typo never widens a
64
+ // match into a passing assertion.
65
+ const MAESTRO_SELECTOR_KEYS = new Set([
66
+ 'text',
67
+ 'id',
68
+ 'width',
69
+ 'height',
70
+ 'tolerance',
71
+ 'optional',
72
+ 'retryTapIfNoChange',
73
+ 'waitUntilVisible',
74
+ 'point',
75
+ 'start',
76
+ 'end',
77
+ 'below',
78
+ 'above',
79
+ 'leftOf',
80
+ 'rightOf',
81
+ 'containsChild',
82
+ 'containsDescendants',
83
+ 'traits',
84
+ 'index',
85
+ 'enabled',
86
+ 'selected',
87
+ 'checked',
88
+ 'focused',
89
+ 'repeat',
90
+ 'delay',
91
+ 'waitToSettleTimeoutMs',
92
+ 'childOf',
93
+ 'label',
94
+ 'css',
95
+ ])
96
+ // maestro filters the runner resolves beyond id and text
97
+ const SELECTOR_FILTER_KEYS = [
98
+ 'childOf',
99
+ 'width',
100
+ 'height',
101
+ 'traits',
102
+ 'enabled',
103
+ 'selected',
104
+ 'checked',
105
+ 'below',
106
+ 'above',
107
+ 'leftOf',
108
+ 'rightOf',
109
+ ] as const
110
+ // maestro filters without a verified ios reading here yet: `containsChild` and
111
+ // `containsDescendants` walk the ios accessibility hierarchy, which hides the
112
+ // children of an accessible element; `focused` is XCUIElement.hasFocus; `css`
113
+ // is web only.
114
+ const UNSUPPORTED_SELECTOR_KEYS = [
115
+ 'containsChild',
116
+ 'containsDescendants',
117
+ 'focused',
118
+ 'css',
119
+ ]
120
+ // ignoring a key widens the match, so an assertion passes that maestro fails.
121
+ function assertSupportedSelector(selector: unknown) {
122
+ if (!selector || typeof selector !== 'object') return
123
+ const keys = Object.keys(selector)
124
+ const unknown = keys.filter((key) => !MAESTRO_SELECTOR_KEYS.has(key))
125
+ if (unknown.length > 0) throw new Error(`Unknown Property: ${unknown.join(', ')}`)
126
+ const unsupported = UNSUPPORTED_SELECTOR_KEYS.filter((key) => keys.includes(key))
127
+ if (unsupported.length > 0) {
128
+ throw new Error(`selector ${unsupported.join(', ')} is not supported by peach yet`)
129
+ }
130
+ for (const key of ['childOf', 'below', 'above', 'leftOf', 'rightOf']) {
131
+ assertSupportedSelector(Reflect.get(selector, key))
132
+ }
39
133
  }
40
134
  type FocusedTextInputInfo = {
41
135
  nodeId?: number | null
@@ -91,8 +185,14 @@ export interface MaestroStep {
91
185
  index?: number
92
186
  point?: string
93
187
  childOf?: { id?: string; text?: string }
188
+ repeat?: number
189
+ delay?: number
94
190
  optional?: boolean
95
191
  }
192
+ doubleTapOn?: NonNullable<MaestroStep['tapOn']>
193
+ assertTrue?: string | { condition: string; label?: string; optional?: boolean }
194
+ setClipboard?: string
195
+ pasteText?: boolean
96
196
  longPressOn?:
97
197
  | string
98
198
  | {
@@ -205,10 +305,12 @@ export interface MaestroStep {
205
305
  commands: MaestroStep[]
206
306
  }
207
307
  scrollUntilVisible?: {
208
- element: string
308
+ element: string | FlowTargetSelector
209
309
  centerElement?: boolean
210
310
  direction?: string
211
311
  timeout?: number
312
+ speed?: number
313
+ visibilityPercentage?: number
212
314
  optional?: boolean
213
315
  }
214
316
  extendedWaitUntil?: {
@@ -242,10 +344,6 @@ export interface MaestroStep {
242
344
  onFlowComplete?: MaestroStep[]
243
345
  }
244
346
 
245
- function sleep(ms: number) {
246
- return new Promise((resolve) => setTimeout(resolve, ms))
247
- }
248
-
249
347
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
250
348
  return Boolean(value && typeof value === 'object' && !Array.isArray(value))
251
349
  }
@@ -261,6 +359,55 @@ function readBooleanKey(value: unknown, key: string): boolean {
261
359
  return value[key] === true
262
360
  }
263
361
 
362
+ // maestro's launchApp sets the app's permissions before launching it: every
363
+ // permission allowed unless the step names its own. names are maestro's ios
364
+ // names; one with no simulated permission (health, homekit) changes nothing.
365
+ const MAESTRO_PERMISSION_KINDS: Record<string, readonly SystemPermissionKind[]> = {
366
+ all: PERMISSION_KINDS,
367
+ bluetooth: ['bluetooth'],
368
+ calendar: ['calendars'],
369
+ camera: ['camera'],
370
+ contacts: ['contacts'],
371
+ faceid: ['face-id'],
372
+ location: ['location-when-in-use', 'location-always'],
373
+ medialibrary: ['media-library'],
374
+ microphone: ['microphone'],
375
+ motion: ['motion'],
376
+ notifications: ['notifications'],
377
+ photos: ['photo-library', 'photo-library-add-only'],
378
+ reminders: ['reminders'],
379
+ siri: ['siri'],
380
+ speech: ['speech-recognition'],
381
+ usertracking: ['app-tracking-transparency'],
382
+ }
383
+
384
+ const MAESTRO_PERMISSION_STATUS: Record<string, SystemPermissionStatus> = {
385
+ allow: 'authorized',
386
+ deny: 'denied',
387
+ unset: 'not-determined',
388
+ }
389
+
390
+ function maestroLaunchPermissions(
391
+ permissions: unknown,
392
+ ): Map<SystemPermissionKind, SystemPermissionStatus> {
393
+ const entries = isObjectRecord(permissions)
394
+ ? Object.entries(permissions)
395
+ : [['all', 'allow'] as const]
396
+ const statuses = new Map<SystemPermissionKind, SystemPermissionStatus>()
397
+ // `all` first so a named permission overrides it
398
+ entries.sort(([a], [b]) => Number(b === 'all') - Number(a === 'all'))
399
+ for (const [name, value] of entries) {
400
+ const status = MAESTRO_PERMISSION_STATUS[String(value).toLowerCase()]
401
+ if (!status) {
402
+ throw new Error(`launchApp.permissions.${name} must be allow, deny, or unset`)
403
+ }
404
+ for (const kind of MAESTRO_PERMISSION_KINDS[name.toLowerCase()] ?? []) {
405
+ statuses.set(kind, status)
406
+ }
407
+ }
408
+ return statuses
409
+ }
410
+
264
411
  function readRecentShellAppId(state: ShellState | null): string | null {
265
412
  if (!state) return null
266
413
  const recentApps = state.recentApps
@@ -274,12 +421,6 @@ function readRecentShellAppId(state: ShellState | null): string | null {
274
421
  return readStringKey(bindings, 'app:one') || readStringKey(bindings, 'app:two') || null
275
422
  }
276
423
 
277
- export function resolveFlowScreenshotPath(screenshotDir: string, name: string): string {
278
- return resolveFlowScreenshotPathWithMode(screenshotDir, name, {
279
- mode: 'dir',
280
- })
281
- }
282
-
283
424
  function resolveProjectRelativeFlowBase(flowDir?: string): string {
284
425
  if (!flowDir) return process.cwd()
285
426
  return path.basename(flowDir) === '.maestro' ? path.dirname(flowDir) : flowDir
@@ -364,6 +505,8 @@ function normalizeBareStringStep(verb: string): MaestroStep {
364
505
  return { clearState: true }
365
506
  case 'clearKeychain':
366
507
  return { clearKeychain: true }
508
+ case 'pasteText':
509
+ return { pasteText: true }
367
510
  case 'eraseText':
368
511
  return { eraseText: 50 }
369
512
  case 'scroll':
@@ -411,6 +554,8 @@ export class SootSimBridgeFlowRunner {
411
554
  private lastRecordingDurationMs: number | null = null
412
555
  private lastRecordingFrameStats: unknown = null
413
556
  private flowTraceSteps: SootSimFlowTraceStep[] = []
557
+ // when the latest step that acted on the app settled
558
+ private lastInteractionAt = Date.now()
414
559
  // set by runStep when a step throws — lets the outer flow command
415
560
  // dump a rich per-step failure bundle (screenshot + describe.json +
416
561
  // console + network) keyed by the failing step.
@@ -616,9 +761,9 @@ export class SootSimBridgeFlowRunner {
616
761
  const record = value as Record<string, unknown>
617
762
  const direct = record.id ?? record.text ?? record.name ?? record.path
618
763
  if (typeof direct === 'string' && direct) return direct.slice(0, 80)
619
- // extendedWaitUntil keeps its target under visible/notVisible, so without
620
- // this the step every flow waits on renders as a bare "Wait".
621
- const awaited = record.visible ?? record.notVisible
764
+ // extendedWaitUntil keeps its target under visible/notVisible and
765
+ // scrollUntilVisible under element, so without this they render bare.
766
+ const awaited = record.visible ?? record.notVisible ?? record.element
622
767
  if (typeof awaited === 'string' && awaited) return awaited.slice(0, 80)
623
768
  if (awaited && typeof awaited === 'object') {
624
769
  const target = awaited as Record<string, unknown>
@@ -746,13 +891,15 @@ export class SootSimBridgeFlowRunner {
746
891
  }
747
892
 
748
893
  private async findElement(opts: FlowTargetSelector) {
749
- // patterns and combined selectors need the full candidate set so id and
750
- // text constrain the same node. literal single selectors keep the direct lookup.
894
+ assertSupportedSelector(opts)
895
+ // id selectors, patterns and filtered selectors resolve over the full
896
+ // candidate set in maestro's order, so a repeated id (one per list row)
897
+ // means the top row. a literal text selector keeps the direct lookup.
751
898
  if (
752
- opts.childOf != null ||
753
- typeof opts.index === 'number' ||
754
- (opts.id && opts.text) ||
755
- [opts.id, opts.text].some((value) => value && /[.*+?^$()[\]{}|\\]/.test(value))
899
+ opts.id != null ||
900
+ opts.index != null ||
901
+ (opts.text != null && /[.*+?^$()[\]{}|\\]/.test(opts.text)) ||
902
+ SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
756
903
  ) {
757
904
  return this.findElementScoped(opts)
758
905
  }
@@ -786,70 +933,156 @@ export class SootSimBridgeFlowRunner {
786
933
  })()`)
787
934
  }
788
935
 
789
- // maestro `index:` (nth match) and `childOf:` (match scoped to a container)
790
- // resolver. queryAll returns every matching node in tree order; childOf keeps
791
- // only candidates whose center sits inside the container's bounds.
936
+ // maestro's selector resolver (Orchestra.buildFilter over Filters.kt):
937
+ // id and text patterns, size, traits, enabled/selected, the relative
938
+ // below/above/leftOf/rightOf filters, `index:` (nth match) and `childOf:`
939
+ // (match scoped to a container). a nested selector resolves the same way,
940
+ // and a relative filter holds when ANY match of its anchor satisfies it.
792
941
  private async findElementScoped(opts: FlowTargetSelector) {
793
- const index = typeof opts.index === 'number' ? opts.index : 0
942
+ const needsEveryNode =
943
+ !opts.id ||
944
+ opts.childOf != null ||
945
+ SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
794
946
  return this.evaluate<any>(`(async () => {
795
947
  const test = window.__sootsimTest
796
948
  if (!test || !test.queryAll) return null
949
+ const selector = ${JSON.stringify(opts)}
950
+ // maestro matches the whole id or text against the selector's regex
797
951
  const compile = (value) => {
798
952
  if (!value) return null
799
953
  let pattern = null
800
- try { pattern = new RegExp(value) } catch {}
954
+ try { pattern = new RegExp('^(?:' + value + ')$') } catch {}
801
955
  return (candidate) => typeof candidate === 'string' &&
802
956
  (candidate === value || !!pattern?.test(candidate))
803
957
  }
804
- const matchesId = compile(${JSON.stringify(opts.id)})
805
- const matchesText = compile(${JSON.stringify(opts.text)})
806
958
  const all = ((await test.queryAll(${JSON.stringify(
807
- opts.id && !opts.childOf ? { idPattern: opts.id } : {},
959
+ needsEveryNode ? {} : { idPattern: opts.id },
808
960
  )})) || []).filter(
809
961
  (n) => n && n.absolutePosition && n.layout,
810
962
  )
811
- let candidates = all.filter((n) =>
812
- (!matchesId || matchesId(n.testID) || matchesId(n.id)) &&
813
- (!matchesText || matchesText(n.text) || matchesText(n.accessibilityLabel)),
814
- )
815
- const literalText = candidates.filter((n) =>
816
- n.text === ${JSON.stringify(opts.text)} ||
817
- n.accessibilityLabel === ${JSON.stringify(opts.text)},
818
- )
819
- if (matchesText && literalText.length > 0) candidates = literalText
820
- // prefer on-screen matches (findByTestId's visible-first behavior); only
821
- // fall back to off-screen ones when nothing is on-screen, so index: still
822
- // resolves a target that needs scrolling into view.
823
- const onScreen = candidates.filter((n) => {
824
- const cy = n.absolutePosition.y + n.layout.height / 2
825
- return cy > 0 && cy < ${SCREEN_H}
826
- })
827
- if (onScreen.length > 0) candidates = onScreen
828
- ${
829
- opts.childOf
830
- ? `
831
- const matchesContainerId = compile(${JSON.stringify(opts.childOf.id)})
832
- const matchesContainerText = compile(${JSON.stringify(opts.childOf.text)})
833
- const container = all.find((n) =>
834
- (!matchesContainerId || matchesContainerId(n.testID) || matchesContainerId(n.id)) &&
835
- (!matchesContainerText || matchesContainerText(n.text) || matchesContainerText(n.accessibilityLabel)),
836
- )
837
- if (!container || !container.absolutePosition || !container.layout) return null
838
- const x0 = container.absolutePosition.x
839
- const y0 = container.absolutePosition.y
840
- const x1 = x0 + container.layout.width
841
- const y1 = y0 + container.layout.height
842
- candidates = candidates.filter((n) => {
843
- const cx = n.absolutePosition.x + n.layout.width / 2
844
- const cy = n.absolutePosition.y + n.layout.height / 2
845
- return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
846
- })`
847
- : ''
848
- }
849
- if (${JSON.stringify(opts.index === undefined && !!opts.text && !opts.id)}) {
850
- candidates.sort((a, b) => (a.text || '').length - (b.text || '').length)
851
- }
852
- const node = candidates[${index}]
963
+ // maestro reads ios frames as integer edges (AXElement.boundsString)
964
+ const bounds = (n) => {
965
+ const left = Math.trunc(n.absolutePosition.x)
966
+ const top = Math.trunc(n.absolutePosition.y)
967
+ return {
968
+ x: left,
969
+ y: top,
970
+ width: Math.trunc(n.absolutePosition.x + n.layout.width) - left,
971
+ height: Math.trunc(n.absolutePosition.y + n.layout.height) - top,
972
+ }
973
+ }
974
+ const painted = (n) => (n.visibleFrame?.area ?? 0) > 0
975
+ const byPosition = (a, b) =>
976
+ a.absolutePosition.y - b.absolutePosition.y ||
977
+ a.absolutePosition.x - b.absolutePosition.x
978
+ const relative = {
979
+ below: (n, other) => bounds(n).y > bounds(other).y,
980
+ above: (n, other) => bounds(n).y < bounds(other).y,
981
+ leftOf: (n, other) => bounds(n).x < bounds(other).x,
982
+ rightOf: (n, other) => bounds(n).x > bounds(other).x,
983
+ }
984
+ const traitFilters = {
985
+ TEXT: (n) => n.text != null,
986
+ SQUARE: (n) => {
987
+ const b = bounds(n)
988
+ return Math.abs(1 - b.width / b.height) < 0.03
989
+ },
990
+ LONG_TEXT: (n) => (n.text?.length ?? 0) > 200,
991
+ }
992
+ const matchAll = (raw) => {
993
+ const sel = typeof raw === 'string' ? { text: raw } : raw
994
+ const matchesId = compile(sel.id)
995
+ const matchesText = compile(sel.text)
996
+ let candidates = all.filter((n) =>
997
+ (!matchesId || matchesId(n.testID) || matchesId(n.id)) &&
998
+ (!matchesText || matchesText(n.text) || matchesText(n.accessibilityLabel)),
999
+ )
1000
+ const literalText = candidates.filter((n) =>
1001
+ n.text === sel.text || n.accessibilityLabel === sel.text,
1002
+ )
1003
+ if (matchesText && literalText.length > 0) candidates = literalText
1004
+ if (sel.width != null || sel.height != null) {
1005
+ const tolerance = sel.tolerance ?? 0
1006
+ candidates = candidates.filter((n) => {
1007
+ const b = bounds(n)
1008
+ return (sel.width == null || Math.abs(b.width - sel.width) <= tolerance) &&
1009
+ (sel.height == null || Math.abs(b.height - sel.height) <= tolerance)
1010
+ })
1011
+ }
1012
+ for (const trait of sel.traits ? String(sel.traits).split(' ') : []) {
1013
+ const filter = traitFilters[trait.replaceAll('-', '_').toUpperCase()]
1014
+ if (!filter) throw new Error('unknown selector trait: ' + trait)
1015
+ candidates = candidates.filter(filter)
1016
+ }
1017
+ // ios reports isEnabled / isSelected from the accessibility traits
1018
+ // react native derives from accessibilityState
1019
+ if (sel.enabled != null) {
1020
+ candidates = candidates.filter((n) => !n.accessibilityState?.disabled === sel.enabled)
1021
+ }
1022
+ if (sel.selected != null) {
1023
+ candidates = candidates.filter((n) => !!n.accessibilityState?.selected === sel.selected)
1024
+ }
1025
+ // maestro's ios checked: a switch whose value is "1". react native
1026
+ // gives the switch role that trait and reports "1" from
1027
+ // accessibilityState.checked; a checkbox role gets no trait, so ios
1028
+ // never reads it as checked.
1029
+ if (sel.checked != null) {
1030
+ candidates = candidates.filter(
1031
+ (n) =>
1032
+ (n.accessibilityRole === 'switch' && n.accessibilityState?.checked === true) ===
1033
+ sel.checked,
1034
+ )
1035
+ }
1036
+ for (const key of Object.keys(relative)) {
1037
+ if (sel[key] == null) continue
1038
+ const anchors = pick(matchAll(sel[key]), sel[key].index)
1039
+ candidates = candidates.filter((n) => anchors.some((other) => relative[key](n, other)))
1040
+ }
1041
+ // prefer painted matches, then on-screen ones (findByTestId's
1042
+ // visible-first behavior); only fall back to off-screen ones when
1043
+ // nothing is on-screen, so index: still resolves a target that needs
1044
+ // scrolling into view. a screen still mounted under the one on top
1045
+ // paints nothing, and maestro's hierarchy never holds it.
1046
+ const onScreen = candidates.filter((n) => {
1047
+ const cy = n.absolutePosition.y + n.layout.height / 2
1048
+ return cy > 0 && cy < ${SCREEN_H}
1049
+ })
1050
+ if (candidates.some(painted)) candidates = candidates.filter(painted)
1051
+ else if (onScreen.length > 0) candidates = onScreen
1052
+ if (sel.childOf) {
1053
+ const containers = matchAll(sel.childOf)
1054
+ const container = containers.find(painted) ?? containers[0]
1055
+ if (!container) return []
1056
+ const x0 = container.absolutePosition.x
1057
+ const y0 = container.absolutePosition.y
1058
+ const x1 = x0 + container.layout.width
1059
+ const y1 = y0 + container.layout.height
1060
+ candidates = candidates.filter((n) => {
1061
+ const cx = n.absolutePosition.x + n.layout.width / 2
1062
+ const cy = n.absolutePosition.y + n.layout.height / 2
1063
+ return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
1064
+ })
1065
+ }
1066
+ // maestro's index: sorts matches by frame y, then x, and without an
1067
+ // index takes the first in the ios hierarchy, which lays siblings out
1068
+ // by position rather than subview order (RAN on ios 27: two views with
1069
+ // one identifier, the first subview moved below by a transform, maestro
1070
+ // takes the upper one). a list that renders rows in key order and
1071
+ // places them by transform still resolves its top row.
1072
+ candidates.sort(byPosition)
1073
+ if (sel.index == null && sel.text && !sel.id) {
1074
+ candidates.sort((a, b) => (a.text || '').length - (b.text || '').length)
1075
+ }
1076
+ return candidates
1077
+ }
1078
+ // maestro Filters.index: a negative index counts from the end
1079
+ const pick = (candidates, rawIndex) => {
1080
+ if (rawIndex == null) return candidates
1081
+ const index = Math.trunc(Number(rawIndex))
1082
+ const resolved = index >= 0 ? index : candidates.length + index
1083
+ return candidates[resolved] ? [candidates[resolved]] : []
1084
+ }
1085
+ const node = pick(matchAll(selector), selector.index)[0]
853
1086
  if (!node) return null
854
1087
  return {
855
1088
  nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
@@ -1062,14 +1295,30 @@ export class SootSimBridgeFlowRunner {
1062
1295
 
1063
1296
  async tapOn(target: string | FlowPointTarget) {
1064
1297
  const opts = typeof target === 'string' ? { text: target } : target
1298
+ const repeat = Math.max(1, Math.trunc(opts.repeat ?? 1))
1299
+ const repeatDelay = opts.delay !== undefined && opts.delay >= 0 ? opts.delay : 100
1300
+ // maestro Maestro.hierarchyBasedTap with a TapRepeat: every tap is padded
1301
+ // out to `delay` ms (a single tap waits for nothing). the first tap is
1302
+ // already delivered at `tappedAtMs`; this delivers the rest.
1303
+ const repeatTaps = async (tappedAtMs: number, tapAgain: () => Promise<unknown>) => {
1304
+ if (repeat === 1) return
1305
+ for (let i = 1; i <= repeat; i++) {
1306
+ await sleep(Math.max(0, repeatDelay - (Date.now() - tappedAtMs)))
1307
+ if (i === repeat) return
1308
+ tappedAtMs = Date.now()
1309
+ await tapAgain()
1310
+ }
1311
+ }
1065
1312
  if (opts.point) {
1066
1313
  const point = await this.resolveInteractionPoint(opts)
1067
1314
  if (!point) throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
1068
- const direct = await this.activatePressAt(point.x, point.y)
1315
+ const tappedAtMs = Date.now()
1316
+ const direct = repeat === 1 ? await this.activatePressAt(point.x, point.y) : null
1069
1317
  if (!direct?.ok) {
1070
1318
  await this.tap(point.x, point.y, point.target)
1319
+ await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
1071
1320
  }
1072
- await sleep(300)
1321
+ await this.settleAfterInput()
1073
1322
  return
1074
1323
  }
1075
1324
  // auto-wait like real maestro: if the target is not mounted yet, poll
@@ -1093,10 +1342,7 @@ export class SootSimBridgeFlowRunner {
1093
1342
  // command uses. non-text-input taps keep the single-tap path.
1094
1343
  const tapInputAttempts = element.isTextInput ? 4 : 1
1095
1344
  for (let attempt = 0; attempt < tapInputAttempts; attempt++) {
1096
- const current =
1097
- attempt === 0
1098
- ? element
1099
- : await this.findElement({ text: opts.text, id: opts.id })
1345
+ const current = attempt === 0 ? element : await this.findElement(opts)
1100
1346
  if (!current) break
1101
1347
  const tapTarget =
1102
1348
  typeof current.nodeId === 'number'
@@ -1104,15 +1350,29 @@ export class SootSimBridgeFlowRunner {
1104
1350
  : null
1105
1351
  const cx = tapTarget?.cx ?? current.absolutePosition.x + current.layout.width / 2
1106
1352
  const cy = tapTarget?.cy ?? current.absolutePosition.y + current.layout.height / 2
1107
- const hit = await this.tap(
1108
- cx,
1109
- cy,
1110
- this.replayTargetForElement(opts, current, tapTarget),
1353
+ let replayTarget: ReplayTapTarget | undefined = this.replayTargetForElement(
1354
+ opts,
1355
+ current,
1356
+ tapTarget,
1111
1357
  )
1358
+ let tappedAtMs = Date.now()
1359
+ let hit = await this.tap(cx, cy, replayTarget)
1360
+ // maestro taps the element's center and whatever sits on top there
1361
+ // takes it, as a finger would: a label under a text input's hitSlop
1362
+ // focuses the input. name the cover, then deliver that same tap.
1363
+ if (hit?.requestedTargetMatched === false && hit.reason === 'target-covered') {
1364
+ console.warn(
1365
+ `[flow] tapOn ${JSON.stringify(opts)}: covered by ${JSON.stringify(hit.coordinateTarget)}, tapping it as maestro does`,
1366
+ )
1367
+ replayTarget = undefined
1368
+ tappedAtMs = Date.now()
1369
+ hit = await this.tap(cx, cy)
1370
+ }
1112
1371
  if (hit?.requestedTargetMatched === false)
1113
1372
  throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
1114
1373
  if (hit?.hit === true) {
1115
- await sleep(300)
1374
+ await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
1375
+ await this.settleAfterInput()
1116
1376
  if (!current.isTextInput) return
1117
1377
  // confirm focus landed on THIS text input; retry the tap if a
1118
1378
  // previous field still owns focus.
@@ -1124,10 +1384,14 @@ export class SootSimBridgeFlowRunner {
1124
1384
  }
1125
1385
  // fallback: bypass hit-testing by activating press handlers directly.
1126
1386
  // skips the cursor animation, but keeps the flow running on targets the
1127
- // canvas hit-test missed (e.g. clipped or zero-area hit regions).
1128
- const direct = await this.activatePressTarget(opts)
1387
+ // canvas hit-test missed (e.g. clipped or zero-area hit regions). it
1388
+ // looks up by id or text alone, so a selector that narrows further only
1389
+ // takes it for an element its own match already found.
1390
+ const narrowed =
1391
+ opts.index != null || SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
1392
+ const direct = element || !narrowed ? await this.activatePressTarget(opts) : null
1129
1393
  if (direct?.ok) {
1130
- await sleep(300)
1394
+ await this.settleAfterInput()
1131
1395
  return
1132
1396
  }
1133
1397
  throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
@@ -1141,12 +1405,12 @@ export class SootSimBridgeFlowRunner {
1141
1405
  if (result?.ok === false || result?.value === false) {
1142
1406
  throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
1143
1407
  }
1144
- await sleep(300)
1408
+ await this.settleAfterInput()
1145
1409
  }
1146
1410
 
1147
1411
  async tapAtCoords(x: number, y: number) {
1148
1412
  await this.tap(x, y)
1149
- await sleep(300)
1413
+ await this.settleAfterInput()
1150
1414
  }
1151
1415
 
1152
1416
  async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
@@ -1158,19 +1422,21 @@ export class SootSimBridgeFlowRunner {
1158
1422
  `doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
1159
1423
  )
1160
1424
  }
1161
- await sleep(300)
1425
+ await this.settleAfterInput()
1162
1426
  }
1163
1427
 
1164
1428
  async assertVisible(target: string | { id?: string; text?: string }) {
1165
1429
  const opts = typeof target === 'string' ? { text: target } : target
1166
- // maestro auto-retries assertions for up to 7 seconds before failing; a
1167
- // shorter window fails flows real maestro passes.
1168
- for (let attempt = 0; attempt < 35; attempt++) {
1430
+ // maestro retries until its lookup window after the latest interaction
1431
+ // closes, in wall time: counting attempts stretched the window with every
1432
+ // slow query, and a shorter one fails flows real maestro passes.
1433
+ const deadline = this.assertionDeadline()
1434
+ do {
1169
1435
  if (await this.isElementVisible(opts)) {
1170
1436
  return
1171
1437
  }
1172
1438
  await sleep(200)
1173
- }
1439
+ } while (Date.now() < deadline)
1174
1440
  // report the geometry rather than a guess at the cause. a zero visibleFrame
1175
1441
  // says the node paints nothing; it cannot on its own separate scrolled-away
1176
1442
  // from painted-over, so print the frame and let the reader judge. a missing
@@ -1186,14 +1452,15 @@ export class SootSimBridgeFlowRunner {
1186
1452
 
1187
1453
  async assertNotVisible(target: string | { id?: string; text?: string }) {
1188
1454
  const opts = typeof target === 'string' ? { text: target } : target
1189
- // like assertVisible, maestro waits up to 7 seconds for the element to
1455
+ // like assertVisible, maestro waits out the same window for the element to
1190
1456
  // disappear rather than failing on the first visible sample.
1191
- for (let attempt = 0; attempt < 35; attempt++) {
1457
+ const deadline = this.assertionDeadline()
1458
+ do {
1192
1459
  if (!(await this.isElementVisible(opts))) {
1193
1460
  return
1194
1461
  }
1195
1462
  await sleep(200)
1196
- }
1463
+ } while (Date.now() < deadline)
1197
1464
  const found = await this.findElement(opts)
1198
1465
  const where = found
1199
1466
  ? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
@@ -1201,6 +1468,10 @@ export class SootSimBridgeFlowRunner {
1201
1468
  throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
1202
1469
  }
1203
1470
 
1471
+ private assertionDeadline(): number {
1472
+ return this.lastInteractionAt + scaledTimeout(ASSERTION_LOOKUP_MS)
1473
+ }
1474
+
1204
1475
  // visibility is the engine's call, not a layout-bounds guess. the old test
1205
1476
  // here was a vertical band (`y + height > 0 && y < SCREEN_H`), so a node
1206
1477
  // sealed under an opaque overlay, pushed off the left or right edge, or
@@ -1378,21 +1649,22 @@ export class SootSimBridgeFlowRunner {
1378
1649
  }
1379
1650
 
1380
1651
  async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
1381
- const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
1652
+ const { timeout, ...selector } = opts
1653
+ const deadline = Date.now() + scaledTimeout(timeout || DEFAULT_TIMEOUT)
1382
1654
  while (Date.now() < deadline) {
1383
- const element = await this.findElement(opts)
1655
+ const element = await this.findElement(selector)
1384
1656
  if (element) return
1385
1657
  await sleep(200)
1386
1658
  }
1387
1659
  // surface whether the element existed but was off-screen vs never
1388
1660
  // showed up at all — the two failure modes need different debugging
1389
1661
  // (scroll-position bug vs missing wait-step / wrong screen).
1390
- const final = await this.findElement(opts)
1662
+ const final = await this.findElement(selector)
1391
1663
  const detail = final
1392
1664
  ? ` (matched node y=${Math.round(final.absolutePosition.y)} — still off-screen at deadline)`
1393
1665
  : ''
1394
1666
  throw new Error(
1395
- `waitFor: ${JSON.stringify(opts)} not found after ${opts.timeout || DEFAULT_TIMEOUT}ms${detail}`,
1667
+ `waitFor: ${JSON.stringify(opts)} not found after ${timeout || DEFAULT_TIMEOUT}ms${detail}`,
1396
1668
  )
1397
1669
  }
1398
1670
 
@@ -1685,7 +1957,7 @@ export class SootSimBridgeFlowRunner {
1685
1957
  if (!result?.ok) {
1686
1958
  throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
1687
1959
  }
1688
- await sleep(300)
1960
+ await this.settleAfterInput()
1689
1961
  }
1690
1962
 
1691
1963
  async swipeCoords(start: string, end: string, duration = 300) {
@@ -1809,40 +2081,70 @@ export class SootSimBridgeFlowRunner {
1809
2081
  }
1810
2082
 
1811
2083
  async scrollUntilVisible(opts: {
1812
- element: string
2084
+ element: string | FlowTargetSelector
1813
2085
  centerElement?: boolean
1814
2086
  direction?: string
1815
2087
  timeout?: number
2088
+ speed?: number
2089
+ visibilityPercentage?: number
1816
2090
  }) {
1817
- const deadline = Date.now() + (opts.timeout || 15000)
2091
+ // as maestro's orchestra does: `element` is a selector map or bare text;
2092
+ // the element counts once its bounds clipped to the screen cover the
2093
+ // required share (the percentage is integer-divided by 100 there, so 100
2094
+ // means whole and anything lower means any); otherwise swipe from the
2095
+ // screen center toward the far edge for 1000 * (100 - speed) / 100 + 1 ms
2096
+ // (speed 40 by default), settle, and look again until the 20 s timeout.
2097
+ const target =
2098
+ typeof opts.element === 'string' ? { text: opts.element } : opts.element
2099
+ const required = Math.trunc((opts.visibilityPercentage ?? 100) / 100)
2100
+ const duration = Math.max(
2101
+ 1,
2102
+ Math.trunc((1000 * (100 - (opts.speed ?? 40))) / 100) + 1,
2103
+ )
2104
+ const deadline = Date.now() + (opts.timeout || 20000)
1818
2105
  const direction = opts.direction?.toUpperCase() || 'DOWN'
1819
- const swipeDirection =
1820
- direction === 'DOWN' ? 'UP' : direction === 'UP' ? 'DOWN' : direction
2106
+ const centerX = SCREEN_W / 2
2107
+ const centerY = SCREEN_H / 2
2108
+ const swipeEnd =
2109
+ direction === 'UP'
2110
+ ? { x: centerX, y: SCREEN_H * 0.9 }
2111
+ : direction === 'LEFT'
2112
+ ? { x: SCREEN_W * 0.9, y: centerY }
2113
+ : direction === 'RIGHT'
2114
+ ? { x: SCREEN_W * 0.1, y: centerY }
2115
+ : { x: centerX, y: SCREEN_H * 0.1 }
1821
2116
 
1822
2117
  while (Date.now() < deadline) {
1823
- const element = await this.findElement({ text: opts.element })
1824
- if (
1825
- element &&
1826
- element.absolutePosition.y >= 0 &&
1827
- element.absolutePosition.y + element.layout.height > 0 &&
1828
- element.absolutePosition.y < SCREEN_H - 50
1829
- ) {
1830
- if (opts.centerElement) {
1831
- const targetY = SCREEN_H / 2
1832
- const diff = element.absolutePosition.y - targetY
1833
- if (Math.abs(diff) > 100) {
1834
- const distance = Math.min(150, Math.abs(diff) * 0.5)
1835
- const endY = diff > 0 ? targetY - distance : targetY + distance
1836
- await this.drag(SCREEN_W / 2, targetY, SCREEN_W / 2, endY, 10, 16)
2118
+ const element = await this.findElement(target)
2119
+ if (element) {
2120
+ const { x, y } = element.absolutePosition
2121
+ const { width, height } = element.layout
2122
+ const shownX = Math.max(0, Math.min(x + width, SCREEN_W) - Math.max(x, 0))
2123
+ const shownY = Math.max(0, Math.min(y + height, SCREEN_H) - Math.max(y, 0))
2124
+ const share = width * height > 0 ? (shownX * shownY) / (width * height) : 0
2125
+ if (share >= required) {
2126
+ if (opts.centerElement) {
2127
+ const diff = y - centerY
2128
+ if (Math.abs(diff) > 100) {
2129
+ const distance = Math.min(150, Math.abs(diff) * 0.5)
2130
+ const endY = diff > 0 ? centerY - distance : centerY + distance
2131
+ await this.drag(centerX, centerY, centerX, endY, 10, 16)
2132
+ }
1837
2133
  }
2134
+ return
1838
2135
  }
1839
- return
1840
2136
  }
1841
- await this.swipe(swipeDirection, 250)
1842
- await sleep(500)
2137
+ await this.drag(
2138
+ centerX,
2139
+ centerY,
2140
+ swipeEnd.x,
2141
+ swipeEnd.y,
2142
+ Math.max(1, Math.round(duration / 16)),
2143
+ 16,
2144
+ )
1843
2145
  }
1844
2146
 
1845
- throw new Error(`scrollUntilVisible: "${opts.element}" not found`)
2147
+ throw new Error(`scrollUntilVisible: ${JSON.stringify(target)} not found`)
1846
2148
  }
1847
2149
 
1848
2150
  async extendedWaitUntil(opts: {
@@ -1956,6 +2258,7 @@ export class SootSimBridgeFlowRunner {
1956
2258
  }
1957
2259
  const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
1958
2260
  const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
2261
+ await this.setLaunchPermissions(isObjectRecord(opts) ? opts.permissions : undefined)
1959
2262
 
1960
2263
  if (!this.firstLaunchDone) {
1961
2264
  this.firstLaunchDone = true
@@ -2049,6 +2352,16 @@ export class SootSimBridgeFlowRunner {
2049
2352
  }
2050
2353
  }
2051
2354
 
2355
+ private async setLaunchPermissions(permissions: unknown) {
2356
+ const statuses = maestroLaunchPermissions(permissions)
2357
+ const state = await getShellState(this.bridge)
2358
+ const appId = readStringKey(state, 'activeApp') || readRecentShellAppId(state)
2359
+ if (!appId) throw new Error('launchApp: no simulator app to set permissions for')
2360
+ for (const [kind, status] of statuses) {
2361
+ await this.callTest('permissionSetAppPermission', appId, kind, { status })
2362
+ }
2363
+ }
2364
+
2052
2365
  private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
2053
2366
  try {
2054
2367
  await this.evaluate(
@@ -2065,6 +2378,26 @@ export class SootSimBridgeFlowRunner {
2065
2378
  }
2066
2379
  }
2067
2380
 
2381
+ // maestro waits after every input for the screen to stop changing, up to
2382
+ // its ios driver's 3 s settle budget, comparing screenshots, so an action
2383
+ // still in flight (a submit spinner, a sheet closing) finishes before the
2384
+ // next command resolves its target. an app can take a few hundred ms to
2385
+ // react to a press, so stillness must hold about half a second, and it
2386
+ // counts painted frames because a spinner repaints without moving layout.
2387
+ // a screen that never stops proceeds when the budget runs out, as there.
2388
+ private async settleAfterInput() {
2389
+ await waitForSootsimIdle({
2390
+ bridge: this.bridge,
2391
+ simId: this.opts.simId,
2392
+ maxMs: 3000,
2393
+ pollMs: 32,
2394
+ stablePolls: 16,
2395
+ strict: true,
2396
+ paintStill: true,
2397
+ })
2398
+ this.lastInteractionAt = Date.now()
2399
+ }
2400
+
2068
2401
  private async waitForVisualSettle() {
2069
2402
  const result = await waitForSootsimIdle({
2070
2403
  bridge: this.bridge,
@@ -2175,7 +2508,9 @@ export class SootSimBridgeFlowRunner {
2175
2508
  // sandbox. log loudly so the author notices, then continue — matching the
2176
2509
  // "loud warning, not silent skip" policy in the plan.
2177
2510
  clearKeychain() {
2178
- console.warn('[flow] clearKeychain: peach has no keychain surface — no-op (warning)')
2511
+ console.warn(
2512
+ '[flow] clearKeychain: peach has no keychain surface — no-op (warning)',
2513
+ )
2179
2514
  }
2180
2515
 
2181
2516
  // maestro copyTextFrom: find an element and capture its text for use in
@@ -2251,27 +2586,29 @@ export class SootSimBridgeFlowRunner {
2251
2586
  // `visible:` and `notVisible:` answer the same question assertVisible does,
2252
2587
  // so they read the engine's visibleFrame too. resolving them by mere presence
2253
2588
  // in the tree let a guarded step fire on a node painted over by an overlay.
2589
+ // every key present must hold, in maestro's evaluateCondition order.
2254
2590
  private async evaluateWhen(when: NonNullable<MaestroStep['when']>): Promise<boolean> {
2591
+ if (
2592
+ when.platform !== undefined &&
2593
+ // peach emulates iOS — match against the context's platform the way
2594
+ // upstream compares against cachedDeviceInfo.platform. keeps
2595
+ // cross-platform maestro flows running without stripping guards.
2596
+ when.platform.toLowerCase() !== this.js.maestro.platform.toLowerCase()
2597
+ ) {
2598
+ return false
2599
+ }
2600
+ // the `true:` script string was template-evaluated with the step;
2601
+ // apply upstream's falsy rules (blank/false/undefined/null/0).
2602
+ if (when.true !== undefined && !scriptConditionIsTruthy(when.true)) return false
2255
2603
  if (when.visible !== undefined) {
2256
2604
  const opts =
2257
2605
  typeof when.visible === 'string' ? { text: when.visible } : when.visible
2258
- return this.isElementVisible(opts)
2606
+ if (!(await this.isElementVisible(opts))) return false
2259
2607
  }
2260
2608
  if (when.notVisible !== undefined) {
2261
2609
  const opts =
2262
2610
  typeof when.notVisible === 'string' ? { text: when.notVisible } : when.notVisible
2263
- return !(await this.isElementVisible(opts))
2264
- }
2265
- if (when.platform !== undefined) {
2266
- // peach emulates iOS — match against the context's platform the way
2267
- // upstream compares against cachedDeviceInfo.platform. keeps
2268
- // cross-platform maestro flows running without stripping guards.
2269
- return when.platform.toLowerCase() === this.js.maestro.platform.toLowerCase()
2270
- }
2271
- if (when.true !== undefined) {
2272
- // the `true:` script string was template-evaluated with the step;
2273
- // apply upstream's falsy rules (blank/false/undefined/null/0).
2274
- return scriptConditionIsTruthy(when.true)
2611
+ if (await this.isElementVisible(opts)) return false
2275
2612
  }
2276
2613
  return true
2277
2614
  }
@@ -2386,7 +2723,12 @@ export class SootSimBridgeFlowRunner {
2386
2723
  // already fails with its own reason. sharing the watchdog's 10s budget
2387
2724
  // meant the watchdog always won that race, reporting "bridge probably
2388
2725
  // hung" for a screen that was merely still animating.
2389
- step.takeScreenshot
2726
+ step.takeScreenshot ||
2727
+ // assertions wait out maestro's lookup window, longer than 10s, and
2728
+ // every query they make times out on its own, so they report their
2729
+ // own failure instead of the watchdog's guess.
2730
+ step.assertVisible ||
2731
+ step.assertNotVisible
2390
2732
  )
2391
2733
  const STEP_WATCHDOG_MS = 10_000
2392
2734
  const body = this.runStepInner(step)
@@ -2470,7 +2812,14 @@ export class SootSimBridgeFlowRunner {
2470
2812
 
2471
2813
  private async runStepInner(step: MaestroStep) {
2472
2814
  if (step.tapOn) await this.tapOn(step.tapOn)
2473
- else if (step.longPressOn) await this.longPressOn(step.longPressOn)
2815
+ else if (step.doubleTapOn) {
2816
+ // maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
2817
+ const target =
2818
+ typeof step.doubleTapOn === 'string'
2819
+ ? { text: step.doubleTapOn }
2820
+ : step.doubleTapOn
2821
+ await this.tapOn({ ...target, repeat: 2 })
2822
+ } else if (step.longPressOn) await this.longPressOn(step.longPressOn)
2474
2823
  else if (step.scrollUntilVisible)
2475
2824
  await this.scrollUntilVisible(step.scrollUntilVisible)
2476
2825
  else if (step.extendedWaitUntil) await this.extendedWaitUntil(step.extendedWaitUntil)
@@ -2599,6 +2948,22 @@ export class SootSimBridgeFlowRunner {
2599
2948
  this.clearKeychain()
2600
2949
  } else if (step.copyTextFrom !== undefined) {
2601
2950
  await this.copyTextFrom(step.copyTextFrom)
2951
+ } else if (step.assertTrue !== undefined) {
2952
+ // maestro assertConditionCommand on an already-interpolated scriptCondition
2953
+ const assertTrue =
2954
+ typeof step.assertTrue === 'object'
2955
+ ? step.assertTrue
2956
+ : { condition: step.assertTrue }
2957
+ if (!scriptConditionIsTruthy(assertTrue.condition)) {
2958
+ throw new Error(
2959
+ `Assertion is false: ${assertTrue.label ?? `${assertTrue.condition} is true`}`,
2960
+ )
2961
+ }
2962
+ } else if (step.setClipboard !== undefined) {
2963
+ this.js.setCopiedText(step.setClipboard)
2964
+ } else if (step.pasteText) {
2965
+ const copied = this.js.maestro.copiedText
2966
+ if (copied !== null) await this.inputText(copied)
2602
2967
  } else if (typeof step.evalScript === 'string') {
2603
2968
  this.evalScript(step.evalScript)
2604
2969
  } else if (step.runScript) {
@@ -2718,6 +3083,7 @@ export class SootSimBridgeFlowRunner {
2718
3083
 
2719
3084
  async runFlow(steps: MaestroStep[], options: { skipPlan?: boolean } = {}) {
2720
3085
  const { onStart, onComplete, body } = this.extractFlowBody(steps)
3086
+ this.lastInteractionAt = Date.now()
2721
3087
 
2722
3088
  if (!options.skipPlan && this.liveStatus) {
2723
3089
  await this.planFlow(steps)