peachsim 0.1.1625 → 0.1.1640

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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -0,0 +1,106 @@
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
+ if (timers.size === 0 && poll) {
102
+ clearInterval(poll)
103
+ poll = undefined
104
+ }
105
+ }
106
+ }
@@ -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
 
@@ -330,9 +333,11 @@ export function printBridgeWorldNotice(
330
333
  export interface WsBridge {
331
334
  readonly plane?: 'local' | 'cloud'
332
335
  // `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single
333
- // call — used for fast readiness probes so a dead/unresponsive sim fails in
334
- // seconds instead of stalling the full command timeout.
335
- send(cmd: WsBridgeCommand, opts?: { timeoutMs?: number }): Promise<any>
336
+ // call. defaultTimeoutMs supplies a probe cap unless --timeout was explicit.
337
+ send(
338
+ cmd: WsBridgeCommand,
339
+ opts?: { timeoutMs?: number; defaultTimeoutMs?: number },
340
+ ): Promise<any>
336
341
  // includeRetained appends the bridge's retained disconnected sims
337
342
  // (readyState 'closed' with disconnectCause) after the live ones. only
338
343
  // loss-reporting surfaces pass it; every other consumer keeps the
@@ -378,6 +383,7 @@ interface WsResponse {
378
383
  interface WsBridgeOptions {
379
384
  connectTimeoutMs?: number
380
385
  commandTimeoutMs?: number
386
+ explicitTimeout?: boolean
381
387
  simId?: string
382
388
  cliLabel?: string
383
389
  leaseScope?: 'connection'
@@ -452,12 +458,14 @@ export interface ParsedBridgeCliArgs {
452
458
  simId?: string
453
459
  simIdSource: 'flag' | 'saved' | 'none'
454
460
  commandTimeoutMs: number
461
+ explicitTimeout?: boolean
455
462
  }
456
463
 
457
464
  interface BridgeFlagState {
458
465
  wsPort: number
459
466
  explicitPort: boolean
460
467
  commandTimeoutMs: number
468
+ explicitTimeout?: boolean
461
469
  simId: string | undefined
462
470
  simIdSource: ParsedBridgeCliArgs['simIdSource']
463
471
  }
@@ -484,7 +492,10 @@ const BRIDGE_VALUE_FLAGS_TABLE: readonly {
484
492
  {
485
493
  names: ['--timeout'],
486
494
  apply: (state, value) => {
487
- if (value !== undefined) state.commandTimeoutMs = Number(value)
495
+ if (value !== undefined) {
496
+ state.commandTimeoutMs = Number(value)
497
+ state.explicitTimeout = true
498
+ }
488
499
  },
489
500
  },
490
501
  {
@@ -570,10 +581,12 @@ export function parseBridgeCliArgs(
570
581
  simId: state.simId,
571
582
  simIdSource: state.simIdSource,
572
583
  commandTimeoutMs: state.commandTimeoutMs,
584
+ explicitTimeout: state.explicitTimeout,
573
585
  }
574
586
  }
575
587
 
576
588
  export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBridge {
589
+ assertPeachPortEnvironment(process.env)
577
590
  let nextId = 1
578
591
  const commandTimeoutMs = opts.commandTimeoutMs ?? 15000
579
592
  const pending = new Map<
@@ -745,7 +758,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
745
758
  async send(cmd, callOpts) {
746
759
  // NOTE: `opts` (the createBridge closure arg) carries simId; do not
747
760
  // shadow it with the per-call arg.
748
- const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
761
+ const effectiveTimeoutMs =
762
+ callOpts?.timeoutMs ??
763
+ (opts.explicitTimeout ? commandTimeoutMs : callOpts?.defaultTimeoutMs) ??
764
+ commandTimeoutMs
749
765
  const sendOnce = async (
750
766
  defaultSimId?: string,
751
767
  cmdOverride?: Record<string, any>,
@@ -754,30 +770,31 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
754
770
  if (terminalError) throw terminalError
755
771
  const id = nextId++
756
772
  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)
773
+ const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
774
+ if (payload.simId === undefined && defaultSimId) {
775
+ payload.simId = defaultSimId
776
+ }
777
+
778
+ const clearTimeoutFn = setPauseAwareTimeout(
779
+ () => {
780
+ pending.delete(id)
781
+ reject(new Error(`command timed out after ${effectiveTimeoutMs / 1000}s`))
782
+ },
783
+ effectiveTimeoutMs,
784
+ lineageBySimId.get(payload.simId)?.hostPid,
785
+ )
765
786
 
766
787
  pending.set(id, {
767
788
  resolve: (v) => {
768
- clearTimeout(timeout)
789
+ clearTimeoutFn()
769
790
  resolve(v)
770
791
  },
771
792
  reject: (e) => {
772
- clearTimeout(timeout)
793
+ clearTimeoutFn()
773
794
  reject(e)
774
795
  },
775
796
  })
776
797
 
777
- const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
778
- if (payload.simId === undefined && defaultSimId) {
779
- payload.simId = defaultSimId
780
- }
781
798
  maybeNoticeTarget(payload.type ?? '', payload.simId)
782
799
  ws.send(JSON.stringify(payload))
783
800
  })
@@ -946,6 +963,7 @@ export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
946
963
  if (cloudBridge) return cloudBridge
947
964
  const options = {
948
965
  commandTimeoutMs: parsed.commandTimeoutMs,
966
+ explicitTimeout: parsed.explicitTimeout,
949
967
  simId: parsed.simId,
950
968
  simIdSource: parsed.simIdSource,
951
969
  }
@@ -43,6 +43,15 @@ function proofBoundPath(root, relative) {
43
43
  return resolved
44
44
  }
45
45
 
46
+ // lifecycle receipts bind every clock and census read to the retained bytes.
47
+ function boundText(root, relative, sha256, label) {
48
+ const bytes = fs.readFileSync(proofBoundPath(root, relative))
49
+ if (proofByteHash(bytes) !== sha256)
50
+ throw new Error(`${label} does not match its recorded hash`)
51
+ return bytes.toString('utf8')
52
+ }
53
+ module.exports.boundText = boundText
54
+
46
55
  function authenticateDiscreteFrameAsset(root, declaration, calibrate = false) {
47
56
  const cadence = parseDiscreteFrameCadence(declaration)
48
57
  const fixture = fs.readFileSync(proofBoundPath(root, cadence.asset.fixturePath))
@@ -125,6 +134,10 @@ function beginDiscreteFrameCapture({
125
134
  }) {
126
135
  if (target.startsWith('android'))
127
136
  throw new Error('discrete frame cadence is uncalibrated on Android')
137
+ if (!fs.existsSync(path.join(root, 'capture-context.json')))
138
+ throw new Error(
139
+ `discrete frame cadence needs an attempt minted by the paired proof capture (bun run test:conformance:proof); ${root} has no capture-context.json`,
140
+ )
128
141
  const attempt = JSON.parse(
129
142
  fs.readFileSync(path.join(root, 'capture-context.json'), 'utf8'),
130
143
  )
@@ -318,13 +331,21 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
318
331
  )
319
332
  )
320
333
  throw new Error('native cadence has no source-matched framebuffer consumer')
321
- const frames = fs
322
- .readFileSync(proofBoundPath(root, native.receipt), 'utf8')
334
+ const frames = boundText(
335
+ root,
336
+ native.receipt,
337
+ native.receiptSha256,
338
+ 'native cadence source receipt',
339
+ )
323
340
  .trim()
324
341
  .split('\n')
325
342
  .map(JSON.parse)
326
- const media = fs
327
- .readFileSync(proofBoundPath(root, native.mediaFrames), 'utf8')
343
+ const media = boundText(
344
+ root,
345
+ native.mediaFrames,
346
+ native.mediaFramesSha256,
347
+ 'native cadence presentation census',
348
+ )
328
349
  .trim()
329
350
  .split('\n')
330
351
  .map(JSON.parse)
@@ -335,11 +356,34 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
335
356
  requireNativeRawFrame,
336
357
  } = require('../../peach-conformance/e2e/utils/native-frame-receipt.cjs')
337
358
  const rawRoot = path.dirname(proofBoundPath(root, native.receipt))
359
+ // a callback whose frame a newer one overwrote mid-read keeps no bytes; its
360
+ // superseded receipt fills exactly the gap it leaves in the callback order
361
+ const superseded = new Set(
362
+ boundText(
363
+ root,
364
+ native.superseded,
365
+ native.supersededSha256,
366
+ 'native cadence superseded receipt',
367
+ )
368
+ .split('\n')
369
+ .filter(Boolean)
370
+ .map((line) => {
371
+ const row = JSON.parse(line)
372
+ if (
373
+ !Number.isSafeInteger(row.callbackIndex) ||
374
+ row.seedBeforeRead === row.seedAfterRead
375
+ )
376
+ throw new Error('native superseded receipt is not an overwritten read')
377
+ return row.callbackIndex
378
+ }),
379
+ )
338
380
  const origin = frames[0].consumerArrival
381
+ let expectedCallback = 0
339
382
  for (const [index, frame] of frames.entries()) {
383
+ while (superseded.delete(expectedCallback)) expectedCallback++
340
384
  const clock = frame.consumerArrival
341
385
  if (
342
- frame.callbackIndex !== index ||
386
+ frame.callbackIndex !== expectedCallback++ ||
343
387
  media[index].sourceFrameIndex !== index ||
344
388
  !clock ||
345
389
  clock.domain !== 'host-mach-absolute-time' ||
@@ -370,6 +414,9 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
370
414
  ),
371
415
  )
372
416
  }
417
+ while (superseded.delete(expectedCallback)) expectedCallback++
418
+ if (superseded.size)
419
+ throw new Error('native superseded receipts fall outside the callback order')
373
420
  }
374
421
 
375
422
  function readDiscreteFrameReceipt(root, sourceRoot, reference, expected) {
@@ -706,9 +753,23 @@ function discreteFrameEvidenceFiles(root, evidence) {
706
753
  const life = JSON.parse(
707
754
  fs.readFileSync(proofBoundPath(root, receipt.lifecycle.path), 'utf8'),
708
755
  )
756
+ // the app's ui frame clock and delivered input are replayed from their
757
+ // own receipts, so their bytes travel bound to the hashes the lifecycle
758
+ // recorded
759
+ for (const [label, auxiliary] of [
760
+ ['ui frame clock', life.uiFrameClock],
761
+ ['delivered input', life.deliveredUIKitInput],
762
+ ]) {
763
+ if (!auxiliary) continue
764
+ const bytes = fs.readFileSync(proofBoundPath(root, auxiliary.sourceReceipt))
765
+ if (proofByteHash(bytes) !== auxiliary.sourceReceiptSha256)
766
+ throw new Error(`native ${label} receipt does not match its recorded hash`)
767
+ paths.add(auxiliary.sourceReceipt)
768
+ }
709
769
  const native = life.nativeFramebuffer
710
770
  if (native) {
711
771
  paths.add(native.receipt)
772
+ paths.add(native.superseded)
712
773
  paths.add(native.mediaFrames)
713
774
  const rows = fs
714
775
  .readFileSync(proofBoundPath(root, native.receipt), 'utf8')
@@ -104,3 +104,10 @@ export function writeDiscreteFrameReceipt(
104
104
  context: DiscreteProofContext,
105
105
  trace: DiscreteFrameTrace,
106
106
  ): DiscreteProofReference
107
+
108
+ export function boundText(
109
+ root: string,
110
+ relative: string,
111
+ sha256: string,
112
+ label: string,
113
+ ): string
@@ -54,8 +54,12 @@ export function isNodeVisible(node: SootSimNodeInfo | null | undefined): boolean
54
54
  }
55
55
 
56
56
  // a native host is focused when its editor child holds focus.
57
- export function focusIsWithin(focused: SootSimFocusedNodeInfo, nodeId: number): boolean {
58
- return focused.nodeId === nodeId || focused.ancestorNodeIds.includes(nodeId)
57
+ export function focusIsWithin(
58
+ focused: SootSimFocusedNodeInfo,
59
+ node: SootSimNodeInfo,
60
+ ): boolean {
61
+ const nativeNodeId = node.nativeTag ?? node.nodeId
62
+ return focused.nodeId === nativeNodeId || focused.ancestorNodeIds.includes(nativeNodeId)
59
63
  }
60
64
 
61
65
  // the engine owns focus for canvas inputs and hosted native editors.
@@ -64,7 +68,7 @@ export async function isTextInputNodeFocused(
64
68
  page: Page,
65
69
  ): Promise<boolean> {
66
70
  const focused = await page.evaluate(async () => window.__sootsimTest!.getFocusedNode())
67
- return focused !== null && focusIsWithin(focused, node.nodeId)
71
+ return focused !== null && focusIsWithin(focused, node)
68
72
  }
69
73
 
70
74
  async function isNodeFocused(node: SootSimNodeInfo, page: Page): Promise<boolean> {