@jackwener/opencli 1.6.1 → 1.6.2

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 (384) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +27 -45
  3. package/README.zh-CN.md +32 -34
  4. package/autoresearch/browse-tasks.json +18 -20
  5. package/autoresearch/commands/debug.ts +163 -0
  6. package/autoresearch/commands/fix.ts +145 -0
  7. package/autoresearch/commands/plan.ts +88 -0
  8. package/autoresearch/commands/run.ts +138 -0
  9. package/autoresearch/config.ts +82 -0
  10. package/autoresearch/engine.ts +359 -0
  11. package/autoresearch/eval-all.ts +127 -0
  12. package/autoresearch/eval-browse.ts +1 -1
  13. package/autoresearch/eval-publish.ts +238 -0
  14. package/autoresearch/eval-save.ts +249 -0
  15. package/autoresearch/eval-skill.ts +14 -8
  16. package/autoresearch/eval-v2ex.ts +220 -0
  17. package/autoresearch/eval-zhihu.ts +230 -0
  18. package/autoresearch/logger.ts +69 -0
  19. package/autoresearch/presets/combined-reliability.ts +27 -0
  20. package/autoresearch/presets/index.ts +23 -0
  21. package/autoresearch/presets/operate-reliability.ts +24 -0
  22. package/autoresearch/presets/save-reliability.ts +26 -0
  23. package/autoresearch/presets/skill-quality.ts +20 -0
  24. package/autoresearch/presets/v2ex-reliability.ts +24 -0
  25. package/autoresearch/presets/zhihu-reliability.ts +25 -0
  26. package/autoresearch/publish-tasks.json +345 -0
  27. package/autoresearch/run-save.sh +11 -0
  28. package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
  29. package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
  30. package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
  31. package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
  32. package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
  33. package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
  34. package/autoresearch/save-tasks.json +281 -0
  35. package/autoresearch/v2ex-tasks.json +899 -0
  36. package/autoresearch/zhihu-tasks.json +848 -0
  37. package/dist/browser/base-page.d.ts +4 -2
  38. package/dist/browser/base-page.js +37 -4
  39. package/dist/browser/bridge.js +10 -8
  40. package/dist/browser/cdp.js +2 -6
  41. package/dist/browser/daemon-client.d.ts +11 -1
  42. package/dist/browser/daemon-client.js +3 -0
  43. package/dist/browser/dom-helpers.d.ts +4 -2
  44. package/dist/browser/dom-helpers.js +42 -31
  45. package/dist/browser/dom-snapshot.js +23 -1
  46. package/dist/browser/page.d.ts +7 -2
  47. package/dist/browser/page.js +112 -30
  48. package/dist/browser.test.js +1 -1
  49. package/dist/build-manifest.d.ts +1 -0
  50. package/dist/build-manifest.js +1 -0
  51. package/dist/cli-manifest.json +1135 -184
  52. package/dist/cli.d.ts +2 -0
  53. package/dist/cli.js +48 -7
  54. package/dist/cli.test.d.ts +1 -0
  55. package/dist/cli.test.js +88 -0
  56. package/dist/clis/1688/item.d.ts +70 -0
  57. package/dist/clis/1688/item.js +187 -0
  58. package/dist/clis/1688/item.test.d.ts +1 -0
  59. package/dist/clis/1688/item.test.js +67 -0
  60. package/dist/clis/1688/search.d.ts +56 -0
  61. package/dist/clis/1688/search.js +309 -0
  62. package/dist/clis/1688/search.test.d.ts +1 -0
  63. package/dist/clis/1688/search.test.js +75 -0
  64. package/dist/clis/1688/shared.d.ts +112 -0
  65. package/dist/clis/1688/shared.js +514 -0
  66. package/dist/clis/1688/shared.test.d.ts +1 -0
  67. package/dist/clis/1688/shared.test.js +57 -0
  68. package/dist/clis/1688/store.d.ts +45 -0
  69. package/dist/clis/1688/store.js +226 -0
  70. package/dist/clis/1688/store.test.d.ts +1 -0
  71. package/dist/clis/1688/store.test.js +62 -0
  72. package/dist/clis/amazon/bestsellers.d.ts +0 -20
  73. package/dist/clis/amazon/bestsellers.js +6 -129
  74. package/dist/clis/amazon/bestsellers.test.js +12 -3
  75. package/dist/clis/amazon/movers-shakers.d.ts +1 -0
  76. package/dist/clis/amazon/movers-shakers.js +7 -0
  77. package/dist/clis/amazon/new-releases.d.ts +1 -0
  78. package/dist/clis/amazon/new-releases.js +7 -0
  79. package/dist/clis/amazon/rankings.d.ts +59 -0
  80. package/dist/clis/amazon/rankings.js +226 -0
  81. package/dist/clis/amazon/rankings.test.d.ts +1 -0
  82. package/dist/clis/amazon/rankings.test.js +41 -0
  83. package/dist/clis/amazon/shared.d.ts +11 -0
  84. package/dist/clis/amazon/shared.js +121 -11
  85. package/dist/clis/amazon/shared.test.js +11 -0
  86. package/dist/clis/bilibili/comments.js +2 -2
  87. package/dist/clis/bilibili/comments.test.js +3 -2
  88. package/dist/clis/bilibili/download.js +2 -1
  89. package/dist/clis/bilibili/subtitle.js +4 -3
  90. package/dist/clis/bilibili/subtitle.test.js +2 -1
  91. package/dist/clis/bilibili/utils.d.ts +5 -0
  92. package/dist/clis/bilibili/utils.js +30 -0
  93. package/dist/clis/bilibili/utils.test.d.ts +1 -0
  94. package/dist/clis/bilibili/utils.test.js +17 -0
  95. package/dist/clis/douban/marks.js +1 -1
  96. package/dist/clis/douban/subject.yaml +50 -19
  97. package/dist/clis/doubao/utils.js +32 -12
  98. package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
  99. package/dist/clis/douyin/_shared/transcode.test.js +0 -2
  100. package/dist/clis/douyin/draft.test.js +0 -2
  101. package/dist/clis/facebook/search.test.js +0 -2
  102. package/dist/clis/gemini/ask.js +9 -3
  103. package/dist/clis/gemini/ask.test.d.ts +1 -0
  104. package/dist/clis/gemini/ask.test.js +100 -0
  105. package/dist/clis/gemini/reply-state.test.d.ts +1 -0
  106. package/dist/clis/gemini/reply-state.test.js +641 -0
  107. package/dist/clis/gemini/utils.d.ts +44 -1
  108. package/dist/clis/gemini/utils.js +528 -61
  109. package/dist/clis/gemini/utils.test.js +149 -2
  110. package/dist/clis/hupu/detail.d.ts +1 -0
  111. package/dist/clis/hupu/detail.js +72 -0
  112. package/dist/clis/hupu/hot.yaml +43 -0
  113. package/dist/clis/hupu/like.d.ts +1 -0
  114. package/dist/clis/hupu/like.js +75 -0
  115. package/dist/clis/hupu/reply.d.ts +1 -0
  116. package/dist/clis/hupu/reply.js +71 -0
  117. package/dist/clis/hupu/search.d.ts +1 -0
  118. package/dist/clis/hupu/search.js +59 -0
  119. package/dist/clis/hupu/unlike.d.ts +1 -0
  120. package/dist/clis/hupu/unlike.js +75 -0
  121. package/dist/clis/hupu/utils.d.ts +20 -0
  122. package/dist/clis/hupu/utils.js +319 -0
  123. package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
  124. package/dist/clis/instagram/_shared/private-publish.js +1030 -0
  125. package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
  126. package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
  127. package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
  128. package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
  129. package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
  130. package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
  131. package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
  132. package/dist/clis/instagram/_shared/runtime-info.js +81 -0
  133. package/dist/clis/instagram/note.d.ts +1 -0
  134. package/dist/clis/instagram/note.js +222 -0
  135. package/dist/clis/instagram/note.test.d.ts +1 -0
  136. package/dist/clis/instagram/note.test.js +81 -0
  137. package/dist/clis/instagram/post.d.ts +4 -0
  138. package/dist/clis/instagram/post.js +1496 -0
  139. package/dist/clis/instagram/post.test.d.ts +1 -0
  140. package/dist/clis/instagram/post.test.js +1647 -0
  141. package/dist/clis/instagram/reel.d.ts +1 -0
  142. package/dist/clis/instagram/reel.js +826 -0
  143. package/dist/clis/instagram/reel.test.d.ts +1 -0
  144. package/dist/clis/instagram/reel.test.js +167 -0
  145. package/dist/clis/instagram/story.d.ts +1 -0
  146. package/dist/clis/instagram/story.js +115 -0
  147. package/dist/clis/instagram/story.test.d.ts +1 -0
  148. package/dist/clis/instagram/story.test.js +167 -0
  149. package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
  150. package/dist/clis/sinafinance/stock-rank.js +65 -0
  151. package/dist/clis/substack/utils.test.js +0 -2
  152. package/dist/clis/twitter/post.js +72 -45
  153. package/dist/clis/twitter/post.test.d.ts +1 -0
  154. package/dist/clis/twitter/post.test.js +116 -0
  155. package/dist/clis/twitter/reply.d.ts +12 -0
  156. package/dist/clis/twitter/reply.js +257 -35
  157. package/dist/clis/twitter/reply.test.d.ts +1 -0
  158. package/dist/clis/twitter/reply.test.js +151 -0
  159. package/dist/clis/xianyu/chat.d.ts +7 -0
  160. package/dist/clis/xianyu/chat.js +146 -0
  161. package/dist/clis/xianyu/chat.test.d.ts +1 -0
  162. package/dist/clis/xianyu/chat.test.js +15 -0
  163. package/dist/clis/xianyu/item.d.ts +7 -0
  164. package/dist/clis/xianyu/item.js +152 -0
  165. package/dist/clis/xianyu/item.test.d.ts +1 -0
  166. package/dist/clis/xianyu/item.test.js +56 -0
  167. package/dist/clis/xianyu/search.d.ts +10 -0
  168. package/dist/clis/xianyu/search.js +134 -0
  169. package/dist/clis/xianyu/search.test.d.ts +1 -0
  170. package/dist/clis/xianyu/search.test.js +17 -0
  171. package/dist/clis/xianyu/utils.d.ts +1 -0
  172. package/dist/clis/xianyu/utils.js +8 -0
  173. package/dist/clis/xiaoe/catalog.yaml +129 -0
  174. package/dist/clis/xiaoe/content.yaml +43 -0
  175. package/dist/clis/xiaoe/courses.yaml +73 -0
  176. package/dist/clis/xiaoe/detail.yaml +39 -0
  177. package/dist/clis/xiaoe/play-url.yaml +124 -0
  178. package/dist/clis/xiaohongshu/comments.test.js +0 -2
  179. package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
  180. package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
  181. package/dist/clis/xiaohongshu/download.test.js +0 -2
  182. package/dist/clis/xiaohongshu/note.test.js +0 -2
  183. package/dist/clis/xiaohongshu/publish.test.js +0 -2
  184. package/dist/clis/xiaohongshu/search.js +29 -20
  185. package/dist/clis/xiaohongshu/search.test.js +56 -48
  186. package/dist/clis/yuanbao/ask.d.ts +21 -0
  187. package/dist/clis/yuanbao/ask.js +427 -0
  188. package/dist/clis/yuanbao/ask.test.d.ts +1 -0
  189. package/dist/clis/yuanbao/ask.test.js +124 -0
  190. package/dist/clis/yuanbao/new.d.ts +1 -0
  191. package/dist/clis/yuanbao/new.js +70 -0
  192. package/dist/clis/yuanbao/new.test.d.ts +1 -0
  193. package/dist/clis/yuanbao/new.test.js +30 -0
  194. package/dist/clis/yuanbao/shared.d.ts +13 -0
  195. package/dist/clis/yuanbao/shared.js +49 -0
  196. package/dist/clis/zhihu/question.js +30 -19
  197. package/dist/clis/zhihu/question.test.js +34 -16
  198. package/dist/commanderAdapter.js +8 -4
  199. package/dist/commanderAdapter.test.js +42 -0
  200. package/dist/completion.js +3 -1
  201. package/dist/completion.test.d.ts +1 -0
  202. package/dist/completion.test.js +23 -0
  203. package/dist/doctor.js +1 -1
  204. package/dist/electron-apps.d.ts +2 -0
  205. package/dist/electron-apps.js +7 -1
  206. package/dist/errors.js +1 -1
  207. package/dist/execution.js +25 -35
  208. package/dist/explore.js +1 -1
  209. package/dist/launcher.d.ts +4 -0
  210. package/dist/launcher.js +64 -8
  211. package/dist/launcher.test.js +88 -7
  212. package/dist/output.d.ts +2 -0
  213. package/dist/output.js +10 -1
  214. package/dist/output.test.d.ts +0 -3
  215. package/dist/output.test.js +59 -92
  216. package/dist/pipeline/executor.test.js +0 -2
  217. package/dist/pipeline/steps/download.test.js +0 -2
  218. package/dist/registry.d.ts +2 -0
  219. package/dist/serialization.d.ts +1 -0
  220. package/dist/serialization.js +1 -0
  221. package/dist/types.d.ts +9 -2
  222. package/docs/.vitepress/config.mts +4 -0
  223. package/docs/adapters/browser/1688.md +52 -0
  224. package/docs/adapters/browser/36kr.md +2 -1
  225. package/docs/adapters/browser/doubao.md +5 -1
  226. package/docs/adapters/browser/hupu.md +53 -0
  227. package/docs/adapters/browser/sinafinance.md +32 -2
  228. package/docs/adapters/browser/weibo.md +6 -1
  229. package/docs/adapters/browser/wikipedia.md +2 -0
  230. package/docs/adapters/browser/xianyu.md +42 -0
  231. package/docs/adapters/browser/xiaoe.md +44 -0
  232. package/docs/adapters/browser/yuanbao.md +64 -0
  233. package/docs/adapters/index.md +14 -5
  234. package/docs/comparison.md +1 -1
  235. package/docs/developer/ai-workflow.md +2 -2
  236. package/docs/developer/contributing.md +1 -1
  237. package/docs/developer/testing.md +2 -0
  238. package/docs/guide/plugins.md +1 -0
  239. package/docs/guide/troubleshooting.md +11 -0
  240. package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
  241. package/docs/zh/guide/plugins.md +1 -0
  242. package/extension/dist/background.js +1127 -0
  243. package/extension/src/background.test.ts +39 -0
  244. package/extension/src/background.ts +223 -34
  245. package/extension/src/cdp.ts +194 -4
  246. package/extension/src/protocol.ts +22 -1
  247. package/package.json +3 -2
  248. package/scripts/postinstall.js +1 -1
  249. package/skills/opencli-explorer/SKILL.md +1 -1
  250. package/skills/opencli-oneshot/SKILL.md +2 -2
  251. package/skills/opencli-operate/SKILL.md +120 -27
  252. package/skills/opencli-usage/SKILL.md +31 -20
  253. package/skills/opencli-usage/browser.md +114 -16
  254. package/skills/opencli-usage/public-api.md +32 -3
  255. package/skills/smart-search/SKILL.md +156 -0
  256. package/skills/smart-search/references/sources-ai.md +74 -0
  257. package/skills/smart-search/references/sources-info.md +43 -0
  258. package/skills/smart-search/references/sources-media.md +50 -0
  259. package/skills/smart-search/references/sources-other.md +42 -0
  260. package/skills/smart-search/references/sources-shopping.md +31 -0
  261. package/skills/smart-search/references/sources-social.md +51 -0
  262. package/skills/smart-search/references/sources-tech.md +42 -0
  263. package/skills/smart-search/references/sources-travel.md +20 -0
  264. package/src/browser/base-page.ts +41 -6
  265. package/src/browser/bridge.ts +11 -8
  266. package/src/browser/cdp.ts +1 -8
  267. package/src/browser/daemon-client.ts +11 -1
  268. package/src/browser/dom-helpers.ts +43 -31
  269. package/src/browser/dom-snapshot.ts +23 -1
  270. package/src/browser/page.ts +115 -31
  271. package/src/browser.test.ts +1 -1
  272. package/src/build-manifest.ts +2 -0
  273. package/src/cli.test.ts +133 -0
  274. package/src/cli.ts +73 -11
  275. package/src/clis/1688/item.test.ts +69 -0
  276. package/src/clis/1688/item.ts +282 -0
  277. package/src/clis/1688/search.test.ts +81 -0
  278. package/src/clis/1688/search.ts +402 -0
  279. package/src/clis/1688/shared.test.ts +75 -0
  280. package/src/clis/1688/shared.ts +623 -0
  281. package/src/clis/1688/store.test.ts +69 -0
  282. package/src/clis/1688/store.ts +300 -0
  283. package/src/clis/amazon/bestsellers.test.ts +12 -3
  284. package/src/clis/amazon/bestsellers.ts +6 -178
  285. package/src/clis/amazon/movers-shakers.ts +8 -0
  286. package/src/clis/amazon/new-releases.ts +8 -0
  287. package/src/clis/amazon/rankings.test.ts +47 -0
  288. package/src/clis/amazon/rankings.ts +312 -0
  289. package/src/clis/amazon/shared.test.ts +16 -0
  290. package/src/clis/amazon/shared.ts +134 -12
  291. package/src/clis/bilibili/comments.test.ts +4 -3
  292. package/src/clis/bilibili/comments.ts +2 -2
  293. package/src/clis/bilibili/download.ts +2 -1
  294. package/src/clis/bilibili/subtitle.test.ts +2 -1
  295. package/src/clis/bilibili/subtitle.ts +4 -3
  296. package/src/clis/bilibili/utils.test.ts +21 -0
  297. package/src/clis/bilibili/utils.ts +27 -0
  298. package/src/clis/douban/marks.ts +1 -1
  299. package/src/clis/douban/subject.yaml +50 -19
  300. package/src/clis/doubao/utils.ts +32 -12
  301. package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
  302. package/src/clis/douyin/_shared/transcode.test.ts +0 -2
  303. package/src/clis/douyin/draft.test.ts +0 -2
  304. package/src/clis/facebook/search.test.ts +0 -2
  305. package/src/clis/gemini/ask.test.ts +116 -0
  306. package/src/clis/gemini/ask.ts +10 -3
  307. package/src/clis/gemini/reply-state.test.ts +708 -0
  308. package/src/clis/gemini/utils.test.ts +184 -2
  309. package/src/clis/gemini/utils.ts +588 -60
  310. package/src/clis/hupu/detail.ts +126 -0
  311. package/src/clis/hupu/hot.yaml +43 -0
  312. package/src/clis/hupu/like.ts +76 -0
  313. package/src/clis/hupu/reply.ts +76 -0
  314. package/src/clis/hupu/search.ts +95 -0
  315. package/src/clis/hupu/unlike.ts +76 -0
  316. package/src/clis/hupu/utils.ts +381 -0
  317. package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
  318. package/src/clis/instagram/_shared/private-publish.ts +1303 -0
  319. package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
  320. package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
  321. package/src/clis/instagram/_shared/runtime-info.ts +91 -0
  322. package/src/clis/instagram/note.test.ts +96 -0
  323. package/src/clis/instagram/note.ts +254 -0
  324. package/src/clis/instagram/post.test.ts +1716 -0
  325. package/src/clis/instagram/post.ts +1620 -0
  326. package/src/clis/instagram/reel.test.ts +191 -0
  327. package/src/clis/instagram/reel.ts +886 -0
  328. package/src/clis/instagram/story.test.ts +191 -0
  329. package/src/clis/instagram/story.ts +151 -0
  330. package/src/clis/sinafinance/stock-rank.ts +68 -0
  331. package/src/clis/substack/utils.test.ts +0 -2
  332. package/src/clis/twitter/post.test.ts +157 -0
  333. package/src/clis/twitter/post.ts +82 -48
  334. package/src/clis/twitter/reply.test.ts +177 -0
  335. package/src/clis/twitter/reply.ts +285 -39
  336. package/src/clis/xianyu/chat.test.ts +20 -0
  337. package/src/clis/xianyu/chat.ts +175 -0
  338. package/src/clis/xianyu/item.test.ts +67 -0
  339. package/src/clis/xianyu/item.ts +172 -0
  340. package/src/clis/xianyu/search.test.ts +22 -0
  341. package/src/clis/xianyu/search.ts +151 -0
  342. package/src/clis/xianyu/utils.ts +9 -0
  343. package/src/clis/xiaoe/catalog.yaml +129 -0
  344. package/src/clis/xiaoe/content.yaml +43 -0
  345. package/src/clis/xiaoe/courses.yaml +73 -0
  346. package/src/clis/xiaoe/detail.yaml +39 -0
  347. package/src/clis/xiaoe/play-url.yaml +124 -0
  348. package/src/clis/xiaohongshu/comments.test.ts +0 -2
  349. package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
  350. package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
  351. package/src/clis/xiaohongshu/download.test.ts +0 -2
  352. package/src/clis/xiaohongshu/note.test.ts +0 -2
  353. package/src/clis/xiaohongshu/publish.test.ts +0 -2
  354. package/src/clis/xiaohongshu/search.test.ts +59 -48
  355. package/src/clis/xiaohongshu/search.ts +31 -21
  356. package/src/clis/yuanbao/ask.test.ts +156 -0
  357. package/src/clis/yuanbao/ask.ts +522 -0
  358. package/src/clis/yuanbao/new.test.ts +36 -0
  359. package/src/clis/yuanbao/new.ts +81 -0
  360. package/src/clis/yuanbao/shared.ts +57 -0
  361. package/src/clis/zhihu/question.test.ts +42 -17
  362. package/src/clis/zhihu/question.ts +31 -26
  363. package/src/commanderAdapter.test.ts +51 -0
  364. package/src/commanderAdapter.ts +8 -4
  365. package/src/completion.test.ts +30 -0
  366. package/src/completion.ts +3 -1
  367. package/src/doctor.ts +1 -1
  368. package/src/electron-apps.ts +9 -1
  369. package/src/errors.ts +1 -1
  370. package/src/execution.ts +26 -30
  371. package/src/explore.ts +1 -1
  372. package/src/launcher.test.ts +121 -7
  373. package/src/launcher.ts +87 -9
  374. package/src/output.test.ts +50 -90
  375. package/src/output.ts +10 -1
  376. package/src/pipeline/executor.test.ts +0 -2
  377. package/src/pipeline/steps/download.test.ts +0 -2
  378. package/src/registry.ts +2 -0
  379. package/src/serialization.ts +2 -0
  380. package/src/types.ts +9 -2
  381. package/tests/e2e/browser-auth.test.ts +9 -0
  382. package/CLI-EXPLORER.md +0 -724
  383. package/CLI-ONESHOT.md +0 -216
  384. package/SKILL.md +0 -59
@@ -1,30 +1,29 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '../../registry.js';
3
- import { AuthRequiredError } from '../../errors.js';
3
+ import { AuthRequiredError, CliError } from '../../errors.js';
4
4
  import './question.js';
5
5
 
6
6
  describe('zhihu question', () => {
7
- it('returns answers even when the unused question detail request fails', async () => {
7
+ it('returns answers from the Zhihu API', async () => {
8
8
  const cmd = getRegistry().get('zhihu/question');
9
9
  expect(cmd?.func).toBeTypeOf('function');
10
10
 
11
- const evaluate = vi.fn().mockImplementation(async (_fn: unknown, args: { questionId: string; answerLimit: number }) => {
12
- expect(args).toEqual({ questionId: '2021881398772981878', answerLimit: 3 });
11
+ const goto = vi.fn().mockResolvedValue(undefined);
12
+ const evaluate = vi.fn().mockImplementation(async (js: string) => {
13
+ expect(js).toContain('questions/2021881398772981878/answers?limit=3');
14
+ expect(js).toContain("credentials: 'include'");
13
15
  return {
14
- ok: true,
15
- answers: [
16
+ data: [
16
17
  {
17
18
  author: { name: 'alice' },
18
19
  voteup_count: 12,
19
- content: '<p>Hello <b>Zhihu</b></p>',
20
+ content: 'Hello Zhihu',
20
21
  },
21
22
  ],
22
23
  };
23
24
  });
24
25
 
25
- const page = {
26
- evaluate,
27
- } as any;
26
+ const page = { goto, evaluate } as any;
28
27
 
29
28
  await expect(
30
29
  cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
@@ -37,15 +36,15 @@ describe('zhihu question', () => {
37
36
  },
38
37
  ]);
39
38
 
39
+ expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878');
40
40
  expect(evaluate).toHaveBeenCalledTimes(1);
41
41
  });
42
42
 
43
43
  it('maps auth-like answer failures to AuthRequiredError', async () => {
44
44
  const cmd = getRegistry().get('zhihu/question');
45
- expect(cmd?.func).toBeTypeOf('function');
46
-
47
45
  const page = {
48
- evaluate: vi.fn().mockResolvedValue({ ok: false, status: 403 }),
46
+ goto: vi.fn().mockResolvedValue(undefined),
47
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
49
48
  } as any;
50
49
 
51
50
  await expect(
@@ -53,19 +52,45 @@ describe('zhihu question', () => {
53
52
  ).rejects.toBeInstanceOf(AuthRequiredError);
54
53
  });
55
54
 
56
- it('preserves non-auth fetch failures as CliError instead of login errors', async () => {
55
+ it('preserves non-auth fetch failures as CliError', async () => {
57
56
  const cmd = getRegistry().get('zhihu/question');
58
- expect(cmd?.func).toBeTypeOf('function');
57
+ const page = {
58
+ goto: vi.fn().mockResolvedValue(undefined),
59
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
60
+ } as any;
59
61
 
62
+ await expect(
63
+ cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
64
+ ).rejects.toMatchObject({
65
+ code: 'FETCH_ERROR',
66
+ message: 'Zhihu question answers request failed (HTTP 500)',
67
+ });
68
+ });
69
+
70
+ it('handles null evaluate response as fetch error', async () => {
71
+ const cmd = getRegistry().get('zhihu/question');
60
72
  const page = {
61
- evaluate: vi.fn().mockResolvedValue({ ok: false, status: 500 }),
73
+ goto: vi.fn().mockResolvedValue(undefined),
74
+ evaluate: vi.fn().mockResolvedValue(null),
62
75
  } as any;
63
76
 
64
77
  await expect(
65
78
  cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
66
79
  ).rejects.toMatchObject({
67
80
  code: 'FETCH_ERROR',
68
- message: 'Zhihu question answers request failed with HTTP 500',
81
+ message: 'Zhihu question answers request failed',
69
82
  });
70
83
  });
84
+
85
+ it('rejects non-numeric question IDs', async () => {
86
+ const cmd = getRegistry().get('zhihu/question');
87
+ const page = { goto: vi.fn(), evaluate: vi.fn() } as any;
88
+
89
+ await expect(
90
+ cmd!.func!(page, { id: "abc'; alert(1); //", limit: 1 }),
91
+ ).rejects.toBeInstanceOf(CliError);
92
+
93
+ expect(page.goto).not.toHaveBeenCalled();
94
+ expect(page.evaluate).not.toHaveBeenCalled();
95
+ });
71
96
  });
@@ -1,6 +1,16 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { AuthRequiredError, CliError } from '../../errors.js';
3
3
 
4
+ function stripHtml(html: string): string {
5
+ return html
6
+ .replace(/<[^>]+>/g, '')
7
+ .replace(/&nbsp;/g, ' ')
8
+ .replace(/&lt;/g, '<')
9
+ .replace(/&gt;/g, '>')
10
+ .replace(/&amp;/g, '&')
11
+ .trim();
12
+ }
13
+
4
14
  cli({
5
15
  site: 'zhihu',
6
16
  name: 'question',
@@ -14,45 +24,40 @@ cli({
14
24
  columns: ['rank', 'author', 'votes', 'content'],
15
25
  func: async (page, kwargs) => {
16
26
  const { id, limit = 5 } = kwargs;
27
+ const questionId = String(id);
28
+ if (!/^\d+$/.test(questionId)) {
29
+ throw new CliError('INVALID_INPUT', 'Question ID must be numeric', 'Example: opencli zhihu question 123456789');
30
+ }
17
31
  const answerLimit = Number(limit);
18
32
 
19
- const stripHtml = (html: string) =>
20
- (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
33
+ await page.goto(`https://www.zhihu.com/question/${questionId}`);
21
34
 
22
- // Only fetch answers here. The question detail endpoint is not used by the
23
- // current CLI output and can fail independently, which would incorrectly
24
- // turn a successful answers response into a login error.
25
- const result = await (page as any).evaluate(
26
- async ({ questionId, answerLimit }: { questionId: string; answerLimit: number }) => {
27
- const aResp = await fetch(
28
- `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`,
29
- { credentials: 'include' },
30
- );
31
- if (!aResp.ok) return { ok: false as const, status: aResp.status };
32
- const a = await aResp.json();
33
- return { ok: true as const, answers: Array.isArray(a?.data) ? a.data : [] };
34
- },
35
- { questionId: String(id), answerLimit },
36
- );
35
+ const url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`;
36
+ const data: any = await page.evaluate(`
37
+ (async () => {
38
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
39
+ if (!r.ok) return { __httpError: r.status };
40
+ return await r.json();
41
+ })()
42
+ `);
37
43
 
38
- if (!result?.ok) {
39
- if (result?.status === 401 || result?.status === 403) {
44
+ if (!data || data.__httpError) {
45
+ const status = data?.__httpError;
46
+ if (status === 401 || status === 403) {
40
47
  throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
41
48
  }
42
49
  throw new CliError(
43
50
  'FETCH_ERROR',
44
- `Zhihu question answers request failed with HTTP ${result?.status ?? 'unknown'}`,
51
+ status ? `Zhihu question answers request failed (HTTP ${status})` : 'Zhihu question answers request failed',
45
52
  'Try again later or rerun with -v for more detail',
46
53
  );
47
54
  }
48
55
 
49
- const answers = result.answers.slice(0, answerLimit).map((a: any, i: number) => ({
56
+ return (data.data || []).map((item: any, i: number) => ({
50
57
  rank: i + 1,
51
- author: a.author?.name ?? 'anonymous',
52
- votes: a.voteup_count ?? 0,
53
- content: stripHtml(a.content ?? '').slice(0, 200),
58
+ author: item.author?.name || 'anonymous',
59
+ votes: item.voteup_count || 0,
60
+ content: stripHtml(item.content || '').substring(0, 200),
54
61
  }));
55
-
56
- return answers;
57
62
  },
58
63
  });
@@ -125,6 +125,57 @@ describe('commanderAdapter boolean alias support', () => {
125
125
  });
126
126
  });
127
127
 
128
+ describe('commanderAdapter value-required optional options', () => {
129
+ const cmd: CliCommand = {
130
+ site: 'instagram',
131
+ name: 'post',
132
+ description: 'Post to Instagram',
133
+ browser: true,
134
+ args: [
135
+ { name: 'image', valueRequired: true, help: 'Single image path' },
136
+ { name: 'images', valueRequired: true, help: 'Comma-separated image paths' },
137
+ { name: 'content', positional: true, required: false, help: 'Caption text' },
138
+ ],
139
+ validateArgs: (kwargs) => {
140
+ if (!kwargs.image && !kwargs.images) {
141
+ throw new Error('media required');
142
+ }
143
+ },
144
+ func: vi.fn(),
145
+ };
146
+
147
+ beforeEach(() => {
148
+ mockExecuteCommand.mockReset();
149
+ mockExecuteCommand.mockResolvedValue([]);
150
+ mockRenderOutput.mockReset();
151
+ delete process.env.OPENCLI_VERBOSE;
152
+ process.exitCode = undefined;
153
+ });
154
+
155
+ it('requires a value when --image is present', async () => {
156
+ const program = new Command();
157
+ program.exitOverride();
158
+ const siteCmd = program.command('instagram');
159
+ registerCommandToProgram(siteCmd, cmd);
160
+
161
+ await expect(
162
+ program.parseAsync(['node', 'opencli', 'instagram', 'post', '--image']),
163
+ ).rejects.toMatchObject({ code: 'commander.optionMissingArgument' });
164
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('runs validateArgs before executeCommand so missing media does not dispatch the browser command', async () => {
168
+ const program = new Command();
169
+ const siteCmd = program.command('instagram');
170
+ registerCommandToProgram(siteCmd, cmd);
171
+
172
+ await program.parseAsync(['node', 'opencli', 'instagram', 'post', 'caption only']);
173
+
174
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
175
+ expect(process.exitCode).toBeDefined();
176
+ });
177
+ });
178
+
128
179
  describe('commanderAdapter command aliases', () => {
129
180
  const cmd: CliCommand = {
130
181
  site: 'notebooklm',
@@ -62,7 +62,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
62
62
  subCmd.argument(bracket, arg.help ?? '');
63
63
  positionalArgs.push(arg);
64
64
  } else {
65
- const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
65
+ const expectsValue = arg.required || arg.valueRequired;
66
+ const flag = expectsValue ? `--${arg.name} <value>` : `--${arg.name} [value]`;
66
67
  if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
67
68
  else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
68
69
  else subCmd.option(flag, arg.help ?? '');
@@ -93,9 +94,11 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
93
94
  const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
94
95
  if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
95
96
  }
97
+ cmd.validateArgs?.(kwargs);
96
98
 
97
99
  const verbose = optionsRecord.verbose === true;
98
100
  let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
101
+ const formatExplicit = subCmd.getOptionValueSource('format') === 'cli';
99
102
  if (verbose) process.env.OPENCLI_VERBOSE = '1';
100
103
  if (cmd.deprecated) {
101
104
  const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
@@ -109,7 +112,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
109
112
  }
110
113
 
111
114
  const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
112
- if (format === 'table' && resolved.defaultFormat) {
115
+ if (!formatExplicit && format === 'table' && resolved.defaultFormat) {
113
116
  format = resolved.defaultFormat;
114
117
  }
115
118
 
@@ -118,6 +121,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
118
121
  }
119
122
  renderOutput(result, {
120
123
  fmt: format,
124
+ fmtExplicit: formatExplicit,
121
125
  columns: resolved.columns,
122
126
  title: `${resolved.site}/${resolved.name}`,
123
127
  elapsed: (Date.now() - startTime) / 1000,
@@ -209,7 +213,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
209
213
  if (err instanceof AuthRequiredError) {
210
214
  console.error(chalk.red(`🔒 Not logged in to ${err.domain}`));
211
215
  // Respect custom hints set by the adapter; fall back to generic guidance.
212
- console.error(chalk.yellow(`→ ${err.hint ?? `Open Chrome and log in to https://${err.domain}, then retry.`}`));
216
+ console.error(chalk.yellow(`→ ${err.hint ?? `Open Chrome or Chromium and log in to https://${err.domain}, then retry.`}`));
213
217
  return;
214
218
  }
215
219
 
@@ -270,7 +274,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
270
274
 
271
275
  if (kind === 'auth') {
272
276
  console.error(chalk.red(`🔒 ${msg}`));
273
- console.error(chalk.yellow('→ Open Chrome, log in to the target site, then retry.'));
277
+ console.error(chalk.yellow('→ Open Chrome or Chromium, log in to the target site, then retry.'));
274
278
  return;
275
279
  }
276
280
  if (kind === 'http') {
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockGetRegistry } = vi.hoisted(() => ({
4
+ mockGetRegistry: vi.fn(() => new Map([
5
+ ['github/issues', { site: 'github', name: 'issues' }],
6
+ ])),
7
+ }));
8
+
9
+ vi.mock('./registry.js', () => ({
10
+ getRegistry: mockGetRegistry,
11
+ }));
12
+
13
+ import { getCompletions } from './completion.js';
14
+
15
+ describe('getCompletions', () => {
16
+ it('includes top-level built-ins that are registered outside the site registry', () => {
17
+ const completions = getCompletions([], 1);
18
+
19
+ expect(completions).toContain('plugin');
20
+ expect(completions).toContain('install');
21
+ expect(completions).toContain('register');
22
+ expect(completions).not.toContain('setup');
23
+ });
24
+
25
+ it('still includes discovered site names', () => {
26
+ const completions = getCompletions([], 1);
27
+
28
+ expect(completions).toContain('github');
29
+ });
30
+ });
package/src/completion.ts CHANGED
@@ -24,7 +24,9 @@ const BUILTIN_COMMANDS = [
24
24
  'generate',
25
25
  'cascade',
26
26
  'doctor',
27
- 'setup',
27
+ 'plugin',
28
+ 'install',
29
+ 'register',
28
30
  'completion',
29
31
  ];
30
32
 
package/src/doctor.ts CHANGED
@@ -84,7 +84,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
84
84
  }
85
85
  if (status.running && !status.extensionConnected) {
86
86
  issues.push(
87
- 'Daemon is running but the Chrome extension is not connected.\n' +
87
+ 'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
88
88
  'Please install the opencli Browser Bridge extension:\n' +
89
89
  ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
90
90
  ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
@@ -15,6 +15,8 @@ export interface ElectronAppEntry {
15
15
  port: number;
16
16
  /** macOS process name for detection via pgrep */
17
17
  processName: string;
18
+ /** Candidate executable names inside Contents/MacOS/, tried in order */
19
+ executableNames?: string[];
18
20
  /** macOS bundle ID for path discovery */
19
21
  bundleId?: string;
20
22
  /** Human-readable name for prompts */
@@ -30,7 +32,13 @@ export const builtinApps: Record<string, ElectronAppEntry> = {
30
32
  notion: { port: 9230, processName: 'Notion', bundleId: 'notion.id', displayName: 'Notion' },
31
33
  'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
32
34
  'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
33
- antigravity: { port: 9234, processName: 'Antigravity', bundleId: 'dev.antigravity.app', displayName: 'Antigravity' },
35
+ antigravity: {
36
+ port: 9234,
37
+ processName: 'Antigravity',
38
+ executableNames: ['Electron', 'Antigravity'],
39
+ bundleId: 'dev.antigravity.app',
40
+ displayName: 'Antigravity',
41
+ },
34
42
  chatgpt: { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
35
43
  };
36
44
 
package/src/errors.ts CHANGED
@@ -91,7 +91,7 @@ export class AuthRequiredError extends CliError {
91
91
  super(
92
92
  'AUTH_REQUIRED',
93
93
  message ?? `Not logged in to ${domain}`,
94
- `Please open Chrome and log in to https://${domain}`,
94
+ `Please open Chrome or Chromium and log in to https://${domain}`,
95
95
  EXIT_CODES.NOPERM,
96
96
  );
97
97
  this.domain = domain;
package/src/execution.ts CHANGED
@@ -21,7 +21,7 @@ import { emitHook, type HookContext } from './hooks.js';
21
21
  import { checkDaemonStatus } from './browser/discover.js';
22
22
  import { log } from './logger.js';
23
23
  import { isElectronApp } from './electron-apps.js';
24
- import { resolveElectronEndpoint } from './launcher.js';
24
+ import { probeCDP, resolveElectronEndpoint } from './launcher.js';
25
25
 
26
26
  const _loadedModules = new Set<string>();
27
27
 
@@ -131,23 +131,6 @@ function ensureRequiredEnv(cmd: CliCommand): void {
131
131
  );
132
132
  }
133
133
 
134
- /**
135
- * Check if the browser is already on the target domain, avoiding redundant navigation.
136
- * Returns true if current page hostname matches the pre-nav URL hostname.
137
- */
138
- async function isAlreadyOnDomain(page: IPage, targetUrl: string): Promise<boolean> {
139
- if (!page.getCurrentUrl) return false;
140
- try {
141
- const currentUrl = await page.getCurrentUrl();
142
- if (!currentUrl) return false;
143
- const currentHost = new URL(currentUrl).hostname;
144
- const targetHost = new URL(targetUrl).hostname;
145
- return currentHost === targetHost;
146
- } catch {
147
- return false;
148
- }
149
- }
150
-
151
134
  export async function executeCommand(
152
135
  cmd: CliCommand,
153
136
  rawKwargs: CommandArgs,
@@ -156,6 +139,7 @@ export async function executeCommand(
156
139
  let kwargs: CommandArgs;
157
140
  try {
158
141
  kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
142
+ cmd.validateArgs?.(kwargs);
159
143
  } catch (err) {
160
144
  if (err instanceof ArgumentError) throw err;
161
145
  throw new ArgumentError(getErrorMessage(err));
@@ -175,8 +159,20 @@ export async function executeCommand(
175
159
  let cdpEndpoint: string | undefined;
176
160
 
177
161
  if (electron) {
178
- // Electron apps: auto-detect, prompt restart if needed, launch with CDP
179
- cdpEndpoint = await resolveElectronEndpoint(cmd.site);
162
+ // Electron apps: respect manual endpoint override, then try auto-detect
163
+ const manualEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
164
+ if (manualEndpoint) {
165
+ const port = Number(new URL(manualEndpoint).port);
166
+ if (!await probeCDP(port)) {
167
+ throw new CommandExecutionError(
168
+ `CDP not reachable at ${manualEndpoint}`,
169
+ 'Check that the app is running with --remote-debugging-port and the endpoint is correct.',
170
+ );
171
+ }
172
+ cdpEndpoint = manualEndpoint;
173
+ } else {
174
+ cdpEndpoint = await resolveElectronEndpoint(cmd.site);
175
+ }
180
176
  } else {
181
177
  // Browser Bridge: fail-fast when daemon is up but extension is missing.
182
178
  // 300ms timeout avoids a full 2s wait on cold-start.
@@ -186,7 +182,7 @@ export async function executeCommand(
186
182
  'Browser Bridge extension not connected',
187
183
  'Install the Browser Bridge:\n' +
188
184
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
189
- ' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
185
+ ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
190
186
  ' Then run: opencli doctor',
191
187
  );
192
188
  }
@@ -197,15 +193,15 @@ export async function executeCommand(
197
193
  result = await browserSession(BrowserFactory, async (page) => {
198
194
  const preNavUrl = resolvePreNav(cmd);
199
195
  if (preNavUrl) {
200
- const skip = await isAlreadyOnDomain(page, preNavUrl);
201
- if (skip) {
202
- if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
203
- } else {
204
- try {
205
- await page.goto(preNavUrl);
206
- } catch (err) {
207
- if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
208
- }
196
+ // Navigate directly the extension's handleNavigate already has a fast-path
197
+ // that skips navigation if the tab is already at the target URL.
198
+ // This avoids an extra exec round-trip (getCurrentUrl) on first command and
199
+ // lets the extension create the automation window with the target URL directly
200
+ // instead of about:blank.
201
+ try {
202
+ await page.goto(preNavUrl);
203
+ } catch (err) {
204
+ if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
209
205
  }
210
206
  }
211
207
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
package/src/explore.ts CHANGED
@@ -386,7 +386,7 @@ export async function exploreUrl(
386
386
  const clicks = await page.evaluate(INTERACT_FUZZ_JS);
387
387
  await page.wait(2); // wait for XHRs to settle
388
388
  } catch (e) {
389
- log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
389
+ log.verbose(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
390
390
  }
391
391
  }
392
392
 
@@ -1,13 +1,34 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { probeCDP, detectProcess, discoverAppPath } from './launcher.js';
2
+ import type { ElectronAppEntry } from './electron-apps.js';
3
+ import { detectProcess, discoverAppPath, launchDetachedApp, launchElectronApp, probeCDP, resolveExecutableCandidates } from './launcher.js';
4
+
5
+ interface MockChildProcess {
6
+ once: ReturnType<typeof vi.fn>;
7
+ off: ReturnType<typeof vi.fn>;
8
+ unref: ReturnType<typeof vi.fn>;
9
+ emit: (event: string, value?: unknown) => void;
10
+ }
11
+
12
+ function createMockChildProcess(): MockChildProcess {
13
+ const listeners = new Map<string, Array<(value?: unknown) => void>>();
14
+
15
+ return {
16
+ once: vi.fn((event: string, handler: (value?: unknown) => void) => {
17
+ listeners.set(event, [...(listeners.get(event) ?? []), handler]);
18
+ }),
19
+ off: vi.fn((event: string, handler: (value?: unknown) => void) => {
20
+ listeners.set(event, (listeners.get(event) ?? []).filter((listener) => listener !== handler));
21
+ }),
22
+ unref: vi.fn(),
23
+ emit: (event: string, value?: unknown) => {
24
+ for (const listener of listeners.get(event) ?? []) listener(value);
25
+ },
26
+ };
27
+ }
3
28
 
4
29
  vi.mock('node:child_process', () => ({
5
30
  execFileSync: vi.fn(),
6
- spawn: vi.fn(() => ({
7
- unref: vi.fn(),
8
- pid: 12345,
9
- on: vi.fn(),
10
- })),
31
+ spawn: vi.fn(),
11
32
  }));
12
33
 
13
34
  const cp = vi.mocked(await import('node:child_process'));
@@ -34,7 +55,7 @@ describe('detectProcess', () => {
34
55
  expect(result).toBe(false);
35
56
  });
36
57
 
37
- it('returns true when pgrep finds a process', () => {
58
+ it.skipIf(process.platform === 'win32')('returns true when pgrep finds a process', () => {
38
59
  cp.execFileSync.mockReturnValue('12345\n');
39
60
  const result = detectProcess('Cursor');
40
61
  expect(result).toBe(true);
@@ -65,3 +86,96 @@ describe('discoverAppPath', () => {
65
86
  expect(result).toBeNull();
66
87
  });
67
88
  });
89
+
90
+ describe('launchDetachedApp', () => {
91
+ beforeEach(() => {
92
+ vi.restoreAllMocks();
93
+ cp.spawn.mockReset();
94
+ });
95
+
96
+ it('unrefs the process after spawn succeeds', async () => {
97
+ const child = createMockChildProcess();
98
+ cp.spawn.mockImplementation(() => {
99
+ queueMicrotask(() => child.emit('spawn'));
100
+ return child as unknown as ReturnType<typeof cp.spawn>;
101
+ });
102
+
103
+ await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity'))
104
+ .resolves
105
+ .toBeUndefined();
106
+ expect(child.unref).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it('converts ENOENT into a controlled launch error', async () => {
110
+ const child = createMockChildProcess();
111
+ cp.spawn.mockImplementation(() => {
112
+ queueMicrotask(() => child.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' })));
113
+ return child as unknown as ReturnType<typeof cp.spawn>;
114
+ });
115
+
116
+ await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity'))
117
+ .rejects
118
+ .toThrow('Could not launch Antigravity');
119
+ expect(child.unref).not.toHaveBeenCalled();
120
+ });
121
+ });
122
+
123
+ describe('resolveExecutableCandidates', () => {
124
+ it('prefers explicit executable candidates over processName', () => {
125
+ const app: ElectronAppEntry = {
126
+ port: 9234,
127
+ processName: 'Antigravity',
128
+ executableNames: ['Electron', 'Antigravity'],
129
+ };
130
+
131
+ expect(resolveExecutableCandidates('/Applications/Antigravity.app', app)).toEqual([
132
+ '/Applications/Antigravity.app/Contents/MacOS/Electron',
133
+ '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
134
+ ]);
135
+ });
136
+ });
137
+
138
+ describe('launchElectronApp', () => {
139
+ beforeEach(() => {
140
+ vi.restoreAllMocks();
141
+ cp.spawn.mockReset();
142
+ });
143
+
144
+ it('falls back to the next executable candidate when the first is missing', async () => {
145
+ const firstChild = createMockChildProcess();
146
+ const secondChild = createMockChildProcess();
147
+ const app: ElectronAppEntry = {
148
+ port: 9234,
149
+ processName: 'Antigravity',
150
+ executableNames: ['Electron', 'Antigravity'],
151
+ };
152
+
153
+ cp.spawn
154
+ .mockImplementationOnce(() => {
155
+ queueMicrotask(() => firstChild.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' })));
156
+ return firstChild as unknown as ReturnType<typeof cp.spawn>;
157
+ })
158
+ .mockImplementationOnce(() => {
159
+ queueMicrotask(() => secondChild.emit('spawn'));
160
+ return secondChild as unknown as ReturnType<typeof cp.spawn>;
161
+ });
162
+
163
+ await expect(
164
+ launchElectronApp('/Applications/Antigravity.app', app, ['--remote-debugging-port=9234'], 'Antigravity'),
165
+ ).resolves.toBeUndefined();
166
+
167
+ expect(cp.spawn).toHaveBeenNthCalledWith(
168
+ 1,
169
+ '/Applications/Antigravity.app/Contents/MacOS/Electron',
170
+ ['--remote-debugging-port=9234'],
171
+ { detached: true, stdio: 'ignore' },
172
+ );
173
+ expect(cp.spawn).toHaveBeenNthCalledWith(
174
+ 2,
175
+ '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
176
+ ['--remote-debugging-port=9234'],
177
+ { detached: true, stdio: 'ignore' },
178
+ );
179
+ expect(secondChild.unref).toHaveBeenCalledTimes(1);
180
+ });
181
+ });