peachsim 0.1.1635 → 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 (142) hide show
  1. package/README.md +10 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +96 -22
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/cloud-session.ts +16 -0
  7. package/cli/commands/assert.ts +17 -4
  8. package/cli/commands/box.ts +33 -19
  9. package/cli/commands/doctor.ts +37 -20
  10. package/cli/commands/flow.ts +1 -1
  11. package/cli/commands/inspect/actions.ts +3 -2
  12. package/cli/commands/inspect/core.ts +0 -8
  13. package/cli/commands/inspect/shared.ts +9 -4
  14. package/cli/commands/inspect.ts +65 -61
  15. package/cli/commands/platform.ts +2 -1
  16. package/cli/commands/preview.ts +1 -1
  17. package/cli/commands/record.ts +154 -68
  18. package/cli/commands/swift.ts +18 -4
  19. package/cli/commands/three-mode.ts +4 -38
  20. package/cli/commands/upload.ts +55 -49
  21. package/cli/drivers/playwright-sim-host.ts +65 -42
  22. package/cli/flow-live-status.ts +0 -2
  23. package/cli/main.ts +2 -2
  24. package/cli/outbound-endpoints.ts +7 -5
  25. package/cli/pause-aware-timeout.ts +4 -0
  26. package/cli/ws-bridge.ts +19 -16
  27. package/detox/attempt-events.cjs +99 -6
  28. package/detox/attempt-events.d.cts +7 -0
  29. package/detox/expectations.ts +7 -3
  30. package/detox/index.ts +259 -139
  31. package/detox/jest-environment.cjs +5 -1
  32. package/detox/motion-change.cjs +170 -21
  33. package/detox/motion-change.d.cts +32 -0
  34. package/detox/navigation-lifecycle.ts +35 -3
  35. package/dist-lib/agent-daemon-client.cjs +1 -1
  36. package/dist-lib/agent-events.cjs +1 -1
  37. package/dist-lib/attached-projects.cjs +1 -1
  38. package/dist-lib/auth/shared-session.cjs +1 -1
  39. package/dist-lib/backend-origin.cjs +17 -7
  40. package/dist-lib/beta.cjs +1 -1
  41. package/dist-lib/beta.mjs +1 -1
  42. package/dist-lib/bridge-constants.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.cjs +1 -1
  44. package/dist-lib/bridge-contract-input.mjs +1 -1
  45. package/dist-lib/bridge-contract.cjs +1 -1
  46. package/dist-lib/bridge-contract.mjs +1 -1
  47. package/dist-lib/bundle-channel.cjs +5014 -2268
  48. package/dist-lib/capture-contract.cjs +1 -1
  49. package/dist-lib/capture-contract.mjs +1 -1
  50. package/dist-lib/cloud-contract.cjs +1 -1
  51. package/dist-lib/cloud-contract.mjs +1 -1
  52. package/dist-lib/cloud-test-args.cjs +1 -1
  53. package/dist-lib/cloud-test-args.mjs +1 -1
  54. package/dist-lib/cloud.cjs +1 -1
  55. package/dist-lib/cloud.mjs +1 -1
  56. package/dist-lib/config.cjs +1 -1
  57. package/dist-lib/config.mjs +1 -1
  58. package/dist-lib/detox/index.cjs +1036 -166
  59. package/dist-lib/detox/jest-environment.cjs +5 -1
  60. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  61. package/dist-lib/expo-swift-bundle.cjs +4613 -1963
  62. package/dist-lib/expo-swift-metadata.cjs +9 -7
  63. package/dist-lib/expo-swift-metadata.mjs +9 -7
  64. package/dist-lib/home-paths.cjs +1 -1
  65. package/dist-lib/host/bridge-host.cjs +58072 -16400
  66. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  67. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  68. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  69. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  70. package/dist-lib/host/websocket-proxy.cjs +1 -1
  71. package/dist-lib/index.cjs +2790 -143
  72. package/dist-lib/jump-to-source-babel.cjs +1 -1
  73. package/dist-lib/jump-to-source-native.cjs +1 -1
  74. package/dist-lib/kotlin-host.cjs +1 -1
  75. package/dist-lib/menu.cjs +1 -1
  76. package/dist-lib/menu.mjs +1 -1
  77. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  78. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  79. package/dist-lib/metro-production-bundle.cjs +1 -1
  80. package/dist-lib/metro-production-bundle.mjs +1 -1
  81. package/dist-lib/metro.cjs +2579 -95
  82. package/dist-lib/native-app-identity-project.cjs +243 -53
  83. package/dist-lib/native-app-identity-project.mjs +243 -53
  84. package/dist-lib/native-app-identity.cjs +29 -30
  85. package/dist-lib/native-app-identity.mjs +29 -30
  86. package/dist-lib/native-firebase-metadata.cjs +1 -1
  87. package/dist-lib/native-firebase-metadata.mjs +1 -1
  88. package/dist-lib/plist.cjs +1 -1
  89. package/dist-lib/plist.mjs +1 -1
  90. package/dist-lib/profiles.cjs +1 -1
  91. package/dist-lib/public-brand.cjs +1 -1
  92. package/dist-lib/react-native-host-modules.cjs +2 -2
  93. package/dist-lib/react-native-host-modules.mjs +2 -2
  94. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  95. package/dist-lib/sdk.cjs +4 -3
  96. package/dist-lib/sdk.mjs +4 -3
  97. package/dist-lib/skills.cjs +66475 -23097
  98. package/dist-lib/swift-compile.cjs +452 -260
  99. package/dist-lib/swift-compile.mjs +451 -252
  100. package/dist-lib/swift-package.cjs +1 -1
  101. package/dist-lib/swift-package.mjs +1 -1
  102. package/dist-lib/swift-submit.cjs +14 -7
  103. package/dist-lib/swift-submit.mjs +14 -7
  104. package/dist-lib/swift-toolchain.cjs +316 -41
  105. package/dist-lib/swift.cjs +1 -1
  106. package/dist-lib/vite-swift.cjs +8188 -5636
  107. package/dist-lib/vite.cjs +2857 -12
  108. package/dist-lib/worklet-executable.cjs +14 -2
  109. package/dist-lib/worklet-executable.mjs +14 -2
  110. package/package.json +1 -1
  111. package/src/backend-origin.ts +21 -6
  112. package/src/expo-swift-host.ts +5 -2
  113. package/src/expoKotlinContract.ts +4 -7
  114. package/src/expoSwiftContract.ts +4 -6
  115. package/src/host/staged-host-files.ts +9 -0
  116. package/src/native-app-identity-project.ts +1 -4
  117. package/src/native-app-identity.ts +1 -4
  118. package/src/nativeAppIconContract.ts +1 -4
  119. package/src/output-lock-stdio.ts +36 -0
  120. package/src/react-native-bridge-modules.ts +106 -78
  121. package/src/react-native-host-modules.ts +4 -1
  122. package/src/settings-bundle.ts +1 -4
  123. package/src/swift-compile.ts +224 -139
  124. package/src/swift-distribution.lock.json +23 -12
  125. package/src/swift-distribution.ts +103 -4
  126. package/src/swift-host.ts +95 -30
  127. package/src/swift-icu.ts +16 -10
  128. package/src/swift-objc-compile.ts +547 -0
  129. package/src/swift-objc-metadata.ts +204 -0
  130. package/src/swift-objc-sdk.ts +883 -19
  131. package/src/swift-objc-standin.ts +126 -14
  132. package/src/swift-package-manifest.ts +42 -1
  133. package/src/swift-packages.ts +35 -21
  134. package/src/swift-root-lock.ts +153 -0
  135. package/src/swift-standin-emit.ts +40 -7
  136. package/src/swift-standins.ts +404 -138
  137. package/src/swift-submit.ts +18 -7
  138. package/src/swift-toolchain.ts +378 -46
  139. package/src/swiftContract.ts +12 -0
  140. package/src/worker-system-chunks.ts +11 -12
  141. package/src/worklet-executable.ts +19 -1
  142. package/src/xcode-project.ts +280 -33
@@ -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')
@@ -526,7 +522,7 @@ export async function runUpload(
526
522
  bridge,
527
523
  '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
528
524
  )
529
- if (snapshot?.bundleUrl) {
525
+ if (hasBundle && snapshot?.bundleUrl) {
530
526
  const bundleOrigin = new URL(snapshot.bundleUrl).origin
531
527
  // manifest stays the body-less re-fetch path for bundle-origin static
532
528
  // assets (chunks, fonts, wasm). bundle-origin api endpoints would also
@@ -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`)
@@ -788,7 +784,7 @@ export async function runUpload(
788
784
  .join('\n')
789
785
  const timelineGz = gzipSync(traceTimelineJsonl ? `${traceTimelineJsonl}\n` : '')
790
786
  const storageGz =
791
- opts.storageSnapshot && hasShareableStorage(opts.storageSnapshot)
787
+ hasBundle && opts.storageSnapshot && hasShareableStorage(opts.storageSnapshot)
792
788
  ? gzipSync(JSON.stringify(opts.storageSnapshot))
793
789
  : undefined
794
790
  console.log(
@@ -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
  }
@@ -975,10 +971,9 @@ export async function runUpload(
975
971
  // fetcher (same code path as the build's `peach app-fonts stage`) so both
976
972
  // pipelines store identical bytes keyed by sha256(url).
977
973
  const appFontFiles: PreparedFile[] = []
978
- const appFontsWire = await resolveAppFontsWire(
979
- appFontsWireFromPageHref(snapshot.pageHref),
980
- fetch,
981
- )
974
+ const appFontsWire = hasBundle
975
+ ? await resolveAppFontsWire(appFontsWireFromPageHref(snapshot.pageHref), fetch)
976
+ : ''
982
977
  if (appFontsWire) {
983
978
  const fonts = await fetchAppFontFiles(appFontsWire, {
984
979
  onStaged: ({ url, byteLength }) =>
@@ -1007,7 +1002,7 @@ export async function runUpload(
1007
1002
  // still-live metro and persist them keyed by sha256(url). the engine's
1008
1003
  // <img src> resolves them through the share fetch proxy in preview mode.
1009
1004
  const appSplashFiles: PreparedFile[] = []
1010
- const appSplashWire = appSplashWireFromPageHref(snapshot.pageHref) || ''
1005
+ const appSplashWire = hasBundle ? appSplashWireFromPageHref(snapshot.pageHref) : ''
1011
1006
  if (appSplashWire) {
1012
1007
  const images = await fetchAppSplashImages(appSplashWire, {
1013
1008
  onStaged: ({ url, byteLength }) =>
@@ -1037,8 +1032,9 @@ export async function runUpload(
1037
1032
  // explicitly so the installed app keeps its identity in share replay.
1038
1033
  const appIdentityFiles: PreparedFile[] = []
1039
1034
  let appManifest: string | undefined
1040
- const metroOrigin = loopbackOriginOf(snapshot.bundleUrl)
1041
- if (metroOrigin) {
1035
+ const metroOrigin =
1036
+ hasBundle && 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) {
@@ -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
@@ -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
  ) ||
@@ -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
 
@@ -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
@@ -372,15 +374,10 @@ export class BridgeSimLockedError extends Error {
372
374
  }
373
375
  }
374
376
 
375
- interface WsResponse {
376
- id: number
377
- result?: any
378
- error?: string
379
- }
380
-
381
377
  interface WsBridgeOptions {
382
378
  connectTimeoutMs?: number
383
379
  commandTimeoutMs?: number
380
+ explicitTimeout?: boolean
384
381
  simId?: string
385
382
  cliLabel?: string
386
383
  leaseScope?: 'connection'
@@ -455,12 +452,14 @@ export interface ParsedBridgeCliArgs {
455
452
  simId?: string
456
453
  simIdSource: 'flag' | 'saved' | 'none'
457
454
  commandTimeoutMs: number
455
+ explicitTimeout?: boolean
458
456
  }
459
457
 
460
458
  interface BridgeFlagState {
461
459
  wsPort: number
462
460
  explicitPort: boolean
463
461
  commandTimeoutMs: number
462
+ explicitTimeout?: boolean
464
463
  simId: string | undefined
465
464
  simIdSource: ParsedBridgeCliArgs['simIdSource']
466
465
  }
@@ -487,7 +486,10 @@ const BRIDGE_VALUE_FLAGS_TABLE: readonly {
487
486
  {
488
487
  names: ['--timeout'],
489
488
  apply: (state, value) => {
490
- if (value !== undefined) state.commandTimeoutMs = Number(value)
489
+ if (value !== undefined) {
490
+ state.commandTimeoutMs = Number(value)
491
+ state.explicitTimeout = true
492
+ }
491
493
  },
492
494
  },
493
495
  {
@@ -573,6 +575,7 @@ export function parseBridgeCliArgs(
573
575
  simId: state.simId,
574
576
  simIdSource: state.simIdSource,
575
577
  commandTimeoutMs: state.commandTimeoutMs,
578
+ explicitTimeout: state.explicitTimeout,
576
579
  }
577
580
  }
578
581
 
@@ -749,7 +752,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
749
752
  async send(cmd, callOpts) {
750
753
  // NOTE: `opts` (the createBridge closure arg) carries simId; do not
751
754
  // shadow it with the per-call arg.
752
- const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
755
+ const effectiveTimeoutMs =
756
+ callOpts?.timeoutMs ??
757
+ (opts.explicitTimeout ? commandTimeoutMs : callOpts?.defaultTimeoutMs) ??
758
+ commandTimeoutMs
753
759
  const sendOnce = async (
754
760
  defaultSimId?: string,
755
761
  cmdOverride?: Record<string, any>,
@@ -766,11 +772,7 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
766
772
  const clearTimeoutFn = setPauseAwareTimeout(
767
773
  () => {
768
774
  pending.delete(id)
769
- reject(
770
- new Error(
771
- `command timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`,
772
- ),
773
- )
775
+ reject(new Error(`command timed out after ${effectiveTimeoutMs / 1000}s`))
774
776
  },
775
777
  effectiveTimeoutMs,
776
778
  lineageBySimId.get(payload.simId)?.hostPid,
@@ -955,6 +957,7 @@ export function createBridgeFromParsed(parsed: ParsedBridgeCliArgs): WsBridge {
955
957
  if (cloudBridge) return cloudBridge
956
958
  const options = {
957
959
  commandTimeoutMs: parsed.commandTimeoutMs,
960
+ explicitTimeout: parsed.explicitTimeout,
958
961
  simId: parsed.simId,
959
962
  simIdSource: parsed.simIdSource,
960
963
  }