@monoes/monobrowse 1.0.11 → 1.0.13

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 (380) hide show
  1. package/dist/src/__tests__/adapters.test.js +5 -2
  2. package/dist/src/__tests__/adapters.test.js.map +1 -1
  3. package/dist/src/__tests__/batch-eval-parsing.test.js +4 -2
  4. package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -1
  5. package/dist/src/__tests__/box-model.test.js +9 -5
  6. package/dist/src/__tests__/box-model.test.js.map +1 -1
  7. package/dist/src/__tests__/browser-launch.test.d.ts +10 -0
  8. package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
  9. package/dist/src/__tests__/browser-launch.test.js +17 -10
  10. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  11. package/dist/src/__tests__/cdp-client.test.js +9 -3
  12. package/dist/src/__tests__/cdp-client.test.js.map +1 -1
  13. package/dist/src/__tests__/close-browser.test.d.ts +13 -0
  14. package/dist/src/__tests__/close-browser.test.d.ts.map +1 -1
  15. package/dist/src/__tests__/close-browser.test.js +5 -5
  16. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  17. package/dist/src/__tests__/console-log.test.js +17 -6
  18. package/dist/src/__tests__/console-log.test.js.map +1 -1
  19. package/dist/src/__tests__/find.test.js +26 -6
  20. package/dist/src/__tests__/find.test.js.map +1 -1
  21. package/dist/src/__tests__/frame-command.test.js +9 -3
  22. package/dist/src/__tests__/frame-command.test.js.map +1 -1
  23. package/dist/src/__tests__/open-url-navigate-error.test.js +1 -3
  24. package/dist/src/__tests__/open-url-navigate-error.test.js.map +1 -1
  25. package/dist/src/__tests__/output.test.js +1 -1
  26. package/dist/src/__tests__/output.test.js.map +1 -1
  27. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  28. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  29. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  30. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  31. package/dist/src/__tests__/reap-idle-browser.test.d.ts +15 -0
  32. package/dist/src/__tests__/reap-idle-browser.test.d.ts.map +1 -0
  33. package/dist/src/__tests__/reap-idle-browser.test.js +238 -0
  34. package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -0
  35. package/dist/src/__tests__/ref-cache.test.js +18 -10
  36. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  37. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-a11y.test.js +147 -0
  40. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  41. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-analyze.test.js +157 -0
  44. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  45. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-budget.test.js +80 -0
  48. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  49. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-diff.test.js +231 -0
  52. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  53. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  54. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  55. package/dist/src/__tests__/report-evidence.test.js +214 -0
  56. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  57. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  58. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  59. package/dist/src/__tests__/report-flake.test.js +182 -0
  60. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  61. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  62. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  63. package/dist/src/__tests__/report-history.test.js +229 -0
  64. package/dist/src/__tests__/report-history.test.js.map +1 -0
  65. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  66. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  67. package/dist/src/__tests__/report-output.test.js +169 -0
  68. package/dist/src/__tests__/report-output.test.js.map +1 -0
  69. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  70. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  71. package/dist/src/__tests__/report-png.test.js +84 -0
  72. package/dist/src/__tests__/report-png.test.js.map +1 -0
  73. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  74. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  75. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  76. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  77. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  78. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  79. package/dist/src/__tests__/report-render.test.js +143 -0
  80. package/dist/src/__tests__/report-render.test.js.map +1 -0
  81. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  82. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  83. package/dist/src/__tests__/report-run.test.js +234 -0
  84. package/dist/src/__tests__/report-run.test.js.map +1 -0
  85. package/dist/src/__tests__/session-state.test.d.ts +8 -0
  86. package/dist/src/__tests__/session-state.test.d.ts.map +1 -1
  87. package/dist/src/__tests__/session-state.test.js +10 -10
  88. package/dist/src/__tests__/session-state.test.js.map +1 -1
  89. package/dist/src/__tests__/snapshot.test.js +44 -14
  90. package/dist/src/__tests__/snapshot.test.js.map +1 -1
  91. package/dist/src/__tests__/storage-eval.test.js +14 -6
  92. package/dist/src/__tests__/storage-eval.test.js.map +1 -1
  93. package/dist/src/__tests__/tabs.test.js +25 -9
  94. package/dist/src/__tests__/tabs.test.js.map +1 -1
  95. package/dist/src/browser/action-builder/analyzer.d.ts +1 -1
  96. package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -1
  97. package/dist/src/browser/action-builder/analyzer.js +25 -9
  98. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  99. package/dist/src/browser/action-builder/types.d.ts.map +1 -1
  100. package/dist/src/browser/actions.d.ts +1 -1
  101. package/dist/src/browser/actions.d.ts.map +1 -1
  102. package/dist/src/browser/actions.js +16 -6
  103. package/dist/src/browser/actions.js.map +1 -1
  104. package/dist/src/browser/adapters/gemini.d.ts.map +1 -1
  105. package/dist/src/browser/adapters/gemini.js.map +1 -1
  106. package/dist/src/browser/adapters/google.d.ts.map +1 -1
  107. package/dist/src/browser/adapters/google.js.map +1 -1
  108. package/dist/src/browser/adapters/index.d.ts.map +1 -1
  109. package/dist/src/browser/adapters/index.js +3 -3
  110. package/dist/src/browser/adapters/index.js.map +1 -1
  111. package/dist/src/browser/adapters/instagram.d.ts.map +1 -1
  112. package/dist/src/browser/adapters/instagram.js.map +1 -1
  113. package/dist/src/browser/adapters/linkedin.d.ts.map +1 -1
  114. package/dist/src/browser/adapters/linkedin.js.map +1 -1
  115. package/dist/src/browser/adapters/microsoft.d.ts.map +1 -1
  116. package/dist/src/browser/adapters/microsoft.js.map +1 -1
  117. package/dist/src/browser/adapters/x.d.ts.map +1 -1
  118. package/dist/src/browser/adapters/x.js.map +1 -1
  119. package/dist/src/browser/browser.d.ts +30 -0
  120. package/dist/src/browser/browser.d.ts.map +1 -1
  121. package/dist/src/browser/browser.js +122 -15
  122. package/dist/src/browser/browser.js.map +1 -1
  123. package/dist/src/browser/cdp.d.ts.map +1 -1
  124. package/dist/src/browser/cdp.js +21 -8
  125. package/dist/src/browser/cdp.js.map +1 -1
  126. package/dist/src/browser/console-log.d.ts.map +1 -1
  127. package/dist/src/browser/console-log.js +2 -2
  128. package/dist/src/browser/console-log.js.map +1 -1
  129. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  130. package/dist/src/browser/dashboard/server.js +29 -19
  131. package/dist/src/browser/dashboard/server.js.map +1 -1
  132. package/dist/src/browser/dashboard/ui.html +153 -165
  133. package/dist/src/browser/dialog.d.ts.map +1 -1
  134. package/dist/src/browser/dialog.js +3 -1
  135. package/dist/src/browser/dialog.js.map +1 -1
  136. package/dist/src/browser/emulation.d.ts.map +1 -1
  137. package/dist/src/browser/emulation.js +21 -6
  138. package/dist/src/browser/emulation.js.map +1 -1
  139. package/dist/src/browser/find.d.ts +4 -4
  140. package/dist/src/browser/find.d.ts.map +1 -1
  141. package/dist/src/browser/find.js +14 -6
  142. package/dist/src/browser/find.js.map +1 -1
  143. package/dist/src/browser/har.d.ts.map +1 -1
  144. package/dist/src/browser/har.js +14 -7
  145. package/dist/src/browser/har.js.map +1 -1
  146. package/dist/src/browser/index.d.ts +17 -16
  147. package/dist/src/browser/index.d.ts.map +1 -1
  148. package/dist/src/browser/index.js +17 -16
  149. package/dist/src/browser/index.js.map +1 -1
  150. package/dist/src/browser/network.d.ts.map +1 -1
  151. package/dist/src/browser/network.js +14 -4
  152. package/dist/src/browser/network.js.map +1 -1
  153. package/dist/src/browser/pdf.d.ts.map +1 -1
  154. package/dist/src/browser/pdf.js +3 -3
  155. package/dist/src/browser/pdf.js.map +1 -1
  156. package/dist/src/browser/profiler.d.ts.map +1 -1
  157. package/dist/src/browser/profiler.js +22 -20
  158. package/dist/src/browser/profiler.js.map +1 -1
  159. package/dist/src/browser/record.d.ts +1 -1
  160. package/dist/src/browser/record.d.ts.map +1 -1
  161. package/dist/src/browser/record.js +18 -7
  162. package/dist/src/browser/record.js.map +1 -1
  163. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  164. package/dist/src/browser/ref-cache.js +9 -4
  165. package/dist/src/browser/ref-cache.js.map +1 -1
  166. package/dist/src/browser/screenshot.d.ts.map +1 -1
  167. package/dist/src/browser/screenshot.js +11 -7
  168. package/dist/src/browser/screenshot.js.map +1 -1
  169. package/dist/src/browser/session.d.ts +1 -1
  170. package/dist/src/browser/session.d.ts.map +1 -1
  171. package/dist/src/browser/session.js +27 -8
  172. package/dist/src/browser/session.js.map +1 -1
  173. package/dist/src/browser/snapshot.d.ts +1 -1
  174. package/dist/src/browser/snapshot.d.ts.map +1 -1
  175. package/dist/src/browser/snapshot.js +8 -4
  176. package/dist/src/browser/snapshot.js.map +1 -1
  177. package/dist/src/browser/storage.d.ts.map +1 -1
  178. package/dist/src/browser/storage.js.map +1 -1
  179. package/dist/src/browser/tabs.d.ts.map +1 -1
  180. package/dist/src/browser/tabs.js +9 -3
  181. package/dist/src/browser/tabs.js.map +1 -1
  182. package/dist/src/browser/trace.d.ts.map +1 -1
  183. package/dist/src/browser/trace.js +3 -3
  184. package/dist/src/browser/trace.js.map +1 -1
  185. package/dist/src/browser/types.d.ts.map +1 -1
  186. package/dist/src/browser/types.js +23 -4
  187. package/dist/src/browser/types.js.map +1 -1
  188. package/dist/src/browser/vitals.d.ts.map +1 -1
  189. package/dist/src/browser/vitals.js +1 -1
  190. package/dist/src/browser/vitals.js.map +1 -1
  191. package/dist/src/browser/wait.d.ts.map +1 -1
  192. package/dist/src/browser/wait.js +11 -5
  193. package/dist/src/browser/wait.js.map +1 -1
  194. package/dist/src/cli/action.d.ts.map +1 -1
  195. package/dist/src/cli/action.js +14 -11
  196. package/dist/src/cli/action.js.map +1 -1
  197. package/dist/src/cli/commands.d.ts.map +1 -1
  198. package/dist/src/cli/commands.js +707 -159
  199. package/dist/src/cli/commands.js.map +1 -1
  200. package/dist/src/cli/output.js +5 -5
  201. package/dist/src/cli/output.js.map +1 -1
  202. package/dist/src/cli/platform.d.ts.map +1 -1
  203. package/dist/src/cli/platform.js +15 -11
  204. package/dist/src/cli/platform.js.map +1 -1
  205. package/dist/src/cli/types.d.ts +1 -1
  206. package/dist/src/cli/types.d.ts.map +1 -1
  207. package/dist/src/cli.js +21 -9
  208. package/dist/src/cli.js.map +1 -1
  209. package/dist/src/index.d.ts +2 -2
  210. package/dist/src/index.d.ts.map +1 -1
  211. package/dist/src/index.js +2 -2
  212. package/dist/src/index.js.map +1 -1
  213. package/dist/src/report/a11y.d.ts +52 -0
  214. package/dist/src/report/a11y.d.ts.map +1 -0
  215. package/dist/src/report/a11y.js +231 -0
  216. package/dist/src/report/a11y.js.map +1 -0
  217. package/dist/src/report/analyze.d.ts +20 -0
  218. package/dist/src/report/analyze.d.ts.map +1 -0
  219. package/dist/src/report/analyze.js +136 -0
  220. package/dist/src/report/analyze.js.map +1 -0
  221. package/dist/src/report/budget.d.ts +27 -0
  222. package/dist/src/report/budget.d.ts.map +1 -0
  223. package/dist/src/report/budget.js +143 -0
  224. package/dist/src/report/budget.js.map +1 -0
  225. package/dist/src/report/collect-a11y.d.ts +25 -0
  226. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  227. package/dist/src/report/collect-a11y.js +154 -0
  228. package/dist/src/report/collect-a11y.js.map +1 -0
  229. package/dist/src/report/collect.d.ts +45 -0
  230. package/dist/src/report/collect.d.ts.map +1 -0
  231. package/dist/src/report/collect.js +275 -0
  232. package/dist/src/report/collect.js.map +1 -0
  233. package/dist/src/report/evidence.d.ts +80 -0
  234. package/dist/src/report/evidence.d.ts.map +1 -0
  235. package/dist/src/report/evidence.js +279 -0
  236. package/dist/src/report/evidence.js.map +1 -0
  237. package/dist/src/report/flake.d.ts +88 -0
  238. package/dist/src/report/flake.d.ts.map +1 -0
  239. package/dist/src/report/flake.js +232 -0
  240. package/dist/src/report/flake.js.map +1 -0
  241. package/dist/src/report/history.d.ts +85 -0
  242. package/dist/src/report/history.d.ts.map +1 -0
  243. package/dist/src/report/history.js +176 -0
  244. package/dist/src/report/history.js.map +1 -0
  245. package/dist/src/report/index.d.ts +102 -0
  246. package/dist/src/report/index.d.ts.map +1 -0
  247. package/dist/src/report/index.js +216 -0
  248. package/dist/src/report/index.js.map +1 -0
  249. package/dist/src/report/pixel-diff.d.ts +30 -0
  250. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  251. package/dist/src/report/pixel-diff.js +175 -0
  252. package/dist/src/report/pixel-diff.js.map +1 -0
  253. package/dist/src/report/png.d.ts +28 -0
  254. package/dist/src/report/png.d.ts.map +1 -0
  255. package/dist/src/report/png.js +259 -0
  256. package/dist/src/report/png.js.map +1 -0
  257. package/dist/src/report/render-evidence.d.ts +16 -0
  258. package/dist/src/report/render-evidence.d.ts.map +1 -0
  259. package/dist/src/report/render-evidence.js +174 -0
  260. package/dist/src/report/render-evidence.js.map +1 -0
  261. package/dist/src/report/render-trend.d.ts +16 -0
  262. package/dist/src/report/render-trend.d.ts.map +1 -0
  263. package/dist/src/report/render-trend.js +194 -0
  264. package/dist/src/report/render-trend.js.map +1 -0
  265. package/dist/src/report/render.d.ts +21 -0
  266. package/dist/src/report/render.d.ts.map +1 -0
  267. package/dist/src/report/render.js +286 -0
  268. package/dist/src/report/render.js.map +1 -0
  269. package/dist/src/report/run-diff.d.ts +18 -0
  270. package/dist/src/report/run-diff.d.ts.map +1 -0
  271. package/dist/src/report/run-diff.js +51 -0
  272. package/dist/src/report/run-diff.js.map +1 -0
  273. package/dist/src/report/structure.d.ts +41 -0
  274. package/dist/src/report/structure.d.ts.map +1 -0
  275. package/dist/src/report/structure.js +210 -0
  276. package/dist/src/report/structure.js.map +1 -0
  277. package/dist/src/report/trend.d.ts +26 -0
  278. package/dist/src/report/trend.d.ts.map +1 -0
  279. package/dist/src/report/trend.js +164 -0
  280. package/dist/src/report/trend.js.map +1 -0
  281. package/dist/src/report/types.d.ts +320 -0
  282. package/dist/src/report/types.d.ts.map +1 -0
  283. package/dist/src/report/types.js +11 -0
  284. package/dist/src/report/types.js.map +1 -0
  285. package/dist/src/report/util.d.ts +17 -0
  286. package/dist/src/report/util.d.ts.map +1 -0
  287. package/dist/src/report/util.js +45 -0
  288. package/dist/src/report/util.js.map +1 -0
  289. package/dist/tsconfig.tsbuildinfo +1 -1
  290. package/package.json +1 -1
  291. package/src/__tests__/adapters.test.ts +15 -8
  292. package/src/__tests__/batch-eval-parsing.test.ts +4 -2
  293. package/src/__tests__/box-model.test.ts +9 -5
  294. package/src/__tests__/browser-launch.test.ts +26 -15
  295. package/src/__tests__/cdp-client.test.ts +25 -9
  296. package/src/__tests__/close-browser.test.ts +11 -6
  297. package/src/__tests__/console-log.test.ts +36 -13
  298. package/src/__tests__/find.test.ts +58 -20
  299. package/src/__tests__/frame-command.test.ts +12 -6
  300. package/src/__tests__/open-url-navigate-error.test.ts +2 -4
  301. package/src/__tests__/output.test.ts +1 -1
  302. package/src/__tests__/profiler-heap.test.ts +191 -0
  303. package/src/__tests__/reap-idle-browser.test.ts +276 -0
  304. package/src/__tests__/ref-cache.test.ts +25 -12
  305. package/src/__tests__/report-a11y.test.ts +180 -0
  306. package/src/__tests__/report-analyze.test.ts +202 -0
  307. package/src/__tests__/report-budget.test.ts +92 -0
  308. package/src/__tests__/report-diff.test.ts +272 -0
  309. package/src/__tests__/report-evidence.test.ts +276 -0
  310. package/src/__tests__/report-flake.test.ts +214 -0
  311. package/src/__tests__/report-history.test.ts +293 -0
  312. package/src/__tests__/report-output.test.ts +210 -0
  313. package/src/__tests__/report-png.test.ts +95 -0
  314. package/src/__tests__/report-render-extras.test.ts +315 -0
  315. package/src/__tests__/report-render.test.ts +175 -0
  316. package/src/__tests__/report-run.test.ts +264 -0
  317. package/src/__tests__/session-state.test.ts +40 -28
  318. package/src/__tests__/snapshot.test.ts +60 -21
  319. package/src/__tests__/storage-eval.test.ts +30 -16
  320. package/src/__tests__/tabs.test.ts +26 -10
  321. package/src/browser/action-builder/analyzer.ts +39 -15
  322. package/src/browser/action-builder/types.ts +45 -8
  323. package/src/browser/actions.ts +288 -123
  324. package/src/browser/adapters/gemini.ts +7 -3
  325. package/src/browser/adapters/google.ts +5 -3
  326. package/src/browser/adapters/index.ts +5 -4
  327. package/src/browser/adapters/instagram.ts +4 -2
  328. package/src/browser/adapters/linkedin.ts +4 -2
  329. package/src/browser/adapters/microsoft.ts +9 -5
  330. package/src/browser/adapters/x.ts +7 -3
  331. package/src/browser/browser.ts +188 -42
  332. package/src/browser/cdp.ts +43 -11
  333. package/src/browser/console-log.ts +24 -7
  334. package/src/browser/dashboard/server.ts +44 -25
  335. package/src/browser/dashboard/ui.html +153 -165
  336. package/src/browser/dialog.ts +25 -8
  337. package/src/browser/emulation.ts +86 -36
  338. package/src/browser/find.ts +110 -47
  339. package/src/browser/har.ts +58 -26
  340. package/src/browser/index.ts +17 -16
  341. package/src/browser/network.ts +147 -46
  342. package/src/browser/pdf.ts +4 -4
  343. package/src/browser/profiler.ts +39 -19
  344. package/src/browser/record.ts +38 -19
  345. package/src/browser/ref-cache.ts +38 -17
  346. package/src/browser/screenshot.ts +58 -32
  347. package/src/browser/session.ts +51 -18
  348. package/src/browser/snapshot.ts +34 -20
  349. package/src/browser/storage.ts +73 -17
  350. package/src/browser/tabs.ts +29 -17
  351. package/src/browser/trace.ts +14 -10
  352. package/src/browser/types.ts +23 -4
  353. package/src/browser/vitals.ts +33 -16
  354. package/src/browser/wait.ts +47 -21
  355. package/src/cli/action.ts +52 -21
  356. package/src/cli/commands.ts +1365 -385
  357. package/src/cli/output.ts +5 -5
  358. package/src/cli/platform.ts +44 -23
  359. package/src/cli/types.ts +1 -1
  360. package/src/cli.ts +34 -18
  361. package/src/index.ts +2 -2
  362. package/src/report/a11y.ts +242 -0
  363. package/src/report/analyze.ts +181 -0
  364. package/src/report/budget.ts +155 -0
  365. package/src/report/collect-a11y.ts +200 -0
  366. package/src/report/collect.ts +408 -0
  367. package/src/report/evidence.ts +379 -0
  368. package/src/report/flake.ts +303 -0
  369. package/src/report/history.ts +219 -0
  370. package/src/report/index.ts +307 -0
  371. package/src/report/pixel-diff.ts +197 -0
  372. package/src/report/png.ts +290 -0
  373. package/src/report/render-evidence.ts +194 -0
  374. package/src/report/render-trend.ts +225 -0
  375. package/src/report/render.ts +314 -0
  376. package/src/report/run-diff.ts +63 -0
  377. package/src/report/structure.ts +212 -0
  378. package/src/report/trend.ts +189 -0
  379. package/src/report/types.ts +356 -0
  380. package/src/report/util.ts +48 -0
@@ -3,11 +3,11 @@
3
3
  * Provides ref-based element model and token-efficient accessibility snapshots
4
4
  */
5
5
 
6
- import type { Command, CommandContext, CommandResult } from './types.js';
7
- import { output } from './output.js';
6
+ import type { CdpClient, ElementRef, FindAction, NetworkRoute } from '../index.js';
8
7
  import { createActionCommand } from './action.js';
8
+ import { output } from './output.js';
9
9
  import { createPlatformCommand } from './platform.js';
10
- import type { CdpClient, ElementRef, NetworkRoute, FindAction } from '../index.js';
10
+ import type { Command, CommandContext, CommandResult } from './types.js';
11
11
 
12
12
  // Runtime state (single session per CLI process)
13
13
  const DEFAULT_PORT = 9222;
@@ -51,7 +51,11 @@ function ensureSignalCleanupHandlers(): void {
51
51
  } else {
52
52
  const pid = browser.getLaunchedPid(_port);
53
53
  if (pid !== undefined) {
54
- try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
54
+ try {
55
+ process.kill(pid, 'SIGKILL');
56
+ } catch {
57
+ /* already exited */
58
+ }
55
59
  }
56
60
  }
57
61
  } catch {
@@ -69,7 +73,9 @@ function ensureSignalCleanupHandlers(): void {
69
73
  // ref-cache.ts's saveActivePort/loadActivePort) so subsequent commands
70
74
  // attach to the browser the user actually opened instead of silently
71
75
  // launching/attaching to a second, unrelated Chrome instance on 9222.
72
- async function resolveDefaultPort(browser: Awaited<ReturnType<typeof getBrowser>>): Promise<number> {
76
+ async function resolveDefaultPort(
77
+ browser: Awaited<ReturnType<typeof getBrowser>>,
78
+ ): Promise<number> {
73
79
  const persisted = await browser.loadActivePortInfo();
74
80
  if (!persisted) return DEFAULT_PORT;
75
81
  if (!persisted.launched) {
@@ -78,10 +84,14 @@ async function resolveDefaultPort(browser: Awaited<ReturnType<typeof getBrowser>
78
84
  // user's debug port and silently swap which browser commands act on —
79
85
  // fail loudly instead.
80
86
  try {
81
- await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(1500) });
87
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
88
+ signal: AbortSignal.timeout(1500),
89
+ });
82
90
  } catch {
83
91
  await browser.clearActivePort();
84
- throw new Error(`Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`);
92
+ throw new Error(
93
+ `Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`,
94
+ );
85
95
  }
86
96
  }
87
97
  return persisted.port;
@@ -89,7 +99,7 @@ async function resolveDefaultPort(browser: Awaited<ReturnType<typeof getBrowser>
89
99
 
90
100
  async function ensureConnected(port: number, targetId?: string) {
91
101
  const browser = await getBrowser();
92
- if (!_client || !_client.isConnected()) {
102
+ if (!_client?.isConnected()) {
93
103
  if (_client && _sessionId) {
94
104
  browser.teardownRouteInterception(_sessionId);
95
105
  browser.stopRequestCapture(_sessionId);
@@ -128,20 +138,20 @@ async function ensureConnected(port: number, targetId?: string) {
128
138
  async function hydrateRefsFromCache(
129
139
  browser: Awaited<ReturnType<typeof getBrowser>>,
130
140
  targetId: string,
131
- currentUrl: string
141
+ currentUrl: string,
132
142
  ): Promise<void> {
133
143
  const cached = await browser.loadRefCache(targetId);
134
144
  if (!cached) return;
135
145
  if (currentUrl && cached.url && currentUrl !== cached.url) {
136
146
  output.printError(
137
- `Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`
147
+ `Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`,
138
148
  );
139
149
  return; // leave _refs empty — do not attempt to resolve refs against a different page
140
150
  }
141
151
  _refs = cached.refs;
142
152
  if (cached.stale) {
143
153
  output.printWarning(
144
- `AX ref cache is ${Math.round(cached.ageMs / 1000)}s old — page may have changed since the last snapshot; re-run snapshot if refs don't resolve as expected`
154
+ `AX ref cache is ${Math.round(cached.ageMs / 1000)}s old — page may have changed since the last snapshot; re-run snapshot if refs don't resolve as expected`,
145
155
  );
146
156
  }
147
157
  }
@@ -150,23 +160,45 @@ async function hydrateRefsFromCache(
150
160
  // Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
151
161
  // (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
152
162
  // DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
153
- const ATTENTION_URL_RE = /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
163
+ const ATTENTION_URL_RE =
164
+ /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
154
165
 
155
- async function detectAttentionNeeded(client: CdpClient, sessionId: string, url: string): Promise<'login' | 'captcha' | null> {
166
+ async function detectAttentionNeeded(
167
+ client: CdpClient,
168
+ sessionId: string,
169
+ url: string,
170
+ ): Promise<'login' | 'captcha' | null> {
156
171
  if (ATTENTION_URL_RE.test(url)) return 'login';
157
172
  try {
158
- const hasPassword = await client.send<{ result: { value: boolean } }>('Runtime.evaluate', {
159
- expression: '!!document.querySelector("input[type=password]")',
160
- returnByValue: true,
161
- }, sessionId).then(r => r.result?.value === true).catch(() => false);
173
+ const hasPassword = await client
174
+ .send<{ result: { value: boolean } }>(
175
+ 'Runtime.evaluate',
176
+ {
177
+ expression: '!!document.querySelector("input[type=password]")',
178
+ returnByValue: true,
179
+ },
180
+ sessionId,
181
+ )
182
+ .then((r) => r.result?.value === true)
183
+ .catch(() => false);
162
184
  if (hasPassword) return 'login';
163
185
 
164
- const hasCaptcha = await client.send<{ result: { value: boolean } }>('Runtime.evaluate', {
165
- expression: '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
166
- returnByValue: true,
167
- }, sessionId).then(r => r.result?.value === true).catch(() => false);
186
+ const hasCaptcha = await client
187
+ .send<{ result: { value: boolean } }>(
188
+ 'Runtime.evaluate',
189
+ {
190
+ expression:
191
+ '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
192
+ returnByValue: true,
193
+ },
194
+ sessionId,
195
+ )
196
+ .then((r) => r.result?.value === true)
197
+ .catch(() => false);
168
198
  if (hasCaptcha) return 'captcha';
169
- } catch { /* ignore CDP errors */ }
199
+ } catch {
200
+ /* ignore CDP errors */
201
+ }
170
202
  return null;
171
203
  }
172
204
 
@@ -174,13 +206,19 @@ function waitForEnter(): Promise<void> {
174
206
  if (!process.stdin.isTTY) {
175
207
  // Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
176
208
  // Auto-continue after a short grace period so automation isn't blocked.
177
- output.printWarning('Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.');
178
- return new Promise(resolve => setTimeout(resolve, 30_000));
209
+ output.printWarning(
210
+ 'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
211
+ );
212
+ return new Promise((resolve) => setTimeout(resolve, 30_000));
179
213
  }
180
- return new Promise(resolve => {
214
+ return new Promise((resolve) => {
181
215
  process.stdin.resume();
182
216
  process.stdin.setEncoding('utf8');
183
- const onData = () => { process.stdin.pause(); process.stdin.off('data', onData); resolve(); };
217
+ const onData = () => {
218
+ process.stdin.pause();
219
+ process.stdin.off('data', onData);
220
+ resolve();
221
+ };
184
222
  process.stdin.once('data', onData);
185
223
  });
186
224
  }
@@ -191,7 +229,11 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
191
229
  // Snapshot cookies from current headless session before closing
192
230
  let savedCookies: unknown[] = [];
193
231
  if (_client && _sessionId) {
194
- try { savedCookies = await browser.getCookies(_client, _sessionId); } catch { /* ignore */ }
232
+ try {
233
+ savedCookies = await browser.getCookies(_client, _sessionId);
234
+ } catch {
235
+ /* ignore */
236
+ }
195
237
  browser.teardownRouteInterception(_sessionId);
196
238
  browser.stopRequestCapture(_sessionId);
197
239
  browser.teardownDialogHandling(_sessionId);
@@ -215,7 +257,9 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
215
257
  _sessionId = fallback.sessionId;
216
258
  _targetId = fallback.target.id;
217
259
  _refs = new Map();
218
- throw new Error(`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`);
260
+ throw new Error(
261
+ `Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
262
+ );
219
263
  }
220
264
  const conn = await browser.connectToTarget(headedPort);
221
265
  _client = conn.client;
@@ -224,16 +268,28 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
224
268
  _refs = new Map();
225
269
 
226
270
  if (savedCookies.length) {
227
- try { await browser.setCookies(_client, _sessionId, savedCookies as Parameters<typeof browser.setCookies>[2]); } catch { /* ignore */ }
271
+ try {
272
+ await browser.setCookies(
273
+ _client,
274
+ _sessionId,
275
+ savedCookies as Parameters<typeof browser.setCookies>[2],
276
+ );
277
+ } catch {
278
+ /* ignore */
279
+ }
228
280
  }
229
281
  await browser.openUrl(_client, _sessionId, url);
230
282
 
231
- output.printInfo('Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...');
283
+ output.printInfo(
284
+ 'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
285
+ );
232
286
  await waitForEnter();
233
287
 
234
288
  // Capture post-auth cookies
235
289
  const authCookies = await browser.getCookies(_client, _sessionId).catch(() => [] as unknown[]);
236
- const authLocalStorage = await browser.getLocalStorage(_client, _sessionId).catch(() => ({}) as Record<string, string>);
290
+ const authLocalStorage = await browser
291
+ .getLocalStorage(_client, _sessionId)
292
+ .catch(() => ({}) as Record<string, string>);
237
293
 
238
294
  // Close headed session — actually terminate the underlying Chrome process
239
295
  // (Browser.close CDP command, PID-kill fallback), not just our CDP client
@@ -258,10 +314,22 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
258
314
  _refs = new Map();
259
315
 
260
316
  if (authCookies.length) {
261
- try { await browser.setCookies(_client, _sessionId, authCookies as Parameters<typeof browser.setCookies>[2]); } catch { /* ignore */ }
317
+ try {
318
+ await browser.setCookies(
319
+ _client,
320
+ _sessionId,
321
+ authCookies as Parameters<typeof browser.setCookies>[2],
322
+ );
323
+ } catch {
324
+ /* ignore */
325
+ }
262
326
  }
263
327
  if (authLocalStorage && Object.keys(authLocalStorage).length) {
264
- try { await browser.setLocalStorage(_client, _sessionId, authLocalStorage); } catch { /* ignore */ }
328
+ try {
329
+ await browser.setLocalStorage(_client, _sessionId, authLocalStorage);
330
+ } catch {
331
+ /* ignore */
332
+ }
265
333
  }
266
334
  }
267
335
 
@@ -278,7 +346,7 @@ function imageFormat<T extends string>(value: unknown, allowed: readonly T[], fa
278
346
  }
279
347
 
280
348
  function print(msg: string) {
281
- process.stdout.write(msg + '\n');
349
+ process.stdout.write(`${msg}\n`);
282
350
  }
283
351
 
284
352
  // ---------------------------------------------------------------------------
@@ -290,7 +358,12 @@ const openCommand: Command = {
290
358
  description: 'Open a URL in the browser. Usage: monomind browse open <url>',
291
359
  options: [
292
360
  { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
293
- { name: 'headed', type: 'boolean', description: 'Force visible browser window', default: false },
361
+ {
362
+ name: 'headed',
363
+ type: 'boolean',
364
+ description: 'Force visible browser window',
365
+ default: false,
366
+ },
294
367
  { name: 'session', short: 's', type: 'string', description: 'Session name to restore' },
295
368
  { name: 'state', type: 'string', description: 'State file to load' },
296
369
  ],
@@ -306,13 +379,25 @@ const openCommand: Command = {
306
379
  const prevSid = _sessionId;
307
380
  const prevClient = _client;
308
381
  if (browser.getHarStatus(prevSid).recording) {
309
- try { await browser.stopHarRecording(prevClient, prevSid); } catch { /* ignore */ }
382
+ try {
383
+ await browser.stopHarRecording(prevClient, prevSid);
384
+ } catch {
385
+ /* ignore */
386
+ }
310
387
  }
311
388
  if (browser.getTraceStatus(prevSid)) {
312
- try { await browser.stopTrace(prevClient, prevSid); } catch { /* ignore */ }
389
+ try {
390
+ await browser.stopTrace(prevClient, prevSid);
391
+ } catch {
392
+ /* ignore */
393
+ }
313
394
  }
314
395
  if (browser.isProfilingActive(prevSid)) {
315
- try { await browser.stopCpuProfile(prevClient, prevSid); } catch { /* ignore */ }
396
+ try {
397
+ await browser.stopCpuProfile(prevClient, prevSid);
398
+ } catch {
399
+ /* ignore */
400
+ }
316
401
  }
317
402
  browser.teardownRouteInterception(prevSid);
318
403
  browser.stopRequestCapture(prevSid);
@@ -352,7 +437,10 @@ const openCommand: Command = {
352
437
  // launched it) instead of overwriting with undefined.
353
438
  const existing = await browser.loadActivePortInfo();
354
439
  const preserved = existing && existing.port === _port ? existing : undefined;
355
- await browser.saveActivePort(_port, { pid: preserved?.pid, userDataDir: preserved?.userDataDir });
440
+ await browser.saveActivePort(_port, {
441
+ pid: preserved?.pid,
442
+ userDataDir: preserved?.userDataDir,
443
+ });
356
444
  }
357
445
  ensureSignalCleanupHandlers();
358
446
  const conn = await browser.connectToTarget(_port);
@@ -381,10 +469,14 @@ const openCommand: Command = {
381
469
  if (!forceHeaded) {
382
470
  const attentionType = await detectAttentionNeeded(_client, _sessionId, currentUrl);
383
471
  if (attentionType) {
384
- output.printWarning(`${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`);
472
+ output.printWarning(
473
+ `${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`,
474
+ );
385
475
  await switchToHeaded(currentUrl, port);
386
476
  await browser.openUrl(_client!, _sessionId, currentUrl);
387
- output.printSuccess(`Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`);
477
+ output.printSuccess(
478
+ `Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`,
479
+ );
388
480
  }
389
481
  }
390
482
 
@@ -400,15 +492,50 @@ const snapshotCommand: Command = {
400
492
  name: 'snapshot',
401
493
  description: 'Capture accessibility snapshot with ref-based element handles (@e1, @e2, ...)',
402
494
  options: [
403
- { name: 'interactive', short: 'i', type: 'boolean', description: 'Interactive elements only (93% token reduction)', default: false },
404
- { name: 'compact', short: 'c', type: 'boolean', description: 'Compact output format', default: false },
495
+ {
496
+ name: 'interactive',
497
+ short: 'i',
498
+ type: 'boolean',
499
+ description: 'Interactive elements only (93% token reduction)',
500
+ default: false,
501
+ },
502
+ {
503
+ name: 'compact',
504
+ short: 'c',
505
+ type: 'boolean',
506
+ description: 'Compact output format',
507
+ default: false,
508
+ },
405
509
  { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
406
510
  { name: 'depth', short: 'd', type: 'number', description: 'Max depth of AX tree to show' },
407
- { name: 'selector', short: 's', type: 'string', description: 'Scope snapshot to a CSS selector' },
408
- { name: 'save', type: 'string', description: 'Save snapshot text to file (baseline for --diff)' },
409
- { name: 'diff', type: 'string', description: 'Compare current snapshot against a saved baseline file' },
410
- { name: 'content-boundaries', type: 'boolean', description: 'Wrap output in sentinel markers to prevent page-content injection attacks', default: false },
411
- { name: 'max-output', type: 'number', description: 'Truncate output to N characters (prevents context window blowout on large pages)' },
511
+ {
512
+ name: 'selector',
513
+ short: 's',
514
+ type: 'string',
515
+ description: 'Scope snapshot to a CSS selector',
516
+ },
517
+ {
518
+ name: 'save',
519
+ type: 'string',
520
+ description: 'Save snapshot text to file (baseline for --diff)',
521
+ },
522
+ {
523
+ name: 'diff',
524
+ type: 'string',
525
+ description: 'Compare current snapshot against a saved baseline file',
526
+ },
527
+ {
528
+ name: 'content-boundaries',
529
+ type: 'boolean',
530
+ description: 'Wrap output in sentinel markers to prevent page-content injection attacks',
531
+ default: false,
532
+ },
533
+ {
534
+ name: 'max-output',
535
+ type: 'number',
536
+ description:
537
+ 'Truncate output to N characters (prevents context window blowout on large pages)',
538
+ },
412
539
  ],
413
540
  action: async (ctx: CommandContext): Promise<CommandResult> => {
414
541
  const { client, sessionId } = await ensureConnected(_port);
@@ -436,8 +563,8 @@ const snapshotCommand: Command = {
436
563
 
437
564
  // --save: write snapshot text to baseline file
438
565
  if (ctx.flags.save) {
439
- const { writeFile, mkdir } = await import('fs/promises');
440
- const { dirname } = await import('path');
566
+ const { writeFile, mkdir } = await import('node:fs/promises');
567
+ const { dirname } = await import('node:path');
441
568
  const savePath = ctx.flags.save as string;
442
569
  await mkdir(dirname(savePath), { recursive: true }).catch(() => {});
443
570
  await writeFile(savePath, result.text, 'utf8');
@@ -447,35 +574,58 @@ const snapshotCommand: Command = {
447
574
 
448
575
  // --diff: compare against baseline file
449
576
  if (ctx.flags.diff) {
450
- const { readFile } = await import('fs/promises');
577
+ const { readFile } = await import('node:fs/promises');
451
578
  const baselinePath = ctx.flags.diff as string;
452
579
  let baseline: string;
453
- try { baseline = await readFile(baselinePath, 'utf8'); }
454
- catch { throw new Error(`Baseline not found: ${baselinePath}. Run snapshot --save first.`); }
580
+ try {
581
+ baseline = await readFile(baselinePath, 'utf8');
582
+ } catch {
583
+ throw new Error(`Baseline not found: ${baselinePath}. Run snapshot --save first.`);
584
+ }
455
585
  const currentLines = result.text.split('\n');
456
586
  const baselineLines = baseline.split('\n');
457
- const added: string[] = [], removed: string[] = [];
587
+ const added: string[] = [],
588
+ removed: string[] = [];
458
589
  const baseSet = new Set(baselineLines);
459
590
  const curSet = new Set(currentLines);
460
591
  for (const l of currentLines) if (!baseSet.has(l)) added.push(l);
461
592
  for (const l of baselineLines) if (!curSet.has(l)) removed.push(l);
462
593
  const changed = added.length > 0 || removed.length > 0;
463
594
  if (ctx.flags.json) {
464
- print(JSON.stringify({ changed, additions: added.length, removals: removed.length, added, removed }));
595
+ print(
596
+ JSON.stringify({
597
+ changed,
598
+ additions: added.length,
599
+ removals: removed.length,
600
+ added,
601
+ removed,
602
+ }),
603
+ );
465
604
  } else {
466
- if (!changed) { output.printSuccess('No snapshot changes detected'); }
467
- else {
605
+ if (!changed) {
606
+ output.printSuccess('No snapshot changes detected');
607
+ } else {
468
608
  output.printWarning(`Snapshot changed: +${added.length} lines, -${removed.length} lines`);
469
609
  for (const l of added) print(`\x1b[32m+ ${l}\x1b[0m`);
470
610
  for (const l of removed) print(`\x1b[31m- ${l}\x1b[0m`);
471
611
  }
472
612
  }
473
- return { success: true, data: { changed, additions: added.length, removals: removed.length } };
613
+ return {
614
+ success: true,
615
+ data: { changed, additions: added.length, removals: removed.length },
616
+ };
474
617
  }
475
618
 
476
619
  if (ctx.flags.json) {
477
620
  const refsObj = Object.fromEntries([...result.refs.entries()].map(([k, v]) => [k, v]));
478
- print(JSON.stringify({ url: result.url, title: result.title, refs: refsObj, snapshot: result.text }));
621
+ print(
622
+ JSON.stringify({
623
+ url: result.url,
624
+ title: result.title,
625
+ refs: refsObj,
626
+ snapshot: result.text,
627
+ }),
628
+ );
479
629
  } else {
480
630
  print(`[${result.title}] ${result.url}\n`);
481
631
  print(applyOutputLimits(result.text));
@@ -563,11 +713,20 @@ const waitCommand: Command = {
563
713
  { name: 'text', type: 'string', description: 'Wait for text to appear in page' },
564
714
  { name: 'not-text', type: 'string', description: 'Wait for text to disappear from page' },
565
715
  { name: 'selector', type: 'string', description: 'Wait for CSS selector to appear' },
566
- { name: 'load', type: 'string', description: 'Wait for load event: load|networkidle|domcontentloaded' },
716
+ {
717
+ name: 'load',
718
+ type: 'string',
719
+ description: 'Wait for load event: load|networkidle|domcontentloaded',
720
+ },
567
721
  { name: 'fn', type: 'string', description: 'Wait until JS expression returns truthy' },
568
722
  { name: 'ms', type: 'number', description: 'Wait N milliseconds' },
569
723
  { name: 'timeout', short: 't', type: 'number', description: 'Timeout in ms', default: 30000 },
570
- { name: 'download', type: 'string', description: 'Wait for a file download to complete and save to path (monitors Browser.downloadProgress events)' },
724
+ {
725
+ name: 'download',
726
+ type: 'string',
727
+ description:
728
+ 'Wait for a file download to complete and save to path (monitors Browser.downloadProgress events)',
729
+ },
571
730
  ],
572
731
  action: async (ctx: CommandContext): Promise<CommandResult> => {
573
732
  const { client, sessionId } = await ensureConnected(_port);
@@ -584,12 +743,17 @@ const waitCommand: Command = {
584
743
  if (ctx.flags.fn) {
585
744
  const expr = ctx.flags.fn as string;
586
745
  const rawTimeout = (ctx.flags.timeout as number) ?? 30000;
587
- const timeout = Number.isFinite(rawTimeout) ? Math.max(100, Math.min(rawTimeout, 300_000)) : 30000; // cap at 5min
746
+ const timeout = Number.isFinite(rawTimeout)
747
+ ? Math.max(100, Math.min(rawTimeout, 300_000))
748
+ : 30000; // cap at 5min
588
749
  const interval = 200;
589
750
  const deadline = Date.now() + timeout;
590
751
  while (Date.now() < deadline) {
591
752
  const result = await browser.evaluateJs(client, sessionId, expr);
592
- if (result) { output.printSuccess('Wait function returned truthy'); return { success: true }; }
753
+ if (result) {
754
+ output.printSuccess('Wait function returned truthy');
755
+ return { success: true };
756
+ }
593
757
  await new Promise((r) => setTimeout(r, interval));
594
758
  }
595
759
  throw new Error(`Timeout waiting for --fn: ${expr}`);
@@ -601,8 +765,15 @@ const waitCommand: Command = {
601
765
  const interval = 200;
602
766
  const deadline = Date.now() + timeout;
603
767
  while (Date.now() < deadline) {
604
- const text = await browser.evaluateJs(client, sessionId, 'document.body?.innerText ?? ""') as string;
605
- if (!text.includes(target)) { output.printSuccess('Text disappeared'); return { success: true }; }
768
+ const text = (await browser.evaluateJs(
769
+ client,
770
+ sessionId,
771
+ 'document.body?.innerText ?? ""',
772
+ )) as string;
773
+ if (!text.includes(target)) {
774
+ output.printSuccess('Text disappeared');
775
+ return { success: true };
776
+ }
606
777
  await new Promise((r) => setTimeout(r, interval));
607
778
  }
608
779
  throw new Error(`Timeout waiting for text to disappear: "${target}"`);
@@ -610,28 +781,52 @@ const waitCommand: Command = {
610
781
 
611
782
  if (ctx.flags.download) {
612
783
  const savePath = ctx.flags.download as string;
613
- const { mkdir } = await import('fs/promises');
614
- const { dirname, join } = await import('path');
615
- const { tmpdir } = await import('os');
784
+ const { mkdir } = await import('node:fs/promises');
785
+ const { dirname, join } = await import('node:path');
786
+ const { tmpdir } = await import('node:os');
616
787
  const downloadDir = join(tmpdir(), `mm-dl-wait-${Date.now()}`);
617
788
  await mkdir(downloadDir, { recursive: true });
618
- await client.send('Browser.setDownloadBehavior', {
619
- behavior: 'allow', downloadPath: downloadDir, eventsEnabled: true,
620
- }, undefined).catch(() =>
621
- client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadDir }, sessionId).catch(() => {})
622
- );
789
+ await client
790
+ .send(
791
+ 'Browser.setDownloadBehavior',
792
+ {
793
+ behavior: 'allow',
794
+ downloadPath: downloadDir,
795
+ eventsEnabled: true,
796
+ },
797
+ undefined,
798
+ )
799
+ .catch(() =>
800
+ client
801
+ .send(
802
+ 'Page.setDownloadBehavior',
803
+ { behavior: 'allow', downloadPath: downloadDir },
804
+ sessionId,
805
+ )
806
+ .catch(() => {}),
807
+ );
623
808
  const MAX_DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // I6: cap at 5 minutes
624
809
  const rawTimeout = Math.min((ctx.flags.timeout as number) ?? 30000, MAX_DOWNLOAD_TIMEOUT);
625
810
  const finalPath = await new Promise<string>((resolve, reject) => {
626
811
  let guid = '';
627
812
  let settled = false;
628
813
  // C2: capture off() functions to avoid listener leaks
629
- const offBegin = client.on('Browser.downloadWillBegin', (params: Record<string, unknown>) => { guid = params.guid as string; });
814
+ const offBegin = client.on(
815
+ 'Browser.downloadWillBegin',
816
+ (params: Record<string, unknown>) => {
817
+ guid = params.guid as string;
818
+ },
819
+ );
630
820
  let offProgress: (() => void) | undefined;
631
821
  // cleanup defined before setTimeout so the timeout callback can call it
632
822
  let tid: ReturnType<typeof setTimeout>;
633
823
  let pollTid: ReturnType<typeof setInterval> | undefined;
634
- const cleanup = () => { clearTimeout(tid); clearInterval(pollTid); offBegin?.(); offProgress?.(); };
824
+ const cleanup = () => {
825
+ clearTimeout(tid);
826
+ clearInterval(pollTid);
827
+ offBegin?.();
828
+ offProgress?.();
829
+ };
635
830
  const finish = (path: string) => {
636
831
  if (settled) return;
637
832
  settled = true;
@@ -645,24 +840,27 @@ const waitCommand: Command = {
645
840
  reject(err);
646
841
  };
647
842
  tid = setTimeout(() => fail(new Error('Download timed out')), rawTimeout);
648
- offProgress = client.on('Browser.downloadProgress', async (params: Record<string, unknown>) => {
649
- if (params.guid === guid && params.state === 'completed') {
650
- const { readdir, rename, rmdir } = await import('fs/promises');
651
- const files = await readdir(downloadDir);
652
- if (files.length > 0) {
653
- const src = join(downloadDir, files[0]);
654
- await mkdir(dirname(savePath), { recursive: true });
655
- await rename(src, savePath);
656
- await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
657
- finish(savePath);
658
- } else {
659
- await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
660
- fail(new Error('Download completed but no file found'));
843
+ offProgress = client.on(
844
+ 'Browser.downloadProgress',
845
+ async (params: Record<string, unknown>) => {
846
+ if (params.guid === guid && params.state === 'completed') {
847
+ const { readdir, rename, rmdir } = await import('node:fs/promises');
848
+ const files = await readdir(downloadDir);
849
+ if (files.length > 0) {
850
+ const src = join(downloadDir, files[0]);
851
+ await mkdir(dirname(savePath), { recursive: true });
852
+ await rename(src, savePath);
853
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
854
+ finish(savePath);
855
+ } else {
856
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
857
+ fail(new Error('Download completed but no file found'));
858
+ }
859
+ } else if (params.guid === guid && params.state === 'canceled') {
860
+ fail(new Error('Download was canceled'));
661
861
  }
662
- } else if (params.guid === guid && params.state === 'canceled') {
663
- fail(new Error('Download was canceled'));
664
- }
665
- });
862
+ },
863
+ );
666
864
 
667
865
  // Fallback for the empty-guid race: this process's listener attaches
668
866
  // AFTER a separate `click` process may have already started (and even
@@ -675,7 +873,7 @@ const waitCommand: Command = {
675
873
  pollTid = setInterval(async () => {
676
874
  if (settled || guid) return; // a real CDP event has taken over
677
875
  try {
678
- const { stat } = await import('fs/promises');
876
+ const { stat } = await import('node:fs/promises');
679
877
  const st = await stat(savePath);
680
878
  if (st.isFile() && st.size > 0 && st.size === lastSize) {
681
879
  stableReads++;
@@ -708,13 +906,25 @@ const waitCommand: Command = {
708
906
 
709
907
  const screenshotCommand: Command = {
710
908
  name: 'screenshot',
711
- description: 'Capture a screenshot. Usage: monomind browse screenshot [path] [--annotate] [--hide-scrollbars]',
909
+ description:
910
+ 'Capture a screenshot. Usage: monomind browse screenshot [path] [--annotate] [--hide-scrollbars]',
712
911
  options: [
713
912
  { name: 'full', type: 'boolean', description: 'Full page screenshot', default: false },
714
913
  { name: 'format', type: 'string', description: 'Format: png|jpeg|webp', default: 'png' },
715
914
  { name: 'quality', type: 'number', description: 'Quality 0-100 for jpeg/webp', default: 80 },
716
- { name: 'annotate', type: 'boolean', description: 'Overlay numbered labels keyed to @eN refs from last snapshot (viewport-only; do not combine with --full)', default: false },
717
- { name: 'hide-scrollbars', type: 'boolean', description: 'Hide native scrollbars via CSS injection before capture', default: false },
915
+ {
916
+ name: 'annotate',
917
+ type: 'boolean',
918
+ description:
919
+ 'Overlay numbered labels keyed to @eN refs from last snapshot (viewport-only; do not combine with --full)',
920
+ default: false,
921
+ },
922
+ {
923
+ name: 'hide-scrollbars',
924
+ type: 'boolean',
925
+ description: 'Hide native scrollbars via CSS injection before capture',
926
+ default: false,
927
+ },
718
928
  { name: 'json', type: 'boolean', description: 'Output JSON with path', default: false },
719
929
  ],
720
930
  action: async (ctx: CommandContext): Promise<CommandResult> => {
@@ -723,10 +933,16 @@ const screenshotCommand: Command = {
723
933
 
724
934
  const hideScrollbars = ctx.flags['hide-scrollbars'] as boolean;
725
935
  if (hideScrollbars) {
726
- await client.send('Runtime.evaluate', {
727
- expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)return;var el=document.createElement('style');el.id='__mm_noscroll__';el.textContent='*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important;-ms-overflow-style:none!important}';document.head.appendChild(el);})()`,
728
- returnByValue: false,
729
- }, sessionId).catch(() => {});
936
+ await client
937
+ .send(
938
+ 'Runtime.evaluate',
939
+ {
940
+ expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)return;var el=document.createElement('style');el.id='__mm_noscroll__';el.textContent='*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important;-ms-overflow-style:none!important}';document.head.appendChild(el);})()`,
941
+ returnByValue: false,
942
+ },
943
+ sessionId,
944
+ )
945
+ .catch(() => {});
730
946
  }
731
947
 
732
948
  const annotate = ctx.flags.annotate as boolean;
@@ -743,10 +959,16 @@ const screenshotCommand: Command = {
743
959
  });
744
960
  } finally {
745
961
  if (hideScrollbars) {
746
- await client.send('Runtime.evaluate', {
747
- expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)s.remove();})()`,
748
- returnByValue: false,
749
- }, sessionId).catch(() => {});
962
+ await client
963
+ .send(
964
+ 'Runtime.evaluate',
965
+ {
966
+ expression: `(function(){var s=document.getElementById('__mm_noscroll__');if(s)s.remove();})()`,
967
+ returnByValue: false,
968
+ },
969
+ sessionId,
970
+ )
971
+ .catch(() => {});
750
972
  }
751
973
  }
752
974
 
@@ -762,16 +984,16 @@ const screenshotCommand: Command = {
762
984
 
763
985
  const getCommand: Command = {
764
986
  name: 'get',
765
- description: 'Get page info. Usage: monomind browse get url|title|text|html|value|attr|count|box|styles [@ref] [attrName]',
766
- options: [
767
- { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
768
- ],
987
+ description:
988
+ 'Get page info. Usage: monomind browse get url|title|text|html|value|attr|count|box|styles [@ref] [attrName]',
989
+ options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
769
990
  action: async (ctx: CommandContext): Promise<CommandResult> => {
770
991
  const { client, sessionId } = await ensureConnected(_port);
771
992
  const browser = await getBrowser();
772
993
 
773
994
  const what = ctx.args[0] as string;
774
- if (!what) throw new Error('Usage: monomind browse get url|title|text|html|value|attr|count|box|styles');
995
+ if (!what)
996
+ throw new Error('Usage: monomind browse get url|title|text|html|value|attr|count|box|styles');
775
997
 
776
998
  let value: unknown;
777
999
 
@@ -790,19 +1012,32 @@ const getCommand: Command = {
790
1012
  if (!ref) throw new Error(`Ref @${refKey} not found`);
791
1013
  const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
792
1014
  if (!objectId) throw new Error('Element not in DOM');
793
- const result = await client.send<{ result: { value?: string } }>('Runtime.callFunctionOn', {
794
- functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
795
- objectId,
796
- returnByValue: true,
797
- }, sessionId);
1015
+ const result = await client.send<{ result: { value?: string } }>(
1016
+ 'Runtime.callFunctionOn',
1017
+ {
1018
+ functionDeclaration:
1019
+ 'function() { return this.innerText || this.textContent || ""; }',
1020
+ objectId,
1021
+ returnByValue: true,
1022
+ },
1023
+ sessionId,
1024
+ );
798
1025
  value = result.result?.value ?? '';
799
1026
  } else {
800
- value = (await browser.evaluateJs(client, sessionId, 'document.body?.innerText ?? ""')) as string;
1027
+ value = (await browser.evaluateJs(
1028
+ client,
1029
+ sessionId,
1030
+ 'document.body?.innerText ?? ""',
1031
+ )) as string;
801
1032
  }
802
1033
  break;
803
1034
  }
804
1035
  case 'html':
805
- value = (await browser.evaluateJs(client, sessionId, 'document.documentElement.outerHTML')) as string;
1036
+ value = (await browser.evaluateJs(
1037
+ client,
1038
+ sessionId,
1039
+ 'document.documentElement.outerHTML',
1040
+ )) as string;
806
1041
  break;
807
1042
  case 'value': {
808
1043
  const refArg = ctx.args[1] as string;
@@ -812,33 +1047,48 @@ const getCommand: Command = {
812
1047
  if (!ref) throw new Error(`Ref @${refKey} not found`);
813
1048
  const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
814
1049
  if (!objectId) throw new Error('Element not in DOM');
815
- const r = await client.send<{ result: { value?: string } }>('Runtime.callFunctionOn', {
816
- functionDeclaration: 'function() { return this.value ?? null; }',
817
- objectId, returnByValue: true,
818
- }, sessionId);
1050
+ const r = await client.send<{ result: { value?: string } }>(
1051
+ 'Runtime.callFunctionOn',
1052
+ {
1053
+ functionDeclaration: 'function() { return this.value ?? null; }',
1054
+ objectId,
1055
+ returnByValue: true,
1056
+ },
1057
+ sessionId,
1058
+ );
819
1059
  value = r.result?.value ?? null;
820
1060
  break;
821
1061
  }
822
1062
  case 'attr': {
823
1063
  const refArg = ctx.args[1] as string;
824
1064
  const attrName = ctx.args[2] as string;
825
- if (!refArg || !attrName) throw new Error('Usage: monomind browse get attr @ref <attrName>');
1065
+ if (!refArg || !attrName)
1066
+ throw new Error('Usage: monomind browse get attr @ref <attrName>');
826
1067
  const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
827
1068
  const ref = _refs.get(refKey);
828
1069
  if (!ref) throw new Error(`Ref @${refKey} not found`);
829
1070
  const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
830
1071
  if (!objectId) throw new Error('Element not in DOM');
831
- const r = await client.send<{ result: { value?: string } }>('Runtime.callFunctionOn', {
832
- functionDeclaration: `function() { return this.getAttribute(${JSON.stringify(attrName)}); }`,
833
- objectId, returnByValue: true,
834
- }, sessionId);
1072
+ const r = await client.send<{ result: { value?: string } }>(
1073
+ 'Runtime.callFunctionOn',
1074
+ {
1075
+ functionDeclaration: `function() { return this.getAttribute(${JSON.stringify(attrName)}); }`,
1076
+ objectId,
1077
+ returnByValue: true,
1078
+ },
1079
+ sessionId,
1080
+ );
835
1081
  value = r.result?.value ?? null;
836
1082
  break;
837
1083
  }
838
1084
  case 'count': {
839
1085
  const selector = ctx.args[1] as string;
840
1086
  if (!selector) throw new Error('Usage: monomind browse get count <cssSelector>');
841
- value = await browser.evaluateJs(client, sessionId, `document.querySelectorAll(${JSON.stringify(selector)}).length`);
1087
+ value = await browser.evaluateJs(
1088
+ client,
1089
+ sessionId,
1090
+ `document.querySelectorAll(${JSON.stringify(selector)}).length`,
1091
+ );
842
1092
  break;
843
1093
  }
844
1094
  case 'box': {
@@ -859,11 +1109,21 @@ const getCommand: Command = {
859
1109
  if (!ref) throw new Error(`Ref @${refKey} not found`);
860
1110
  const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
861
1111
  if (!objectId) throw new Error('Element not in DOM');
862
- const r = await client.send<{ result: { value?: string } }>('Runtime.callFunctionOn', {
863
- functionDeclaration: 'function() { const s = window.getComputedStyle(this); return JSON.stringify(Object.fromEntries([...s].map(k => [k, s.getPropertyValue(k)]))); }',
864
- objectId, returnByValue: true,
865
- }, sessionId);
866
- try { value = JSON.parse(r.result?.value ?? '{}'); } catch { value = {}; }
1112
+ const r = await client.send<{ result: { value?: string } }>(
1113
+ 'Runtime.callFunctionOn',
1114
+ {
1115
+ functionDeclaration:
1116
+ 'function() { const s = window.getComputedStyle(this); return JSON.stringify(Object.fromEntries([...s].map(k => [k, s.getPropertyValue(k)]))); }',
1117
+ objectId,
1118
+ returnByValue: true,
1119
+ },
1120
+ sessionId,
1121
+ );
1122
+ try {
1123
+ value = JSON.parse(r.result?.value ?? '{}');
1124
+ } catch {
1125
+ value = {};
1126
+ }
867
1127
  break;
868
1128
  }
869
1129
  default:
@@ -882,11 +1142,17 @@ const getCommand: Command = {
882
1142
 
883
1143
  const scrollCommand: Command = {
884
1144
  name: 'scroll',
885
- description: 'Scroll the page. Usage: monomind browse scroll up|down|left|right [amount] [--selector ".sidebar"]',
1145
+ description:
1146
+ 'Scroll the page. Usage: monomind browse scroll up|down|left|right [amount] [--selector ".sidebar"]',
886
1147
  options: [
887
1148
  { name: 'amount', short: 'a', type: 'number', description: 'Pixels to scroll', default: 300 },
888
1149
  { name: 'ref', type: 'string', description: 'Element ref to scroll within' },
889
- { name: 'selector', short: 's', type: 'string', description: 'CSS selector of element to scroll within' },
1150
+ {
1151
+ name: 'selector',
1152
+ short: 's',
1153
+ type: 'string',
1154
+ description: 'CSS selector of element to scroll within',
1155
+ },
890
1156
  ],
891
1157
  action: async (ctx: CommandContext): Promise<CommandResult> => {
892
1158
  const { client, sessionId } = await ensureConnected(_port);
@@ -896,18 +1162,29 @@ const scrollCommand: Command = {
896
1162
  if (!direction) throw new Error('Usage: monomind browse scroll up|down|left|right [amount]');
897
1163
 
898
1164
  // Support positional amount: scroll down 300
899
- const positionalAmount = ctx.args[1] !== undefined ? parseInt(ctx.args[1] as string, 10) : undefined;
900
- const amount = (positionalAmount && Number.isFinite(positionalAmount)) ? positionalAmount : (ctx.flags.amount as number) ?? 300;
1165
+ const positionalAmount =
1166
+ ctx.args[1] !== undefined ? parseInt(ctx.args[1] as string, 10) : undefined;
1167
+ const amount =
1168
+ positionalAmount && Number.isFinite(positionalAmount)
1169
+ ? positionalAmount
1170
+ : ((ctx.flags.amount as number) ?? 300);
901
1171
 
902
1172
  if (ctx.flags.selector) {
903
1173
  const sel = ctx.flags.selector as string;
904
1174
  const dx = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
905
1175
  const dy = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
906
- const posJson = await browser.evaluateJs(client, sessionId,
907
- `(function(){var el=document.querySelector(${JSON.stringify(sel)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`) as string | null;
1176
+ const posJson = (await browser.evaluateJs(
1177
+ client,
1178
+ sessionId,
1179
+ `(function(){var el=document.querySelector(${JSON.stringify(sel)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`,
1180
+ )) as string | null;
908
1181
  if (!posJson) throw new Error(`Selector not found: ${sel}`);
909
1182
  const pos = JSON.parse(posJson) as { x: number; y: number };
910
- await client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: pos.x, y: pos.y, deltaX: dx, deltaY: dy }, sessionId);
1183
+ await client.send(
1184
+ 'Input.dispatchMouseEvent',
1185
+ { type: 'mouseWheel', x: pos.x, y: pos.y, deltaX: dx, deltaY: dy },
1186
+ sessionId,
1187
+ );
911
1188
  output.printSuccess(`Scrolled ${direction} in ${sel}`);
912
1189
  return { success: true };
913
1190
  }
@@ -916,7 +1193,7 @@ const scrollCommand: Command = {
916
1193
  if (ctx.flags.ref) {
917
1194
  const refKey = (ctx.flags.ref as string).startsWith('@')
918
1195
  ? (ctx.flags.ref as string).slice(1)
919
- : ctx.flags.ref as string;
1196
+ : (ctx.flags.ref as string);
920
1197
  ref = _refs.get(refKey);
921
1198
  }
922
1199
 
@@ -942,15 +1219,26 @@ const navigateCommand: Command = {
942
1219
  let offFrameStarted: () => void = () => {};
943
1220
  const frameStartedPromise = new Promise<void>((resolve) => {
944
1221
  offFrameStarted = client.on('Page.frameStartedLoading', (_params, sid) => {
945
- if (sid === sessionId) { const off = offFrameStarted; offFrameStarted = () => {}; off(); resolve(); }
1222
+ if (sid === sessionId) {
1223
+ const off = offFrameStarted;
1224
+ offFrameStarted = () => {};
1225
+ off();
1226
+ resolve();
1227
+ }
946
1228
  });
947
1229
  });
948
1230
  try {
949
- await client.send('Runtime.evaluate', {
950
- expression: direction === 'back' ? 'history.back()' : 'history.forward()',
951
- }, sessionId);
1231
+ await client.send(
1232
+ 'Runtime.evaluate',
1233
+ {
1234
+ expression: direction === 'back' ? 'history.back()' : 'history.forward()',
1235
+ },
1236
+ sessionId,
1237
+ );
952
1238
  let fallbackHandle: ReturnType<typeof setTimeout> | undefined;
953
- const fallbackPromise = new Promise<void>((r) => { fallbackHandle = setTimeout(r, 2000); });
1239
+ const fallbackPromise = new Promise<void>((r) => {
1240
+ fallbackHandle = setTimeout(r, 2000);
1241
+ });
954
1242
  await Promise.race([frameStartedPromise, fallbackPromise]);
955
1243
  if (fallbackHandle !== undefined) clearTimeout(fallbackHandle);
956
1244
  } finally {
@@ -975,29 +1263,44 @@ const navigateCommand: Command = {
975
1263
 
976
1264
  const setCommand: Command = {
977
1265
  name: 'set',
978
- description: 'Configure browser settings. Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
1266
+ description:
1267
+ 'Configure browser settings. Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
979
1268
  action: async (ctx: CommandContext): Promise<CommandResult> => {
980
1269
  const { client, sessionId } = await ensureConnected(_port);
981
1270
  const browser = await getBrowser();
982
1271
 
983
1272
  const setting = ctx.args[0] as string;
984
- if (!setting) throw new Error('Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>');
1273
+ if (!setting)
1274
+ throw new Error(
1275
+ 'Usage: monomind browse set viewport|device|geo|offline|media|credentials|useragent <args>',
1276
+ );
985
1277
 
986
1278
  switch (setting) {
987
1279
  case 'viewport': {
988
1280
  const width = parseInt(ctx.args[1] as string, 10);
989
1281
  const height = parseInt(ctx.args[2] as string, 10);
990
1282
  const dpr = parseFloat(ctx.args[3] as string) || undefined;
991
- if (isNaN(width) || isNaN(height)) throw new Error('Usage: set viewport <width> <height> [dpr]');
992
- await client.send('Emulation.setDeviceMetricsOverride', {
993
- width, height, deviceScaleFactor: dpr ?? 1, mobile: false,
994
- }, sessionId);
1283
+ if (Number.isNaN(width) || Number.isNaN(height))
1284
+ throw new Error('Usage: set viewport <width> <height> [dpr]');
1285
+ await client.send(
1286
+ 'Emulation.setDeviceMetricsOverride',
1287
+ {
1288
+ width,
1289
+ height,
1290
+ deviceScaleFactor: dpr ?? 1,
1291
+ mobile: false,
1292
+ },
1293
+ sessionId,
1294
+ );
995
1295
  output.printSuccess(`Viewport set to ${width}x${height}${dpr ? ` @${dpr}x` : ''}`);
996
1296
  break;
997
1297
  }
998
1298
  case 'device': {
999
1299
  const deviceName = ctx.args[1] as string;
1000
- if (!deviceName) throw new Error(`Usage: set device <name>. Available: ${browser.listDevices().join(', ')}`);
1300
+ if (!deviceName)
1301
+ throw new Error(
1302
+ `Usage: set device <name>. Available: ${browser.listDevices().join(', ')}`,
1303
+ );
1001
1304
  await browser.emulateDevice(client, sessionId, deviceName);
1002
1305
  output.printSuccess(`Emulating device: ${deviceName}`);
1003
1306
  break;
@@ -1006,7 +1309,8 @@ const setCommand: Command = {
1006
1309
  const lat = parseFloat(ctx.args[1] as string);
1007
1310
  const lon = parseFloat(ctx.args[2] as string);
1008
1311
  const acc = parseFloat(ctx.args[3] as string) || 100;
1009
- if (isNaN(lat) || isNaN(lon)) throw new Error('Usage: set geo <latitude> <longitude> [accuracy]');
1312
+ if (Number.isNaN(lat) || Number.isNaN(lon))
1313
+ throw new Error('Usage: set geo <latitude> <longitude> [accuracy]');
1010
1314
  await browser.setGeolocation(client, sessionId, lat, lon, acc);
1011
1315
  output.printSuccess(`Geolocation set: ${lat}, ${lon}`);
1012
1316
  break;
@@ -1042,7 +1346,9 @@ const setCommand: Command = {
1042
1346
  break;
1043
1347
  }
1044
1348
  default:
1045
- throw new Error(`Unknown setting: ${setting}. Use: viewport|device|geo|offline|media|credentials|useragent`);
1349
+ throw new Error(
1350
+ `Unknown setting: ${setting}. Use: viewport|device|geo|offline|media|credentials|useragent`,
1351
+ );
1046
1352
  }
1047
1353
 
1048
1354
  return { success: true };
@@ -1051,9 +1357,14 @@ const setCommand: Command = {
1051
1357
 
1052
1358
  const stateCommand: Command = {
1053
1359
  name: 'state',
1054
- description: 'Manage browser session state. Usage: monomind browse state save|load|list|rename|clean [name]',
1360
+ description:
1361
+ 'Manage browser session state. Usage: monomind browse state save|load|list|rename|clean [name]',
1055
1362
  options: [
1056
- { name: 'older-than', type: 'number', description: 'For state clean: remove sessions older than N days' },
1363
+ {
1364
+ name: 'older-than',
1365
+ type: 'number',
1366
+ description: 'For state clean: remove sessions older than N days',
1367
+ },
1057
1368
  ],
1058
1369
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1059
1370
  const browser = await getBrowser();
@@ -1104,7 +1415,13 @@ const stateCommand: Command = {
1104
1415
  const title = await browser.getCurrentTitle(c, sid);
1105
1416
  const cookies = await browser.getCookies(c, sid);
1106
1417
  const ls = await browser.getAllLocalStorage(c, sid);
1107
- const info = { url, title, cookies: cookies.length, localStorage: Object.keys(ls).length, refs: _refs.size };
1418
+ const info = {
1419
+ url,
1420
+ title,
1421
+ cookies: cookies.length,
1422
+ localStorage: Object.keys(ls).length,
1423
+ refs: _refs.size,
1424
+ };
1108
1425
  print(JSON.stringify(info, null, 2));
1109
1426
  return { success: true, data: info };
1110
1427
  }
@@ -1121,18 +1438,26 @@ const stateCommand: Command = {
1121
1438
  case 'rename': {
1122
1439
  const oldName = ctx.args[1] as string;
1123
1440
  const newName = ctx.args[2] as string;
1124
- if (!oldName || !newName) throw new Error('Usage: monomind browse state rename <old-name> <new-name>');
1441
+ if (!oldName || !newName)
1442
+ throw new Error('Usage: monomind browse state rename <old-name> <new-name>');
1125
1443
  const sessions = await browser.listSessions();
1126
1444
  if (!sessions.includes(oldName)) throw new Error(`Session not found: ${oldName}`);
1127
1445
  // W1: validate newName to prevent path traversal
1128
- const { basename: basenameFn } = await import('path');
1446
+ const { basename: basenameFn } = await import('node:path');
1129
1447
  const safeName = basenameFn(newName);
1130
1448
  if (safeName !== newName || safeName.startsWith('.') || safeName.includes('/')) {
1131
- throw new Error('Invalid session name — must not contain path separators or start with "."');
1449
+ throw new Error(
1450
+ 'Invalid session name — must not contain path separators or start with "."',
1451
+ );
1132
1452
  }
1133
- const { unlink: unlinkRename, readFile, writeFile, mkdir: mkdirRename } = await import('fs/promises');
1134
- const { join: joinR } = await import('path');
1135
- const { homedir } = await import('os');
1453
+ const {
1454
+ unlink: unlinkRename,
1455
+ readFile,
1456
+ writeFile,
1457
+ mkdir: mkdirRename,
1458
+ } = await import('node:fs/promises');
1459
+ const { join: joinR } = await import('node:path');
1460
+ const { homedir } = await import('node:os');
1136
1461
  const sessionDir = joinR(homedir(), '.monomind', 'browser-sessions');
1137
1462
  const oldPath = joinR(sessionDir, `${oldName}.json`);
1138
1463
  const newPath = joinR(sessionDir, `${newName}.json`);
@@ -1146,9 +1471,9 @@ const stateCommand: Command = {
1146
1471
  }
1147
1472
  case 'clean': {
1148
1473
  const days = (ctx.flags['older-than'] as number) ?? 7;
1149
- const { unlink, stat } = await import('fs/promises');
1150
- const { join: joinC } = await import('path');
1151
- const { homedir: homedirC } = await import('os');
1474
+ const { unlink, stat } = await import('node:fs/promises');
1475
+ const { join: joinC } = await import('node:path');
1476
+ const { homedir: homedirC } = await import('node:os');
1152
1477
  const sessionDir = joinC(homedirC(), '.monomind', 'browser-sessions');
1153
1478
  const sessions = await browser.listSessions();
1154
1479
  const cutoff = Date.now() - days * 86400 * 1000;
@@ -1180,30 +1505,52 @@ const networkCommand: Command = {
1180
1505
  { name: 'status', type: 'number', description: 'HTTP status for fulfill', default: 200 },
1181
1506
  { name: 'headers', type: 'string', description: 'JSON headers object' },
1182
1507
  { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
1183
- { name: 'filter', type: 'string', description: 'Filter requests by URL substring (for network requests)' },
1184
- { name: 'method', type: 'string', description: 'Filter by HTTP method, e.g. GET, POST (for network requests)' },
1185
- { name: 'status-code', type: 'number', description: 'Filter by HTTP status code (for network requests)' },
1186
- { name: 'type', type: 'string', description: 'Filter by resource type: xhr|fetch|document|script|stylesheet|image (for network requests)' },
1508
+ {
1509
+ name: 'filter',
1510
+ type: 'string',
1511
+ description: 'Filter requests by URL substring (for network requests)',
1512
+ },
1513
+ {
1514
+ name: 'method',
1515
+ type: 'string',
1516
+ description: 'Filter by HTTP method, e.g. GET, POST (for network requests)',
1517
+ },
1518
+ {
1519
+ name: 'status-code',
1520
+ type: 'number',
1521
+ description: 'Filter by HTTP status code (for network requests)',
1522
+ },
1523
+ {
1524
+ name: 'type',
1525
+ type: 'string',
1526
+ description:
1527
+ 'Filter by resource type: xhr|fetch|document|script|stylesheet|image (for network requests)',
1528
+ },
1187
1529
  ],
1188
1530
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1189
1531
  const { client, sessionId } = await ensureConnected(_port);
1190
1532
  const browser = await getBrowser();
1191
1533
  const action = ctx.args[0] as string;
1192
- if (!action) throw new Error('Usage: monomind browse network route|unroute|cookies|headers|requests');
1534
+ if (!action)
1535
+ throw new Error('Usage: monomind browse network route|unroute|cookies|headers|requests');
1193
1536
 
1194
1537
  switch (action) {
1195
1538
  case 'route': {
1196
1539
  const pattern = ctx.flags.pattern as string;
1197
1540
  if (!pattern) throw new Error('--pattern required for network route');
1198
- const routes: NetworkRoute[] = [{
1199
- pattern,
1200
- action: ctx.flags.abort ? 'abort' : ctx.flags.fulfill ? 'fulfill' : 'continue',
1201
- response: ctx.flags.fulfill ? {
1202
- status: ctx.flags.status as number,
1203
- body: ctx.flags.fulfill as string,
1204
- headers: ctx.flags.headers ? JSON.parse(ctx.flags.headers as string) : {},
1205
- } : undefined,
1206
- }];
1541
+ const routes: NetworkRoute[] = [
1542
+ {
1543
+ pattern,
1544
+ action: ctx.flags.abort ? 'abort' : ctx.flags.fulfill ? 'fulfill' : 'continue',
1545
+ response: ctx.flags.fulfill
1546
+ ? {
1547
+ status: ctx.flags.status as number,
1548
+ body: ctx.flags.fulfill as string,
1549
+ headers: ctx.flags.headers ? JSON.parse(ctx.flags.headers as string) : {},
1550
+ }
1551
+ : undefined,
1552
+ },
1553
+ ];
1207
1554
  await browser.setupRoutes(client, sessionId, routes);
1208
1555
  output.printSuccess(`Network route set: ${pattern}`);
1209
1556
  break;
@@ -1225,7 +1572,7 @@ const networkCommand: Command = {
1225
1572
  break;
1226
1573
  }
1227
1574
  case 'capture': {
1228
- const subAction = ctx.args[1] as string ?? 'start';
1575
+ const subAction = (ctx.args[1] as string) ?? 'start';
1229
1576
  if (subAction === 'start') {
1230
1577
  browser.startRequestCapture(client, sessionId);
1231
1578
  output.printSuccess('Request capture started');
@@ -1245,13 +1592,22 @@ const networkCommand: Command = {
1245
1592
  const filterStatus = ctx.flags['status-code'] as number | undefined;
1246
1593
  const filterType = ctx.flags.type as string | undefined;
1247
1594
  if (filterUrl) reqs = reqs.filter((r) => r.url.includes(filterUrl));
1248
- if (filterMethod) reqs = reqs.filter((r) => (r.method ?? 'GET').toUpperCase() === filterMethod.toUpperCase());
1595
+ if (filterMethod)
1596
+ reqs = reqs.filter(
1597
+ (r) => (r.method ?? 'GET').toUpperCase() === filterMethod.toUpperCase(),
1598
+ );
1249
1599
  if (filterStatus) reqs = reqs.filter((r) => r.status === filterStatus);
1250
- if (filterType) reqs = reqs.filter((r) => (r as Record<string, unknown>).resourceType === filterType || (r as Record<string, unknown>).type === filterType);
1600
+ if (filterType)
1601
+ reqs = reqs.filter(
1602
+ (r) =>
1603
+ (r as Record<string, unknown>).resourceType === filterType ||
1604
+ (r as Record<string, unknown>).type === filterType,
1605
+ );
1251
1606
  if (ctx.flags.json) print(JSON.stringify({ data: reqs }));
1252
1607
  else {
1253
- if (reqs.length === 0) { output.printInfo('No captured requests. Run: network capture start'); }
1254
- else for (const r of reqs) print(` ${r.method ?? 'GET'} ${r.status ?? '-'} ${r.url}`);
1608
+ if (reqs.length === 0) {
1609
+ output.printInfo('No captured requests. Run: network capture start');
1610
+ } else for (const r of reqs) print(` ${r.method ?? 'GET'} ${r.status ?? '-'} ${r.url}`);
1255
1611
  }
1256
1612
  return { success: true, data: { requests: reqs } };
1257
1613
  }
@@ -1259,14 +1615,23 @@ const networkCommand: Command = {
1259
1615
  const reqId = ctx.args[1] as string;
1260
1616
  if (!reqId) throw new Error('Usage: monomind browse network request <requestId>');
1261
1617
  const reqs = browser.getCapturedRequests(sessionId);
1262
- const req = reqs.find((r) => (r as Record<string, unknown>).requestId === reqId || (r as Record<string, unknown>).id === reqId);
1263
- if (!req) { output.printWarning(`Request not found: ${reqId}`); return { success: false }; }
1618
+ const req = reqs.find(
1619
+ (r) =>
1620
+ (r as Record<string, unknown>).requestId === reqId ||
1621
+ (r as Record<string, unknown>).id === reqId,
1622
+ );
1623
+ if (!req) {
1624
+ output.printWarning(`Request not found: ${reqId}`);
1625
+ return { success: false };
1626
+ }
1264
1627
  if (ctx.flags.json) print(JSON.stringify({ data: req }));
1265
1628
  else print(JSON.stringify(req, null, 2));
1266
1629
  return { success: true, data: { request: req } };
1267
1630
  }
1268
1631
  default:
1269
- throw new Error(`Unknown: ${action}. Use: route|unroute|cookies|headers|capture|requests|request`);
1632
+ throw new Error(
1633
+ `Unknown: ${action}. Use: route|unroute|cookies|headers|capture|requests|request`,
1634
+ );
1270
1635
  }
1271
1636
 
1272
1637
  return { success: true };
@@ -1282,7 +1647,7 @@ const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
1282
1647
 
1283
1648
  function truncateForOutput(text: string, maxOutput: number): string {
1284
1649
  if (!(maxOutput > 0) || text.length <= maxOutput) return text;
1285
- return text.slice(0, maxOutput) + `\n[... truncated at ${maxOutput} chars]`;
1650
+ return `${text.slice(0, maxOutput)}\n[... truncated at ${maxOutput} chars]`;
1286
1651
  }
1287
1652
 
1288
1653
  const evalCommand: Command = {
@@ -1290,9 +1655,22 @@ const evalCommand: Command = {
1290
1655
  description: 'Evaluate JavaScript in page context. Usage: monomind browse eval "document.title"',
1291
1656
  options: [
1292
1657
  { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
1293
- { name: 'stdin', type: 'boolean', description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)', default: false },
1294
- { name: 'max-output', type: 'number', description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)` },
1295
- { name: 'timeout', type: 'number', description: 'Max ms to wait for evaluation to settle (default 30000)' },
1658
+ {
1659
+ name: 'stdin',
1660
+ type: 'boolean',
1661
+ description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)',
1662
+ default: false,
1663
+ },
1664
+ {
1665
+ name: 'max-output',
1666
+ type: 'number',
1667
+ description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)`,
1668
+ },
1669
+ {
1670
+ name: 'timeout',
1671
+ type: 'number',
1672
+ description: 'Max ms to wait for evaluation to settle (default 30000)',
1673
+ },
1296
1674
  ],
1297
1675
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1298
1676
  const { client, sessionId } = await ensureConnected(_port);
@@ -1320,6 +1698,272 @@ const evalCommand: Command = {
1320
1698
  },
1321
1699
  };
1322
1700
 
1701
+ const reportCommand: Command = {
1702
+ name: 'report',
1703
+ description:
1704
+ 'Test a page and write one self-contained HTML report + sibling JSON. Usage: monomind browse report <url>',
1705
+ options: [
1706
+ { name: 'out', short: 'o', type: 'string', description: 'Output .html path, or a directory' },
1707
+ {
1708
+ name: 'budget',
1709
+ short: 'b',
1710
+ type: 'string',
1711
+ description: 'Budget JSON file path, or inline JSON',
1712
+ },
1713
+ {
1714
+ name: 'devices',
1715
+ short: 'd',
1716
+ type: 'string',
1717
+ description: 'Comma-separated device names for the screenshot matrix',
1718
+ },
1719
+ {
1720
+ name: 'wait',
1721
+ short: 'w',
1722
+ type: 'string',
1723
+ description: 'Extra settle step: a CSS selector to wait for, or milliseconds',
1724
+ },
1725
+ { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
1726
+ {
1727
+ name: 'full-page',
1728
+ type: 'boolean',
1729
+ description: 'Full-page screenshot (--no-full-page for viewport only)',
1730
+ default: true,
1731
+ },
1732
+ {
1733
+ name: 'vitals-wait',
1734
+ type: 'number',
1735
+ description: 'Milliseconds to let the web-vitals observers run',
1736
+ default: 2500,
1737
+ },
1738
+ {
1739
+ name: 'timeout',
1740
+ type: 'number',
1741
+ description: 'Milliseconds to wait for the page to go network-idle',
1742
+ default: 20000,
1743
+ },
1744
+ {
1745
+ name: 'keep-open',
1746
+ type: 'boolean',
1747
+ description: 'Leave the browser running after the report',
1748
+ default: false,
1749
+ },
1750
+ {
1751
+ name: 'repeat',
1752
+ type: 'number',
1753
+ description: 'Run the URL N times and report per-check flake rates (RIG-14)',
1754
+ },
1755
+ {
1756
+ name: 'record',
1757
+ type: 'boolean',
1758
+ description: 'Record frames for the evidence timeline even when the run passes',
1759
+ default: false,
1760
+ },
1761
+ {
1762
+ name: 'history',
1763
+ type: 'boolean',
1764
+ description: 'Print the stored run history for this URL instead of running it',
1765
+ default: false,
1766
+ },
1767
+ {
1768
+ name: 'save',
1769
+ type: 'boolean',
1770
+ description: 'Save this run to the history store (--no-save to skip)',
1771
+ default: true,
1772
+ },
1773
+ {
1774
+ name: 'history-max',
1775
+ type: 'number',
1776
+ description: 'Runs kept per URL before the oldest are pruned (default 20)',
1777
+ },
1778
+ {
1779
+ name: 'trend-window',
1780
+ type: 'number',
1781
+ description: 'Prior runs charted in the trend section (default 10)',
1782
+ },
1783
+ ],
1784
+ examples: [
1785
+ { command: 'monomind browse report https://example.com', description: 'Report with defaults' },
1786
+ {
1787
+ command: 'monomind browse report https://example.com --repeat 5',
1788
+ description: 'Run five times and report flake rates',
1789
+ },
1790
+ {
1791
+ command: 'monomind browse report https://example.com --history',
1792
+ description: 'Show the stored run history for a URL',
1793
+ },
1794
+ {
1795
+ command: 'monomind browse report https://example.com --record',
1796
+ description: 'Attach a frame-by-frame evidence timeline',
1797
+ },
1798
+ {
1799
+ command: 'monomind browse report https://example.com --out ./reports/home.html',
1800
+ description: 'Choose the output path',
1801
+ },
1802
+ {
1803
+ command: 'monomind browse report https://example.com --budget \'{"lcp":4000}\'',
1804
+ description: 'Relax a budget inline',
1805
+ },
1806
+ {
1807
+ command: 'monomind browse report https://example.com --devices "iPhone 14,iPad"',
1808
+ description: 'Add a device screenshot matrix',
1809
+ },
1810
+ ],
1811
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
1812
+ const url = ctx.args[0] as string;
1813
+ if (!url) throw new Error('URL required. Usage: monomind browse report <url>');
1814
+
1815
+ // Imported lazily for the same reason getBrowser() is: this module loads
1816
+ // on every CLI invocation, and nothing but `report` needs the renderer.
1817
+ const { runReport, runReportRepeated, readHistory } = await import('../report/index.js');
1818
+
1819
+ // --history is a read of the store, so it deliberately never launches a
1820
+ // browser — `report --history` must work offline and on a dead site.
1821
+ if (ctx.flags.history) {
1822
+ const { dir, runs } = await readHistory(url);
1823
+ if (ctx.flags.json) {
1824
+ print(JSON.stringify({ data: { dir, runs } }, null, 2));
1825
+ } else if (!runs.length) {
1826
+ output.printWarning(`No stored runs for ${url}`);
1827
+ print(`Looked in: ${dir}`);
1828
+ } else {
1829
+ print(`${runs.length} run(s) for ${url}`);
1830
+ print(`Stored in: ${dir}`);
1831
+ for (const run of runs) {
1832
+ const lcp = run.vitals.lcp === undefined ? '—' : `${Math.round(run.vitals.lcp)}ms`;
1833
+ print(
1834
+ ` ${run.capturedAt} ${run.verdict.toUpperCase().padEnd(4)} ` +
1835
+ `LCP ${lcp.padStart(7)} ${run.counts.consoleErrors} console errors ` +
1836
+ `${run.counts.failedRequests} failed requests ${run.counts.a11yErrors} a11y errors`,
1837
+ );
1838
+ }
1839
+ }
1840
+ return { success: true, data: { dir, runs } };
1841
+ }
1842
+
1843
+ const repeatRaw = ctx.flags.repeat as number | undefined;
1844
+ const repeat =
1845
+ typeof repeatRaw === 'number' && Number.isFinite(repeatRaw) && repeatRaw > 1
1846
+ ? Math.floor(repeatRaw)
1847
+ : undefined;
1848
+
1849
+ const { client, sessionId } = await ensureConnected(_port);
1850
+ const browser = await getBrowser();
1851
+
1852
+ const rawDevices = ctx.flags.devices;
1853
+ const devices =
1854
+ typeof rawDevices === 'string'
1855
+ ? rawDevices
1856
+ .split(',')
1857
+ .map((d) => d.trim())
1858
+ .filter(Boolean)
1859
+ : undefined;
1860
+
1861
+ const runOptions = {
1862
+ url,
1863
+ out: ctx.flags.out as string | undefined,
1864
+ budget: ctx.flags.budget as string | undefined,
1865
+ devices,
1866
+ wait: ctx.flags.wait as string | undefined,
1867
+ vitalsWaitMs: ctx.flags['vitals-wait'] as number | undefined,
1868
+ loadTimeoutMs: ctx.flags.timeout as number | undefined,
1869
+ fullPage: ctx.flags['full-page'] !== false,
1870
+ cwd: ctx.cwd,
1871
+ // A failing run records its own evidence without being asked; --record
1872
+ // is for when you want the timeline from a run that passes.
1873
+ record: ctx.flags.record === true,
1874
+ history: ctx.flags.save !== false,
1875
+ historyMax: ctx.flags['history-max'] as number | undefined,
1876
+ trendWindow: ctx.flags['trend-window'] as number | undefined,
1877
+ };
1878
+
1879
+ let result:
1880
+ | Awaited<ReturnType<typeof runReport>>
1881
+ | Awaited<ReturnType<typeof runReportRepeated>>;
1882
+ try {
1883
+ result = repeat
1884
+ ? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
1885
+ : await runReport(client, sessionId, runOptions);
1886
+ } finally {
1887
+ // A report is a one-shot command — CI should not be left with an
1888
+ // orphan Chrome. Only close a browser THIS process launched: an
1889
+ // attached one belongs to the user's own `open`/`connect` session.
1890
+ if (!ctx.flags['keep-open'] && browser.getLaunchedPid(_port) !== undefined) {
1891
+ browser.stopRequestCapture(sessionId);
1892
+ browser.teardownConsoleCapture(sessionId);
1893
+ try {
1894
+ await browser.closeBrowser(client, _port);
1895
+ } catch {
1896
+ /* best-effort */
1897
+ }
1898
+ _client = null;
1899
+ _sessionId = '';
1900
+ _targetId = '';
1901
+ _refs = new Map();
1902
+ await browser.clearActivePort();
1903
+ await browser.clearRefCache();
1904
+ }
1905
+ }
1906
+
1907
+ const { report, htmlPath, jsonPath, summary, historyDir } = result;
1908
+ const { flake } = result;
1909
+ // With --repeat the flake verdict governs: a check that failed 2 of 5
1910
+ // runs must not exit 0 just because the last run happened to be green.
1911
+ const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
1912
+
1913
+ if (ctx.flags.json) {
1914
+ const { toJsonReport } = await import('../report/index.js');
1915
+ print(
1916
+ JSON.stringify(
1917
+ { data: { ...toJsonReport(report), flake, htmlPath, jsonPath, historyDir } },
1918
+ null,
1919
+ 2,
1920
+ ),
1921
+ );
1922
+ } else {
1923
+ if (passed) output.printSuccess(summary);
1924
+ else output.printError(summary);
1925
+ if (flake) {
1926
+ print(` ${flake.confidenceNote}`);
1927
+ for (const check of flake.checks.filter((c) => !c.stable)) {
1928
+ print(` ${check.label}: failed ${check.failed} of ${check.runs} runs`);
1929
+ }
1930
+ for (const signal of flake.signals.slice(0, 5)) {
1931
+ print(
1932
+ ` ${signal.kind} "${signal.signature}" in ${signal.runs} of ${signal.total} runs`,
1933
+ );
1934
+ }
1935
+ }
1936
+ for (const failure of report.failures) {
1937
+ print(` ${failure.budget}: expected ${failure.expected}, got ${failure.actual}`);
1938
+ if (failure.detail) print(` ${failure.detail}`);
1939
+ }
1940
+ for (const headline of report.trend?.headlines ?? []) output.printWarning(headline);
1941
+ if (report.diff?.structure) {
1942
+ const d = report.diff.structure;
1943
+ print(
1944
+ ` vs previous run: ${d.gained.length} gained, ${d.lost.length} lost, ${d.renamed.length} renamed`,
1945
+ );
1946
+ }
1947
+ if (report.diff?.pixels?.comparable) {
1948
+ print(
1949
+ ` vs previous run: ${report.diff.pixels.changedPercent.toFixed(2)}% of pixels changed`,
1950
+ );
1951
+ }
1952
+ for (const note of report.notes) output.printWarning(note);
1953
+ print(`Report: ${htmlPath}`);
1954
+ print(`JSON: ${jsonPath}`);
1955
+ if (historyDir) print(`History: ${historyDir}`);
1956
+ }
1957
+
1958
+ // Non-zero exit is the point of RIG-06 — CI and agents gate on it.
1959
+ return {
1960
+ success: passed,
1961
+ exitCode: passed ? 0 : 1,
1962
+ data: { htmlPath, jsonPath, report, flake },
1963
+ };
1964
+ },
1965
+ };
1966
+
1323
1967
  const closeCommand: Command = {
1324
1968
  name: 'close',
1325
1969
  description: 'Close the active browser session',
@@ -1330,13 +1974,25 @@ const closeCommand: Command = {
1330
1974
  const client = _client;
1331
1975
  // Tear down per-session Maps and listeners before closing
1332
1976
  if (browser.getHarStatus(sid).recording) {
1333
- try { await browser.stopHarRecording(client, sid); } catch { /* ignore */ }
1977
+ try {
1978
+ await browser.stopHarRecording(client, sid);
1979
+ } catch {
1980
+ /* ignore */
1981
+ }
1334
1982
  }
1335
1983
  if (browser.getTraceStatus(sid)) {
1336
- try { await browser.stopTrace(client, sid); } catch { /* ignore */ }
1984
+ try {
1985
+ await browser.stopTrace(client, sid);
1986
+ } catch {
1987
+ /* ignore */
1988
+ }
1337
1989
  }
1338
1990
  if (browser.isProfilingActive(sid)) {
1339
- try { await browser.stopCpuProfile(client, sid); } catch { /* ignore */ }
1991
+ try {
1992
+ await browser.stopCpuProfile(client, sid);
1993
+ } catch {
1994
+ /* ignore */
1995
+ }
1340
1996
  }
1341
1997
  browser.teardownRouteInterception(sid);
1342
1998
  browser.stopRequestCapture(sid);
@@ -1370,7 +2026,11 @@ const closeCommand: Command = {
1370
2026
  } finally {
1371
2027
  // Always drop our websocket — a hung Browser.close must not keep
1372
2028
  // this CLI process's event loop alive.
1373
- try { conn.client.close(); } catch { /* already gone */ }
2029
+ try {
2030
+ conn.client.close();
2031
+ } catch {
2032
+ /* already gone */
2033
+ }
1374
2034
  }
1375
2035
  // closeBrowser has no PID fallback in a fresh process (launchedPids
1376
2036
  // is per-process) — re-probe so we report what actually happened.
@@ -1380,19 +2040,28 @@ const closeCommand: Command = {
1380
2040
  const probeDeadline = Date.now() + 3000;
1381
2041
  while (stillUp && Date.now() < probeDeadline) {
1382
2042
  try {
1383
- await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(800) });
1384
- await new Promise(r => setTimeout(r, 300));
1385
- } catch { stillUp = false; }
2043
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
2044
+ signal: AbortSignal.timeout(800),
2045
+ });
2046
+ await new Promise((r) => setTimeout(r, 300));
2047
+ } catch {
2048
+ stillUp = false;
2049
+ }
1386
2050
  }
1387
- if (stillUp) output.printWarning(`Browser on port ${persisted.port} did not exit — kill it manually if needed`);
2051
+ if (stillUp)
2052
+ output.printWarning(
2053
+ `Browser on port ${persisted.port} did not exit — kill it manually if needed`,
2054
+ );
1388
2055
  else output.printSuccess(`Closed browser on port ${persisted.port}`);
1389
2056
  } catch {
1390
2057
  output.printInfo(`No browser answering on port ${persisted.port} — nothing to close`);
1391
2058
  }
1392
2059
  } else {
1393
- output.printInfo(persisted
1394
- ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
1395
- : 'No active browser session');
2060
+ output.printInfo(
2061
+ persisted
2062
+ ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
2063
+ : 'No active browser session',
2064
+ );
1396
2065
  }
1397
2066
  await browser.clearActivePort();
1398
2067
  await browser.clearRefCache();
@@ -1439,7 +2108,8 @@ const focusCommand: Command = {
1439
2108
 
1440
2109
  const typeCommand: Command = {
1441
2110
  name: 'type',
1442
- description: 'Type text into element (appends, does not clear). Usage: monomind browse type @e1 "text"',
2111
+ description:
2112
+ 'Type text into element (appends, does not clear). Usage: monomind browse type @e1 "text"',
1443
2113
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1444
2114
  const { client, sessionId } = await ensureConnected(_port);
1445
2115
  const browser = await getBrowser();
@@ -1566,7 +2236,7 @@ async function resolveElementObjectId(
1566
2236
  client: import('../index.js').CdpClient,
1567
2237
  sessionId: string,
1568
2238
  refs: Map<string, import('../index.js').ElementRef>,
1569
- refOrSelector: string
2239
+ refOrSelector: string,
1570
2240
  ): Promise<string> {
1571
2241
  const browser = await getBrowser();
1572
2242
  if (refOrSelector.startsWith('@') || /^e\d+$/.test(refOrSelector)) {
@@ -1577,11 +2247,16 @@ async function resolveElementObjectId(
1577
2247
  return objectId;
1578
2248
  }
1579
2249
  // CSS selector path
1580
- const res = await client.send<{ result: { objectId?: string; subtype?: string } }>('Runtime.evaluate', {
1581
- expression: `document.querySelector(${JSON.stringify(refOrSelector)})`,
1582
- returnByValue: false,
1583
- }, sessionId);
1584
- if (!res.result?.objectId || res.result?.subtype === 'null') throw new Error(`Selector not found: ${refOrSelector}`);
2250
+ const res = await client.send<{ result: { objectId?: string; subtype?: string } }>(
2251
+ 'Runtime.evaluate',
2252
+ {
2253
+ expression: `document.querySelector(${JSON.stringify(refOrSelector)})`,
2254
+ returnByValue: false,
2255
+ },
2256
+ sessionId,
2257
+ );
2258
+ if (!res.result?.objectId || res.result?.subtype === 'null')
2259
+ throw new Error(`Selector not found: ${refOrSelector}`);
1585
2260
  return res.result.objectId;
1586
2261
  }
1587
2262
 
@@ -1594,63 +2269,87 @@ const isvisibleCommand: Command = {
1594
2269
  const arg = ctx.args[0] as string;
1595
2270
  if (!arg) throw new Error('Usage: monomind browse isvisible @e1|".selector"');
1596
2271
  const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
1597
- const r = await client.send<{ result: { value?: boolean } }>('Runtime.callFunctionOn', {
1598
- functionDeclaration: `function(){var r=this.getBoundingClientRect(),s=window.getComputedStyle(this);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&parseFloat(s.opacity)>0;}`,
1599
- objectId,
1600
- returnByValue: true,
1601
- }, sessionId);
2272
+ const r = await client.send<{ result: { value?: boolean } }>(
2273
+ 'Runtime.callFunctionOn',
2274
+ {
2275
+ functionDeclaration: `function(){var r=this.getBoundingClientRect(),s=window.getComputedStyle(this);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&parseFloat(s.opacity)>0;}`,
2276
+ objectId,
2277
+ returnByValue: true,
2278
+ },
2279
+ sessionId,
2280
+ );
1602
2281
  const visible = r.result?.value ?? false;
1603
- if (ctx.flags.json) { print(JSON.stringify({ visible })); }
1604
- else { output.printSuccess(`isvisible: ${visible}`); }
2282
+ if (ctx.flags.json) {
2283
+ print(JSON.stringify({ visible }));
2284
+ } else {
2285
+ output.printSuccess(`isvisible: ${visible}`);
2286
+ }
1605
2287
  return { success: true, data: { visible } };
1606
2288
  },
1607
2289
  };
1608
2290
 
1609
2291
  const isenabledCommand: Command = {
1610
2292
  name: 'isenabled',
1611
- description: 'Check if element is enabled (not disabled). Usage: monomind browse isenabled @e1|"selector"',
2293
+ description:
2294
+ 'Check if element is enabled (not disabled). Usage: monomind browse isenabled @e1|"selector"',
1612
2295
  options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
1613
2296
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1614
2297
  const { client, sessionId } = await ensureConnected(_port);
1615
2298
  const arg = ctx.args[0] as string;
1616
2299
  if (!arg) throw new Error('Usage: monomind browse isenabled @e1|".selector"');
1617
2300
  const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
1618
- const r = await client.send<{ result: { value?: boolean } }>('Runtime.callFunctionOn', {
1619
- functionDeclaration: `function(){return !this.disabled;}`,
1620
- objectId,
1621
- returnByValue: true,
1622
- }, sessionId);
2301
+ const r = await client.send<{ result: { value?: boolean } }>(
2302
+ 'Runtime.callFunctionOn',
2303
+ {
2304
+ functionDeclaration: `function(){return !this.disabled;}`,
2305
+ objectId,
2306
+ returnByValue: true,
2307
+ },
2308
+ sessionId,
2309
+ );
1623
2310
  const enabled = r.result?.value ?? true;
1624
- if (ctx.flags.json) { print(JSON.stringify({ enabled })); }
1625
- else { output.printSuccess(`isenabled: ${enabled}`); }
2311
+ if (ctx.flags.json) {
2312
+ print(JSON.stringify({ enabled }));
2313
+ } else {
2314
+ output.printSuccess(`isenabled: ${enabled}`);
2315
+ }
1626
2316
  return { success: true, data: { enabled } };
1627
2317
  },
1628
2318
  };
1629
2319
 
1630
2320
  const ischeckedCommand: Command = {
1631
2321
  name: 'ischecked',
1632
- description: 'Check if checkbox/radio is checked. Usage: monomind browse ischecked @e1|"selector"',
2322
+ description:
2323
+ 'Check if checkbox/radio is checked. Usage: monomind browse ischecked @e1|"selector"',
1633
2324
  options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
1634
2325
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1635
2326
  const { client, sessionId } = await ensureConnected(_port);
1636
2327
  const arg = ctx.args[0] as string;
1637
2328
  if (!arg) throw new Error('Usage: monomind browse ischecked @e1|".selector"');
1638
2329
  const objectId = await resolveElementObjectId(client, sessionId, _refs, arg);
1639
- const r = await client.send<{ result: { value?: boolean } }>('Runtime.callFunctionOn', {
1640
- functionDeclaration: `function(){var el=this,tag=el.tagName&&el.tagName.toUpperCase();if(tag==='INPUT'&&(el.type==='checkbox'||el.type==='radio'))return el.checked;var role=el.getAttribute&&el.getAttribute('role');if(role&&['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'].indexOf(role)!==-1)return el.getAttribute('aria-checked')==='true';var label=tag!=='LABEL'?el.closest&&el.closest('label'):el;if(label&&label.control&&(label.control.type==='checkbox'||label.control.type==='radio'))return label.control.checked;var inp=el.querySelector&&el.querySelector('input[type="checkbox"],input[type="radio"]');return inp?inp.checked:false;}`,
1641
- objectId,
1642
- returnByValue: true,
1643
- }, sessionId);
2330
+ const r = await client.send<{ result: { value?: boolean } }>(
2331
+ 'Runtime.callFunctionOn',
2332
+ {
2333
+ functionDeclaration: `function(){var el=this,tag=el.tagName&&el.tagName.toUpperCase();if(tag==='INPUT'&&(el.type==='checkbox'||el.type==='radio'))return el.checked;var role=el.getAttribute&&el.getAttribute('role');if(role&&['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'].indexOf(role)!==-1)return el.getAttribute('aria-checked')==='true';var label=tag!=='LABEL'?el.closest&&el.closest('label'):el;if(label&&label.control&&(label.control.type==='checkbox'||label.control.type==='radio'))return label.control.checked;var inp=el.querySelector&&el.querySelector('input[type="checkbox"],input[type="radio"]');return inp?inp.checked:false;}`,
2334
+ objectId,
2335
+ returnByValue: true,
2336
+ },
2337
+ sessionId,
2338
+ );
1644
2339
  const checked = r.result?.value ?? false;
1645
- if (ctx.flags.json) { print(JSON.stringify({ checked })); }
1646
- else { output.printSuccess(`ischecked: ${checked}`); }
2340
+ if (ctx.flags.json) {
2341
+ print(JSON.stringify({ checked }));
2342
+ } else {
2343
+ output.printSuccess(`ischecked: ${checked}`);
2344
+ }
1647
2345
  return { success: true, data: { checked } };
1648
2346
  },
1649
2347
  };
1650
2348
 
1651
2349
  const tapCommand: Command = {
1652
2350
  name: 'tap',
1653
- description: 'Tap element with a touch event (mobile testing). Usage: monomind browse tap @e1|"selector"',
2351
+ description:
2352
+ 'Tap element with a touch event (mobile testing). Usage: monomind browse tap @e1|"selector"',
1654
2353
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1655
2354
  const { client, sessionId } = await ensureConnected(_port);
1656
2355
  const browser = await getBrowser();
@@ -1669,13 +2368,21 @@ const tapCommand: Command = {
1669
2368
  x = Math.round(box.x);
1670
2369
  y = Math.round(box.y);
1671
2370
  } else {
1672
- const posJson = await browser.evaluateJs(client, sessionId,
1673
- `(function(){var el=document.querySelector(${JSON.stringify(arg)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`) as string | null;
2371
+ const posJson = (await browser.evaluateJs(
2372
+ client,
2373
+ sessionId,
2374
+ `(function(){var el=document.querySelector(${JSON.stringify(arg)});if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});})()`,
2375
+ )) as string | null;
1674
2376
  if (!posJson) throw new Error(`Selector not found: ${arg}`);
1675
2377
  const pos = JSON.parse(posJson) as { x: number; y: number };
1676
- x = Math.round(pos.x); y = Math.round(pos.y);
2378
+ x = Math.round(pos.x);
2379
+ y = Math.round(pos.y);
1677
2380
  }
1678
- await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] }, sessionId);
2381
+ await client.send(
2382
+ 'Input.dispatchTouchEvent',
2383
+ { type: 'touchStart', touchPoints: [{ x, y }] },
2384
+ sessionId,
2385
+ );
1679
2386
  await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
1680
2387
  output.printSuccess(`Tapped at (${x}, ${y})`);
1681
2388
  return { success: true, data: { x, y } };
@@ -1684,31 +2391,62 @@ const tapCommand: Command = {
1684
2391
 
1685
2392
  const swipeCommand: Command = {
1686
2393
  name: 'swipe',
1687
- description: 'Swipe gesture (mobile). Usage: monomind browse swipe up|down|left|right [distance] [--x N] [--y N]',
2394
+ description:
2395
+ 'Swipe gesture (mobile). Usage: monomind browse swipe up|down|left|right [distance] [--x N] [--y N]',
1688
2396
  options: [
1689
- { name: 'x', type: 'number', description: 'Start X coordinate (default: center)', default: 200 },
1690
- { name: 'y', type: 'number', description: 'Start Y coordinate (default: center)', default: 400 },
1691
- { name: 'distance', short: 'd', type: 'number', description: 'Swipe distance in pixels', default: 300 },
2397
+ {
2398
+ name: 'x',
2399
+ type: 'number',
2400
+ description: 'Start X coordinate (default: center)',
2401
+ default: 200,
2402
+ },
2403
+ {
2404
+ name: 'y',
2405
+ type: 'number',
2406
+ description: 'Start Y coordinate (default: center)',
2407
+ default: 400,
2408
+ },
2409
+ {
2410
+ name: 'distance',
2411
+ short: 'd',
2412
+ type: 'number',
2413
+ description: 'Swipe distance in pixels',
2414
+ default: 300,
2415
+ },
1692
2416
  ],
1693
2417
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1694
2418
  const { client, sessionId } = await ensureConnected(_port);
1695
- const direction = (ctx.args[0] as string) as 'up' | 'down' | 'left' | 'right';
2419
+ const direction = ctx.args[0] as string as 'up' | 'down' | 'left' | 'right';
1696
2420
  if (!['up', 'down', 'left', 'right'].includes(direction)) {
1697
- throw new Error('Usage: monomind browse swipe up|down|left|right [--x N] [--y N] [--distance N]');
2421
+ throw new Error(
2422
+ 'Usage: monomind browse swipe up|down|left|right [--x N] [--y N] [--distance N]',
2423
+ );
1698
2424
  }
1699
2425
  const startX = (ctx.flags.x as number) ?? 200;
1700
2426
  const startY = (ctx.flags.y as number) ?? 400;
1701
- const positionalDistance = ctx.args[1] !== undefined ? parseInt(ctx.args[1] as string, 10) : undefined;
1702
- const distance = (positionalDistance && Number.isFinite(positionalDistance)) ? positionalDistance : (ctx.flags.distance as number) ?? 300;
2427
+ const positionalDistance =
2428
+ ctx.args[1] !== undefined ? parseInt(ctx.args[1] as string, 10) : undefined;
2429
+ const distance =
2430
+ positionalDistance && Number.isFinite(positionalDistance)
2431
+ ? positionalDistance
2432
+ : ((ctx.flags.distance as number) ?? 300);
1703
2433
  const dx = direction === 'right' ? distance : direction === 'left' ? -distance : 0;
1704
2434
  const dy = direction === 'down' ? distance : direction === 'up' ? -distance : 0;
1705
2435
 
1706
- await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y: startY }] }, sessionId);
2436
+ await client.send(
2437
+ 'Input.dispatchTouchEvent',
2438
+ { type: 'touchStart', touchPoints: [{ x: startX, y: startY }] },
2439
+ sessionId,
2440
+ );
1707
2441
  const steps = 10;
1708
2442
  for (let i = 1; i <= steps; i++) {
1709
- const x = Math.round(startX + dx * i / steps);
1710
- const y = Math.round(startY + dy * i / steps);
1711
- await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] }, sessionId);
2443
+ const x = Math.round(startX + (dx * i) / steps);
2444
+ const y = Math.round(startY + (dy * i) / steps);
2445
+ await client.send(
2446
+ 'Input.dispatchTouchEvent',
2447
+ { type: 'touchMove', touchPoints: [{ x, y }] },
2448
+ sessionId,
2449
+ );
1712
2450
  await new Promise((r) => setTimeout(r, 16));
1713
2451
  }
1714
2452
  await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
@@ -1761,7 +2499,8 @@ const uploadCommand: Command = {
1761
2499
  const browser = await getBrowser();
1762
2500
  const refArg = ctx.args[0] as string;
1763
2501
  const files = ctx.args.slice(1) as string[];
1764
- if (!refArg || files.length === 0) throw new Error('Usage: monomind browse upload @e1 <file1> [file2...]');
2502
+ if (!refArg || files.length === 0)
2503
+ throw new Error('Usage: monomind browse upload @e1 <file1> [file2...]');
1765
2504
  const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
1766
2505
  const ref = await browser.resolveRef(client, sessionId, _refs, refKey);
1767
2506
  await browser.uploadFile(client, sessionId, ref, files);
@@ -1772,37 +2511,57 @@ const uploadCommand: Command = {
1772
2511
 
1773
2512
  const downloadCommand: Command = {
1774
2513
  name: 'download',
1775
- description: 'Click an element and capture the triggered file download. Usage: monomind browse download @e1 ./output.pdf',
2514
+ description:
2515
+ 'Click an element and capture the triggered file download. Usage: monomind browse download @e1 ./output.pdf',
1776
2516
  options: [
1777
- { name: 'timeout', short: 't', type: 'number', description: 'Max wait for download in ms', default: 30000 },
2517
+ {
2518
+ name: 'timeout',
2519
+ short: 't',
2520
+ type: 'number',
2521
+ description: 'Max wait for download in ms',
2522
+ default: 30000,
2523
+ },
1778
2524
  { name: 'json', type: 'boolean', description: 'Output result as JSON', default: false },
1779
2525
  ],
1780
2526
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1781
2527
  const { client, sessionId } = await ensureConnected(_port);
1782
- const browser = await getBrowser();
2528
+ const _browser = await getBrowser();
1783
2529
  const refOrSel = ctx.args[0] as string;
1784
2530
  const savePath = ctx.args[1] as string;
1785
- if (!refOrSel || !savePath) throw new Error('Usage: monomind browse download @e1|selector <save-path>');
2531
+ if (!refOrSel || !savePath)
2532
+ throw new Error('Usage: monomind browse download @e1|selector <save-path>');
1786
2533
 
1787
- const { mkdir } = await import('fs/promises');
1788
- const { dirname } = await import('path');
1789
- const { tmpdir } = await import('os');
1790
- const { join } = await import('path');
2534
+ const { mkdir } = await import('node:fs/promises');
2535
+ const { dirname } = await import('node:path');
2536
+ const { tmpdir } = await import('node:os');
2537
+ const { join } = await import('node:path');
1791
2538
  const downloadDir = join(tmpdir(), `mm-download-${Date.now()}`);
1792
2539
  await mkdir(downloadDir, { recursive: true });
1793
2540
 
1794
2541
  // Enable Page.downloadWillBegin / Page.downloadProgress events
1795
- await client.send('Browser.setDownloadBehavior', {
1796
- behavior: 'allow',
1797
- downloadPath: downloadDir,
1798
- eventsEnabled: true,
1799
- }, undefined).catch(() => {
1800
- // Fallback: older Chrome API (session-scoped)
1801
- return client.send('Page.setDownloadBehavior', {
1802
- behavior: 'allow',
1803
- downloadPath: downloadDir,
1804
- }, sessionId).catch(() => {});
1805
- });
2542
+ await client
2543
+ .send(
2544
+ 'Browser.setDownloadBehavior',
2545
+ {
2546
+ behavior: 'allow',
2547
+ downloadPath: downloadDir,
2548
+ eventsEnabled: true,
2549
+ },
2550
+ undefined,
2551
+ )
2552
+ .catch(() => {
2553
+ // Fallback: older Chrome API (session-scoped)
2554
+ return client
2555
+ .send(
2556
+ 'Page.setDownloadBehavior',
2557
+ {
2558
+ behavior: 'allow',
2559
+ downloadPath: downloadDir,
2560
+ },
2561
+ sessionId,
2562
+ )
2563
+ .catch(() => {});
2564
+ });
1806
2565
 
1807
2566
  // Track when download completes
1808
2567
  const downloadPromise = new Promise<string>((resolve, reject) => {
@@ -1814,38 +2573,52 @@ const downloadCommand: Command = {
1814
2573
  let offProgress: (() => void) | undefined;
1815
2574
  // cleanup defined before setTimeout so the timeout callback can call it
1816
2575
  let timeout: ReturnType<typeof setTimeout>;
1817
- const cleanup = () => { clearTimeout(timeout); offBegin?.(); offProgress?.(); };
1818
- timeout = setTimeout(() => { cleanup(); reject(new Error('Download timed out')); }, ctx.flags.timeout as number);
1819
- offProgress = client.on('Browser.downloadProgress', async (params: Record<string, unknown>) => {
1820
- if (params.guid === guid && params.state === 'completed') {
1821
- cleanup();
1822
- // Find the downloaded file in downloadDir
1823
- const { readdir, rename, rmdir } = await import('fs/promises');
1824
- const files = await readdir(downloadDir);
1825
- if (files.length > 0) {
1826
- const src = join(downloadDir, files[0]);
1827
- await mkdir(dirname(savePath), { recursive: true });
1828
- await rename(src, savePath);
1829
- await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
1830
- resolve(savePath);
1831
- } else {
1832
- await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
1833
- reject(new Error('Download completed but no file found'));
2576
+ const cleanup = () => {
2577
+ clearTimeout(timeout);
2578
+ offBegin?.();
2579
+ offProgress?.();
2580
+ };
2581
+ timeout = setTimeout(() => {
2582
+ cleanup();
2583
+ reject(new Error('Download timed out'));
2584
+ }, ctx.flags.timeout as number);
2585
+ offProgress = client.on(
2586
+ 'Browser.downloadProgress',
2587
+ async (params: Record<string, unknown>) => {
2588
+ if (params.guid === guid && params.state === 'completed') {
2589
+ cleanup();
2590
+ // Find the downloaded file in downloadDir
2591
+ const { readdir, rename, rmdir } = await import('node:fs/promises');
2592
+ const files = await readdir(downloadDir);
2593
+ if (files.length > 0) {
2594
+ const src = join(downloadDir, files[0]);
2595
+ await mkdir(dirname(savePath), { recursive: true });
2596
+ await rename(src, savePath);
2597
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
2598
+ resolve(savePath);
2599
+ } else {
2600
+ await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
2601
+ reject(new Error('Download completed but no file found'));
2602
+ }
2603
+ } else if (params.guid === guid && params.state === 'canceled') {
2604
+ cleanup();
2605
+ reject(new Error('Download was canceled'));
1834
2606
  }
1835
- } else if (params.guid === guid && params.state === 'canceled') {
1836
- cleanup();
1837
- reject(new Error('Download was canceled'));
1838
- }
1839
- });
2607
+ },
2608
+ );
1840
2609
  });
1841
2610
 
1842
2611
  // Click the element to trigger download
1843
2612
  const objectId = await resolveElementObjectId(client, sessionId, _refs, refOrSel);
1844
- await client.send<{ result: unknown }>('Runtime.callFunctionOn', {
1845
- functionDeclaration: 'function(){ this.click(); }',
1846
- objectId,
1847
- returnByValue: true,
1848
- }, sessionId);
2613
+ await client.send<{ result: unknown }>(
2614
+ 'Runtime.callFunctionOn',
2615
+ {
2616
+ functionDeclaration: 'function(){ this.click(); }',
2617
+ objectId,
2618
+ returnByValue: true,
2619
+ },
2620
+ sessionId,
2621
+ );
1849
2622
 
1850
2623
  const finalPath = await downloadPromise;
1851
2624
  if (ctx.flags.json) print(JSON.stringify({ data: { path: finalPath } }));
@@ -1982,7 +2755,8 @@ const dialogCommand: Command = {
1982
2755
 
1983
2756
  const frameCommand: Command = {
1984
2757
  name: 'frame',
1985
- description: 'Switch to iframe or back to main. Usage: monomind browse frame "#frame-id" | frame main',
2758
+ description:
2759
+ 'Switch to iframe or back to main. Usage: monomind browse frame "#frame-id" | frame main',
1986
2760
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1987
2761
  const { client, sessionId } = await ensureConnected(_port);
1988
2762
  const browser = await getBrowser();
@@ -2015,9 +2789,7 @@ const frameCommand: Command = {
2015
2789
  const tabCommand: Command = {
2016
2790
  name: 'tab',
2017
2791
  description: 'Tab management. Usage: monomind browse tab list|new|close [url]',
2018
- options: [
2019
- { name: 'label', type: 'string', description: 'Label for new tab' },
2020
- ],
2792
+ options: [{ name: 'label', type: 'string', description: 'Label for new tab' }],
2021
2793
  action: async (ctx: CommandContext): Promise<CommandResult> => {
2022
2794
  const { client, sessionId } = await ensureConnected(_port);
2023
2795
  const browser = await getBrowser();
@@ -2042,13 +2814,25 @@ const tabCommand: Command = {
2042
2814
  const sid = _sessionId;
2043
2815
  // Stop profiling before closing the tab so CDP commands still reach the live session
2044
2816
  if (browser.getHarStatus(sid).recording) {
2045
- try { await browser.stopHarRecording(client, sid); } catch { /* ignore */ }
2817
+ try {
2818
+ await browser.stopHarRecording(client, sid);
2819
+ } catch {
2820
+ /* ignore */
2821
+ }
2046
2822
  }
2047
2823
  if (browser.getTraceStatus(sid)) {
2048
- try { await browser.stopTrace(client, sid); } catch { /* ignore */ }
2824
+ try {
2825
+ await browser.stopTrace(client, sid);
2826
+ } catch {
2827
+ /* ignore */
2828
+ }
2049
2829
  }
2050
2830
  if (browser.isProfilingActive(sid)) {
2051
- try { await browser.stopCpuProfile(client, sid); } catch { /* ignore */ }
2831
+ try {
2832
+ await browser.stopCpuProfile(client, sid);
2833
+ } catch {
2834
+ /* ignore */
2835
+ }
2052
2836
  }
2053
2837
  browser.teardownRouteInterception(sid);
2054
2838
  browser.stopRequestCapture(sid);
@@ -2072,13 +2856,25 @@ const tabCommand: Command = {
2072
2856
  const newSid = await browser.activateTab(client, sessionId, action);
2073
2857
  const oldSid = _sessionId;
2074
2858
  if (browser.getHarStatus(oldSid).recording) {
2075
- try { await browser.stopHarRecording(client, oldSid); } catch { /* ignore */ }
2859
+ try {
2860
+ await browser.stopHarRecording(client, oldSid);
2861
+ } catch {
2862
+ /* ignore */
2863
+ }
2076
2864
  }
2077
2865
  if (browser.getTraceStatus(oldSid)) {
2078
- try { await browser.stopTrace(client, oldSid); } catch { /* ignore */ }
2866
+ try {
2867
+ await browser.stopTrace(client, oldSid);
2868
+ } catch {
2869
+ /* ignore */
2870
+ }
2079
2871
  }
2080
2872
  if (browser.isProfilingActive(oldSid)) {
2081
- try { await browser.stopCpuProfile(client, oldSid); } catch { /* ignore */ }
2873
+ try {
2874
+ await browser.stopCpuProfile(client, oldSid);
2875
+ } catch {
2876
+ /* ignore */
2877
+ }
2082
2878
  }
2083
2879
  await browser.disableInterception(client, oldSid).catch(() => {});
2084
2880
  browser.stopRequestCapture(oldSid);
@@ -2105,23 +2901,47 @@ const windowCommand: Command = {
2105
2901
 
2106
2902
  if (!action || action === 'new') {
2107
2903
  // Create isolated browser context (incognito-like) with a fresh page
2108
- const ctxResult = await client.send<{ browserContextId: string }>('Target.createBrowserContext', {}, undefined);
2904
+ const ctxResult = await client.send<{ browserContextId: string }>(
2905
+ 'Target.createBrowserContext',
2906
+ {},
2907
+ undefined,
2908
+ );
2109
2909
  const browserContextId = ctxResult.browserContextId;
2110
2910
  const url = (ctx.args[1] as string) || 'about:blank';
2111
- const targetResult = await client.send<{ targetId: string }>('Target.createTarget', { url, browserContextId }, undefined);
2911
+ const targetResult = await client.send<{ targetId: string }>(
2912
+ 'Target.createTarget',
2913
+ { url, browserContextId },
2914
+ undefined,
2915
+ );
2112
2916
  const targetId = targetResult.targetId;
2113
- const attachResult = await client.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }, undefined);
2917
+ const attachResult = await client.send<{ sessionId: string }>(
2918
+ 'Target.attachToTarget',
2919
+ { targetId, flatten: true },
2920
+ undefined,
2921
+ );
2114
2922
  const newSessionId = attachResult.sessionId;
2115
2923
  // W3: fully tear down old session before switching
2116
2924
  const oldSid = _sessionId;
2117
2925
  if (browser.getHarStatus(oldSid).recording) {
2118
- try { await browser.stopHarRecording(client, oldSid); } catch { /* ignore */ }
2926
+ try {
2927
+ await browser.stopHarRecording(client, oldSid);
2928
+ } catch {
2929
+ /* ignore */
2930
+ }
2119
2931
  }
2120
2932
  if (browser.getTraceStatus(oldSid)) {
2121
- try { await browser.stopTrace(client, oldSid); } catch { /* ignore */ }
2933
+ try {
2934
+ await browser.stopTrace(client, oldSid);
2935
+ } catch {
2936
+ /* ignore */
2937
+ }
2122
2938
  }
2123
2939
  if (browser.isProfilingActive(oldSid)) {
2124
- try { await browser.stopCpuProfile(client, oldSid); } catch { /* ignore */ }
2940
+ try {
2941
+ await browser.stopCpuProfile(client, oldSid);
2942
+ } catch {
2943
+ /* ignore */
2944
+ }
2125
2945
  }
2126
2946
  browser.teardownRouteInterception(oldSid);
2127
2947
  browser.stopRequestCapture(oldSid);
@@ -2196,7 +3016,8 @@ const errorsCommand: Command = {
2196
3016
 
2197
3017
  const storageCommand: Command = {
2198
3018
  name: 'storage',
2199
- description: 'localStorage/sessionStorage management. Usage: monomind browse storage local|session [key] [--set val] [--clear]',
3019
+ description:
3020
+ 'localStorage/sessionStorage management. Usage: monomind browse storage local|session [key] [--set val] [--clear]',
2200
3021
  options: [
2201
3022
  { name: 'set', type: 'string', description: 'Value to set for key' },
2202
3023
  { name: 'clear', type: 'boolean', description: 'Clear all storage', default: false },
@@ -2221,7 +3042,8 @@ const storageCommand: Command = {
2221
3042
  }
2222
3043
 
2223
3044
  if (key && ctx.flags.set !== undefined) {
2224
- if (isLocal) await browser.setLocalStorageKey(client, sessionId, key, ctx.flags.set as string);
3045
+ if (isLocal)
3046
+ await browser.setLocalStorageKey(client, sessionId, key, ctx.flags.set as string);
2225
3047
  else await browser.setSessionStorageKey(client, sessionId, key, ctx.flags.set as string);
2226
3048
  output.printSuccess(`Set ${key}`);
2227
3049
  return { success: true };
@@ -2281,11 +3103,13 @@ const cookiesCommand: Command = {
2281
3103
  if (!name || value === undefined) {
2282
3104
  throw new Error('Usage: monomind browse cookies set <name> <value> [--domain <d>]');
2283
3105
  }
2284
- await browser.setCookies(client, sessionId, [{
2285
- name,
2286
- value,
2287
- domain: ctx.flags.domain as string,
2288
- }]);
3106
+ await browser.setCookies(client, sessionId, [
3107
+ {
3108
+ name,
3109
+ value,
3110
+ domain: ctx.flags.domain as string,
3111
+ },
3112
+ ]);
2289
3113
  output.printSuccess(`Cookie set: ${name}`);
2290
3114
  break;
2291
3115
  }
@@ -2323,9 +3147,7 @@ const pdfCommand: Command = {
2323
3147
  const isCommand: Command = {
2324
3148
  name: 'is',
2325
3149
  description: 'Check element state. Usage: monomind browse is visible|enabled|checked @e1',
2326
- options: [
2327
- { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
2328
- ],
3150
+ options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
2329
3151
  action: async (ctx: CommandContext): Promise<CommandResult> => {
2330
3152
  const { client, sessionId } = await ensureConnected(_port);
2331
3153
  const browser = await getBrowser();
@@ -2338,10 +3160,17 @@ const isCommand: Command = {
2338
3160
 
2339
3161
  let result: boolean;
2340
3162
  switch (check) {
2341
- case 'visible': result = await browser.isVisible(client, sessionId, ref); break;
2342
- case 'enabled': result = await browser.isEnabled(client, sessionId, ref); break;
2343
- case 'checked': result = await browser.isChecked(client, sessionId, ref); break;
2344
- default: throw new Error(`Unknown check: ${check}. Use: visible|enabled|checked`);
3163
+ case 'visible':
3164
+ result = await browser.isVisible(client, sessionId, ref);
3165
+ break;
3166
+ case 'enabled':
3167
+ result = await browser.isEnabled(client, sessionId, ref);
3168
+ break;
3169
+ case 'checked':
3170
+ result = await browser.isChecked(client, sessionId, ref);
3171
+ break;
3172
+ default:
3173
+ throw new Error(`Unknown check: ${check}. Use: visible|enabled|checked`);
2345
3174
  }
2346
3175
 
2347
3176
  if (ctx.flags.json) {
@@ -2355,7 +3184,8 @@ const isCommand: Command = {
2355
3184
 
2356
3185
  const findCommand: Command = {
2357
3186
  name: 'find',
2358
- description: 'Find elements by semantic locators. Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]',
3187
+ description:
3188
+ 'Find elements by semantic locators. Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]',
2359
3189
  options: [
2360
3190
  { name: 'name', type: 'string', description: 'Filter by accessible name' },
2361
3191
  { name: 'exact', type: 'boolean', description: 'Require exact match', default: false },
@@ -2369,7 +3199,10 @@ const findCommand: Command = {
2369
3199
  const value = ctx.args[1] as string;
2370
3200
  const action = ctx.args[2] as FindAction | undefined;
2371
3201
 
2372
- if (!locator || !value) throw new Error('Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]');
3202
+ if (!locator || !value)
3203
+ throw new Error(
3204
+ 'Usage: monomind browse find role|text|label|placeholder|testid|alttext|title|selector <value> [action]',
3205
+ );
2373
3206
 
2374
3207
  const opts = {
2375
3208
  name: ctx.flags.name as string,
@@ -2382,9 +3215,15 @@ const findCommand: Command = {
2382
3215
  if (locator === 'alttext') {
2383
3216
  // I2: use JS attribute comparison to avoid broken CSS selectors for values with spaces/quotes
2384
3217
  const valJson = JSON.stringify(value);
2385
- const found = await browser.evaluateJs(client, sessionId,
2386
- `(function(v){var el=document.querySelector('img[alt]')||null;var all=document.querySelectorAll('[alt]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('alt')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`) as boolean;
2387
- if (!found) { output.printWarning(`alttext not found: ${value}`); return { success: false }; }
3218
+ const found = (await browser.evaluateJs(
3219
+ client,
3220
+ sessionId,
3221
+ `(function(v){var el=document.querySelector('img[alt]')||null;var all=document.querySelectorAll('[alt]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('alt')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`,
3222
+ )) as boolean;
3223
+ if (!found) {
3224
+ output.printWarning(`alttext not found: ${value}`);
3225
+ return { success: false };
3226
+ }
2388
3227
  output.printSuccess(`Found element with alt="${value}"`);
2389
3228
  return { success: true, data: { alttext: value } };
2390
3229
  }
@@ -2393,28 +3232,49 @@ const findCommand: Command = {
2393
3232
  if (locator === 'title') {
2394
3233
  // I2: use JS attribute comparison to avoid broken CSS selectors for values with spaces/quotes
2395
3234
  const valJson = JSON.stringify(value);
2396
- const found = await browser.evaluateJs(client, sessionId,
2397
- `(function(v){var all=document.querySelectorAll('[title]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('title')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`) as boolean;
2398
- if (!found) { output.printWarning(`title not found: ${value}`); return { success: false }; }
3235
+ const found = (await browser.evaluateJs(
3236
+ client,
3237
+ sessionId,
3238
+ `(function(v){var all=document.querySelectorAll('[title]');for(var i=0;i<all.length;i++){if(all[i].getAttribute('title')===v){all[i].setAttribute('data-mm-located','true');return true;}}return false;})(${valJson})`,
3239
+ )) as boolean;
3240
+ if (!found) {
3241
+ output.printWarning(`title not found: ${value}`);
3242
+ return { success: false };
3243
+ }
2399
3244
  output.printSuccess(`Found element with title="${value}"`);
2400
3245
  return { success: true, data: { title: value } };
2401
3246
  }
2402
3247
 
2403
3248
  let ref: ElementRef | null = null;
2404
3249
  switch (locator) {
2405
- case 'role': ref = await browser.findByRole(client, sessionId, _refs, value, opts); break;
2406
- case 'text': ref = await browser.findByText(client, sessionId, _refs, value, opts); break;
2407
- case 'label': ref = await browser.findByLabel(client, sessionId, _refs, value, opts); break;
2408
- case 'placeholder': ref = await browser.findByPlaceholder(client, sessionId, _refs, value, opts); break;
2409
- case 'selector': ref = await browser.findBySelector(client, sessionId, _refs, value, opts); break;
3250
+ case 'role':
3251
+ ref = await browser.findByRole(client, sessionId, _refs, value, opts);
3252
+ break;
3253
+ case 'text':
3254
+ ref = await browser.findByText(client, sessionId, _refs, value, opts);
3255
+ break;
3256
+ case 'label':
3257
+ ref = await browser.findByLabel(client, sessionId, _refs, value, opts);
3258
+ break;
3259
+ case 'placeholder':
3260
+ ref = await browser.findByPlaceholder(client, sessionId, _refs, value, opts);
3261
+ break;
3262
+ case 'selector':
3263
+ ref = await browser.findBySelector(client, sessionId, _refs, value, opts);
3264
+ break;
2410
3265
  case 'testid': {
2411
3266
  const sel = await browser.findByTestId(client, sessionId, value);
2412
- if (!sel) { output.printWarning(`testid not found: ${value}`); return { success: false }; }
3267
+ if (!sel) {
3268
+ output.printWarning(`testid not found: ${value}`);
3269
+ return { success: false };
3270
+ }
2413
3271
  output.printSuccess(`Found testid selector: ${sel}`);
2414
3272
  return { success: true, data: { selector: sel } };
2415
3273
  }
2416
3274
  default:
2417
- throw new Error(`Unknown locator: ${locator}. Use: role|text|label|placeholder|testid|alttext|title|selector`);
3275
+ throw new Error(
3276
+ `Unknown locator: ${locator}. Use: role|text|label|placeholder|testid|alttext|title|selector`,
3277
+ );
2418
3278
  }
2419
3279
 
2420
3280
  if (!ref) {
@@ -2426,7 +3286,9 @@ const findCommand: Command = {
2426
3286
 
2427
3287
  if (action) {
2428
3288
  switch (action) {
2429
- case 'click': await browser.clickElement(client, sessionId, ref); break;
3289
+ case 'click':
3290
+ await browser.clickElement(client, sessionId, ref);
3291
+ break;
2430
3292
  case 'fill': {
2431
3293
  const fillValue = ctx.args[3] as string;
2432
3294
  await browser.fillElement(client, sessionId, ref, fillValue ?? '');
@@ -2437,18 +3299,31 @@ const findCommand: Command = {
2437
3299
  await browser.typeIntoElement(client, sessionId, ref, typeValue ?? '');
2438
3300
  break;
2439
3301
  }
2440
- case 'hover': await browser.hoverElement(client, sessionId, ref); break;
2441
- case 'focus': await browser.focusElement(client, sessionId, ref); break;
2442
- case 'check': await browser.checkElement(client, sessionId, ref, true); break;
2443
- case 'uncheck': await browser.checkElement(client, sessionId, ref, false); break;
3302
+ case 'hover':
3303
+ await browser.hoverElement(client, sessionId, ref);
3304
+ break;
3305
+ case 'focus':
3306
+ await browser.focusElement(client, sessionId, ref);
3307
+ break;
3308
+ case 'check':
3309
+ await browser.checkElement(client, sessionId, ref, true);
3310
+ break;
3311
+ case 'uncheck':
3312
+ await browser.checkElement(client, sessionId, ref, false);
3313
+ break;
2444
3314
  case 'text': {
2445
3315
  const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
2446
3316
  if (objectId) {
2447
- const r = await client.send<{ result: { value?: string } }>('Runtime.callFunctionOn', {
2448
- functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
2449
- objectId,
2450
- returnByValue: true,
2451
- }, sessionId);
3317
+ const r = await client.send<{ result: { value?: string } }>(
3318
+ 'Runtime.callFunctionOn',
3319
+ {
3320
+ functionDeclaration:
3321
+ 'function() { return this.innerText || this.textContent || ""; }',
3322
+ objectId,
3323
+ returnByValue: true,
3324
+ },
3325
+ sessionId,
3326
+ );
2452
3327
  print(r.result?.value ?? '');
2453
3328
  }
2454
3329
  break;
@@ -2478,9 +3353,16 @@ const highlightCommand: Command = {
2478
3353
 
2479
3354
  const diffCommand: Command = {
2480
3355
  name: 'diff',
2481
- description: 'Compare two URLs or snapshots. Usage: monomind browse diff url <url1> <url2> [--interactive] [--json]',
3356
+ description:
3357
+ 'Compare two URLs or snapshots. Usage: monomind browse diff url <url1> <url2> [--interactive] [--json]',
2482
3358
  options: [
2483
- { name: 'interactive', short: 'i', type: 'boolean', description: 'Snapshot interactive elements only', default: false },
3359
+ {
3360
+ name: 'interactive',
3361
+ short: 'i',
3362
+ type: 'boolean',
3363
+ description: 'Snapshot interactive elements only',
3364
+ default: false,
3365
+ },
2484
3366
  { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
2485
3367
  ],
2486
3368
  action: async (ctx: CommandContext): Promise<CommandResult> => {
@@ -2496,12 +3378,16 @@ const diffCommand: Command = {
2496
3378
  // Capture snapshot at url1
2497
3379
  await browser.openUrl(client, sessionId, url1);
2498
3380
  await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
2499
- const snap1 = await browser.captureSnapshot(client, sessionId, { interactiveOnly: ctx.flags.interactive as boolean });
3381
+ const snap1 = await browser.captureSnapshot(client, sessionId, {
3382
+ interactiveOnly: ctx.flags.interactive as boolean,
3383
+ });
2500
3384
 
2501
3385
  // Capture snapshot at url2
2502
3386
  await browser.openUrl(client, sessionId, url2);
2503
3387
  await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
2504
- const snap2 = await browser.captureSnapshot(client, sessionId, { interactiveOnly: ctx.flags.interactive as boolean });
3388
+ const snap2 = await browser.captureSnapshot(client, sessionId, {
3389
+ interactiveOnly: ctx.flags.interactive as boolean,
3390
+ });
2505
3391
 
2506
3392
  _refs = snap2.refs;
2507
3393
  await browser.saveRefCache(_targetId, snap2.url, _refs);
@@ -2516,17 +3402,32 @@ const diffCommand: Command = {
2516
3402
  const changed = onlyIn1.length > 0 || onlyIn2.length > 0;
2517
3403
 
2518
3404
  if (ctx.flags.json) {
2519
- print(JSON.stringify({ changed, url1, url2, onlyIn1, onlyIn2, additions: onlyIn2.length, removals: onlyIn1.length }));
3405
+ print(
3406
+ JSON.stringify({
3407
+ changed,
3408
+ url1,
3409
+ url2,
3410
+ onlyIn1,
3411
+ onlyIn2,
3412
+ additions: onlyIn2.length,
3413
+ removals: onlyIn1.length,
3414
+ }),
3415
+ );
2520
3416
  } else {
2521
3417
  if (!changed) {
2522
3418
  output.printSuccess(`No differences between ${url1} and ${url2}`);
2523
3419
  } else {
2524
- output.printWarning(`Diff: ${url1} vs ${url2} — +${onlyIn2.length} lines, -${onlyIn1.length} lines`);
3420
+ output.printWarning(
3421
+ `Diff: ${url1} vs ${url2} — +${onlyIn2.length} lines, -${onlyIn1.length} lines`,
3422
+ );
2525
3423
  for (const l of onlyIn1) print(`\x1b[31m- ${l}\x1b[0m`);
2526
3424
  for (const l of onlyIn2) print(`\x1b[32m+ ${l}\x1b[0m`);
2527
3425
  }
2528
3426
  }
2529
- return { success: true, data: { changed, url1, url2, additions: onlyIn2.length, removals: onlyIn1.length } };
3427
+ return {
3428
+ success: true,
3429
+ data: { changed, url1, url2, additions: onlyIn2.length, removals: onlyIn1.length },
3430
+ };
2530
3431
  }
2531
3432
 
2532
3433
  throw new Error('Usage: monomind browse diff url <url1> <url2>');
@@ -2557,12 +3458,18 @@ function tokenizeBatchCommand(input: string): string[] {
2557
3458
  let inQuote: '"' | "'" | null = null;
2558
3459
  for (const ch of input.trim()) {
2559
3460
  if (inQuote) {
2560
- if (ch === inQuote) { inQuote = null; }
2561
- else { current += ch; }
3461
+ if (ch === inQuote) {
3462
+ inQuote = null;
3463
+ } else {
3464
+ current += ch;
3465
+ }
2562
3466
  } else if (ch === '"' || ch === "'") {
2563
3467
  inQuote = ch;
2564
3468
  } else if (/\s/.test(ch)) {
2565
- if (current) { tokens.push(current); current = ''; }
3469
+ if (current) {
3470
+ tokens.push(current);
3471
+ current = '';
3472
+ }
2566
3473
  } else {
2567
3474
  current += ch;
2568
3475
  }
@@ -2597,8 +3504,15 @@ function tokenizeBatchCommand(input: string): string[] {
2597
3504
  * Exported for direct unit testing — not part of the CLI's public API.
2598
3505
  */
2599
3506
  export function deriveBoxOutput(
2600
- center: { x: number; y: number; width: number; height: number } | null
2601
- ): { x: number; y: number; width: number; height: number; centerX: number; centerY: number } | null {
3507
+ center: { x: number; y: number; width: number; height: number } | null,
3508
+ ): {
3509
+ x: number;
3510
+ y: number;
3511
+ width: number;
3512
+ height: number;
3513
+ centerX: number;
3514
+ centerY: number;
3515
+ } | null {
2602
3516
  if (!center) return null;
2603
3517
  return {
2604
3518
  x: center.x - center.width / 2,
@@ -2610,7 +3524,11 @@ export function deriveBoxOutput(
2610
3524
  };
2611
3525
  }
2612
3526
 
2613
- export function parseBatchCommandLine(cmdStr: string): { subName: string; subArgs: string[]; flags: Record<string, unknown> } {
3527
+ export function parseBatchCommandLine(cmdStr: string): {
3528
+ subName: string;
3529
+ subArgs: string[];
3530
+ flags: Record<string, unknown>;
3531
+ } {
2614
3532
  const trimmed = cmdStr.trim();
2615
3533
  const evalMatch = trimmed.match(/^eval\b\s*/);
2616
3534
  if (!evalMatch) {
@@ -2635,7 +3553,6 @@ export function parseBatchCommandLine(cmdStr: string): { subName: string; subArg
2635
3553
  flags[boolFlag[1]] = true;
2636
3554
  rest = rest.slice(boolFlag[0].length);
2637
3555
  consumedAnother = true;
2638
- continue;
2639
3556
  }
2640
3557
  }
2641
3558
  return { subName: 'eval', subArgs: [rest], flags };
@@ -2643,7 +3560,8 @@ export function parseBatchCommandLine(cmdStr: string): { subName: string; subArg
2643
3560
 
2644
3561
  const batchCommand: Command = {
2645
3562
  name: 'batch',
2646
- description: 'Execute multiple commands. Usage: monomind browse batch "open url" "snapshot -i" "click @e1"',
3563
+ description:
3564
+ 'Execute multiple commands. Usage: monomind browse batch "open url" "snapshot -i" "click @e1"',
2647
3565
  options: [
2648
3566
  { name: 'bail', type: 'boolean', description: 'Stop on first error', default: false },
2649
3567
  { name: 'json', type: 'boolean', description: 'Input from JSON stdin', default: false },
@@ -2692,7 +3610,11 @@ const batchCommand: Command = {
2692
3610
  } else {
2693
3611
  parsedFlags[key] = true;
2694
3612
  }
2695
- } else if (subArgs[i].startsWith('-') && subArgs[i].length === 2 && /[a-zA-Z]/.test(subArgs[i][1])) {
3613
+ } else if (
3614
+ subArgs[i].startsWith('-') &&
3615
+ subArgs[i].length === 2 &&
3616
+ /[a-zA-Z]/.test(subArgs[i][1])
3617
+ ) {
2696
3618
  consumedIndices.add(i);
2697
3619
  const shortKey = subArgs[i][1];
2698
3620
  const optDef = subCmd.options?.find((o) => o.short === shortKey);
@@ -2723,7 +3645,11 @@ const batchCommand: Command = {
2723
3645
 
2724
3646
  const cmdResult = await subCmd.action(fakeCtx);
2725
3647
  const succeeded = cmdResult?.success !== false;
2726
- results.push({ command: cmdStr, success: succeeded, error: succeeded ? undefined : 'Command returned failure' });
3648
+ results.push({
3649
+ command: cmdStr,
3650
+ success: succeeded,
3651
+ error: succeeded ? undefined : 'Command returned failure',
3652
+ });
2727
3653
  if (!succeeded && ctx.flags.bail) break;
2728
3654
  } catch (e) {
2729
3655
  const err = e instanceof Error ? e.message : String(e);
@@ -2741,7 +3667,8 @@ const batchCommand: Command = {
2741
3667
 
2742
3668
  const addinitscriptCommand: Command = {
2743
3669
  name: 'addinitscript',
2744
- description: 'Add script to run before page navigation. Usage: monomind browse addinitscript "window.x=1"',
3670
+ description:
3671
+ 'Add script to run before page navigation. Usage: monomind browse addinitscript "window.x=1"',
2745
3672
  action: async (ctx: CommandContext): Promise<CommandResult> => {
2746
3673
  const { client, sessionId } = await ensureConnected(_port);
2747
3674
  const browser = await getBrowser();
@@ -2755,7 +3682,8 @@ const addinitscriptCommand: Command = {
2755
3682
 
2756
3683
  const removeinitscriptCommand: Command = {
2757
3684
  name: 'removeinitscript',
2758
- description: 'Remove a previously added init script. Usage: monomind browse removeinitscript <id>',
3685
+ description:
3686
+ 'Remove a previously added init script. Usage: monomind browse removeinitscript <id>',
2759
3687
  action: async (ctx: CommandContext): Promise<CommandResult> => {
2760
3688
  const { client, sessionId } = await ensureConnected(_port);
2761
3689
  const browser = await getBrowser();
@@ -2769,11 +3697,17 @@ const removeinitscriptCommand: Command = {
2769
3697
 
2770
3698
  const connectCommand: Command = {
2771
3699
  name: 'connect',
2772
- description: 'Connect to existing Chrome instance; later commands reuse this session (note: `open` without --port still launches on its own default). Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
3700
+ description:
3701
+ 'Connect to existing Chrome instance; later commands reuse this session (note: `open` without --port still launches on its own default). Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
2773
3702
  options: [
2774
3703
  { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
2775
3704
  { name: 'target', type: 'string', description: 'Target ID to attach to' },
2776
- { name: 'auto-connect', type: 'boolean', description: 'Auto-discover running Chrome on ports 9222 and 9229', default: false },
3705
+ {
3706
+ name: 'auto-connect',
3707
+ type: 'boolean',
3708
+ description: 'Auto-discover running Chrome on ports 9222 and 9229',
3709
+ default: false,
3710
+ },
2777
3711
  ],
2778
3712
  action: async (ctx: CommandContext): Promise<CommandResult> => {
2779
3713
  let port = (ctx.flags.port as number) ?? 9222;
@@ -2784,10 +3718,19 @@ const connectCommand: Command = {
2784
3718
  for (const p of probePorts) {
2785
3719
  try {
2786
3720
  const r = await fetch(`http://127.0.0.1:${p}/json/version`);
2787
- if (r.ok) { port = p; found = true; break; }
2788
- } catch { /* port not open */ }
3721
+ if (r.ok) {
3722
+ port = p;
3723
+ found = true;
3724
+ break;
3725
+ }
3726
+ } catch {
3727
+ /* port not open */
3728
+ }
2789
3729
  }
2790
- if (!found) throw new Error('No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --port.');
3730
+ if (!found)
3731
+ throw new Error(
3732
+ 'No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --port.',
3733
+ );
2791
3734
  }
2792
3735
 
2793
3736
  const browser = await getBrowser();
@@ -2796,13 +3739,25 @@ const connectCommand: Command = {
2796
3739
  const prevSid = _sessionId;
2797
3740
  const prevClient = _client;
2798
3741
  if (browser.getHarStatus(prevSid).recording) {
2799
- try { await browser.stopHarRecording(prevClient, prevSid); } catch { /* ignore */ }
3742
+ try {
3743
+ await browser.stopHarRecording(prevClient, prevSid);
3744
+ } catch {
3745
+ /* ignore */
3746
+ }
2800
3747
  }
2801
3748
  if (browser.getTraceStatus(prevSid)) {
2802
- try { await browser.stopTrace(prevClient, prevSid); } catch { /* ignore */ }
3749
+ try {
3750
+ await browser.stopTrace(prevClient, prevSid);
3751
+ } catch {
3752
+ /* ignore */
3753
+ }
2803
3754
  }
2804
3755
  if (browser.isProfilingActive(prevSid)) {
2805
- try { await browser.stopCpuProfile(prevClient, prevSid); } catch { /* ignore */ }
3756
+ try {
3757
+ await browser.stopCpuProfile(prevClient, prevSid);
3758
+ } catch {
3759
+ /* ignore */
3760
+ }
2806
3761
  }
2807
3762
  browser.teardownRouteInterception(prevSid);
2808
3763
  browser.stopRequestCapture(prevSid);
@@ -2880,7 +3835,10 @@ const recordCommand: Command = {
2880
3835
  case 'status': {
2881
3836
  const status = browser.getRecordingStatus(sessionId);
2882
3837
  if (ctx.flags.json) print(JSON.stringify({ data: status }));
2883
- else print(`Recording: ${status.recording} | Frames: ${status.frames}${status.autoStopped ? ' (auto-stopped: buffer limit reached — run "record stop" to save)' : ''}`);
3838
+ else
3839
+ print(
3840
+ `Recording: ${status.recording} | Frames: ${status.frames}${status.autoStopped ? ' (auto-stopped: buffer limit reached — run "record stop" to save)' : ''}`,
3841
+ );
2884
3842
  return { success: true, data: status };
2885
3843
  }
2886
3844
  default:
@@ -2904,7 +3862,9 @@ const traceCommand: Command = {
2904
3862
 
2905
3863
  switch (action) {
2906
3864
  case 'start':
2907
- await browser.startTrace(client, sessionId, { screenshots: ctx.flags.screenshots as boolean });
3865
+ await browser.startTrace(client, sessionId, {
3866
+ screenshots: ctx.flags.screenshots as boolean,
3867
+ });
2908
3868
  output.printSuccess('Trace started');
2909
3869
  return { success: true };
2910
3870
  case 'stop': {
@@ -2937,7 +3897,9 @@ const profilerCommand: Command = {
2937
3897
 
2938
3898
  switch (action) {
2939
3899
  case 'start':
2940
- await browser.startCpuProfile(client, sessionId, { samplingInterval: ctx.flags.interval as number });
3900
+ await browser.startCpuProfile(client, sessionId, {
3901
+ samplingInterval: ctx.flags.interval as number,
3902
+ });
2941
3903
  output.printSuccess('CPU profiler started');
2942
3904
  return { success: true };
2943
3905
  case 'stop': {
@@ -2997,7 +3959,12 @@ const harCommand: Command = {
2997
3959
  output.printSuccess('HAR recording started');
2998
3960
  return { success: true };
2999
3961
  case 'stop': {
3000
- const path = await browser.stopHarRecording(client, sessionId, ctx.args[1] as string, ctx.flags.bodies as boolean);
3962
+ const path = await browser.stopHarRecording(
3963
+ client,
3964
+ sessionId,
3965
+ ctx.args[1] as string,
3966
+ ctx.flags.bodies as boolean,
3967
+ );
3001
3968
  if (ctx.flags.json) print(JSON.stringify({ data: { path } }));
3002
3969
  else output.printSuccess(`HAR saved: ${path}`);
3003
3970
  return { success: true, data: { path } };
@@ -3022,7 +3989,8 @@ const resizeCommand: Command = {
3022
3989
  const browser = await getBrowser();
3023
3990
  const width = parseInt(ctx.args[0] as string, 10);
3024
3991
  const height = parseInt(ctx.args[1] as string, 10);
3025
- if (isNaN(width) || isNaN(height)) throw new Error('Usage: monomind browse resize <width> <height>');
3992
+ if (Number.isNaN(width) || Number.isNaN(height))
3993
+ throw new Error('Usage: monomind browse resize <width> <height>');
3026
3994
  await browser.setViewport(client, sessionId, width, height);
3027
3995
  output.printSuccess(`Resized to ${width}x${height}`);
3028
3996
  return { success: true, data: { width, height } };
@@ -3157,6 +4125,7 @@ const browseCommand: Command = {
3157
4125
  traceCommand,
3158
4126
  profilerCommand,
3159
4127
  vitalsCommand,
4128
+ reportCommand,
3160
4129
  harCommand,
3161
4130
  resizeCommand,
3162
4131
  closeCommand,
@@ -3169,7 +4138,10 @@ const browseCommand: Command = {
3169
4138
  ],
3170
4139
  examples: [
3171
4140
  { command: 'monomind browse open https://example.com', description: 'Open a URL' },
3172
- { command: 'monomind browse snapshot -i', description: 'Interactive-only snapshot (93% token reduction)' },
4141
+ {
4142
+ command: 'monomind browse snapshot -i',
4143
+ description: 'Interactive-only snapshot (93% token reduction)',
4144
+ },
3173
4145
  { command: 'monomind browse click @e3', description: 'Click element by ref' },
3174
4146
  { command: 'monomind browse fill @e1 "user@example.com"', description: 'Fill an input' },
3175
4147
  { command: 'monomind browse press Enter', description: 'Press Enter key' },
@@ -3183,7 +4155,14 @@ const browseCommand: Command = {
3183
4155
  { command: 'monomind browse state save my-session', description: 'Save session state' },
3184
4156
  { command: 'monomind browse navigate back', description: 'Navigate back' },
3185
4157
  { command: 'monomind browse eval "document.title"', description: 'Evaluate JavaScript' },
3186
- { command: 'monomind browse network route --pattern "https://api.*" --abort', description: 'Abort API calls' },
4158
+ {
4159
+ command: 'monomind browse network route --pattern "https://api.*" --abort',
4160
+ description: 'Abort API calls',
4161
+ },
4162
+ {
4163
+ command: 'monomind browse report https://example.com',
4164
+ description: 'One-command page report with a pass/fail verdict',
4165
+ },
3187
4166
  { command: 'monomind browse close', description: 'Close browser session' },
3188
4167
  ],
3189
4168
  action: async (_ctx: CommandContext): Promise<CommandResult> => {
@@ -3240,6 +4219,7 @@ const browseCommand: Command = {
3240
4219
  output.printInfo(' batch Execute multiple commands');
3241
4220
  output.printInfo(' addinitscript Add script to run before page navigation');
3242
4221
  output.printInfo(' removeinitscript Remove a previously added init script');
4222
+ output.printInfo(' report Test a page and write an HTML report');
3243
4223
  output.printInfo(' close Close the browser session');
3244
4224
  return { success: true };
3245
4225
  },