peachsim 0.1.1620 → 0.1.1635
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +45 -6
- package/cli/app-config.ts +4 -1
- package/cli/app-fonts.ts +39 -18
- package/cli/app-stubs.ts +224 -9
- package/cli/bridge-flow-runner.ts +248 -156
- package/cli/cloud-client.ts +68 -57
- package/cli/command-registry.ts +1 -0
- package/cli/commands/box/search-command.ts +3 -2
- package/cli/commands/box.ts +10 -29
- package/cli/commands/control.ts +391 -92
- package/cli/commands/doctor.ts +17 -184
- package/cli/commands/flow.ts +79 -11
- package/cli/commands/inspect/core.ts +10 -5
- package/cli/commands/inspect/settling.ts +71 -22
- package/cli/commands/inspect/wait-ready.ts +12 -0
- package/cli/commands/inspect.ts +26 -3
- package/cli/commands/install-desktop.ts +1 -0
- package/cli/commands/maestro-compare.ts +105 -67
- package/cli/commands/maestro.ts +9 -1
- package/cli/commands/platform.ts +71 -36
- package/cli/commands/record.ts +21 -1
- package/cli/commands/screenshot.ts +23 -1
- package/cli/commands/serve.ts +7 -2
- package/cli/commands/setup.ts +19 -42
- package/cli/commands/swift.ts +5 -0
- package/cli/commands/upload.ts +51 -8
- package/cli/desktop-companion.ts +102 -17
- package/cli/dev-build-freshness.ts +37 -2
- package/cli/drivers/desktop.ts +14 -1
- package/cli/drivers/playwright-sim-host.ts +44 -8
- package/cli/drivers/playwright.ts +9 -8
- package/cli/drivers/types.ts +5 -0
- package/cli/maestro-accessibility.ts +53 -0
- package/cli/maestro-compare.ts +108 -12
- package/cli/main.ts +2 -1
- package/cli/outbound-endpoints.ts +35 -6
- package/cli/parent-pid.ts +0 -9
- package/cli/pause-aware-timeout.ts +102 -0
- package/cli/remote-simulators.ts +112 -0
- package/cli/remoteSimulatorContract.ts +21 -0
- package/cli/scene-video.ts +89 -0
- package/cli/ws-bridge.ts +23 -14
- package/detox/attempt-events.cjs +4 -0
- package/detox/expectations.ts +5 -1
- package/detox/index.ts +241 -181
- package/detox/motion-change.cjs +73 -0
- package/detox/motion-change.d.cts +21 -0
- package/detox/navigation-lifecycle.ts +22 -4
- package/dist-lib/agent-daemon-client.cjs +3 -2
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +3 -2
- 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 +13 -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 +14 -8
- package/dist-lib/bridge-contract.mjs +13 -7
- package/dist-lib/bundle-channel.cjs +10480 -7962
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +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 +3 -2
- package/dist-lib/cloud.mjs +3 -2
- package/dist-lib/config.cjs +10 -6
- package/dist-lib/config.mjs +10 -6
- package/dist-lib/detox/index.cjs +1854 -966
- package/dist-lib/dev-bundle-resolution.cjs +64 -24
- package/dist-lib/expo-swift-bundle.cjs +8715 -6271
- package/dist-lib/expo-swift-metadata.cjs +7 -4
- package/dist-lib/expo-swift-metadata.mjs +7 -4
- package/dist-lib/home-paths.cjs +32 -3
- package/dist-lib/host/bridge-host.cjs +10135 -7060
- 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 +1843 -408
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +3 -2
- package/dist-lib/menu.mjs +3 -2
- 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 +5 -4
- package/dist-lib/metro-production-bundle.mjs +5 -4
- package/dist-lib/metro.cjs +1182 -418
- package/dist-lib/native-app-identity-project.cjs +782 -23
- package/dist-lib/native-app-identity-project.mjs +781 -23
- package/dist-lib/native-app-identity.cjs +276 -9
- package/dist-lib/native-app-identity.mjs +273 -8
- package/dist-lib/native-firebase-metadata.cjs +5 -4
- package/dist-lib/native-firebase-metadata.mjs +5 -4
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +3 -2
- package/dist-lib/react-native-host-modules.cjs +8 -1
- package/dist-lib/react-native-host-modules.mjs +8 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
- package/dist-lib/sdk.cjs +115 -34
- package/dist-lib/sdk.mjs +115 -33
- package/dist-lib/skills.cjs +15274 -9904
- package/dist-lib/swift-compile.cjs +1165 -173
- package/dist-lib/swift-compile.mjs +1212 -203
- package/dist-lib/swift-package.cjs +12 -3
- package/dist-lib/swift-package.mjs +12 -3
- package/dist-lib/swift-submit.cjs +7 -1
- package/dist-lib/swift-submit.mjs +5 -1
- package/dist-lib/swift-toolchain.cjs +133 -18
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +2648 -862
- package/dist-lib/vite.cjs +70 -14
- package/dist-lib/worklet-executable.cjs +25 -2
- package/dist-lib/worklet-executable.mjs +25 -2
- package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
- package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
- package/package.json +2 -2
- package/scripts/dev-server-scanner.ts +35 -14
- package/skills/peach-perf/SKILL.md +11 -5
- package/skills/peach-setup/SKILL.md +16 -19
- package/src/admitted-mac-build.ts +34 -8
- package/src/android-app-identity.ts +146 -0
- package/src/android-autolinking.ts +75 -0
- package/src/android-string-value.ts +36 -0
- package/src/android-strings.ts +5 -36
- package/src/androidAutolinkingContract.ts +40 -0
- package/src/bridge-constants.ts +11 -0
- package/src/bridge-contract.ts +22 -34
- package/src/bundleGlobalFooter.ts +26 -7
- package/src/codex-client.ts +3 -2
- package/src/config.ts +15 -0
- package/src/desktop-host-delivery.ts +5 -1
- package/src/dev-bundle-resolution.ts +60 -26
- package/src/expo-kotlin-host.ts +6 -36
- package/src/expo-manifest-body.ts +13 -0
- package/src/expo-swift-bundle.ts +17 -12
- package/src/expo-swift-host.ts +640 -192
- package/src/expo-swift-ready.ts +78 -0
- package/src/expoSwiftContract.ts +19 -0
- package/src/home-paths.ts +48 -2
- package/src/host/bridge-host.ts +12 -57
- package/src/host/staged-host-files.ts +29 -2
- package/src/installed-package-versions.ts +54 -14
- package/src/launchMetadataContract.ts +24 -0
- package/src/metro-plugin.ts +37 -42
- package/src/native-app-identity-project.ts +142 -5
- package/src/native-app-identity.ts +176 -11
- package/src/native-firebase-config.ts +209 -13
- package/src/nitro-compile.ts +2 -5
- package/src/nitro-source.ts +147 -63
- package/src/process-group.ts +114 -61
- package/src/process-output.ts +0 -9
- package/src/public-brand.ts +1 -0
- package/src/react-native-bridge-modules.ts +416 -0
- package/src/react-native-host-modules.ts +7 -0
- package/src/sim-client.ts +6 -4
- package/src/swift-binary-modules.ts +2 -2
- package/src/swift-compile.ts +1007 -108
- package/src/swift-compiler-digest.ts +14 -0
- package/src/swift-distribution.lock.json +40 -12
- package/src/swift-distribution.ts +61 -9
- package/src/swift-host.ts +149 -56
- package/src/swift-icu.ts +14 -6
- package/src/swift-objc-sdk.ts +133 -10
- package/src/swift-objc-standin.ts +38 -3
- package/src/swift-package-manifest.ts +21 -6
- package/src/swift-package.ts +15 -1
- package/src/swift-packages.ts +119 -60
- package/src/swift-standin-emit.ts +57 -9
- package/src/swift-standins.ts +185 -25
- package/src/swift-submit.ts +10 -0
- package/src/swift-toolchain.ts +149 -23
- package/src/vite-plugin.ts +20 -20
- package/src/worklet-executable.ts +30 -3
- package/src/worklets-babel.ts +35 -14
- package/src/xcode-project.ts +10 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PeachAccessibilityNode } from '@peach/globals'
|
|
2
|
+
|
|
3
|
+
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
4
|
+
export function accessibilityBounds(node: Pick<PeachAccessibilityNode, 'frame'>): {
|
|
5
|
+
x: number
|
|
6
|
+
y: number
|
|
7
|
+
width: number
|
|
8
|
+
height: number
|
|
9
|
+
} {
|
|
10
|
+
const left = Math.trunc(node.frame.x)
|
|
11
|
+
const top = Math.trunc(node.frame.y)
|
|
12
|
+
return {
|
|
13
|
+
x: left,
|
|
14
|
+
y: top,
|
|
15
|
+
width: Math.trunc(node.frame.x + node.frame.width) - left,
|
|
16
|
+
height: Math.trunc(node.frame.y + node.frame.height) - top,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// upstream Filters.containsChild compares the resolved child TreeNode against
|
|
21
|
+
// a candidate's immediate children by structural equality: the native
|
|
22
|
+
// attribute map, the boolean flags, and the children, recursively. the native
|
|
23
|
+
// hintText attribute is the placeholder (accessibilityHint is not a TreeNode
|
|
24
|
+
// attribute), and engine identity (elementId/nodeId) plus the projection's own
|
|
25
|
+
// metadata (type, role, isAccessibilityElement, state) are not part of that
|
|
26
|
+
// native shape.
|
|
27
|
+
// bounds is explicit so serialized page evaluation survives cli minification.
|
|
28
|
+
export function sameNativeAccessibilityShape(
|
|
29
|
+
a: PeachAccessibilityNode,
|
|
30
|
+
b: PeachAccessibilityNode,
|
|
31
|
+
bounds: typeof accessibilityBounds,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (
|
|
34
|
+
(a.testID ?? '') !== (b.testID ?? '') ||
|
|
35
|
+
(a.label ?? '') !== (b.label ?? '') ||
|
|
36
|
+
(a.placeholder ?? '') !== (b.placeholder ?? '') ||
|
|
37
|
+
(a.value ?? '') !== (b.value ?? '') ||
|
|
38
|
+
a.enabled !== b.enabled ||
|
|
39
|
+
a.selected !== b.selected ||
|
|
40
|
+
a.checked !== b.checked ||
|
|
41
|
+
a.clickable !== b.clickable ||
|
|
42
|
+
(a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
|
|
43
|
+
JSON.stringify(bounds(a)) !== JSON.stringify(bounds(b)) ||
|
|
44
|
+
a.children.length !== b.children.length
|
|
45
|
+
) {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
for (const [index, child] of a.children.entries()) {
|
|
49
|
+
const other = b.children[index]
|
|
50
|
+
if (!other || !sameNativeAccessibilityShape(child, other, bounds)) return false
|
|
51
|
+
}
|
|
52
|
+
return true
|
|
53
|
+
}
|
package/cli/maestro-compare.ts
CHANGED
|
@@ -121,8 +121,8 @@ function absolutizeRefs(steps: unknown, flowDir: string): unknown {
|
|
|
121
121
|
})
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// insert
|
|
125
|
-
// as one step and are captured once, after they return.
|
|
124
|
+
// insert maestro's pixel-stillness wait before each checkpoint on both sides.
|
|
125
|
+
// subflows run as one step and are captured once, after they return.
|
|
126
126
|
export function instrumentFlowSource(
|
|
127
127
|
source: string,
|
|
128
128
|
flowDir: string,
|
|
@@ -159,6 +159,7 @@ export function instrumentFlowSource(
|
|
|
159
159
|
const label = stepLabel(step)
|
|
160
160
|
const name = `${COMPARE_SHOT_DIR}/${String(captures.length + 1).padStart(3, '0')}-${slugify(label)}`
|
|
161
161
|
captures.push({ name, step: index + 1, label })
|
|
162
|
+
out.push('waitForAnimationToEnd')
|
|
162
163
|
out.push({ takeScreenshot: name })
|
|
163
164
|
}
|
|
164
165
|
// maestro reads flows as yaml 1.1, where a bare yes, no, on or off is a
|
|
@@ -280,14 +281,52 @@ export function nativeStepsFromCommandsJson(json: unknown): CompareStep[] {
|
|
|
280
281
|
return steps
|
|
281
282
|
}
|
|
282
283
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
const XML_ENTITIES: Record<string, string> = {
|
|
285
|
+
amp: '&',
|
|
286
|
+
lt: '<',
|
|
287
|
+
gt: '>',
|
|
288
|
+
quot: '"',
|
|
289
|
+
apos: "'",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function decodeXml(text: string): string {
|
|
293
|
+
return text.replace(/&(?:#(\d+)|#x([\da-f]+)|(\w+));/gi, (match, dec, hex, name) => {
|
|
294
|
+
if (dec || hex) return String.fromCodePoint(parseInt(dec ?? hex, dec ? 10 : 16))
|
|
295
|
+
return XML_ENTITIES[name] ?? match
|
|
288
296
|
})
|
|
289
|
-
|
|
290
|
-
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function junitFlowKey(name: string, fileStem: string): string {
|
|
300
|
+
return JSON.stringify([name, fileStem])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// maestro's own per-flow report (--format junit): one testcase per flow, whose
|
|
304
|
+
// status and failure text are the verdict whatever the flow did or printed.
|
|
305
|
+
// values hold the failure message per testcase, undefined for a pass, in
|
|
306
|
+
// report order so flows that share a name and file stem pair in run order.
|
|
307
|
+
export function nativeFlowVerdictsFromJunit(
|
|
308
|
+
xml: string,
|
|
309
|
+
): Map<string, Array<string | undefined>> {
|
|
310
|
+
const verdicts = new Map<string, Array<string | undefined>>()
|
|
311
|
+
for (const match of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
312
|
+
const attrs = new Map<string, string>()
|
|
313
|
+
for (const attr of (match[1] ?? '').matchAll(/([\w:-]+)="([^"]*)"/g)) {
|
|
314
|
+
attrs.set(attr[1]!, decodeXml(attr[2]!))
|
|
315
|
+
}
|
|
316
|
+
const file = attrs.get('file')
|
|
317
|
+
const name = attrs.get('name')
|
|
318
|
+
if (file === undefined || name === undefined) continue
|
|
319
|
+
const body = match[3] ?? ''
|
|
320
|
+
const failure =
|
|
321
|
+
/<(?:failure|error)\b[^>]*?(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/.exec(body)
|
|
322
|
+
const failed = failure !== null || attrs.get('status') === 'ERROR'
|
|
323
|
+
const message = failure?.[1] ? decodeXml(failure[1]).trim() : ''
|
|
324
|
+
const key = junitFlowKey(name, path.basename(file).replace(/\.(ya?ml)$/i, ''))
|
|
325
|
+
const list = verdicts.get(key) ?? []
|
|
326
|
+
list.push(failed ? `Maestro failed this flow: ${message || 'no message'}` : undefined)
|
|
327
|
+
verdicts.set(key, list)
|
|
328
|
+
}
|
|
329
|
+
return verdicts
|
|
291
330
|
}
|
|
292
331
|
|
|
293
332
|
// peach's commands.json (maestro-report.ts buildFlowCommandsJson)
|
|
@@ -325,6 +364,30 @@ export interface ScreenDiff {
|
|
|
325
364
|
maskedTopPx: number
|
|
326
365
|
}
|
|
327
366
|
|
|
367
|
+
// captures of one framebuffer can differ by a column or row: maestro's xctest
|
|
368
|
+
// shot drops the rightmost column of an odd framebuffer. such a pair is scored
|
|
369
|
+
// on the top-left area both have, with no resampling, which would shift every
|
|
370
|
+
// edge. null when the sizes match or differ by more than one pixel.
|
|
371
|
+
export function commonAreaSize(
|
|
372
|
+
a: { width: number; height: number },
|
|
373
|
+
b: { width: number; height: number },
|
|
374
|
+
): { width: number; height: number } | null {
|
|
375
|
+
const dw = Math.abs(a.width - b.width)
|
|
376
|
+
const dh = Math.abs(a.height - b.height)
|
|
377
|
+
if ((dw === 0 && dh === 0) || dw > 1 || dh > 1) return null
|
|
378
|
+
return { width: Math.min(a.width, b.width), height: Math.min(a.height, b.height) }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cropTopLeft(src: PNG, width: number, height: number): PNG {
|
|
382
|
+
if (src.width === width && src.height === height) return src
|
|
383
|
+
const out = new PNG({ width, height })
|
|
384
|
+
for (let y = 0; y < height; y++) {
|
|
385
|
+
const from = y * src.width * 4
|
|
386
|
+
src.data.copy(out.data, y * width * 4, from, from + width * 4)
|
|
387
|
+
}
|
|
388
|
+
return out
|
|
389
|
+
}
|
|
390
|
+
|
|
328
391
|
function resample(src: PNG, width: number, height: number): PNG {
|
|
329
392
|
const out = new PNG({ width, height })
|
|
330
393
|
const sx = src.width / width
|
|
@@ -402,8 +465,13 @@ export function compareScreens(
|
|
|
402
465
|
peachPng: Buffer,
|
|
403
466
|
opts: { maskTopPx?: number } = {},
|
|
404
467
|
): { diff: ScreenDiff; triptych: Buffer } {
|
|
405
|
-
|
|
468
|
+
let native: PNG = PNG.sync.read(nativePng)
|
|
406
469
|
let peach: PNG = PNG.sync.read(peachPng)
|
|
470
|
+
const common = commonAreaSize(native, peach)
|
|
471
|
+
if (common) {
|
|
472
|
+
native = cropTopLeft(native, common.width, common.height)
|
|
473
|
+
peach = cropTopLeft(peach, common.width, common.height)
|
|
474
|
+
}
|
|
407
475
|
const { width, height } = native
|
|
408
476
|
const aspectMismatch = Math.abs(peach.width / peach.height - width / height) > 0.01
|
|
409
477
|
const resized = peach.width !== width || peach.height !== height
|
|
@@ -455,6 +523,30 @@ export interface CompareSide {
|
|
|
455
523
|
errors: string[]
|
|
456
524
|
}
|
|
457
525
|
|
|
526
|
+
// flow verdicts own status; diagnostics do not reject a pass.
|
|
527
|
+
export function buildCompareSide(
|
|
528
|
+
steps: CompareStep[],
|
|
529
|
+
outcome: CompareSide['status'],
|
|
530
|
+
extraErrors: string[],
|
|
531
|
+
runFailure?: string,
|
|
532
|
+
): CompareSide {
|
|
533
|
+
const failed = steps.find((step) => step.status === 'failed')
|
|
534
|
+
const executed = steps.some((step) => step.status !== 'skipped')
|
|
535
|
+
const errors = [
|
|
536
|
+
...(failed ? [`step "${failed.label}" failed: ${failed.error ?? 'no message'}`] : []),
|
|
537
|
+
...(runFailure ? [runFailure] : []),
|
|
538
|
+
...extraErrors,
|
|
539
|
+
...(!executed && outcome !== 'not run' ? ['flow executed no steps'] : []),
|
|
540
|
+
]
|
|
541
|
+
return {
|
|
542
|
+
status:
|
|
543
|
+
outcome === 'passed' && (failed || runFailure || !executed) ? 'failed' : outcome,
|
|
544
|
+
durationMs: steps.reduce((sum, step) => sum + step.durationMs, 0),
|
|
545
|
+
steps,
|
|
546
|
+
errors,
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
458
550
|
export interface CompareScreen {
|
|
459
551
|
name: string
|
|
460
552
|
step: number
|
|
@@ -578,10 +670,14 @@ function screenCard(screen: CompareScreen, threshold: number, flowIndex: number)
|
|
|
578
670
|
].filter(Boolean)
|
|
579
671
|
let body = ''
|
|
580
672
|
if (screen.image) {
|
|
673
|
+
const imageUrl = screen.image.split('/').map(encodeURIComponent).join('/')
|
|
581
674
|
body = `<div class="cols"><span>iOS simulator</span><span>Peach</span><span>pixel diff</span></div>
|
|
582
|
-
<a href="${esc(
|
|
675
|
+
<a href="${esc(imageUrl)}" target="_blank"><img loading="lazy" src="${esc(imageUrl)}" alt="${esc(screen.label)}"></a>`
|
|
583
676
|
} else {
|
|
584
|
-
const only = screen.nativeOnly ?? screen.peachOnly
|
|
677
|
+
const only = (screen.nativeOnly ?? screen.peachOnly)
|
|
678
|
+
?.split('/')
|
|
679
|
+
.map(encodeURIComponent)
|
|
680
|
+
.join('/')
|
|
585
681
|
body = only
|
|
586
682
|
? `<div class="cols one"><span>${screen.nativeOnly ? 'iOS simulator' : 'Peach'} only</span></div><a href="${esc(only)}" target="_blank"><img class="single" loading="lazy" src="${esc(only)}" alt="${esc(screen.label)}"></a>`
|
|
587
683
|
: '<p class="quiet">Neither side reached this step.</p>'
|
package/cli/main.ts
CHANGED
|
@@ -63,7 +63,8 @@ async function handleTopLevelError(err: unknown): Promise<never> {
|
|
|
63
63
|
msg.startsWith(
|
|
64
64
|
`package.json already defines scripts.${rnxPublicBrand.commandName} as `,
|
|
65
65
|
) ||
|
|
66
|
-
msg.startsWith('could not resolve a native bundle for ')
|
|
66
|
+
msg.startsWith('could not resolve a native bundle for ') ||
|
|
67
|
+
msg.startsWith('could not load ')
|
|
67
68
|
if (isKnownCliFailure && !process.env.SOOTSIM_VERBOSE) {
|
|
68
69
|
process.stderr.write(` ${msg}\n`)
|
|
69
70
|
await exitWithFlushedOutput(1)
|
|
@@ -147,7 +147,8 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
|
|
|
147
147
|
},
|
|
148
148
|
remote_display_server: {
|
|
149
149
|
category: 'explicit-user-action',
|
|
150
|
-
destination:
|
|
150
|
+
destination:
|
|
151
|
+
'display-server WebSocket URL, or scene recording URL, configured by the embedding host',
|
|
151
152
|
preConsent: true,
|
|
152
153
|
payload:
|
|
153
154
|
'display protocol identity, local input events, scroll lifecycle, resync requests, and applied/painted acknowledgements',
|
|
@@ -159,13 +160,11 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
|
|
|
159
160
|
payload: 'preview objects selected by the user for upload',
|
|
160
161
|
},
|
|
161
162
|
// its own entry, deliberately not folded into contrast_user_action. this is
|
|
162
|
-
// the
|
|
163
|
-
// and to an operator-chosen host rather than a Contrast one, so it must not
|
|
164
|
-
// be readable as "the usual Contrast call".
|
|
163
|
+
// the egress that sends the contents of the user's project to a cloud host.
|
|
165
164
|
peach_cloud_box: {
|
|
166
165
|
category: 'explicit-user-action',
|
|
167
166
|
destination:
|
|
168
|
-
'the Peach Cloud box service
|
|
167
|
+
'the deployed Peach Cloud box service by default, overridden by --endpoint or PEACH_BOX_ENDPOINT',
|
|
169
168
|
preConsent: false,
|
|
170
169
|
payload:
|
|
171
170
|
'every file git reports for the current checkout, then shell commands and file reads and writes for as long as the box shell is open',
|
|
@@ -252,6 +251,10 @@ export interface OutboundCallDeclaration {
|
|
|
252
251
|
// a new fetch, websocket, node HTTP request, or TCP connection has no entry and
|
|
253
252
|
// fails scripts/check/outbound-endpoints.ts until its destination and consent behavior are declared.
|
|
254
253
|
export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> = {
|
|
254
|
+
'packages/peach-engine/src/launch-metadata.ts :: fetch :: reference': {
|
|
255
|
+
category: 'local_development',
|
|
256
|
+
count: 1,
|
|
257
|
+
},
|
|
255
258
|
'packages/peach-engine/src/engine/barcode-reader.worker.ts :: fetch :: wasmUrl': {
|
|
256
259
|
category: 'same_origin_runtime',
|
|
257
260
|
count: 1,
|
|
@@ -282,6 +285,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
282
285
|
// empty, so the request carries no project or app data.
|
|
283
286
|
'packages/peachsim/cli/cloud-client.ts :: fetch :: `${options.apiOrigin}/v1/boxes/${encodeURIComponent()}/display-grant`':
|
|
284
287
|
{ category: 'peach_cloud_sim', count: 1 },
|
|
288
|
+
// a fresh box's boot receipts are read until its page loads or reports an
|
|
289
|
+
// error; the request carries no body.
|
|
290
|
+
'packages/peachsim/cli/cloud-client.ts :: fetch :: `${options.apiOrigin}/v1/boxes/${encodeURIComponent()}/lifecycle`':
|
|
291
|
+
{ category: 'peach_cloud_sim', count: 1 },
|
|
285
292
|
// `peach remote` creates through the instance api and lists what it made.
|
|
286
293
|
// the create carries labels and wait=true in the query; the list filters
|
|
287
294
|
// by the remote label.
|
|
@@ -379,6 +386,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
379
386
|
'packages/peach-cloud-box/src/browser-run.ts :: fetch :: `https://browser.invalid/v1/devtools/browser/${sessionId}`':
|
|
380
387
|
{ category: 'peach_cloud_box', count: 2 },
|
|
381
388
|
// list the account sessions a cloud box is billed against. no project content.
|
|
389
|
+
"packages/peachsim/cli/remote-simulators.ts :: fetch :: `${account.apiOrigin.replace(/\\/+$/, '')}/api/v1/rnx/sessions?${query}`":
|
|
390
|
+
{ category: 'contrast_user_action', count: 1 },
|
|
382
391
|
"packages/peach-cloud-box/src/client.ts :: fetch :: `${options.apiOrigin.replace(/\\/+$/, '')}/api/v1/rnx/sessions?${search}`":
|
|
383
392
|
{ category: 'contrast_user_action', count: 1 },
|
|
384
393
|
// `peach box connect` asks the account service which box that ID or name is
|
|
@@ -482,7 +491,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
482
491
|
},
|
|
483
492
|
'packages/peach-engine/src/engine/lottie-runtime.ts :: fetch :: descriptor.value': {
|
|
484
493
|
category: 'guest_app_network',
|
|
485
|
-
count:
|
|
494
|
+
count: 1,
|
|
486
495
|
},
|
|
487
496
|
'packages/peach-engine/src/bundle-fetch.ts :: fetch-alias :: buildBrowserAssetProxyUrl(devServerUrl)':
|
|
488
497
|
{ category: 'guest_app_network', count: 1 },
|
|
@@ -525,6 +534,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
525
534
|
},
|
|
526
535
|
'packages/peach-engine/src/mcp/ws-bridge.ts :: websocket :: resolveSootsimBridgeWebSocketUrl({ bridgePort, href: window.location.href, })':
|
|
527
536
|
{ category: 'local_development', count: 1 },
|
|
537
|
+
'packages/peach-engine/src/remote-stream/browser-tenant-proxy.ts :: fetch :: this.config.url':
|
|
538
|
+
{ category: 'remote_display_server', count: 1 },
|
|
528
539
|
'packages/peach-engine/src/remote-stream/browser-tenant-proxy.ts :: websocket :: this.config.url':
|
|
529
540
|
{ category: 'remote_display_server', count: 1 },
|
|
530
541
|
'packages/peach-engine/src/menu/menuActions.ts :: fetch :: screenshot': {
|
|
@@ -612,6 +623,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
612
623
|
category: 'guest_app_network',
|
|
613
624
|
count: 1,
|
|
614
625
|
},
|
|
626
|
+
'packages/peach-engine/src/render-worker/map-host.ts :: fetch-alias :: request.url': {
|
|
627
|
+
category: 'guest_app_network',
|
|
628
|
+
count: 1,
|
|
629
|
+
},
|
|
615
630
|
'packages/peach-engine/src/render-worker/map-host.ts :: fetch-alias :: url': {
|
|
616
631
|
category: 'map_style_delivery',
|
|
617
632
|
count: 1,
|
|
@@ -658,6 +673,14 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
658
673
|
category: 'local_development',
|
|
659
674
|
count: 1,
|
|
660
675
|
},
|
|
676
|
+
'packages/peachsim/cli/commands/control.ts :: net.connect :: port': {
|
|
677
|
+
category: 'local_development',
|
|
678
|
+
count: 1,
|
|
679
|
+
},
|
|
680
|
+
'packages/peachsim/cli/commands/control.ts :: fetch :: origin': {
|
|
681
|
+
category: 'local_development',
|
|
682
|
+
count: 1,
|
|
683
|
+
},
|
|
661
684
|
'packages/peachsim/cli/commands/control.ts :: websocket :: `ws://127.0.0.1:${port}`': {
|
|
662
685
|
category: 'local_development',
|
|
663
686
|
count: 1,
|
|
@@ -764,6 +787,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
764
787
|
category: 'local_development',
|
|
765
788
|
count: 1,
|
|
766
789
|
},
|
|
790
|
+
'packages/peach-engine/src/components/native-ui/ShareSheetView.tsx :: fetch-alias :: `/__fetch-proxy?url=${encodeURIComponent()}`':
|
|
791
|
+
{ category: 'guest_app_network', count: 1 },
|
|
767
792
|
'packages/peachsim/src/dev-bundle-resolution.ts :: fetch :: `/__fetch-proxy?url=${encodeURIComponent()}`':
|
|
768
793
|
{ category: 'guest_app_network', count: 1 },
|
|
769
794
|
'packages/peachsim/src/dev-bundle-resolution.ts :: fetch :: targetUrl': {
|
|
@@ -788,6 +813,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
788
813
|
category: 'local_development',
|
|
789
814
|
count: 1,
|
|
790
815
|
},
|
|
816
|
+
'packages/peachsim/src/expo-swift-ready.ts :: fetch :: endpointUrl': {
|
|
817
|
+
category: 'local_development',
|
|
818
|
+
count: 1,
|
|
819
|
+
},
|
|
791
820
|
'packages/peach-engine/src/expo-swift-compilation.ts :: fetch-alias :: address': {
|
|
792
821
|
category: 'local_development',
|
|
793
822
|
count: 1,
|
package/cli/parent-pid.ts
CHANGED
|
@@ -68,15 +68,6 @@ function buildComm(): CommLookup {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function build(): Lookup {
|
|
71
|
-
// on any platform, if the shell exports $PPID and it differs from the
|
|
72
|
-
// node-level process.ppid, it's the shell's own parent — one level up
|
|
73
|
-
// from where we need to be. this is the cheapest and most portable
|
|
74
|
-
// grand-ppid source since it needs no subprocess or FFI.
|
|
75
|
-
const envPpid = Number(process.env.PPID)
|
|
76
|
-
if (Number.isFinite(envPpid) && envPpid > 1 && envPpid !== process.ppid) {
|
|
77
|
-
return () => envPpid
|
|
78
|
-
}
|
|
79
|
-
|
|
80
71
|
if (process.platform === 'linux') return buildLinux()
|
|
81
72
|
if (process.platform === 'darwin') {
|
|
82
73
|
// on Bun: libproc via FFI is ~0.8µs/call. on Node: one `ps` snapshot of
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
// the team machine load governor SIGSTOPs a job and records its pids in this
|
|
7
|
+
// ledger (`{ [rootPid]: [{ pid, started_at_ms }] }`, `{}` when nothing is
|
|
8
|
+
// held) until it resumes them. a plain setTimeout keeps running across that
|
|
9
|
+
// hold, so a command the paused browser could not answer times out for load
|
|
10
|
+
// it never caused. a deadline only stops advancing while the pid it waits on
|
|
11
|
+
// is held; a dead or unrelated process never extends it.
|
|
12
|
+
const LEDGER_PATH = path.join(
|
|
13
|
+
process.env.TM_HOME || path.join(os.homedir(), '.team-machine'),
|
|
14
|
+
'governor-pauses.json',
|
|
15
|
+
)
|
|
16
|
+
const TICK_MS = 500
|
|
17
|
+
|
|
18
|
+
interface Timer {
|
|
19
|
+
hostPid: number
|
|
20
|
+
remaining: number
|
|
21
|
+
accountedAt: number
|
|
22
|
+
fire: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const timers = new Set<Timer>()
|
|
26
|
+
let poll: ReturnType<typeof setInterval> | undefined
|
|
27
|
+
|
|
28
|
+
function pausedPids(): number[] {
|
|
29
|
+
try {
|
|
30
|
+
const ledger: Record<string, { pid: number }[]> = JSON.parse(
|
|
31
|
+
readFileSync(LEDGER_PATH, 'utf8'),
|
|
32
|
+
)
|
|
33
|
+
return Object.values(ledger).flatMap((procs) => procs.map((proc) => proc.pid))
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// the pids in `paused` that are `rootPid` or descend from it, by ps ancestry.
|
|
40
|
+
function heldRoots(paused: number[], roots: Set<number>): Set<number> {
|
|
41
|
+
const parents = new Map<number, number>()
|
|
42
|
+
for (const line of execFileSync('ps', ['-A', '-o', 'pid=,ppid='], {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
}).split('\n')) {
|
|
45
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number)
|
|
46
|
+
if (pid && ppid !== undefined) parents.set(pid, ppid)
|
|
47
|
+
}
|
|
48
|
+
const held = new Set<number>()
|
|
49
|
+
for (const start of paused) {
|
|
50
|
+
for (let pid: number | undefined = start, hops = 0; pid && hops < 64; hops++) {
|
|
51
|
+
if (roots.has(pid)) {
|
|
52
|
+
held.add(pid)
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
pid = parents.get(pid)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return held
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tick() {
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
const paused = pausedPids()
|
|
64
|
+
const held = paused.length
|
|
65
|
+
? heldRoots(paused, new Set([...timers].map((timer) => timer.hostPid)))
|
|
66
|
+
: new Set<number>()
|
|
67
|
+
for (const timer of [...timers]) {
|
|
68
|
+
// each timer accounts only since its own creation or last accounting
|
|
69
|
+
if (!held.has(timer.hostPid)) timer.remaining -= now - timer.accountedAt
|
|
70
|
+
timer.accountedAt = now
|
|
71
|
+
if (timer.remaining <= 0) {
|
|
72
|
+
timers.delete(timer)
|
|
73
|
+
timer.fire()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (timers.size === 0 && poll) {
|
|
77
|
+
clearInterval(poll)
|
|
78
|
+
poll = undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// like setTimeout, but the budget does not advance while the governor holds
|
|
83
|
+
// `hostPid` (the browser host the command waits on) or a descendant paused.
|
|
84
|
+
// one shared poll serves every timer. returns the clear function.
|
|
85
|
+
export function setPauseAwareTimeout(
|
|
86
|
+
fn: () => void,
|
|
87
|
+
budgetMs: number,
|
|
88
|
+
hostPid: number | undefined,
|
|
89
|
+
): () => void {
|
|
90
|
+
if (hostPid === undefined) {
|
|
91
|
+
const timer = setTimeout(fn, budgetMs)
|
|
92
|
+
return () => clearTimeout(timer)
|
|
93
|
+
}
|
|
94
|
+
const timer: Timer = { hostPid, remaining: budgetMs, accountedAt: Date.now(), fire: fn }
|
|
95
|
+
timers.add(timer)
|
|
96
|
+
if (!poll) {
|
|
97
|
+
poll = setInterval(tick, TICK_MS)
|
|
98
|
+
}
|
|
99
|
+
return () => {
|
|
100
|
+
timers.delete(timer)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloudBoxConnectUrl,
|
|
3
|
+
deleteCloudBox,
|
|
4
|
+
resolveCloudBoxReference,
|
|
5
|
+
} from 'peach-cloud-box/client'
|
|
6
|
+
import { isRecord } from '../src/is-record'
|
|
7
|
+
import { deleteRemoteSim, listRemoteSims } from './cloud-client'
|
|
8
|
+
import type {
|
|
9
|
+
AccountRemoteSimulator,
|
|
10
|
+
HostedRemoteSimulator,
|
|
11
|
+
RemoteSimulatorAccount,
|
|
12
|
+
} from './remoteSimulatorContract'
|
|
13
|
+
|
|
14
|
+
// the account's meter is the existing index of hosted simulators. shell-only
|
|
15
|
+
// boxes have no simulator session and do not belong in this list.
|
|
16
|
+
async function listHostedSimulators(
|
|
17
|
+
account: RemoteSimulatorAccount,
|
|
18
|
+
): Promise<HostedRemoteSimulator[]> {
|
|
19
|
+
const query = new URLSearchParams({ kind: 'sim', status: 'running' })
|
|
20
|
+
if (account.accountId) query.set('accountId', account.accountId)
|
|
21
|
+
const response = await fetch(
|
|
22
|
+
`${account.apiOrigin.replace(/\/+$/, '')}/api/v1/rnx/sessions?${query}`,
|
|
23
|
+
{
|
|
24
|
+
headers: { authorization: account.authorization },
|
|
25
|
+
signal: AbortSignal.timeout(30_000),
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`hosted simulator list ${response.status}: ${await response.text()}`)
|
|
30
|
+
}
|
|
31
|
+
const parsed: unknown = await response.json()
|
|
32
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.sessions)) {
|
|
33
|
+
throw new Error('hosted simulator list returned invalid JSON')
|
|
34
|
+
}
|
|
35
|
+
const sims: HostedRemoteSimulator[] = []
|
|
36
|
+
for (const session of parsed.sessions) {
|
|
37
|
+
if (!isRecord(session))
|
|
38
|
+
throw new Error('hosted simulator list returned an invalid session')
|
|
39
|
+
if (
|
|
40
|
+
session.kind !== 'sim' ||
|
|
41
|
+
session.size !== 'micro' ||
|
|
42
|
+
session.status !== 'running'
|
|
43
|
+
)
|
|
44
|
+
continue
|
|
45
|
+
if (
|
|
46
|
+
typeof session.boxId !== 'string' ||
|
|
47
|
+
!session.boxId ||
|
|
48
|
+
(session.name !== null && typeof session.name !== 'string') ||
|
|
49
|
+
(session.device !== null && typeof session.device !== 'string') ||
|
|
50
|
+
typeof session.startedAt !== 'number' ||
|
|
51
|
+
!Number.isFinite(session.startedAt) ||
|
|
52
|
+
typeof session.meteredMs !== 'number' ||
|
|
53
|
+
!Number.isFinite(session.meteredMs)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error('hosted simulator list returned an invalid simulator')
|
|
56
|
+
}
|
|
57
|
+
sims.push({
|
|
58
|
+
service: 'box',
|
|
59
|
+
simId: session.boxId,
|
|
60
|
+
name: session.name,
|
|
61
|
+
status: 'running',
|
|
62
|
+
device: session.device,
|
|
63
|
+
startedAt: session.startedAt,
|
|
64
|
+
meteredMs: session.meteredMs,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return sims
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listAccountRemoteSimulators(
|
|
71
|
+
account: RemoteSimulatorAccount,
|
|
72
|
+
): Promise<AccountRemoteSimulator[]> {
|
|
73
|
+
const [hosted, nano] = await Promise.all([
|
|
74
|
+
listHostedSimulators(account),
|
|
75
|
+
listRemoteSims({
|
|
76
|
+
authorization: account.authorization,
|
|
77
|
+
accountId: account.accountId,
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
80
|
+
return [
|
|
81
|
+
...hosted,
|
|
82
|
+
...nano.map((sim): AccountRemoteSimulator => ({ ...sim, service: 'sim' })),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function stopAccountRemoteSimulator(
|
|
87
|
+
account: RemoteSimulatorAccount,
|
|
88
|
+
simId: string,
|
|
89
|
+
): Promise<{ simId: string } | null> {
|
|
90
|
+
const hosted = await listHostedSimulators(account)
|
|
91
|
+
const sim = hosted.find((candidate) => candidate.simId === simId)
|
|
92
|
+
if (!sim)
|
|
93
|
+
return deleteRemoteSim({
|
|
94
|
+
authorization: account.authorization,
|
|
95
|
+
accountId: account.accountId,
|
|
96
|
+
simId,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// resolve through the account service before addressing a host. another
|
|
100
|
+
// account's id must never turn into a provider request with this key.
|
|
101
|
+
const reference = await resolveCloudBoxReference({
|
|
102
|
+
apiOrigin: account.apiOrigin,
|
|
103
|
+
apiKey: account.authorization.replace(/^Bearer /, ''),
|
|
104
|
+
accountId: account.accountId,
|
|
105
|
+
boxId: sim.simId,
|
|
106
|
+
})
|
|
107
|
+
await deleteCloudBox({
|
|
108
|
+
connectUrl: cloudBoxConnectUrl(reference.endpoint, reference.boxId),
|
|
109
|
+
accessToken: account.authorization.replace(/^Bearer /, ''),
|
|
110
|
+
})
|
|
111
|
+
return { simId: sim.simId }
|
|
112
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RnxCloudSimView } from '../src/cloud-contract'
|
|
2
|
+
|
|
3
|
+
export interface HostedRemoteSimulator {
|
|
4
|
+
service: 'box'
|
|
5
|
+
simId: string
|
|
6
|
+
name: string | null
|
|
7
|
+
status: 'running'
|
|
8
|
+
device: string | null
|
|
9
|
+
startedAt: number
|
|
10
|
+
meteredMs: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AccountRemoteSimulator =
|
|
14
|
+
| (RnxCloudSimView & { service: 'sim' })
|
|
15
|
+
| HostedRemoteSimulator
|
|
16
|
+
|
|
17
|
+
export interface RemoteSimulatorAccount {
|
|
18
|
+
apiOrigin: string
|
|
19
|
+
authorization: string
|
|
20
|
+
accountId: string | null
|
|
21
|
+
}
|