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.
- package/LICENSE +1 -1
- package/README.md +3 -2
- package/cli/bin.ts +6 -2
- package/cli/bridge-flow-runner.ts +79 -72
- package/cli/commands/box/bundle-plane.ts +7 -2
- package/cli/commands/box.ts +9 -2
- package/cli/commands/cleanup.ts +6 -6
- package/cli/commands/control.ts +36 -6
- package/cli/commands/detox.ts +25 -11
- package/cli/commands/flow.ts +20 -8
- package/cli/commands/inspect.ts +22 -37
- package/cli/commands/maestro-generate.ts +5 -12
- package/cli/commands/maestro.ts +16 -6
- package/cli/commands/mode.ts +11 -7
- package/cli/commands/platform.ts +1 -0
- package/cli/commands/report-issue.ts +1 -1
- package/cli/commands/setup.ts +16 -11
- package/cli/commands/show-touches-mode.ts +33 -0
- package/cli/commands/skills.ts +16 -0
- package/cli/commands/test.ts +22 -1
- package/cli/commands/upload.ts +18 -8
- package/cli/commands/welcome.ts +3 -3
- package/cli/drivers/playwright-sim-host.ts +16 -8
- package/cli/drivers/playwright.ts +8 -5
- package/cli/help-core.ts +9 -4
- package/cli/internal-child.ts +14 -2
- package/cli/levenshtein.ts +27 -0
- package/cli/main.ts +28 -18
- package/cli/run-rnx.ts +8 -4
- package/cli/ws-bridge.ts +3 -2
- package/detox/index.ts +234 -7
- package/dist-lib/agent-daemon-client.cjs +2 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +4 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +18 -8
- package/dist-lib/capture-contract.mjs +18 -8
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +170 -13
- package/dist-lib/dev-bundle-resolution.cjs +2 -2
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +280 -138
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +62 -12
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +7 -1
- package/dist-lib/menu.mjs +7 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +43 -4
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +431 -276
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +14512 -14208
- package/dist-lib/vite-kotlin.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +44 -4
- package/install.mjs +16 -0
- package/install.sh +199 -0
- package/package.json +6 -1
- package/scripts/dev-server-scanner.ts +77 -46
- package/skills/contrast/SKILL.md +1 -1
- package/src/bridge-constants.ts +11 -0
- package/src/capture-contract.ts +40 -7
- package/src/dev-bundle-resolution.ts +1 -1
- package/src/host/bridge-host.ts +26 -137
- package/src/menu.ts +7 -0
- package/src/native-seam-manifest.ts +4 -16
- package/src/runtime-assets.ts +59 -2
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
486
|
-
y:
|
|
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 < ${
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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 =
|
|
1964
|
-
const
|
|
1965
|
-
const
|
|
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 =
|
|
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
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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,
|
|
2123
|
-
const shownY = Math.max(0, Math.min(y + height,
|
|
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
|
-
|
|
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 {
|
|
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;
|
package/cli/commands/box.ts
CHANGED
|
@@ -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({
|
|
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,
|
package/cli/commands/cleanup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
package/cli/commands/control.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
1603
|
-
//
|
|
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
|
package/cli/commands/detox.ts
CHANGED
|
@@ -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
|
|
42
|
+
peach detox --no-launch skip the running-shell check
|
|
43
43
|
|
|
44
|
-
peach detox
|
|
45
|
-
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
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
|
|
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
|
}
|
package/cli/commands/flow.ts
CHANGED
|
@@ -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,
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
635
|
-
//
|
|
636
|
-
//
|
|
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
|
-
|
|
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
|
}
|