peachsim 0.1.1625 → 0.1.1640

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -18,15 +18,18 @@ import {
18
18
  // require, in a child process the CLI spawns as itself.
19
19
  import {
20
20
  chromeDisabledFeaturesSwitch,
21
+ LINUX_CHROME_SCOPE_ENV,
21
22
  softwareChromeLaunchOptions,
22
23
  } from '../../../../scripts/lib/reap-browser'
23
24
  import { createBrowserPageTargetReader } from '../../../peach-engine/src/browser-page-target'
25
+ import { captureBrowserAccessibility } from '../../../peach-engine/src/capture/browser-accessibility-host'
24
26
  import {
25
27
  readBrowserCompositeCaptureRequest,
26
28
  resolveBrowserCompositeCdpClip,
27
29
  } from '../../../peach-engine/src/capture/browser-composite'
28
30
  import { WEBVIEW_HOST_BOOTSTRAP_SOURCE } from '../../../peach-engine/src/components/native-ui/webview-frame-bridge'
29
31
  import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render-worker/browser-pointer'
32
+ import { automateWebAuthenticationSessions } from '../../../peach-engine/src/web-authentication-automation-host'
30
33
  import {
31
34
  automateWebViewFrames,
32
35
  WEBVIEW_HOST_BINDING,
@@ -69,6 +72,9 @@ const callerUserDataDir = process.env.SOOTSIM_PW_USERDATADIR || '';
69
72
  // SOOTSIM_PW_OWNER_POLL_MS is a test-only override of the 5s poll interval;
70
73
  // production never sets it.
71
74
  const ownerPid = Number(process.env.SOOTSIM_PW_OWNER_PID || 0);
75
+ // the open command waiting for this host to connect; it may queue for the
76
+ // browser slot and then be killed by its own caller's budget.
77
+ const launcherPid = Number(process.env.SOOTSIM_PW_LAUNCHER_PID || 0);
72
78
  const ownerPollMs = Number(process.env.SOOTSIM_PW_OWNER_POLL_MS || 5000);
73
79
  // optional CDP port — exposes Chrome's remote-debugging endpoint so a
74
80
  // bridge-driven sim can also be cpu-profiled (peach perf cpu --cdp-port).
@@ -103,13 +109,14 @@ const viewportSpec = /^(\\d+)x(\\d+)$/.exec(process.env.SOOTSIM_PW_VIEWPORT || '
103
109
  const viewport = viewportSpec
104
110
  ? { width: Number(viewportSpec[1]), height: Number(viewportSpec[2]) }
105
111
  : { width: 600, height: 900 };
106
- // device pixels per css pixel. playwright's default is 1, which makes the
107
- // engine allocate its canvas backing store at HALF the resolution a normal
108
- // retina-mac chrome would (dpr 2) — the sim looks visibly soft. default to 2
109
- // 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.
110
116
  const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2);
111
117
  const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500);
112
118
  const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || '';
119
+ const admittedFile = process.env.SOOTSIM_PW_ADMITTED_FILE || '';
113
120
  const completionFile = process.env.PEACH_PW_COMPLETION_FILE || '';
114
121
  const disposableBrowserCacheDirs = new Set(${JSON.stringify(
115
122
  DISPOSABLE_BROWSER_CACHE_DIR_NAMES,
@@ -120,7 +127,9 @@ const listProcessChildren = ${listProcessChildren.toString()};
120
127
  const { tmpdir } = require('os');
121
128
  const { join } = require('path');
122
129
  const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
130
+ const captureBrowserAccessibility = ${captureBrowserAccessibility.toString()};
123
131
  const automateWebViewFrames = ${automateWebViewFrames.toString()};
132
+ const automateWebAuthenticationSessions = ${automateWebAuthenticationSessions.toString()};
124
133
  const webViewAutomationHelpers = ${webViewAutomationHelpersSource()};
125
134
  const readBrowserCompositeCaptureRequest = ${readBrowserCompositeCaptureRequest.toString()};
126
135
  const resolveBrowserCompositeCdpClip = ${resolveBrowserCompositeCdpClip.toString()};
@@ -136,12 +145,9 @@ const readChromiumQueue = ${readChromiumQueue.toString()};
136
145
  // aborted/closed \`peach open\` leaked a renderer + gpu helper burning 100%+
137
146
  // CPU until manually killed (multiple incidents 2026-06).
138
147
  //
139
- // fix: identify our chrome tree by its UNIQUE user-data-dir. every helper a
140
- // chrome root spawns inherits \`--user-data-dir=<dir>\` in its cmdline, so
141
- // \`pgrep -f <profileDir>\` finds exactly the tree we launched and nothing else
142
- // — Chrome for Testing and bundled playwright chromium alike. as a side benefit we
143
- // no longer need the launch / launchPersistentContext fork: we ALWAYS use a
144
- // 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.
145
151
  const reapEnabled = process.platform !== 'win32';
146
152
  let profileDir = callerUserDataDir;
147
153
  let ephemeralProfile = false;
@@ -153,33 +159,35 @@ if (!profileDir) {
153
159
  // host a bounded temporary cache so persistent named profiles retain cookies
154
160
  // and storage without multiplying a private cache for every profile.
155
161
  const diskCacheDir = mkdtempSync(join(tmpdir(), 'peach-playwright-cache-'));
156
- 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 = () => {
157
166
  if (!reapEnabled) return [];
158
167
  try {
159
168
  // match the whole --user-data-dir argument: a pattern search also found
160
169
  // browsers whose profile path merely starts with this one.
161
170
  const flag = ' --user-data-dir=' + profileDir + ' ';
162
- return execSync('ps -axo pid=,command=', {
163
- stdio: ['ignore', 'pipe', 'ignore'],
164
- maxBuffer: Infinity,
165
- })
166
- .toString().split('\\n').map((line) => line.trim().match(/^(\\d+)\\s+(.*)$/))
167
- .filter((row) => row && (' ' + row[2] + ' ').includes(flag))
168
- .map((row) => Number(row[1]))
169
- .filter((p) => p && p !== process.pid);
170
- } catch { return []; }
171
- };
172
- const chromeCommandsByProfile = () => {
173
- if (!reapEnabled) return [];
174
- const pids = chromePidsByProfile();
175
- if (pids.length === 0) return [];
176
- try {
177
- return execSync('ps -p ' + pids.join(',') + ' -o command=', {
178
- stdio: ['ignore', 'pipe', 'ignore'],
179
- maxBuffer: Infinity,
180
- }).toString().split('\\n').filter(Boolean);
181
- } 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
+ }
182
188
  };
189
+ const chromePidsByProfile = () => profileProcesses().map(([pid]) => pid);
190
+ const chromeCommandsByProfile = () => profileProcesses().map(([, command]) => command);
183
191
  const assertNoUnsafeSwiftShaderFlag = () => {
184
192
  if (!hardwareGpuRequired) return;
185
193
  const unsafe = chromeCommandsByProfile().find((command) =>
@@ -200,7 +208,29 @@ const killTree = (pid) => {
200
208
  for (const k of kids) killTree(k);
201
209
  try { process.kill(pid, 'SIGKILL'); } catch {}
202
210
  };
203
- 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
+ };
204
234
  const removeDisposableCaches = (root) => {
205
235
  let children = [];
206
236
  try { children = readdirSync(root, { withFileTypes: true }); } catch { return; }
@@ -230,7 +260,7 @@ const cleanupProfile = () => {
230
260
  try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
231
261
  return;
232
262
  }
233
- removeDisposableCaches(profileDir);
263
+ if (profileLaunched) removeDisposableCaches(profileDir);
234
264
  };
235
265
  (async () => {
236
266
  const pw = require(modulePath);
@@ -394,14 +424,16 @@ const cleanupProfile = () => {
394
424
  await bounded('desktop host stop', stopDesktopHost, timeoutMs);
395
425
  if (context) await bounded('context.close', () => context.close(), timeoutMs);
396
426
  await terminateBrowserProcess();
397
- // close() + root-SIGKILL above handle the graceful case; the helper tree
398
- // (gpu/renderer/utilities) reparents and survives every one of those, so
399
- // 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.
400
429
  reapChrome();
401
430
  cleanupProfile();
402
431
  if (connectAckFile) {
403
432
  try { unlinkSync(connectAckFile); } catch {}
404
433
  }
434
+ if (admittedFile) {
435
+ try { unlinkSync(admittedFile); } catch {}
436
+ }
405
437
  process.exit(code);
406
438
  };
407
439
  process.once('SIGTERM', () => { void shutdown(0); });
@@ -434,13 +466,25 @@ const cleanupProfile = () => {
434
466
  throw new Error('[playwright-driver] missing valid owning process; refusing unsupervised browser host');
435
467
  }
436
468
  const intervalMs = Number.isFinite(ownerPollMs) && ownerPollMs > 0 ? ownerPollMs : 5000;
437
- ownerWatch = setInterval(() => {
469
+ const gone = (pid) => {
438
470
  try {
439
- process.kill(ownerPid, 0);
471
+ process.kill(pid, 0);
472
+ return false;
440
473
  } catch (err) {
441
- if (err && err.code === 'EPERM') return;
474
+ return !(err && err.code === 'EPERM');
475
+ }
476
+ };
477
+ ownerWatch = setInterval(() => {
478
+ if (gone(ownerPid)) {
442
479
  process.stderr.write('[owner-gone] owning process ' + ownerPid + ' exited; closing playwright host\\n');
443
480
  void shutdown(0);
481
+ return;
482
+ }
483
+ // until the sim connects, the open command is the only one waiting for
484
+ // it; once that command is gone nobody receives the sim.
485
+ if (!connected && !ackFileExists() && Number.isInteger(launcherPid) && launcherPid > 1 && gone(launcherPid)) {
486
+ process.stderr.write('[launcher-gone] open command ' + launcherPid + ' exited before the sim connected; closing playwright host\\n');
487
+ void shutdown(2);
444
488
  }
445
489
  }, intervalMs);
446
490
  if (typeof ownerWatch.unref === 'function') ownerWatch.unref();
@@ -472,13 +516,19 @@ const cleanupProfile = () => {
472
516
  const opts = channel
473
517
  ? { headless, channel, viewport, deviceScaleFactor }
474
518
  : { headless, viewport, deviceScaleFactor };
519
+ opts.env = { ...process.env, PEACH_PW_BROWSER_OWNER: diskCacheDir };
475
520
  opts.executablePath = executablePath;
476
521
  if (libraryPath) {
477
522
  opts.env = {
478
- ...process.env,
523
+ ...opts.env,
479
524
  LD_LIBRARY_PATH: [libraryPath, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'),
480
525
  };
481
526
  }
527
+ // keep the browser main process with its children in the caller's cgroup.
528
+ // otherwise chromium moves only main into a systemd app scope over dbus.
529
+ if (process.platform === 'linux') {
530
+ opts.env = { ...process.env, ...opts.env, ...${JSON.stringify(LINUX_CHROME_SCOPE_ENV)} };
531
+ }
482
532
  // playwright injects --force-color-profile=srgb by default, which reads
483
533
  // visibly washed-out next to a normal chrome window on a wide-gamut mac
484
534
  // display. strip it so driver-launched sims color-match the user's own
@@ -534,11 +584,9 @@ const cleanupProfile = () => {
534
584
  // room for chrome's own toolbar, so the window isn't desktop-shaped
535
585
  // around a letterboxed page.
536
586
  if (!headless) opts.args.push('--window-size=' + viewport.width + ',' + (viewport.height + 88));
537
- // ALWAYS persistent: every chrome process inherits --user-data-dir=<dir>
538
- // in its cmdline, which is how reapChrome() finds the whole tree (root +
539
- // helpers) on shutdown. without a unique dir there is no way to attribute
540
- // an orphaned helper back to us, which is the leak we just fixed.
541
- return await pw.chromium.launchPersistentContext(profileDir, opts);
587
+ const launched = await pw.chromium.launchPersistentContext(profileDir, opts);
588
+ profileLaunched = true;
589
+ return launched;
542
590
  };
543
591
  // the host-wide chromium slot is held for this host's life; its exit frees it
544
592
  await acquireChromiumAdmission({
@@ -549,6 +597,11 @@ const cleanupProfile = () => {
549
597
  label: 'peach sim host ' + profileDir,
550
598
  readQueue: readChromiumQueue,
551
599
  });
600
+ // the launcher's connect wait starts here: time queued for the slot is not
601
+ // time the sim had to connect.
602
+ if (admittedFile) {
603
+ try { writeFileSync(admittedFile, JSON.stringify({ admittedAt: Date.now(), pid: process.pid }) + '\\n'); } catch {}
604
+ }
552
605
  clearStaleSingletonLock();
553
606
  let launchErr = null;
554
607
  let launchedChannel = null;
@@ -591,6 +644,8 @@ const cleanupProfile = () => {
591
644
  await context.grantPermissions(['camera']);
592
645
  const hostMeta = {
593
646
  sootsimHostDriver: 'playwright',
647
+ peachRenderer: hostCommand ? 'native' : 'chromium',
648
+ peachHostExecutable: hostCommand ? hostCommand[0] : null,
594
649
  sootsimHostPid: process.pid,
595
650
  sootsimHostOwnerPid: ownerPid,
596
651
  sootsimHostStartedAt: Date.now(),
@@ -629,6 +684,8 @@ const cleanupProfile = () => {
629
684
  await shutdown(0);
630
685
  });
631
686
  const pointerHosts = new WeakMap();
687
+ await context.exposeBinding('__peachHostAccessibility', (source, instanceIds) =>
688
+ captureBrowserAccessibility(source.page, instanceIds));
632
689
  await context.exposeBinding('__sootsimHostPointer', async (source, value) => {
633
690
  let pending = pointerHosts.get(source.page);
634
691
  if (!pending) {
@@ -677,6 +734,7 @@ const cleanupProfile = () => {
677
734
  }, Number.isFinite(connectTimeoutMs) && connectTimeoutMs > 0 ? connectTimeoutMs : 120000);
678
735
  if (typeof connectTimer.unref === 'function') connectTimer.unref();
679
736
  const page = await context.newPage();
737
+ await automateWebAuthenticationSessions(context, '__peachWebAuthenticationHost', page, !headless);
680
738
  if (hardwareGpuRequired) {
681
739
  assertNoUnsafeSwiftShaderFlag();
682
740
  const gpu = await page.evaluate(() => {
@@ -151,18 +151,15 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
151
151
  // renders natively and lets the page present only on capture; the host
152
152
  // reaches the browser over cdp, so it needs a debugging port.
153
153
  let hostCommand: string[] | null = null
154
- if (headless && desktopHostPlatform()) {
154
+ if (headless && !opts.chromium && desktopHostPlatform()) {
155
155
  try {
156
156
  hostCommand = headlessHostCommand()
157
157
  if (!hostCommand && desktopHostPlatform() === 'linux-x64') {
158
- // a missing or older host upgrades to the channel's current release; one published
159
- // before the compatible headless host existed leaves this run on the browser engine alone
158
+ // delivery honors PEACH_DESKTOP_VERSION; a pin never follows channel latest.
160
159
  await desktopHostDelivery().install()
161
160
  hostCommand = headlessHostCommand()
162
161
  if (!hostCommand)
163
- console.error(
164
- ' native host release has no compatible headless host yet; rendering in the browser',
165
- )
162
+ throw new Error('selected native host has no compatible headless entrypoint')
166
163
  }
167
164
  } catch (error) {
168
165
  return {
@@ -175,6 +172,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
175
172
 
176
173
  const errLog = join(tmpdir(), playwrightHostLogName(Date.now(), process.pid))
177
174
  const connectAckFile = `${errLog}.connected`
175
+ const admittedFile = `${errLog}.admitted`
178
176
  const completionFile = `${errLog}.completed`
179
177
  const errFd = openSync(errLog, 'a')
180
178
  try {
@@ -210,9 +208,11 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
210
208
  ? { SOOTSIM_PW_VIEWPORT: `${opts.viewport.width}x${opts.viewport.height}` }
211
209
  : {}),
212
210
  SOOTSIM_PW_CONNECTED_ACK_FILE: connectAckFile,
211
+ SOOTSIM_PW_ADMITTED_FILE: admittedFile,
213
212
  PEACH_PW_COMPLETION_FILE: completionFile,
214
213
  SOOTSIM_PW_CONNECT_TIMEOUT_MS: String(opts.connectTimeoutMs ?? 120_000),
215
214
  SOOTSIM_PW_OWNER_PID: String(ownerPid),
215
+ SOOTSIM_PW_LAUNCHER_PID: String(process.pid),
216
216
  SOOTSIM_PW_LIBRARY_PATH: provisioning.libraryPath ?? '',
217
217
  },
218
218
  },
@@ -233,11 +233,12 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
233
233
  const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits, or after ${idleMinutes} min with no peach command or input if another browser launch is waiting for its slot`
234
234
  return {
235
235
  launched: true,
236
- message: `playwright chrome launched → ${targetUrl.href}${ownerNote}`,
236
+ message: `playwright ${hostCommand ? 'native host' : 'Chromium'} launched → ${targetUrl.href}${ownerNote}`,
237
237
  pid: child.pid,
238
238
  target: resolved.spec,
239
239
  attachUrl: targetUrl.href,
240
240
  connectAckFile,
241
+ admittedFile,
241
242
  diagnosticLogPath: errLog,
242
243
  completion,
243
244
  completionFile,
@@ -257,7 +258,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
257
258
  export const playwrightDriver: Driver = {
258
259
  id: 'playwright',
259
260
  name: 'playwright',
260
- description: 'programmatic chromium via the playwright package — headless default',
261
+ description: 'isolated Chromium; headless uses the native host when available',
261
262
  kind: 'automation',
262
263
  availability: probe,
263
264
  launch,
@@ -30,6 +30,8 @@ export interface DriverLaunchOptions {
30
30
  url?: string
31
31
  // run in headless mode where the driver supports it. ignored by electron.
32
32
  headless?: boolean
33
+ // opt this open into browser rendering without acquiring or attaching a native host.
34
+ chromium?: boolean
33
35
  // initial device model seed forwarded to the peach shell. the electron
34
36
  // driver forwards it via argv (--device=<model>); browser drivers receive
35
37
  // it already stamped on the URL (?device=<model>) by runOpenCommand.
@@ -86,6 +88,9 @@ export interface DriverLaunchResult {
86
88
  target?: string
87
89
  attachUrl?: string
88
90
  connectAckFile?: string
91
+ // written once the host holds its machine-wide browser slot; its connect
92
+ // budget starts there, so the launcher's wait starts there too.
93
+ admittedFile?: string
89
94
  diagnosticLogPath?: string
90
95
  // foreground guest hosts await the detached driver's exit without polling.
91
96
  completion?: Promise<DriverCompletion>
@@ -1,7 +1,7 @@
1
1
  import type { PeachAccessibilityNode } from '@peach/globals'
2
2
 
3
3
  // maestro reads ios frames as integer edges (AXElement.boundsString).
4
- export function accessibilityBounds(node: PeachAccessibilityNode): {
4
+ export function accessibilityBounds(node: Pick<PeachAccessibilityNode, 'frame'>): {
5
5
  x: number
6
6
  y: number
7
7
  width: number
@@ -121,8 +121,8 @@ function absolutizeRefs(steps: unknown, flowDir: string): unknown {
121
121
  })
122
122
  }
123
123
 
124
- // insert one capture after every screen-changing top-level step. subflows run
125
- // as one step and are captured once, after they return.
124
+ // insert maestro's pixel-stillness wait before each checkpoint on both sides.
125
+ // subflows run as one step and are captured once, after they return.
126
126
  export function instrumentFlowSource(
127
127
  source: string,
128
128
  flowDir: string,
@@ -159,6 +159,7 @@ export function instrumentFlowSource(
159
159
  const label = stepLabel(step)
160
160
  const name = `${COMPARE_SHOT_DIR}/${String(captures.length + 1).padStart(3, '0')}-${slugify(label)}`
161
161
  captures.push({ name, step: index + 1, label })
162
+ out.push('waitForAnimationToEnd')
162
163
  out.push({ takeScreenshot: name })
163
164
  }
164
165
  // maestro reads flows as yaml 1.1, where a bare yes, no, on or off is a
@@ -280,14 +281,52 @@ export function nativeStepsFromCommandsJson(json: unknown): CompareStep[] {
280
281
  return steps
281
282
  }
282
283
 
283
- export function nativeFlowFailureFromLog(log: string, name: string): string | undefined {
284
- const verdict = `[Failed] ${name}`
285
- return log.split(/[\r\n]+/).some((line) => {
286
- const text = line.trim()
287
- return text === verdict || text.startsWith(`${verdict} (`)
284
+ const XML_ENTITIES: Record<string, string> = {
285
+ amp: '&',
286
+ lt: '<',
287
+ gt: '>',
288
+ quot: '"',
289
+ apos: "'",
290
+ }
291
+
292
+ function decodeXml(text: string): string {
293
+ return text.replace(/&(?:#(\d+)|#x([\da-f]+)|(\w+));/gi, (match, dec, hex, name) => {
294
+ if (dec || hex) return String.fromCodePoint(parseInt(dec ?? hex, dec ? 10 : 16))
295
+ return XML_ENTITIES[name] ?? match
288
296
  })
289
- ? 'Maestro reported this flow failed; see ios.log'
290
- : undefined
297
+ }
298
+
299
+ export function junitFlowKey(name: string, fileStem: string): string {
300
+ return JSON.stringify([name, fileStem])
301
+ }
302
+
303
+ // maestro's own per-flow report (--format junit): one testcase per flow, whose
304
+ // status and failure text are the verdict whatever the flow did or printed.
305
+ // values hold the failure message per testcase, undefined for a pass, in
306
+ // report order so flows that share a name and file stem pair in run order.
307
+ export function nativeFlowVerdictsFromJunit(
308
+ xml: string,
309
+ ): Map<string, Array<string | undefined>> {
310
+ const verdicts = new Map<string, Array<string | undefined>>()
311
+ for (const match of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
312
+ const attrs = new Map<string, string>()
313
+ for (const attr of (match[1] ?? '').matchAll(/([\w:-]+)="([^"]*)"/g)) {
314
+ attrs.set(attr[1]!, decodeXml(attr[2]!))
315
+ }
316
+ const file = attrs.get('file')
317
+ const name = attrs.get('name')
318
+ if (file === undefined || name === undefined) continue
319
+ const body = match[3] ?? ''
320
+ const failure =
321
+ /<(?:failure|error)\b[^>]*?(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/.exec(body)
322
+ const failed = failure !== null || attrs.get('status') === 'ERROR'
323
+ const message = failure?.[1] ? decodeXml(failure[1]).trim() : ''
324
+ const key = junitFlowKey(name, path.basename(file).replace(/\.(ya?ml)$/i, ''))
325
+ const list = verdicts.get(key) ?? []
326
+ list.push(failed ? `Maestro failed this flow: ${message || 'no message'}` : undefined)
327
+ verdicts.set(key, list)
328
+ }
329
+ return verdicts
291
330
  }
292
331
 
293
332
  // peach's commands.json (maestro-report.ts buildFlowCommandsJson)
@@ -325,6 +364,30 @@ export interface ScreenDiff {
325
364
  maskedTopPx: number
326
365
  }
327
366
 
367
+ // captures of one framebuffer can differ by a column or row: maestro's xctest
368
+ // shot drops the rightmost column of an odd framebuffer. such a pair is scored
369
+ // on the top-left area both have, with no resampling, which would shift every
370
+ // edge. null when the sizes match or differ by more than one pixel.
371
+ export function commonAreaSize(
372
+ a: { width: number; height: number },
373
+ b: { width: number; height: number },
374
+ ): { width: number; height: number } | null {
375
+ const dw = Math.abs(a.width - b.width)
376
+ const dh = Math.abs(a.height - b.height)
377
+ if ((dw === 0 && dh === 0) || dw > 1 || dh > 1) return null
378
+ return { width: Math.min(a.width, b.width), height: Math.min(a.height, b.height) }
379
+ }
380
+
381
+ function cropTopLeft(src: PNG, width: number, height: number): PNG {
382
+ if (src.width === width && src.height === height) return src
383
+ const out = new PNG({ width, height })
384
+ for (let y = 0; y < height; y++) {
385
+ const from = y * src.width * 4
386
+ src.data.copy(out.data, y * width * 4, from, from + width * 4)
387
+ }
388
+ return out
389
+ }
390
+
328
391
  function resample(src: PNG, width: number, height: number): PNG {
329
392
  const out = new PNG({ width, height })
330
393
  const sx = src.width / width
@@ -402,8 +465,13 @@ export function compareScreens(
402
465
  peachPng: Buffer,
403
466
  opts: { maskTopPx?: number } = {},
404
467
  ): { diff: ScreenDiff; triptych: Buffer } {
405
- const native = PNG.sync.read(nativePng)
468
+ let native: PNG = PNG.sync.read(nativePng)
406
469
  let peach: PNG = PNG.sync.read(peachPng)
470
+ const common = commonAreaSize(native, peach)
471
+ if (common) {
472
+ native = cropTopLeft(native, common.width, common.height)
473
+ peach = cropTopLeft(peach, common.width, common.height)
474
+ }
407
475
  const { width, height } = native
408
476
  const aspectMismatch = Math.abs(peach.width / peach.height - width / height) > 0.01
409
477
  const resized = peach.width !== width || peach.height !== height
@@ -602,10 +670,14 @@ function screenCard(screen: CompareScreen, threshold: number, flowIndex: number)
602
670
  ].filter(Boolean)
603
671
  let body = ''
604
672
  if (screen.image) {
673
+ const imageUrl = screen.image.split('/').map(encodeURIComponent).join('/')
605
674
  body = `<div class="cols"><span>iOS simulator</span><span>Peach</span><span>pixel diff</span></div>
606
- <a href="${esc(screen.image)}" target="_blank"><img loading="lazy" src="${esc(screen.image)}" alt="${esc(screen.label)}"></a>`
675
+ <a href="${esc(imageUrl)}" target="_blank"><img loading="lazy" src="${esc(imageUrl)}" alt="${esc(screen.label)}"></a>`
607
676
  } else {
608
- const only = screen.nativeOnly ?? screen.peachOnly
677
+ const only = (screen.nativeOnly ?? screen.peachOnly)
678
+ ?.split('/')
679
+ .map(encodeURIComponent)
680
+ .join('/')
609
681
  body = only
610
682
  ? `<div class="cols one"><span>${screen.nativeOnly ? 'iOS simulator' : 'Peach'} only</span></div><a href="${esc(only)}" target="_blank"><img class="single" loading="lazy" src="${esc(only)}" alt="${esc(screen.label)}"></a>`
611
683
  : '<p class="quiet">Neither side reached this step.</p>'
package/cli/main.ts CHANGED
@@ -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
  ) ||
@@ -63,7 +63,8 @@ async function handleTopLevelError(err: unknown): Promise<never> {
63
63
  msg.startsWith(
64
64
  `package.json already defines scripts.${rnxPublicBrand.commandName} as `,
65
65
  ) ||
66
- msg.startsWith('could not resolve a native bundle for ')
66
+ msg.startsWith('could not resolve a native bundle for ') ||
67
+ msg.startsWith('could not load ')
67
68
  if (isKnownCliFailure && !process.env.SOOTSIM_VERBOSE) {
68
69
  process.stderr.write(` ${msg}\n`)
69
70
  await exitWithFlushedOutput(1)
@@ -491,7 +491,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
491
491
  },
492
492
  'packages/peach-engine/src/engine/lottie-runtime.ts :: fetch :: descriptor.value': {
493
493
  category: 'guest_app_network',
494
- count: 2,
494
+ count: 1,
495
495
  },
496
496
  'packages/peach-engine/src/bundle-fetch.ts :: fetch-alias :: buildBrowserAssetProxyUrl(devServerUrl)':
497
497
  { category: 'guest_app_network', count: 1 },
@@ -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,
@@ -673,6 +673,14 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
673
673
  category: 'local_development',
674
674
  count: 1,
675
675
  },
676
+ 'packages/peachsim/cli/commands/control.ts :: net.connect :: port': {
677
+ category: 'local_development',
678
+ count: 1,
679
+ },
680
+ 'packages/peachsim/cli/commands/control.ts :: fetch :: origin': {
681
+ category: 'local_development',
682
+ count: 1,
683
+ },
676
684
  'packages/peachsim/cli/commands/control.ts :: websocket :: `ws://127.0.0.1:${port}`': {
677
685
  category: 'local_development',
678
686
  count: 1,
@@ -805,6 +813,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
805
813
  category: 'local_development',
806
814
  count: 1,
807
815
  },
816
+ 'packages/peachsim/src/expo-swift-ready.ts :: fetch :: endpointUrl': {
817
+ category: 'local_development',
818
+ count: 1,
819
+ },
808
820
  'packages/peach-engine/src/expo-swift-compilation.ts :: fetch-alias :: address': {
809
821
  category: 'local_development',
810
822
  count: 1,
@@ -855,8 +867,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
855
867
  category: 'same_origin_runtime',
856
868
  count: 1,
857
869
  },
858
- 'packages/peach-engine/src/engine/image-loader.ts :: fetch-alias :: resolveImageFetchUri(uri)':
859
- { 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
+ },
860
874
  'packages/peach-engine/src/ExternalApp.tsx :: fetch-alias :: requestUrl': {
861
875
  category: 'guest_app_network',
862
876
  count: 1,