peachsim 0.1.1625 → 0.1.1635
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +10 -1
- package/cli/app-config.ts +4 -1
- package/cli/app-fonts.ts +37 -17
- package/cli/app-stubs.ts +224 -9
- package/cli/bridge-flow-runner.ts +64 -43
- package/cli/command-registry.ts +1 -0
- package/cli/commands/control.ts +283 -49
- 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 +14 -2
- 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 +97 -38
- package/cli/commands/maestro.ts +9 -1
- 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 +46 -7
- package/cli/desktop-companion.ts +102 -17
- package/cli/drivers/desktop.ts +14 -1
- package/cli/drivers/playwright-sim-host.ts +38 -3
- package/cli/drivers/playwright.ts +9 -8
- package/cli/drivers/types.ts +5 -0
- package/cli/maestro-accessibility.ts +1 -1
- package/cli/maestro-compare.ts +84 -12
- package/cli/main.ts +2 -1
- package/cli/outbound-endpoints.ts +13 -1
- package/cli/pause-aware-timeout.ts +102 -0
- package/cli/scene-video.ts +89 -0
- package/cli/ws-bridge.ts +23 -14
- package/detox/attempt-events.cjs +4 -0
- package/detox/index.ts +167 -142
- package/detox/motion-change.cjs +57 -21
- package/detox/motion-change.d.cts +7 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.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 +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 +9536 -7604
- 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 +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +6 -3
- package/dist-lib/config.mjs +6 -3
- package/dist-lib/detox/index.cjs +895 -664
- package/dist-lib/dev-bundle-resolution.cjs +14 -2
- package/dist-lib/expo-swift-bundle.cjs +7818 -5916
- package/dist-lib/expo-swift-metadata.cjs +3 -1
- package/dist-lib/expo-swift-metadata.mjs +3 -1
- package/dist-lib/home-paths.cjs +32 -3
- package/dist-lib/host/bridge-host.cjs +8340 -6392
- 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 +812 -339
- 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 +1 -1
- package/dist-lib/menu.mjs +1 -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 +391 -28
- package/dist-lib/native-app-identity-project.cjs +55 -2
- package/dist-lib/native-app-identity-project.mjs +55 -2
- package/dist-lib/native-app-identity.cjs +53 -1
- package/dist-lib/native-app-identity.mjs +52 -1
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- 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 +1 -1
- package/dist-lib/react-native-host-modules.cjs +5 -1
- package/dist-lib/react-native-host-modules.mjs +5 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
- package/dist-lib/sdk.cjs +27 -11
- package/dist-lib/sdk.mjs +27 -10
- package/dist-lib/skills.cjs +12276 -8947
- package/dist-lib/swift-compile.cjs +924 -118
- package/dist-lib/swift-compile.mjs +966 -152
- 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 +1955 -498
- package/dist-lib/vite.cjs +3 -1
- package/dist-lib/worklet-executable.cjs +1 -1
- package/dist-lib/worklet-executable.mjs +1 -1
- package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
- package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +2 -1
- package/skills/peach-perf/SKILL.md +11 -5
- package/skills/peach-setup/SKILL.md +16 -19
- package/src/bridge-constants.ts +11 -0
- package/src/bridge-contract.ts +22 -34
- package/src/config.ts +15 -0
- package/src/desktop-host-delivery.ts +5 -1
- package/src/dev-bundle-resolution.ts +2 -1
- package/src/expo-manifest-body.ts +13 -0
- package/src/expo-swift-host.ts +191 -47
- package/src/expo-swift-ready.ts +78 -0
- package/src/expoSwiftContract.ts +13 -0
- package/src/home-paths.ts +48 -2
- package/src/host/bridge-host.ts +5 -2
- package/src/native-app-identity-project.ts +26 -2
- package/src/native-app-identity.ts +90 -0
- package/src/native-firebase-config.ts +209 -13
- package/src/process-group.ts +25 -2
- package/src/react-native-bridge-modules.ts +416 -0
- package/src/react-native-host-modules.ts +4 -0
- package/src/swift-binary-modules.ts +2 -2
- package/src/swift-compile.ts +835 -84
- package/src/swift-compiler-digest.ts +14 -0
- package/src/swift-distribution.lock.json +14 -12
- package/src/swift-host.ts +91 -29
- package/src/swift-icu.ts +14 -6
- package/src/swift-objc-sdk.ts +133 -10
- package/src/swift-objc-standin.ts +25 -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 +168 -20
- package/src/swift-submit.ts +10 -0
- package/src/swift-toolchain.ts +149 -23
- package/src/vite-plugin.ts +12 -1
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
|
|
@@ -602,10 +670,14 @@ function screenCard(screen: CompareScreen, threshold: number, flowIndex: number)
|
|
|
602
670
|
].filter(Boolean)
|
|
603
671
|
let body = ''
|
|
604
672
|
if (screen.image) {
|
|
673
|
+
const imageUrl = screen.image.split('/').map(encodeURIComponent).join('/')
|
|
605
674
|
body = `<div class="cols"><span>iOS simulator</span><span>Peach</span><span>pixel diff</span></div>
|
|
606
|
-
<a href="${esc(
|
|
675
|
+
<a href="${esc(imageUrl)}" target="_blank"><img loading="lazy" src="${esc(imageUrl)}" alt="${esc(screen.label)}"></a>`
|
|
607
676
|
} else {
|
|
608
|
-
const only = screen.nativeOnly ?? screen.peachOnly
|
|
677
|
+
const only = (screen.nativeOnly ?? screen.peachOnly)
|
|
678
|
+
?.split('/')
|
|
679
|
+
.map(encodeURIComponent)
|
|
680
|
+
.join('/')
|
|
609
681
|
body = only
|
|
610
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>`
|
|
611
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)
|
|
@@ -491,7 +491,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
491
491
|
},
|
|
492
492
|
'packages/peach-engine/src/engine/lottie-runtime.ts :: fetch :: descriptor.value': {
|
|
493
493
|
category: 'guest_app_network',
|
|
494
|
-
count:
|
|
494
|
+
count: 1,
|
|
495
495
|
},
|
|
496
496
|
'packages/peach-engine/src/bundle-fetch.ts :: fetch-alias :: buildBrowserAssetProxyUrl(devServerUrl)':
|
|
497
497
|
{ category: 'guest_app_network', count: 1 },
|
|
@@ -673,6 +673,14 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
673
673
|
category: 'local_development',
|
|
674
674
|
count: 1,
|
|
675
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
|
+
},
|
|
676
684
|
'packages/peachsim/cli/commands/control.ts :: websocket :: `ws://127.0.0.1:${port}`': {
|
|
677
685
|
category: 'local_development',
|
|
678
686
|
count: 1,
|
|
@@ -805,6 +813,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
805
813
|
category: 'local_development',
|
|
806
814
|
count: 1,
|
|
807
815
|
},
|
|
816
|
+
'packages/peachsim/src/expo-swift-ready.ts :: fetch :: endpointUrl': {
|
|
817
|
+
category: 'local_development',
|
|
818
|
+
count: 1,
|
|
819
|
+
},
|
|
808
820
|
'packages/peach-engine/src/expo-swift-compilation.ts :: fetch-alias :: address': {
|
|
809
821
|
category: 'local_development',
|
|
810
822
|
count: 1,
|
|
@@ -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,89 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { readSceneRecordingUpload } from 'peach-engine/remote-stream/scene-recording'
|
|
4
|
+
import { launchReapedChrome } from '../../../scripts/lib/reap-browser'
|
|
5
|
+
import {
|
|
6
|
+
ensurePlaywrightBrowser,
|
|
7
|
+
provisionPlaywright,
|
|
8
|
+
} from './drivers/playwright-provisioning'
|
|
9
|
+
|
|
10
|
+
// render the saved display stream in an isolated viewer. guest code never runs,
|
|
11
|
+
// and capture time is independent of the machine that recorded the app.
|
|
12
|
+
export async function renderSceneToVideo(options: {
|
|
13
|
+
scene: Uint8Array
|
|
14
|
+
shellUrl: string
|
|
15
|
+
output: string
|
|
16
|
+
}): Promise<{
|
|
17
|
+
durationMs: number
|
|
18
|
+
frameCount: number
|
|
19
|
+
renderTimeMs: number
|
|
20
|
+
sizeBytes: number
|
|
21
|
+
}> {
|
|
22
|
+
const { header } = await readSceneRecordingUpload(options.scene)
|
|
23
|
+
const resolved = await provisionPlaywright()
|
|
24
|
+
const installed = await ensurePlaywrightBrowser(resolved, true)
|
|
25
|
+
if (!installed.ok) throw new Error(installed.message)
|
|
26
|
+
const require = createRequire(import.meta.url)
|
|
27
|
+
const { chromium }: typeof import('playwright-core') = require(resolved.modulePath)
|
|
28
|
+
const browser = await launchReapedChrome((opts) => chromium.launch(opts), {
|
|
29
|
+
headless: true,
|
|
30
|
+
...(installed.libraryPath
|
|
31
|
+
? { env: { ...process.env, LD_LIBRARY_PATH: installed.libraryPath } }
|
|
32
|
+
: {}),
|
|
33
|
+
})
|
|
34
|
+
try {
|
|
35
|
+
const source = new URL(options.shellUrl)
|
|
36
|
+
const url = new URL(source.pathname, source.origin)
|
|
37
|
+
url.searchParams.set('device', header.peer.deviceProfile)
|
|
38
|
+
url.searchParams.set('colorScheme', header.peer.appearance)
|
|
39
|
+
for (const key of ['orientation']) {
|
|
40
|
+
const value = source.searchParams.get(key)
|
|
41
|
+
if (value) url.searchParams.set(key, value)
|
|
42
|
+
}
|
|
43
|
+
const sceneUrl = new URL('__scene_export.psr.gz', url).href
|
|
44
|
+
const page = await browser.newPage({
|
|
45
|
+
viewport: { width: 600, height: 1000 },
|
|
46
|
+
deviceScaleFactor: 3,
|
|
47
|
+
})
|
|
48
|
+
await page.route(sceneUrl, (route) =>
|
|
49
|
+
route.fulfill({
|
|
50
|
+
contentType: 'application/vnd.peach.scene-stream+gzip',
|
|
51
|
+
body: Buffer.from(options.scene),
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
url.searchParams.set(
|
|
55
|
+
'remoteEngine',
|
|
56
|
+
JSON.stringify({ url: sceneUrl, expectedPeer: header.peer, autoplay: false }),
|
|
57
|
+
)
|
|
58
|
+
await page.goto(url.href)
|
|
59
|
+
await page
|
|
60
|
+
.locator('[data-sootsim-boot-splash="hidden"]')
|
|
61
|
+
.waitFor({ state: 'attached', timeout: 120_000 })
|
|
62
|
+
await page.waitForFunction(
|
|
63
|
+
() => !!window.SootSim?.bridges.recorder && !!window.SootSim.bridges.sceneStream,
|
|
64
|
+
undefined,
|
|
65
|
+
{ timeout: 120_000 },
|
|
66
|
+
)
|
|
67
|
+
const result = await page.evaluate(async () => {
|
|
68
|
+
const recorder = window.SootSim?.bridges.recorder
|
|
69
|
+
if (!recorder) throw new Error('scene recorder bridge unavailable')
|
|
70
|
+
return recorder.renderScene()
|
|
71
|
+
})
|
|
72
|
+
const chunks: Buffer[] = []
|
|
73
|
+
let offset = 0
|
|
74
|
+
for (;;) {
|
|
75
|
+
const chunk = await page.evaluate(async (at) => {
|
|
76
|
+
return window.SootSim?.bridges.recorder?.getBlobBase64({ offset: at })
|
|
77
|
+
}, offset)
|
|
78
|
+
if (!chunk) throw new Error('scene render returned no video')
|
|
79
|
+
chunks.push(Buffer.from(chunk.data, 'base64'))
|
|
80
|
+
offset = chunk.offset
|
|
81
|
+
if (chunk.done) break
|
|
82
|
+
}
|
|
83
|
+
const video = Buffer.concat(chunks)
|
|
84
|
+
await writeFile(options.output, video)
|
|
85
|
+
return { ...result, sizeBytes: video.byteLength }
|
|
86
|
+
} finally {
|
|
87
|
+
await browser.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
package/cli/ws-bridge.ts
CHANGED
|
@@ -10,6 +10,7 @@ import fs from 'node:fs'
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { WebSocket } from 'ws'
|
|
12
12
|
import {
|
|
13
|
+
assertPeachPortEnvironment,
|
|
13
14
|
DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
14
15
|
resolveSootsimBridgePort,
|
|
15
16
|
} from '../src/bridge-constants'
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
readCurrentSimId,
|
|
32
33
|
saveCurrentSimId,
|
|
33
34
|
} from './current-sim'
|
|
35
|
+
import { setPauseAwareTimeout } from './pause-aware-timeout'
|
|
34
36
|
import type { WsBridgeCommand } from '../src/bridge-contract'
|
|
35
37
|
|
|
36
38
|
// --- bridge world resolution ---------------------------------------------
|
|
@@ -297,6 +299,7 @@ export function resolveBridgePortForPin(pin: {
|
|
|
297
299
|
* vs repo checkout) — invocation-path heuristics made `peach list` flap
|
|
298
300
|
* between worlds. */
|
|
299
301
|
export function resolveDefaultBridgePort(): number {
|
|
302
|
+
assertPeachPortEnvironment(process.env)
|
|
300
303
|
return resolveBridgeWorld().bridgePort
|
|
301
304
|
}
|
|
302
305
|
|
|
@@ -574,6 +577,7 @@ export function parseBridgeCliArgs(
|
|
|
574
577
|
}
|
|
575
578
|
|
|
576
579
|
export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge {
|
|
580
|
+
assertPeachPortEnvironment(process.env)
|
|
577
581
|
let nextId = 1
|
|
578
582
|
const commandTimeoutMs = opts.commandTimeoutMs ?? 15000
|
|
579
583
|
const pending = new Map<
|
|
@@ -754,30 +758,35 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
|
|
|
754
758
|
if (terminalError) throw terminalError
|
|
755
759
|
const id = nextId++
|
|
756
760
|
return new Promise((resolve, reject) => {
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
)
|
|
764
|
-
|
|
761
|
+
const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
|
|
762
|
+
if (payload.simId === undefined && defaultSimId) {
|
|
763
|
+
payload.simId = defaultSimId
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const clearTimeoutFn = setPauseAwareTimeout(
|
|
767
|
+
() => {
|
|
768
|
+
pending.delete(id)
|
|
769
|
+
reject(
|
|
770
|
+
new Error(
|
|
771
|
+
`command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
},
|
|
775
|
+
effectiveTimeoutMs,
|
|
776
|
+
lineageBySimId.get(payload.simId)?.hostPid,
|
|
777
|
+
)
|
|
765
778
|
|
|
766
779
|
pending.set(id, {
|
|
767
780
|
resolve: (v) => {
|
|
768
|
-
|
|
781
|
+
clearTimeoutFn()
|
|
769
782
|
resolve(v)
|
|
770
783
|
},
|
|
771
784
|
reject: (e) => {
|
|
772
|
-
|
|
785
|
+
clearTimeoutFn()
|
|
773
786
|
reject(e)
|
|
774
787
|
},
|
|
775
788
|
})
|
|
776
789
|
|
|
777
|
-
const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
|
|
778
|
-
if (payload.simId === undefined && defaultSimId) {
|
|
779
|
-
payload.simId = defaultSimId
|
|
780
|
-
}
|
|
781
790
|
maybeNoticeTarget(payload.type ?? '', payload.simId)
|
|
782
791
|
ws.send(JSON.stringify(payload))
|
|
783
792
|
})
|
package/detox/attempt-events.cjs
CHANGED
|
@@ -125,6 +125,10 @@ function beginDiscreteFrameCapture({
|
|
|
125
125
|
}) {
|
|
126
126
|
if (target.startsWith('android'))
|
|
127
127
|
throw new Error('discrete frame cadence is uncalibrated on Android')
|
|
128
|
+
if (!fs.existsSync(path.join(root, 'capture-context.json')))
|
|
129
|
+
throw new Error(
|
|
130
|
+
`discrete frame cadence needs an attempt minted by the paired proof capture (bun run test:conformance:proof); ${root} has no capture-context.json`,
|
|
131
|
+
)
|
|
128
132
|
const attempt = JSON.parse(
|
|
129
133
|
fs.readFileSync(path.join(root, 'capture-context.json'), 'utf8'),
|
|
130
134
|
)
|