peachsim 0.1.1640 → 0.1.1641

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 (109) hide show
  1. package/README.md +5 -0
  2. package/cli/app-stubs.ts +38 -13
  3. package/cli/cloud-session.ts +16 -0
  4. package/cli/commands/assert.ts +17 -4
  5. package/cli/commands/box.ts +33 -19
  6. package/cli/commands/doctor.ts +24 -15
  7. package/cli/commands/inspect/actions.ts +3 -2
  8. package/cli/commands/inspect/core.ts +0 -8
  9. package/cli/commands/inspect.ts +0 -14
  10. package/cli/commands/platform.ts +2 -1
  11. package/cli/commands/record.ts +64 -28
  12. package/cli/commands/swift.ts +10 -2
  13. package/cli/commands/three-mode.ts +4 -38
  14. package/cli/commands/upload.ts +8 -8
  15. package/cli/flow-live-status.ts +0 -2
  16. package/cli/main.ts +1 -1
  17. package/cli/ws-bridge.ts +0 -6
  18. package/detox/attempt-events.cjs +41 -5
  19. package/detox/index.ts +155 -109
  20. package/detox/jest-environment.cjs +5 -1
  21. package/dist-lib/agent-daemon-client.cjs +1 -1
  22. package/dist-lib/agent-events.cjs +1 -1
  23. package/dist-lib/attached-projects.cjs +1 -1
  24. package/dist-lib/auth/shared-session.cjs +1 -1
  25. package/dist-lib/backend-origin.cjs +10 -1
  26. package/dist-lib/beta.cjs +1 -1
  27. package/dist-lib/beta.mjs +1 -1
  28. package/dist-lib/bridge-constants.cjs +1 -1
  29. package/dist-lib/bridge-contract-input.cjs +1 -1
  30. package/dist-lib/bridge-contract-input.mjs +1 -1
  31. package/dist-lib/bridge-contract.cjs +1 -1
  32. package/dist-lib/bridge-contract.mjs +1 -1
  33. package/dist-lib/bundle-channel.cjs +2059 -1368
  34. package/dist-lib/capture-contract.cjs +1 -1
  35. package/dist-lib/capture-contract.mjs +1 -1
  36. package/dist-lib/cloud-contract.cjs +1 -1
  37. package/dist-lib/cloud-contract.mjs +1 -1
  38. package/dist-lib/cloud-test-args.cjs +1 -1
  39. package/dist-lib/cloud-test-args.mjs +1 -1
  40. package/dist-lib/cloud.cjs +1 -1
  41. package/dist-lib/cloud.mjs +1 -1
  42. package/dist-lib/config.cjs +1 -1
  43. package/dist-lib/config.mjs +1 -1
  44. package/dist-lib/detox/index.cjs +572 -125
  45. package/dist-lib/detox/jest-environment.cjs +5 -1
  46. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  47. package/dist-lib/expo-swift-bundle.cjs +1837 -1164
  48. package/dist-lib/expo-swift-metadata.cjs +1 -1
  49. package/dist-lib/expo-swift-metadata.mjs +1 -1
  50. package/dist-lib/home-paths.cjs +1 -1
  51. package/dist-lib/host/bridge-host.cjs +2290 -1616
  52. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  53. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  54. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  55. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  56. package/dist-lib/host/websocket-proxy.cjs +1 -1
  57. package/dist-lib/index.cjs +44 -17
  58. package/dist-lib/jump-to-source-babel.cjs +1 -1
  59. package/dist-lib/jump-to-source-native.cjs +1 -1
  60. package/dist-lib/kotlin-host.cjs +1 -1
  61. package/dist-lib/menu.cjs +1 -1
  62. package/dist-lib/menu.mjs +1 -1
  63. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  64. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  65. package/dist-lib/metro-production-bundle.cjs +1 -1
  66. package/dist-lib/metro-production-bundle.mjs +1 -1
  67. package/dist-lib/metro.cjs +33 -13
  68. package/dist-lib/native-app-identity-project.cjs +7 -4
  69. package/dist-lib/native-app-identity-project.mjs +7 -4
  70. package/dist-lib/native-app-identity.cjs +1 -1
  71. package/dist-lib/native-app-identity.mjs +1 -1
  72. package/dist-lib/native-firebase-metadata.cjs +1 -1
  73. package/dist-lib/native-firebase-metadata.mjs +1 -1
  74. package/dist-lib/plist.cjs +1 -1
  75. package/dist-lib/plist.mjs +1 -1
  76. package/dist-lib/profiles.cjs +1 -1
  77. package/dist-lib/public-brand.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.cjs +1 -1
  79. package/dist-lib/react-native-host-modules.mjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  81. package/dist-lib/sdk.cjs +4 -3
  82. package/dist-lib/sdk.mjs +4 -3
  83. package/dist-lib/skills.cjs +2823 -2109
  84. package/dist-lib/swift-compile.cjs +370 -239
  85. package/dist-lib/swift-compile.mjs +368 -230
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +8 -3
  89. package/dist-lib/swift-submit.mjs +8 -3
  90. package/dist-lib/swift-toolchain.cjs +1 -1
  91. package/dist-lib/swift.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1757 -1108
  93. package/dist-lib/vite.cjs +1 -1
  94. package/dist-lib/worklet-executable.cjs +1 -1
  95. package/dist-lib/worklet-executable.mjs +1 -1
  96. package/package.json +1 -1
  97. package/src/backend-origin.ts +8 -0
  98. package/src/react-native-bridge-modules.ts +89 -76
  99. package/src/swift-compile.ts +134 -107
  100. package/src/swift-distribution.lock.json +9 -8
  101. package/src/swift-host.ts +6 -4
  102. package/src/swift-objc-compile.ts +385 -193
  103. package/src/swift-objc-metadata.ts +204 -0
  104. package/src/swift-objc-sdk.ts +195 -2
  105. package/src/swift-package-manifest.ts +6 -3
  106. package/src/swift-root-lock.ts +153 -0
  107. package/src/swift-submit.ts +15 -2
  108. package/src/swiftContract.ts +12 -0
  109. package/src/xcode-project.ts +10 -3
@@ -6,6 +6,10 @@
6
6
  import { createHash } from 'node:crypto'
7
7
  import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
8
  import { dirname, extname, resolve } from 'node:path'
9
+ import {
10
+ collectTimelineAssetRefs,
11
+ timelineAssetRefOf,
12
+ } from 'peach-engine/screenshots/timeline-assets'
9
13
  import {
10
14
  isThreeModeSetting,
11
15
  THREE_MODE_SETTING_IDS,
@@ -353,44 +357,6 @@ function mimeTypeForAsset(path: string, contentType: unknown) {
353
357
  // timeline as blob: URLs.
354
358
  const TIMELINE_ASSET_CHUNK_BYTES = 1_048_576
355
359
 
356
- type TimelineAssetRef = {
357
- src: string
358
- recording: boolean
359
- }
360
-
361
- function timelineAssetRefOf(value: Record<string, unknown>): TimelineAssetRef | null {
362
- if (
363
- (value.type === 'recording' ||
364
- value.type === 'screenshot' ||
365
- value.kind === 'screen') &&
366
- typeof value.src === 'string' &&
367
- !/^(?:blob:|data:|https?:)/i.test(value.src)
368
- ) {
369
- return { src: value.src, recording: value.type === 'recording' }
370
- }
371
- return null
372
- }
373
-
374
- function collectTimelineAssetRefs(value: unknown): TimelineAssetRef[] {
375
- const refs: TimelineAssetRef[] = []
376
- const seen = new Set<string>()
377
- const visit = (node: unknown): void => {
378
- if (Array.isArray(node)) {
379
- for (const entry of node) visit(entry)
380
- return
381
- }
382
- if (!isRecord(node)) return
383
- for (const entry of Object.values(node)) visit(entry)
384
- const ref = timelineAssetRefOf(node)
385
- if (ref && !seen.has(ref.src)) {
386
- seen.add(ref.src)
387
- refs.push(ref)
388
- }
389
- }
390
- visit(value)
391
- return refs
392
- }
393
-
394
360
  type DistinctTimelineAsset = {
395
361
  hash: string
396
362
  mime: string
@@ -522,7 +522,7 @@ export async function runUpload(
522
522
  bridge,
523
523
  '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
524
524
  )
525
- if (snapshot?.bundleUrl) {
525
+ if (hasBundle && snapshot?.bundleUrl) {
526
526
  const bundleOrigin = new URL(snapshot.bundleUrl).origin
527
527
  // manifest stays the body-less re-fetch path for bundle-origin static
528
528
  // assets (chunks, fonts, wasm). bundle-origin api endpoints would also
@@ -784,7 +784,7 @@ export async function runUpload(
784
784
  .join('\n')
785
785
  const timelineGz = gzipSync(traceTimelineJsonl ? `${traceTimelineJsonl}\n` : '')
786
786
  const storageGz =
787
- opts.storageSnapshot && hasShareableStorage(opts.storageSnapshot)
787
+ hasBundle && opts.storageSnapshot && hasShareableStorage(opts.storageSnapshot)
788
788
  ? gzipSync(JSON.stringify(opts.storageSnapshot))
789
789
  : undefined
790
790
  console.log(
@@ -971,10 +971,9 @@ export async function runUpload(
971
971
  // fetcher (same code path as the build's `peach app-fonts stage`) so both
972
972
  // pipelines store identical bytes keyed by sha256(url).
973
973
  const appFontFiles: PreparedFile[] = []
974
- const appFontsWire = await resolveAppFontsWire(
975
- appFontsWireFromPageHref(snapshot.pageHref),
976
- fetch,
977
- )
974
+ const appFontsWire = hasBundle
975
+ ? await resolveAppFontsWire(appFontsWireFromPageHref(snapshot.pageHref), fetch)
976
+ : ''
978
977
  if (appFontsWire) {
979
978
  const fonts = await fetchAppFontFiles(appFontsWire, {
980
979
  onStaged: ({ url, byteLength }) =>
@@ -1003,7 +1002,7 @@ export async function runUpload(
1003
1002
  // still-live metro and persist them keyed by sha256(url). the engine's
1004
1003
  // <img src> resolves them through the share fetch proxy in preview mode.
1005
1004
  const appSplashFiles: PreparedFile[] = []
1006
- const appSplashWire = appSplashWireFromPageHref(snapshot.pageHref) || ''
1005
+ const appSplashWire = hasBundle ? appSplashWireFromPageHref(snapshot.pageHref) : ''
1007
1006
  if (appSplashWire) {
1008
1007
  const images = await fetchAppSplashImages(appSplashWire, {
1009
1008
  onStaged: ({ url, byteLength }) =>
@@ -1033,7 +1032,8 @@ export async function runUpload(
1033
1032
  // explicitly so the installed app keeps its identity in share replay.
1034
1033
  const appIdentityFiles: PreparedFile[] = []
1035
1034
  let appManifest: string | undefined
1036
- const metroOrigin = snapshot.bundleUrl ? loopbackOriginOf(snapshot.bundleUrl) : null
1035
+ const metroOrigin =
1036
+ hasBundle && snapshot.bundleUrl ? loopbackOriginOf(snapshot.bundleUrl) : null
1037
1037
  if (metroOrigin && snapshot.bundleUrl) {
1038
1038
  const platform = new URL(snapshot.bundleUrl).searchParams.get('platform') || 'ios'
1039
1039
  const manifestUrl = `${metroOrigin}/`
@@ -13,8 +13,6 @@ export type LiveStepStatus = 'running' | 'success' | 'failure' | 'skipped'
13
13
 
14
14
  type PlanStep = { index: number; name: string; target?: string }
15
15
 
16
- const PAUSE_POLL_MS = 400
17
-
18
16
  function replyPaused(reply: unknown) {
19
17
  if (typeof reply !== 'object' || reply == null || !('control' in reply)) return false
20
18
  const control = reply.control
package/cli/main.ts CHANGED
@@ -466,7 +466,7 @@ try {
466
466
  // inner verb's --json payload, so it never needs the shared bridge
467
467
  // machinery that lives inside runInspect.
468
468
  const { runAssert } = await import('./commands/assert')
469
- await runAssert(parsed.commandArgs)
469
+ await runAssert(parsed.commandArgs, { port, sim: globalSimTarget })
470
470
  break
471
471
  }
472
472
 
package/cli/ws-bridge.ts CHANGED
@@ -374,12 +374,6 @@ export class BridgeSimLockedError extends Error {
374
374
  }
375
375
  }
376
376
 
377
- interface WsResponse {
378
- id: number
379
- result?: any
380
- error?: string
381
- }
382
-
383
377
  interface WsBridgeOptions {
384
378
  connectTimeoutMs?: number
385
379
  commandTimeoutMs?: number
@@ -318,6 +318,10 @@ function writeDiscreteFrameReceipt(
318
318
  }
319
319
 
320
320
  function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
321
+ const {
322
+ authenticateNativeRefreshSamples,
323
+ } = require('../../peach-conformance/e2e/utils/native-frame-receipt.cjs')
324
+ authenticateNativeRefreshSamples(root, sourceRoot, life)
321
325
  const native = life.nativeFramebuffer
322
326
  if (
323
327
  !native ||
@@ -344,13 +348,13 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
344
348
  root,
345
349
  native.mediaFrames,
346
350
  native.mediaFramesSha256,
347
- 'native cadence presentation census',
351
+ 'native cadence sampled surface census',
348
352
  )
349
353
  .trim()
350
354
  .split('\n')
351
355
  .map(JSON.parse)
352
356
  if (frames.length !== decoded.observations.length || media.length !== frames.length)
353
- throw new Error('native cadence acquisition and presentation census differ')
357
+ throw new Error('native cadence acquisition and sampled surface census differ')
354
358
  const {
355
359
  decodeNativeFramebuffer,
356
360
  requireNativeRawFrame,
@@ -377,13 +381,25 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
377
381
  return row.callbackIndex
378
382
  }),
379
383
  )
380
- const origin = frames[0].consumerArrival
384
+ const reads = boundText(
385
+ root,
386
+ native.reads,
387
+ native.readsSha256,
388
+ 'native cadence observations',
389
+ )
390
+ .split('\n')
391
+ .filter(Boolean)
392
+ .map(JSON.parse)
381
393
  let expectedCallback = 0
382
- for (const [index, frame] of frames.entries()) {
394
+ for (const read of reads) {
383
395
  while (superseded.delete(expectedCallback)) expectedCallback++
396
+ if (read.callbackIndex !== expectedCallback++)
397
+ throw new Error('native cadence observations have invalid callback order')
398
+ }
399
+ const origin = frames[0].consumerArrival
400
+ for (const [index, frame] of frames.entries()) {
384
401
  const clock = frame.consumerArrival
385
402
  if (
386
- frame.callbackIndex !== expectedCallback++ ||
387
403
  media[index].sourceFrameIndex !== index ||
388
404
  !clock ||
389
405
  clock.domain !== 'host-mach-absolute-time' ||
@@ -771,6 +787,26 @@ function discreteFrameEvidenceFiles(root, evidence) {
771
787
  paths.add(native.receipt)
772
788
  paths.add(native.superseded)
773
789
  paths.add(native.mediaFrames)
790
+ paths.add(native.gradingMarkers)
791
+ paths.add(native.gradingPlan)
792
+ paths.add(native.windowResolution)
793
+ paths.add(native.reads)
794
+ for (const receipt of native.finalReads) {
795
+ const text = boundText(
796
+ root,
797
+ receipt.path,
798
+ receipt.sha256,
799
+ 'native sampled end state',
800
+ )
801
+ paths.add(receipt.path)
802
+ for (const row of text.split('\n').filter(Boolean).map(JSON.parse)) {
803
+ const file = proofBoundPath(
804
+ path.dirname(proofBoundPath(root, receipt.path)),
805
+ row.rawPath,
806
+ )
807
+ paths.add(path.relative(root, file).split(path.sep).join('/'))
808
+ }
809
+ }
774
810
  const rows = fs
775
811
  .readFileSync(proofBoundPath(root, native.receipt), 'utf8')
776
812
  .trim()
package/detox/index.ts CHANGED
@@ -14,7 +14,11 @@ import {
14
14
  type ChromiumLaunchAdmission,
15
15
  } from '../../../scripts/lib/reap-browser'
16
16
  import { automateWebAuthenticationSessions } from '../../peach-engine/src/web-authentication-automation-host'
17
- import type { LockstepInputReceipt, SootSimLockstepFrameReceipt } from '@peach/globals'
17
+ import type {
18
+ LockstepInputReceipt,
19
+ LockstepScheduledInput,
20
+ SootSimLockstepFrameReceipt,
21
+ } from '@peach/globals'
18
22
  export { reserveChromiumAdmission } from '../../../scripts/lib/reap-browser'
19
23
  import {
20
24
  readBrowserCompositeCaptureRequest,
@@ -72,6 +76,8 @@ import type { DiscreteProofContext } from './attempt-events.cjs'
72
76
  import type { SootSimElement } from './element-types'
73
77
  import type { Browser, BrowserContext, Page } from 'playwright-core'
74
78
 
79
+ const WORKLET_FRAME_TRACE_LIMIT = 16384
80
+
75
81
  type DiscreteStageFrame = {
76
82
  label: string
77
83
  offsetMs: number
@@ -492,8 +498,12 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
492
498
  )
493
499
  if (!result?.settled) {
494
500
  const pending = result?.pending ?? 'no peer stats reply'
495
- throw new Error(
496
- `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
501
+ throw await describeSootsimBridgeFailure(
502
+ page,
503
+ new Error(
504
+ `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
505
+ ),
506
+ 'sootsim worklet runtime did not settle',
497
507
  )
498
508
  }
499
509
  }
@@ -2119,7 +2129,7 @@ export const device = {
2119
2129
  },
2120
2130
 
2121
2131
  async uninstallApp() {
2122
- // no-op
2132
+ await closeBrowser(false)
2123
2133
  },
2124
2134
 
2125
2135
  async openURL(url: { url: string; sourceApp?: string }) {
@@ -2639,15 +2649,18 @@ export const device = {
2639
2649
  options: {
2640
2650
  detectionTimeoutMs?: number
2641
2651
  workletFrameTimestampsSeconds?: number[]
2652
+ workletFrameTracePath?: string
2642
2653
  frame?: { height: number; width: number; x: number; y: number } | null
2643
2654
  // a pointer the capture itself presses at the trigger point and releases
2644
2655
  // once durationMs of lockstep time has elapsed, so held states (a lifted
2645
2656
  // control thumb, a pressed row) are painted mid-hold. moves drag the
2646
2657
  // held pointer to each point once afterMs of lockstep time has elapsed
2647
- // since the press, and the release lands at the last point reached.
2658
+ // since the press. an explicit release position preserves witnessed up coordinates.
2648
2659
  hold?: {
2649
2660
  durationMs: number
2650
- moves?: Array<{ afterMs: number; x: number; y: number }>
2661
+ eventDurationMs?: number
2662
+ release?: { x: number; y: number }
2663
+ moves?: Array<{ afterMs: number; eventAfterMs?: number; x: number; y: number }>
2651
2664
  x: number
2652
2665
  y: number
2653
2666
  } | null
@@ -2703,6 +2716,14 @@ export const device = {
2703
2716
  definition: MOTION_CHANGE_DEFINITION,
2704
2717
  installGlobal: true,
2705
2718
  })
2719
+ if (options.workletFrameTracePath) {
2720
+ await page.evaluate(async (limit) => {
2721
+ const enable = window.SootSim?.bridges?.test?.setShellReanimatedTraceEnabled
2722
+ if (typeof enable !== 'function')
2723
+ throw new Error('worklet trace bridge is absent')
2724
+ await enable(true, true, limit)
2725
+ }, WORKLET_FRAME_TRACE_LIMIT)
2726
+ }
2706
2727
  const capturePromise: Promise<{
2707
2728
  inputReceipts: LockstepInputReceipt[]
2708
2729
  anchors: Array<{
@@ -2877,61 +2898,9 @@ export const device = {
2877
2898
  const controller = await lockstep.begin(60, {
2878
2899
  workletFrameTimestampsSeconds: input.workletFrameTimestampsSeconds,
2879
2900
  })
2880
- // the hold's pointer goes down before the first stepped frame, so its
2881
- // start is one frame before the first receipt; each move and the release
2882
- // land on the first stepped frame at or past their time from there. the
2883
- // deadlines are frame indexes: comparing summed virtual timestamps
2884
- // against a duration lets rounding push an event past its frame.
2885
- const moves = input.hold?.moves ?? []
2886
- const holdState = {
2887
- nextMove: 0,
2888
- pressFrameIndex: null as number | null,
2889
- releaseReceipt: null as SootSimLockstepFrameReceipt | null,
2890
- released: false,
2891
- x: input.hold?.x ?? 0,
2892
- y: input.hold?.y ?? 0,
2893
- }
2894
- const holdEventFrameIndex = (afterMs: number) =>
2895
- (holdState.pressFrameIndex ?? 0) + controller.framesForOffset(afterMs)
2896
- const nextHoldEventFrameIndex = () => {
2897
- if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
2898
- return null
2899
- }
2900
- const move = moves[holdState.nextMove]
2901
- return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2902
- }
2903
- const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
2904
- if (!input.hold || !interact || holdState.released) return
2905
- if (holdState.pressFrameIndex === null) {
2906
- holdState.pressFrameIndex = receipt.frameIndex - 1
2907
- }
2908
- for (
2909
- let move = moves[holdState.nextMove];
2910
- move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs);
2911
- move = moves[holdState.nextMove]
2912
- ) {
2913
- holdState.nextMove++
2914
- holdState.x = move.x
2915
- holdState.y = move.y
2916
- // event time remains on the replay clock when delivery ceilings
2917
- // to a display tick or several native samples share one frame.
2918
- await interact.touchMove(
2919
- move.x,
2920
- move.y,
2921
- 999,
2922
- controller.initialFrame.virtualTimestampMs + move.afterMs,
2923
- )
2924
- }
2925
- if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2926
- holdState.released = true
2927
- holdState.releaseReceipt = receipt
2928
- await interact.touchUp(
2929
- holdState.x,
2930
- holdState.y,
2931
- 999,
2932
- controller.initialFrame.virtualTimestampMs + input.hold.durationMs,
2933
- )
2934
- }
2901
+ let heldInput: Promise<void> | null = null
2902
+ const releaseReceipt = () =>
2903
+ controller.inputReceipts.find((receipt) => receipt.phase === 'up')
2935
2904
  // an anchor is its visible-change frame; its bitmap is kept so a 0ms
2936
2905
  // stage can still capture the anchor frame itself.
2937
2906
  const keptFrames = new Map<
@@ -2960,17 +2929,51 @@ export const device = {
2960
2929
  throw new Error('paired worklets callback preceded the Peach trigger')
2961
2930
  state.ready = true
2962
2931
  if (input.hold && interact) {
2963
- const hit = await interact.touchDown(
2964
- input.hold.x,
2965
- input.hold.y,
2966
- 999,
2967
- controller.initialFrame.virtualTimestampMs,
2968
- )
2969
- if (!hit) {
2970
- throw new Error(
2971
- `peach motion hold missed at ${input.hold.x},${input.hold.y}`,
2972
- )
2973
- }
2932
+ const hold = input.hold
2933
+ const moves = hold.moves ?? []
2934
+ const release = hold.release ?? moves.at(-1) ?? hold
2935
+ const events: LockstepScheduledInput[] = [
2936
+ {
2937
+ phase: 'down',
2938
+ x: hold.x,
2939
+ y: hold.y,
2940
+ pointerId: 999,
2941
+ afterMs: 0,
2942
+ async deliver(timestamp) {
2943
+ const hit = await interact.touchDown(hold.x, hold.y, 999, timestamp)
2944
+ if (!hit)
2945
+ throw new Error(`peach motion hold missed at ${hold.x},${hold.y}`)
2946
+ },
2947
+ },
2948
+ ...moves.map(
2949
+ (move): LockstepScheduledInput => ({
2950
+ phase: 'move',
2951
+ x: move.x,
2952
+ y: move.y,
2953
+ pointerId: 999,
2954
+ afterMs: move.afterMs,
2955
+ eventAfterMs: move.eventAfterMs,
2956
+ async deliver(timestamp: number) {
2957
+ await interact.touchMove(move.x, move.y, 999, timestamp)
2958
+ },
2959
+ }),
2960
+ ),
2961
+ {
2962
+ phase: 'up',
2963
+ x: release.x,
2964
+ y: release.y,
2965
+ pointerId: 999,
2966
+ afterMs: hold.durationMs,
2967
+ eventAfterMs: hold.eventDurationMs,
2968
+ async deliver(timestamp) {
2969
+ await interact.touchUp(release.x, release.y, 999, timestamp)
2970
+ },
2971
+ },
2972
+ ]
2973
+ heldInput = controller.scheduleInput(events)
2974
+ // rendering propagates delivery errors; also observe cancellation rejection.
2975
+ heldInput.catch(() => {})
2976
+ await controller.flushFrame()
2974
2977
  } else {
2975
2978
  while (!state.triggerStarted) {
2976
2979
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
@@ -3008,12 +3011,12 @@ export const device = {
3008
3011
  for (let frame = 0; !visibleStart && frame < detectionFrameLimit; frame++) {
3009
3012
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3010
3013
  const receipt = await controller.stepFrame()
3011
- await advanceHold(receipt)
3012
3014
  const candidateBitmap = await captureBitmap()
3013
3015
  // a touch anchor's stages may precede the first visible change
3014
3016
  if (
3015
3017
  input.anchorAtTouch === 'down' ||
3016
- (input.anchorAtTouch === 'up' && holdState.releaseReceipt === receipt)
3018
+ (input.anchorAtTouch === 'up' &&
3019
+ releaseReceipt()?.frameIndex === receipt.frameIndex)
3017
3020
  ) {
3018
3021
  keepFrame(receipt, await createImageBitmap(candidateBitmap))
3019
3022
  }
@@ -3053,17 +3056,17 @@ export const device = {
3053
3056
  let currentReceipt = visibleStart
3054
3057
  if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
3055
3058
  if (input.anchorAtTouch === 'up') {
3056
- while (!holdState.released) {
3059
+ while (!releaseReceipt()) {
3057
3060
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3058
3061
  const receipt = await controller.stepFrame()
3059
- await advanceHold(receipt)
3060
3062
  currentReceipt = receipt
3061
- if (holdState.released) keepFrame(receipt, await captureBitmap())
3063
+ if (releaseReceipt()) keepFrame(receipt, await captureBitmap())
3062
3064
  }
3063
- if (!holdState.releaseReceipt) {
3065
+ const released = releaseReceipt()
3066
+ if (!released) {
3064
3067
  throw new Error('peach release anchoring found no release frame')
3065
3068
  }
3066
- driveStart = holdState.releaseReceipt
3069
+ driveStart = { ...released, tenantFrame: null }
3067
3070
  }
3068
3071
  const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
3069
3072
  const anchorRecords: Array<{
@@ -3107,7 +3110,6 @@ export const device = {
3107
3110
  while (searchReceipt.virtualTimestampMs < searchStartVts) {
3108
3111
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3109
3112
  searchReceipt = await controller.stepFrame()
3110
- await advanceHold(searchReceipt)
3111
3113
  }
3112
3114
  currentReceipt = searchReceipt
3113
3115
  let anchorBitmap = await captureBitmap()
@@ -3123,7 +3125,6 @@ export const device = {
3123
3125
  for (let frame = 0; frame < detectionFrameLimit; frame++) {
3124
3126
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3125
3127
  const receipt = await controller.stepFrame()
3126
- await advanceHold(receipt)
3127
3128
  const candidateBitmap = await captureBitmap()
3128
3129
  const bounds = boundsFor(candidateBitmap)
3129
3130
  const candidate = regionPixels(candidateBitmap)
@@ -3176,9 +3177,6 @@ export const device = {
3176
3177
  for (const stage of input.stages) {
3177
3178
  if (state.cancelled) throw new Error('peach motion capture was cancelled')
3178
3179
  const { anchorId, receipt: anchorReceipt } = await resolveAnchor(stage)
3179
- // step through every hold move and the release that fall before
3180
- // this stage one frame at a time, so the stage paints the pointer
3181
- // where it belongs rather than a hold the jump skipped past.
3182
3180
  const stageFrameIndex =
3183
3181
  anchorReceipt.frameIndex + controller.framesForOffset(stage.offsetMs)
3184
3182
  const kept = keptFrames.get(stageFrameIndex)
@@ -3195,15 +3193,7 @@ export const device = {
3195
3193
  })
3196
3194
  continue
3197
3195
  }
3198
- for (
3199
- let eventFrameIndex = nextHoldEventFrameIndex();
3200
- eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
3201
- eventFrameIndex = nextHoldEventFrameIndex()
3202
- ) {
3203
- await advanceHold(await controller.stepFrame())
3204
- }
3205
3196
  const receipt = await controller.stepToOffset(anchorReceipt, stage.offsetMs)
3206
- await advanceHold(receipt)
3207
3197
  currentReceipt = receipt
3208
3198
  snapshots.push({
3209
3199
  actualMs: receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
@@ -3222,11 +3212,11 @@ export const device = {
3222
3212
  // own frames; the settle jump below would carry the pointer past them
3223
3213
  // and leave it down. the settle then runs from that release.
3224
3214
  let releasedOffsetMs = 0
3225
- while (nextHoldEventFrameIndex() !== null) {
3215
+ while (input.hold && !releaseReceipt()) {
3226
3216
  const receipt = await controller.stepFrame()
3227
- await advanceHold(receipt)
3228
3217
  releasedOffsetMs = receipt.virtualTimestampMs - driveStart.virtualTimestampMs
3229
3218
  }
3219
+ await heldInput
3230
3220
  if (input.settleMs > 0) {
3231
3221
  const maximumPrimaryMs = snapshots.reduce(
3232
3222
  (maximum, snapshot) =>
@@ -3297,23 +3287,25 @@ export const device = {
3297
3287
  },
3298
3288
  )
3299
3289
 
3300
- await Promise.race([
3301
- page.waitForFunction(
3302
- (captureToken) => {
3303
- const registry = window.__sootsimVisibleChangeCaptures
3304
- return registry?.[captureToken]?.ready === true
3305
- },
3306
- token,
3307
- { timeout: 5000 },
3308
- ),
3309
- capturePromise.then(() => {
3310
- throw new Error('peach motion capture completed before its trigger')
3311
- }),
3312
- ])
3313
-
3314
- const triggerStartMs = Date.now()
3290
+ let captureFailure: { error: unknown } | undefined
3291
+ let triggerStartMs = 0
3315
3292
  let triggerCompletedMs = 0
3316
3293
  try {
3294
+ await Promise.race([
3295
+ page.waitForFunction(
3296
+ (captureToken) => {
3297
+ const registry = window.__sootsimVisibleChangeCaptures
3298
+ return registry?.[captureToken]?.ready === true
3299
+ },
3300
+ token,
3301
+ { timeout: 5000 },
3302
+ ),
3303
+ capturePromise.then(() => {
3304
+ throw new Error('peach motion capture completed before its trigger')
3305
+ }),
3306
+ ])
3307
+
3308
+ triggerStartMs = Date.now()
3317
3309
  const triggerPromise = (async () => {
3318
3310
  if (!options.hold) {
3319
3311
  if (typeof options.trigger !== 'function') {
@@ -3349,6 +3341,7 @@ export const device = {
3349
3341
  },
3350
3342
  }
3351
3343
  } catch (error) {
3344
+ captureFailure = { error }
3352
3345
  await page
3353
3346
  .evaluate((captureToken) => {
3354
3347
  const registry = window.__sootsimVisibleChangeCaptures
@@ -3358,6 +3351,59 @@ export const device = {
3358
3351
  .catch(() => {})
3359
3352
  await capturePromise.catch(() => {})
3360
3353
  throw error
3354
+ } finally {
3355
+ if (options.workletFrameTracePath) {
3356
+ try {
3357
+ const events = await page.evaluate(async () => {
3358
+ const test = window.SootSim?.bridges?.test
3359
+ const read = test?.getShellReanimatedTrace
3360
+ const enable = test?.setShellReanimatedTraceEnabled
3361
+ if (typeof read !== 'function' || typeof enable !== 'function')
3362
+ throw new Error('worklet trace bridge is absent')
3363
+ try {
3364
+ return await read()
3365
+ } finally {
3366
+ await enable(false, false)
3367
+ }
3368
+ })
3369
+ fs.writeFileSync(
3370
+ options.workletFrameTracePath,
3371
+ JSON.stringify(
3372
+ {
3373
+ source: 'peach-native-frame-queue',
3374
+ limit: WORKLET_FRAME_TRACE_LIMIT,
3375
+ events,
3376
+ },
3377
+ null,
3378
+ 2,
3379
+ ),
3380
+ )
3381
+ } catch (traceError) {
3382
+ if (!captureFailure) throw traceError
3383
+ const primary = captureFailure.error
3384
+ if (
3385
+ primary &&
3386
+ (typeof primary === 'object' || typeof primary === 'function') &&
3387
+ Object.isExtensible(primary)
3388
+ ) {
3389
+ try {
3390
+ Object.defineProperty(primary, 'workletFrameTraceError', {
3391
+ value: traceError,
3392
+ configurable: true,
3393
+ })
3394
+ } catch (attachmentError) {
3395
+ console.error(
3396
+ '[peach motion capture] could not attach trace failure:',
3397
+ attachmentError,
3398
+ )
3399
+ }
3400
+ }
3401
+ console.error(
3402
+ '[peach motion capture] secondary worklet trace failure:',
3403
+ traceError,
3404
+ )
3405
+ }
3406
+ }
3361
3407
  }
3362
3408
  },
3363
3409
 
@@ -50,11 +50,15 @@ module.exports = class PeachEnvironment extends TestEnvironment {
50
50
  }
51
51
  }
52
52
 
53
- async handleTestEvent(event) {
53
+ async handleTestEvent(event, state) {
54
54
  if (event.name === 'hook_failure') {
55
55
  this.controller.abort(new Error('Peach test hook failed'))
56
56
  try {
57
57
  await this.global.__peachDetoxCleanup?.()
58
+ } catch (error) {
59
+ // circus already recorded the hook failure. a cleanup failure belongs
60
+ // beside it, rather than escaping this handler and replacing the result.
61
+ state.unhandledErrors.push(error)
58
62
  } finally {
59
63
  this.releaseAdmission?.()
60
64
  this.releaseAdmission = null
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1641 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1641 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1641 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1641 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;