rnxsim 0.1.313 → 0.1.315

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 (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-CBU2WDKV.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-UNB4R27L.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-YMLRF3JD.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-5N6HUCL5.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-BHDR3A6G.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-LWUVCPYK.js} +2 -2
  131. package/dist-cli/chunks/camera-CNO6B2JZ.js +33 -0
  132. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-25VCR44B.js} +1 -1
  133. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-26CSIR76.js} +8 -9
  134. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-26WMWUH4.js} +1 -1
  135. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-2JZ53D4A.js} +1 -1
  136. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-2POODCTW.js} +2 -2
  137. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-4LTBK3ZN.js} +2 -2
  138. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-54MIRTFE.js} +4 -4
  139. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-56H6EVO4.js} +2 -2
  140. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-657SQG4D.js} +1 -1
  141. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-6ZMRJPKI.js} +1 -1
  142. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-AOGGOC4V.js} +2 -2
  143. package/dist-cli/chunks/chunk-BPCDO7ST.js +15 -0
  144. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-C5O5CTXJ.js} +1 -1
  145. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-CMM64PPR.js} +1 -1
  146. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-D3M36D7O.js} +62 -87
  147. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-DNCNFZ7V.js} +1 -1
  148. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-FNTTIR2P.js} +2 -2
  149. package/dist-cli/chunks/chunk-H362IX2I.js +4 -0
  150. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-J6HMTUZH.js} +2 -2
  151. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-JZ6VTZ5B.js} +3 -3
  152. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-KQSJVNFE.js} +4 -4
  153. package/dist-cli/chunks/chunk-L4JZKVCC.js +6 -0
  154. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-LP2BTEJ6.js} +2 -2
  155. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-LT6UC7I3.js} +2 -2
  156. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-MCKQH4AM.js} +2 -2
  157. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-MF7WUHQ3.js} +2 -2
  158. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-MSSY4EEZ.js} +1 -1
  159. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-N5ZCYP5F.js} +2 -2
  160. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-NCKAD3V7.js} +2 -2
  161. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-O2KRHVTU.js} +2 -2
  162. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-OF2HTH25.js} +2 -2
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-OXS2IWO2.js} +2 -2
  164. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-PVL7XSPN.js} +1 -1
  165. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-PVPAP3TV.js} +1 -1
  166. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-PXNAZYGV.js} +1 -1
  167. package/dist-cli/chunks/chunk-QR3HTFRB.js +5 -0
  168. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-QR7MWAWE.js} +1 -1
  169. package/dist-cli/chunks/chunk-RJIEZ2NY.js +4 -0
  170. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RVM4EZ4E.js} +2 -2
  171. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-SZKSFMWS.js} +1 -1
  172. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-TEL7G234.js} +2 -2
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-U2HNIHBY.js} +1 -1
  174. package/dist-cli/chunks/chunk-UROXJVY3.js +27 -0
  175. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-WM32MGTJ.js} +2 -2
  176. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-WMZ7NJAO.js} +89 -133
  177. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-WUXCH6WN.js} +10 -9
  178. package/dist-cli/chunks/chunk-XOXGC3EH.js +9 -0
  179. package/dist-cli/chunks/chunk-XSSHENNY.js +4 -0
  180. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-XZ6O67AR.js} +1 -1
  181. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-Y2MWQKOS.js} +3 -3
  182. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-YY24QI25.js} +1 -1
  183. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-ZWX6DDIS.js} +2 -2
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-F3NUJ3SL.js} +2 -2
  185. package/dist-cli/chunks/cli-version-XIW6O3FG.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-AX2SEPTH.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4QQO5JVU.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-OCKKSOWA.js} +2 -2
  189. package/dist-cli/chunks/daemon-N5VLMYKQ.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-T6MWVEVX.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-NCW7R7CB.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-G6GITO2X.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-7BTEJCTH.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-SC5LACE3.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KK56QQZA.js} +2 -2
  196. package/dist-cli/chunks/drivers-NPGVBWMG.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-WGYEHGIA.js} +3 -3
  198. package/dist-cli/chunks/flow-KQBIBM3N.js +4 -0
  199. package/dist-cli/chunks/help-G7CAKI65.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-CWTZWH3L.js} +2 -2
  201. package/dist-cli/chunks/home-paths-EBT4XHAS.js +4 -0
  202. package/dist-cli/chunks/inspect-CCPZXVDU.js +4 -0
  203. package/dist-cli/chunks/install-desktop-NZ3VGTQZ.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-GSYOTGVQ.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-JPRIR7YK.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-LXKDNO5M.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-5ZIKOU53.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-N3QHXCES.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-XDH6X24N.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-VOPL26FM.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-CZFKM4UD.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-FODSOGMA.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-FBPCZKOV.js} +2 -2
  214. package/dist-cli/chunks/reset-3H35GFO3.js +4 -0
  215. package/dist-cli/chunks/runtime-4OVFRXV5.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7S3FYVO7.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-CDYHPNC3.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-EBIIY2YU.js} +2 -2
  219. package/dist-cli/chunks/serve-NELNSCZP.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-DF5ZDDIV.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-E2QZF6WZ.js} +2 -2
  222. package/dist-cli/chunks/state-MBZDKZ7U.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-J5BMNVXM.js} +6 -6
  224. package/dist-cli/chunks/store-U3DXC7HH.js +4 -0
  225. package/dist-cli/chunks/telemetry-XIPJVT5E.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-MC6N5BHY.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-7UTU53DH.js} +2 -2
  228. package/dist-cli/chunks/upload-2M2KOTYI.js +4 -0
  229. package/dist-cli/chunks/version-VNACNYQK.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-MGJ2TUKR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-7DLKH3OD.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
@@ -0,0 +1,698 @@
1
+ // playwright driver — launches Playwright's Google Chrome for Testing via the
2
+ // `playwright` package. availability depends on whether the package is
3
+ // resolvable from the current working directory OR from the CLI's own
4
+ // install tree (repo-local install is typical; the latter covers a global
5
+ // CLI invoked from a directory with no node_modules).
6
+ //
7
+ // the browser runs in a *detached* node subprocess (see PLAYWRIGHT_SIM_HOST)
8
+ // rather than in this CLI process. that is load-bearing: `sootsim open`
9
+ // returns the moment the sim connects, and playwright tears its browser
10
+ // down when the launching process exits. an in-process launch would mean
11
+ // the sim vanished the instant `open` finished. the detached host keeps the
12
+ // test browser alive until the sim's page closes (e.g. via `sootsim close`).
13
+
14
+ import { spawn } from 'child_process'
15
+ import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs'
16
+ import { tmpdir } from 'os'
17
+ import { join } from 'path'
18
+ import { DISPOSABLE_BROWSER_CACHE_DIR_NAMES } from '../../src/browser-cache'
19
+ import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles'
20
+ import { getProcessComm } from '../parent-pid'
21
+ import { ensurePlaywrightBrowser, resolvePlaywright } from './playwright-provisioning'
22
+ import type {
23
+ Driver,
24
+ DriverAvailability,
25
+ DriverLaunchOptions,
26
+ DriverLaunchResult,
27
+ } from './types'
28
+
29
+ // CJS script run via `node -e`. resolves playwright from the absolute path
30
+ // the driver hands it, launches Chrome for Testing, opens the target url, then keeps
31
+ // itself (and therefore the browser) alive until the browser disconnects.
32
+ // kept as an inline string so no extra build artifact has to ship with the
33
+ // bundled CLI. all inputs arrive via env vars (argv layout under `-e` is
34
+ // fiddly; env is unambiguous).
35
+ export const PLAYWRIGHT_SIM_HOST = `
36
+ const modulePath = process.env.SOOTSIM_PW_MODULE;
37
+ const url = process.env.SOOTSIM_PW_URL;
38
+ // the WS bridge port the sim page must register with, injected below as
39
+ // window.__sootsimBridgePort so the page skips its shell-port heuristic (which
40
+ // assumes the default offset and breaks when the dev bridge drifted).
41
+ const bridgePort = process.env.SOOTSIM_PW_BRIDGE_PORT || '';
42
+ const headless = process.env.SOOTSIM_PW_HEADLESS === '1';
43
+ const callerUserDataDir = process.env.SOOTSIM_PW_USERDATADIR || '';
44
+ // pid of the session that launched us (its topmost non-init ancestor). when
45
+ // that process exits we tear Chrome down — see startOwnerWatch below. it is a
46
+ // required lifetime bound: an invalid value aborts before Chrome launches.
47
+ // SOOTSIM_PW_OWNER_POLL_MS is a test-only override of the 5s poll interval;
48
+ // production never sets it.
49
+ const ownerPid = Number(process.env.SOOTSIM_PW_OWNER_PID || 0);
50
+ const ownerPollMs = Number(process.env.SOOTSIM_PW_OWNER_POLL_MS || 5000);
51
+ // optional CDP port — exposes Chrome's remote-debugging endpoint so a
52
+ // bridge-driven sim can also be cpu-profiled (sootsim perf cpu --cdp-port).
53
+ // playwright keeps its own pipe connection; the port is an additional endpoint.
54
+ const cdpPort = process.env.SOOTSIM_PW_CDP_PORT || '';
55
+ // extra chromium launch args, space-separated. a linux GPU runner injects
56
+ // '--use-angle=vulkan --enable-features=Vulkan' here so chrome binds the
57
+ // real GPU instead of silently falling back to swiftshader software
58
+ // rendering (which pegs cores and produces ~1fps canvas capture).
59
+ const extraChromeArgs = (process.env.SOOTSIM_PW_CHROME_ARGS || '')
60
+ .split(' ')
61
+ .filter(Boolean);
62
+ // Local CanvasKit work must never fall back to Playwright's software
63
+ // renderer. CI may use the bundled browser for non-interactive coverage, but
64
+ // local test drives fail closed unless the hardware-backed test browser is
65
+ // proven after launch.
66
+ const hardwareGpuRequired = process.env.CI !== '1' && process.env.CI !== 'true';
67
+ const softwareRendererPattern = /swiftshader|software(?: rasterizer)?|llvmpipe|lavapipe|softpipe/i;
68
+ // page viewport as '<w>x<h>'. the caller sizes it from the device profile;
69
+ // the fallback is phone-shaped because the page hosts a phone sim — the
70
+ // playwright default (1280x720 desktop) letterboxes the device frame.
71
+ const viewportSpec = /^(\\d+)x(\\d+)$/.exec(process.env.SOOTSIM_PW_VIEWPORT || '');
72
+ const viewport = viewportSpec
73
+ ? { width: Number(viewportSpec[1]), height: Number(viewportSpec[2]) }
74
+ : { width: 600, height: 900 };
75
+ // device pixels per css pixel. playwright's default is 1, which makes the
76
+ // engine allocate its canvas backing store at HALF the resolution a normal
77
+ // retina-mac chrome would (dpr 2) — the sim looks visibly soft. default to 2
78
+ // so a driver-launched sim matches what the user sees in their own browser.
79
+ const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2);
80
+ const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500);
81
+ const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || '';
82
+ const disposableBrowserCacheDirs = new Set(${JSON.stringify(
83
+ DISPOSABLE_BROWSER_CACHE_DIR_NAMES,
84
+ )});
85
+ const { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync } = require('fs');
86
+ const { execSync } = require('child_process');
87
+ const { tmpdir } = require('os');
88
+ const { join } = require('path');
89
+ // chrome tree reaper — playwright's browser.close() and even SIGKILLing the
90
+ // root chrome process do NOT reliably take down the helper tree (gpu, renderer,
91
+ // network/storage utilities). they reparent to launchd/init and survive, each
92
+ // one busy enough to peg CPU at 100%+. close() only reaches the root.
93
+ //
94
+ // the prior approach was \`pgrep -f ms-playwright/[c]hromium\`. it never matched
95
+ // system Chrome, so the reaper was a no-op in the common case and every
96
+ // aborted/closed \`sootsim open\` leaked a renderer + gpu helper burning 100%+
97
+ // CPU until manually killed (multiple incidents 2026-06).
98
+ //
99
+ // fix: identify our chrome tree by its UNIQUE user-data-dir. every helper a
100
+ // chrome root spawns inherits \`--user-data-dir=<dir>\` in its cmdline, so
101
+ // \`pgrep -f <profileDir>\` finds exactly the tree we launched and nothing else
102
+ // — Chrome for Testing and bundled playwright chromium alike. as a side benefit we
103
+ // no longer need the launch / launchPersistentContext fork: we ALWAYS use a
104
+ // profile dir (mkdtemp'd if the caller didn't pass one, then removed at exit).
105
+ const reapEnabled = process.platform !== 'win32';
106
+ let profileDir = callerUserDataDir;
107
+ let ephemeralProfile = false;
108
+ if (!profileDir) {
109
+ profileDir = mkdtempSync(join(tmpdir(), 'sootsim-playwright-host-'));
110
+ ephemeralProfile = true;
111
+ }
112
+ // browser HTTP caches are acceleration data, not profile identity. give each
113
+ // host a bounded temporary cache so persistent named profiles retain cookies
114
+ // and storage without multiplying a private cache for every profile.
115
+ const diskCacheDir = mkdtempSync(join(tmpdir(), 'sootsim-playwright-cache-'));
116
+ const chromePidsByProfile = () => {
117
+ if (!reapEnabled) return [];
118
+ try {
119
+ return execSync('pgrep -f ' + profileDir + ' || true', { stdio: ['ignore', 'pipe', 'ignore'] })
120
+ .toString().split('\\n').filter(Boolean).map((p) => Number(p))
121
+ .filter((p) => p && p !== process.pid);
122
+ } catch { return []; }
123
+ };
124
+ const chromeCommandsByProfile = () => {
125
+ if (!reapEnabled) return [];
126
+ const pids = chromePidsByProfile();
127
+ if (pids.length === 0) return [];
128
+ try {
129
+ return execSync('ps -p ' + pids.join(',') + ' -o command=', {
130
+ stdio: ['ignore', 'pipe', 'ignore'],
131
+ }).toString().split('\\n').filter(Boolean);
132
+ } catch { return []; }
133
+ };
134
+ const assertNoUnsafeSwiftShaderFlag = () => {
135
+ if (!hardwareGpuRequired) return;
136
+ const unsafe = chromeCommandsByProfile().find((command) =>
137
+ command.includes('--enable-unsafe-swiftshader') ||
138
+ /--use-angle=(?:swiftshader|swiftshader-webgl)(?:\\s|$)/i.test(command)
139
+ );
140
+ if (unsafe) {
141
+ throw new Error(
142
+ '[playwright-driver] refused software-rendering Chrome: launched process contains a SwiftShader flag'
143
+ );
144
+ }
145
+ };
146
+ const killTree = (pid) => {
147
+ let kids = [];
148
+ try {
149
+ kids = execSync('pgrep -P ' + pid + ' || true', { stdio: ['ignore', 'pipe', 'ignore'] })
150
+ .toString().split('\\n').filter(Boolean);
151
+ } catch {}
152
+ for (const k of kids) killTree(Number(k));
153
+ try { process.kill(pid, 'SIGKILL'); } catch {}
154
+ };
155
+ const reapChrome = () => { for (const pid of chromePidsByProfile()) killTree(pid); };
156
+ const removeDisposableCaches = (root) => {
157
+ let children = [];
158
+ try { children = readdirSync(root, { withFileTypes: true }); } catch { return; }
159
+ for (const child of children) {
160
+ if (!child.isDirectory() || child.isSymbolicLink()) continue;
161
+ const childPath = join(root, child.name);
162
+ if (disposableBrowserCacheDirs.has(child.name)) {
163
+ try { rmSync(childPath, { recursive: true, force: true }); } catch {}
164
+ continue;
165
+ }
166
+ removeDisposableCaches(childPath);
167
+ }
168
+ };
169
+ const cleanupProfile = () => {
170
+ try { rmSync(diskCacheDir, { recursive: true, force: true }); } catch {}
171
+ if (ephemeralProfile) {
172
+ try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
173
+ return;
174
+ }
175
+ removeDisposableCaches(profileDir);
176
+ };
177
+ (async () => {
178
+ const pw = require(modulePath);
179
+ let context;
180
+ let keepAlive = null;
181
+ let connectTimer = null;
182
+ let connectAckPoll = null;
183
+ let ownerWatch = null;
184
+ let connected = false;
185
+ let shuttingDown = false;
186
+ const clearConnectTimer = () => {
187
+ if (connectTimer) {
188
+ clearTimeout(connectTimer);
189
+ connectTimer = null;
190
+ }
191
+ if (connectAckPoll) {
192
+ clearInterval(connectAckPoll);
193
+ connectAckPoll = null;
194
+ }
195
+ };
196
+ const markConnected = () => {
197
+ connected = true;
198
+ clearConnectTimer();
199
+ };
200
+ const ackFileExists = () => {
201
+ if (!connectAckFile) return false;
202
+ try { return existsSync(connectAckFile); } catch { return false; }
203
+ };
204
+ const bounded = async (label, work, timeoutMs) => {
205
+ let timer = null;
206
+ try {
207
+ const timeout = new Promise((resolve) => {
208
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
209
+ });
210
+ if (typeof timer.unref === 'function') timer.unref();
211
+ const result = await Promise.race([
212
+ Promise.resolve().then(work).then(
213
+ () => ({ timedOut: false }),
214
+ (error) => ({ timedOut: false, error }),
215
+ ),
216
+ timeout,
217
+ ]);
218
+ if (result && result.timedOut) {
219
+ process.stderr.write('[shutdown-timeout] ' + label + ' exceeded ' + timeoutMs + 'ms\\n');
220
+ return false;
221
+ }
222
+ if (result && result.error) {
223
+ process.stderr.write('[shutdown-error] ' + label + ': ' + String((result.error && result.error.message) || result.error) + '\\n');
224
+ return false;
225
+ }
226
+ return true;
227
+ } finally {
228
+ if (timer) clearTimeout(timer);
229
+ }
230
+ };
231
+ const browserProcess = () => {
232
+ if (context && typeof context.browser === 'function') {
233
+ try {
234
+ const b = context.browser();
235
+ if (b && typeof b.process === 'function') return b.process();
236
+ } catch {}
237
+ }
238
+ return null;
239
+ };
240
+ const processIsAlive = (proc) => {
241
+ if (!proc || !proc.pid) return false;
242
+ try {
243
+ process.kill(proc.pid, 0);
244
+ return true;
245
+ } catch (err) {
246
+ return err && err.code === 'EPERM';
247
+ }
248
+ };
249
+ const terminateBrowserProcess = async () => {
250
+ const proc = browserProcess();
251
+ if (!proc || !proc.pid || !processIsAlive(proc)) return;
252
+ try { proc.kill('SIGTERM'); } catch {}
253
+ await bounded('browser process exit after SIGTERM', async () => {
254
+ while (processIsAlive(proc)) {
255
+ await new Promise((resolve) => setTimeout(resolve, 50));
256
+ }
257
+ }, 600);
258
+ if (!processIsAlive(proc)) return;
259
+ try { proc.kill('SIGKILL'); } catch {}
260
+ };
261
+ const shutdown = async (code = 0) => {
262
+ if (shuttingDown) return;
263
+ shuttingDown = true;
264
+ clearConnectTimer();
265
+ if (keepAlive) clearInterval(keepAlive);
266
+ if (ownerWatch) clearInterval(ownerWatch);
267
+ const timeoutMs = Number.isFinite(closeTimeoutMs) && closeTimeoutMs > 0 ? closeTimeoutMs : 2500;
268
+ if (context) await bounded('context.close', () => context.close(), timeoutMs);
269
+ await terminateBrowserProcess();
270
+ // close() + root-SIGKILL above handle the graceful case; the helper tree
271
+ // (gpu/renderer/utilities) reparents and survives every one of those, so
272
+ // we always sweep by our unique profile dir as the load-bearing teardown.
273
+ reapChrome();
274
+ cleanupProfile();
275
+ if (connectAckFile) {
276
+ try { unlinkSync(connectAckFile); } catch {}
277
+ }
278
+ process.exit(code);
279
+ };
280
+ process.once('SIGTERM', () => { void shutdown(0); });
281
+ process.once('SIGINT', () => { void shutdown(0); });
282
+ // the launch-failure path at the bottom of this script exits without going
283
+ // through shutdown(); the exit hook (sync sweep) still reaps the tree.
284
+ process.on('exit', () => { reapChrome(); cleanupProfile(); });
285
+ // owner-liveness watchdog — the load-bearing fix for leaked Chrome hosts.
286
+ // we are spawned detached + unref'd so \`sootsim open\` returns immediately,
287
+ // which means nothing in the OS process tree ties Chrome's lifetime to the
288
+ // session that asked for it. once the sim connects, the connect-timeout
289
+ // safety net is cleared, so absent an explicit \`sootsim close\` (or the
290
+ // daemon's idle reaper closing the sim socket) the host would hold Chrome
291
+ // open forever after its owner exits. poll the owning session's pid and tear
292
+ // down when it's gone. ESRCH (kill throws) = owner exited; EPERM = alive but
293
+ // not signalable by us, which still counts as alive.
294
+ const startOwnerWatch = () => {
295
+ if (!Number.isInteger(ownerPid) || ownerPid <= 1) {
296
+ throw new Error('[playwright-driver] missing valid owning process; refusing unsupervised browser host');
297
+ }
298
+ const intervalMs = Number.isFinite(ownerPollMs) && ownerPollMs > 0 ? ownerPollMs : 5000;
299
+ ownerWatch = setInterval(() => {
300
+ try {
301
+ process.kill(ownerPid, 0);
302
+ } catch (err) {
303
+ if (err && err.code === 'EPERM') return;
304
+ process.stderr.write('[owner-gone] owning process ' + ownerPid + ' exited; closing playwright host\\n');
305
+ void shutdown(0);
306
+ }
307
+ }, intervalMs);
308
+ if (typeof ownerWatch.unref === 'function') ownerWatch.unref();
309
+ };
310
+ startOwnerWatch();
311
+ // Local rendering uses Playwright's full Chrome for Testing app. On macOS its
312
+ // com.google.chrome.for.testing identity cannot intercept open/quit events for
313
+ // the user's com.google.Chrome browser. CI may use chromium_headless_shell;
314
+ // local rendering fails closed because that shell has no GPU and silently
315
+ // chooses SwiftShader. NOTE: this is NOT enough to make \`sootsim perf cpu\`
316
+ // work — that profiles the tenant *worker*, and the JS Self-Profiler API
317
+ // cannot be constructed in a dedicated worker (see
318
+ // worker-handlers/cpu-profile.ts). cpu-profile needs a CDP-based profiler
319
+ // instead.
320
+ if (
321
+ hardwareGpuRequired &&
322
+ extraChromeArgs.some((arg) =>
323
+ arg === '--enable-unsafe-swiftshader' ||
324
+ /^--use-angle=(?:swiftshader|swiftshader-webgl)$/i.test(arg)
325
+ )
326
+ ) {
327
+ throw new Error('[playwright-driver] refused SwiftShader launch arguments');
328
+ }
329
+ // one browser path per environment: the full, separately identified test app
330
+ // locally; chromium_headless_shell on CI. never launch a branded user browser.
331
+ const channelsToTry = hardwareGpuRequired ? ['chromium'] : [null];
332
+ const launchWith = async (channel) => {
333
+ const opts = channel
334
+ ? { headless, channel, viewport, deviceScaleFactor }
335
+ : { headless, viewport, deviceScaleFactor };
336
+ // playwright injects --force-color-profile=srgb by default, which reads
337
+ // visibly washed-out next to a normal chrome window on a wide-gamut mac
338
+ // display. strip it so driver-launched sims color-match the user's own
339
+ // browser (real incident 2026-07-09: "low res and whitewashed" sim).
340
+ opts.ignoreDefaultArgs = ['--force-color-profile=srgb'];
341
+ // headless chromium backgrounds/occludes the renderer and throttles rAF +
342
+ // timers to ~1fps, which silently starves combined/video frame capture
343
+ // (was 47 frames over 63s -> frozen-looking mp4). keep the render loop at
344
+ // full rate so the recorder captures real motion. document.hidden stays
345
+ // false in headless, so assertTabVisibleForCapture never caught this — it
346
+ // has to be fixed at the launch flags.
347
+ opts.args = [
348
+ // the compositor renders through skia graphite on webgpu, so a launch
349
+ // without navigator.gpu boots to a fatal instead of a sim.
350
+ '--enable-unsafe-webgpu',
351
+ '--disk-cache-dir=' + diskCacheDir,
352
+ '--disk-cache-size=67108864',
353
+ '--disable-background-timer-throttling',
354
+ '--disable-backgrounding-occluded-windows',
355
+ '--disable-renderer-backgrounding',
356
+ ];
357
+ if (channel) {
358
+ // the full test browser can bind the real platform GPU — force the native
359
+ // ANGLE backend and strip playwright's --enable-unsafe-swiftshader
360
+ // default arg. without both, headless chrome silently falls back to
361
+ // swiftshader software rendering for the CanvasKit/WebGL canvas and
362
+ // pegs every core. SOOTSIM_PW_CHROME_ARGS is appended later, so an
363
+ // env-injected --use-angle=... still overrides (last flag wins).
364
+ const angle = process.platform === 'darwin' ? 'metal'
365
+ : process.platform === 'linux' ? 'vulkan' : 'default';
366
+ opts.args.unshift(
367
+ '--use-angle=' + angle,
368
+ '--enable-gpu',
369
+ '--enable-gpu-rasterization',
370
+ '--enable-zero-copy',
371
+ '--ignore-gpu-blocklist',
372
+ );
373
+ opts.ignoreDefaultArgs.push('--enable-unsafe-swiftshader');
374
+ } else {
375
+ // playwright's bundled software chromium exposes no navigator.gpu until
376
+ // Vulkan is named explicitly, and webgpu is the only backend there is.
377
+ opts.args.push('--enable-features=Vulkan');
378
+ }
379
+ if (cdpPort) opts.args.push('--remote-debugging-port=' + cdpPort);
380
+ if (extraChromeArgs.length) opts.args.push(...extraChromeArgs);
381
+ // headed: size the OS window to the (phone-shaped) viewport too, plus
382
+ // room for chrome's own toolbar, so the window isn't desktop-shaped
383
+ // around a letterboxed page.
384
+ if (!headless) opts.args.push('--window-size=' + viewport.width + ',' + (viewport.height + 88));
385
+ // ALWAYS persistent: every chrome process inherits --user-data-dir=<dir>
386
+ // in its cmdline, which is how reapChrome() finds the whole tree (root +
387
+ // helpers) on shutdown. without a unique dir there is no way to attribute
388
+ // an orphaned helper back to us, which is the leak we just fixed.
389
+ return await pw.chromium.launchPersistentContext(profileDir, opts);
390
+ };
391
+ let launchErr = null;
392
+ let launchedChannel = null;
393
+ for (const channel of channelsToTry) {
394
+ try {
395
+ context = await launchWith(channel);
396
+ launchedChannel = channel || 'chromium';
397
+ launchErr = null;
398
+ break;
399
+ } catch (err) {
400
+ launchErr = err;
401
+ const msg = err && err.message ? String(err.message) : '';
402
+ const isMissingChannel =
403
+ /not found at/i.test(msg) ||
404
+ /not installed/i.test(msg) ||
405
+ /Chromium distribution/i.test(msg);
406
+ if (!isMissingChannel) throw err; // unexpected failure: bubble up
407
+ if (channel) {
408
+ const next = hardwareGpuRequired
409
+ ? '; bundled Chromium is disabled for local GPU work\\n'
410
+ : ', falling back to bundled chromium\\n';
411
+ process.stderr.write('[playwright-driver] channel "' + channel + '" not available' + next);
412
+ }
413
+ }
414
+ }
415
+ if (launchErr) throw launchErr;
416
+ if (launchedChannel) {
417
+ process.stderr.write('[playwright-driver] launched ' + launchedChannel + '\\n');
418
+ }
419
+ // grant camera up front so the sim's Camera app reaches the host webcam
420
+ // through getUserMedia, mirroring a real iOS simulator where the camera just
421
+ // works. without this the unhandled chrome permission prompt auto-denies and
422
+ // CameraApp renders its "Camera Access Required" placeholder.
423
+ await context.grantPermissions(['camera']);
424
+ const hostMeta = {
425
+ sootsimHostDriver: 'playwright',
426
+ sootsimHostPid: process.pid,
427
+ sootsimHostOwnerPid: ownerPid,
428
+ sootsimHostStartedAt: Date.now(),
429
+ };
430
+ // inject the resolved bridge port (numeric literal) BEFORE the page runs, so
431
+ // the shell/engine talk to the daemon this driver owns. addInitScript runs
432
+ // ahead of every page script, and the vite dev plugin only fills the value in
433
+ // when it is still unset, so this one wins. plain assignment, NOT
434
+ // defineProperty: other injectors also assign to it, and a non-writable
435
+ // property would throw at that assignment and crash boot.
436
+ const bridgePortInject = bridgePort
437
+ ? 'window.__sootsimBridgePort = ' + Number(bridgePort) + ';'
438
+ : '';
439
+ await context.addInitScript({
440
+ content: 'Object.defineProperty(window, "__sootsimHostMeta", { value: ' + JSON.stringify(hostMeta) + ', configurable: true });' + bridgePortInject,
441
+ });
442
+ await context.exposeBinding('__sootsimHostClose', async () => {
443
+ await shutdown(0);
444
+ });
445
+ const connectTimeoutMs = Number(process.env.SOOTSIM_PW_CONNECT_TIMEOUT_MS || 120000);
446
+ if (connectAckFile) {
447
+ connectAckPoll = setInterval(() => {
448
+ if (ackFileExists()) markConnected();
449
+ }, 500);
450
+ if (typeof connectAckPoll.unref === 'function') connectAckPoll.unref();
451
+ }
452
+ connectTimer = setTimeout(() => {
453
+ if (ackFileExists()) {
454
+ markConnected();
455
+ return;
456
+ }
457
+ if (connected) return;
458
+ process.stderr.write('[connect-timeout] sim did not register with the bridge within ' + connectTimeoutMs + 'ms; closing playwright host\\n');
459
+ void shutdown(2);
460
+ }, Number.isFinite(connectTimeoutMs) && connectTimeoutMs > 0 ? connectTimeoutMs : 120000);
461
+ if (typeof connectTimer.unref === 'function') connectTimer.unref();
462
+ const page = await context.newPage();
463
+ if (hardwareGpuRequired) {
464
+ assertNoUnsafeSwiftShaderFlag();
465
+ const gpu = await page.evaluate(() => {
466
+ const canvas = document.createElement('canvas');
467
+ const gl =
468
+ canvas.getContext('webgl2', { powerPreference: 'high-performance' }) ||
469
+ canvas.getContext('webgl', { powerPreference: 'high-performance' });
470
+ if (!gl) return { renderer: null, vendor: null };
471
+ const info = gl.getExtension('WEBGL_debug_renderer_info');
472
+ return {
473
+ renderer: info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : null,
474
+ vendor: info ? String(gl.getParameter(info.UNMASKED_VENDOR_WEBGL)) : null,
475
+ };
476
+ });
477
+ if (!gpu.renderer) {
478
+ throw new Error(
479
+ '[playwright-driver] could not prove a hardware WebGL renderer; refusing local render-heavy launch'
480
+ );
481
+ }
482
+ if (softwareRendererPattern.test(gpu.renderer)) {
483
+ throw new Error('[playwright-driver] refused software WebGL renderer: ' + gpu.renderer);
484
+ }
485
+ assertNoUnsafeSwiftShaderFlag();
486
+ process.stderr.write(
487
+ '[playwright-driver] hardware WebGL renderer: ' + gpu.renderer +
488
+ (gpu.vendor ? ' (' + gpu.vendor + ')' : '') + '\\n'
489
+ );
490
+ }
491
+ // surface page-side failures (engine boot crash, failed WS registration)
492
+ // to stderr so a sim that never connects produces a real diagnostic
493
+ // instead of a silent "timed out waiting for opened sim to connect".
494
+ page.on('pageerror', (e) => {
495
+ process.stderr.write('[pageerror] ' + ((e && e.stack) || e) + '\\n');
496
+ });
497
+ page.on('console', (m) => {
498
+ if (m.type() === 'error') {
499
+ process.stderr.write('[console.error] ' + m.text() + '\\n');
500
+ }
501
+ });
502
+ page.on('crash', () => process.stderr.write('[page] renderer crashed\\n'));
503
+ try {
504
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
505
+ } catch (err) {
506
+ process.stderr.write('[goto] ' + String((err && err.message) || err) + '\\n');
507
+ }
508
+ keepAlive = setInterval(() => {}, 1 << 30);
509
+ // browser disconnect (e.g. user killed chrome from outside) → tear down.
510
+ // we are persistent-only now, so .browser() is the only handle to it.
511
+ try {
512
+ const b = context.browser && context.browser();
513
+ if (b && typeof b.on === 'function') b.on('disconnected', () => { void shutdown(0); });
514
+ } catch {}
515
+ context.on('close', () => { void shutdown(0); });
516
+ page.on('close', () => { void shutdown(0); });
517
+ })().catch((err) => {
518
+ process.stderr.write(String((err && err.stack) || err) + '\\n');
519
+ process.exit(1);
520
+ });
521
+ `
522
+
523
+ function probe(): DriverAvailability {
524
+ const resolved = resolvePlaywright()
525
+ if (!resolved) {
526
+ return {
527
+ available: false,
528
+ reason: 'playwright not installed in the current workspace',
529
+ }
530
+ }
531
+ return {
532
+ available: true,
533
+ reason: null,
534
+ detail: `resolved via ${resolved.spec}@${resolved.version}`,
535
+ }
536
+ }
537
+
538
+ // when a headed launch fails on a Linux box with no display, chrome's own
539
+ // error ("Missing X server", "cannot open display") tells the user *what*
540
+ // is wrong but not *what to do*. surface the actionable flag.
541
+ function headlessHintForLinuxFailure(
542
+ opts: DriverLaunchOptions,
543
+ detail: string,
544
+ ): string | null {
545
+ if (process.platform !== 'linux') return null
546
+ if (opts.headless) return null
547
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return null
548
+ if (!/missing x server|cannot open display|x server|x11/i.test(detail)) {
549
+ return null
550
+ }
551
+ return ' hint: linux without DISPLAY/WAYLAND_DISPLAY — rerun without --headed (or add --headless) to run playwright headless.'
552
+ }
553
+
554
+ // resolve the Chrome user-data dir for a profiled launch. a non-empty
555
+ // return means the host should use a persistent context; '' means a plain
556
+ // (in-memory) browser launch.
557
+ function resolveUserDataDir(opts: DriverLaunchOptions): string {
558
+ if (opts.profileId) {
559
+ return playwrightProfileUserDataDir(ensureProfile(opts.profileId).id)
560
+ }
561
+ if (opts.ephemeralProfile) {
562
+ return mkdtempSync(join(tmpdir(), 'sootsim-playwright-profile-'))
563
+ }
564
+ return ''
565
+ }
566
+
567
+ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
568
+ const resolved = resolvePlaywright()
569
+ if (!resolved) {
570
+ return {
571
+ launched: false,
572
+ message: 'playwright not installed — run `bun add -D playwright` first',
573
+ }
574
+ }
575
+ if (!opts.url) {
576
+ return { launched: false, message: 'playwright driver requires a target url' }
577
+ }
578
+ const ownerPid = opts.ownerPid
579
+ if (ownerPid === undefined || !Number.isInteger(ownerPid) || ownerPid <= 1) {
580
+ return {
581
+ launched: false,
582
+ message: 'playwright driver requires a valid owning session process',
583
+ }
584
+ }
585
+
586
+ // keep browser discovery, installation, and the detached host on one exact
587
+ // cache contract even if another caller mutates process.env during launch.
588
+ const playwrightEnv = { ...process.env }
589
+ const provisioning = ensurePlaywrightBrowser(resolved, playwrightEnv)
590
+ if (!provisioning.ok) {
591
+ return {
592
+ launched: false,
593
+ message: `playwright browser provisioning failed: ${provisioning.message}`,
594
+ }
595
+ }
596
+
597
+ const errLog = join(
598
+ tmpdir(),
599
+ `sootsim-playwright-host-${Date.now().toString(36)}-${process.pid}.log`,
600
+ )
601
+ const connectAckFile = `${errLog}.connected`
602
+ const errFd = openSync(errLog, 'a')
603
+ try {
604
+ // spawn the browser host detached so Chrome outlives `sootsim
605
+ // open`. stdout is dropped; stderr is captured to errLog so an early
606
+ // crash (bad profile dir, missing browser binary) produces a real
607
+ // message instead of a silent "timed out waiting for sim".
608
+ // resolve the owning session's pid *before* we detach — after the spawn,
609
+ // the host's own ancestry is just launchd. the host polls this pid and
610
+ // tears Chrome down when the session that launched it exits.
611
+ const child = spawn(process.execPath, ['-e', PLAYWRIGHT_SIM_HOST], {
612
+ detached: true,
613
+ stdio: ['ignore', 'ignore', errFd],
614
+ env: {
615
+ ...playwrightEnv,
616
+ SOOTSIM_PW_MODULE: resolved.modulePath,
617
+ SOOTSIM_PW_URL: opts.url,
618
+ SOOTSIM_PW_BRIDGE_PORT: opts.bridgePort ? String(opts.bridgePort) : '',
619
+ SOOTSIM_PW_HEADLESS: (opts.headless ?? true) ? '1' : '0',
620
+ SOOTSIM_PW_USERDATADIR: resolveUserDataDir(opts),
621
+ SOOTSIM_PW_CDP_PORT: opts.cdpPort ? String(opts.cdpPort) : '',
622
+ // when the caller passed no viewport, leave the inherited
623
+ // SOOTSIM_PW_VIEWPORT (from playwrightEnv) alone instead of
624
+ // clobbering it with '' — the driver reads it as its own default.
625
+ ...(opts.viewport
626
+ ? { SOOTSIM_PW_VIEWPORT: `${opts.viewport.width}x${opts.viewport.height}` }
627
+ : {}),
628
+ SOOTSIM_PW_CONNECTED_ACK_FILE: connectAckFile,
629
+ SOOTSIM_PW_CONNECT_TIMEOUT_MS: String(opts.connectTimeoutMs ?? 120_000),
630
+ SOOTSIM_PW_OWNER_PID: String(ownerPid),
631
+ },
632
+ })
633
+ child.unref()
634
+
635
+ // give the host a short window to fail fast (resolve errors, browser
636
+ // binary missing). if it is still alive after that, the sim is on its
637
+ // way and the caller's own `waitForSimMatch` takes over.
638
+ const earlyExit = await new Promise<number | null>((resolve) => {
639
+ const timer = setTimeout(() => resolve(null), 4000)
640
+ child.once('exit', (code) => {
641
+ clearTimeout(timer)
642
+ resolve(code ?? 0)
643
+ })
644
+ })
645
+ if (earlyExit !== null) {
646
+ // ANY exit inside the 4s startup window is a failure: a healthy host
647
+ // stays alive until the sim disconnects (much longer than 4s). a code-0
648
+ // early exit can happen when the inline script's top-level catch fires
649
+ // before the launch even reaches a thrown error path, or when chromium
650
+ // is killed externally — both still mean "no sim is coming". emit the
651
+ // host's FULL stderr — its first line is the error message, and the
652
+ // prior `slice(-3)` kept only trailing stack frames, dropping the
653
+ // message and making CI failures undiagnosable. leave errLog on disk
654
+ // (path included) so it can be inspected after the fact.
655
+ const detail = readFileSync(errLog, 'utf8').trim()
656
+ const hint = headlessHintForLinuxFailure(opts, detail)
657
+ return {
658
+ launched: false,
659
+ message: `playwright host exited early (code ${earlyExit}) — host log ${errLog}${
660
+ detail ? `:\n${detail}` : ''
661
+ }${hint ? `\n${hint}` : ''}`,
662
+ }
663
+ }
664
+ // name the lifetime tie: the host watchdog closes chrome ~5s after the
665
+ // owning session pid exits, which surprises one-shot/detached shells
666
+ // (their topmost non-init ancestor is the shell itself, so the sim dies
667
+ // the moment the command finishes).
668
+ const ownerComm = getProcessComm(ownerPid)
669
+ const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
670
+ return {
671
+ launched: true,
672
+ message: `playwright chrome launched → ${opts.url}${ownerNote}`,
673
+ pid: child.pid,
674
+ target: resolved.spec,
675
+ attachUrl: opts.url,
676
+ connectAckFile,
677
+ diagnosticLogPath: errLog,
678
+ }
679
+ } catch (err) {
680
+ return {
681
+ launched: false,
682
+ message: `playwright launch failed: ${err instanceof Error ? err.message : String(err)}`,
683
+ }
684
+ } finally {
685
+ // parent's copy of the fd; the detached host keeps its own inherited
686
+ // dup, so closing here does not cut off the child's stderr.
687
+ closeSync(errFd)
688
+ }
689
+ }
690
+
691
+ export const playwrightDriver: Driver = {
692
+ id: 'playwright',
693
+ name: 'playwright',
694
+ description: 'programmatic chromium via the playwright package — headless default',
695
+ kind: 'automation',
696
+ availability: probe,
697
+ launch,
698
+ }