peachsim 0.1.1625 → 0.1.1640

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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -1,6 +1,7 @@
1
1
  // peach inspect — dump node tree, accessibility info, query nodes
2
2
  // connects to the peach WS bridge (no Playwright)
3
3
 
4
+ import { Script } from 'node:vm'
4
5
  import { getCliInspectVerbMeta } from '@peach/skills/cli'
5
6
  import { renderCliCommandHelp, renderCliGroupHelp } from '@peach/skills/cli/renderers'
6
7
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
@@ -34,6 +35,7 @@ import {
34
35
  parseBridgeCliArgs,
35
36
  printBridgeWorldNotice,
36
37
  type WsBridge,
38
+ type BridgeSimInfo,
37
39
  } from '../ws-bridge'
38
40
  import {
39
41
  isTapSuccess,
@@ -193,6 +195,8 @@ async function performSingleStep(
193
195
  }
194
196
  }
195
197
 
198
+ // runs after a command already spent its whole budget, so an explicit --timeout
199
+ // must not make the liveness check wait that long a second time
196
200
  async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
197
201
  try {
198
202
  await bridge.send({ type: 'evaluate', code: '1' }, { timeoutMs: 3000 })
@@ -209,6 +213,18 @@ async function reportMissingTarget(
209
213
  verb: 'tap' | 'double-tap' | 'long-press',
210
214
  target: { mode: 'testid' | 'text'; value: string },
211
215
  ): Promise<void> {
216
+ if (target.mode === 'testid') {
217
+ // the tree can hold the id while the engine has no point to press: the node
218
+ // is not painted yet, or was replaced since the query. say so, so the
219
+ // suggestion list never names the id that was just reported missing.
220
+ const exact = await inspectFind(bridge, { testId: target.value }).catch(() => null)
221
+ if (exact?.result) {
222
+ console.error(
223
+ ` found ${target.value} in the tree but it has no tap target yet; it may be unpainted, hidden or covered: wait for it with peach wait selector ${target.value}`,
224
+ )
225
+ return
226
+ }
227
+ }
212
228
  console.error(` not found: ${target.value}`)
213
229
  if (target.mode !== 'testid') return
214
230
  const byText = await inspectFind(bridge, { text: target.value }).catch(() => null)
@@ -596,13 +612,13 @@ export function printShellPerfReport(result: Record<string, any>) {
596
612
  console.log(``)
597
613
  console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`)
598
614
  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)` : ''}`,
615
+ ` 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
616
  )
601
617
  console.log(
602
618
  ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`,
603
619
  )
604
620
  console.log(
605
- ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`,
621
+ ` present interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms (during motion, ${cadence.paintIntervals} intervals, including native frames)`,
606
622
  )
607
623
  }
608
624
  const compositorCadence = result.compositorCadence
@@ -857,11 +873,10 @@ async function waitForKeyboardVisible(
857
873
  }
858
874
 
859
875
  /**
860
- * keys for a CPU simulator. it has no keyboard bridge to probe and refuses the
861
- * `keyboard` command, so its `perform` presses each character against
862
- * whatever holds focus and fails the batch when nothing does.
876
+ * dispatch through the shared input executor, which checks the requested
877
+ * node identity before targeted typing and carries refusals out of the CLI.
863
878
  */
864
- async function performCloudKeys(
879
+ async function performKeys(
865
880
  bridge: WsBridge,
866
881
  steps: Array<Extract<PerformStep, { type: 'type' | 'typeInto' | 'key' }>>,
867
882
  actionLabel: string,
@@ -1247,7 +1262,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1247
1262
  return
1248
1263
  }
1249
1264
 
1250
- // `peach list --drivers` — pure local registry view, no bridge needed.
1265
+ // driver discovery works offline; a live bridge also reports each running renderer.
1251
1266
  // short-circuit here so users can discover drivers even when no peach
1252
1267
  // session is running.
1253
1268
  if (
@@ -1256,6 +1271,19 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1256
1271
  ) {
1257
1272
  const { buildDriverListRows } = await import('../drivers')
1258
1273
  const rows = buildDriverListRows()
1274
+ const bridge = (opts.createBridge ?? createBridgeFromParsed)(parsed)
1275
+ let sims: BridgeSimInfo[] = []
1276
+ try {
1277
+ sims = await bridge.listSims()
1278
+ } catch {
1279
+ // local availability remains useful when no bridge is running.
1280
+ } finally {
1281
+ bridge.close()
1282
+ }
1283
+ if (wantsJson(effectiveArgs)) {
1284
+ printJson({ drivers: rows, sims })
1285
+ return
1286
+ }
1259
1287
  console.log(` available drivers (${rows.length}):\n`)
1260
1288
  const idWidth = Math.max(...rows.map((r) => r.id.length), 6)
1261
1289
  const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4)
@@ -1270,6 +1298,15 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1270
1298
  console.log(` unavailable: ${row.reason}`)
1271
1299
  }
1272
1300
  }
1301
+ for (const sim of sims) {
1302
+ const renderer = sim.meta?.peachRenderer
1303
+ const executable = sim.meta?.peachHostExecutable
1304
+ if (sim.readyState === 'open' && typeof renderer === 'string') {
1305
+ console.log(
1306
+ ` in use: ${sim.id} · ${String(sim.meta?.sootsimHostDriver)} · ${renderer}${typeof executable === 'string' ? ` @ ${executable}` : ''}`,
1307
+ )
1308
+ }
1309
+ }
1273
1310
  return
1274
1311
  }
1275
1312
 
@@ -2439,7 +2476,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2439
2476
  console.error(' type-into requires a resolved text input node identity')
2440
2477
  peachExit(1)
2441
2478
  }
2442
- const typed = await performCloudKeys(
2479
+ const typed = await performKeys(
2443
2480
  bridge,
2444
2481
  [{ type: 'typeInto', text, nodeId }],
2445
2482
  'type-into',
@@ -2477,6 +2514,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2477
2514
  return {
2478
2515
  cx: n.absolutePosition.x + (n.layout.width || 0) / 2,
2479
2516
  cy: n.absolutePosition.y + (n.layout.height || 0) / 2,
2517
+ nodeId: n.nodeId,
2480
2518
  id: n.id,
2481
2519
  testID: n.testID,
2482
2520
  type: n.type,
@@ -2491,7 +2529,13 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2491
2529
  peachExit(1)
2492
2530
  }
2493
2531
  if (!tiPayload.isTextInput) {
2494
- console.error(` warning: ${targetId} is not a text input (isTextInput: false)`)
2532
+ console.error(` ${targetId} is not a text input (isTextInput: false)`)
2533
+ peachExit(1)
2534
+ }
2535
+ const nodeId: unknown = tiPayload.nodeId
2536
+ if (typeof nodeId !== 'number' || !Number.isSafeInteger(nodeId) || nodeId === 0) {
2537
+ console.error(' type-into requires a resolved text input node identity')
2538
+ peachExit(1)
2495
2539
  }
2496
2540
  // step 2: tap to focus
2497
2541
  const tapResult = await bridge.send({
@@ -2499,12 +2543,17 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2499
2543
  x: tiPayload.cx,
2500
2544
  y: tiPayload.cy,
2501
2545
  target: {
2546
+ nodeId,
2502
2547
  id: tiPayload.id ?? targetId,
2503
2548
  testID: tiPayload.testID ?? targetId,
2504
2549
  text: null,
2505
2550
  type: tiPayload.type ?? null,
2506
2551
  },
2507
2552
  })
2553
+ if (!isTapSuccess(tapResult)) {
2554
+ console.error(` type-into could not tap ${targetId}`)
2555
+ peachExit(1)
2556
+ }
2508
2557
  const targetSecureTextEntry = tiPayload.secureTextEntry === true
2509
2558
  const keyboardState = await waitForKeyboardVisible(bridge, 1000, {
2510
2559
  targetId,
@@ -2535,38 +2584,26 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2535
2584
  peachExit(1)
2536
2585
  }
2537
2586
  }
2538
- // step 3: clear existing text then type through the visible keyboard.
2539
- // count the field's value, masked while secure. the node's described
2540
- // text is its placeholder while empty, and the keyboard spec's text
2541
- // follows only predictive candidates.
2542
- const drawnCharacters = async () => {
2543
- const count: unknown = await bridge.send({
2544
- type: 'evaluate',
2545
- code: `(async () => {
2546
- const text = await window.__sootsimTest?.getTextInputValue(${tiArg})
2547
- return text ? [...new Intl.Segmenter().segment(text)].length : 0
2548
- })()`,
2549
- })
2550
- if (typeof count !== 'number') {
2551
- console.error(` could not read the current text of ${targetId}`)
2552
- peachExit(1)
2553
- }
2554
- return count
2555
- }
2556
- const existingCharacters = await drawnCharacters()
2557
- for (let i = 0; i < existingCharacters; i++) {
2558
- await bridge.send({ type: 'keyboard', action: 'press', text: 'delete' })
2559
- }
2560
- // backspace clears only what precedes the caret the tap placed
2561
- if (existingCharacters > 0 && (await drawnCharacters()) > 0) {
2562
- console.error(
2563
- ` type-into could not clear ${targetId}: text remains after the caret the tap placed`,
2564
- )
2587
+ // type at the live selection through the same guarded operation as
2588
+ // chained and cloud typing; clearing first changes controlled values.
2589
+ const typed = await performKeys(
2590
+ bridge,
2591
+ [{ type: 'typeInto', text, nodeId }],
2592
+ 'type-into',
2593
+ )
2594
+ const receipt: unknown = typed.steps[0]?.value
2595
+ if (
2596
+ !receipt ||
2597
+ typeof receipt !== 'object' ||
2598
+ typeof Reflect.get(receipt, 'secureTextEntry') !== 'boolean'
2599
+ ) {
2600
+ console.error(' type-into did not return its matched input secure state')
2565
2601
  peachExit(1)
2566
2602
  }
2567
- await bridge.send({ type: 'keyboard', action: 'type', text })
2568
2603
  const secureTextEntry =
2569
- targetSecureTextEntry || isSecureKeyboardState(keyboardState as any)
2604
+ targetSecureTextEntry ||
2605
+ Reflect.get(receipt, 'secureTextEntry') === true ||
2606
+ isSecureKeyboardState(keyboardState as any)
2570
2607
  const outputText = secureTextEntry
2571
2608
  ? SECURE_TEXT_REDACTION
2572
2609
  : redactTextForKeyboardState(text, keyboardState as any)
@@ -2606,7 +2643,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2606
2643
  peachExit(1)
2607
2644
  }
2608
2645
  if (bridge.plane === 'cloud') {
2609
- await performCloudKeys(bridge, [{ type: 'type', text }], 'type')
2646
+ await performKeys(bridge, [{ type: 'type', text }], 'type')
2610
2647
  await recordInspectAction(
2611
2648
  'inspect type',
2612
2649
  { inputText: text },
@@ -2640,7 +2677,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2640
2677
  peachExit(1)
2641
2678
  }
2642
2679
  if (bridge.plane === 'cloud') {
2643
- await performCloudKeys(bridge, [{ type: 'key', key: name }], 'key')
2680
+ await performKeys(bridge, [{ type: 'key', key: name }], 'key')
2644
2681
  } else {
2645
2682
  await requireVisualKeyboard(bridge, 'key')
2646
2683
  await bridge.send({ type: 'keyboard', action: 'press', text: name })
@@ -2657,7 +2694,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2657
2694
  peachExit(1)
2658
2695
  }
2659
2696
  if (bridge.plane === 'cloud') {
2660
- await performCloudKeys(
2697
+ await performKeys(
2661
2698
  bridge,
2662
2699
  keys.map((key) => ({ type: 'key' as const, key })),
2663
2700
  'key-sequence',
@@ -3694,13 +3731,17 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3694
3731
  // wrap in async to support await in worker mode. no identifier
3695
3732
  // rewriting — the snippet runs verbatim so tokens like `root`,
3696
3733
  // `debug`, `test` refer to whatever the user actually typed.
3697
- // multi-statement snippets need a block body: the bare expression
3698
- // form terminates at the first `;` (Unexpected token).
3734
+ // compile without executing to distinguish expressions from statements;
3735
+ // punctuation inside callbacks, literals and comments is not a separator.
3699
3736
  let expandedCode = code
3700
3737
  if (!expandedCode.startsWith('(async')) {
3701
- expandedCode = /[;\n]/.test(expandedCode)
3702
- ? `(async () => { ${expandedCode} })()`
3703
- : `(async () => ${expandedCode})()`
3738
+ expandedCode = `(async () => (\n${code}\n))()`
3739
+ try {
3740
+ new Script(expandedCode)
3741
+ } catch (error) {
3742
+ if (!(error instanceof SyntaxError)) throw error
3743
+ expandedCode = `(async () => {\n${code}\n})()`
3744
+ }
3704
3745
  }
3705
3746
  const result = await bridge.send({ type: 'evaluate', code: expandedCode })
3706
3747
  printJson(result)
@@ -3936,11 +3977,11 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3936
3977
  case 'start': {
3937
3978
  const result = await bridge.send({
3938
3979
  type: 'evaluate',
3939
- code: `(() => {
3980
+ code: `(async () => {
3940
3981
  if (!window.__sootsimShellPerf) {
3941
3982
  return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
3942
3983
  }
3943
- window.__sootsimShellPerf.start()
3984
+ await window.__sootsimShellPerf.start()
3944
3985
  return { started: true }
3945
3986
  })()`,
3946
3987
  })
@@ -4015,7 +4056,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4015
4056
  if (!window.__sootsimShellPerf) {
4016
4057
  return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
4017
4058
  }
4018
- window.__sootsimShellPerf.start()
4059
+ await window.__sootsimShellPerf.start()
4019
4060
  // give a frame for profiling to engage before the event
4020
4061
  await new Promise(r => requestAnimationFrame(() => r(undefined)))
4021
4062
  window.dispatchEvent(new Event('${eventName}'))
@@ -4603,7 +4644,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4603
4644
  // re-incurs the full per-command timeout. three sequential probes turn a
4604
4645
  // single 15s timeout into ~60s of apparent hang (QA F19-1). there is
4605
4646
  // nothing useful to fetch from a sim that just timed out, so skip them.
4606
- const timedOut = /^command timed out after (\d+)s$/.exec(message)
4647
+ const timedOut = /^command timed out after (\d+(?:\.\d+)?)s$/.exec(message)
4607
4648
  // a disconnected bridge is genuinely gone; recover is right and probing
4608
4649
  // it would just re-incur a full timeout. no bridge at all means there is
4609
4650
  // no sim to recover, only one to open.
@@ -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,6 +8,7 @@ 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 {
@@ -17,7 +18,8 @@ import {
17
18
  comparePassed,
18
19
  instrumentFlowSource,
19
20
  nativeStepsFromCommandsJson,
20
- nativeFlowFailureFromLog,
21
+ nativeFlowVerdictsFromJunit,
22
+ junitFlowKey,
21
23
  peachStepsFromCommandsJson,
22
24
  screensOverThreshold,
23
25
  type CompareCapture,
@@ -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,22 +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
- // peach names each flow's output dir after the file stem, suffixing repeats
205
- // in run order (commands/maestro.ts uniqueFlowDir).
206
- function peachFlowDirs(root: string, files: string[]): string[] {
207
- const seen = new Map<string, number>()
208
- return files.map((file) => {
209
- const slug =
210
- path
211
- .basename(file)
212
- .replace(/\.(ya?ml)$/i, '')
213
- .replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
214
- const n = (seen.get(slug) ?? 0) + 1
215
- seen.set(slug, n)
216
- return path.join(root, n === 1 ? slug : `${slug}-${n}`)
217
- })
218
- }
219
-
220
238
  // the flow's appId as maestro resolves it: its `${...}` templates evaluate
221
239
  // against --env values (in process.env) and the flow's own env block.
222
240
  function resolveFlowAppId(frontmatter: Record<string, unknown>): string | undefined {
@@ -424,6 +442,10 @@ export async function runMaestroCompare(
424
442
  '--test-output-dir',
425
443
  nativeOut,
426
444
  '--no-ansi',
445
+ '--format',
446
+ 'junit',
447
+ '--output',
448
+ path.join(out, 'ios-report.xml'),
427
449
  ...shared,
428
450
  ...envPairs,
429
451
  ],
@@ -433,18 +455,23 @@ export async function runMaestroCompare(
433
455
  spawnSync('xcrun', ['simctl', 'status_bar', udid, 'clear'], { stdio: 'ignore' })
434
456
  }
435
457
  console.log(`\n ios │ maestro exited ${nativeExit}\n`)
436
- 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
+ )
437
461
 
438
462
  // 2. peach, same instrumented flows (its --env already applied above)
439
463
  const peachOut = path.join(out, 'peach')
440
- const peachResults = new Map<string, { exit: number; playbackFailure: string }>()
464
+ const peachResults = new Map<
465
+ string,
466
+ { exit: number; playbackFailure: string; outputDir: string | null }
467
+ >()
441
468
  await runMaestro(
442
469
  ['test', runTarget, '--test-output-dir', peachOut, ...shared, ...peachArgs],
443
470
  {
444
471
  ...opts,
445
472
  captureConsole: true,
446
- onFlowResult: ({ file, exit, playbackFailure }) => {
447
- peachResults.set(path.resolve(file), { exit, playbackFailure })
473
+ onFlowResult: ({ file, exit, playbackFailure, outputDir }) => {
474
+ peachResults.set(path.resolve(file), { exit, playbackFailure, outputDir })
448
475
  },
449
476
  },
450
477
  )
@@ -458,26 +485,56 @@ export async function runMaestroCompare(
458
485
  .sort()
459
486
  .pop()
460
487
  : undefined
461
- const peachDirs = peachFlowDirs(
462
- peachOut,
463
- instrumented.map((f) => f.copy),
464
- )
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
+ }
465
513
  const imagesDir = path.join(out, 'images')
466
514
  const flows: CompareFlow[] = []
467
515
  for (const [i, flow] of instrumented.entries()) {
468
- const nativeDir = nativeRun ? path.join(nativeRun, flow.name) : null
469
- const peachDir = peachDirs[i]!
470
- const nativeRan = Boolean(nativeDir && fs.existsSync(nativeDir))
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)
471
520
  const peachResult = peachResults.get(path.resolve(flow.copy))
472
- const nativeFailure = nativeFlowFailureFromLog(nativeLog, flow.name)
473
- const peachRan = fs.existsSync(path.join(peachDir, 'commands.json'))
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
+ )
474
529
  const nativeSteps = nativeRan
475
- ? nativeStepsFromCommandsJson(readJson(path.join(nativeDir!, 'commands.json')))
530
+ ? nativeStepsFromCommandsJson(nativeOutput?.commands)
476
531
  : []
477
532
  const peachSteps = peachRan
478
- ? peachStepsFromCommandsJson(readJson(path.join(peachDir, 'commands.json')))
533
+ ? peachStepsFromCommandsJson(readJson(path.join(peachDir!, 'commands.json')))
479
534
  : []
480
- 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 {
481
538
  errors?: unknown[]
482
539
  requests?: unknown[]
483
540
  } | null
@@ -492,15 +549,17 @@ export async function runMaestroCompare(
492
549
  const nativePng = nativeDir
493
550
  ? path.join(nativeDir, 'takeScreenshot', `${capture.name}.png`)
494
551
  : ''
495
- const peachPng = path.join(peachDir, 'screenshots', `${capture.name}.png`)
552
+ const peachPng = peachDir
553
+ ? path.join(peachDir, 'screenshots', `${capture.name}.png`)
554
+ : ''
496
555
  const hasNative = Boolean(nativePng) && fs.existsSync(nativePng)
497
- const hasPeach = fs.existsSync(peachPng)
556
+ const hasPeach = Boolean(peachPng) && fs.existsSync(peachPng)
498
557
  const imageRel = path.join(
499
558
  'images',
500
559
  `${i + 1}-${capture.name.split('/').pop()}.png`,
501
560
  )
502
561
  if (hasNative && hasPeach) {
503
- const nativeBytes = fs.readFileSync(nativePng)
562
+ const nativeBytes = alignNativeCapture(fs.readFileSync(nativePng))
504
563
  const { diff, triptych } = compareScreens(
505
564
  nativeBytes,
506
565
  fs.readFileSync(peachPng),
@@ -51,7 +51,12 @@ export interface RunMaestroOptions {
51
51
  // (maestro compare reads it for the errors Peach saw)
52
52
  captureConsole?: boolean
53
53
  // each flow's final verdict, including a failure before any step ran
54
- onFlowResult?: (result: { file: string; exit: number; playbackFailure: string }) => void
54
+ onFlowResult?: (result: {
55
+ file: string
56
+ exit: number
57
+ playbackFailure: string
58
+ outputDir: string | null
59
+ }) => void
55
60
  }
56
61
 
57
62
  const MAESTRO_DIRS = ['.maestro', 'maestro']
@@ -518,6 +523,7 @@ export async function runMaestro(
518
523
  let worstExit = 0
519
524
  const results: Array<{
520
525
  file: string
526
+ outputDir: string | null
521
527
  exit: number
522
528
  durationMs: number
523
529
  steps: ReturnType<typeof getLastFlowTraceSteps>
@@ -537,6 +543,7 @@ export async function runMaestro(
537
543
  console.error(` error: ${playbackFailure}`)
538
544
  results.push({
539
545
  file,
546
+ outputDir: flowTestDir,
540
547
  exit: 1,
541
548
  durationMs: 0,
542
549
  steps: [],
@@ -610,6 +617,7 @@ export async function runMaestro(
610
617
  }
611
618
  const result = {
612
619
  file,
620
+ outputDir: flowTestDir,
613
621
  exit,
614
622
  durationMs,
615
623
  steps: traceSteps,
@@ -88,7 +88,7 @@ export async function runPreview(args: string[]): Promise<number> {
88
88
  bundleSizeBytes: artifact.bytes.byteLength,
89
89
  bundleContentType: 'application/javascript',
90
90
  bundleEncoding: 'br',
91
- bundleMinified: true,
91
+ bundleMinified: artifact.minified,
92
92
  bundleOrigin: null,
93
93
  entry: null,
94
94
  isTransformed: false,