peachsim 0.1.1619 → 0.1.1625

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +222 -175
  4. package/cli/cloud-client.ts +81 -48
  5. package/cli/commands/box/search-command.ts +3 -2
  6. package/cli/commands/box.ts +10 -29
  7. package/cli/commands/control.ts +108 -43
  8. package/cli/commands/inspect/settling.ts +57 -20
  9. package/cli/commands/maestro-compare.ts +11 -32
  10. package/cli/commands/platform.ts +74 -17
  11. package/cli/commands/record.ts +20 -15
  12. package/cli/commands/upload.ts +5 -1
  13. package/cli/dev-build-freshness.ts +37 -2
  14. package/cli/drivers/playwright-sim-host.ts +18 -6
  15. package/cli/maestro-accessibility.ts +53 -0
  16. package/cli/maestro-compare.ts +24 -0
  17. package/cli/outbound-endpoints.ts +27 -8
  18. package/cli/parent-pid.ts +0 -9
  19. package/cli/remote-simulators.ts +112 -0
  20. package/cli/remoteSimulatorContract.ts +21 -0
  21. package/detox/expectations.ts +5 -1
  22. package/detox/index.ts +133 -98
  23. package/detox/motion-change.cjs +37 -0
  24. package/detox/motion-change.d.cts +16 -0
  25. package/detox/navigation-lifecycle.ts +22 -4
  26. package/dist-lib/agent-daemon-client.cjs +3 -2
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +3 -2
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +1528 -937
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +4 -3
  46. package/dist-lib/cloud.mjs +4 -3
  47. package/dist-lib/config.cjs +5 -4
  48. package/dist-lib/config.mjs +5 -4
  49. package/dist-lib/detox/index.cjs +1021 -364
  50. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  51. package/dist-lib/expo-swift-bundle.cjs +1351 -804
  52. package/dist-lib/expo-swift-metadata.cjs +5 -4
  53. package/dist-lib/expo-swift-metadata.mjs +5 -4
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +2475 -1343
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +1269 -300
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +3 -2
  66. package/dist-lib/menu.mjs +3 -2
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +5 -4
  70. package/dist-lib/metro-production-bundle.mjs +5 -4
  71. package/dist-lib/metro.cjs +806 -398
  72. package/dist-lib/native-app-identity-project.cjs +729 -21
  73. package/dist-lib/native-app-identity-project.mjs +728 -21
  74. package/dist-lib/native-app-identity.cjs +232 -9
  75. package/dist-lib/native-app-identity.mjs +230 -8
  76. package/dist-lib/native-firebase-metadata.cjs +5 -4
  77. package/dist-lib/native-firebase-metadata.mjs +5 -4
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +3 -2
  82. package/dist-lib/react-native-host-modules.cjs +4 -1
  83. package/dist-lib/react-native-host-modules.mjs +4 -1
  84. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  85. package/dist-lib/sdk.cjs +89 -24
  86. package/dist-lib/sdk.mjs +89 -24
  87. package/dist-lib/skills.cjs +4471 -2394
  88. package/dist-lib/swift-compile.cjs +336 -145
  89. package/dist-lib/swift-compile.mjs +350 -150
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +1 -1
  93. package/dist-lib/swift-submit.mjs +1 -1
  94. package/dist-lib/swift-toolchain.cjs +1 -1
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +891 -557
  97. package/dist-lib/vite.cjs +68 -14
  98. package/dist-lib/worklet-executable.cjs +25 -2
  99. package/dist-lib/worklet-executable.mjs +25 -2
  100. package/package.json +2 -2
  101. package/scripts/dev-server-scanner.ts +33 -13
  102. package/src/admitted-mac-build.ts +34 -8
  103. package/src/android-app-identity.ts +146 -0
  104. package/src/android-autolinking.ts +75 -0
  105. package/src/android-string-value.ts +36 -0
  106. package/src/android-strings.ts +5 -36
  107. package/src/androidAutolinkingContract.ts +40 -0
  108. package/src/bundleGlobalFooter.ts +26 -7
  109. package/src/cloud.ts +2 -2
  110. package/src/codex-client.ts +3 -2
  111. package/src/dev-bundle-resolution.ts +58 -25
  112. package/src/expo-kotlin-host.ts +6 -36
  113. package/src/expo-swift-bundle.ts +17 -12
  114. package/src/expo-swift-host.ts +477 -173
  115. package/src/expoSwiftContract.ts +6 -0
  116. package/src/host/bridge-host.ts +7 -55
  117. package/src/host/staged-host-files.ts +29 -2
  118. package/src/installed-package-versions.ts +54 -14
  119. package/src/launchMetadataContract.ts +24 -0
  120. package/src/metro-plugin.ts +37 -42
  121. package/src/native-app-identity-project.ts +118 -3
  122. package/src/native-app-identity.ts +103 -11
  123. package/src/nitro-compile.ts +2 -5
  124. package/src/nitro-source.ts +147 -63
  125. package/src/process-group.ts +90 -60
  126. package/src/process-output.ts +0 -9
  127. package/src/public-brand.ts +1 -0
  128. package/src/react-native-host-modules.ts +3 -0
  129. package/src/sim-client.ts +6 -4
  130. package/src/swift-compile.ts +185 -31
  131. package/src/swift-distribution.lock.json +37 -11
  132. package/src/swift-distribution.ts +61 -9
  133. package/src/swift-host.ts +58 -27
  134. package/src/swift-objc-standin.ts +13 -0
  135. package/src/swift-standin-emit.ts +1 -1
  136. package/src/swift-standins.ts +17 -5
  137. package/src/vite-plugin.ts +8 -19
  138. package/src/worklet-executable.ts +30 -3
  139. package/src/worklets-babel.ts +35 -14
  140. package/src/xcode-project.ts +10 -0
@@ -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
@@ -420,7 +443,7 @@ export async function waitForSootsimIdle({
420
443
  pixelFrameToken = token
421
444
  }
422
445
  }
423
- return { layout: nodes.join(';'), screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
446
+ return { layout: nodes.join(';'), nativeHierarchy, screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
424
447
  }
425
448
 
426
449
  // how long generic background network is allowed to keep us waiting AFTER
@@ -434,6 +457,7 @@ export async function waitForSootsimIdle({
434
457
  // so the generic request counters are a courtesy, not a gate: honor them
435
458
  // only within this grace once the screen is visually still.
436
459
  const networkQuietGraceMs = 1200
460
+ let lastNativeHierarchy = null
437
461
  let lastLayout = ''
438
462
  let lastScreenPixels = null
439
463
  let screenChangedPolls = 0
@@ -461,6 +485,9 @@ export async function waitForSootsimIdle({
461
485
  }
462
486
  return true
463
487
  }
488
+ const hierarchyUnchanged = (current, previous) =>
489
+ current !== null && previous !== null && current.length === previous.length &&
490
+ current.every((node, index) => sameNativeAccessibilityShape(node, previous[index], accessibilityBounds))
464
491
  const IDLE_HELD = { idleHeld: true }
465
492
  while (true) {
466
493
  if (clockNow() >= deadline) {
@@ -506,7 +533,7 @@ export async function waitForSootsimIdle({
506
533
  // the loop head drains a transition still in flight before it ends.
507
534
  if (snapshot.workerBusy && clockNow() >= deadline && report) continue
508
535
  polls++
509
- report = { snapshot, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
536
+ report = { snapshot, nativeHierarchy: lastNativeHierarchy, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
510
537
  if (snapshot.layoutDirty) dirtyPolls++
511
538
  if (snapshot.animating || snapshot.shellAnimating) animatingPolls++
512
539
  if (snapshot.webViewStarting) webViewStartingPolls++
@@ -527,7 +554,15 @@ export async function waitForSootsimIdle({
527
554
  // strictOk and adds shell motion plus the blocking-stats floor. a
528
555
  // null count or commit reading means the runtime does not report
529
556
  // it, which reads as stable the way a missing stats block does.
530
- const visuallyStill =
557
+ // native screen checks observe output, not animation ownership. after
558
+ // its 3 s pixel phase, iOS accepts two equal native TreeNodes even
559
+ // while paint-only playback continues. overlap hierarchy reads so
560
+ // neither the gesture nor screenshot deadline grows.
561
+ const maestroStill = snapshot.screenPixels !== null && !snapshot.workerBusy && !snapshot.webViewStarting && (
562
+ !screenChanged || (clockNow() - start >= 3000 &&
563
+ hierarchyUnchanged(snapshot.nativeHierarchy, lastNativeHierarchy))
564
+ )
565
+ const visuallyStill = maestro ? maestroStill :
531
566
  strictOk &&
532
567
  !snapshot.animating &&
533
568
  !snapshot.layoutDirty &&
@@ -555,7 +590,7 @@ export async function waitForSootsimIdle({
555
590
  // grace (continuous background traffic must not block forever).
556
591
  if (
557
592
  stillProven() &&
558
- (motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
593
+ (maestro || motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
559
594
  ) {
560
595
  if (transitionWait) {
561
596
  await transitionWait
@@ -567,6 +602,7 @@ export async function waitForSootsimIdle({
567
602
  stillPolls = []
568
603
  visuallyStillSince = 0
569
604
  }
605
+ lastNativeHierarchy = snapshot.nativeHierarchy
570
606
  lastLayout = snapshot.layout
571
607
  lastScreenPixels = snapshot.screenPixels
572
608
  lastRequestTotal = snapshot.requestTotal
@@ -589,20 +625,21 @@ export async function waitForSootsimIdle({
589
625
  }
590
626
  const last = report.snapshot
591
627
  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')
628
+ if (!maestro && strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
629
+ if (!maestro && last.animating) blockers.push('animations still running')
630
+ if (!maestro && last.shellAnimating) blockers.push('shell animations still running (' + last.shellBlockers + ')')
631
+ if (!maestro && last.layoutDirty) blockers.push('layout still dirty')
632
+ if (!maestro && last.appCommits !== null && report.appCommits !== null && last.appCommits !== report.appCommits) blockers.push('layout commits still landing')
633
+ if (!maestro && last.nodeCount !== null && report.nodeCount !== null && last.nodeCount !== report.nodeCount) blockers.push('node count still changing')
634
+ if (!maestro && !motionOnly && last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
599
635
  if (last.workerBusy) blockers.push('a render worker too busy to report')
600
636
  if (last.webViewStarting) blockers.push(last.webViewBlocker || 'webView still starting')
601
- if (last.layout !== report.layout) blockers.push('layout still changing')
637
+ if (!maestro && last.layout !== report.layout) blockers.push('layout still changing')
638
+ if (maestro && !hierarchyUnchanged(last.nativeHierarchy, report.nativeHierarchy)) blockers.push(last.nativeHierarchy === null ? 'native accessibility hierarchy unavailable' : 'native accessibility hierarchy still changing')
602
639
  if (paintStill && !last.screenPixels) blockers.push('screen pixels unavailable')
603
640
  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')
641
+ if (!maestro && !motionOnly && last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
642
+ else if (!maestro && !motionOnly && last.requestTotal !== report.requestTotal) blockers.push('new requests issued')
606
643
  if (polls < 2) blockers.push('sim too busy to poll twice')
607
644
  // the last read is still: motion ended too near the deadline for the
608
645
  // stillness window to hold before it.
@@ -12,6 +12,7 @@ import { rnxHomeDir } from '../../src/home-paths'
12
12
  import { parseFlowFile } from '../flow-file'
13
13
  import {
14
14
  buildCompareHtml,
15
+ buildCompareSide,
15
16
  compareScreens,
16
17
  comparePassed,
17
18
  instrumentFlowSource,
@@ -22,7 +23,6 @@ import {
22
23
  type CompareCapture,
23
24
  type CompareFlow,
24
25
  type CompareReport,
25
- type CompareSide,
26
26
  } from '../maestro-compare'
27
27
  import { MaestroJsContext } from '../maestro-js'
28
28
  import { collectFlowFiles, loadMaestroWorkspaceConfig } from '../maestro-workspace'
@@ -201,29 +201,6 @@ function formatFailedRequest(entry: unknown): string {
201
201
  return `request failed: ${e.method ?? 'GET'} ${e.url ?? '?'} ${e.status ?? e.error ?? ''}`.trim()
202
202
  }
203
203
 
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
204
  // peach names each flow's output dir after the file stem, suffixing repeats
228
205
  // in run order (commands/maestro.ts uniqueFlowDir).
229
206
  function peachFlowDirs(root: string, files: string[]): string[] {
@@ -460,14 +437,14 @@ export async function runMaestroCompare(
460
437
 
461
438
  // 2. peach, same instrumented flows (its --env already applied above)
462
439
  const peachOut = path.join(out, 'peach')
463
- const peachFailures = new Map<string, string>()
440
+ const peachResults = new Map<string, { exit: number; playbackFailure: string }>()
464
441
  await runMaestro(
465
442
  ['test', runTarget, '--test-output-dir', peachOut, ...shared, ...peachArgs],
466
443
  {
467
444
  ...opts,
468
445
  captureConsole: true,
469
446
  onFlowResult: ({ file, exit, playbackFailure }) => {
470
- if (exit !== 0) peachFailures.set(path.resolve(file), playbackFailure)
447
+ peachResults.set(path.resolve(file), { exit, playbackFailure })
471
448
  },
472
449
  },
473
450
  )
@@ -491,6 +468,8 @@ export async function runMaestroCompare(
491
468
  const nativeDir = nativeRun ? path.join(nativeRun, flow.name) : null
492
469
  const peachDir = peachDirs[i]!
493
470
  const nativeRan = Boolean(nativeDir && fs.existsSync(nativeDir))
471
+ const peachResult = peachResults.get(path.resolve(flow.copy))
472
+ const nativeFailure = nativeFlowFailureFromLog(nativeLog, flow.name)
494
473
  const peachRan = fs.existsSync(path.join(peachDir, 'commands.json'))
495
474
  const nativeSteps = nativeRan
496
475
  ? nativeStepsFromCommandsJson(readJson(path.join(nativeDir!, 'commands.json')))
@@ -545,21 +524,21 @@ export async function runMaestroCompare(
545
524
  flows.push({
546
525
  name: flow.name,
547
526
  file: displayPath(cwd, flow.file),
548
- native: sideFromSteps(
527
+ native: buildCompareSide(
549
528
  nativeSteps,
550
- nativeRan,
529
+ nativeFailure ? 'failed' : nativeRan ? 'passed' : 'not run',
551
530
  nativeRan
552
531
  ? []
553
532
  : [
554
533
  `maestro produced no output for this flow (exit ${nativeExit}); see ios.log`,
555
534
  ],
556
- nativeFlowFailureFromLog(nativeLog, flow.name),
535
+ nativeFailure,
557
536
  ),
558
- peach: sideFromSteps(
537
+ peach: buildCompareSide(
559
538
  peachSteps,
560
- peachRan,
539
+ peachResult ? (peachResult.exit === 0 ? 'passed' : 'failed') : 'not run',
561
540
  peachConsole,
562
- peachFailures.get(path.resolve(flow.copy)),
541
+ peachResult?.exit ? peachResult.playbackFailure : undefined,
563
542
  ),
564
543
  screens,
565
544
  })
@@ -17,7 +17,7 @@ import { settingsStore } from 'peach-engine/settings/store'
17
17
  import { rnxPublicBrand } from '../../src/public-brand'
18
18
  import { UnsupportedRemoteHostError } from '../cloud-client'
19
19
  import { rethrowIfExit } from '../run-rnx'
20
- import type { CloudSession } from '../cloud-session'
20
+ import type { CloudBoxSession, CloudSession } from '../cloud-session'
21
21
 
22
22
  const PLATFORM_LABEL: Record<RuntimePlatform, string> = {
23
23
  ios: 'iOS',
@@ -275,6 +275,7 @@ async function runRemoteCreate(
275
275
  const [
276
276
  { authHeaderOrExit, cloudAccountIdOrExit },
277
277
  {
278
+ assertBundleBuiltFor,
278
279
  claimCloudSimulator,
279
280
  closeCloudBox,
280
281
  closeCloudSession,
@@ -300,6 +301,7 @@ async function runRemoteCreate(
300
301
  undefined,
301
302
  input.assetsDirectory,
302
303
  )
304
+ assertBundleBuiltFor(artifact.bytes, platform, `peach ${platform} --remote`)
303
305
  const descriptor = requireCloudSessionShellUpdate()
304
306
  const { auth, header } = authHeaderOrExit(
305
307
  `${platform} ${args.includes('--nano') ? '--nano' : '--remote'}`,
@@ -401,8 +403,8 @@ async function runRemoteCreate(
401
403
  * a nano upload answers to does not apply.
402
404
  *
403
405
  * a box created from the already-built app boots it in its own page, and this
404
- * opens the bare shell watch url minted from the box's display grant — the same
405
- * /simulator#remoteEngine fragment the nano service mints, pointed at the box.
406
+ * opens the watch url the box service mints with its display grant, on the
407
+ * deploy that serves that page, as the nano service mints one on its own.
406
408
  */
407
409
  async function runRemoteBox(
408
410
  platform: RuntimePlatform,
@@ -412,7 +414,15 @@ async function runRemoteBox(
412
414
  const command = `${platform} --remote`
413
415
  const input = remoteInputFromArgs(args, platform)
414
416
  const [
415
- { closeCloudBox, closeCloudSession, produceCloudBoxArtifact, requestBoxWatchUrl },
417
+ {
418
+ assertBundleBuiltFor,
419
+ BoxBootFailedError,
420
+ closeCloudBox,
421
+ closeCloudSession,
422
+ produceCloudBoxArtifact,
423
+ awaitBoxBoot,
424
+ requestBoxWatchUrl,
425
+ },
416
426
  box,
417
427
  { cloudBoxConnectUrl, ensureCloudBoxBrowser, readCloudBoxSession },
418
428
  { authHeaderOrExit, cloudAccountIdOrExit },
@@ -431,13 +441,35 @@ async function runRemoteBox(
431
441
  import('../cloud-session'),
432
442
  ])
433
443
  const { openInBrowser } = box
444
+ // an app that cannot boot never gets a simulator, so the box ends with the
445
+ // error instead of billing until someone finds it.
446
+ const awaitBootOrCloseBox = async (session: CloudBoxSession) => {
447
+ try {
448
+ await awaitBoxBoot({
449
+ apiOrigin: session.apiOrigin,
450
+ boxId: session.boxId,
451
+ boxToken: session.boxToken,
452
+ })
453
+ } catch (error) {
454
+ if (error instanceof BoxBootFailedError) {
455
+ await closeCloudBox(session)
456
+ try {
457
+ publishCloudSessionToShell(null)
458
+ } catch {
459
+ // no shell update channel is required outside an interactive peach shell
460
+ }
461
+ delete process.env[PEACH_CLOUD_SESSION_ENV]
462
+ }
463
+ throw error
464
+ }
465
+ }
434
466
  const artifact = await produceCloudBoxArtifact(input.bundlePath, input.assetsDirectory)
467
+ assertBundleBuiltFor(artifact.bytes, platform, `peach ${command}`)
435
468
  // the session records which bundle a box already holds, so reattaching can
436
469
  // tell the same app from a rebuilt one. the box producer returns no digest
437
470
  // of its own, so take it here over the bytes that actually crossed.
438
471
  const artifactSha256 = createHash('sha256').update(artifact.bytes).digest('hex')
439
- const endpoint = box.cloudBoxEndpoint(args, command)
440
- if (!endpoint) return 1
472
+ const endpoint = box.cloudBoxEndpoint(args)
441
473
 
442
474
  const existingSession = readCloudSession()
443
475
  // a launch link only means something to an app that is launching, so a box
@@ -464,6 +496,9 @@ async function runRemoteBox(
464
496
  connectUrl: cloudBoxConnectUrl(existingSession.apiOrigin, existingSession.boxId),
465
497
  accessToken: existingSession.boxToken,
466
498
  })
499
+ // a lapsed tab reboots the app, so a reconnect reads that boot's outcome
500
+ // before waiting on a simulator a throwing app never attaches.
501
+ await awaitBootOrCloseBox(existingSession)
467
502
  // grants are short-lived, so a reconnect mints a fresh one rather than
468
503
  // reopening whatever url the create printed.
469
504
  const watchUrl = await requestBoxWatchUrl({
@@ -551,7 +586,7 @@ async function runRemoteBox(
551
586
  // `peach remote close` cannot end it; name the command that does.
552
587
  if (!attachedToShell) {
553
588
  console.log(
554
- ` this shell has no Peach integration; the Box bills until: ${rnxPublicBrand.commandName} box stop ${created.id}`,
589
+ ` this shell has no Peach integration; the Box bills until: ${rnxPublicBrand.commandName} box delete ${created.id}`,
555
590
  )
556
591
  }
557
592
  // the session is published before the grant is minted, so a grant that never
@@ -561,6 +596,7 @@ async function runRemoteBox(
561
596
  boxId: created.id,
562
597
  boxToken: created.accessToken ?? '',
563
598
  })
599
+ await awaitBootOrCloseBox(session)
564
600
  console.log(` open ${watchUrl}`)
565
601
  return openInBrowser(watchUrl)
566
602
  }
@@ -576,11 +612,22 @@ export async function runRemoteList(args: string[]): Promise<number> {
576
612
  }
577
613
  throw new Error(`usage: peach remote list [--json]`)
578
614
  }
579
- const [{ authHeaderOrExit, cloudAccountIdOrExit }, { listRemoteSims }] =
580
- await Promise.all([import('../auth'), import('../cloud-client')])
615
+ const [
616
+ { authHeaderOrExit, cloudAccountIdOrExit },
617
+ { listAccountRemoteSimulators },
618
+ { cloudBoxApiOrigin },
619
+ ] = await Promise.all([
620
+ import('../auth'),
621
+ import('../remote-simulators'),
622
+ import('./box'),
623
+ ])
581
624
  const { auth, header } = authHeaderOrExit('remote list')
582
625
  const accountId = cloudAccountIdOrExit(auth)
583
- const sims = await listRemoteSims({ authorization: header, accountId })
626
+ const sims = await listAccountRemoteSimulators({
627
+ apiOrigin: cloudBoxApiOrigin(args, auth),
628
+ authorization: header,
629
+ accountId,
630
+ })
584
631
  if (json) {
585
632
  console.log(JSON.stringify({ sims }))
586
633
  return 0
@@ -590,6 +637,10 @@ export async function runRemoteList(args: string[]): Promise<number> {
590
637
  return 0
591
638
  }
592
639
  for (const sim of sims) {
640
+ if (sim.service === 'box') {
641
+ console.log(` ${sim.simId} ${sim.status} ${sim.name ?? ''}`)
642
+ continue
643
+ }
593
644
  const platformLabel = sim.labels['peach.platform'] ?? 'unknown'
594
645
  console.log(
595
646
  ` ${sim.simId} ${sim.status} ${platformLabel} sha256:${sim.artifact.sha256.slice(0, 12)}`,
@@ -621,11 +672,13 @@ export async function runRemoteStop(args: string[]): Promise<number> {
621
672
  const simId = positional[0]
622
673
  const [
623
674
  { authHeaderOrExit, cloudAccountIdOrExit },
624
- { deleteRemoteSim },
675
+ { stopAccountRemoteSimulator },
676
+ { cloudBoxApiOrigin },
625
677
  { publishCloudSessionToShell, readCloudSession, requireCloudSessionShellUpdate },
626
678
  ] = await Promise.all([
627
679
  import('../auth'),
628
- import('../cloud-client'),
680
+ import('../remote-simulators'),
681
+ import('./box'),
629
682
  import('../cloud-session'),
630
683
  ])
631
684
  let current: CloudSession | null = null
@@ -637,16 +690,20 @@ export async function runRemoteStop(args: string[]): Promise<number> {
637
690
  // stopping the shell's own simulator clears the shell with it, so the
638
691
  // update channel is required before anything is deleted.
639
692
  const descriptor =
640
- current?.service === 'sim' && current.simId === simId
693
+ (current?.service === 'sim' && current.simId === simId) ||
694
+ (current?.service === 'box' && current.boxId === simId)
641
695
  ? requireCloudSessionShellUpdate()
642
696
  : null
643
697
  const { auth, header } = authHeaderOrExit('remote stop')
644
698
  const accountId = cloudAccountIdOrExit(auth)
645
- const deleted = await deleteRemoteSim({
699
+ const deleted = await stopAccountRemoteSimulator(
700
+ {
701
+ apiOrigin: cloudBoxApiOrigin(args, auth),
702
+ authorization: header,
703
+ accountId,
704
+ },
646
705
  simId,
647
- authorization: header,
648
- accountId,
649
- })
706
+ )
650
707
  if (!deleted) {
651
708
  console.error(
652
709
  ` ${rnxPublicBrand.commandName} remote stop: ${simId} does not exist`,
@@ -416,40 +416,45 @@ export async function runRecord(args: string[], opts: RecordOptions) {
416
416
  }
417
417
  await new Promise((r) => setTimeout(r, durationMs))
418
418
  const deadline = Date.now() + Math.max(5_000, durationMs)
419
- let frames: Array<{ data: string; size: number }> | null = null
419
+ // frames come back a few at a time: one message holding every base64 png
420
+ // exceeds the bridge socket's message cap and closes it.
421
+ const FRAMES_PER_PAGE = 2
422
+ let saved = 0
420
423
  for (;;) {
421
424
  const result: {
422
425
  ok: boolean
423
426
  done: boolean
427
+ totalFrames?: number
424
428
  frames?: Array<{ data: string; size: number }>
425
429
  error?: string
426
430
  } | null = await bridge.send({
427
431
  type: 'evaluate',
428
- code: `window.__sootsimRecorder.getFrameCaptureResult(${start.requestId})`,
432
+ code: `window.__sootsimRecorder.getFrameCaptureResult(${start.requestId}, { offset: ${saved}, limit: ${FRAMES_PER_PAGE} })`,
429
433
  })
430
434
  if (!result) {
431
435
  console.error(' frame capture result missing')
432
436
  peachExit(1)
433
437
  }
434
- if (result.done) {
435
- if (!result.ok) {
436
- console.error(` frame capture failed: ${result.error ?? 'unknown error'}`)
438
+ if (!result.done) {
439
+ if (Date.now() >= deadline) {
440
+ console.error(' frame capture timed out')
437
441
  peachExit(1)
438
442
  }
439
- frames = result.frames ?? []
440
- break
443
+ await new Promise((r) => setTimeout(r, 100))
444
+ continue
441
445
  }
442
- if (Date.now() >= deadline) {
443
- console.error(' frame capture timed out')
446
+ if (!result.ok) {
447
+ console.error(` frame capture failed: ${result.error ?? 'unknown error'}`)
444
448
  peachExit(1)
445
449
  }
446
- await new Promise((r) => setTimeout(r, 100))
450
+ for (const f of result.frames ?? []) {
451
+ saved++
452
+ const p = `${outDir}/frame-${String(saved).padStart(3, '0')}.png`
453
+ writeFileSync(p, Buffer.from(f.data, 'base64'))
454
+ }
455
+ if (saved >= (result.totalFrames ?? 0)) break
447
456
  }
448
- frames.forEach((f, i) => {
449
- const p = `${outDir}/frame-${String(i + 1).padStart(3, '0')}.png`
450
- writeFileSync(p, Buffer.from(f.data, 'base64'))
451
- })
452
- console.log(` saved ${frames.length} frames`)
457
+ console.log(` saved ${saved} frames`)
453
458
  return
454
459
  }
455
460
 
@@ -14,6 +14,7 @@ import path from 'path'
14
14
  import { gzipSync, gunzipSync } from 'zlib'
15
15
  import { readProfileTrace } from '@peach/globals/profile-trace'
16
16
  import { toProfileFile, type ProfileFile } from '@peach/globals/profiler'
17
+ import { resolveAppFontsWire } from 'peach-engine/engine/config-plugin-fonts'
17
18
  import { shouldCompress } from 'peach-engine/preview/compress'
18
19
  import { brotliBundle } from 'peach-engine/preview/compress-node'
19
20
  import {
@@ -939,7 +940,10 @@ export async function runUpload(
939
940
  // fetcher (same code path as the build's `peach app-fonts stage`) so both
940
941
  // pipelines store identical bytes keyed by sha256(url).
941
942
  const appFontFiles: PreparedFile[] = []
942
- const appFontsWire = appFontsWireFromPageHref(snapshot.pageHref) || ''
943
+ const appFontsWire = await resolveAppFontsWire(
944
+ appFontsWireFromPageHref(snapshot.pageHref),
945
+ fetch,
946
+ )
943
947
  if (appFontsWire) {
944
948
  const fonts = await fetchAppFontFiles(appFontsWire, {
945
949
  onStaged: ({ url, byteLength }) =>
@@ -14,8 +14,8 @@
14
14
  // watcher would bundle chokidar and private engine/bundler modules into the cli.
15
15
  // deps-web belongs to a different served tree.
16
16
 
17
- import { readdirSync, statSync, type Dirent } from 'node:fs'
18
- import { join, relative, sep } from 'node:path'
17
+ import { readdirSync, statSync, utimesSync, type Dirent } from 'node:fs'
18
+ import { join, relative, resolve, sep } from 'node:path'
19
19
 
20
20
  export const DEV_BUILD_REBUILD_COMMAND = 'bun run build:peach'
21
21
 
@@ -159,6 +159,41 @@ function newestForEntry(monorepoRoot: string, repoPath: string): NewestFile | nu
159
159
  return null
160
160
  }
161
161
 
162
+ /** stamps the sentinels under `installedRoot` as built at `sourceCapturedAt`,
163
+ * when the build captured its source. an install that finds identical bytes
164
+ * (remote cache hit, byte-identical promotion) leaves their old mtimes, which
165
+ * the gate would read as a stale build; stamping the install time instead
166
+ * would hide a source edit made while the build ran. */
167
+ export function markDevBuildCurrent(
168
+ monorepoRoot: string,
169
+ installedRoot: string,
170
+ sourceCapturedAt: Date,
171
+ ): void {
172
+ const installed = resolve(installedRoot)
173
+ for (const route of ROUTES) {
174
+ for (const artifact of route.artifacts) {
175
+ const absolute = join(monorepoRoot, artifact)
176
+ if (absolute !== installed && !absolute.startsWith(installed + sep)) continue
177
+ const stack = [absolute]
178
+ while (stack.length > 0) {
179
+ const path = stack.pop()
180
+ if (path === undefined) continue
181
+ let stat: ReturnType<typeof statSync>
182
+ try {
183
+ stat = statSync(path)
184
+ } catch {
185
+ continue
186
+ }
187
+ if (stat.isDirectory()) {
188
+ for (const name of readdirSync(path)) stack.push(join(path, name))
189
+ } else {
190
+ utimesSync(path, sourceCapturedAt, sourceCapturedAt)
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+
162
197
  function toRepoPath(monorepoRoot: string, absolutePath: string): string {
163
198
  return relative(monorepoRoot, absolutePath).split(sep).join('/')
164
199
  }