peachsim 0.1.578 → 0.1.580
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 +11 -9
- package/cli/app-catalog.ts +77 -0
- package/cli/app-fonts.ts +4 -20
- package/cli/app-splash.ts +2 -18
- package/cli/bridge-flow-runner.ts +505 -139
- package/cli/cloud-client.ts +1 -4
- package/cli/cloud-session.ts +1 -4
- package/cli/cloud-test-result-files.ts +3 -0
- package/cli/cloud-test-tunnel.ts +2 -5
- package/cli/command-registry.ts +2 -0
- package/cli/commands/agent.ts +149 -23
- package/cli/commands/assert.ts +1 -4
- package/cli/commands/box/peach-command.ts +62 -0
- package/cli/commands/box/plane-share.ts +0 -70
- package/cli/commands/box/rnx-command.ts +1 -60
- package/cli/commands/box/search-command.ts +14 -0
- package/cli/commands/box.ts +2 -2
- package/cli/commands/control.ts +121 -27
- package/cli/commands/cpu-profile.ts +23 -10
- package/cli/commands/daemon.ts +1 -9
- package/cli/commands/detox.ts +26 -18
- package/cli/commands/device.ts +129 -1
- package/cli/commands/film.ts +25 -16
- package/cli/commands/flow.ts +9 -2
- package/cli/commands/inspect/actions.ts +75 -1
- package/cli/commands/inspect/core.ts +81 -2
- package/cli/commands/inspect/describe.ts +2 -6
- package/cli/commands/inspect/env.ts +1 -1
- package/cli/commands/inspect/settling.ts +20 -3
- package/cli/commands/inspect/shared.ts +1 -4
- package/cli/commands/inspect/sleep.ts +1 -1
- package/cli/commands/inspect.ts +16 -49
- package/cli/commands/install-desktop.ts +8 -6
- package/cli/commands/login.ts +3 -5
- package/cli/commands/maestro-generate.ts +1 -4
- package/cli/commands/maestro.ts +38 -21
- package/cli/commands/no-bridge-hint.ts +0 -14
- package/cli/commands/permissions.ts +2 -2
- package/cli/commands/platform.ts +13 -6
- package/cli/commands/screenshot.ts +6 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/test.ts +6 -2
- package/cli/commands/three-mode.ts +162 -19
- package/cli/commands/upload.ts +3 -18
- package/cli/current-sim.ts +0 -1
- package/cli/desktop-companion.ts +40 -8
- package/cli/drivers/playwright-provisioning.ts +1 -4
- package/cli/flow-export.ts +1 -8
- package/cli/flow-session.ts +0 -4
- package/cli/hints.ts +1 -16
- package/cli/inspect-notice-state.ts +0 -4
- package/cli/main.ts +2 -4
- package/cli/outbound-endpoints.ts +1 -24
- package/cli/parse-args.ts +1 -2
- package/cli/rnx-in-process.ts +3 -1
- package/cli/run-rnx-core.ts +4 -2
- package/cli/run-rnx.ts +18 -10
- package/cli/send-to-box.ts +1 -4
- package/cli/telemetry.ts +1 -1
- package/cli/ws-bridge.ts +1 -5
- package/detox/gestures.ts +20 -6
- package/detox/index.ts +379 -123
- package/detox/navigation-lifecycle.ts +22 -13
- package/dist-lib/agent-daemon-client.cjs +21 -15
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +3 -17
- package/dist-lib/agent-sessions.cjs +5 -4
- package/dist-lib/attached-projects.cjs +1 -77
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +11 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +7 -5
- package/dist-lib/bridge-contract-input.mjs +7 -5
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +30 -7
- package/dist-lib/capture-contract.mjs +30 -7
- 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 +17 -11
- package/dist-lib/cloud.mjs +17 -11
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +321 -122
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +427 -212
- package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
- 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 +315 -58
- 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 +67 -31
- package/dist-lib/menu.mjs +64 -28
- package/dist-lib/metro-fingerprint-registry.cjs +8 -4
- package/dist-lib/metro-fingerprint-registry.mjs +6 -4
- package/dist-lib/metro-production-bundle.cjs +2 -2
- package/dist-lib/metro-production-bundle.mjs +2 -2
- package/dist-lib/metro.cjs +8 -4
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +7 -4
- 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 +3 -13
- package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
- package/dist-lib/sdk.cjs +129 -10
- package/dist-lib/sdk.mjs +127 -10
- package/dist-lib/skills.cjs +1635 -1121
- package/dist-lib/swift-compile.cjs +277 -70
- package/dist-lib/swift-compile.mjs +275 -70
- package/dist-lib/swift-package.cjs +17 -5
- package/dist-lib/swift-package.mjs +15 -4
- package/dist-lib/swift-submit.cjs +32 -8
- package/dist-lib/swift-submit.mjs +29 -7
- package/dist-lib/swift.cjs +30912 -577
- package/dist-lib/vite-kotlin.cjs +356 -0
- package/dist-lib/vite-swift.cjs +1743 -0
- package/dist-lib/vite.cjs +208 -1039
- package/kotlin/README.md +18 -0
- package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
- package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
- package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
- package/kotlin/Sources/org/json/JSON.kt +244 -0
- package/kotlin/toolchain.json +19 -0
- package/package.json +14 -2
- package/scripts/dev-server-scanner.ts +0 -18
- package/scripts/optional-demo-registry.ts +0 -17
- package/skills/contrast/SKILL.md +2 -2
- package/skills/peach-setup/SKILL.md +16 -0
- package/src/agent-daemon-client.ts +36 -22
- package/src/agent-identity.ts +0 -15
- package/src/agent-prompt.ts +12 -0
- package/src/attached-projects.ts +0 -72
- package/src/backend-origin.ts +10 -0
- package/src/bridge-contract-input.ts +2 -5
- package/src/bridge-contract.ts +2 -0
- package/src/capture-contract.ts +53 -11
- package/src/cloud.ts +4 -9
- package/src/dev-server-open.ts +0 -99
- package/src/home-paths.ts +2 -3
- package/src/host/agent-host.ts +82 -36
- package/src/host/bridge-host.ts +5 -11
- package/src/host/fetch-proxy-handler.ts +19 -43
- package/src/is-record.ts +4 -0
- package/src/menu.ts +82 -37
- package/src/metro-fingerprint-registry.ts +1 -4
- package/src/metro-production-bundle.ts +1 -1
- package/src/plist.ts +1 -4
- package/src/public-brand.ts +5 -3
- package/src/render-mode.ts +0 -12
- package/src/runtime-assets.ts +9 -2
- package/src/screenshots/compose.ts +7 -1
- package/src/screenshots/frame-compose.ts +21 -10
- package/src/screenshots/schema.ts +1 -4
- package/src/sim-client.ts +2 -4
- package/src/skills/builtin/maestro-test.ts +1 -4
- package/src/sleep.ts +4 -0
- package/src/swift-compile.ts +209 -47
- package/src/swift-icu.ts +140 -0
- package/src/swift-package.ts +26 -2
- package/src/swift-submit.ts +84 -4
- package/src/swift.ts +2 -0
- package/src/vite-plugin-kotlin.ts +376 -0
- package/src/vite-plugin-one.ts +224 -36
- package/src/vite-plugin-swift.ts +382 -96
- package/src/vite-plugin.ts +121 -134
- package/src/vite.d.ts +10 -0
- package/cli/resolve-assets.ts +0 -63
- package/src/rolldown-plugin-swift.ts +0 -244
- package/src/team-machine-prompt.ts +0 -73
package/cli/commands/login.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
writeSharedDesktopAuthSession,
|
|
9
9
|
type SharedDesktopAuthSession,
|
|
10
10
|
} from '../../src/auth/shared-session'
|
|
11
|
+
import { isLoopbackHost } from '../../src/backend-origin'
|
|
11
12
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
12
13
|
import { rnxExit } from '../run-rnx'
|
|
13
14
|
|
|
@@ -176,11 +177,8 @@ async function runDevLogin(origin: string): Promise<LoginResult> {
|
|
|
176
177
|
function isLocalOrigin(origin: string): boolean {
|
|
177
178
|
try {
|
|
178
179
|
const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
179
|
-
if (hostname
|
|
180
|
-
if (/^127(?:\.\d{1,3}){3}$/.test(hostname)) return true
|
|
181
|
-
if (hostname === '::1' || hostname === '0.0.0.0') return true
|
|
180
|
+
if (isLoopbackHost(hostname)) return true
|
|
182
181
|
if (hostname.endsWith('.local')) return true
|
|
183
|
-
if (hostname.endsWith('.localhost')) return true
|
|
184
182
|
// VM↔host bridge aliases — orb, docker, lima, multipass all forward the
|
|
185
183
|
// host on a well-known name (IPv4 or IPv6) so a VM-internal `peach login`
|
|
186
184
|
// can still take the dev-login fast path against the host's Contrast dev
|
|
@@ -321,7 +319,7 @@ export async function runLogin(args: string[]) {
|
|
|
321
319
|
return
|
|
322
320
|
}
|
|
323
321
|
|
|
324
|
-
const url = new URL(`${origin.replace(/\/$/, '')}/auth/
|
|
322
|
+
const url = new URL(`${origin.replace(/\/$/, '')}/auth/peach-cli`)
|
|
325
323
|
url.searchParams.set('state', state)
|
|
326
324
|
url.searchParams.set('port', String(address.port))
|
|
327
325
|
url.searchParams.set('contrast', origin)
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './flow'
|
|
19
19
|
import { inspectDescribe } from './inspect/core'
|
|
20
20
|
import { resolveDefaultUploadOrigin } from './upload'
|
|
21
|
+
import { isRecord } from '../../src/is-record'
|
|
21
22
|
|
|
22
23
|
const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
|
|
23
24
|
const DESCRIBE_CONTEXT_LIMIT = 18_000
|
|
@@ -210,10 +211,6 @@ function extractJson(text: string): unknown {
|
|
|
210
211
|
return JSON.parse(candidate)
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
214
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
215
|
-
}
|
|
216
|
-
|
|
217
214
|
function parseGeneratedFlow(text: string): {
|
|
218
215
|
summary: string
|
|
219
216
|
steps: Record<string, unknown>[]
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -111,9 +111,12 @@ peach maestro — compatibility matrix
|
|
|
111
111
|
|
|
112
112
|
supported verbs:
|
|
113
113
|
launchApp (including arguments), stopApp, clearState, clearKeychain (warn-only),
|
|
114
|
-
|
|
114
|
+
selectors: text, id, index (negative from the end), childOf, below, above,
|
|
115
|
+
leftOf, rightOf, width / height / tolerance, traits, enabled, selected, checked,
|
|
116
|
+
tapOn (including repeat / delay), doubleTapOn, tapAtCoords, longPressOn,
|
|
115
117
|
inputText, pressKey, dispatchKey, hideKeyboard, eraseText,
|
|
116
|
-
|
|
118
|
+
setClipboard, pasteText,
|
|
119
|
+
assertVisible, assertNotVisible, assertTrue, assertTreeContains,
|
|
117
120
|
waitFor, extendedWaitUntil, waitForAnimationToEnd,
|
|
118
121
|
scroll, scrollUntilVisible, scrollTo, swipe, pinch,
|
|
119
122
|
takeScreenshot, dumpTree, back,
|
|
@@ -158,10 +161,12 @@ partial:
|
|
|
158
161
|
MAESTRO_* (so \${SOOTSIM_*} credentials resolve); a bare \${NAME} that is
|
|
159
162
|
undefined fails the step loudly instead of typing "undefined".
|
|
160
163
|
|
|
161
|
-
not yet implemented (
|
|
162
|
-
travel, setLocation, setAirplaneMode, killApp, faker,
|
|
164
|
+
not yet implemented (the step fails with "unsupported flow step"):
|
|
165
|
+
travel, setLocation, setAirplaneMode, killApp, faker, inputRandom*,
|
|
163
166
|
addMedia, startRecording, stopRecording (use --record flag),
|
|
164
167
|
repeat.while (only repeat.times is supported).
|
|
168
|
+
selector keys that fail the step: containsChild, containsDescendants,
|
|
169
|
+
focused, css.
|
|
165
170
|
`)
|
|
166
171
|
}
|
|
167
172
|
|
|
@@ -303,7 +308,9 @@ export function extractMaestroCompatFlags(args: string[]): {
|
|
|
303
308
|
break
|
|
304
309
|
case '--platform':
|
|
305
310
|
if (value.toLowerCase() !== 'ios') {
|
|
306
|
-
throw new Error(
|
|
311
|
+
throw new Error(
|
|
312
|
+
`peach emulates iOS only; --platform ${value} is not supported`,
|
|
313
|
+
)
|
|
307
314
|
}
|
|
308
315
|
compat.platform = value
|
|
309
316
|
break
|
|
@@ -874,23 +881,33 @@ export async function runMaestro(
|
|
|
874
881
|
}
|
|
875
882
|
|
|
876
883
|
const upload = getLastFlowPreviewUploadResult()
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
// the collected
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
884
|
+
// only --preview uploads, so only it leaves receipts in the working
|
|
885
|
+
// directory. they are the per-flow manifest: written with null ids when
|
|
886
|
+
// the upload failed, so the flow never disappears from the collected
|
|
887
|
+
// entries.
|
|
888
|
+
if (args.includes('--preview')) {
|
|
889
|
+
const previewPayload = {
|
|
890
|
+
flow: path.relative(cwd, file) || file,
|
|
891
|
+
previewId: upload?.shareId ?? null,
|
|
892
|
+
previewUrl: upload?.previewUrl ?? null,
|
|
893
|
+
// the failing step as the junit report names it, on one line
|
|
894
|
+
failure:
|
|
895
|
+
exit === 0
|
|
896
|
+
? null
|
|
897
|
+
: failureFromTraceSteps(traceSteps, 'flow playback failed').body.split(
|
|
898
|
+
'\n',
|
|
899
|
+
)[0],
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
|
|
903
|
+
fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
|
|
904
|
+
}
|
|
905
|
+
} catch {}
|
|
906
|
+
if (!upload?.shareId && !upload?.previewUrl) {
|
|
907
|
+
console.error(' error: required hosted recording upload failed')
|
|
908
|
+
if (exit === 0) exit = 1
|
|
909
|
+
if (worstExit === 0) worstExit = 1
|
|
888
910
|
}
|
|
889
|
-
} catch {}
|
|
890
|
-
if (!upload?.shareId && !upload?.previewUrl && args.includes('--preview')) {
|
|
891
|
-
console.error(' error: required hosted recording upload failed')
|
|
892
|
-
if (exit === 0) exit = 1
|
|
893
|
-
if (worstExit === 0) worstExit = 1
|
|
894
911
|
}
|
|
895
912
|
|
|
896
913
|
if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// shared hint printed when the CLI has no bridge or no desktop companion.
|
|
2
2
|
|
|
3
|
-
import { findDesktopCompanion } from '../desktop-companion'
|
|
4
3
|
import type { BridgeSimInfo } from '../ws-bridge'
|
|
5
4
|
|
|
6
5
|
export function printMissingCompanionHint() {
|
|
@@ -14,19 +13,6 @@ export function printMissingCompanionHint() {
|
|
|
14
13
|
console.error('')
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
export function printMissingBridgeHint(port: number) {
|
|
18
|
-
const companion = findDesktopCompanion()
|
|
19
|
-
console.log(` note: no peach bridge detected on port ${port}`)
|
|
20
|
-
if (companion) {
|
|
21
|
-
console.log(` launch the installed companion with:`)
|
|
22
|
-
console.log(` peach desktop`)
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
console.log('')
|
|
26
|
-
console.log(' run `peach daemon install` to enable the faster background service,')
|
|
27
|
-
console.log(' or run `peach serve` in another shell for a foreground bridge.')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
16
|
export function printMissingSimHint(port: number) {
|
|
31
17
|
console.error('')
|
|
32
18
|
console.error(` no sim is connected to the peach bridge on port ${port}.`)
|
|
@@ -28,7 +28,7 @@ const STATUSES: readonly SystemPermissionStatus[] = [
|
|
|
28
28
|
'unavailable',
|
|
29
29
|
]
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
export const PERMISSION_KINDS: readonly SystemPermissionKind[] = [
|
|
32
32
|
'app-tracking-transparency',
|
|
33
33
|
'bluetooth',
|
|
34
34
|
'calendars',
|
|
@@ -71,7 +71,7 @@ function flagValue(args: string[], flag: string): string | undefined {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function permissionKind(value: string): SystemPermissionKind | null {
|
|
74
|
-
return
|
|
74
|
+
return PERMISSION_KINDS.find((candidate) => candidate === value) ?? null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function permissionStatus(value: string): SystemPermissionStatus | null {
|
package/cli/commands/platform.ts
CHANGED
|
@@ -460,12 +460,6 @@ async function runRemoteBox(
|
|
|
460
460
|
apiKey: header.replace(/^Bearer /, ''),
|
|
461
461
|
accountId,
|
|
462
462
|
})
|
|
463
|
-
// creating a box starts no browser, and only its Browser Run tab hosts the
|
|
464
|
-
// simulator, so without this the viewer opens onto a box nobody is running.
|
|
465
|
-
await ensureCloudBoxBrowser({
|
|
466
|
-
connectUrl: cloudBoxConnectUrl(endpoint, created.id),
|
|
467
|
-
accessToken: created.accessToken ?? '',
|
|
468
|
-
})
|
|
469
463
|
const session = createCloudBoxSession({
|
|
470
464
|
boxId: created.id,
|
|
471
465
|
boxToken: created.accessToken ?? '',
|
|
@@ -477,6 +471,19 @@ async function runRemoteBox(
|
|
|
477
471
|
bytes: artifact.bytes.length,
|
|
478
472
|
},
|
|
479
473
|
})
|
|
474
|
+
// creating a box starts no browser, and only its Browser Run tab hosts the
|
|
475
|
+
// simulator, so without this the viewer opens onto a box nobody is running.
|
|
476
|
+
// a box whose browser never started is one nobody can use, and it would
|
|
477
|
+
// bill until someone found it, so it closes with the failure.
|
|
478
|
+
try {
|
|
479
|
+
await ensureCloudBoxBrowser({
|
|
480
|
+
connectUrl: cloudBoxConnectUrl(endpoint, created.id),
|
|
481
|
+
accessToken: created.accessToken ?? '',
|
|
482
|
+
})
|
|
483
|
+
} catch (error) {
|
|
484
|
+
await closeCloudBox(session)
|
|
485
|
+
throw error
|
|
486
|
+
}
|
|
480
487
|
try {
|
|
481
488
|
publishCloudSessionToShell(session)
|
|
482
489
|
} catch {
|
|
@@ -474,7 +474,12 @@ async function runPlaywrightScreenshot(args: string[], opts: ScreenshotOptions)
|
|
|
474
474
|
})
|
|
475
475
|
|
|
476
476
|
try {
|
|
477
|
-
const page = await browser.newPage({
|
|
477
|
+
const page = await browser.newPage({
|
|
478
|
+
viewport: { width: 500, height: 900 },
|
|
479
|
+
// this fallback screenshots a page a person opens; state display density
|
|
480
|
+
// so the PNG is retina rather than half of what the display shows.
|
|
481
|
+
deviceScaleFactor: 2,
|
|
482
|
+
})
|
|
478
483
|
await page.goto(url, { waitUntil: 'networkidle' })
|
|
479
484
|
await page.waitForTimeout(2000)
|
|
480
485
|
|
package/cli/commands/skills.ts
CHANGED
package/cli/commands/test.ts
CHANGED
|
@@ -572,7 +572,9 @@ async function readJobStatus(
|
|
|
572
572
|
export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
|
|
573
573
|
const auth = resolveCliAuth()
|
|
574
574
|
if (!auth) {
|
|
575
|
-
console.error(
|
|
575
|
+
console.error(
|
|
576
|
+
' error: cancelling a cloud test requires `peach login` or PEACH_API_KEY',
|
|
577
|
+
)
|
|
576
578
|
return 1
|
|
577
579
|
}
|
|
578
580
|
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
|
|
@@ -947,7 +949,9 @@ export async function runCloudTest(
|
|
|
947
949
|
}
|
|
948
950
|
}
|
|
949
951
|
|
|
950
|
-
console.error(
|
|
952
|
+
console.error(
|
|
953
|
+
` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
|
|
954
|
+
)
|
|
951
955
|
return 1
|
|
952
956
|
} finally {
|
|
953
957
|
closeTunnel?.()
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type WsBridge,
|
|
19
19
|
} from '../ws-bridge'
|
|
20
20
|
import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
|
|
21
|
+
import { isRecord } from '../../src/is-record'
|
|
21
22
|
|
|
22
23
|
type ThreeModeConfig = Record<string, unknown>
|
|
23
24
|
type ThreeModeState = Record<string, unknown> & {
|
|
@@ -97,6 +98,12 @@ measure options (projected bounds only — no pixels rendered):
|
|
|
97
98
|
--at <ms[,ms...]> sample exact timeline times (overrides from/to/count)
|
|
98
99
|
--output <file.json> write the measurement JSON here instead of stdout
|
|
99
100
|
|
|
101
|
+
asset delivery:
|
|
102
|
+
local recording/screenshot files referenced by the timeline upload once per
|
|
103
|
+
distinct file in ~1mb chunks; the page serves them back as blob: urls.
|
|
104
|
+
a later command against the same page reuses them without re-uploading.
|
|
105
|
+
uploads need a development build of the page; production drops the bridge.
|
|
106
|
+
|
|
100
107
|
examples:
|
|
101
108
|
peach mode three
|
|
102
109
|
peach mode three on
|
|
@@ -328,10 +335,6 @@ function readNumberFlag(args: string[], flag: string, fallback: number, minimum
|
|
|
328
335
|
return value
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
332
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
338
|
function mimeTypeForAsset(path: string, contentType: unknown) {
|
|
336
339
|
const extension = extname(path).toLowerCase()
|
|
337
340
|
if (contentType === 'recording') {
|
|
@@ -343,36 +346,171 @@ function mimeTypeForAsset(path: string, contentType: unknown) {
|
|
|
343
346
|
return 'image/png'
|
|
344
347
|
}
|
|
345
348
|
|
|
346
|
-
|
|
349
|
+
// the ws bridge server caps inbound messages at its 100 MiB maxPayload, so a
|
|
350
|
+
// timeline with base64-inlined assets closes the socket (code 1009) once the
|
|
351
|
+
// clips grow past it. local assets travel in 1 MiB raw chunks instead — each
|
|
352
|
+
// bridge message stays near 1.4 MB — and the page serves them back to the
|
|
353
|
+
// timeline as blob: URLs.
|
|
354
|
+
const TIMELINE_ASSET_CHUNK_BYTES = 1_048_576
|
|
355
|
+
|
|
356
|
+
type TimelineAssetRef = {
|
|
357
|
+
src: string
|
|
358
|
+
recording: boolean
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function timelineAssetRefOf(value: Record<string, unknown>): TimelineAssetRef | null {
|
|
362
|
+
if (
|
|
363
|
+
(value.type === 'recording' ||
|
|
364
|
+
value.type === 'screenshot' ||
|
|
365
|
+
value.kind === 'screen') &&
|
|
366
|
+
typeof value.src === 'string' &&
|
|
367
|
+
!/^(?:blob:|data:|https?:)/i.test(value.src)
|
|
368
|
+
) {
|
|
369
|
+
return { src: value.src, recording: value.type === 'recording' }
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function collectTimelineAssetRefs(value: unknown): TimelineAssetRef[] {
|
|
375
|
+
const refs: TimelineAssetRef[] = []
|
|
376
|
+
const seen = new Set<string>()
|
|
377
|
+
const visit = (node: unknown): void => {
|
|
378
|
+
if (Array.isArray(node)) {
|
|
379
|
+
for (const entry of node) visit(entry)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (!isRecord(node)) return
|
|
383
|
+
for (const entry of Object.values(node)) visit(entry)
|
|
384
|
+
const ref = timelineAssetRefOf(node)
|
|
385
|
+
if (ref && !seen.has(ref.src)) {
|
|
386
|
+
seen.add(ref.src)
|
|
387
|
+
refs.push(ref)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
visit(value)
|
|
391
|
+
return refs
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
type DistinctTimelineAsset = {
|
|
395
|
+
hash: string
|
|
396
|
+
mime: string
|
|
397
|
+
bytes: Buffer
|
|
398
|
+
srcs: string[]
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readDistinctTimelineAssets(
|
|
402
|
+
value: unknown,
|
|
403
|
+
baseDirectory: string,
|
|
404
|
+
): DistinctTimelineAsset[] {
|
|
405
|
+
const byHash = new Map<string, DistinctTimelineAsset>()
|
|
406
|
+
for (const ref of collectTimelineAssetRefs(value)) {
|
|
407
|
+
const assetPath = resolve(baseDirectory, ref.src)
|
|
408
|
+
const bytes = readFileSync(assetPath)
|
|
409
|
+
const hash = createHash('sha256').update(bytes).digest('hex')
|
|
410
|
+
const existing = byHash.get(hash)
|
|
411
|
+
if (existing) {
|
|
412
|
+
existing.srcs.push(ref.src)
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
byHash.set(hash, {
|
|
416
|
+
hash,
|
|
417
|
+
mime: mimeTypeForAsset(assetPath, ref.recording ? 'recording' : 'screenshot'),
|
|
418
|
+
bytes,
|
|
419
|
+
srcs: [ref.src],
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
return [...byHash.values()]
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function rewriteTimelineAssetSrcs(
|
|
426
|
+
value: unknown,
|
|
427
|
+
urlBySrc: Map<string, string>,
|
|
428
|
+
): unknown {
|
|
347
429
|
if (Array.isArray(value)) {
|
|
348
|
-
return value.map((entry) =>
|
|
430
|
+
return value.map((entry) => rewriteTimelineAssetSrcs(entry, urlBySrc))
|
|
349
431
|
}
|
|
350
432
|
if (!isRecord(value)) return value
|
|
351
433
|
|
|
352
434
|
const result: Record<string, unknown> = {}
|
|
353
435
|
for (const [key, entry] of Object.entries(value)) {
|
|
354
|
-
result[key] =
|
|
436
|
+
result[key] = rewriteTimelineAssetSrcs(entry, urlBySrc)
|
|
355
437
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
typeof result.src === 'string' &&
|
|
361
|
-
!/^(?:blob:|data:|https?:)/i.test(result.src)
|
|
362
|
-
) {
|
|
363
|
-
const assetPath = resolve(baseDirectory, result.src)
|
|
364
|
-
const bytes = readFileSync(assetPath)
|
|
365
|
-
result.src = `data:${mimeTypeForAsset(assetPath, result.type === 'recording' ? 'recording' : 'screenshot')};base64,${bytes.toString('base64')}`
|
|
438
|
+
const ref = timelineAssetRefOf(result)
|
|
439
|
+
if (ref) {
|
|
440
|
+
const url = urlBySrc.get(ref.src)
|
|
441
|
+
if (url) result.src = url
|
|
366
442
|
}
|
|
367
443
|
return result
|
|
368
444
|
}
|
|
369
445
|
|
|
446
|
+
type TimelineAssetUploadResult = {
|
|
447
|
+
hash: string
|
|
448
|
+
url: string | null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function uploadTimelineAssets(
|
|
452
|
+
bridge: WsBridge,
|
|
453
|
+
timeline: unknown,
|
|
454
|
+
baseDirectory: string,
|
|
455
|
+
): Promise<unknown> {
|
|
456
|
+
const assets = readDistinctTimelineAssets(timeline, baseDirectory)
|
|
457
|
+
const urlBySrc = new Map<string, string>()
|
|
458
|
+
for (const asset of assets) {
|
|
459
|
+
const shortHash = asset.hash.slice(0, 12)
|
|
460
|
+
const known = await callInBridgeWrite<TimelineAssetUploadResult>(
|
|
461
|
+
bridge,
|
|
462
|
+
'SootSim.bridges.threeMode.uploadAsset',
|
|
463
|
+
{ op: 'has', hash: asset.hash },
|
|
464
|
+
)
|
|
465
|
+
if (known?.url) {
|
|
466
|
+
console.log(
|
|
467
|
+
` three-mode: reusing uploaded asset ${shortHash} (${asset.bytes.length} bytes, upload skipped)`,
|
|
468
|
+
)
|
|
469
|
+
for (const src of asset.srcs) urlBySrc.set(src, known.url)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
await callInBridgeWrite(bridge, 'SootSim.bridges.threeMode.uploadAsset', {
|
|
473
|
+
op: 'begin',
|
|
474
|
+
hash: asset.hash,
|
|
475
|
+
mime: asset.mime,
|
|
476
|
+
bytes: asset.bytes.length,
|
|
477
|
+
})
|
|
478
|
+
const chunks = Math.max(1, Math.ceil(asset.bytes.length / TIMELINE_ASSET_CHUNK_BYTES))
|
|
479
|
+
for (let index = 0; index < chunks; index += 1) {
|
|
480
|
+
const slice = asset.bytes.subarray(
|
|
481
|
+
index * TIMELINE_ASSET_CHUNK_BYTES,
|
|
482
|
+
(index + 1) * TIMELINE_ASSET_CHUNK_BYTES,
|
|
483
|
+
)
|
|
484
|
+
await callInBridgeWrite(bridge, 'SootSim.bridges.threeMode.uploadAsset', {
|
|
485
|
+
op: 'chunk',
|
|
486
|
+
hash: asset.hash,
|
|
487
|
+
index,
|
|
488
|
+
base64: slice.toString('base64'),
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
const finished = await callInBridgeWrite<TimelineAssetUploadResult>(
|
|
492
|
+
bridge,
|
|
493
|
+
'SootSim.bridges.threeMode.uploadAsset',
|
|
494
|
+
{ op: 'finish', hash: asset.hash },
|
|
495
|
+
)
|
|
496
|
+
if (!finished?.url) {
|
|
497
|
+
throw new Error(`three-mode asset upload finished without a URL for ${shortHash}`)
|
|
498
|
+
}
|
|
499
|
+
console.log(
|
|
500
|
+
` three-mode: uploaded asset ${shortHash} (${asset.bytes.length} bytes in ${chunks} chunks)`,
|
|
501
|
+
)
|
|
502
|
+
for (const src of asset.srcs) urlBySrc.set(src, finished.url)
|
|
503
|
+
}
|
|
504
|
+
return rewriteTimelineAssetSrcs(timeline, urlBySrc)
|
|
505
|
+
}
|
|
506
|
+
|
|
370
507
|
function readTimelineFile(path: string) {
|
|
371
508
|
const absolutePath = resolve(path)
|
|
372
509
|
const parsed: unknown = JSON.parse(readFileSync(absolutePath, 'utf8'))
|
|
373
510
|
return {
|
|
374
511
|
absolutePath,
|
|
375
|
-
timeline:
|
|
512
|
+
timeline: parsed,
|
|
513
|
+
baseDirectory: dirname(absolutePath),
|
|
376
514
|
}
|
|
377
515
|
}
|
|
378
516
|
|
|
@@ -434,10 +572,15 @@ async function runThreeModeTimelineAction(
|
|
|
434
572
|
if (!timelinePath)
|
|
435
573
|
throw new Error(`three-mode ${action} expects a timeline JSON path`)
|
|
436
574
|
const loadedFile = readTimelineFile(timelinePath)
|
|
575
|
+
const timeline = await uploadTimelineAssets(
|
|
576
|
+
bridge,
|
|
577
|
+
loadedFile.timeline,
|
|
578
|
+
loadedFile.baseDirectory,
|
|
579
|
+
)
|
|
437
580
|
const loaded = await callInBridgeWrite<TimelineSampleResult>(
|
|
438
581
|
bridge,
|
|
439
582
|
'SootSim.bridges.threeMode.loadTimeline',
|
|
440
|
-
|
|
583
|
+
timeline,
|
|
441
584
|
)
|
|
442
585
|
if (action === 'load') {
|
|
443
586
|
console.log(
|
package/cli/commands/upload.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
isTraceTimelineEvent,
|
|
22
22
|
parseTraceTimelineJsonl,
|
|
23
23
|
} from 'peach-engine/preview/trace'
|
|
24
|
+
import { loopbackOriginOf } from '../../src/backend-origin'
|
|
24
25
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
25
26
|
import { fetchAppFontFiles } from '../app-fonts'
|
|
26
27
|
import { fetchAppSplashImages } from '../app-splash'
|
|
@@ -115,7 +116,7 @@ function readSnapshotManifest(manifestPath: string): AttachedInteractionSnapshot
|
|
|
115
116
|
|
|
116
117
|
const REMOTE_DEFAULT_ORIGIN = process.env.PEACH_UPLOAD_ORIGIN || 'https://contrast.dev'
|
|
117
118
|
// the canonical public preview link. Uploads go to the contrast backend, but
|
|
118
|
-
// reviewers/users open rnx preview pages on
|
|
119
|
+
// reviewers/users open rnx preview pages on peachsim.com.
|
|
119
120
|
const REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN =
|
|
120
121
|
process.env.PEACH_PREVIEW_ORIGIN || rnxPublicBrand.origin
|
|
121
122
|
export async function resolveDefaultUploadOrigin(
|
|
@@ -130,22 +131,6 @@ function normalizeOrigin(origin: string): string {
|
|
|
130
131
|
return origin.replace(/\/$/, '')
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
function isLoopbackOrigin(origin: string): boolean {
|
|
134
|
-
try {
|
|
135
|
-
const parsed = new URL(origin)
|
|
136
|
-
const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
137
|
-
return (
|
|
138
|
-
hostname === 'localhost' ||
|
|
139
|
-
hostname.endsWith('.localhost') ||
|
|
140
|
-
hostname === '0.0.0.0' ||
|
|
141
|
-
hostname === '::1' ||
|
|
142
|
-
/^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
143
|
-
)
|
|
144
|
-
} catch {
|
|
145
|
-
return false
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
134
|
function isContrastOrigin(origin: string): boolean {
|
|
150
135
|
try {
|
|
151
136
|
const hostname = new URL(origin).hostname.toLowerCase()
|
|
@@ -163,7 +148,7 @@ export function resolvePublicPreviewOrigin(
|
|
|
163
148
|
if (process.env.PEACH_PREVIEW_ORIGIN) {
|
|
164
149
|
return normalizeOrigin(process.env.PEACH_PREVIEW_ORIGIN)
|
|
165
150
|
}
|
|
166
|
-
if (
|
|
151
|
+
if (loopbackOriginOf(uploadOrigin) !== null) return normalizeOrigin(uploadOrigin)
|
|
167
152
|
if (isContrastOrigin(uploadOrigin)) {
|
|
168
153
|
return normalizeOrigin(REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN)
|
|
169
154
|
}
|
package/cli/current-sim.ts
CHANGED
|
@@ -19,7 +19,6 @@ export interface LocalCurrentTarget {
|
|
|
19
19
|
// progressively weaker signal of "same caller across invocations".
|
|
20
20
|
const IDENTITY_ENV_VARS = [
|
|
21
21
|
'PEACH_CLI_IDENTITY', // explicit override — fastest path, skips all pid lookups
|
|
22
|
-
'TM_SESSION', // Team Machine session id; routes prompt-bar followups back to this agent
|
|
23
22
|
'CLAUDE_CODE_SESSION_ID', // claude code, if ever set
|
|
24
23
|
'CODEX_THREAD_ID', // codex / openai agent thread, stable across tool invocations
|
|
25
24
|
'TERM_SESSION_ID', // macOS Terminal.app + iTerm2
|
package/cli/desktop-companion.ts
CHANGED
|
@@ -89,12 +89,10 @@ function findElectronBin(startDir: string): string | null {
|
|
|
89
89
|
return null
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function
|
|
93
|
-
const root = getSootsimRoot()
|
|
92
|
+
function findInstalledMacApp(): DesktopCompanionInstall | null {
|
|
94
93
|
const candidates = [
|
|
95
94
|
'/Applications/peach.app',
|
|
96
95
|
resolve(process.env.HOME || '', 'Applications/peach.app'),
|
|
97
|
-
...(root ? [resolve(root, 'app/peach.app')] : []),
|
|
98
96
|
]
|
|
99
97
|
|
|
100
98
|
const found = candidates.find((candidate) => existsSync(candidate))
|
|
@@ -106,6 +104,18 @@ function findMacApp(): DesktopCompanionInstall | null {
|
|
|
106
104
|
}
|
|
107
105
|
}
|
|
108
106
|
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findMacApp(): DesktopCompanionInstall | null {
|
|
111
|
+
const installed = findInstalledMacApp()
|
|
112
|
+
if (installed) return installed
|
|
113
|
+
const root = getSootsimRoot()
|
|
114
|
+
const builtApp = root ? resolve(root, 'app/peach.app') : null
|
|
115
|
+
if (builtApp && existsSync(builtApp)) {
|
|
116
|
+
return { path: builtApp, platform: 'darwin', kind: 'mac-app' }
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
try {
|
|
110
120
|
const result = execFileSync(
|
|
111
121
|
'mdfind',
|
|
@@ -124,6 +134,13 @@ function findMacApp(): DesktopCompanionInstall | null {
|
|
|
124
134
|
return null
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
export function findInstalledDesktopCompanion(): DesktopCompanionInstall | null {
|
|
138
|
+
if (process.platform === 'darwin') return findInstalledMacApp()
|
|
139
|
+
if (process.platform === 'linux') return findInstalledLinuxApp()
|
|
140
|
+
if (process.platform === 'win32') return findWindowsInstall()
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
function findFirstAppImage(dir: string): string | null {
|
|
128
145
|
if (!existsSync(dir)) return null
|
|
129
146
|
const match = readdirSync(dir)
|
|
@@ -133,16 +150,12 @@ function findFirstAppImage(dir: string): string | null {
|
|
|
133
150
|
return match ? join(dir, match) : null
|
|
134
151
|
}
|
|
135
152
|
|
|
136
|
-
function
|
|
137
|
-
const root = getSootsimRoot()
|
|
153
|
+
function findInstalledLinuxApp(): DesktopCompanionInstall | null {
|
|
138
154
|
const directCandidates = [
|
|
139
155
|
resolve(process.env.HOME || '', 'Applications/peach.AppImage'),
|
|
140
156
|
resolve(process.env.HOME || '', '.local/bin/peach.AppImage'),
|
|
141
157
|
'/opt/peach/peach.AppImage',
|
|
142
158
|
'/opt/peach/peach',
|
|
143
|
-
...(root
|
|
144
|
-
? [resolve(root, 'app/peach.AppImage'), resolve(root, 'release/linux-unpacked/peach')]
|
|
145
|
-
: []),
|
|
146
159
|
]
|
|
147
160
|
|
|
148
161
|
const directMatch = directCandidates.find((candidate) => existsSync(candidate))
|
|
@@ -154,6 +167,25 @@ function findLinuxInstall(): DesktopCompanionInstall | null {
|
|
|
154
167
|
}
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findLinuxInstall(): DesktopCompanionInstall | null {
|
|
174
|
+
const installed = findInstalledLinuxApp()
|
|
175
|
+
if (installed) return installed
|
|
176
|
+
const root = getSootsimRoot()
|
|
177
|
+
const directCandidates = root
|
|
178
|
+
? [resolve(root, 'app/peach.AppImage'), resolve(root, 'release/linux-unpacked/peach')]
|
|
179
|
+
: []
|
|
180
|
+
const directMatch = directCandidates.find((candidate) => existsSync(candidate))
|
|
181
|
+
if (directMatch) {
|
|
182
|
+
return {
|
|
183
|
+
path: directMatch,
|
|
184
|
+
platform: 'linux',
|
|
185
|
+
kind: directMatch.endsWith('.AppImage') ? 'linux-appimage' : 'linux-binary',
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
157
189
|
const scannedMatch = root
|
|
158
190
|
? findFirstAppImage(resolve(root, 'app')) ||
|
|
159
191
|
findFirstAppImage(resolve(root, 'release'))
|
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
|
|
6
6
|
import { rnxSelfInvocation } from '../self-invocation'
|
|
7
|
+
import { isRecord } from '../../src/is-record'
|
|
7
8
|
|
|
8
9
|
export interface ResolvedPlaywright {
|
|
9
10
|
// package name that resolved (for human-facing messaging)
|
|
@@ -27,10 +28,6 @@ export type PlaywrightBrowserProvisioningResult =
|
|
|
27
28
|
message: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
31
|
export function resolvePlaywright(): ResolvedPlaywright | null {
|
|
35
32
|
// resolve from several roots, not just cwd. a user can invoke a global CLI
|
|
36
33
|
// from an arbitrary directory while Playwright is installed beside the CLI.
|