peachsim 0.1.1625 → 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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -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,
@@ -1,5 +1,5 @@
1
1
  // peach record — drives the engine's built-in canvas recorder over
2
- // the WS bridge. no playwright, no ffmpeg. encoding happens in the
2
+ // the WS bridge. scene export opens an isolated viewer; encoding happens in the
3
3
  // running peach page (webm via MediaRecorder, mp4 via WebCodecs, gif
4
4
  // via gifenc, raw png frames via a shared bitmap sampler).
5
5
 
@@ -143,6 +143,8 @@ const RECORD_VALUE_FLAGS = new Set([
143
143
  '--origin',
144
144
  '--owner',
145
145
  '--repo',
146
+ '--scene',
147
+ '--shell-url',
146
148
  ])
147
149
  const RECORD_SUBCOMMANDS = new Set([
148
150
  'start',
@@ -151,6 +153,7 @@ const RECORD_SUBCOMMANDS = new Set([
151
153
  'status',
152
154
  'prelude-start',
153
155
  'upload',
156
+ 'render',
154
157
  ])
155
158
 
156
159
  // the record subcommand (start/stop/cancel/status) is the first non-flag
@@ -195,6 +198,23 @@ export async function runRecord(args: string[], opts: RecordOptions) {
195
198
  // drop the subcommand token; the rest (incl. any --sim/--port) flows on
196
199
  // to the subcommand handlers, which parse bridge flags themselves.
197
200
  const rest = [...args.slice(0, sub.index), ...args.slice(sub.index + 1)]
201
+ if (sub.name === 'render') {
202
+ const scene = valueOf(rest, '--scene')
203
+ const shellUrl = valueOf(rest, '--shell-url')
204
+ const output = valueOf(rest, '--output')
205
+ if (!scene || !shellUrl || !output)
206
+ throw new Error('record render requires --scene, --shell-url and --output')
207
+ const { renderSceneToVideo } = await import('../scene-video')
208
+ const result = await renderSceneToVideo({
209
+ scene: readFileSync(scene),
210
+ shellUrl,
211
+ output,
212
+ })
213
+ console.log(
214
+ ` video: ${resolve(output)} (${result.frameCount} frames at 60 fps, ${result.sizeBytes} bytes, rendered in ${(result.renderTimeMs / 1000).toFixed(1)}s)`,
215
+ )
216
+ return
217
+ }
198
218
  if (sub.name === 'start') {
199
219
  await recordStart(rest, opts)
200
220
  return
@@ -140,7 +140,15 @@ examples:
140
140
  // --id / --text, missing device model). surface them as a one-line
141
141
  // failure instead of an uncaught internal stack trace.
142
142
  try {
143
- await captureViaBridge(parsed, outputArg, rect, withFrame, layers, allowLoading)
143
+ await captureViaBridge(
144
+ parsed,
145
+ outputArg,
146
+ rect,
147
+ withFrame,
148
+ layers,
149
+ allowLoading,
150
+ opts.verbose === true || args.includes('--verbose') || args.includes('-v'),
151
+ )
144
152
  } catch (err) {
145
153
  console.error(
146
154
  ` screenshot failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -206,7 +214,16 @@ async function captureViaBridge(
206
214
  withFrame: boolean,
207
215
  layers: 'full' | 'tenant' | 'shell' | undefined,
208
216
  allowLoading: boolean,
217
+ verbose: boolean,
209
218
  ) {
219
+ // --verbose names the phase a slow capture spent its time in.
220
+ const phases: string[] = []
221
+ let phaseStart = Date.now()
222
+ const phase = (name: string) => {
223
+ const now = Date.now()
224
+ phases.push(`${name} ${now - phaseStart}ms`)
225
+ phaseStart = now
226
+ }
210
227
  // 45s, not 10s: a first-paint capture on a headless CI runner with software
211
228
  // GL (SwiftShader) is bounded but slow. forceRenderAll caps at ~6s (two
212
229
  // 3s-bounded passes) and the WebGL→2D drawImage readback + PNG encode of a
@@ -225,6 +242,7 @@ async function captureViaBridge(
225
242
  // affects screenshot fidelity directly, so call it out explicitly so the
226
243
  // user doesn't accept a stale frame as ground truth.
227
244
  const health = await checkSimHealth(bridge)
245
+ phase('health')
228
246
  if (health.hidden) {
229
247
  process.stderr.write(
230
248
  ' ⚠ screenshot will capture the LAST PAINTED canvas — likely stale.\n' +
@@ -267,6 +285,7 @@ async function captureViaBridge(
267
285
  }
268
286
  }
269
287
 
288
+ phase('ready')
270
289
  let resolvedRect: SampleRect | null = rect
271
290
  const matcher = (rect as { __matcher?: { id?: string; text?: string } })?.__matcher
272
291
  if (matcher) {
@@ -297,6 +316,7 @@ async function captureViaBridge(
297
316
  if (layers) request.layers = layers
298
317
  if (cropRect) request.crop = cropRect
299
318
  const dataUrl: string = await bridge.send(request)
319
+ phase('capture')
300
320
  const pngPrefix = 'data:image/png;base64,'
301
321
  // never write a 0-byte "saved" file from an empty or malformed payload —
302
322
  // the sim may be alive but not painting (check `peach get errors`)
@@ -326,6 +346,8 @@ async function captureViaBridge(
326
346
  } else {
327
347
  await writeScreenshotOutput(outputPath, rawBuffer)
328
348
  }
349
+ phase('write')
350
+ if (verbose) console.log(` timing: ${phases.join(', ')}`)
329
351
  console.log(` saved: ${outputPath}`)
330
352
  } finally {
331
353
  bridge.close()
@@ -8,7 +8,10 @@
8
8
  // run in the foreground (ctrl-c to stop). the persistent launchd/systemd
9
9
  // daemon installation is handled by `peach daemon install`.
10
10
 
11
- import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
11
+ import {
12
+ assertPeachPortEnvironment,
13
+ DEFAULT_SOOTSIM_BRIDGE_PORT,
14
+ } from '../../src/bridge-constants'
12
15
  import {
13
16
  ensureRnxHome,
14
17
  isDaemonLockfileFresh,
@@ -58,6 +61,7 @@ examples:
58
61
  peachExit(0)
59
62
  }
60
63
 
64
+ assertPeachPortEnvironment(process.env)
61
65
  const portArgIdx = args.indexOf('--port')
62
66
  const port =
63
67
  portArgIdx >= 0 && args[portArgIdx + 1]
@@ -147,7 +151,8 @@ examples:
147
151
  if (sims.length > 0) lastUsedAt = Date.now()
148
152
  else if (
149
153
  starterGoneAt &&
150
- Date.now() - Math.max(starterGoneAt, lastUsedAt, host.lastRequestAt) >= UNUSED_EXIT_MS
154
+ Date.now() - Math.max(starterGoneAt, lastUsedAt, host.lastRequestAt) >=
155
+ UNUSED_EXIT_MS
151
156
  ) {
152
157
  void shutdown('unused')
153
158
  return
@@ -17,7 +17,7 @@ import {
17
17
  writePrivacyPreferences,
18
18
  } from '../../src/home-paths'
19
19
  import { rnxRuntime } from '../../src/runtime-delivery'
20
- import { findDesktopCompanion } from '../desktop-companion'
20
+ import { findDesktopCompanion, nativeDesktopIsDefault } from '../desktop-companion'
21
21
  import { isCi, PRIVACY_NOTICE } from '../privacy'
22
22
  import { confirm, select } from '../prompt'
23
23
  import {
@@ -155,12 +155,7 @@ export async function runSetup(
155
155
  serviceInstalled = true
156
156
  }
157
157
 
158
- if (
159
- simulatorDriver !== 'playwright' &&
160
- process.platform !== 'darwin' &&
161
- !options.dryRun &&
162
- (simulatorDriver === 'desktop' || !findDesktopCompanion({ native: false }))
163
- ) {
158
+ if (simulatorDriver !== 'playwright' && !options.dryRun && !findDesktopCompanion()) {
164
159
  await runInstallDesktop(
165
160
  simulatorDriver === 'desktop' ? ['--yes'] : ['--yes', '--electron'],
166
161
  )
@@ -272,7 +267,7 @@ function readSimulatorDriverFlag(args: string[]): SimulatorDriverPreference | un
272
267
  if (desktop && browser) {
273
268
  throw new Error('--desktop and --browser cannot be used together')
274
269
  }
275
- if (desktop) return desktopHostPlatform() === 'mac-arm64' ? 'desktop' : 'electron'
270
+ if (desktop) return desktopHostPlatform() ? 'desktop' : 'electron'
276
271
  if (browser) return 'playwright'
277
272
  return undefined
278
273
  }
@@ -331,46 +326,29 @@ async function decideService(
331
326
  return confirm('Enable the background service?', true)
332
327
  }
333
328
 
329
+ // the native app is the simulator window wherever it ships, and setup installs
330
+ // it without asking. Windows chooses between the browser and its Electron app.
334
331
  async function decideSimulatorDriver(
335
332
  options: SetupOptions,
336
333
  ): Promise<SimulatorDriverPreference> {
337
- if (process.platform === 'darwin' && !findDesktopCompanion()) {
338
- if (options.simulatorDriver && options.simulatorDriver !== 'playwright') {
339
- throw new Error('install the Mac app with `peach desktop install --native` first')
340
- }
341
- return 'playwright'
342
- }
343
334
  if (options.simulatorDriver) return options.simulatorDriver
344
335
  const current = readSimulatorDriverPreference()
345
336
  if (current) return current
337
+ if (isCi()) return 'playwright'
338
+ if (nativeDesktopIsDefault()) return 'desktop'
339
+ if (process.platform !== 'win32') return 'playwright'
346
340
  if (options.ci || options.yes || options.dryRun) return 'playwright'
347
341
  requireInteractive(options, '--desktop or --browser')
348
- const desktopDescription =
349
- process.platform === 'darwin'
350
- ? 'Desktop opens your installed native runtime (beta).'
351
- : process.platform === 'win32'
352
- ? 'Desktop downloads and runs the peach installer.'
353
- : 'Desktop installs the native .deb with sudo apt-get and needs a desktop session.'
354
- const desktopChoice =
355
- process.platform === 'darwin'
356
- ? 'Native app (beta) Faster, may have more issues'
357
- : process.platform === 'win32'
358
- ? 'Desktop app Runs the peach installer'
359
- : 'Native app (beta) Faster, may have more issues'
360
342
  printPromptBlock('Simulator window', [
361
343
  'Browser opens immediately in an isolated Chrome window.',
362
- desktopDescription,
363
- 'The Desktop app opens in a dedicated native window.',
344
+ 'Desktop downloads and runs the peach installer.',
345
+ 'The Desktop app opens in a dedicated window.',
364
346
  ])
365
347
  const choice = await select('Where should peach open?', [
366
348
  'Browser (default) No additional download',
367
- desktopChoice,
349
+ 'Desktop app Runs the peach installer',
368
350
  ])
369
- return choice === 0
370
- ? 'playwright'
371
- : process.platform === 'darwin'
372
- ? 'desktop'
373
- : 'electron'
351
+ return choice === 0 ? 'playwright' : 'electron'
374
352
  }
375
353
 
376
354
  // diagnostics and crash reports are on unless turned off; setup says so once
@@ -433,15 +411,14 @@ function printIntro(options: SetupOptions): void {
433
411
  if (options.json) return
434
412
  printBrand('Run any React Native app in a local simulator. No project setup required.')
435
413
  if (desktopHostPlatform()) {
436
- printPromptBlock('Optional native runtime (beta)', [
437
- 'Try the native runtime for faster simulation. It is beta, so you may run into more issues.',
438
- 'Install the app: peach desktop install',
439
- 'Open your app: peach open <port> --driver desktop',
440
- 'Open a browser window: peach open <port> --driver playwright --headed',
414
+ printPromptBlock('Native desktop app', [
415
+ 'peach opens your app in its native desktop app, which is faster than the browser.',
416
+ 'Setup installs it, and peach open <port> uses it.',
417
+ 'Open a browser window instead: peach open <port> --driver playwright --headed',
441
418
  ...(process.platform === 'linux'
442
- ? ['Linux desktop installation uses sudo apt-get and needs a desktop session.']
419
+ ? ['On Linux the app installs with sudo apt-get and needs a desktop session.']
443
420
  : []),
444
- 'The background service is a separate choice that keeps the bridge ready for either runtime.',
421
+ 'The background service is a separate choice that keeps the bridge ready for either window.',
445
422
  ])
446
423
  }
447
424
  }
@@ -451,7 +428,7 @@ function printHelp(): void {
451
428
  peach setup - configure optional local conveniences
452
429
 
453
430
  peach works without project setup. This command can enable the faster background
454
- service, choose Browser or Desktop, configure private diagnostics, and optionally
431
+ service, install the native desktop app, configure private diagnostics, and optionally
455
432
  add an exact peachsim dependency plus a peach script for teammates.
456
433
 
457
434
  usage:
@@ -73,6 +73,11 @@ export async function runSwift(
73
73
  } else if (arg === '--scheme') {
74
74
  scheme = input[++index]
75
75
  if (!scheme || scheme.startsWith('-')) throw new Error('--scheme requires a value')
76
+ } else if (arg === '--swift-sdks-path') {
77
+ const value = input[++index]
78
+ if (!value || value.startsWith('-'))
79
+ throw new Error('--swift-sdks-path requires a value')
80
+ process.env.PEACH_SWIFT_SDKS_PATH = path.resolve(value)
76
81
  } else if (arg === '--host-port' || valueFlags.has(arg)) {
77
82
  const value = input[++index]
78
83
  if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`)