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.
Files changed (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -121,8 +121,8 @@ function absolutizeRefs(steps: unknown, flowDir: string): unknown {
121
121
  })
122
122
  }
123
123
 
124
- // insert one capture after every screen-changing top-level step. subflows run
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
- export function nativeFlowFailureFromLog(log: string, name: string): string | undefined {
284
- const verdict = `[Failed] ${name}`
285
- return log.split(/[\r\n]+/).some((line) => {
286
- const text = line.trim()
287
- return text === verdict || text.startsWith(`${verdict} (`)
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
- ? 'Maestro reported this flow failed; see ios.log'
290
- : undefined
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
- const native = PNG.sync.read(nativePng)
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(screen.image)}" target="_blank"><img loading="lazy" src="${esc(screen.image)}" alt="${esc(screen.label)}"></a>`
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: 2,
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 timeout = setTimeout(() => {
758
- pending.delete(id)
759
- reject(
760
- new Error(
761
- `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
762
- ),
763
- )
764
- }, effectiveTimeoutMs)
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
- clearTimeout(timeout)
781
+ clearTimeoutFn()
769
782
  resolve(v)
770
783
  },
771
784
  reject: (e) => {
772
- clearTimeout(timeout)
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
  })
@@ -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
  )