peachsim 0.1.1635 → 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 (129) hide show
  1. package/README.md +5 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +62 -13
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/commands/doctor.ts +13 -5
  7. package/cli/commands/flow.ts +1 -1
  8. package/cli/commands/inspect/shared.ts +9 -4
  9. package/cli/commands/inspect.ts +65 -47
  10. package/cli/commands/preview.ts +1 -1
  11. package/cli/commands/record.ts +97 -47
  12. package/cli/commands/swift.ts +9 -3
  13. package/cli/commands/upload.ts +48 -42
  14. package/cli/drivers/playwright-sim-host.ts +65 -42
  15. package/cli/main.ts +1 -1
  16. package/cli/outbound-endpoints.ts +7 -5
  17. package/cli/pause-aware-timeout.ts +4 -0
  18. package/cli/ws-bridge.ts +19 -10
  19. package/detox/attempt-events.cjs +62 -5
  20. package/detox/attempt-events.d.cts +7 -0
  21. package/detox/expectations.ts +7 -3
  22. package/detox/index.ts +121 -47
  23. package/detox/motion-change.cjs +170 -21
  24. package/detox/motion-change.d.cts +32 -0
  25. package/detox/navigation-lifecycle.ts +35 -3
  26. package/dist-lib/agent-daemon-client.cjs +1 -1
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +1 -1
  30. package/dist-lib/backend-origin.cjs +8 -7
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +4038 -1983
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +1 -1
  46. package/dist-lib/cloud.mjs +1 -1
  47. package/dist-lib/config.cjs +1 -1
  48. package/dist-lib/config.mjs +1 -1
  49. package/dist-lib/detox/index.cjs +509 -86
  50. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  51. package/dist-lib/expo-swift-bundle.cjs +3551 -1574
  52. package/dist-lib/expo-swift-metadata.cjs +9 -7
  53. package/dist-lib/expo-swift-metadata.mjs +9 -7
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +43633 -2635
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +2748 -128
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +1 -1
  66. package/dist-lib/menu.mjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +1 -1
  70. package/dist-lib/metro-production-bundle.mjs +1 -1
  71. package/dist-lib/metro.cjs +2547 -83
  72. package/dist-lib/native-app-identity-project.cjs +237 -50
  73. package/dist-lib/native-app-identity-project.mjs +237 -50
  74. package/dist-lib/native-app-identity.cjs +29 -30
  75. package/dist-lib/native-app-identity.mjs +29 -30
  76. package/dist-lib/native-firebase-metadata.cjs +1 -1
  77. package/dist-lib/native-firebase-metadata.mjs +1 -1
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +1 -1
  82. package/dist-lib/react-native-host-modules.cjs +2 -2
  83. package/dist-lib/react-native-host-modules.mjs +2 -2
  84. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  85. package/dist-lib/sdk.cjs +1 -1
  86. package/dist-lib/sdk.mjs +1 -1
  87. package/dist-lib/skills.cjs +65403 -22739
  88. package/dist-lib/swift-compile.cjs +93 -32
  89. package/dist-lib/swift-compile.mjs +93 -32
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +7 -5
  93. package/dist-lib/swift-submit.mjs +7 -5
  94. package/dist-lib/swift-toolchain.cjs +316 -41
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +7335 -5432
  97. package/dist-lib/vite.cjs +2857 -12
  98. package/dist-lib/worklet-executable.cjs +14 -2
  99. package/dist-lib/worklet-executable.mjs +14 -2
  100. package/package.json +1 -1
  101. package/src/backend-origin.ts +13 -6
  102. package/src/expo-swift-host.ts +5 -2
  103. package/src/expoKotlinContract.ts +4 -7
  104. package/src/expoSwiftContract.ts +4 -6
  105. package/src/host/staged-host-files.ts +9 -0
  106. package/src/native-app-identity-project.ts +1 -4
  107. package/src/native-app-identity.ts +1 -4
  108. package/src/nativeAppIconContract.ts +1 -4
  109. package/src/output-lock-stdio.ts +36 -0
  110. package/src/react-native-bridge-modules.ts +21 -6
  111. package/src/react-native-host-modules.ts +4 -1
  112. package/src/settings-bundle.ts +1 -4
  113. package/src/swift-compile.ts +91 -33
  114. package/src/swift-distribution.lock.json +22 -12
  115. package/src/swift-distribution.ts +103 -4
  116. package/src/swift-host.ts +89 -26
  117. package/src/swift-icu.ts +16 -10
  118. package/src/swift-objc-compile.ts +355 -0
  119. package/src/swift-objc-sdk.ts +689 -18
  120. package/src/swift-objc-standin.ts +126 -14
  121. package/src/swift-package-manifest.ts +39 -1
  122. package/src/swift-packages.ts +35 -21
  123. package/src/swift-standin-emit.ts +40 -7
  124. package/src/swift-standins.ts +404 -138
  125. package/src/swift-submit.ts +3 -5
  126. package/src/swift-toolchain.ts +378 -46
  127. package/src/worker-system-chunks.ts +11 -12
  128. package/src/worklet-executable.ts +19 -1
  129. package/src/xcode-project.ts +270 -30
@@ -1,6 +1,6 @@
1
- // peach record upload — capture the currently-loaded bundle + device spec from
2
- // a running peach sim and POST it to the Contrast upload endpoint. returns a
3
- // /preview/<id> url that loads this bundle in a frame.
1
+ // peach record upload — publish a recording from a running simulator. scene
2
+ // recordings carry their own content identity; --live-bundle adds the app
3
+ // bundle for interactive playback under the existing bundle identity.
4
4
  //
5
5
  // uploads with no repository association are private by default: only
6
6
  // signed-in members of the uploader's account can open the link until the
@@ -60,6 +60,7 @@ import {
60
60
  parseBridgeCliArgs,
61
61
  } from '../ws-bridge'
62
62
  import type { SootSimTimelineEvent } from '@peach/globals'
63
+ import type { BundleSnapshot } from 'peach-engine/preview/capture-bundle'
63
64
 
64
65
  interface UploadOptions {
65
66
  port?: number
@@ -86,19 +87,6 @@ function parseServerMessage(responseText?: string): string | null {
86
87
  }
87
88
  }
88
89
 
89
- type BundleSnapshot = {
90
- bundleUrl: string | null
91
- entry: string | null
92
- deviceSpec: InitRequestBody['deviceSpec']
93
- runtimeVersion: string | null
94
- pageHref: string
95
- transformedBundle: {
96
- url: string
97
- byteLength: number
98
- timestamp: number
99
- } | null
100
- }
101
-
102
90
  type AttachedInteractionSnapshot = {
103
91
  meta: NonNullable<InitRequestBody['snapshots']>[number] & { id: string }
104
92
  bytes: Uint8Array
@@ -184,10 +172,10 @@ export function resolvePublicPreviewOrigin(
184
172
 
185
173
  function printHelp() {
186
174
  console.log(`
187
- peach record upload — publish the current recorded bundle as a /preview/<id> link
175
+ peach record upload — publish a recording as a /preview/<id> link
188
176
 
189
177
  usage:
190
- peach record upload [--origin <url>] [--public-origin <url>] (--events <path> | --video <path>) [--screenshot <path>] [--owner <org> --repo <repo>] [--sim <sim>] [--open] [--assets-only]
178
+ peach record upload [--origin <url>] [--public-origin <url>] (--events <path> | --video <path> | --scene <path>) [--live-bundle] [--screenshot <path>] [--owner <org> --repo <repo>] [--sim <sim>] [--open] [--assets-only]
191
179
 
192
180
  options:
193
181
  --origin <url> upload target (default: ${REMOTE_DEFAULT_ORIGIN})
@@ -210,7 +198,9 @@ options:
210
198
  duration hint for attached flow video
211
199
  --scene <path> a scene recording (.psr.gz) of a remote viewer's display
212
200
  stream. the preview paints it through the simulator's
213
- renderer instead of playing a video
201
+ renderer instead of playing a video; uploads without a
202
+ live bundle by default
203
+ --live-bundle include the running app bundle for Live alongside --scene
214
204
  --timeline-events <path>
215
205
  extra trace timeline rows, JSONL or JSONL gz
216
206
  --trace-profile <path>
@@ -443,6 +433,12 @@ export async function runUpload(
443
433
  const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest')
444
434
  const videoPath = extractStringFlag(mutableArgs, '--video')
445
435
  const scenePath = extractStringFlag(mutableArgs, '--scene')
436
+ const includeLiveBundle = extractBoolFlag(mutableArgs, '--live-bundle')
437
+ if (includeLiveBundle && !scenePath) {
438
+ console.error(' --live-bundle requires --scene')
439
+ peachExit(1)
440
+ }
441
+ const hasBundle = includeLiveBundle || !scenePath
446
442
  const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events')
447
443
  const traceProfilePath = extractStringFlag(mutableArgs, '--trace-profile')
448
444
  const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle')
@@ -669,7 +665,7 @@ export async function runUpload(
669
665
  }
670
666
  }
671
667
  }
672
- if (snapshot?.transformedBundle) {
668
+ if (hasBundle && snapshot?.transformedBundle) {
673
669
  transformedBundleText = await pullTransformedBundle(
674
670
  bridge,
675
671
  snapshot.transformedBundle.byteLength,
@@ -712,7 +708,7 @@ export async function runUpload(
712
708
  )
713
709
  peachExit(2)
714
710
  }
715
- if (!snapshot.bundleUrl) {
711
+ if (hasBundle && !snapshot.bundleUrl) {
716
712
  console.error(
717
713
  ' no ?bundle= URL on the current peach tab.\n' +
718
714
  ' open the app you want to share first (e.g. peach open 8082), then run upload.',
@@ -725,15 +721,15 @@ export async function runUpload(
725
721
  // does not have to re-run sootsim's transforms. fall back to re-fetching
726
722
  // the raw metro URL + forcing lazy=false when the transform hasn't
727
723
  // published yet (e.g., tab was opened but bundle not finished loading).
728
- let bundleBytes: Uint8Array
724
+ let bundleBytes: Uint8Array | undefined
729
725
  let isTransformed = false
730
- if (transformedBundleText !== null) {
726
+ if (hasBundle && transformedBundleText !== null) {
731
727
  bundleBytes = new TextEncoder().encode(transformedBundleText)
732
728
  isTransformed = true
733
729
  console.log(
734
730
  ` using post-transform bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`,
735
731
  )
736
- } else {
732
+ } else if (hasBundle && snapshot.bundleUrl) {
737
733
  const fetchBundleUrl = forceInlineBundle(snapshot.bundleUrl)
738
734
  if (fetchBundleUrl !== snapshot.bundleUrl) {
739
735
  console.log(` forcing lazy=false for self-contained bundle`)
@@ -922,7 +918,7 @@ export async function runUpload(
922
918
 
923
919
  const bundleOrigin = (() => {
924
920
  try {
925
- return new URL(snapshot.bundleUrl).origin
921
+ return snapshot.bundleUrl ? new URL(snapshot.bundleUrl).origin : null
926
922
  } catch {
927
923
  return null
928
924
  }
@@ -1037,8 +1033,8 @@ export async function runUpload(
1037
1033
  // explicitly so the installed app keeps its identity in share replay.
1038
1034
  const appIdentityFiles: PreparedFile[] = []
1039
1035
  let appManifest: string | undefined
1040
- const metroOrigin = loopbackOriginOf(snapshot.bundleUrl)
1041
- if (metroOrigin) {
1036
+ const metroOrigin = snapshot.bundleUrl ? loopbackOriginOf(snapshot.bundleUrl) : null
1037
+ if (metroOrigin && snapshot.bundleUrl) {
1042
1038
  const platform = new URL(snapshot.bundleUrl).searchParams.get('platform') || 'ios'
1043
1039
  const manifestUrl = `${metroOrigin}/`
1044
1040
  const response = await fetch(manifestUrl, {
@@ -1120,19 +1116,31 @@ export async function runUpload(
1120
1116
  // brotli source bytes for the upload. finalize verifies the source hash,
1121
1117
  // minifies in the node build container, and stores one canonical brotli
1122
1118
  // bundle. assets stay gzip below.
1123
- const bundleBr = brotliBundle(bundleBytes)
1124
- const bundleSha256 = createHash('sha256').update(bundleBytes).digest('hex')
1125
- console.log(
1126
- ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`,
1127
- )
1119
+ const bundleBr = bundleBytes ? brotliBundle(bundleBytes) : undefined
1120
+ const identityBytes = bundleBytes ?? sceneBytes
1121
+ if (!identityBytes) throw new Error('upload requires a bundle or scene recording')
1122
+ const contentHash = createHash('sha256').update(identityBytes).digest('hex')
1123
+ if (bundleBytes && bundleBr) {
1124
+ console.log(
1125
+ ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`,
1126
+ )
1127
+ }
1128
1128
 
1129
1129
  const initBody: InitRequestBody = {
1130
- contentHash: bundleSha256,
1131
- bundleSizeBytes: bundleBytes.byteLength,
1132
- bundleContentType: guessBundleContentType(snapshot.bundleUrl),
1133
- bundleEncoding: 'br',
1134
- bundleMinified: false,
1135
- bundleOrigin,
1130
+ contentHash,
1131
+ hasBundle,
1132
+ ...(bundleBytes && snapshot.bundleUrl
1133
+ ? {
1134
+ bundleSizeBytes: bundleBytes.byteLength,
1135
+ bundleNativeAppIdentityHeader: snapshot.bundleNativeAppIdentityHeader,
1136
+ bundleContentType: guessBundleContentType(snapshot.bundleUrl),
1137
+ bundleEncoding: 'br',
1138
+ bundleMinified: false,
1139
+ bundleOrigin,
1140
+ entry: snapshot.entry,
1141
+ isTransformed,
1142
+ }
1143
+ : {}),
1136
1144
  runtimeVersion: snapshot.runtimeVersion ?? undefined,
1137
1145
  // config-only fonts resolved by `peach open` and carried on the engine
1138
1146
  // URL. persist them onto the share meta so /preview/<id> re-applies
@@ -1143,8 +1151,6 @@ export async function runUpload(
1143
1151
  // /preview/<id> re-applies `?appSplash=` and paints the same splash; the
1144
1152
  // image bytes are staged above so the engine's <img> resolves in preview.
1145
1153
  appSplash: appSplashWire || undefined,
1146
- entry: snapshot.entry,
1147
- isTransformed,
1148
1154
  deviceSpec: snapshot.deviceSpec,
1149
1155
  installationId: gitHubIdentity?.installationId ?? undefined,
1150
1156
  repoId: gitHubIdentity?.repoId,
@@ -1227,7 +1233,7 @@ export async function runUpload(
1227
1233
  }
1228
1234
 
1229
1235
  const putT0 = Date.now()
1230
- let putBytes = bundleBr.byteLength
1236
+ let putBytes = bundleBr?.byteLength ?? 0
1231
1237
  for (const f of prepared) putBytes += f.bytes.byteLength
1232
1238
  if (eventsGz) putBytes += eventsGz.byteLength
1233
1239
  putBytes += timelineGz.byteLength
@@ -1263,7 +1269,7 @@ export async function runUpload(
1263
1269
  })
1264
1270
  finalize = result.finalize
1265
1271
  const totalObjects =
1266
- 1 +
1272
+ (result.init.bundle ? 1 : 0) +
1267
1273
  result.init.files.length +
1268
1274
  (result.init.events ? 1 : 0) +
1269
1275
  (result.init.timeline ? 1 : 0) +
@@ -22,6 +22,7 @@ import {
22
22
  softwareChromeLaunchOptions,
23
23
  } from '../../../../scripts/lib/reap-browser'
24
24
  import { createBrowserPageTargetReader } from '../../../peach-engine/src/browser-page-target'
25
+ import { captureBrowserAccessibility } from '../../../peach-engine/src/capture/browser-accessibility-host'
25
26
  import {
26
27
  readBrowserCompositeCaptureRequest,
27
28
  resolveBrowserCompositeCdpClip,
@@ -108,10 +109,10 @@ const viewportSpec = /^(\\d+)x(\\d+)$/.exec(process.env.SOOTSIM_PW_VIEWPORT || '
108
109
  const viewport = viewportSpec
109
110
  ? { width: Number(viewportSpec[1]), height: Number(viewportSpec[2]) }
110
111
  : { width: 600, height: 900 };
111
- // device pixels per css pixel. playwright's default is 1, which makes the
112
- // engine allocate its canvas backing store at HALF the resolution a normal
113
- // retina-mac chrome would (dpr 2) — the sim looks visibly soft. default to 2
114
- // so a driver-launched sim matches what the user sees in their own browser.
112
+ // host display pixels per css pixel; default to a retina Mac browser's 2.
113
+ // medium renders at 2 and high at device native, independently of browser DPR.
114
+ // low follows measured on-screen density, capped at device native.
115
+ // SOOTSIM_PW_DSF changes browser DPR, not the selected render resolution.
115
116
  const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2);
116
117
  const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500);
117
118
  const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || '';
@@ -126,6 +127,7 @@ const listProcessChildren = ${listProcessChildren.toString()};
126
127
  const { tmpdir } = require('os');
127
128
  const { join } = require('path');
128
129
  const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
130
+ const captureBrowserAccessibility = ${captureBrowserAccessibility.toString()};
129
131
  const automateWebViewFrames = ${automateWebViewFrames.toString()};
130
132
  const automateWebAuthenticationSessions = ${automateWebAuthenticationSessions.toString()};
131
133
  const webViewAutomationHelpers = ${webViewAutomationHelpersSource()};
@@ -143,12 +145,9 @@ const readChromiumQueue = ${readChromiumQueue.toString()};
143
145
  // aborted/closed \`peach open\` leaked a renderer + gpu helper burning 100%+
144
146
  // CPU until manually killed (multiple incidents 2026-06).
145
147
  //
146
- // fix: identify our chrome tree by its UNIQUE user-data-dir. every helper a
147
- // chrome root spawns inherits \`--user-data-dir=<dir>\` in its cmdline, so
148
- // \`pgrep -f <profileDir>\` finds exactly the tree we launched and nothing else
149
- // — Chrome for Testing and bundled playwright chromium alike. as a side benefit we
150
- // no longer need the launch / launchPersistentContext fork: we ALWAYS use a
151
- // profile dir (mkdtemp'd if the caller didn't pass one, then removed at exit).
148
+ // a named profile can already belong to another host. its path finds browser
149
+ // candidates; the unique launch environment proves which ones we may reap,
150
+ // including helpers reparented after the browser root exits.
152
151
  const reapEnabled = process.platform !== 'win32';
153
152
  let profileDir = callerUserDataDir;
154
153
  let ephemeralProfile = false;
@@ -160,33 +159,35 @@ if (!profileDir) {
160
159
  // host a bounded temporary cache so persistent named profiles retain cookies
161
160
  // and storage without multiplying a private cache for every profile.
162
161
  const diskCacheDir = mkdtempSync(join(tmpdir(), 'peach-playwright-cache-'));
163
- const chromePidsByProfile = () => {
162
+ let profileLaunched = false;
163
+ // [pid, command line] for every process on the profile. linux reads /proc,
164
+ // since minimal and rootless images ship no ps.
165
+ const profileProcesses = () => {
164
166
  if (!reapEnabled) return [];
165
167
  try {
166
168
  // match the whole --user-data-dir argument: a pattern search also found
167
169
  // browsers whose profile path merely starts with this one.
168
170
  const flag = ' --user-data-dir=' + profileDir + ' ';
169
- return execSync('ps -axo pid=,command=', {
170
- stdio: ['ignore', 'pipe', 'ignore'],
171
- maxBuffer: Infinity,
172
- })
173
- .toString().split('\\n').map((line) => line.trim().match(/^(\\d+)\\s+(.*)$/))
174
- .filter((row) => row && (' ' + row[2] + ' ').includes(flag))
175
- .map((row) => Number(row[1]))
176
- .filter((p) => p && p !== process.pid);
177
- } catch { return []; }
178
- };
179
- const chromeCommandsByProfile = () => {
180
- if (!reapEnabled) return [];
181
- const pids = chromePidsByProfile();
182
- if (pids.length === 0) return [];
183
- try {
184
- return execSync('ps -p ' + pids.join(',') + ' -o command=', {
185
- stdio: ['ignore', 'pipe', 'ignore'],
186
- maxBuffer: Infinity,
187
- }).toString().split('\\n').filter(Boolean);
188
- } catch { return []; }
171
+ const rows = process.platform === 'linux'
172
+ ? readdirSync('/proc').filter((name) => /^\\d+$/.test(name)).map((name) => {
173
+ let command = '';
174
+ // a process can exit between the listing and this read
175
+ try { command = readFileSync('/proc/' + name + '/cmdline', 'utf8').split('\\0').join(' ').trim(); } catch {}
176
+ return [Number(name), command];
177
+ })
178
+ : execSync('ps -axo pid=,command=', {
179
+ stdio: ['ignore', 'pipe', 'ignore'],
180
+ maxBuffer: Infinity,
181
+ })
182
+ .toString().split('\\n').map((line) => line.trim().match(/^(\\d+)\\s+(.*)$/))
183
+ .filter(Boolean).map((row) => [Number(row[1]), row[2]]);
184
+ return rows.filter(([pid, command]) => pid && pid !== process.pid && (' ' + command + ' ').includes(flag));
185
+ } catch (error) {
186
+ throw new Error('[playwright-driver] cannot establish browser profile ownership: ' + String(error));
187
+ }
189
188
  };
189
+ const chromePidsByProfile = () => profileProcesses().map(([pid]) => pid);
190
+ const chromeCommandsByProfile = () => profileProcesses().map(([, command]) => command);
190
191
  const assertNoUnsafeSwiftShaderFlag = () => {
191
192
  if (!hardwareGpuRequired) return;
192
193
  const unsafe = chromeCommandsByProfile().find((command) =>
@@ -207,7 +208,29 @@ const killTree = (pid) => {
207
208
  for (const k of kids) killTree(k);
208
209
  try { process.kill(pid, 'SIGKILL'); } catch {}
209
210
  };
210
- const reapChrome = () => { for (const pid of chromePidsByProfile()) killTree(pid); };
211
+ const reapChrome = () => {
212
+ try {
213
+ for (const pid of chromePidsByProfile()) {
214
+ let owned = false;
215
+ if (process.platform === 'linux') {
216
+ try {
217
+ owned = readFileSync('/proc/' + pid + '/environ', 'utf8').split('\\0')
218
+ .includes('PEACH_PW_BROWSER_OWNER=' + diskCacheDir);
219
+ } catch {}
220
+ } else {
221
+ const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
222
+ const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
223
+ const argv = (plain.stdout || '').trimEnd();
224
+ const full = (withEnv.stdout || '').trimEnd();
225
+ owned = plain.status === 0 && withEnv.status === 0 && full.startsWith(argv) &&
226
+ (full.slice(argv.length) + ' ').includes(' PEACH_PW_BROWSER_OWNER=' + diskCacheDir + ' ');
227
+ }
228
+ if (owned) killTree(pid);
229
+ }
230
+ } catch (error) {
231
+ process.stderr.write('[playwright-driver] browser teardown refused: ' + String(error) + '\\n');
232
+ }
233
+ };
211
234
  const removeDisposableCaches = (root) => {
212
235
  let children = [];
213
236
  try { children = readdirSync(root, { withFileTypes: true }); } catch { return; }
@@ -237,7 +260,7 @@ const cleanupProfile = () => {
237
260
  try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
238
261
  return;
239
262
  }
240
- removeDisposableCaches(profileDir);
263
+ if (profileLaunched) removeDisposableCaches(profileDir);
241
264
  };
242
265
  (async () => {
243
266
  const pw = require(modulePath);
@@ -401,9 +424,8 @@ const cleanupProfile = () => {
401
424
  await bounded('desktop host stop', stopDesktopHost, timeoutMs);
402
425
  if (context) await bounded('context.close', () => context.close(), timeoutMs);
403
426
  await terminateBrowserProcess();
404
- // close() + root-SIGKILL above handle the graceful case; the helper tree
405
- // (gpu/renderer/utilities) reparents and survives every one of those, so
406
- // we always sweep by our unique profile dir as the load-bearing teardown.
427
+ // close() can leave reparented helpers; reap only this launch's browser
428
+ // and helpers, even if another host now uses the same named profile.
407
429
  reapChrome();
408
430
  cleanupProfile();
409
431
  if (connectAckFile) {
@@ -494,10 +516,11 @@ const cleanupProfile = () => {
494
516
  const opts = channel
495
517
  ? { headless, channel, viewport, deviceScaleFactor }
496
518
  : { headless, viewport, deviceScaleFactor };
519
+ opts.env = { ...process.env, PEACH_PW_BROWSER_OWNER: diskCacheDir };
497
520
  opts.executablePath = executablePath;
498
521
  if (libraryPath) {
499
522
  opts.env = {
500
- ...process.env,
523
+ ...opts.env,
501
524
  LD_LIBRARY_PATH: [libraryPath, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'),
502
525
  };
503
526
  }
@@ -561,11 +584,9 @@ const cleanupProfile = () => {
561
584
  // room for chrome's own toolbar, so the window isn't desktop-shaped
562
585
  // around a letterboxed page.
563
586
  if (!headless) opts.args.push('--window-size=' + viewport.width + ',' + (viewport.height + 88));
564
- // ALWAYS persistent: every chrome process inherits --user-data-dir=<dir>
565
- // in its cmdline, which is how reapChrome() finds the whole tree (root +
566
- // helpers) on shutdown. without a unique dir there is no way to attribute
567
- // an orphaned helper back to us, which is the leak we just fixed.
568
- return await pw.chromium.launchPersistentContext(profileDir, opts);
587
+ const launched = await pw.chromium.launchPersistentContext(profileDir, opts);
588
+ profileLaunched = true;
589
+ return launched;
569
590
  };
570
591
  // the host-wide chromium slot is held for this host's life; its exit frees it
571
592
  await acquireChromiumAdmission({
@@ -663,6 +684,8 @@ const cleanupProfile = () => {
663
684
  await shutdown(0);
664
685
  });
665
686
  const pointerHosts = new WeakMap();
687
+ await context.exposeBinding('__peachHostAccessibility', (source, instanceIds) =>
688
+ captureBrowserAccessibility(source.page, instanceIds));
666
689
  await context.exposeBinding('__sootsimHostPointer', async (source, value) => {
667
690
  let pending = pointerHosts.get(source.page);
668
691
  if (!pending) {
package/cli/main.ts CHANGED
@@ -42,7 +42,7 @@ async function handleTopLevelError(err: unknown): Promise<never> {
42
42
  // peach's known top-level errors are all single-line, narrow strings.
43
43
  // everything else gets the full stack so unexpected crashes still surface.
44
44
  const isKnownCliFailure =
45
- /^command timed out after \d+s$/.test(msg) ||
45
+ /^command timed out after \d+(?:\.\d+)?s$/.test(msg) ||
46
46
  /^cannot hand off bridge on port \d+: pid \d+ is not this Peach invocation$/.test(
47
47
  msg,
48
48
  ) ||
@@ -528,6 +528,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
528
528
  category: 'guest_app_network',
529
529
  count: 1,
530
530
  },
531
+ "packages/peach-engine/src/mcp/shared-config-transport.ts :: fetch :: '/__sootsim/shared-config'":
532
+ { category: 'same_origin_runtime', count: 1 },
531
533
  'packages/peach-engine/src/mcp/ws-bridge.ts :: fetch :: bridgeHealthUrl': {
532
534
  category: 'local_development',
533
535
  count: 1,
@@ -561,8 +563,6 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
561
563
  category: 'guest_app_network',
562
564
  count: 1,
563
565
  },
564
- "packages/peach-engine/src/projects/projects-persistence.ts :: fetch :: '/__sootsim/shared-config'":
565
- { category: 'same_origin_runtime', count: 1 },
566
566
  'packages/peach-engine/src/react-native/index.ts :: fetch :: resource': {
567
567
  category: 'guest_app_network',
568
568
  count: 1,
@@ -655,7 +655,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
655
655
  count: 1,
656
656
  },
657
657
  "packages/peach-engine/src/settings/persistence.ts :: fetch :: '/__sootsim/shared-config'":
658
- { category: 'same_origin_runtime', count: 2 },
658
+ { category: 'same_origin_runtime', count: 1 },
659
659
  'packages/peachsim/cli/app-fonts.ts :: fetch :: `${origin}/`': {
660
660
  category: 'local_development',
661
661
  count: 1,
@@ -867,8 +867,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
867
867
  category: 'same_origin_runtime',
868
868
  count: 1,
869
869
  },
870
- 'packages/peach-engine/src/engine/image-loader.ts :: fetch-alias :: resolveImageFetchUri(uri)':
871
- { category: 'guest_app_network', count: 1 },
870
+ 'packages/peach-engine/src/engine/image-loader.ts :: fetch-alias :: uri': {
871
+ category: 'guest_app_network',
872
+ count: 1,
873
+ },
872
874
  'packages/peach-engine/src/ExternalApp.tsx :: fetch-alias :: requestUrl': {
873
875
  category: 'guest_app_network',
874
876
  count: 1,
@@ -98,5 +98,9 @@ export function setPauseAwareTimeout(
98
98
  }
99
99
  return () => {
100
100
  timers.delete(timer)
101
+ if (timers.size === 0 && poll) {
102
+ clearInterval(poll)
103
+ poll = undefined
104
+ }
101
105
  }
102
106
  }
package/cli/ws-bridge.ts CHANGED
@@ -333,9 +333,11 @@ export function printBridgeWorldNotice(
333
333
  export interface WsBridge {
334
334
  readonly plane?: 'local' | 'cloud'
335
335
  // `opts.timeoutMs` overrides the bridge-wide commandTimeoutMs for a single
336
- // call — used for fast readiness probes so a dead/unresponsive sim fails in
337
- // seconds instead of stalling the full command timeout.
338
- 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>
339
341
  // includeRetained appends the bridge's retained disconnected sims
340
342
  // (readyState 'closed' with disconnectCause) after the live ones. only
341
343
  // loss-reporting surfaces pass it; every other consumer keeps the
@@ -381,6 +383,7 @@ interface WsResponse {
381
383
  interface WsBridgeOptions {
382
384
  connectTimeoutMs?: number
383
385
  commandTimeoutMs?: number
386
+ explicitTimeout?: boolean
384
387
  simId?: string
385
388
  cliLabel?: string
386
389
  leaseScope?: 'connection'
@@ -455,12 +458,14 @@ export interface ParsedBridgeCliArgs {
455
458
  simId?: string
456
459
  simIdSource: 'flag' | 'saved' | 'none'
457
460
  commandTimeoutMs: number
461
+ explicitTimeout?: boolean
458
462
  }
459
463
 
460
464
  interface BridgeFlagState {
461
465
  wsPort: number
462
466
  explicitPort: boolean
463
467
  commandTimeoutMs: number
468
+ explicitTimeout?: boolean
464
469
  simId: string | undefined
465
470
  simIdSource: ParsedBridgeCliArgs['simIdSource']
466
471
  }
@@ -487,7 +492,10 @@ const BRIDGE_VALUE_FLAGS_TABLE: readonly {
487
492
  {
488
493
  names: ['--timeout'],
489
494
  apply: (state, value) => {
490
- if (value !== undefined) state.commandTimeoutMs = Number(value)
495
+ if (value !== undefined) {
496
+ state.commandTimeoutMs = Number(value)
497
+ state.explicitTimeout = true
498
+ }
491
499
  },
492
500
  },
493
501
  {
@@ -573,6 +581,7 @@ export function parseBridgeCliArgs(
573
581
  simId: state.simId,
574
582
  simIdSource: state.simIdSource,
575
583
  commandTimeoutMs: state.commandTimeoutMs,
584
+ explicitTimeout: state.explicitTimeout,
576
585
  }
577
586
  }
578
587
 
@@ -749,7 +758,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
749
758
  async send(cmd, callOpts) {
750
759
  // NOTE: `opts` (the createBridge closure arg) carries simId; do not
751
760
  // shadow it with the per-call arg.
752
- const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
761
+ const effectiveTimeoutMs =
762
+ callOpts?.timeoutMs ??
763
+ (opts.explicitTimeout ? commandTimeoutMs : callOpts?.defaultTimeoutMs) ??
764
+ commandTimeoutMs
753
765
  const sendOnce = async (
754
766
  defaultSimId?: string,
755
767
  cmdOverride?: Record<string, any>,
@@ -766,11 +778,7 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
766
778
  const clearTimeoutFn = setPauseAwareTimeout(
767
779
  () => {
768
780
  pending.delete(id)
769
- reject(
770
- new Error(
771
- `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
772
- ),
773
- )
781
+ reject(new Error(`command timed out after ${effectiveTimeoutMs / 1000}s`))
774
782
  },
775
783
  effectiveTimeoutMs,
776
784
  lineageBySimId.get(payload.simId)?.hostPid,
@@ -955,6 +963,7 @@ export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
955
963
  if (cloudBridge) return cloudBridge
956
964
  const options = {
957
965
  commandTimeoutMs: parsed.commandTimeoutMs,
966
+ explicitTimeout: parsed.explicitTimeout,
958
967
  simId: parsed.simId,
959
968
  simIdSource: parsed.simIdSource,
960
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))
@@ -322,13 +331,21 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
322
331
  )
323
332
  )
324
333
  throw new Error('native cadence has no source-matched framebuffer consumer')
325
- const frames = fs
326
- .readFileSync(proofBoundPath(root, native.receipt), 'utf8')
334
+ const frames = boundText(
335
+ root,
336
+ native.receipt,
337
+ native.receiptSha256,
338
+ 'native cadence source receipt',
339
+ )
327
340
  .trim()
328
341
  .split('\n')
329
342
  .map(JSON.parse)
330
- const media = fs
331
- .readFileSync(proofBoundPath(root, native.mediaFrames), 'utf8')
343
+ const media = boundText(
344
+ root,
345
+ native.mediaFrames,
346
+ native.mediaFramesSha256,
347
+ 'native cadence presentation census',
348
+ )
332
349
  .trim()
333
350
  .split('\n')
334
351
  .map(JSON.parse)
@@ -339,11 +356,34 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
339
356
  requireNativeRawFrame,
340
357
  } = require('../../peach-conformance/e2e/utils/native-frame-receipt.cjs')
341
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
+ )
342
380
  const origin = frames[0].consumerArrival
381
+ let expectedCallback = 0
343
382
  for (const [index, frame] of frames.entries()) {
383
+ while (superseded.delete(expectedCallback)) expectedCallback++
344
384
  const clock = frame.consumerArrival
345
385
  if (
346
- frame.callbackIndex !== index ||
386
+ frame.callbackIndex !== expectedCallback++ ||
347
387
  media[index].sourceFrameIndex !== index ||
348
388
  !clock ||
349
389
  clock.domain !== 'host-mach-absolute-time' ||
@@ -374,6 +414,9 @@ function authenticateNativeCadenceCensus(root, sourceRoot, life, decoded) {
374
414
  ),
375
415
  )
376
416
  }
417
+ while (superseded.delete(expectedCallback)) expectedCallback++
418
+ if (superseded.size)
419
+ throw new Error('native superseded receipts fall outside the callback order')
377
420
  }
378
421
 
379
422
  function readDiscreteFrameReceipt(root, sourceRoot, reference, expected) {
@@ -710,9 +753,23 @@ function discreteFrameEvidenceFiles(root, evidence) {
710
753
  const life = JSON.parse(
711
754
  fs.readFileSync(proofBoundPath(root, receipt.lifecycle.path), 'utf8'),
712
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
+ }
713
769
  const native = life.nativeFramebuffer
714
770
  if (native) {
715
771
  paths.add(native.receipt)
772
+ paths.add(native.superseded)
716
773
  paths.add(native.mediaFrames)
717
774
  const rows = fs
718
775
  .readFileSync(proofBoundPath(root, native.receipt), 'utf8')