peachsim 0.1.1477 → 0.1.1594

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 (212) hide show
  1. package/README.md +26 -6
  2. package/cli/app-config.ts +43 -8
  3. package/cli/app-fonts.ts +13 -17
  4. package/cli/app-stubs.ts +302 -0
  5. package/cli/bin.ts +6 -2
  6. package/cli/bridge-flow-runner.ts +195 -40
  7. package/cli/cloud-test-tunnel.ts +2 -0
  8. package/cli/command-registry.ts +2 -1
  9. package/cli/commands/box/bundle-plane.ts +4 -0
  10. package/cli/commands/box.ts +3 -20
  11. package/cli/commands/control.ts +155 -58
  12. package/cli/commands/debug.ts +2 -2
  13. package/cli/commands/detox.ts +15 -10
  14. package/cli/commands/do-chain.ts +2 -2
  15. package/cli/commands/doctor.ts +967 -0
  16. package/cli/commands/flow.ts +435 -405
  17. package/cli/commands/inspect/core.ts +45 -19
  18. package/cli/commands/inspect/env.ts +10 -0
  19. package/cli/commands/inspect/list.ts +16 -5
  20. package/cli/commands/inspect/settling.ts +69 -30
  21. package/cli/commands/inspect.ts +32 -12
  22. package/cli/commands/install-desktop.ts +3 -2
  23. package/cli/commands/kotlin.ts +77 -16
  24. package/cli/commands/login.ts +1 -1
  25. package/cli/commands/maestro-compare.ts +16 -3
  26. package/cli/commands/maestro-generate.ts +11 -7
  27. package/cli/commands/maestro.ts +43 -15
  28. package/cli/commands/no-bridge-hint.ts +26 -3
  29. package/cli/commands/platform.ts +16 -6
  30. package/cli/commands/record.ts +9 -1
  31. package/cli/commands/report-issue.ts +2 -1
  32. package/cli/commands/serve.ts +30 -2
  33. package/cli/commands/setup.ts +35 -15
  34. package/cli/commands/swift.ts +4 -0
  35. package/cli/commands/test.ts +2 -1
  36. package/cli/commands/upload.ts +91 -18
  37. package/cli/current-sim.ts +1 -2
  38. package/cli/dev-build-freshness.ts +14 -14
  39. package/cli/device-option.ts +8 -0
  40. package/cli/drivers/desktop.ts +1 -1
  41. package/cli/drivers/playwright-sim-host.ts +30 -11
  42. package/cli/drivers/playwright.ts +1 -4
  43. package/cli/flow-export.ts +2 -2
  44. package/cli/flow-file.ts +18 -18
  45. package/cli/internal-child.ts +7 -6
  46. package/cli/maestro-compare.ts +9 -3
  47. package/cli/maestro-js.ts +2 -1
  48. package/cli/main.ts +3 -3
  49. package/cli/outbound-endpoints.ts +178 -40
  50. package/cli/parse-args.ts +7 -0
  51. package/cli/preview-capture.ts +12 -0
  52. package/cli/privacy.ts +1 -1
  53. package/cli/runtime-compat.ts +16 -0
  54. package/cli/ui.ts +5 -0
  55. package/cli/ws-bridge.ts +107 -44
  56. package/detox/attempt-events.cjs +706 -0
  57. package/detox/attempt-events.d.cts +99 -0
  58. package/detox/index.ts +519 -72
  59. package/detox/matchers.ts +17 -6
  60. package/detox/motion-change.cjs +251 -0
  61. package/detox/motion-change.d.cts +73 -0
  62. package/dist-lib/agent-daemon-client.cjs +1 -1
  63. package/dist-lib/agent-events.cjs +1 -1
  64. package/dist-lib/attached-projects.cjs +1 -1
  65. package/dist-lib/auth/shared-session.cjs +1 -1
  66. package/dist-lib/backend-origin.cjs +1 -1
  67. package/dist-lib/beta.cjs +1 -1
  68. package/dist-lib/beta.mjs +1 -1
  69. package/dist-lib/bridge-constants.cjs +3 -1
  70. package/dist-lib/bridge-contract-input.cjs +1 -1
  71. package/dist-lib/bridge-contract-input.mjs +1 -1
  72. package/dist-lib/bridge-contract.cjs +1 -1
  73. package/dist-lib/bridge-contract.mjs +1 -1
  74. package/dist-lib/bundle-channel.cjs +2058 -493
  75. package/dist-lib/capture-contract.cjs +1 -1
  76. package/dist-lib/capture-contract.mjs +1 -1
  77. package/dist-lib/cloud-contract.cjs +1 -1
  78. package/dist-lib/cloud-contract.mjs +1 -1
  79. package/dist-lib/cloud-test-args.cjs +1 -1
  80. package/dist-lib/cloud-test-args.mjs +1 -1
  81. package/dist-lib/cloud.cjs +1 -1
  82. package/dist-lib/cloud.mjs +1 -1
  83. package/dist-lib/config.cjs +32 -4
  84. package/dist-lib/config.mjs +31 -4
  85. package/dist-lib/detox/index.cjs +1702 -125
  86. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  87. package/dist-lib/expo-swift-bundle.cjs +1949 -457
  88. package/dist-lib/expo-swift-metadata.cjs +37 -17
  89. package/dist-lib/expo-swift-metadata.mjs +37 -17
  90. package/dist-lib/home-paths.cjs +1 -1
  91. package/dist-lib/host/bridge-host.cjs +10719 -8595
  92. package/dist-lib/host/fetch-proxy-handler.cjs +122 -97
  93. package/dist-lib/host/fetch-proxy-overrides.cjs +23 -3
  94. package/dist-lib/host/fetch-proxy-overrides.mjs +19 -2
  95. package/dist-lib/host/replacement-module-handler.cjs +19 -18
  96. package/dist-lib/host/websocket-proxy.cjs +228 -7
  97. package/dist-lib/index.cjs +5577 -4019
  98. package/dist-lib/jump-to-source-babel.cjs +73 -35
  99. package/dist-lib/jump-to-source-native.cjs +86 -7
  100. package/dist-lib/kotlin-host.cjs +3 -1
  101. package/dist-lib/menu.cjs +1 -1
  102. package/dist-lib/menu.mjs +1 -1
  103. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  104. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  105. package/dist-lib/metro-production-bundle.cjs +74 -102
  106. package/dist-lib/metro-production-bundle.mjs +62 -102
  107. package/dist-lib/metro.cjs +4066 -375
  108. package/dist-lib/native-app-identity-project.cjs +232 -118
  109. package/dist-lib/native-app-identity-project.mjs +231 -118
  110. package/dist-lib/native-app-identity.cjs +322 -37
  111. package/dist-lib/native-app-identity.mjs +319 -36
  112. package/dist-lib/native-firebase-metadata.cjs +22 -7
  113. package/dist-lib/native-firebase-metadata.mjs +20 -7
  114. package/dist-lib/plist.cjs +1 -1
  115. package/dist-lib/plist.mjs +1 -1
  116. package/dist-lib/profiles.cjs +1 -1
  117. package/dist-lib/public-brand.cjs +1 -1
  118. package/dist-lib/react-native-host-modules.cjs +13 -1
  119. package/dist-lib/react-native-host-modules.mjs +13 -1
  120. package/dist-lib/scripts/dev-server-scanner.cjs +501 -312
  121. package/dist-lib/sdk.cjs +103 -49
  122. package/dist-lib/sdk.mjs +103 -49
  123. package/dist-lib/skills.cjs +13048 -9763
  124. package/dist-lib/swift-compile.cjs +1851 -437
  125. package/dist-lib/swift-compile.mjs +1860 -445
  126. package/dist-lib/swift-package.cjs +1 -1
  127. package/dist-lib/swift-package.mjs +1 -1
  128. package/dist-lib/swift-submit.cjs +2 -2
  129. package/dist-lib/swift-submit.mjs +2 -2
  130. package/dist-lib/swift-toolchain.cjs +1 -1
  131. package/dist-lib/swift.cjs +1 -1
  132. package/dist-lib/vite-swift.cjs +1938 -462
  133. package/dist-lib/vite.cjs +121 -52
  134. package/dist-lib/worklet-executable.cjs +44 -10
  135. package/dist-lib/worklet-executable.mjs +44 -10
  136. package/install.sh +7 -2
  137. package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +2 -0
  138. package/kotlin/AndroidViewSources/android/graphics/Paint.kt +18 -0
  139. package/kotlin/AndroidViewSources/dev/peach/app/JavaFormat.kt +16 -8
  140. package/kotlin/ComposeSources/PeachComposeHost.kt +31 -12
  141. package/kotlin/ComposeSources/PeachMagnifier.kt +185 -0
  142. package/kotlin/ExpoComposeSources/PeachAndroidView.kt +16 -0
  143. package/kotlin/Sources/java/util/IllegalFormatCodePointException.kt +10 -0
  144. package/kotlin/Sources/java/util/IllegalFormatException.kt +5 -0
  145. package/kotlin/fixtures/counter/CounterApp.native.tsx +30 -3
  146. package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +1 -0
  147. package/kotlin/fixtures/counter/CounterScreen.kt +2 -1
  148. package/kotlin/fixtures/counter/package.json +15 -2
  149. package/kotlin/fixtures/counter/vite.config.ts +11 -1
  150. package/kotlin/fork/NOTICE +1 -0
  151. package/kotlin/fork/README.md +5 -4
  152. package/kotlin/fork/compose-worker-globals.patch +629 -0
  153. package/kotlin/fork/fork.json +1 -1
  154. package/kotlin/toolchain.json +3 -3
  155. package/package.json +1 -1
  156. package/scripts/dev-server-scanner.ts +629 -229
  157. package/skills/contrast/SKILL.md +3 -3
  158. package/skills/peach-debug/SKILL.md +1 -1
  159. package/skills/peach-setup/SKILL.md +22 -12
  160. package/skills/peach-test/SKILL.md +2 -3
  161. package/src/agent-identity.ts +18 -0
  162. package/src/bridge-constants.ts +1 -1
  163. package/src/bundle-channel.mjs +37 -6
  164. package/src/bundleGlobalFooter.ts +90 -17
  165. package/src/config.ts +41 -2
  166. package/src/configured-bundle-producer.ts +6 -2
  167. package/src/desktop-host-delivery.ts +4 -3
  168. package/src/expo-swift-host.ts +33 -7
  169. package/src/host/bridge-host.ts +218 -75
  170. package/src/host/fetch-proxy-handler.ts +145 -117
  171. package/src/host/fetch-proxy-overrides.ts +27 -0
  172. package/src/host/fetch-proxy-transport.ts +77 -63
  173. package/src/host/lease-owner.test.ts +18 -1
  174. package/src/host/lease-owner.ts +4 -5
  175. package/src/host/loopback-ports.ts +34 -0
  176. package/src/host/replacement-module-handler.ts +26 -18
  177. package/src/host/server-scan-handler.ts +87 -0
  178. package/src/host/websocket-proxy.ts +251 -6
  179. package/src/installed-package-versions.ts +65 -0
  180. package/src/jump-to-source-babel.ts +97 -89
  181. package/src/jump-to-source-native.ts +107 -9
  182. package/src/kotlin-host.ts +1 -1
  183. package/src/kotlinDiscoveryContract.ts +27 -0
  184. package/src/metro-plugin.ts +129 -39
  185. package/src/metro-production-bundle.ts +100 -141
  186. package/src/native-app-identity-project.ts +38 -2
  187. package/src/native-app-identity.ts +145 -13
  188. package/src/native-firebase-config.ts +82 -80
  189. package/src/native-firebase-metadata.ts +5 -3
  190. package/src/nativeAppIconContract.ts +132 -0
  191. package/src/nitro-bundle.ts +43 -0
  192. package/src/nitro-compile.ts +107 -0
  193. package/src/nitro-source.ts +314 -0
  194. package/src/nitroContract.ts +51 -0
  195. package/src/oneWidgetContract.ts +14 -3
  196. package/src/packageVersionsContract.ts +52 -0
  197. package/src/react-devtools-frontend-react.ts +45 -0
  198. package/src/react-native-host-modules.ts +12 -0
  199. package/src/skills/builtin/maestro-test.ts +2 -4
  200. package/src/swift-compile.ts +2084 -331
  201. package/src/swift-distribution-contract.ts +11 -0
  202. package/src/swift-distribution.lock.json +45 -11
  203. package/src/swift-host.ts +13 -7
  204. package/src/swift-icu.ts +10 -2
  205. package/src/swift-submit.ts +2 -1
  206. package/src/vite-plugin-one.ts +9 -3
  207. package/src/vite-plugin.ts +63 -29
  208. package/src/worker-system-chunks.ts +55 -51
  209. package/src/worklet-executable.ts +59 -9
  210. package/src/xcode-project.ts +47 -125
  211. package/src/xcode-target-members.ts +107 -0
  212. package/cli/commands/compat.ts +0 -395
@@ -1,5 +1,10 @@
1
1
  import * as fs from 'fs'
2
2
  import * as path from 'path'
3
+ import {
4
+ isStorageSnapshot,
5
+ prepareStorageSnapshotForShare,
6
+ type ShareableStorageSnapshot,
7
+ } from 'peach-engine/preview/storage-snapshot-contract'
3
8
  import { devices, type DeviceModel } from 'peach-engine/settings/devices'
4
9
  import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
5
10
  import { flowTimeoutScale } from '../src/flow-timeout-scale'
@@ -128,21 +133,27 @@ function assertSupportedSelector(selector: unknown) {
128
133
  }
129
134
  }
130
135
 
131
- // the filters whose meaning is defined on the XCTest accessibility hierarchy.
132
- // ordinary lookups stay on the faster render tree; these read the engine's
133
- // accessibility projection through captureAccessibilityTree().
136
+ // these filters require the XCTest hierarchy on every lookup. text selectors
137
+ // also use that hierarchy on ios, including text in a nested anchor.
134
138
  const ACCESSIBILITY_SELECTOR_KEYS = [
135
139
  'containsChild',
136
140
  'containsDescendants',
137
141
  'focused',
138
142
  ] as const
139
143
 
140
- function needsAccessibilityProjection(selector: string | FlowTargetSelector): boolean {
141
- if (typeof selector === 'string') return false
144
+ function needsAccessibilityProjection(
145
+ selector: string | FlowTargetSelector,
146
+ includeText = false,
147
+ ): boolean {
148
+ if (typeof selector === 'string') return includeText
149
+ if (includeText && selector.text != null) return true
142
150
  if (ACCESSIBILITY_SELECTOR_KEYS.some((key) => selector[key] != null)) return true
143
151
  return ['childOf', 'below', 'above', 'leftOf', 'rightOf'].some((key) => {
144
152
  const nested: unknown = Reflect.get(selector, key)
145
- return isSelectorRecord(nested) && needsAccessibilityProjection(nested)
153
+ return (
154
+ (typeof nested === 'string' || isSelectorRecord(nested)) &&
155
+ needsAccessibilityProjection(nested, includeText)
156
+ )
146
157
  })
147
158
  }
148
159
 
@@ -719,6 +730,12 @@ export interface MaestroStep {
719
730
  copyTextFrom?: string | FlowTargetSelector
720
731
  evalScript?: string
721
732
  openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean }
733
+ addMedia?:
734
+ | string
735
+ | string[]
736
+ | {
737
+ files?: string | string[]
738
+ }
722
739
  // maestro `when:` conditional runs the step only if the predicate holds.
723
740
  // supports `visible` / `notVisible` with the same matcher shape used elsewhere.
724
741
  when?: {
@@ -936,6 +953,7 @@ export class SootSimBridgeFlowRunner {
936
953
  private lastRecordingStartedAtMs: number | null = null
937
954
  private lastRecordingDurationMs: number | null = null
938
955
  private lastRecordingFrameStats: unknown = null
956
+ private previewStorageSnapshot: ShareableStorageSnapshot | null = null
939
957
  private flowTraceSteps: SootSimFlowTraceStep[] = []
940
958
  // when the latest step that acted on the app settled
941
959
  private lastInteractionAt = Date.now()
@@ -975,6 +993,9 @@ export class SootSimBridgeFlowRunner {
975
993
  * verifies entitlement before producing a shareable cloud artifact. */
976
994
  requireRecordingEntitlement?: boolean
977
995
  recordingFormat?: 'webm' | 'mp4'
996
+ /** a preview upload replays live from the app's storage as it was when
997
+ * the video began, so the driver captures it right before recording. */
998
+ capturePreviewStorage?: boolean
978
999
  // fires after every page reload (launchApp clear-state) once the
979
1000
  // tree is back and simId has been rotated. lets preview mode
980
1001
  // re-start the event recorder, since `window.location.reload()`
@@ -1101,6 +1122,19 @@ export class SootSimBridgeFlowRunner {
1101
1122
  this.recordingAccessChecked = true
1102
1123
  }
1103
1124
 
1125
+ if (this.opts.capturePreviewStorage && this.recordingStartedAtMs == null) {
1126
+ const dumped: unknown = await this.bridge.send({
1127
+ type: 'storageSnapshot',
1128
+ simId: this.opts.simId,
1129
+ })
1130
+ if (!isStorageSnapshot(dumped)) {
1131
+ throw new Error(
1132
+ 'the simulator returned a storage snapshot this build cannot read',
1133
+ )
1134
+ }
1135
+ this.previewStorageSnapshot = prepareStorageSnapshotForShare(dumped)
1136
+ }
1137
+
1104
1138
  const startedAt = Date.now()
1105
1139
  const result = await this.evaluate<{
1106
1140
  ok: boolean
@@ -1112,7 +1146,7 @@ export class SootSimBridgeFlowRunner {
1112
1146
  if (!rec) return { ok: false, error: 'recorder unavailable on this page' }
1113
1147
  const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')}
1114
1148
  if (rec.state() === 'recording') return { ok: true, format }
1115
- return rec.start({ format, fps: 24 })
1149
+ return rec.start({ format, fps: 30 })
1116
1150
  })()`)
1117
1151
 
1118
1152
  if (!result?.ok) {
@@ -1147,6 +1181,10 @@ export class SootSimBridgeFlowRunner {
1147
1181
  return this.lastRecordingFrameStats
1148
1182
  }
1149
1183
 
1184
+ getPreviewStorageSnapshot(): ShareableStorageSnapshot | null {
1185
+ return this.previewStorageSnapshot
1186
+ }
1187
+
1150
1188
  getFlowTraceSteps(): SootSimFlowTraceStep[] {
1151
1189
  return [...this.flowTraceSteps]
1152
1190
  }
@@ -1162,9 +1200,12 @@ export class SootSimBridgeFlowRunner {
1162
1200
  const value = (step as Record<string, unknown>)[stepName]
1163
1201
  if (typeof value === 'string') return value.slice(0, 80)
1164
1202
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
1203
+ if (Array.isArray(value)) return value.map(String).join(', ').slice(0, 80)
1165
1204
  if (!value || typeof value !== 'object') return undefined
1166
1205
  const record = value as Record<string, unknown>
1167
- const direct = record.id ?? record.text ?? record.name ?? record.path
1206
+ if (Array.isArray(record.files))
1207
+ return record.files.map(String).join(', ').slice(0, 80)
1208
+ const direct = record.id ?? record.text ?? record.name ?? record.path ?? record.files
1168
1209
  if (typeof direct === 'string' && direct) return direct.slice(0, 80)
1169
1210
  // extendedWaitUntil keeps its target under visible/notVisible and
1170
1211
  // scrollUntilVisible under element, so without this they render bare.
@@ -1213,13 +1254,19 @@ export class SootSimBridgeFlowRunner {
1213
1254
  async stopRecording(): Promise<string | null> {
1214
1255
  if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null
1215
1256
  this.recordingEnabled = false
1257
+ if (this.recordingStartedAtMs == null) {
1258
+ this.lastRecordingStartedAtMs = null
1259
+ this.lastRecordingDurationMs = null
1260
+ this.lastRecordingFrameStats = null
1261
+ return null
1262
+ }
1216
1263
 
1217
- const stoppedAt = Date.now()
1218
1264
  const stopped = await this.evaluate<{
1219
1265
  ok: boolean
1220
1266
  error?: string
1221
1267
  size?: number
1222
1268
  mime?: string
1269
+ durationMs: number
1223
1270
  frameStats?: unknown
1224
1271
  }>(`window.__sootsimRecorder.stop()`)
1225
1272
  if (!stopped?.ok) {
@@ -1227,10 +1274,11 @@ export class SootSimBridgeFlowRunner {
1227
1274
  this.lastRecordingFrameStats = null
1228
1275
  throw new Error(stopped?.error || 'recording stop failed')
1229
1276
  }
1277
+ if (!Number.isFinite(stopped.durationMs) || stopped.durationMs <= 0) {
1278
+ throw new Error('recording stop returned no playable duration')
1279
+ }
1230
1280
  this.lastRecordingStartedAtMs = this.recordingStartedAtMs
1231
- this.lastRecordingDurationMs = this.recordingStartedAtMs
1232
- ? Math.max(1, stoppedAt - this.recordingStartedAtMs)
1233
- : null
1281
+ this.lastRecordingDurationMs = stopped.durationMs
1234
1282
  this.lastRecordingFrameStats = stopped.frameStats ?? null
1235
1283
  this.recordingStartedAtMs = null
1236
1284
 
@@ -1297,23 +1345,13 @@ export class SootSimBridgeFlowRunner {
1297
1345
 
1298
1346
  private async findElement(opts: FlowTargetSelector) {
1299
1347
  assertSupportedSelector(opts)
1300
- // the structural filters are defined on the XCTest accessibility
1301
- // hierarchy, so they resolve against the engine's accessibility
1302
- // projection instead of the render tree.
1303
- if (needsAccessibilityProjection(opts)) {
1348
+ if (needsAccessibilityProjection(opts, true)) {
1304
1349
  return this.findElementAccessibilityTree(opts)
1305
1350
  }
1306
1351
  return this.findElementScoped(opts)
1307
1352
  }
1308
1353
 
1309
- // structural filters (containsChild / containsDescendants / focused) read the
1310
- // engine's XCTest accessibility projection — one bridge evaluate, then the
1311
- // same Orchestra.buildFilter semantics the render resolver implements, over
1312
- // PeachAccessibilityNode instead of the visible semantic tree.
1313
- private async resolveAccessibilityElement(
1314
- opts: FlowTargetSelector,
1315
- command: string,
1316
- ): Promise<PeachAccessibilityNode | null> {
1354
+ private async captureAccessibilityTree(): Promise<PeachAccessibilityNode[]> {
1317
1355
  const tree = await this.evaluate(`(async () => {
1318
1356
  const test = window.__sootsimTest
1319
1357
  if (!test || typeof test.captureAccessibilityTree !== 'function') throw new Error('engine accessibility snapshot is unavailable')
@@ -1321,6 +1359,16 @@ export class SootSimBridgeFlowRunner {
1321
1359
  })()`)
1322
1360
  if (!isPeachAccessibilityTree(tree))
1323
1361
  throw new Error('engine returned an invalid accessibility snapshot')
1362
+ return tree
1363
+ }
1364
+
1365
+ // one Orchestra resolver for text, structural filters and copyTextFrom.
1366
+ private async resolveAccessibilityElement(
1367
+ opts: FlowTargetSelector,
1368
+ command: string,
1369
+ snapshot?: PeachAccessibilityNode[],
1370
+ ): Promise<PeachAccessibilityNode | null> {
1371
+ const tree = snapshot ?? (await this.captureAccessibilityTree())
1324
1372
  if (tree.length === 0) return null
1325
1373
  if (tree.some((node) => node.platform !== 'ios'))
1326
1374
  throw new Error(`${command} requires the iOS accessibility hierarchy`)
@@ -1328,9 +1376,19 @@ export class SootSimBridgeFlowRunner {
1328
1376
  }
1329
1377
 
1330
1378
  private async findElementAccessibilityTree(opts: FlowTargetSelector) {
1379
+ const tree = await this.captureAccessibilityTree()
1380
+ // the snapshot's platform is the live device contract. android keeps its
1381
+ // existing render-tree text lookup; XCTest-only filters still refuse it.
1382
+ if (
1383
+ !needsAccessibilityProjection(opts) &&
1384
+ tree.some((node) => node.platform === 'android')
1385
+ ) {
1386
+ return this.findElementScoped(opts)
1387
+ }
1331
1388
  const node = await this.resolveAccessibilityElement(
1332
1389
  opts,
1333
- 'containsChild, containsDescendants and focused',
1390
+ 'text, containsChild, containsDescendants and focused',
1391
+ tree,
1334
1392
  )
1335
1393
  if (!node) return null
1336
1394
  const frame = node.frame
@@ -1382,6 +1440,14 @@ export class SootSimBridgeFlowRunner {
1382
1440
  }
1383
1441
  }
1384
1442
  const painted = (n) => (n.visibleFrame?.area ?? 0) > 0
1443
+ // one viewport contract, shared with isOffscreen in
1444
+ // commands/inspect/resolve-target.ts: a center on the edge still takes
1445
+ // a tap, so the edge counts as on screen here too.
1446
+ const centerOnScreen = (n) => {
1447
+ const cx = n.absolutePosition.x + n.layout.width / 2
1448
+ const cy = n.absolutePosition.y + n.layout.height / 2
1449
+ return cx >= 0 && cx <= ${screen.width} && cy >= 0 && cy <= ${screen.height}
1450
+ }
1385
1451
  const byPosition = (a, b) =>
1386
1452
  a.absolutePosition.y - b.absolutePosition.y ||
1387
1453
  a.absolutePosition.x - b.absolutePosition.x
@@ -1444,17 +1510,10 @@ export class SootSimBridgeFlowRunner {
1444
1510
  const anchors = pick(matchAll(sel[key]), sel[key].index)
1445
1511
  candidates = candidates.filter((n) => anchors.some((other) => relative[key](n, other)))
1446
1512
  }
1447
- // prefer painted matches, then on-screen ones (findByTestId's
1448
- // visible-first behavior); only fall back to off-screen ones when
1449
- // nothing is on-screen, so index: still resolves a target that needs
1450
- // scrolling into view. a screen still mounted under the one on top
1451
- // paints nothing, and maestro's hierarchy never holds it.
1452
- const onScreen = candidates.filter((n) => {
1453
- const cy = n.absolutePosition.y + n.layout.height / 2
1454
- return cy > 0 && cy < ${screen.height}
1455
- })
1456
- if (candidates.some(painted)) candidates = candidates.filter(painted)
1457
- else if (onScreen.length > 0) candidates = onScreen
1513
+ // scope before visibility: childOf narrows the match set first, so a
1514
+ // clipped sliver inside the container survives to resolve (and refuse
1515
+ // honestly) instead of being discarded for a hittable duplicate the
1516
+ // scope then removes.
1458
1517
  if (sel.childOf) {
1459
1518
  const containers = matchAll(sel.childOf)
1460
1519
  const container = containers.find(painted) ?? containers[0]
@@ -1469,6 +1528,26 @@ export class SootSimBridgeFlowRunner {
1469
1528
  return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
1470
1529
  })
1471
1530
  }
1531
+ // prefer painted matches, then on-screen ones (findByTestId's
1532
+ // visible-first behavior); only fall back to off-screen ones when
1533
+ // nothing is on-screen, so index: still resolves a target that needs
1534
+ // scrolling into view. a screen still mounted under the one on top
1535
+ // paints nothing, and maestro's hierarchy never holds it.
1536
+ const onScreen = candidates.filter(centerOnScreen)
1537
+ // without an explicit index, prefer a painted match whose center is
1538
+ // on screen: a painted sliver with its center off screen cannot take
1539
+ // a tap at its center, so a duplicate id resolves to the hittable
1540
+ // match. an explicit index keeps the full painted order, and a
1541
+ // clipped-only match still resolves so the tap reports honestly.
1542
+ const paintedCandidates = candidates.filter(painted)
1543
+ if (paintedCandidates.length > 0) {
1544
+ if (sel.index == null) {
1545
+ const hittable = paintedCandidates.filter(centerOnScreen)
1546
+ candidates = hittable.length > 0 ? hittable : paintedCandidates
1547
+ } else {
1548
+ candidates = paintedCandidates
1549
+ }
1550
+ } else if (onScreen.length > 0) candidates = onScreen
1472
1551
  // maestro's index: sorts matches by frame y, then x, and without an
1473
1552
  // index takes the first in the ios hierarchy, which lays siblings out
1474
1553
  // by position rather than subview order (RAN on ios 27: two views with
@@ -2456,10 +2535,14 @@ export class SootSimBridgeFlowRunner {
2456
2535
  }
2457
2536
 
2458
2537
  async back() {
2459
- try {
2460
- await this.tapOn('‹')
2461
- } catch {
2462
- await this.tapOn('<')
2538
+ const dispatched = await this.bridge.send({
2539
+ type: 'call',
2540
+ simId: this.opts.simId,
2541
+ path: 'SootSim.bridges.mainShell.goBack',
2542
+ args: [],
2543
+ })
2544
+ if (dispatched !== true) {
2545
+ throw new Error('back failed: shell back action was not dispatched')
2463
2546
  }
2464
2547
  }
2465
2548
 
@@ -2797,6 +2880,7 @@ export class SootSimBridgeFlowRunner {
2797
2880
  pollMs: 32,
2798
2881
  stablePolls: 3,
2799
2882
  strict: true,
2883
+ paintStill: true,
2800
2884
  })
2801
2885
  if (!result.settled) {
2802
2886
  throw new Error(
@@ -2968,6 +3052,60 @@ export class SootSimBridgeFlowRunner {
2968
3052
  this.firstLaunchDone = true
2969
3053
  }
2970
3054
 
3055
+ async addMedia(target: NonNullable<MaestroStep['addMedia']>) {
3056
+ const rawFiles =
3057
+ typeof target === 'string'
3058
+ ? [target]
3059
+ : Array.isArray(target)
3060
+ ? target
3061
+ : typeof target.files === 'string'
3062
+ ? [target.files]
3063
+ : Array.isArray(target.files)
3064
+ ? target.files
3065
+ : []
3066
+ if (rawFiles.length === 0) {
3067
+ throw new Error('addMedia requires at least one file path')
3068
+ }
3069
+ for (const fileSpec of rawFiles) {
3070
+ const filePath = path.resolve(this.opts.flowDir, fileSpec)
3071
+ const stat = fs.statSync(filePath)
3072
+ const bytes = fs.readFileSync(filePath)
3073
+ const ext = path.extname(filePath).slice(1).toLowerCase()
3074
+ const mimeType =
3075
+ ext === 'png'
3076
+ ? 'image/png'
3077
+ : ext === 'webp'
3078
+ ? 'image/webp'
3079
+ : ext === 'gif'
3080
+ ? 'image/gif'
3081
+ : ext === 'svg'
3082
+ ? 'image/svg+xml'
3083
+ : ext === 'avif'
3084
+ ? 'image/avif'
3085
+ : ext === 'bmp'
3086
+ ? 'image/bmp'
3087
+ : ext === 'ico'
3088
+ ? 'image/x-icon'
3089
+ : 'image/jpeg'
3090
+ const dataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`
3091
+ const added = await this.evaluate<{ ok: boolean; error?: string }>(
3092
+ `(async () => {
3093
+ const add = window.__sootsimAddPhotoDataUrl
3094
+ if (typeof add !== 'function') {
3095
+ return { ok: false, error: 'photo library bridge unavailable' }
3096
+ }
3097
+ const entry = await add(${JSON.stringify(dataUrl)}, { createdAt: ${JSON.stringify(Math.round(stat.mtimeMs))} })
3098
+ return entry ? { ok: true } : { ok: false, error: 'failed to decode image' }
3099
+ })()`,
3100
+ )
3101
+ if (!added?.ok) {
3102
+ throw new Error(
3103
+ `addMedia(${fileSpec}) failed: ${added?.error || 'unknown error'}`,
3104
+ )
3105
+ }
3106
+ }
3107
+ }
3108
+
2971
3109
  // evaluate a `when:` predicate. returns true if the step should run.
2972
3110
  // `visible:` and `notVisible:` answer the same question assertVisible does,
2973
3111
  // so they read the engine's visibleFrame too. resolving them by mere presence
@@ -3088,6 +3226,18 @@ export class SootSimBridgeFlowRunner {
3088
3226
  }
3089
3227
 
3090
3228
  try {
3229
+ // containers defer to their executed children: a nested launch must finish
3230
+ // resetting the guest before the first playable frame is recorded.
3231
+ if (
3232
+ this.recordingEnabled &&
3233
+ this.recordingStartedAtMs == null &&
3234
+ !step.launchApp &&
3235
+ !step.runFlow &&
3236
+ !step.repeat &&
3237
+ !step.retry
3238
+ ) {
3239
+ await this.ensureRecordingStarted()
3240
+ }
3091
3241
  // simple commands have a wall-clock watchdog. bounded operations keep
3092
3242
  // their own lookup, settle and bridge deadlines, so the watchdog cannot
3093
3243
  // report a completed step while its action continues in the background.
@@ -3118,6 +3268,9 @@ export class SootSimBridgeFlowRunner {
3118
3268
  // meant the watchdog always won that race, reporting "bridge probably
3119
3269
  // hung" for a screen that was merely still animating.
3120
3270
  step.takeScreenshot ||
3271
+ // waitForAnimationToEnd ends at its own timeout, which a flow may set
3272
+ // past 10s, and continues as maestro does on a screen still moving.
3273
+ step.waitForAnimationToEnd ||
3121
3274
  // assertions wait out maestro's lookup window, longer than 10s, and
3122
3275
  // every query they make times out on its own, so they report their
3123
3276
  // own failure instead of the watchdog's guess.
@@ -3377,6 +3530,8 @@ export class SootSimBridgeFlowRunner {
3377
3530
  this.runScript(rs)
3378
3531
  } else if (step.openLink !== undefined) {
3379
3532
  await this.openLink(step.openLink)
3533
+ } else if (step.addMedia !== undefined) {
3534
+ await this.addMedia(step.addMedia)
3380
3535
  } else if (step.runFlow) {
3381
3536
  const rf = step.runFlow
3382
3537
  const file = typeof rf === 'string' ? rf : rf.file
@@ -137,6 +137,8 @@ export function startCloudTestTokenProxy(
137
137
  return
138
138
  }
139
139
  if (selectedPort !== upstreamPort) headers.host = `127.0.0.1:${selectedPort}`
140
+ // the recorder's browser origin belongs to its host, not this dev server.
141
+ if (headers.origin) headers.origin = `http://127.0.0.1:${selectedPort}`
140
142
  for (const key of Object.keys(headers)) {
141
143
  const lower = key.toLowerCase()
142
144
  if (lower.startsWith('x-forwarded-') || lower.startsWith('cf-')) {
@@ -50,7 +50,7 @@ const NODE_COMMAND_NAMES = [
50
50
  'use',
51
51
  'claim',
52
52
  'close',
53
- 'compat',
53
+ 'doctor',
54
54
  'project-report',
55
55
  'support',
56
56
  'desktop',
@@ -129,6 +129,7 @@ const VALUE_FLAGS = new Set([
129
129
  '--device',
130
130
  '--posture',
131
131
  '--os-version',
132
+ '--ios-target',
132
133
  '--output',
133
134
  '-o',
134
135
  '--out',
@@ -10,6 +10,7 @@
10
10
 
11
11
  import {
12
12
  PREBUILT_BUNDLE_DEVICE_PATH,
13
+ PREBUILT_BUNDLE_INITIAL_URL_PATH,
13
14
  PREBUILT_BUNDLE_NATIVE_RENDERER_PATH,
14
15
  prebuiltAppFontsPartPath,
15
16
  PREBUILT_BUNDLE_PART_BYTES,
@@ -25,6 +26,7 @@ export class BundleFilePlane implements ProjectFilePlane {
25
26
  constructor(
26
27
  bundle: Uint8Array,
27
28
  device: string | null,
29
+ initialUrl: string | null,
28
30
  appFonts = '',
29
31
  nativeRenderer?: NativeRenderer,
30
32
  ) {
@@ -32,6 +34,8 @@ export class BundleFilePlane implements ProjectFilePlane {
32
34
  if (appFonts)
33
35
  this.recordParts(new TextEncoder().encode(appFonts), prebuiltAppFontsPartPath)
34
36
  if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
37
+ if (initialUrl)
38
+ this.record(PREBUILT_BUNDLE_INITIAL_URL_PATH, new TextEncoder().encode(initialUrl))
35
39
  if (nativeRenderer !== undefined)
36
40
  this.record(
37
41
  PREBUILT_BUNDLE_NATIVE_RENDERER_PATH,
@@ -27,7 +27,6 @@ import {
27
27
  cloudBoxConnectUrl,
28
28
  createCloudBoxProvider,
29
29
  ensureCloudBoxBrowser,
30
- heartbeatSession,
31
30
  resolveCloudBoxReference,
32
31
  } from 'peach-cloud-box/client'
33
32
  import { getRuntimePlatform, isDeviceModel } from 'peach-engine/settings/devices'
@@ -361,7 +360,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
361
360
  const origin = apiOrigin(args, auth)
362
361
 
363
362
  let session: BoxShellSession | null = null
364
- let heartbeat: { stop: () => void } | null = null
365
363
  try {
366
364
  const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId })
367
365
  const box = await createBoxClient(
@@ -388,11 +386,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
388
386
  }
389
387
  console.log(` ...accessing shell\n`)
390
388
  session = await box.connect()
391
- // the same reason `create` heartbeats: the box holds no account credential,
392
- // so the client using it is what keeps its session alive.
393
- const sessionId = box.description.sessionId
394
- if (!sessionId) throw new BoxClientError('the box service returned no session id')
395
- heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId })
396
389
  return await repl(session, '')
397
390
  } catch (error) {
398
391
  if (error instanceof BoxClientError) {
@@ -401,7 +394,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
401
394
  }
402
395
  throw error
403
396
  } finally {
404
- heartbeat?.stop()
405
397
  session?.close()
406
398
  }
407
399
  }
@@ -529,6 +521,8 @@ export async function createPrebuiltBundleBox(options: {
529
521
  nativeProjectDir?: string
530
522
  // the simulator's device model, or null for the Box page's default
531
523
  device: string | null
524
+ // the link the app launches with, as Linking.getInitialURL reports it
525
+ initialUrl?: string | null
532
526
  name: string
533
527
  endpoint: string
534
528
  apiOrigin: string
@@ -550,6 +544,7 @@ export async function createPrebuiltBundleBox(options: {
550
544
  source: new BundleFilePlane(
551
545
  options.bundle,
552
546
  options.device,
547
+ options.initialUrl ?? null,
553
548
  options.nativeProjectDir
554
549
  ? readNativeLinkedAppFontsWire({
555
550
  projectDir: options.nativeProjectDir,
@@ -711,7 +706,6 @@ async function runCloudBoxCreate(
711
706
  accountId,
712
707
  }
713
708
  let session: BoxShellSession | null = null
714
- let heartbeat: { stop: () => void } | null = null
715
709
  try {
716
710
  const box =
717
711
  'bundle' in source
@@ -760,16 +754,6 @@ async function runCloudBoxCreate(
760
754
  }
761
755
  console.log(` ...accessing shell\n`)
762
756
  session = await box.connect()
763
- // bank the wall clock while the shell is open. the box holds no account
764
- // credential, so the client that is using it is what keeps the session
765
- // alive; when this process dies the sweep closes it at the last heartbeat.
766
- const sessionId = box.description.sessionId
767
- if (!sessionId) throw new BoxClientError('the box service returned no session id')
768
- heartbeat = heartbeatSession({
769
- apiOrigin: apiOrigin(args, auth),
770
- apiKey,
771
- sessionId,
772
- })
773
757
  return await repl(session, '')
774
758
  } catch (error) {
775
759
  if (error instanceof BoxClientError) {
@@ -778,7 +762,6 @@ async function runCloudBoxCreate(
778
762
  }
779
763
  throw error
780
764
  } finally {
781
- heartbeat?.stop()
782
765
  session?.close()
783
766
  }
784
767
  }