peachsim 0.1.1620 → 0.1.1635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -6
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +39 -18
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +248 -156
  7. package/cli/cloud-client.ts +68 -57
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/box/search-command.ts +3 -2
  10. package/cli/commands/box.ts +10 -29
  11. package/cli/commands/control.ts +391 -92
  12. package/cli/commands/doctor.ts +17 -184
  13. package/cli/commands/flow.ts +79 -11
  14. package/cli/commands/inspect/core.ts +10 -5
  15. package/cli/commands/inspect/settling.ts +71 -22
  16. package/cli/commands/inspect/wait-ready.ts +12 -0
  17. package/cli/commands/inspect.ts +26 -3
  18. package/cli/commands/install-desktop.ts +1 -0
  19. package/cli/commands/maestro-compare.ts +105 -67
  20. package/cli/commands/maestro.ts +9 -1
  21. package/cli/commands/platform.ts +71 -36
  22. package/cli/commands/record.ts +21 -1
  23. package/cli/commands/screenshot.ts +23 -1
  24. package/cli/commands/serve.ts +7 -2
  25. package/cli/commands/setup.ts +19 -42
  26. package/cli/commands/swift.ts +5 -0
  27. package/cli/commands/upload.ts +51 -8
  28. package/cli/desktop-companion.ts +102 -17
  29. package/cli/dev-build-freshness.ts +37 -2
  30. package/cli/drivers/desktop.ts +14 -1
  31. package/cli/drivers/playwright-sim-host.ts +44 -8
  32. package/cli/drivers/playwright.ts +9 -8
  33. package/cli/drivers/types.ts +5 -0
  34. package/cli/maestro-accessibility.ts +53 -0
  35. package/cli/maestro-compare.ts +108 -12
  36. package/cli/main.ts +2 -1
  37. package/cli/outbound-endpoints.ts +35 -6
  38. package/cli/parent-pid.ts +0 -9
  39. package/cli/pause-aware-timeout.ts +102 -0
  40. package/cli/remote-simulators.ts +112 -0
  41. package/cli/remoteSimulatorContract.ts +21 -0
  42. package/cli/scene-video.ts +89 -0
  43. package/cli/ws-bridge.ts +23 -14
  44. package/detox/attempt-events.cjs +4 -0
  45. package/detox/expectations.ts +5 -1
  46. package/detox/index.ts +241 -181
  47. package/detox/motion-change.cjs +73 -0
  48. package/detox/motion-change.d.cts +21 -0
  49. package/detox/navigation-lifecycle.ts +22 -4
  50. package/dist-lib/agent-daemon-client.cjs +3 -2
  51. package/dist-lib/agent-events.cjs +1 -1
  52. package/dist-lib/attached-projects.cjs +1 -1
  53. package/dist-lib/auth/shared-session.cjs +3 -2
  54. package/dist-lib/backend-origin.cjs +1 -1
  55. package/dist-lib/beta.cjs +1 -1
  56. package/dist-lib/beta.mjs +1 -1
  57. package/dist-lib/bridge-constants.cjs +13 -1
  58. package/dist-lib/bridge-contract-input.cjs +1 -1
  59. package/dist-lib/bridge-contract-input.mjs +1 -1
  60. package/dist-lib/bridge-contract.cjs +14 -8
  61. package/dist-lib/bridge-contract.mjs +13 -7
  62. package/dist-lib/bundle-channel.cjs +10480 -7962
  63. package/dist-lib/capture-contract.cjs +1 -1
  64. package/dist-lib/capture-contract.mjs +1 -1
  65. package/dist-lib/cloud-contract.cjs +1 -1
  66. package/dist-lib/cloud-contract.mjs +1 -1
  67. package/dist-lib/cloud-test-args.cjs +1 -1
  68. package/dist-lib/cloud-test-args.mjs +1 -1
  69. package/dist-lib/cloud.cjs +3 -2
  70. package/dist-lib/cloud.mjs +3 -2
  71. package/dist-lib/config.cjs +10 -6
  72. package/dist-lib/config.mjs +10 -6
  73. package/dist-lib/detox/index.cjs +1854 -966
  74. package/dist-lib/dev-bundle-resolution.cjs +64 -24
  75. package/dist-lib/expo-swift-bundle.cjs +8715 -6271
  76. package/dist-lib/expo-swift-metadata.cjs +7 -4
  77. package/dist-lib/expo-swift-metadata.mjs +7 -4
  78. package/dist-lib/home-paths.cjs +32 -3
  79. package/dist-lib/host/bridge-host.cjs +10135 -7060
  80. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  81. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  82. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  83. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  84. package/dist-lib/host/websocket-proxy.cjs +1 -1
  85. package/dist-lib/index.cjs +1843 -408
  86. package/dist-lib/jump-to-source-babel.cjs +1 -1
  87. package/dist-lib/jump-to-source-native.cjs +1 -1
  88. package/dist-lib/kotlin-host.cjs +1 -1
  89. package/dist-lib/menu.cjs +3 -2
  90. package/dist-lib/menu.mjs +3 -2
  91. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  92. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  93. package/dist-lib/metro-production-bundle.cjs +5 -4
  94. package/dist-lib/metro-production-bundle.mjs +5 -4
  95. package/dist-lib/metro.cjs +1182 -418
  96. package/dist-lib/native-app-identity-project.cjs +782 -23
  97. package/dist-lib/native-app-identity-project.mjs +781 -23
  98. package/dist-lib/native-app-identity.cjs +276 -9
  99. package/dist-lib/native-app-identity.mjs +273 -8
  100. package/dist-lib/native-firebase-metadata.cjs +5 -4
  101. package/dist-lib/native-firebase-metadata.mjs +5 -4
  102. package/dist-lib/plist.cjs +1 -1
  103. package/dist-lib/plist.mjs +1 -1
  104. package/dist-lib/profiles.cjs +1 -1
  105. package/dist-lib/public-brand.cjs +3 -2
  106. package/dist-lib/react-native-host-modules.cjs +8 -1
  107. package/dist-lib/react-native-host-modules.mjs +8 -1
  108. package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
  109. package/dist-lib/sdk.cjs +115 -34
  110. package/dist-lib/sdk.mjs +115 -33
  111. package/dist-lib/skills.cjs +15274 -9904
  112. package/dist-lib/swift-compile.cjs +1165 -173
  113. package/dist-lib/swift-compile.mjs +1212 -203
  114. package/dist-lib/swift-package.cjs +12 -3
  115. package/dist-lib/swift-package.mjs +12 -3
  116. package/dist-lib/swift-submit.cjs +7 -1
  117. package/dist-lib/swift-submit.mjs +5 -1
  118. package/dist-lib/swift-toolchain.cjs +133 -18
  119. package/dist-lib/swift.cjs +1 -1
  120. package/dist-lib/vite-swift.cjs +2648 -862
  121. package/dist-lib/vite.cjs +70 -14
  122. package/dist-lib/worklet-executable.cjs +25 -2
  123. package/dist-lib/worklet-executable.mjs +25 -2
  124. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  125. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  126. package/package.json +2 -2
  127. package/scripts/dev-server-scanner.ts +35 -14
  128. package/skills/peach-perf/SKILL.md +11 -5
  129. package/skills/peach-setup/SKILL.md +16 -19
  130. package/src/admitted-mac-build.ts +34 -8
  131. package/src/android-app-identity.ts +146 -0
  132. package/src/android-autolinking.ts +75 -0
  133. package/src/android-string-value.ts +36 -0
  134. package/src/android-strings.ts +5 -36
  135. package/src/androidAutolinkingContract.ts +40 -0
  136. package/src/bridge-constants.ts +11 -0
  137. package/src/bridge-contract.ts +22 -34
  138. package/src/bundleGlobalFooter.ts +26 -7
  139. package/src/codex-client.ts +3 -2
  140. package/src/config.ts +15 -0
  141. package/src/desktop-host-delivery.ts +5 -1
  142. package/src/dev-bundle-resolution.ts +60 -26
  143. package/src/expo-kotlin-host.ts +6 -36
  144. package/src/expo-manifest-body.ts +13 -0
  145. package/src/expo-swift-bundle.ts +17 -12
  146. package/src/expo-swift-host.ts +640 -192
  147. package/src/expo-swift-ready.ts +78 -0
  148. package/src/expoSwiftContract.ts +19 -0
  149. package/src/home-paths.ts +48 -2
  150. package/src/host/bridge-host.ts +12 -57
  151. package/src/host/staged-host-files.ts +29 -2
  152. package/src/installed-package-versions.ts +54 -14
  153. package/src/launchMetadataContract.ts +24 -0
  154. package/src/metro-plugin.ts +37 -42
  155. package/src/native-app-identity-project.ts +142 -5
  156. package/src/native-app-identity.ts +176 -11
  157. package/src/native-firebase-config.ts +209 -13
  158. package/src/nitro-compile.ts +2 -5
  159. package/src/nitro-source.ts +147 -63
  160. package/src/process-group.ts +114 -61
  161. package/src/process-output.ts +0 -9
  162. package/src/public-brand.ts +1 -0
  163. package/src/react-native-bridge-modules.ts +416 -0
  164. package/src/react-native-host-modules.ts +7 -0
  165. package/src/sim-client.ts +6 -4
  166. package/src/swift-binary-modules.ts +2 -2
  167. package/src/swift-compile.ts +1007 -108
  168. package/src/swift-compiler-digest.ts +14 -0
  169. package/src/swift-distribution.lock.json +40 -12
  170. package/src/swift-distribution.ts +61 -9
  171. package/src/swift-host.ts +149 -56
  172. package/src/swift-icu.ts +14 -6
  173. package/src/swift-objc-sdk.ts +133 -10
  174. package/src/swift-objc-standin.ts +38 -3
  175. package/src/swift-package-manifest.ts +21 -6
  176. package/src/swift-package.ts +15 -1
  177. package/src/swift-packages.ts +119 -60
  178. package/src/swift-standin-emit.ts +57 -9
  179. package/src/swift-standins.ts +185 -25
  180. package/src/swift-submit.ts +10 -0
  181. package/src/swift-toolchain.ts +149 -23
  182. package/src/vite-plugin.ts +20 -20
  183. package/src/worklet-executable.ts +30 -3
  184. package/src/worklets-babel.ts +35 -14
  185. package/src/xcode-project.ts +10 -0
@@ -1,3 +1,7 @@
1
+ import {
2
+ accessibilityBounds,
3
+ sameNativeAccessibilityShape,
4
+ } from '../../maestro-accessibility'
1
5
  import {
2
6
  inspectReadySnapshot,
3
7
  inspectWaitReady,
@@ -16,9 +20,11 @@ export type WaitForSootsimIdleOptions = {
16
20
  // in fewer polls instead of paying the count at its slower rate.
17
21
  stablePolls?: number
18
22
  strict?: boolean
19
- // compare consecutive full-screen pixels, as maestro's ios driver does.
23
+ // compare consecutive full-screen pixels; explicit animation waits use this.
20
24
  // frame publications and caret blinks do not require a continuous window.
21
25
  paintStill?: boolean
26
+ // native maestro ios: pixel stability first, then native hierarchy equality.
27
+ maestro?: boolean
22
28
  // settle on motion alone, for the waits around a gesture: image loads and
23
29
  // network traffic move nothing on screen, and the hierarchy walk costs
24
30
  // seconds per poll on a large tree under load.
@@ -75,6 +81,7 @@ export async function waitForSootsimIdle({
75
81
  stablePolls = 3,
76
82
  strict = false,
77
83
  paintStill = false,
84
+ maestro = false,
78
85
  motionOnly = false,
79
86
  transitionStartWindowMs = 80,
80
87
  transitionTimeoutMs = 4000,
@@ -108,12 +115,15 @@ export async function waitForSootsimIdle({
108
115
  const pollMs = ${Math.max(1, Math.round(pollMs))}
109
116
  const strict = ${strict ? 'true' : 'false'}
110
117
  const paintStill = ${paintStill ? 'true' : 'false'}
118
+ const maestro = ${maestro ? 'true' : 'false'}
119
+ const accessibilityBounds = ${accessibilityBounds.toString()}
120
+ const sameNativeAccessibilityShape = ${sameNativeAccessibilityShape.toString()}
111
121
  const motionOnly = ${motionOnly ? 'true' : 'false'}
112
122
  // full mounted-geometry observation needs at least two hierarchy reads
113
123
  // plus the stillness window, which a sub-second budget cannot afford:
114
124
  // tight budgets (auto-wait) settle on worker stillness plus root
115
125
  // membership, generous ones confirm the geometry too.
116
- const fullGeometry = ${!motionOnly && maxMs >= 1000 ? 'true' : 'false'}
126
+ const fullGeometry = ${!maestro && !motionOnly && maxMs >= 1000 ? 'true' : 'false'}
117
127
  const requiredStillMs = paintStill ? 0 : ${Math.max(1, Math.round(stablePolls)) * Math.max(1, Math.round(pollMs))}
118
128
  const minimumStablePolls = paintStill ? 1 : ${Math.min(2, Math.max(1, Math.round(stablePolls)))}
119
129
  const composite = paintStill ? window.SootSim?.bridges?.liveComposite : null
@@ -235,6 +245,9 @@ export async function waitForSootsimIdle({
235
245
  // strict also waits out shell motion: a system alert, sheet or menu
236
246
  // animates in the shell worker while the app's own stats read still,
237
247
  // so its presentation is complete only once the shell's list empties.
248
+ const accessibilityRead = maestro
249
+ ? answer(() => window.__sootsimTest.captureAccessibilityTree()).catch(() => null)
250
+ : null
238
251
  const motionRead = strict
239
252
  ? answer(() => window.__sootsimTest?.listAnimations?.()).catch(() => null)
240
253
  : null
@@ -244,9 +257,12 @@ export async function waitForSootsimIdle({
244
257
  // the compositor rejects a stats request it is too busy to answer.
245
258
  const compositorRead = answer(() => window.__sootsimCompositor.getStats()).catch(() => BUSY)
246
259
  const countsRead = motionOnly ? null : answer(() => window.__sootsimTest?.getRequestCounts?.()).catch(() => null)
247
- // mounted-root membership: the live node count moves while the tree
248
- // mounts, unmounts or recycles, in either render mode.
249
- const nodeCountRead = answer(() => window.__sootsimTest?.getNodeCount?.()).catch(() => null)
260
+ // mounted-root membership: the count of nodes that can reach the
261
+ // window moves while the screen mounts, unmounts or recycles, in
262
+ // either render mode. a screen a pushed one covers or an inactive
263
+ // tab stays mounted out of the window, so what it mounts is not
264
+ // screen motion.
265
+ const nodeCountRead = answer(() => window.__sootsimTest?.getWindowNodeCount?.()).catch(() => null)
250
266
  // the mounted hierarchy in render-worker mode comes from the test
251
267
  // bridge: window.__sootsimRoot is an empty placeholder there, so a
252
268
  // walk over it hashes every screen to the same empty string while
@@ -276,7 +292,7 @@ export async function waitForSootsimIdle({
276
292
  const motion = await motionRead
277
293
  if (motion === BUSY) workerBusy = true
278
294
  else if (Array.isArray(motion)) {
279
- const blocking = motion.filter((anim) => anim.realm === 'shell' && anim.priority !== 'low')
295
+ const blocking = motion.filter((anim) => anim.realm === 'shell' && anim.priority !== 'low' && anim.visible !== false)
280
296
  shellAnimating = blocking.length > 0
281
297
  // name what holds the settle so a timeout points at its owner.
282
298
  shellBlockers = blocking
@@ -308,6 +324,8 @@ export async function waitForSootsimIdle({
308
324
  stats.hasActiveAnims === true ||
309
325
  stats.hasActiveBlockingNativeAnimations === true ||
310
326
  (!paintStill && stats.hasPendingBlockingAnimationFrames === true)
327
+ // the runtime reports only layout work that can reach the window:
328
+ // a covered screen or inactive tab committing does not hold it.
311
329
  layoutDirty =
312
330
  stats.layoutDirty === true && stats.renderRequested === true
313
331
  // a freshly-pushed screen showing a skeleton is layout-stable
@@ -340,6 +358,11 @@ export async function waitForSootsimIdle({
340
358
  const total = await nodeCountRead
341
359
  if (total === BUSY) workerBusy = true
342
360
  nodeCount = typeof total === 'number' ? total : null
361
+ const accessibility = accessibilityRead ? await accessibilityRead : null
362
+ const nativeHierarchy = Array.isArray(accessibility) && accessibility.length > 0
363
+ ? accessibility
364
+ : null
365
+ if (accessibility === BUSY) workerBusy = true
343
366
  let hierarchy = hierarchyRead ? await hierarchyRead : null
344
367
  if (hierarchy === BUSY) {
345
368
  workerBusy = true
@@ -399,9 +422,15 @@ export async function waitForSootsimIdle({
399
422
  webViewStarting = true
400
423
  webViewBlocker = 'preview progress element displayed'
401
424
  } else {
402
- const unreadyWebView = document.querySelector(
425
+ // a loading document with no area (an app's hidden worker or
426
+ // executor frame) paints nothing, and a screenshot on device
427
+ // does not wait for it.
428
+ const unreadyWebView = [...document.querySelectorAll(
403
429
  '[data-sootsim-webview-loading="true"], [data-sootsim-webview-mode="placeholder"]'
404
- )
430
+ )].find((view) => {
431
+ const frame = view.querySelector('iframe')
432
+ return !frame || frame.clientWidth > 0 || frame.clientHeight > 0
433
+ })
405
434
  if (unreadyWebView) {
406
435
  webViewStarting = true
407
436
  webViewBlocker = 'webView document still loading'
@@ -420,7 +449,7 @@ export async function waitForSootsimIdle({
420
449
  pixelFrameToken = token
421
450
  }
422
451
  }
423
- return { layout: nodes.join(';'), screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
452
+ return { layout: nodes.join(';'), nativeHierarchy, screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
424
453
  }
425
454
 
426
455
  // how long generic background network is allowed to keep us waiting AFTER
@@ -434,6 +463,7 @@ export async function waitForSootsimIdle({
434
463
  // so the generic request counters are a courtesy, not a gate: honor them
435
464
  // only within this grace once the screen is visually still.
436
465
  const networkQuietGraceMs = 1200
466
+ let lastNativeHierarchy = null
437
467
  let lastLayout = ''
438
468
  let lastScreenPixels = null
439
469
  let screenChangedPolls = 0
@@ -461,6 +491,9 @@ export async function waitForSootsimIdle({
461
491
  }
462
492
  return true
463
493
  }
494
+ const hierarchyUnchanged = (current, previous) =>
495
+ current !== null && previous !== null && current.length === previous.length &&
496
+ current.every((node, index) => sameNativeAccessibilityShape(node, previous[index], accessibilityBounds))
464
497
  const IDLE_HELD = { idleHeld: true }
465
498
  while (true) {
466
499
  if (clockNow() >= deadline) {
@@ -506,7 +539,7 @@ export async function waitForSootsimIdle({
506
539
  // the loop head drains a transition still in flight before it ends.
507
540
  if (snapshot.workerBusy && clockNow() >= deadline && report) continue
508
541
  polls++
509
- report = { snapshot, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
542
+ report = { snapshot, nativeHierarchy: lastNativeHierarchy, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
510
543
  if (snapshot.layoutDirty) dirtyPolls++
511
544
  if (snapshot.animating || snapshot.shellAnimating) animatingPolls++
512
545
  if (snapshot.webViewStarting) webViewStartingPolls++
@@ -527,7 +560,15 @@ export async function waitForSootsimIdle({
527
560
  // strictOk and adds shell motion plus the blocking-stats floor. a
528
561
  // null count or commit reading means the runtime does not report
529
562
  // it, which reads as stable the way a missing stats block does.
530
- const visuallyStill =
563
+ // native screen checks observe output, not animation ownership. after
564
+ // its 3 s pixel phase, iOS accepts two equal native TreeNodes even
565
+ // while paint-only playback continues. overlap hierarchy reads so
566
+ // neither the gesture nor screenshot deadline grows.
567
+ const maestroStill = snapshot.screenPixels !== null && !snapshot.workerBusy && !snapshot.webViewStarting && (
568
+ !screenChanged || (clockNow() - start >= 3000 &&
569
+ hierarchyUnchanged(snapshot.nativeHierarchy, lastNativeHierarchy))
570
+ )
571
+ const visuallyStill = maestro ? maestroStill :
531
572
  strictOk &&
532
573
  !snapshot.animating &&
533
574
  !snapshot.layoutDirty &&
@@ -555,7 +596,7 @@ export async function waitForSootsimIdle({
555
596
  // grace (continuous background traffic must not block forever).
556
597
  if (
557
598
  stillProven() &&
558
- (motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
599
+ (maestro || motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
559
600
  ) {
560
601
  if (transitionWait) {
561
602
  await transitionWait
@@ -567,6 +608,7 @@ export async function waitForSootsimIdle({
567
608
  stillPolls = []
568
609
  visuallyStillSince = 0
569
610
  }
611
+ lastNativeHierarchy = snapshot.nativeHierarchy
570
612
  lastLayout = snapshot.layout
571
613
  lastScreenPixels = snapshot.screenPixels
572
614
  lastRequestTotal = snapshot.requestTotal
@@ -589,20 +631,21 @@ export async function waitForSootsimIdle({
589
631
  }
590
632
  const last = report.snapshot
591
633
  const blockers = []
592
- if (strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
593
- if (last.animating) blockers.push('animations still running')
594
- if (last.shellAnimating) blockers.push('shell animations still running (' + last.shellBlockers + ')')
595
- if (last.layoutDirty) blockers.push('layout still dirty')
596
- if (last.appCommits !== null && report.appCommits !== null && last.appCommits !== report.appCommits) blockers.push('layout commits still landing')
597
- if (last.nodeCount !== null && report.nodeCount !== null && last.nodeCount !== report.nodeCount) blockers.push('node count still changing')
598
- if (!motionOnly && last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
634
+ if (!maestro && strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
635
+ if (!maestro && last.animating) blockers.push('animations still running')
636
+ if (!maestro && last.shellAnimating) blockers.push('shell animations still running (' + last.shellBlockers + ')')
637
+ if (!maestro && last.layoutDirty) blockers.push('layout still dirty')
638
+ if (!maestro && last.appCommits !== null && report.appCommits !== null && last.appCommits !== report.appCommits) blockers.push('layout commits still landing')
639
+ if (!maestro && last.nodeCount !== null && report.nodeCount !== null && last.nodeCount !== report.nodeCount) blockers.push('node count still changing')
640
+ if (!maestro && !motionOnly && last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
599
641
  if (last.workerBusy) blockers.push('a render worker too busy to report')
600
642
  if (last.webViewStarting) blockers.push(last.webViewBlocker || 'webView still starting')
601
- if (last.layout !== report.layout) blockers.push('layout still changing')
643
+ if (!maestro && last.layout !== report.layout) blockers.push('layout still changing')
644
+ if (maestro && !hierarchyUnchanged(last.nativeHierarchy, report.nativeHierarchy)) blockers.push(last.nativeHierarchy === null ? 'native accessibility hierarchy unavailable' : 'native accessibility hierarchy still changing')
602
645
  if (paintStill && !last.screenPixels) blockers.push('screen pixels unavailable')
603
646
  else if (paintStill && !screenUnchanged(last.screenPixels, report.screenPixels)) blockers.push('screen pixels still changing')
604
- if (!motionOnly && last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
605
- else if (!motionOnly && last.requestTotal !== report.requestTotal) blockers.push('new requests issued')
647
+ if (!maestro && !motionOnly && last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
648
+ else if (!maestro && !motionOnly && last.requestTotal !== report.requestTotal) blockers.push('new requests issued')
606
649
  if (polls < 2) blockers.push('sim too busy to poll twice')
607
650
  // the last read is still: motion ended too near the deadline for the
608
651
  // stillness window to hold before it.
@@ -654,6 +697,9 @@ export type SettledWaitReady = {
654
697
  // the last readiness read: the settled tree once the app went idle,
655
698
  // otherwise the read that ended the wait.
656
699
  status: WaitReadyStatus
700
+ // the first read that counted as ready, before the settle: the probe the
701
+ // wait accepted. null when the app never became ready.
702
+ accepted: WaitReadyStatus | null
657
703
  // the last settle; null when the app never became ready.
658
704
  idle: WaitForSootsimIdleResult | null
659
705
  elapsedMs: number
@@ -672,8 +718,10 @@ export async function waitReadyAndSettle(
672
718
  ): Promise<SettledWaitReady> {
673
719
  const start = Date.now()
674
720
  const remaining = () => Math.max(0, start + timeoutMs - Date.now())
721
+ let accepted: WaitReadyStatus | null = null
675
722
  const result = (status: WaitReadyStatus, idle: WaitForSootsimIdleResult | null) => ({
676
723
  status,
724
+ accepted,
677
725
  idle,
678
726
  elapsedMs: Date.now() - start,
679
727
  })
@@ -682,6 +730,7 @@ export async function waitReadyAndSettle(
682
730
  idle = null
683
731
  const ready = await inspectWaitReady(bridge, remaining(), options)
684
732
  if (!ready.ready) return result(ready, idle)
733
+ accepted = ready
685
734
  idle = await waitForSootsimIdle({
686
735
  bridge,
687
736
  simId: options.simId,
@@ -26,10 +26,12 @@ export async function runWaitReadySubcommand(opts: {
26
26
  if (bridge.plane === 'cloud') {
27
27
  const status = await inspectWaitReady(bridge, timeoutMs)
28
28
  if (!status.ready) timedOut(status, status.elapsedMs)
29
+ printAcceptedProbe(status)
29
30
  printReady(status, status.elapsedMs)
30
31
  return
31
32
  }
32
33
  let status: Awaited<ReturnType<typeof waitReadyAndSettle>>['status']
34
+ let accepted: Awaited<ReturnType<typeof waitReadyAndSettle>>['accepted']
33
35
  let idle: Awaited<ReturnType<typeof waitReadyAndSettle>>['idle']
34
36
  let elapsedMs: number
35
37
  try {
@@ -41,6 +43,7 @@ export async function runWaitReadySubcommand(opts: {
41
43
  },
42
44
  })
43
45
  status = settledWait.status
46
+ accepted = settledWait.accepted
44
47
  idle = settledWait.idle
45
48
  elapsedMs = settledWait.elapsedMs
46
49
  } catch (error) {
@@ -51,6 +54,7 @@ export async function runWaitReadySubcommand(opts: {
51
54
  throw error
52
55
  }
53
56
  if (!idle) timedOut(status, elapsedMs)
57
+ if (accepted) printAcceptedProbe(accepted)
54
58
  if (!idle.settled) {
55
59
  console.error(
56
60
  ` ⚠ wait ready timed out after ${elapsedMs}ms — app mounted but did not settle: ${idle.blockedBy} (nodes: ${status.nodes}, targets: ${status.targets})`,
@@ -66,6 +70,14 @@ export async function runWaitReadySubcommand(opts: {
66
70
  printReady(status, elapsedMs)
67
71
  }
68
72
 
73
+ // the probe the wait accepted, kept so a ready that later proves wrong can be
74
+ // read from the command's own output.
75
+ function printAcceptedProbe(status: WaitReadyStatus): void {
76
+ console.error(
77
+ ` accepted probe at ${Math.round(status.elapsedMs)}ms: basis ${status.readyBasis}, flag ${String(status.flag)}, ${status.nodes} nodes (quiet ${Math.round(status.treeQuietMs)}ms), ${status.targets} targets, painted ${status.paintedAppContent}, live frame ${status.liveFrameActive} (${status.liveFrameChannels} channels, ${status.liveFramePublishes} publishes), browser surfaces ${status.browserSurfaceCount}`,
78
+ )
79
+ }
80
+
69
81
  function printReady(status: WaitReadyStatus, elapsedMs: number): void {
70
82
  console.log(
71
83
  ` ready in ${elapsedMs}ms: ${status.nodes} nodes, ${status.targets} targets${status.liveFrameActive ? `, ${status.liveFrameChannels} live surfaces` : ''}${status.browserSurfaceCount ? `, ${status.browserSurfaceCount} browser surfaces` : ''}`,
@@ -34,6 +34,7 @@ import {
34
34
  parseBridgeCliArgs,
35
35
  printBridgeWorldNotice,
36
36
  type WsBridge,
37
+ type BridgeSimInfo,
37
38
  } from '../ws-bridge'
38
39
  import {
39
40
  isTapSuccess,
@@ -596,13 +597,13 @@ export function printShellPerfReport(result: Record<string, any>) {
596
597
  console.log(``)
597
598
  console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`)
598
599
  console.log(
599
- ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}`,
600
+ ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}${cadence.idleBreaks ? ` · ${cadence.idleBreaks} host idle breaks excluded` : ''}`,
600
601
  )
601
602
  console.log(
602
603
  ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`,
603
604
  )
604
605
  console.log(
605
- ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`,
606
+ ` present interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms (during motion, ${cadence.paintIntervals} intervals, including native frames)`,
606
607
  )
607
608
  }
608
609
  const compositorCadence = result.compositorCadence
@@ -1247,7 +1248,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1247
1248
  return
1248
1249
  }
1249
1250
 
1250
- // `peach list --drivers` — pure local registry view, no bridge needed.
1251
+ // driver discovery works offline; a live bridge also reports each running renderer.
1251
1252
  // short-circuit here so users can discover drivers even when no peach
1252
1253
  // session is running.
1253
1254
  if (
@@ -1256,6 +1257,19 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1256
1257
  ) {
1257
1258
  const { buildDriverListRows } = await import('../drivers')
1258
1259
  const rows = buildDriverListRows()
1260
+ const bridge = (opts.createBridge ?? createBridgeFromParsed)(parsed)
1261
+ let sims: BridgeSimInfo[] = []
1262
+ try {
1263
+ sims = await bridge.listSims()
1264
+ } catch {
1265
+ // local availability remains useful when no bridge is running.
1266
+ } finally {
1267
+ bridge.close()
1268
+ }
1269
+ if (wantsJson(effectiveArgs)) {
1270
+ printJson({ drivers: rows, sims })
1271
+ return
1272
+ }
1259
1273
  console.log(` available drivers (${rows.length}):\n`)
1260
1274
  const idWidth = Math.max(...rows.map((r) => r.id.length), 6)
1261
1275
  const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4)
@@ -1270,6 +1284,15 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1270
1284
  console.log(` unavailable: ${row.reason}`)
1271
1285
  }
1272
1286
  }
1287
+ for (const sim of sims) {
1288
+ const renderer = sim.meta?.peachRenderer
1289
+ const executable = sim.meta?.peachHostExecutable
1290
+ if (sim.readyState === 'open' && typeof renderer === 'string') {
1291
+ console.log(
1292
+ ` in use: ${sim.id} · ${String(sim.meta?.sootsimHostDriver)} · ${renderer}${typeof executable === 'string' ? ` @ ${executable}` : ''}`,
1293
+ )
1294
+ }
1295
+ }
1273
1296
  return
1274
1297
  }
1275
1298
 
@@ -126,6 +126,7 @@ async function installLinuxDeb(packagePath: string): Promise<string> {
126
126
  async function installWindowsExe(exePath: string): Promise<string> {
127
127
  // nsis installer prompts and self-elevates; run it and let it take over.
128
128
  await new Promise<void>((resolvePromise, rejectPromise) => {
129
+ // runner-toolchain-platform: win32
129
130
  const child = spawn('cmd', ['/c', 'start', '""', '/wait', exePath], {
130
131
  stdio: 'inherit',
131
132
  })
@@ -8,21 +8,23 @@ import * as fs from 'fs'
8
8
  import * as os from 'os'
9
9
  import * as path from 'path'
10
10
  import { devices } from 'peach-engine/settings/devices'
11
+ import { PNG } from 'pngjs'
11
12
  import { rnxHomeDir } from '../../src/home-paths'
12
13
  import { parseFlowFile } from '../flow-file'
13
14
  import {
14
15
  buildCompareHtml,
16
+ buildCompareSide,
15
17
  compareScreens,
16
18
  comparePassed,
17
19
  instrumentFlowSource,
18
20
  nativeStepsFromCommandsJson,
19
- nativeFlowFailureFromLog,
21
+ nativeFlowVerdictsFromJunit,
22
+ junitFlowKey,
20
23
  peachStepsFromCommandsJson,
21
24
  screensOverThreshold,
22
25
  type CompareCapture,
23
26
  type CompareFlow,
24
27
  type CompareReport,
25
- type CompareSide,
26
28
  } from '../maestro-compare'
27
29
  import { MaestroJsContext } from '../maestro-js'
28
30
  import { collectFlowFiles, loadMaestroWorkspaceConfig } from '../maestro-workspace'
@@ -161,12 +163,44 @@ function statusBarPx(png: Buffer): number {
161
163
  const height = png.readUInt32BE(20)
162
164
  const spec = Object.values(devices).find(
163
165
  (d) =>
164
- Math.round(d.width * d.scale) === width &&
165
- Math.round(d.height * d.scale) === height,
166
+ // maestro can truncate fractional point bounds one pixel below simctl.
167
+ Math.abs(Math.round(d.width * d.scale) - width) <= 1 &&
168
+ Math.abs(Math.round(d.height * d.scale) - height) <= 1,
166
169
  )
167
170
  return spec ? spec.statusBarHeight * spec.scale : 0
168
171
  }
169
172
 
173
+ // maestro's xctest shot on an ios 27 simulator drops the rightmost column of
174
+ // an odd framebuffer (1179 becomes 1178). diffing at that short width
175
+ // resamples peach off the pixel grid, so restore the column first.
176
+ function alignNativeCapture(png: Buffer): Buffer {
177
+ const width = png.readUInt32BE(16)
178
+ const height = png.readUInt32BE(20)
179
+ const spec = Object.values(devices).find(
180
+ (d) =>
181
+ Math.abs(Math.round(d.width * d.scale) - width) <= 1 &&
182
+ Math.abs(Math.round(d.height * d.scale) - height) <= 1,
183
+ )
184
+ if (!spec) return png
185
+ const expectedW = Math.round(spec.width * spec.scale)
186
+ const expectedH = Math.round(spec.height * spec.scale)
187
+ if (expectedH !== height || expectedW !== width + 1) return png
188
+ const src = PNG.sync.read(png)
189
+ const dst = new PNG({ width: expectedW, height })
190
+ for (let y = 0; y < height; y++) {
191
+ const srow = y * src.width * 4
192
+ const drow = y * expectedW * 4
193
+ src.data.copy(dst.data, drow, srow, srow + src.width * 4)
194
+ const last = srow + (src.width - 1) * 4
195
+ const o = drow + src.width * 4
196
+ dst.data[o] = src.data[last] ?? 0
197
+ dst.data[o + 1] = src.data[last + 1] ?? 0
198
+ dst.data[o + 2] = src.data[last + 2] ?? 0
199
+ dst.data[o + 3] = 255
200
+ }
201
+ return PNG.sync.write(dst)
202
+ }
203
+
170
204
  // the flow as its author would name it: relative inside the working
171
205
  // directory, home-relative or absolute outside it.
172
206
  function displayPath(cwd: string, file: string): string {
@@ -201,45 +235,6 @@ function formatFailedRequest(entry: unknown): string {
201
235
  return `request failed: ${e.method ?? 'GET'} ${e.url ?? '?'} ${e.status ?? e.error ?? ''}`.trim()
202
236
  }
203
237
 
204
- function sideFromSteps(
205
- steps: CompareSide['steps'],
206
- ran: boolean,
207
- extraErrors: string[],
208
- // a run that failed outside its steps (the sim or app never came up)
209
- runFailure?: string,
210
- ): CompareSide {
211
- const failed = steps.find((s) => s.status === 'failed')
212
- return {
213
- status: !ran ? 'not run' : failed || runFailure ? 'failed' : 'passed',
214
- durationMs: steps.reduce((sum, s) => sum + s.durationMs, 0),
215
- steps,
216
- errors: [
217
- ...(failed
218
- ? [`step "${failed.label}" failed: ${failed.error ?? 'no message'}`]
219
- : runFailure
220
- ? [runFailure]
221
- : []),
222
- ...extraErrors,
223
- ],
224
- }
225
- }
226
-
227
- // peach names each flow's output dir after the file stem, suffixing repeats
228
- // in run order (commands/maestro.ts uniqueFlowDir).
229
- function peachFlowDirs(root: string, files: string[]): string[] {
230
- const seen = new Map<string, number>()
231
- return files.map((file) => {
232
- const slug =
233
- path
234
- .basename(file)
235
- .replace(/\.(ya?ml)$/i, '')
236
- .replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
237
- const n = (seen.get(slug) ?? 0) + 1
238
- seen.set(slug, n)
239
- return path.join(root, n === 1 ? slug : `${slug}-${n}`)
240
- })
241
- }
242
-
243
238
  // the flow's appId as maestro resolves it: its `${...}` templates evaluate
244
239
  // against --env values (in process.env) and the flow's own env block.
245
240
  function resolveFlowAppId(frontmatter: Record<string, unknown>): string | undefined {
@@ -447,6 +442,10 @@ export async function runMaestroCompare(
447
442
  '--test-output-dir',
448
443
  nativeOut,
449
444
  '--no-ansi',
445
+ '--format',
446
+ 'junit',
447
+ '--output',
448
+ path.join(out, 'ios-report.xml'),
450
449
  ...shared,
451
450
  ...envPairs,
452
451
  ],
@@ -456,18 +455,23 @@ export async function runMaestroCompare(
456
455
  spawnSync('xcrun', ['simctl', 'status_bar', udid, 'clear'], { stdio: 'ignore' })
457
456
  }
458
457
  console.log(`\n ios │ maestro exited ${nativeExit}\n`)
459
- const nativeLog = fs.readFileSync(path.join(out, 'ios.log'), 'utf8')
458
+ const nativeVerdicts = nativeFlowVerdictsFromJunit(
459
+ fs.readFileSync(path.join(out, 'ios-report.xml'), 'utf8'),
460
+ )
460
461
 
461
462
  // 2. peach, same instrumented flows (its --env already applied above)
462
463
  const peachOut = path.join(out, 'peach')
463
- const peachFailures = new Map<string, string>()
464
+ const peachResults = new Map<
465
+ string,
466
+ { exit: number; playbackFailure: string; outputDir: string | null }
467
+ >()
464
468
  await runMaestro(
465
469
  ['test', runTarget, '--test-output-dir', peachOut, ...shared, ...peachArgs],
466
470
  {
467
471
  ...opts,
468
472
  captureConsole: true,
469
- onFlowResult: ({ file, exit, playbackFailure }) => {
470
- if (exit !== 0) peachFailures.set(path.resolve(file), playbackFailure)
473
+ onFlowResult: ({ file, exit, playbackFailure, outputDir }) => {
474
+ peachResults.set(path.resolve(file), { exit, playbackFailure, outputDir })
471
475
  },
472
476
  },
473
477
  )
@@ -481,24 +485,56 @@ export async function runMaestroCompare(
481
485
  .sort()
482
486
  .pop()
483
487
  : undefined
484
- const peachDirs = peachFlowDirs(
485
- peachOut,
486
- instrumented.map((f) => f.copy),
487
- )
488
+ const nativeOutputs = new Map<string, Array<{ dir: string; commands: unknown }>>()
489
+ for (const entry of nativeRun
490
+ ? fs.readdirSync(nativeRun, { withFileTypes: true })
491
+ : []) {
492
+ if (!entry.isDirectory()) continue
493
+ const dir = path.join(nativeRun!, entry.name)
494
+ const commands = readJson(path.join(dir, 'commands.json'))
495
+ if (!Array.isArray(commands)) continue
496
+ // display names are not paths: maestro replaces slashes and suffixes collisions.
497
+ // the top-level bookkeeping retains the original flow identity.
498
+ let name: string | undefined
499
+ let fileStem: string | undefined
500
+ for (const command of commands) {
501
+ if (command?.metadata?.depth !== 0) continue
502
+ const config = command?.command?.applyConfigurationCommand?.config
503
+ if (typeof config?.name === 'string') name ??= config.name
504
+ const env = command?.command?.defineVariablesCommand?.env
505
+ if (typeof env?.MAESTRO_FILENAME === 'string') fileStem ??= env.MAESTRO_FILENAME
506
+ }
507
+ if (fileStem === undefined) continue
508
+ const key = JSON.stringify([name ?? fileStem, fileStem])
509
+ const outputs = nativeOutputs.get(key) ?? []
510
+ outputs.push({ dir, commands })
511
+ nativeOutputs.set(key, outputs)
512
+ }
488
513
  const imagesDir = path.join(out, 'images')
489
514
  const flows: CompareFlow[] = []
490
515
  for (const [i, flow] of instrumented.entries()) {
491
- const nativeDir = nativeRun ? path.join(nativeRun, flow.name) : null
492
- const peachDir = peachDirs[i]!
493
- const nativeRan = Boolean(nativeDir && fs.existsSync(nativeDir))
494
- const peachRan = fs.existsSync(path.join(peachDir, 'commands.json'))
516
+ const fileStem = path.basename(flow.copy).replace(/\.(ya?ml)$/i, '')
517
+ const nativeOutput = nativeOutputs.get(JSON.stringify([flow.name, fileStem]))?.shift()
518
+ const nativeDir = nativeOutput?.dir
519
+ const nativeRan = Boolean(nativeOutput)
520
+ const peachResult = peachResults.get(path.resolve(flow.copy))
521
+ const peachDir = peachResult?.outputDir
522
+ const verdicts = nativeVerdicts.get(junitFlowKey(flow.name, fileStem))
523
+ const nativeFailure = verdicts?.length
524
+ ? verdicts.shift()
525
+ : 'no native verdict in ios-report.xml'
526
+ const peachRan = Boolean(
527
+ peachDir && fs.existsSync(path.join(peachDir, 'commands.json')),
528
+ )
495
529
  const nativeSteps = nativeRan
496
- ? nativeStepsFromCommandsJson(readJson(path.join(nativeDir!, 'commands.json')))
530
+ ? nativeStepsFromCommandsJson(nativeOutput?.commands)
497
531
  : []
498
532
  const peachSteps = peachRan
499
- ? peachStepsFromCommandsJson(readJson(path.join(peachDir, 'commands.json')))
533
+ ? peachStepsFromCommandsJson(readJson(path.join(peachDir!, 'commands.json')))
500
534
  : []
501
- const consoleJson = readJson(path.join(peachDir, 'logs', 'console.json')) as {
535
+ const consoleJson = (
536
+ peachDir ? readJson(path.join(peachDir, 'logs', 'console.json')) : null
537
+ ) as {
502
538
  errors?: unknown[]
503
539
  requests?: unknown[]
504
540
  } | null
@@ -513,15 +549,17 @@ export async function runMaestroCompare(
513
549
  const nativePng = nativeDir
514
550
  ? path.join(nativeDir, 'takeScreenshot', `${capture.name}.png`)
515
551
  : ''
516
- const peachPng = path.join(peachDir, 'screenshots', `${capture.name}.png`)
552
+ const peachPng = peachDir
553
+ ? path.join(peachDir, 'screenshots', `${capture.name}.png`)
554
+ : ''
517
555
  const hasNative = Boolean(nativePng) && fs.existsSync(nativePng)
518
- const hasPeach = fs.existsSync(peachPng)
556
+ const hasPeach = Boolean(peachPng) && fs.existsSync(peachPng)
519
557
  const imageRel = path.join(
520
558
  'images',
521
559
  `${i + 1}-${capture.name.split('/').pop()}.png`,
522
560
  )
523
561
  if (hasNative && hasPeach) {
524
- const nativeBytes = fs.readFileSync(nativePng)
562
+ const nativeBytes = alignNativeCapture(fs.readFileSync(nativePng))
525
563
  const { diff, triptych } = compareScreens(
526
564
  nativeBytes,
527
565
  fs.readFileSync(peachPng),
@@ -545,21 +583,21 @@ export async function runMaestroCompare(
545
583
  flows.push({
546
584
  name: flow.name,
547
585
  file: displayPath(cwd, flow.file),
548
- native: sideFromSteps(
586
+ native: buildCompareSide(
549
587
  nativeSteps,
550
- nativeRan,
588
+ nativeFailure ? 'failed' : nativeRan ? 'passed' : 'not run',
551
589
  nativeRan
552
590
  ? []
553
591
  : [
554
592
  `maestro produced no output for this flow (exit ${nativeExit}); see ios.log`,
555
593
  ],
556
- nativeFlowFailureFromLog(nativeLog, flow.name),
594
+ nativeFailure,
557
595
  ),
558
- peach: sideFromSteps(
596
+ peach: buildCompareSide(
559
597
  peachSteps,
560
- peachRan,
598
+ peachResult ? (peachResult.exit === 0 ? 'passed' : 'failed') : 'not run',
561
599
  peachConsole,
562
- peachFailures.get(path.resolve(flow.copy)),
600
+ peachResult?.exit ? peachResult.playbackFailure : undefined,
563
601
  ),
564
602
  screens,
565
603
  })