peachsim 0.1.626 → 0.1.628

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 (106) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +3 -2
  3. package/cli/bin.ts +6 -2
  4. package/cli/bridge-flow-runner.ts +79 -72
  5. package/cli/commands/box/bundle-plane.ts +7 -2
  6. package/cli/commands/box.ts +9 -2
  7. package/cli/commands/cleanup.ts +6 -6
  8. package/cli/commands/control.ts +36 -6
  9. package/cli/commands/detox.ts +25 -11
  10. package/cli/commands/flow.ts +20 -8
  11. package/cli/commands/inspect.ts +22 -37
  12. package/cli/commands/maestro-generate.ts +5 -12
  13. package/cli/commands/maestro.ts +16 -6
  14. package/cli/commands/mode.ts +11 -7
  15. package/cli/commands/platform.ts +1 -0
  16. package/cli/commands/report-issue.ts +1 -1
  17. package/cli/commands/setup.ts +16 -11
  18. package/cli/commands/show-touches-mode.ts +33 -0
  19. package/cli/commands/skills.ts +16 -0
  20. package/cli/commands/test.ts +22 -1
  21. package/cli/commands/upload.ts +18 -8
  22. package/cli/commands/welcome.ts +3 -3
  23. package/cli/drivers/playwright-sim-host.ts +16 -8
  24. package/cli/drivers/playwright.ts +8 -5
  25. package/cli/help-core.ts +9 -4
  26. package/cli/internal-child.ts +14 -2
  27. package/cli/levenshtein.ts +27 -0
  28. package/cli/main.ts +28 -18
  29. package/cli/run-rnx.ts +8 -4
  30. package/cli/ws-bridge.ts +3 -2
  31. package/detox/index.ts +234 -7
  32. package/dist-lib/agent-daemon-client.cjs +2 -1
  33. package/dist-lib/agent-events.cjs +1 -1
  34. package/dist-lib/agent-identity.cjs +1 -1
  35. package/dist-lib/agent-sessions.cjs +1 -1
  36. package/dist-lib/attached-projects.cjs +1 -1
  37. package/dist-lib/auth/shared-session.cjs +1 -1
  38. package/dist-lib/backend-origin.cjs +1 -1
  39. package/dist-lib/beta.cjs +1 -1
  40. package/dist-lib/beta.mjs +1 -1
  41. package/dist-lib/bridge-constants.cjs +4 -1
  42. package/dist-lib/bridge-contract-input.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.mjs +1 -1
  44. package/dist-lib/bridge-contract.cjs +1 -1
  45. package/dist-lib/bridge-contract.mjs +1 -1
  46. package/dist-lib/capture-contract.cjs +18 -8
  47. package/dist-lib/capture-contract.mjs +18 -8
  48. package/dist-lib/cli-constants.cjs +1 -1
  49. package/dist-lib/cloud-contract.cjs +1 -1
  50. package/dist-lib/cloud-contract.mjs +1 -1
  51. package/dist-lib/cloud-test-args.cjs +1 -1
  52. package/dist-lib/cloud-test-args.mjs +1 -1
  53. package/dist-lib/cloud.cjs +1 -1
  54. package/dist-lib/cloud.mjs +1 -1
  55. package/dist-lib/config.cjs +1 -1
  56. package/dist-lib/detox/index.cjs +170 -13
  57. package/dist-lib/dev-bundle-resolution.cjs +2 -2
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +280 -138
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +62 -12
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/menu.cjs +7 -1
  69. package/dist-lib/menu.mjs +7 -1
  70. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  71. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  72. package/dist-lib/metro-production-bundle.cjs +1 -1
  73. package/dist-lib/metro-production-bundle.mjs +1 -1
  74. package/dist-lib/metro.cjs +43 -4
  75. package/dist-lib/profiles.cjs +1 -1
  76. package/dist-lib/public-brand.cjs +1 -1
  77. package/dist-lib/react-native-host-modules.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.mjs +1 -1
  79. package/dist-lib/render-mode.cjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
  81. package/dist-lib/sdk.cjs +1 -1
  82. package/dist-lib/sdk.mjs +1 -1
  83. package/dist-lib/skills.cjs +431 -276
  84. package/dist-lib/swift-compile.cjs +1 -1
  85. package/dist-lib/swift-compile.mjs +1 -1
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +1 -1
  89. package/dist-lib/swift-submit.mjs +1 -1
  90. package/dist-lib/swift.cjs +14512 -14208
  91. package/dist-lib/vite-kotlin.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1 -1
  93. package/dist-lib/vite.cjs +44 -4
  94. package/install.mjs +16 -0
  95. package/install.sh +199 -0
  96. package/package.json +6 -1
  97. package/scripts/dev-server-scanner.ts +77 -46
  98. package/skills/contrast/SKILL.md +1 -1
  99. package/src/bridge-constants.ts +11 -0
  100. package/src/capture-contract.ts +40 -7
  101. package/src/dev-bundle-resolution.ts +1 -1
  102. package/src/host/bridge-host.ts +26 -137
  103. package/src/menu.ts +7 -0
  104. package/src/native-seam-manifest.ts +4 -16
  105. package/src/runtime-assets.ts +59 -2
  106. package/src/vite-plugin.ts +723 -25
package/LICENSE CHANGED
@@ -3,7 +3,7 @@ Proprietary Software License
3
3
  Copyright (c) Tamagui LLC. All rights reserved.
4
4
 
5
5
  This package (peachsim) is proprietary software of Tamagui LLC, licensed under
6
- the rnx Software License Agreement: https://peachsim.com/legal/license
6
+ the Peach Software License Agreement: https://peachsim.com/legal/license
7
7
 
8
8
  In summary (the agreement governs): personal, non-commercial use is free;
9
9
  commercial use requires an active Pro subscription after a single 30-day
package/README.md CHANGED
@@ -8,7 +8,8 @@ integration library used to drive a browser-native React Native simulator.
8
8
  The CLI and library are separate public artifacts. The recommended installer
9
9
  downloads one Bun-compiled `peach` executable that needs no Node.js, npm, or Bun
10
10
  installation. The `peachsim` npm package contains bundler plugins, testing
11
- drivers, and library exports, with no command-line binary.
11
+ drivers, and library exports; its only command, `npx peachsim`, runs that same
12
+ installer from the copy of `install.sh` it ships.
12
13
 
13
14
  The executable updates itself with `peach upgrade`. Successful interactive human
14
15
  commands refresh a cached stable CLI version in a detached process at most once
@@ -220,7 +221,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
220
221
  ## Getting started
221
222
 
222
223
  ```sh
223
- curl -fsSL https://peachsim.com/install.sh | sh
224
+ curl -fsSL https://peachsim.com/install.sh | sh # or: npx peachsim
224
225
  peach # open ConnectRN and choose a running app in the simulator
225
226
  peach open /settings # dispatch a React Native deep link into that app
226
227
  ```
package/cli/bin.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // whole process, so the CLI itself lives in `./main` and is imported only once
11
11
  // none of them has claimed the run.
12
12
 
13
- import { RNX_INTERNAL_COMMAND } from './internal-child'
13
+ import { RNX_FORKED_SCRIPT_ENV, RNX_INTERNAL_COMMAND } from './internal-child'
14
14
 
15
15
  if (process.argv[2] === '__shell-init') {
16
16
  const { renderRnxShellInit } = await import('./shell-init')
@@ -51,7 +51,11 @@ if (process.argv[2] === '__cloud-confirm') {
51
51
  // are still working when their handler returns, and the process ends when that
52
52
  // work does. so this is the branch that must skip the CLI rather than the CLI
53
53
  // that must skip it.
54
- if (process.argv[2] === RNX_INTERNAL_COMMAND) {
54
+ const forkedScript = process.argv[2]
55
+ if (process.env[RNX_FORKED_SCRIPT_ENV] === '1' && forkedScript?.endsWith('.js')) {
56
+ const { runScript } = await import('./internal-child')
57
+ runScript(forkedScript, process.argv.slice(3))
58
+ } else if (process.argv[2] === RNX_INTERNAL_COMMAND) {
55
59
  const { runInternalChild } = await import('./internal-child')
56
60
  await runInternalChild(process.argv.slice(3))
57
61
  } else {
@@ -30,8 +30,6 @@ const DEFAULT_TIMEOUT = 10000
30
30
  const ASSERTION_LOOKUP_MS = 17_000
31
31
  const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
32
32
  const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
33
- const SCREEN_W = 393
34
- const SCREEN_H = 852
35
33
  type FlowScreenshotPathMode = 'dir' | 'flow'
36
34
  type ReplayTapTarget = SootSimReplayTarget
37
35
 
@@ -472,18 +470,16 @@ export function normalizeFlowScreenshotSpec(
472
470
 
473
471
  function parsePoint(
474
472
  value: string,
475
- bounds?: { x: number; y: number; width: number; height: number },
473
+ bounds: { x: number; y: number; width: number; height: number },
476
474
  ) {
477
475
  const [rawX, rawY] = value.split(',').map((part) => part.trim())
478
476
  const parseCoord = (coord: string, max: number) =>
479
477
  coord.endsWith('%')
480
478
  ? (Number.parseFloat(coord) / 100) * max
481
479
  : Number.parseFloat(coord)
482
- const originX = bounds?.x ?? 0
483
- const originY = bounds?.y ?? 0
484
480
  return {
485
- x: originX + parseCoord(rawX, bounds?.width ?? SCREEN_W),
486
- y: originY + parseCoord(rawY, bounds?.height ?? SCREEN_H),
481
+ x: bounds.x + parseCoord(rawX, bounds.width),
482
+ y: bounds.y + parseCoord(rawY, bounds.height),
487
483
  }
488
484
  }
489
485
 
@@ -624,6 +620,28 @@ export class SootSimBridgeFlowRunner {
624
620
  }) as Promise<T>
625
621
  }
626
622
 
623
+ private async screenBounds() {
624
+ const snapshot = await this.callTest<Record<string, unknown> | null>(
625
+ 'getSurfaceMetricsSnapshot',
626
+ )
627
+ const window = snapshot?.window
628
+ if (
629
+ !window ||
630
+ typeof window !== 'object' ||
631
+ !('width' in window) ||
632
+ !('height' in window) ||
633
+ typeof window.width !== 'number' ||
634
+ typeof window.height !== 'number' ||
635
+ !Number.isFinite(window.width) ||
636
+ !Number.isFinite(window.height) ||
637
+ window.width <= 0 ||
638
+ window.height <= 0
639
+ ) {
640
+ throw new Error('live screen dimensions are unavailable')
641
+ }
642
+ return { x: 0, y: 0, width: window.width, height: window.height }
643
+ }
644
+
627
645
  // ordered input batch executed inside the page in one round-trip; the
628
646
  // engine emits agent-cursor actions for every step natively.
629
647
  private async perform(steps: PerformStep[]): Promise<PerformResult> {
@@ -939,6 +957,7 @@ export class SootSimBridgeFlowRunner {
939
957
  // (match scoped to a container). a nested selector resolves the same way,
940
958
  // and a relative filter holds when ANY match of its anchor satisfies it.
941
959
  private async findElementScoped(opts: FlowTargetSelector) {
960
+ const screen = await this.screenBounds()
942
961
  const needsEveryNode =
943
962
  !opts.id ||
944
963
  opts.childOf != null ||
@@ -1045,7 +1064,7 @@ export class SootSimBridgeFlowRunner {
1045
1064
  // paints nothing, and maestro's hierarchy never holds it.
1046
1065
  const onScreen = candidates.filter((n) => {
1047
1066
  const cy = n.absolutePosition.y + n.layout.height / 2
1048
- return cy > 0 && cy < ${SCREEN_H}
1067
+ return cy > 0 && cy < ${screen.height}
1049
1068
  })
1050
1069
  if (candidates.some(painted)) candidates = candidates.filter(painted)
1051
1070
  else if (onScreen.length > 0) candidates = onScreen
@@ -1263,7 +1282,7 @@ export class SootSimBridgeFlowRunner {
1263
1282
  !opts.childOf
1264
1283
  ) {
1265
1284
  return {
1266
- ...parsePoint(opts.point),
1285
+ ...parsePoint(opts.point, await this.screenBounds()),
1267
1286
  element: null,
1268
1287
  target: undefined,
1269
1288
  }
@@ -1903,34 +1922,21 @@ export class SootSimBridgeFlowRunner {
1903
1922
  return outputDir
1904
1923
  }
1905
1924
 
1906
- async swipe(direction: string = 'UP', duration: number = 300) {
1907
- const centerX = SCREEN_W / 2
1908
- const centerY = SCREEN_H / 2
1909
- const distance = 200
1910
- let fromX = centerX
1911
- let fromY = centerY
1912
- let toX = centerX
1913
- let toY = centerY
1914
-
1915
- switch (direction.toUpperCase()) {
1916
- case 'UP':
1917
- fromY += distance
1918
- toY -= distance
1919
- break
1920
- case 'DOWN':
1921
- fromY -= distance
1922
- toY += distance
1923
- break
1924
- case 'LEFT':
1925
- fromX += distance
1926
- toX -= distance
1927
- break
1928
- case 'RIGHT':
1929
- fromX -= distance
1930
- toX += distance
1931
- break
1932
- }
1933
-
1925
+ // maestro's ios driver: a directional swipe spans the screen, from 90% to
1926
+ // 10% for UP, 20% to 90% for DOWN, and 10% to 90% across for LEFT/RIGHT,
1927
+ // over the command's 400ms default.
1928
+ async swipe(direction: string = 'UP', duration: number = 400) {
1929
+ const screen = await this.screenBounds()
1930
+ const midX = screen.width / 2
1931
+ const midY = screen.height / 2
1932
+ const [fromX, fromY, toX, toY] =
1933
+ direction.toUpperCase() === 'DOWN'
1934
+ ? [midX, screen.height * 0.2, midX, screen.height * 0.9]
1935
+ : direction.toUpperCase() === 'LEFT'
1936
+ ? [screen.width * 0.9, midY, screen.width * 0.1, midY]
1937
+ : direction.toUpperCase() === 'RIGHT'
1938
+ ? [screen.width * 0.1, midY, screen.width * 0.9, midY]
1939
+ : [midX, screen.height * 0.9, midX, screen.height * 0.1]
1934
1940
  const steps = Math.max(10, Math.round(duration / 16))
1935
1941
  await this.drag(fromX, fromY, toX, toY, steps, 16)
1936
1942
  }
@@ -1960,39 +1966,34 @@ export class SootSimBridgeFlowRunner {
1960
1966
  await this.settleAfterInput()
1961
1967
  }
1962
1968
 
1963
- async swipeCoords(start: string, end: string, duration = 300) {
1964
- const from = parsePoint(start)
1965
- const to = parsePoint(end)
1969
+ async swipeCoords(start: string, end: string, duration = 400) {
1970
+ const screen = await this.screenBounds()
1971
+ const from = parsePoint(start, screen)
1972
+ const to = parsePoint(end, screen)
1966
1973
  const steps = Math.max(10, Math.round(duration / 16))
1967
1974
  await this.drag(from.x, from.y, to.x, to.y, steps, 16)
1968
1975
  }
1969
1976
 
1977
+ // maestro's ios driver swipes from the element's center to the 10% or 90%
1978
+ // edge in the swipe's direction.
1970
1979
  async swipeFrom(
1971
1980
  from: { id?: string; text?: string },
1972
1981
  direction: string = 'UP',
1973
- duration = 300,
1982
+ duration = 400,
1974
1983
  ) {
1975
1984
  const element = await this.findElement(from)
1976
1985
  if (!element) throw new Error(`swipeFrom: element not found: ${JSON.stringify(from)}`)
1977
1986
  const startX = element.absolutePosition.x + element.layout.width / 2
1978
1987
  const startY = element.absolutePosition.y + element.layout.height / 2
1979
- const distance = 180
1980
- let endX = startX
1981
- let endY = startY
1982
- switch (direction.toUpperCase()) {
1983
- case 'UP':
1984
- endY -= distance
1985
- break
1986
- case 'DOWN':
1987
- endY += distance
1988
- break
1989
- case 'LEFT':
1990
- endX -= distance
1991
- break
1992
- case 'RIGHT':
1993
- endX += distance
1994
- break
1995
- }
1988
+ const screen = await this.screenBounds()
1989
+ const [endX, endY] =
1990
+ direction.toUpperCase() === 'DOWN'
1991
+ ? [startX, screen.height * 0.9]
1992
+ : direction.toUpperCase() === 'LEFT'
1993
+ ? [screen.width * 0.1, startY]
1994
+ : direction.toUpperCase() === 'RIGHT'
1995
+ ? [screen.width * 0.9, startY]
1996
+ : [startX, screen.height * 0.1]
1996
1997
  const steps = Math.max(10, Math.round(duration / 16))
1997
1998
  await this.drag(startX, startY, endX, endY, steps, 16)
1998
1999
  }
@@ -2103,24 +2104,24 @@ export class SootSimBridgeFlowRunner {
2103
2104
  )
2104
2105
  const deadline = Date.now() + (opts.timeout || 20000)
2105
2106
  const direction = opts.direction?.toUpperCase() || 'DOWN'
2106
- const centerX = SCREEN_W / 2
2107
- const centerY = SCREEN_H / 2
2108
- const swipeEnd =
2109
- direction === 'UP'
2110
- ? { x: centerX, y: SCREEN_H * 0.9 }
2111
- : direction === 'LEFT'
2112
- ? { x: SCREEN_W * 0.9, y: centerY }
2113
- : direction === 'RIGHT'
2114
- ? { x: SCREEN_W * 0.1, y: centerY }
2115
- : { x: centerX, y: SCREEN_H * 0.1 }
2116
-
2117
2107
  while (Date.now() < deadline) {
2108
+ const screen = await this.screenBounds()
2109
+ const centerX = screen.width / 2
2110
+ const centerY = screen.height / 2
2111
+ const swipeEnd =
2112
+ direction === 'UP'
2113
+ ? { x: centerX, y: screen.height * 0.9 }
2114
+ : direction === 'LEFT'
2115
+ ? { x: screen.width * 0.9, y: centerY }
2116
+ : direction === 'RIGHT'
2117
+ ? { x: screen.width * 0.1, y: centerY }
2118
+ : { x: centerX, y: screen.height * 0.1 }
2118
2119
  const element = await this.findElement(target)
2119
2120
  if (element) {
2120
2121
  const { x, y } = element.absolutePosition
2121
2122
  const { width, height } = element.layout
2122
- const shownX = Math.max(0, Math.min(x + width, SCREEN_W) - Math.max(x, 0))
2123
- const shownY = Math.max(0, Math.min(y + height, SCREEN_H) - Math.max(y, 0))
2123
+ const shownX = Math.max(0, Math.min(x + width, screen.width) - Math.max(x, 0))
2124
+ const shownY = Math.max(0, Math.min(y + height, screen.height) - Math.max(y, 0))
2124
2125
  const share = width * height > 0 ? (shownX * shownY) / (width * height) : 0
2125
2126
  if (share >= required) {
2126
2127
  if (opts.centerElement) {
@@ -2839,7 +2840,13 @@ export class SootSimBridgeFlowRunner {
2839
2840
  await this.swipe(step.swipe.direction, step.swipe.duration)
2840
2841
  }
2841
2842
  } else if (step.scroll) {
2842
- await this.swipe(step.scroll.direction === 'DOWN' ? 'UP' : 'DOWN')
2843
+ // maestro's scrollVertical drags 50% to 10% of the screen over 333ms;
2844
+ // scrollUp mirrors it
2845
+ const screen = await this.screenBounds()
2846
+ const midX = screen.width / 2
2847
+ const toY =
2848
+ step.scroll.direction === 'UP' ? screen.height * 0.9 : screen.height * 0.1
2849
+ await this.drag(midX, screen.height / 2, midX, toY, Math.round(333 / 16), 16)
2843
2850
  } else if (step.scrollTo) {
2844
2851
  const target =
2845
2852
  typeof step.scrollTo.nodeId === 'number'
@@ -8,15 +8,20 @@
8
8
  // the bundle is split here rather than by the caller, so the one place that
9
9
  // decides where the parts go is the one place that decides how big they are.
10
10
 
11
- import { PREBUILT_BUNDLE_PART_BYTES, prebuiltBundlePartPath } from '@peach/box/template'
11
+ import {
12
+ PREBUILT_BUNDLE_DEVICE_PATH,
13
+ PREBUILT_BUNDLE_PART_BYTES,
14
+ prebuiltBundlePartPath,
15
+ } from '@peach/box/template'
12
16
  import type { ProjectFileMeta, ProjectFilePlane } from '@peach/box/plane'
13
17
 
14
18
  export class BundleFilePlane implements ProjectFilePlane {
15
19
  private readonly parts = new Map<string, Uint8Array>()
16
20
  private readonly entries = new Map<string, ProjectFileMeta>()
17
21
 
18
- constructor(bundle: Uint8Array) {
22
+ constructor(bundle: Uint8Array, device: string | null) {
19
23
  if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
24
+ if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
20
25
  for (
21
26
  let offset = 0, index = 0;
22
27
  offset < bundle.byteLength;
@@ -527,6 +527,8 @@ function cloudBoxClient(options: {
527
527
  */
528
528
  export async function createPrebuiltBundleBox(options: {
529
529
  bundle: Uint8Array
530
+ // the simulator's device model, or null for the Box page's default
531
+ device: string | null
530
532
  name: string
531
533
  endpoint: string
532
534
  apiOrigin: string
@@ -537,7 +539,7 @@ export async function createPrebuiltBundleBox(options: {
537
539
  name: options.name,
538
540
  stack: prebuiltBundleBoxTemplate.stack,
539
541
  template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
540
- source: new BundleFilePlane(options.bundle),
542
+ source: new BundleFilePlane(options.bundle, options.device),
541
543
  })
542
544
  }
543
545
 
@@ -653,7 +655,12 @@ async function runCloudBoxCreate(
653
655
  try {
654
656
  const box =
655
657
  'bundle' in source
656
- ? await createPrebuiltBundleBox({ ...connection, bundle: source.bundle, name })
658
+ ? await createPrebuiltBundleBox({
659
+ ...connection,
660
+ bundle: source.bundle,
661
+ device: null,
662
+ name,
663
+ })
657
664
  : await cloudBoxClient(connection).create({
658
665
  name,
659
666
  stack: template.stack,
@@ -58,13 +58,13 @@ export async function runCleanup(args: string[]): Promise<number> {
58
58
  if (args.length === 1 && args[0] === '--automatic-worker') {
59
59
  try {
60
60
  const result = applyAutomaticSootsimCleanup()
61
- if (result.status === 'complete') {
61
+ // a run that found nothing stays silent: this worker writes to the
62
+ // user's terminal after the command that spawned it has returned.
63
+ if (result.status === 'complete' && result.reclaimedBytes > 0) {
62
64
  process.stderr.write(
63
- result.reclaimedBytes > 0
64
- ? ` peach automatic cleanup reclaimed ${formatSootsimBytes(
65
- result.reclaimedBytes,
66
- )}; active runtimes and app data were kept.\n`
67
- : ' peach automatic cleanup found no obsolete local data.\n',
65
+ ` peach automatic cleanup reclaimed ${formatSootsimBytes(
66
+ result.reclaimedBytes,
67
+ )}; active runtimes and app data were kept.\n`,
68
68
  )
69
69
  } else if (result.status === 'deferred') {
70
70
  process.stderr.write(
@@ -835,11 +835,15 @@ export interface BuildShellUrlOptions {
835
835
  // host page URL at boot (syncProofModeFromInitUrl) and pin everything that
836
836
  // would otherwise differ frame to frame: the TextInput caret stops blinking
837
837
  // and stays visible, and notifications stop auto-dismissing. capture-frame
838
- // also reads the raw param and stops clipping the device corners, so a
838
+ // also reads the raw param and stops clipping the device corners, and the
839
+ // shell leaves out the Dynamic Island, which is hardware, so a
839
840
  // capture matches simctl's rectangular framebuffer. without it a
840
841
  // screen holding a focused text field never reaches three identical idle
841
842
  // frames, so the screenshot guard burns all 12 attempts and then fails.
842
843
  proof?: boolean
844
+ // launch-time environment variables, stamped as repeated ?env=KEY%3DVALUE.
845
+ // see the --env flag above.
846
+ env?: string[]
843
847
  }
844
848
 
845
849
  function bundleUrlOf(urlStr: string): string | undefined {
@@ -867,12 +871,20 @@ export async function buildShellUrl(
867
871
  await stampAppSplash(await stampAppFonts(resolved, project), project),
868
872
  project,
869
873
  )
870
- if (!opts.device && !opts.proof && !opts.posture && !opts.osVersion) return stamped
874
+ if (
875
+ !opts.device &&
876
+ !opts.proof &&
877
+ !opts.posture &&
878
+ !opts.osVersion &&
879
+ !opts.env?.length
880
+ )
881
+ return stamped
871
882
  const url = new URL(stamped)
872
883
  if (opts.device) url.searchParams.set('device', opts.device)
873
884
  if (opts.posture) url.searchParams.set('posture', opts.posture)
874
885
  if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
875
886
  if (opts.proof) url.searchParams.set('proof', '1')
887
+ for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
876
888
  return url.toString()
877
889
  }
878
890
 
@@ -1412,6 +1424,7 @@ export async function runOpenCommand(
1412
1424
  '--posture',
1413
1425
  '--os-version',
1414
1426
  '--viewport',
1427
+ '--env',
1415
1428
  ],
1416
1429
  })
1417
1430
  const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
@@ -1599,16 +1612,14 @@ export async function runOpenCommand(
1599
1612
  rnxExit(1)
1600
1613
  }
1601
1614
  // the window fits the box the browser stage fits the device into, which for
1602
- // a foldable spans every posture and orientation, so the device draws at its
1603
- // natural size in the pose it opens in instead of shrinking into a window
1604
- // sized for one pose.
1615
+ // a foldable spans both postures, so the device draws at its natural size in
1616
+ // the posture it opens in instead of shrinking into a window sized for one.
1605
1617
  const deviceFitBox = deviceArg
1606
1618
  ? resolveDeviceFitBox(
1607
1619
  deviceArg as DeviceModel,
1608
1620
  false,
1609
1621
  undefined,
1610
1622
  postureArg === 'open' ? 'open' : 'closed',
1611
- true,
1612
1623
  )
1613
1624
  : undefined
1614
1625
 
@@ -1621,6 +1632,24 @@ export async function runOpenCommand(
1621
1632
  rnxExit(1)
1622
1633
  }
1623
1634
 
1635
+ // --env <KEY=VALUE> (repeatable) sets a launch-time environment variable,
1636
+ // the peach analogue of an Xcode scheme env var or SIMCTL_CHILD_<VAR>:
1637
+ // stamped as repeated ?env=, carried on AppLoadRequest, and read into a
1638
+ // Swift guest's ProcessInfo.environment (WASI environ) exactly as it would
1639
+ // read a real launch env. the guest app opts in by reading the key itself;
1640
+ // no other surface is affected.
1641
+ const envArgs: string[] = []
1642
+ for (let i = 0; i < args.length; i++) {
1643
+ if (args[i] !== '--env') continue
1644
+ const raw = args[i + 1]
1645
+ const eqIndex = raw ? raw.indexOf('=') : -1
1646
+ if (!raw || eqIndex <= 0) {
1647
+ console.error(` peach open: --env expects KEY=VALUE, got "${raw ?? ''}"`)
1648
+ rnxExit(1)
1649
+ }
1650
+ envArgs.push(raw)
1651
+ }
1652
+
1624
1653
  // --viewport <WxH> sizes the driver-launched page explicitly (film and
1625
1654
  // promo captures frame the 3d stage in landscape). takes precedence over
1626
1655
  // the device-derived window; without either, the driver keeps its own
@@ -1645,6 +1674,7 @@ export async function runOpenCommand(
1645
1674
  resolvedBundle,
1646
1675
  project,
1647
1676
  proof: args.includes('--proof'),
1677
+ env: envArgs.length ? envArgs : undefined,
1648
1678
  }
1649
1679
 
1650
1680
  // resolve the shell base URL from the bridge port we settled on, so the page
@@ -13,7 +13,6 @@ import {
13
13
  unlinkSync,
14
14
  } from 'fs'
15
15
  import { createRequire } from 'module'
16
- import { fileURLToPath } from 'node:url'
17
16
  import { tmpdir } from 'os'
18
17
  import { dirname, resolve, join, relative } from 'path'
19
18
  import { rnxPublicBrand } from '../../src/public-brand'
@@ -34,15 +33,17 @@ peach detox — run detox-style tests against a peach shell
34
33
 
35
34
  usage:
36
35
  peach detox [testFiles...] run all tests under e2e/, test/e2e/, or detox/
36
+ peach detox --app <port|url> the app each device.launchApp() opens
37
37
  peach detox --config <path> use a specific jest config
38
38
  peach detox init scaffold rnx-detox.config.cjs + sample test
39
39
  peach detox --watch jest watch mode
40
40
  peach detox --headed keep the peach shell window visible
41
41
  peach detox -t <pattern> pass a jest --testNamePattern
42
- peach detox --no-launch skip auto-launching a peach shell
42
+ peach detox --no-launch skip the running-shell check
43
43
 
44
- peach detox auto-launches a peach shell if none is running on the
45
- expected port. disable with --no-launch if you're managing the shell yourself.
44
+ peach detox needs a running peach shell: start your app and run
45
+ \`peach open <app-port>\` first (the background service keeps one ready).
46
+ it warns when none is reachable; --no-launch skips that check.
46
47
  `
47
48
 
48
49
  export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise<void> {
@@ -140,9 +141,25 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
140
141
  }
141
142
  testDir ??= process.cwd()
142
143
 
143
- // auto-launch: make sure a peach shell is reachable before running jest.
144
- // Reuse the Maestro discovery path: scan local dev servers.
145
- // and `peach open` the first match.
144
+ // jest runs from the project, so the preset must resolve from it too; the
145
+ // standalone binary has no package directory of its own to point at.
146
+ let presetPath = ''
147
+ if (!localConfig) {
148
+ try {
149
+ presetPath = createRequire(join(process.cwd(), 'package.json')).resolve(
150
+ `${rnxPublicBrand.packageName}/detox/jest-preset`,
151
+ )
152
+ } catch {
153
+ console.error(
154
+ ` error: ${rnxPublicBrand.commandName} detox runs Jest from this project, which needs its dev dependencies:\n` +
155
+ ` npm i -D ${rnxPublicBrand.packageName} jest`,
156
+ )
157
+ rnxExit(1)
158
+ }
159
+ }
160
+
161
+ // the driver navigates to a running peach shell; warn before jest starts
162
+ // when none is reachable.
146
163
  if (!noLaunch) {
147
164
  await ensureShellRunning(port)
148
165
  }
@@ -162,9 +179,6 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
162
179
  // subpath exports, so we generate a real config file instead.
163
180
  // scope roots + testMatch to the discovered test dir so jest doesn't
164
181
  // scan the entire repo (which causes haste collisions in monorepos).
165
- const presetPath = fileURLToPath(
166
- import.meta.resolve(`${rnxPublicBrand.packageName}/detox/jest-preset`),
167
- )
168
182
  // an external suite may optionally declare its own ignore patterns via e2e/jest-ignore.cjs
169
183
  const suiteIgnoreFile = testDir ? join(testDir, 'e2e', 'jest-ignore.cjs') : null
170
184
  let suiteIgnorePatterns: string[] = []
@@ -483,7 +497,7 @@ describe('example', () => {
483
497
  }
484
498
 
485
499
  console.log(`\n next:`)
486
- console.log(` peach detox # run the sample`)
500
+ console.log(` peach detox --app <dev-server-port> # run the sample`)
487
501
  console.log(` peach detox --watch`)
488
502
  rnxExit(0)
489
503
  }
@@ -21,7 +21,7 @@ import { parseFlowFile, validateFlowFile } from '../flow-file'
21
21
  import { FlowLiveStatusReporter } from '../flow-live-status'
22
22
  import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session'
23
23
  import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
24
- import { rnxExit } from '../run-rnx'
24
+ import { PeachExit, rnxExit } from '../run-rnx'
25
25
  import {
26
26
  BRIDGE_VALUE_FLAGS,
27
27
  createBridge,
@@ -155,6 +155,13 @@ export function getLastFlowPreviewUploadResult(): UploadResult | null {
155
155
  return lastPreviewUploadResult
156
156
  }
157
157
 
158
+ // why the last flow's preview upload failed, as the upload printed it
159
+ let lastPreviewUploadError: string | null = null
160
+
161
+ export function getLastFlowPreviewUploadError(): string | null {
162
+ return lastPreviewUploadError
163
+ }
164
+
158
165
  // per-step trace of the most recent playback, preview or not — callers
159
166
  // Maestro callers fold this into their run registration.
160
167
  let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
@@ -211,6 +218,7 @@ export async function runFlowPlayback(
211
218
  hooks: { beforeSteps?: () => Promise<void> } = {},
212
219
  ): Promise<number> {
213
220
  lastPreviewUploadResult = null
221
+ lastPreviewUploadError = null
214
222
  lastFlowTraceSteps = []
215
223
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
216
224
  console.log(`
@@ -260,8 +268,9 @@ options:
260
268
  headless or stale
261
269
  --proof deterministic-capture mode: pin the TextInput caret
262
270
  visible instead of blinking, stop notifications
263
- auto-dismissing, and leave the device corners unclipped
264
- to match a native simctl framebuffer. a screen holding a
271
+ auto-dismissing, leave the device corners unclipped and
272
+ leave out the Dynamic Island, to match a
273
+ native simctl framebuffer. a screen holding a
265
274
  focused text field cannot reach three identical idle
266
275
  frames without this, so its screenshot fails after 12
267
276
  tries. use it for any run whose screenshots get
@@ -278,7 +287,8 @@ options:
278
287
  public link origin for --preview (default: peachsim.com
279
288
  for prod uploads)
280
289
  --billing-kind test_run
281
- meter the chained preview upload as a test run
290
+ the chained upload is a hosted test job's recording,
291
+ covered by the job's minutes (set by the hosted recorder)
282
292
  --owner <org> associate preview upload with a linked org repo
283
293
  --repo <repo> repo name for --owner; user must belong to the team
284
294
 
@@ -631,9 +641,9 @@ flow extension:
631
641
  // pr-preview run, so allow it for preview uploads. local flow recording
632
642
  // writes a file and matches `peach record --mode video`.
633
643
  allowGitHubRecording: preview,
634
- // a test_run bills its recording from balance at finalize (preflighted
635
- // at upload init), not from a plan entitlement: the plan gate would
636
- // refuse every pay-as-you-go hosted test.
644
+ // a test_run recording is covered by its hosted job's minutes, not by a
645
+ // plan entitlement: the plan gate would refuse every pay-as-you-go
646
+ // hosted test.
637
647
  requireRecordingEntitlement: preview && previewBillingKind !== 'test_run',
638
648
  // re-arm the event recorder after every launchApp-with-reload. the
639
649
  // recorder lives in-page state and gets wiped by window.location.reload.
@@ -947,7 +957,9 @@ flow extension:
947
957
  const { runUpload } = await import('./upload')
948
958
  lastPreviewUploadResult = await runUpload(uploadArgs, {})
949
959
  } catch (err: any) {
950
- console.error(` preview upload failed: ${err?.message || err}`)
960
+ lastPreviewUploadError =
961
+ err instanceof PeachExit && err.reason ? err.reason : String(err?.message || err)
962
+ console.error(` preview upload failed: ${lastPreviewUploadError}`)
951
963
  exitCode = 1
952
964
  }
953
965
  }