@jackwener/opencli 0.9.6 → 1.0.0

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 (307) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +83 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +42 -0
  4. package/.github/ISSUE_TEMPLATE/new_site_adapter.yml +57 -0
  5. package/.github/dependabot.yml +27 -0
  6. package/.github/pull_request_template.md +24 -0
  7. package/.github/workflows/ci.yml +14 -8
  8. package/.github/workflows/e2e-headed.yml +6 -2
  9. package/.github/workflows/pkg-pr-new.yml +2 -2
  10. package/.github/workflows/release-please.yml +25 -0
  11. package/.github/workflows/release.yml +2 -2
  12. package/.github/workflows/security.yml +36 -0
  13. package/CDP.md +1 -1
  14. package/CDP.zh-CN.md +1 -1
  15. package/CLI-ELECTRON.md +89 -36
  16. package/CLI-EXPLORER.md +4 -4
  17. package/CONTRIBUTING.md +167 -0
  18. package/README.md +113 -89
  19. package/README.zh-CN.md +114 -91
  20. package/SKILL.md +10 -8
  21. package/TESTING.md +7 -7
  22. package/dist/browser/daemon-client.d.ts +37 -0
  23. package/dist/browser/daemon-client.js +82 -0
  24. package/dist/browser/discover.d.ts +11 -34
  25. package/dist/browser/discover.js +15 -190
  26. package/dist/browser/errors.d.ts +6 -20
  27. package/dist/browser/errors.js +24 -63
  28. package/dist/browser/index.d.ts +2 -11
  29. package/dist/browser/index.js +5 -11
  30. package/dist/browser/mcp.d.ts +9 -18
  31. package/dist/browser/mcp.js +70 -284
  32. package/dist/browser/page.d.ts +28 -6
  33. package/dist/browser/page.js +210 -85
  34. package/dist/browser.test.js +4 -202
  35. package/dist/build-manifest.d.ts +26 -0
  36. package/dist/build-manifest.js +132 -60
  37. package/dist/build-manifest.test.d.ts +1 -0
  38. package/dist/build-manifest.test.js +26 -0
  39. package/dist/cli-manifest.json +1582 -29
  40. package/dist/clis/bilibili/download.d.ts +10 -0
  41. package/dist/clis/bilibili/download.js +135 -0
  42. package/dist/clis/chatwise/ask.d.ts +1 -0
  43. package/dist/clis/chatwise/ask.js +76 -0
  44. package/dist/clis/chatwise/export.d.ts +1 -0
  45. package/dist/clis/chatwise/export.js +46 -0
  46. package/dist/clis/chatwise/history.d.ts +1 -0
  47. package/dist/clis/chatwise/history.js +43 -0
  48. package/dist/clis/chatwise/model.d.ts +1 -0
  49. package/dist/clis/chatwise/model.js +81 -0
  50. package/dist/clis/chatwise/new.d.ts +1 -0
  51. package/dist/clis/chatwise/new.js +18 -0
  52. package/dist/clis/chatwise/read.d.ts +1 -0
  53. package/dist/clis/chatwise/read.js +39 -0
  54. package/dist/clis/chatwise/screenshot.d.ts +1 -0
  55. package/dist/clis/chatwise/screenshot.js +27 -0
  56. package/dist/clis/chatwise/send.d.ts +1 -0
  57. package/dist/clis/chatwise/send.js +45 -0
  58. package/dist/clis/chatwise/status.d.ts +1 -0
  59. package/dist/clis/chatwise/status.js +22 -0
  60. package/dist/clis/discord-app/channels.d.ts +1 -0
  61. package/dist/clis/discord-app/channels.js +45 -0
  62. package/dist/clis/discord-app/members.d.ts +1 -0
  63. package/dist/clis/discord-app/members.js +38 -0
  64. package/dist/clis/discord-app/read.d.ts +1 -0
  65. package/dist/clis/discord-app/read.js +45 -0
  66. package/dist/clis/discord-app/search.d.ts +1 -0
  67. package/dist/clis/discord-app/search.js +56 -0
  68. package/dist/clis/discord-app/send.d.ts +1 -0
  69. package/dist/clis/discord-app/send.js +27 -0
  70. package/dist/clis/discord-app/servers.d.ts +1 -0
  71. package/dist/clis/discord-app/servers.js +36 -0
  72. package/dist/clis/discord-app/status.d.ts +1 -0
  73. package/dist/clis/discord-app/status.js +16 -0
  74. package/dist/clis/feishu/new.d.ts +1 -0
  75. package/dist/clis/feishu/new.js +27 -0
  76. package/dist/clis/feishu/read.d.ts +1 -0
  77. package/dist/clis/feishu/read.js +40 -0
  78. package/dist/clis/feishu/search.d.ts +1 -0
  79. package/dist/clis/feishu/search.js +30 -0
  80. package/dist/clis/feishu/send.d.ts +1 -0
  81. package/dist/clis/feishu/send.js +39 -0
  82. package/dist/clis/feishu/status.d.ts +1 -0
  83. package/dist/clis/feishu/status.js +28 -0
  84. package/dist/clis/grok/ask.d.ts +1 -0
  85. package/dist/clis/grok/ask.js +82 -0
  86. package/dist/clis/grok/debug.d.ts +1 -0
  87. package/dist/clis/grok/debug.js +45 -0
  88. package/dist/clis/jimeng/generate.yaml +84 -0
  89. package/dist/clis/jimeng/history.yaml +47 -0
  90. package/dist/clis/linux-do/categories.yaml +41 -0
  91. package/dist/clis/linux-do/category.yaml +49 -0
  92. package/dist/clis/linux-do/hot.yaml +50 -0
  93. package/dist/clis/linux-do/latest.yaml +40 -0
  94. package/dist/clis/linux-do/search.yaml +45 -0
  95. package/dist/clis/linux-do/topic.yaml +38 -0
  96. package/dist/clis/neteasemusic/like.d.ts +1 -0
  97. package/dist/clis/neteasemusic/like.js +25 -0
  98. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  99. package/dist/clis/neteasemusic/lyrics.js +47 -0
  100. package/dist/clis/neteasemusic/next.d.ts +1 -0
  101. package/dist/clis/neteasemusic/next.js +26 -0
  102. package/dist/clis/neteasemusic/play.d.ts +1 -0
  103. package/dist/clis/neteasemusic/play.js +26 -0
  104. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  105. package/dist/clis/neteasemusic/playing.js +59 -0
  106. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  107. package/dist/clis/neteasemusic/playlist.js +46 -0
  108. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  109. package/dist/clis/neteasemusic/prev.js +25 -0
  110. package/dist/clis/neteasemusic/search.d.ts +1 -0
  111. package/dist/clis/neteasemusic/search.js +52 -0
  112. package/dist/clis/neteasemusic/status.d.ts +1 -0
  113. package/dist/clis/neteasemusic/status.js +16 -0
  114. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  115. package/dist/clis/neteasemusic/volume.js +54 -0
  116. package/dist/clis/notion/export.d.ts +1 -0
  117. package/dist/clis/notion/export.js +31 -0
  118. package/dist/clis/notion/favorites.d.ts +1 -0
  119. package/dist/clis/notion/favorites.js +84 -0
  120. package/dist/clis/notion/new.d.ts +1 -0
  121. package/dist/clis/notion/new.js +34 -0
  122. package/dist/clis/notion/read.d.ts +1 -0
  123. package/dist/clis/notion/read.js +30 -0
  124. package/dist/clis/notion/search.d.ts +1 -0
  125. package/dist/clis/notion/search.js +46 -0
  126. package/dist/clis/notion/sidebar.d.ts +1 -0
  127. package/dist/clis/notion/sidebar.js +41 -0
  128. package/dist/clis/notion/status.d.ts +1 -0
  129. package/dist/clis/notion/status.js +16 -0
  130. package/dist/clis/notion/write.d.ts +1 -0
  131. package/dist/clis/notion/write.js +40 -0
  132. package/dist/clis/twitter/download.d.ts +8 -0
  133. package/dist/clis/twitter/download.js +204 -0
  134. package/dist/clis/wechat/chats.d.ts +1 -0
  135. package/dist/clis/wechat/chats.js +28 -0
  136. package/dist/clis/wechat/contacts.d.ts +1 -0
  137. package/dist/clis/wechat/contacts.js +28 -0
  138. package/dist/clis/wechat/read.d.ts +1 -0
  139. package/dist/clis/wechat/read.js +58 -0
  140. package/dist/clis/wechat/search.d.ts +1 -0
  141. package/dist/clis/wechat/search.js +31 -0
  142. package/dist/clis/wechat/send.d.ts +1 -0
  143. package/dist/clis/wechat/send.js +42 -0
  144. package/dist/clis/wechat/status.d.ts +1 -0
  145. package/dist/clis/wechat/status.js +29 -0
  146. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +10 -0
  147. package/dist/clis/xiaohongshu/creator-note-detail.js +88 -0
  148. package/dist/clis/xiaohongshu/creator-notes.d.ts +11 -0
  149. package/dist/clis/xiaohongshu/creator-notes.js +109 -0
  150. package/dist/clis/xiaohongshu/creator-profile.d.ts +10 -0
  151. package/dist/clis/xiaohongshu/creator-profile.js +54 -0
  152. package/dist/clis/xiaohongshu/creator-stats.d.ts +10 -0
  153. package/dist/clis/xiaohongshu/creator-stats.js +74 -0
  154. package/dist/clis/xiaohongshu/download.d.ts +7 -0
  155. package/dist/clis/xiaohongshu/download.js +155 -0
  156. package/dist/clis/xiaohongshu/search.js +1 -1
  157. package/dist/clis/xiaohongshu/user-helpers.d.ts +15 -0
  158. package/dist/clis/xiaohongshu/user-helpers.js +67 -0
  159. package/dist/clis/xiaohongshu/user-helpers.test.d.ts +1 -0
  160. package/dist/clis/xiaohongshu/user-helpers.test.js +81 -0
  161. package/dist/clis/xiaohongshu/user.js +46 -29
  162. package/dist/clis/zhihu/download.d.ts +11 -0
  163. package/dist/clis/zhihu/download.js +186 -0
  164. package/dist/clis/zhihu/download.test.d.ts +1 -0
  165. package/dist/clis/zhihu/download.test.js +10 -0
  166. package/dist/daemon.d.ts +13 -0
  167. package/dist/daemon.js +187 -0
  168. package/dist/doctor.d.ts +27 -61
  169. package/dist/doctor.js +70 -601
  170. package/dist/doctor.test.js +30 -170
  171. package/dist/download/index.d.ts +79 -0
  172. package/dist/download/index.js +325 -0
  173. package/dist/download/progress.d.ts +36 -0
  174. package/dist/download/progress.js +111 -0
  175. package/dist/engine.test.js +15 -0
  176. package/dist/main.js +22 -28
  177. package/dist/pipeline/executor.test.js +1 -0
  178. package/dist/pipeline/registry.js +2 -0
  179. package/dist/pipeline/steps/browser.js +2 -2
  180. package/dist/pipeline/steps/download.d.ts +34 -0
  181. package/dist/pipeline/steps/download.js +251 -0
  182. package/dist/pipeline/steps/intercept.js +1 -2
  183. package/dist/pipeline/template.js +28 -0
  184. package/dist/setup.d.ts +6 -0
  185. package/dist/setup.js +46 -160
  186. package/dist/types.d.ts +6 -0
  187. package/extension/icons/icon-128.png +0 -0
  188. package/extension/icons/icon-16.png +0 -0
  189. package/extension/icons/icon-32.png +0 -0
  190. package/extension/icons/icon-48.png +0 -0
  191. package/extension/manifest.json +31 -0
  192. package/extension/package.json +16 -0
  193. package/extension/src/background.ts +293 -0
  194. package/extension/src/cdp.ts +125 -0
  195. package/extension/src/protocol.ts +57 -0
  196. package/extension/store-assets/screenshot-1280x800.png +0 -0
  197. package/extension/tsconfig.json +15 -0
  198. package/extension/vite.config.ts +18 -0
  199. package/package.json +8 -7
  200. package/scripts/test-site.mjs +70 -0
  201. package/src/browser/daemon-client.ts +113 -0
  202. package/src/browser/discover.ts +18 -216
  203. package/src/browser/errors.ts +30 -100
  204. package/src/browser/index.ts +6 -12
  205. package/src/browser/mcp.ts +78 -278
  206. package/src/browser/page.ts +222 -88
  207. package/src/browser.test.ts +3 -210
  208. package/src/build-manifest.test.ts +28 -0
  209. package/src/build-manifest.ts +147 -57
  210. package/src/clis/bilibili/download.ts +161 -0
  211. package/src/clis/chatgpt/README.md +1 -1
  212. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  213. package/src/clis/chatwise/README.md +38 -0
  214. package/src/clis/chatwise/README.zh-CN.md +38 -0
  215. package/src/clis/chatwise/ask.ts +87 -0
  216. package/src/clis/chatwise/export.ts +51 -0
  217. package/src/clis/chatwise/history.ts +47 -0
  218. package/src/clis/chatwise/model.ts +87 -0
  219. package/src/clis/chatwise/new.ts +21 -0
  220. package/src/clis/chatwise/read.ts +42 -0
  221. package/src/clis/chatwise/screenshot.ts +33 -0
  222. package/src/clis/chatwise/send.ts +50 -0
  223. package/src/clis/chatwise/status.ts +25 -0
  224. package/src/clis/discord-app/README.md +28 -0
  225. package/src/clis/discord-app/README.zh-CN.md +28 -0
  226. package/src/clis/discord-app/channels.ts +48 -0
  227. package/src/clis/discord-app/members.ts +41 -0
  228. package/src/clis/discord-app/read.ts +49 -0
  229. package/src/clis/discord-app/search.ts +64 -0
  230. package/src/clis/discord-app/send.ts +32 -0
  231. package/src/clis/discord-app/servers.ts +39 -0
  232. package/src/clis/discord-app/status.ts +18 -0
  233. package/src/clis/feishu/README.md +20 -0
  234. package/src/clis/feishu/README.zh-CN.md +20 -0
  235. package/src/clis/feishu/new.ts +32 -0
  236. package/src/clis/feishu/read.ts +48 -0
  237. package/src/clis/feishu/search.ts +35 -0
  238. package/src/clis/feishu/send.ts +46 -0
  239. package/src/clis/feishu/status.ts +34 -0
  240. package/src/clis/grok/ask.ts +90 -0
  241. package/src/clis/grok/debug.ts +49 -0
  242. package/src/clis/jimeng/generate.yaml +84 -0
  243. package/src/clis/jimeng/history.yaml +47 -0
  244. package/src/clis/linux-do/categories.yaml +41 -0
  245. package/src/clis/linux-do/category.yaml +49 -0
  246. package/src/clis/linux-do/hot.yaml +50 -0
  247. package/src/clis/linux-do/latest.yaml +40 -0
  248. package/src/clis/linux-do/search.yaml +45 -0
  249. package/src/clis/linux-do/topic.yaml +38 -0
  250. package/src/clis/neteasemusic/README.md +31 -0
  251. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  252. package/src/clis/neteasemusic/like.ts +28 -0
  253. package/src/clis/neteasemusic/lyrics.ts +53 -0
  254. package/src/clis/neteasemusic/next.ts +30 -0
  255. package/src/clis/neteasemusic/play.ts +30 -0
  256. package/src/clis/neteasemusic/playing.ts +62 -0
  257. package/src/clis/neteasemusic/playlist.ts +51 -0
  258. package/src/clis/neteasemusic/prev.ts +29 -0
  259. package/src/clis/neteasemusic/search.ts +58 -0
  260. package/src/clis/neteasemusic/status.ts +18 -0
  261. package/src/clis/neteasemusic/volume.ts +61 -0
  262. package/src/clis/notion/README.md +29 -0
  263. package/src/clis/notion/README.zh-CN.md +29 -0
  264. package/src/clis/notion/export.ts +36 -0
  265. package/src/clis/notion/favorites.ts +87 -0
  266. package/src/clis/notion/new.ts +39 -0
  267. package/src/clis/notion/read.ts +33 -0
  268. package/src/clis/notion/search.ts +54 -0
  269. package/src/clis/notion/sidebar.ts +44 -0
  270. package/src/clis/notion/status.ts +18 -0
  271. package/src/clis/notion/write.ts +45 -0
  272. package/src/clis/twitter/download.ts +227 -0
  273. package/src/clis/wechat/README.md +28 -0
  274. package/src/clis/wechat/README.zh-CN.md +28 -0
  275. package/src/clis/wechat/chats.ts +33 -0
  276. package/src/clis/wechat/contacts.ts +33 -0
  277. package/src/clis/wechat/read.ts +72 -0
  278. package/src/clis/wechat/search.ts +36 -0
  279. package/src/clis/wechat/send.ts +49 -0
  280. package/src/clis/wechat/status.ts +35 -0
  281. package/src/clis/xiaohongshu/creator-note-detail.ts +95 -0
  282. package/src/clis/xiaohongshu/creator-notes.ts +116 -0
  283. package/src/clis/xiaohongshu/creator-profile.ts +60 -0
  284. package/src/clis/xiaohongshu/creator-stats.ts +81 -0
  285. package/src/clis/xiaohongshu/download.ts +173 -0
  286. package/src/clis/xiaohongshu/search.ts +1 -1
  287. package/src/clis/xiaohongshu/user-helpers.test.ts +106 -0
  288. package/src/clis/xiaohongshu/user-helpers.ts +85 -0
  289. package/src/clis/xiaohongshu/user.ts +52 -32
  290. package/src/clis/zhihu/download.test.ts +12 -0
  291. package/src/clis/zhihu/download.ts +223 -0
  292. package/src/daemon.ts +217 -0
  293. package/src/doctor.test.ts +32 -193
  294. package/src/doctor.ts +74 -668
  295. package/src/download/index.ts +395 -0
  296. package/src/download/progress.ts +125 -0
  297. package/src/engine.test.ts +17 -0
  298. package/src/main.ts +18 -26
  299. package/src/pipeline/executor.test.ts +1 -0
  300. package/src/pipeline/registry.ts +2 -0
  301. package/src/pipeline/steps/browser.ts +2 -2
  302. package/src/pipeline/steps/download.ts +310 -0
  303. package/src/pipeline/steps/intercept.ts +1 -2
  304. package/src/pipeline/template.ts +26 -0
  305. package/src/setup.ts +47 -183
  306. package/src/types.ts +1 -0
  307. package/tests/e2e/browser-auth.test.ts +25 -0
@@ -1,139 +1,243 @@
1
1
  /**
2
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
2
+ * Page abstraction implements IPage by sending commands to the daemon.
3
+ *
4
+ * All browser operations are ultimately 'exec' (JS evaluation via CDP)
5
+ * plus a few native Chrome Extension APIs (tabs, cookies, navigate).
6
+ *
7
+ * IMPORTANT: After goto(), we remember the tabId returned by the navigate
8
+ * action and pass it to all subsequent commands. This avoids the issue
9
+ * where resolveTabId() in the extension picks a chrome:// or
10
+ * chrome-extension:// tab that can't be debugged.
3
11
  */
4
12
 
5
13
  import { formatSnapshot } from '../snapshotFormatter.js';
6
- import { normalizeEvaluateSource } from '../pipeline/template.js';
7
- import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
8
14
  import type { IPage } from '../types.js';
9
- import { BrowserConnectError } from '../errors.js';
15
+ import { sendCommand } from './daemon-client.js';
10
16
 
11
17
  /**
12
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
18
+ * Page implements IPage by talking to the daemon via HTTP.
13
19
  */
14
20
  export class Page implements IPage {
15
- constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
16
-
17
- async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
18
- const resp = await this._request(method, params);
19
- if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
20
- // Extract text content from MCP result
21
- const result = resp.result as any;
22
-
23
- if (result?.isError) {
24
- const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
25
- throw new BrowserConnectError(
26
- errorText,
27
- 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
28
- );
29
- }
30
-
31
- if (result?.content) {
32
- const textParts = result.content.filter((c: any) => c.type === 'text');
33
- if (textParts.length >= 1) {
34
- let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
35
-
36
- // Some versions of the MCP return error text without the `isError` boolean flag
37
- if (typeof text === 'string' && text.trim().startsWith('### Error')) {
38
- throw new BrowserConnectError(
39
- text.trim(),
40
- 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
41
- );
42
- }
21
+ /** Active tab ID, set after navigate and used in all subsequent commands */
22
+ private _tabId: number | undefined;
43
23
 
44
- // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
45
- // Strip the "### Ran Playwright code" suffix to get clean JSON
46
- const codeMarker = text.indexOf('### Ran Playwright code');
47
- if (codeMarker !== -1) {
48
- text = text.slice(0, codeMarker).trim();
49
- }
50
- // Also handle "### Result\n[JSON]" format (some MCP versions)
51
- const resultMarker = text.indexOf('### Result\n');
52
- if (resultMarker !== -1) {
53
- text = text.slice(resultMarker + '### Result\n'.length).trim();
54
- }
55
- try { return JSON.parse(text); } catch { return text; }
56
- }
57
- }
58
- return result;
24
+ /** Helper: spread tabId into command params if we have one */
25
+ private _tabOpt(): { tabId: number } | Record<string, never> {
26
+ return this._tabId !== undefined ? { tabId: this._tabId } : {};
59
27
  }
60
28
 
61
- // --- High-level methods ---
62
-
63
29
  async goto(url: string): Promise<void> {
64
- await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
30
+ const result = await sendCommand('navigate', {
31
+ url,
32
+ ...this._tabOpt(),
33
+ }) as { tabId?: number };
34
+ // Remember the tabId for subsequent exec calls
35
+ if (result?.tabId) {
36
+ this._tabId = result.tabId;
37
+ }
65
38
  }
66
39
 
67
40
  async evaluate(js: string): Promise<any> {
68
- // Normalize IIFE format to function format expected by MCP browser_evaluate
69
- const normalized = normalizeEvaluateSource(js);
70
- return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
41
+ const code = wrapForEval(js);
42
+ return sendCommand('exec', { code, ...this._tabOpt() });
71
43
  }
72
44
 
73
45
  async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
74
- const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
46
+ const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
47
+ const code = `
48
+ (async () => {
49
+ function buildTree(node, depth) {
50
+ if (depth > ${maxDepth}) return '';
51
+ const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
52
+ const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
53
+ const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
54
+
55
+ ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
56
+
57
+ let indent = ' '.repeat(depth);
58
+ let line = indent + role;
59
+ if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
60
+ if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
61
+ if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
62
+
63
+ let result = line + '\\n';
64
+ if (node.children) {
65
+ for (const child of node.children) {
66
+ result += buildTree(child, depth + 1);
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+ return buildTree(document.body, 0);
72
+ })()
73
+ `;
74
+ const raw = await sendCommand('exec', { code, ...this._tabOpt() });
75
75
  if (opts.raw) return raw;
76
76
  if (typeof raw === 'string') return formatSnapshot(raw, opts);
77
77
  return raw;
78
78
  }
79
79
 
80
80
  async click(ref: string): Promise<void> {
81
- await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
81
+ const safeRef = JSON.stringify(ref);
82
+ const code = `
83
+ (() => {
84
+ const ref = ${safeRef};
85
+ const el = document.querySelector('[data-ref="' + ref + '"]')
86
+ || document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
87
+ if (!el) throw new Error('Element not found: ' + ref);
88
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
89
+ el.click();
90
+ return 'clicked';
91
+ })()
92
+ `;
93
+ await sendCommand('exec', { code, ...this._tabOpt() });
82
94
  }
83
95
 
84
96
  async typeText(ref: string, text: string): Promise<void> {
85
- await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
97
+ const safeRef = JSON.stringify(ref);
98
+ const safeText = JSON.stringify(text);
99
+ const code = `
100
+ (() => {
101
+ const ref = ${safeRef};
102
+ const el = document.querySelector('[data-ref="' + ref + '"]')
103
+ || document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
104
+ if (!el) throw new Error('Element not found: ' + ref);
105
+ el.focus();
106
+ el.value = ${safeText};
107
+ el.dispatchEvent(new Event('input', { bubbles: true }));
108
+ el.dispatchEvent(new Event('change', { bubbles: true }));
109
+ return 'typed';
110
+ })()
111
+ `;
112
+ await sendCommand('exec', { code, ...this._tabOpt() });
86
113
  }
87
114
 
88
115
  async pressKey(key: string): Promise<void> {
89
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
116
+ const code = `
117
+ (() => {
118
+ const el = document.activeElement || document.body;
119
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
120
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
121
+ return 'pressed';
122
+ })()
123
+ `;
124
+ await sendCommand('exec', { code, ...this._tabOpt() });
90
125
  }
91
126
 
92
127
  async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
93
128
  if (typeof options === 'number') {
94
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
95
- } else {
96
- // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
97
- await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
129
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
130
+ return;
131
+ }
132
+ if (options.time) {
133
+ await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
134
+ return;
135
+ }
136
+ if (options.text) {
137
+ const timeout = (options.timeout ?? 30) * 1000;
138
+ const code = `
139
+ new Promise((resolve, reject) => {
140
+ const deadline = Date.now() + ${timeout};
141
+ const check = () => {
142
+ if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
143
+ if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
144
+ setTimeout(check, 200);
145
+ };
146
+ check();
147
+ })
148
+ `;
149
+ await sendCommand('exec', { code, ...this._tabOpt() });
98
150
  }
99
151
  }
100
152
 
101
153
  async tabs(): Promise<any> {
102
- return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
154
+ return sendCommand('tabs', { op: 'list' });
103
155
  }
104
156
 
105
157
  async closeTab(index?: number): Promise<void> {
106
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
158
+ await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
107
159
  }
108
160
 
109
161
  async newTab(): Promise<void> {
110
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
162
+ await sendCommand('tabs', { op: 'new' });
111
163
  }
112
164
 
113
165
  async selectTab(index: number): Promise<void> {
114
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
166
+ await sendCommand('tabs', { op: 'select', index });
115
167
  }
116
168
 
117
169
  async networkRequests(includeStatic: boolean = false): Promise<any> {
118
- return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
170
+ const code = `
171
+ (() => {
172
+ const entries = performance.getEntriesByType('resource');
173
+ return entries
174
+ ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
175
+ .map(e => ({
176
+ url: e.name,
177
+ type: e.initiatorType,
178
+ duration: Math.round(e.duration),
179
+ size: e.transferSize || 0,
180
+ }));
181
+ })()
182
+ `;
183
+ return sendCommand('exec', { code, ...this._tabOpt() });
119
184
  }
120
185
 
121
186
  async consoleMessages(level: string = 'info'): Promise<any> {
122
- return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
187
+ // Console messages can't be retrospectively read via CDP Runtime.evaluate.
188
+ // Would need Runtime.consoleAPICalled event listener, which is not yet implemented.
189
+ if (process.env.OPENCLI_VERBOSE) {
190
+ console.error('[page] consoleMessages() not supported in lightweight mode — returning empty');
191
+ }
192
+ return [];
123
193
  }
124
194
 
125
- async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
126
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
195
+ /**
196
+ * Capture a screenshot via CDP Page.captureScreenshot.
197
+ * @param options.format - 'png' (default) or 'jpeg'
198
+ * @param options.quality - JPEG quality 0-100
199
+ * @param options.fullPage - capture full scrollable page
200
+ * @param options.path - save to file path (returns base64 if omitted)
201
+ */
202
+ async screenshot(options: {
203
+ format?: 'png' | 'jpeg';
204
+ quality?: number;
205
+ fullPage?: boolean;
206
+ path?: string;
207
+ } = {}): Promise<string> {
208
+ const base64 = await sendCommand('screenshot', {
209
+ format: options.format,
210
+ quality: options.quality,
211
+ fullPage: options.fullPage,
212
+ ...this._tabOpt(),
213
+ }) as string;
214
+
215
+ if (options.path) {
216
+ const fs = await import('node:fs');
217
+ const path = await import('node:path');
218
+ const dir = path.dirname(options.path);
219
+ fs.mkdirSync(dir, { recursive: true });
220
+ fs.writeFileSync(options.path, Buffer.from(base64, 'base64'));
221
+ }
222
+
223
+ return base64;
224
+ }
225
+
226
+ async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
227
+ const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
228
+ const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
229
+ await sendCommand('exec', {
230
+ code: `window.scrollBy(${dx}, ${dy})`,
231
+ ...this._tabOpt(),
232
+ });
127
233
  }
128
234
 
129
235
  async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
130
236
  const times = options.times ?? 3;
131
237
  const delayMs = options.delayMs ?? 2000;
132
- const js = `
133
- async () => {
134
- const maxTimes = ${times};
135
- const maxWaitMs = ${delayMs};
136
- for (let i = 0; i < maxTimes; i++) {
238
+ const code = `
239
+ (async () => {
240
+ for (let i = 0; i < ${times}; i++) {
137
241
  const lastHeight = document.body.scrollHeight;
138
242
  window.scrollTo(0, lastHeight);
139
243
  await new Promise(resolve => {
@@ -142,30 +246,60 @@ export class Page implements IPage {
142
246
  if (document.body.scrollHeight > lastHeight) {
143
247
  clearTimeout(timeoutId);
144
248
  observer.disconnect();
145
- setTimeout(resolve, 100); // Small debounce for rendering
249
+ setTimeout(resolve, 100);
146
250
  }
147
251
  });
148
252
  observer.observe(document.body, { childList: true, subtree: true });
149
- timeoutId = setTimeout(() => {
150
- observer.disconnect();
151
- resolve(null);
152
- }, maxWaitMs);
253
+ timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
153
254
  });
154
255
  }
155
- }
256
+ })()
156
257
  `;
157
- await this.evaluate(js);
258
+ await sendCommand('exec', { code, ...this._tabOpt() });
158
259
  }
159
260
 
160
261
  async installInterceptor(pattern: string): Promise<void> {
161
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
162
- arrayName: '__opencli_xhr',
163
- patchGuard: '__opencli_interceptor_patched',
164
- }));
262
+ const { generateInterceptorJs } = await import('../interceptor.js');
263
+ await sendCommand('exec', {
264
+ code: generateInterceptorJs(JSON.stringify(pattern), {
265
+ arrayName: '__opencli_xhr',
266
+ patchGuard: '__opencli_interceptor_patched',
267
+ }),
268
+ ...this._tabOpt(),
269
+ });
165
270
  }
166
271
 
167
272
  async getInterceptedRequests(): Promise<any[]> {
168
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
169
- return result || [];
273
+ const { generateReadInterceptedJs } = await import('../interceptor.js');
274
+ const result = await sendCommand('exec', {
275
+ code: generateReadInterceptedJs('__opencli_xhr'),
276
+ ...this._tabOpt(),
277
+ });
278
+ return (result as any[]) || [];
170
279
  }
171
280
  }
281
+
282
+ // ─── Helpers ─────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Wrap JS code for CDP Runtime.evaluate:
286
+ * - Already an IIFE `(...)()` → send as-is
287
+ * - Arrow/function literal → wrap as IIFE `(code)()`
288
+ * - `new Promise(...)` or raw expression → send as-is (expression)
289
+ */
290
+ function wrapForEval(js: string): string {
291
+ const code = js.trim();
292
+ if (!code) return 'undefined';
293
+
294
+ // Already an IIFE: `(async () => { ... })()` or `(function() {...})()`
295
+ if (/^\([\s\S]*\)\s*\(.*\)\s*$/.test(code)) return code;
296
+
297
+ // Arrow function: `() => ...` or `async () => ...`
298
+ if (/^(async\s+)?(\([^)]*\)|[A-Za-z_]\w*)\s*=>/.test(code)) return `(${code})()`;
299
+
300
+ // Function declaration: `function ...` or `async function ...`
301
+ if (/^(async\s+)?function[\s(]/.test(code)) return `(${code})()`;
302
+
303
+ // Everything else: bare expression, `new Promise(...)`, etc. → evaluate directly
304
+ return code;
305
+ }
@@ -1,22 +1,7 @@
1
1
  import { afterEach, describe, it, expect, vi } from 'vitest';
2
2
  import { PlaywrightMCP, __test__ } from './browser/index.js';
3
3
 
4
- afterEach(() => {
5
- __test__.resetMcpServerPathCache();
6
- __test__.setMcpDiscoveryTestHooks();
7
- delete process.env.OPENCLI_MCP_SERVER_PATH;
8
- });
9
-
10
4
  describe('browser helpers', () => {
11
- it('creates JSON-RPC requests with unique ids', () => {
12
- const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
13
- const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
14
-
15
- expect(second.id).toBe(first.id + 1);
16
- expect(first.message).toContain(`"id":${first.id}`);
17
- expect(second.message).toContain(`"id":${second.id}`);
18
- });
19
-
20
5
  it('extracts tab entries from string snapshots', () => {
21
6
  const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
22
7
 
@@ -55,199 +40,9 @@ describe('browser helpers', () => {
55
40
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
56
41
  });
57
42
 
58
- it('builds extension MCP args in local mode (no CI)', () => {
59
- const savedCI = process.env.CI;
60
- delete process.env.CI;
61
- try {
62
- expect(__test__.buildMcpArgs({
63
- mcpPath: '/tmp/cli.js',
64
- executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
65
- })).toEqual([
66
- '/tmp/cli.js',
67
- '--extension',
68
- '--executable-path',
69
- '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
70
- ]);
71
-
72
- expect(__test__.buildMcpArgs({
73
- mcpPath: '/tmp/cli.js',
74
- })).toEqual([
75
- '/tmp/cli.js',
76
- '--extension',
77
- ]);
78
- } finally {
79
- if (savedCI !== undefined) {
80
- process.env.CI = savedCI;
81
- } else {
82
- delete process.env.CI;
83
- }
84
- }
85
- });
86
-
87
- it('builds standalone MCP args in CI mode', () => {
88
- const savedCI = process.env.CI;
89
- process.env.CI = 'true';
90
- try {
91
- // CI mode: no --extension — browser launches in standalone headed mode
92
- expect(__test__.buildMcpArgs({
93
- mcpPath: '/tmp/cli.js',
94
- })).toEqual([
95
- '/tmp/cli.js',
96
- ]);
97
-
98
- expect(__test__.buildMcpArgs({
99
- mcpPath: '/tmp/cli.js',
100
- executablePath: '/usr/bin/chromium',
101
- })).toEqual([
102
- '/tmp/cli.js',
103
- '--executable-path',
104
- '/usr/bin/chromium',
105
- ]);
106
- } finally {
107
- if (savedCI !== undefined) {
108
- process.env.CI = savedCI;
109
- } else {
110
- delete process.env.CI;
111
- }
112
- }
113
- });
114
-
115
- it('builds a direct node launch spec when a local MCP path is available', () => {
116
- const savedCI = process.env.CI;
117
- delete process.env.CI;
118
- try {
119
- expect(__test__.buildMcpLaunchSpec({
120
- mcpPath: '/tmp/cli.js',
121
- executablePath: '/usr/bin/google-chrome',
122
- })).toEqual({
123
- command: 'node',
124
- args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
125
- usedNpxFallback: false,
126
- });
127
- } finally {
128
- if (savedCI !== undefined) {
129
- process.env.CI = savedCI;
130
- } else {
131
- delete process.env.CI;
132
- }
133
- }
134
- });
135
-
136
- it('falls back to npx bootstrap when no MCP path is available', () => {
137
- const savedCI = process.env.CI;
138
- delete process.env.CI;
139
- try {
140
- expect(__test__.buildMcpLaunchSpec({
141
- mcpPath: null,
142
- })).toEqual({
143
- command: 'npx',
144
- args: ['-y', '@playwright/mcp@latest', '--extension'],
145
- usedNpxFallback: true,
146
- });
147
- } finally {
148
- if (savedCI !== undefined) {
149
- process.env.CI = savedCI;
150
- } else {
151
- delete process.env.CI;
152
- }
153
- }
154
- });
155
-
156
43
  it('times out slow promises', async () => {
157
44
  await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
158
45
  });
159
-
160
- it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
161
- process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
162
- const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
163
- const execSync = vi.fn();
164
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
165
-
166
- expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
167
- expect(execSync).not.toHaveBeenCalled();
168
- expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
169
- });
170
-
171
- it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
172
- const originalExecPath = process.execPath;
173
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
174
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
175
- Object.defineProperty(process, 'execPath', {
176
- value: runtimeExecPath,
177
- configurable: true,
178
- });
179
-
180
- const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
181
- const execSync = vi.fn();
182
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
183
-
184
- try {
185
- expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
186
- expect(execSync).not.toHaveBeenCalled();
187
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
188
- } finally {
189
- Object.defineProperty(process, 'execPath', {
190
- value: originalExecPath,
191
- configurable: true,
192
- });
193
- }
194
- });
195
-
196
- it('falls back to npm root -g when runtime prefix lookup misses', () => {
197
- const originalExecPath = process.execPath;
198
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
199
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
200
- const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
201
- const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
202
- Object.defineProperty(process, 'execPath', {
203
- value: runtimeExecPath,
204
- configurable: true,
205
- });
206
-
207
- const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
208
- const execSync = vi.fn((command: string) => {
209
- if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
210
- throw new Error(`unexpected command: ${String(command)}`);
211
- });
212
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
213
-
214
- try {
215
- expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
216
- expect(execSync).toHaveBeenCalledOnce();
217
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
218
- expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
219
- } finally {
220
- Object.defineProperty(process, 'execPath', {
221
- value: originalExecPath,
222
- configurable: true,
223
- });
224
- }
225
- });
226
-
227
- it('returns null when new global discovery paths are unavailable', () => {
228
- const originalExecPath = process.execPath;
229
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
230
- Object.defineProperty(process, 'execPath', {
231
- value: runtimeExecPath,
232
- configurable: true,
233
- });
234
-
235
- const existsSync = vi.fn(() => false);
236
- const execSync = vi.fn((command: string) => {
237
- if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
238
- throw new Error(`missing command: ${String(command)}`);
239
- });
240
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
241
-
242
- try {
243
- expect(__test__.findMcpServerPath()).toBeNull();
244
- } finally {
245
- Object.defineProperty(process, 'execPath', {
246
- value: originalExecPath,
247
- configurable: true,
248
- });
249
- }
250
- });
251
46
  });
252
47
 
253
48
  describe('PlaywrightMCP state', () => {
@@ -265,22 +60,20 @@ describe('PlaywrightMCP state', () => {
265
60
  const mcp = new PlaywrightMCP();
266
61
  await mcp.close();
267
62
 
268
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
63
+ await expect(mcp.connect()).rejects.toThrow('Session is closed');
269
64
  });
270
65
 
271
66
  it('rejects connect() while already connecting', async () => {
272
67
  const mcp = new PlaywrightMCP();
273
68
  (mcp as any)._state = 'connecting';
274
69
 
275
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
70
+ await expect(mcp.connect()).rejects.toThrow('Already connecting');
276
71
  });
277
72
 
278
73
  it('rejects connect() while closing', async () => {
279
74
  const mcp = new PlaywrightMCP();
280
75
  (mcp as any)._state = 'closing';
281
76
 
282
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
77
+ await expect(mcp.connect()).rejects.toThrow('Session is closing');
283
78
  });
284
-
285
-
286
79
  });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseTsArgsBlock } from './build-manifest.js';
3
+
4
+ describe('parseTsArgsBlock', () => {
5
+ it('keeps args with nested choices arrays', () => {
6
+ const args = parseTsArgsBlock(`
7
+ {
8
+ name: 'period',
9
+ type: 'string',
10
+ default: 'seven',
11
+ help: 'Stats period: seven or thirty',
12
+ choices: ['seven', 'thirty'],
13
+ },
14
+ `);
15
+
16
+ expect(args).toEqual([
17
+ {
18
+ name: 'period',
19
+ type: 'string',
20
+ default: 'seven',
21
+ required: false,
22
+ positional: undefined,
23
+ help: 'Stats period: seven or thirty',
24
+ choices: ['seven', 'thirty'],
25
+ },
26
+ ]);
27
+ });
28
+ });