@jackwener/opencli 1.7.22 → 1.8.1

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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { ensureApplet, ggbEval, ggbGetProperty, ggbListObjects, ggbWaitForObjectCount } from './utils.js';
5
+ import './add-circle.js';
6
+ import './add-line.js';
7
+ import './add-point.js';
8
+ import './add-polygon.js';
9
+ import './eval.js';
10
+ import './hexagon.js';
11
+ import './info.js';
12
+ import './list.js';
13
+ import './triangle.js';
14
+
15
+ function createPageMock(url = 'https://www.geogebra.org/geometry') {
16
+ return {
17
+ goto: vi.fn().mockResolvedValue(undefined),
18
+ evaluate: vi.fn(),
19
+ getCurrentUrl: vi.fn().mockResolvedValue(url),
20
+ wait: vi.fn().mockResolvedValue(undefined),
21
+ screenshot: vi.fn().mockResolvedValue(undefined),
22
+ };
23
+ }
24
+
25
+ describe('ensureApplet', () => {
26
+ it('skips navigation when already on the geometry page', async () => {
27
+ const page = createPageMock('https://www.geogebra.org/geometry');
28
+ page.evaluate.mockResolvedValue(true);
29
+ await ensureApplet(page);
30
+ expect(page.goto).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('unwraps Browser Bridge evaluate envelopes while checking applet readiness', async () => {
34
+ const page = createPageMock('https://www.geogebra.org/geometry');
35
+ page.evaluate.mockResolvedValue({ session: 1, data: true });
36
+ await ensureApplet(page);
37
+ expect(page.goto).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('navigates when not on the geometry page', async () => {
41
+ const page = createPageMock('https://example.com');
42
+ page.evaluate.mockResolvedValue(true);
43
+ await ensureApplet(page);
44
+ expect(page.goto).toHaveBeenCalledWith('https://www.geogebra.org/geometry');
45
+ });
46
+
47
+ it('throws when ggbApplet never becomes available', async () => {
48
+ const page = createPageMock();
49
+ page.evaluate.mockResolvedValue(false);
50
+ await expect(ensureApplet(page)).rejects.toThrow(CommandExecutionError);
51
+ });
52
+ });
53
+
54
+ describe('ggbEval', () => {
55
+ it('calls evalCommandGetLabels and evalCommand', async () => {
56
+ const page = createPageMock();
57
+ page.evaluate.mockResolvedValue({ ok: true, label: 'A', beforeCount: 0, afterCount: 1, error: null });
58
+ const result = await ggbEval(page, 'A=(1,2)');
59
+ expect(result).toEqual({ ok: true, label: 'A', beforeCount: 0, afterCount: 1, error: null });
60
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it('throws typed errors for malformed evaluate results', async () => {
64
+ const page = createPageMock();
65
+ page.evaluate.mockResolvedValue({ nope: true });
66
+ await expect(ggbEval(page, 'A=(1,2)')).rejects.toThrow(CommandExecutionError);
67
+ });
68
+ });
69
+
70
+ describe('ggbGetProperty', () => {
71
+ it('requests a property from the applet', async () => {
72
+ const page = createPageMock();
73
+ page.evaluate.mockResolvedValue('point');
74
+ const result = await ggbGetProperty(page, 'A', 'type');
75
+ expect(result).toBe('point');
76
+ });
77
+ });
78
+
79
+ describe('ggbListObjects', () => {
80
+ it('normalizes object rows from the applet', async () => {
81
+ const page = createPageMock();
82
+ page.evaluate.mockResolvedValue([
83
+ { name: 'A', type: 'point', value: '(0, 0)', visible: true },
84
+ { name: 't1', type: 'polygon', value: '', visible: true },
85
+ ]);
86
+ const result = await ggbListObjects(page);
87
+ expect(result).toHaveLength(2);
88
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it('unwraps Browser Bridge envelopes for object rows', async () => {
92
+ const page = createPageMock();
93
+ page.evaluate.mockResolvedValue({ session: 1, data: [{ name: 'A', type: 'point', value: '(0, 0)', visible: true }] });
94
+ const result = await ggbListObjects(page);
95
+ expect(result).toEqual([{ name: 'A', type: 'point', value: '(0, 0)', visible: true }]);
96
+ });
97
+
98
+ it('throws typed error when object names exist but property extraction fails', async () => {
99
+ const page = createPageMock();
100
+ page.evaluate.mockResolvedValue({ error: 'getObjectType failed', name: 'A' });
101
+ await expect(ggbListObjects(page)).rejects.toThrow(CommandExecutionError);
102
+ });
103
+ });
104
+
105
+ describe('ggbWaitForObjectCount', () => {
106
+ it('returns the detected object count', async () => {
107
+ const page = createPageMock();
108
+ page.evaluate.mockResolvedValue(4);
109
+ const result = await ggbWaitForObjectCount(page, 4);
110
+ expect(result).toBe(4);
111
+ });
112
+ });
113
+
114
+ describe('geogebra command typed boundaries', () => {
115
+ it('validates add-point args before navigating', async () => {
116
+ const command = getRegistry().get('geogebra/add-point');
117
+ const page = createPageMock('https://example.com');
118
+ await expect(command.func(page, { name: 'A', coords: 'bad' })).rejects.toThrow(ArgumentError);
119
+ expect(page.goto).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('validates triangle size before navigating instead of silently defaulting', async () => {
123
+ const command = getRegistry().get('geogebra/triangle');
124
+ const page = createPageMock('https://example.com');
125
+ await expect(command.func(page, { size: 'not-a-number' })).rejects.toThrow(ArgumentError);
126
+ expect(page.goto).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('fails eval command execution instead of returning a success row for failed commands', async () => {
130
+ const command = getRegistry().get('geogebra/eval');
131
+ const page = createPageMock('https://www.geogebra.org/geometry');
132
+ page.evaluate
133
+ .mockResolvedValueOnce(true)
134
+ .mockResolvedValueOnce({ ok: false, label: '', beforeCount: 0, afterCount: 0, error: 'Unknown command' });
135
+ await expect(command.func(page, { command: 'NotACommand(' })).rejects.toThrow(CommandExecutionError);
136
+ });
137
+
138
+ it('creates add-line rows from validated labels', async () => {
139
+ const command = getRegistry().get('geogebra/add-line');
140
+ const page = createPageMock('https://www.geogebra.org/geometry');
141
+ page.evaluate
142
+ .mockResolvedValueOnce(true)
143
+ .mockResolvedValueOnce({ ok: true, label: 'f', beforeCount: 2, afterCount: 3, error: null });
144
+ await expect(command.func(page, { points: 'A,B', type: 'segment' })).resolves.toEqual([
145
+ { label: 'f', type: 'segment', points: 'A,B' },
146
+ ]);
147
+ });
148
+
149
+ it('does not turn object property extraction failures into empty list results', async () => {
150
+ const command = getRegistry().get('geogebra/list');
151
+ const page = createPageMock('https://www.geogebra.org/geometry');
152
+ page.evaluate
153
+ .mockResolvedValueOnce(true)
154
+ .mockResolvedValueOnce({ error: 'getObjectType failed', name: 'A' });
155
+ await expect(command.func(page, {})).rejects.toThrow(CommandExecutionError);
156
+ });
157
+
158
+ it('does not turn malformed info existence probes into not-found results', async () => {
159
+ const command = getRegistry().get('geogebra/info');
160
+ const page = createPageMock('https://www.geogebra.org/geometry');
161
+ page.evaluate
162
+ .mockResolvedValueOnce(true)
163
+ .mockResolvedValueOnce({ nope: true });
164
+ await expect(command.func(page, { name: 'A' })).rejects.toThrow(CommandExecutionError);
165
+ });
166
+
167
+ it('maps explicit false info existence probes to EmptyResultError', async () => {
168
+ const command = getRegistry().get('geogebra/info');
169
+ const page = createPageMock('https://www.geogebra.org/geometry');
170
+ page.evaluate
171
+ .mockResolvedValueOnce(true)
172
+ .mockResolvedValueOnce({ ok: true, exists: false });
173
+ await expect(command.func(page, { name: 'A' })).rejects.toThrow(EmptyResultError);
174
+ });
175
+ });
@@ -0,0 +1,62 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ensureApplet, ggbEval, ggbListObjects, ggbWaitForObjectCount, normalizeNumber, requireGgbSuccess } from './utils.js';
6
+
7
+ /**
8
+ * Draw a regular hexagon on the GeoGebra Geometry canvas.
9
+ * Creates center point, vertex, and the regular polygon in one session.
10
+ */
11
+ cli({
12
+ site: 'geogebra',
13
+ name: 'hexagon',
14
+ access: 'write',
15
+ description: 'Draw a regular hexagon centered at the origin',
16
+ domain: 'www.geogebra.org',
17
+ strategy: Strategy.PUBLIC,
18
+ browser: true,
19
+ navigateBefore: false,
20
+ example: 'opencli geogebra hexagon --size 3',
21
+ args: [
22
+ { name: 'size', required: false, default: '2', help: 'Radius of the hexagon (default: 2)' },
23
+ ],
24
+ columns: ['step', 'result'],
25
+ func: async (page, kwargs) => {
26
+ const size = normalizeNumber(kwargs.size, 'size', { defaultValue: 2, positive: true });
27
+ await ensureApplet(page);
28
+ const results = [];
29
+
30
+ const vertices = [
31
+ ['V1', `(${size},0)`],
32
+ ['V2', `(${size}*cos(pi/3),${size}*sin(pi/3))`],
33
+ ['V3', `(${size}*cos(2*pi/3),${size}*sin(2*pi/3))`],
34
+ ['V4', `(-${size},0)`],
35
+ ['V5', `(${size}*cos(4*pi/3),${size}*sin(4*pi/3))`],
36
+ ['V6', `(${size}*cos(5*pi/3),${size}*sin(5*pi/3))`],
37
+ ];
38
+ for (const [name, coords] of vertices) {
39
+ const result = requireGgbSuccess(await ggbEval(page, `${name}=${coords}`), `Failed to create point ${name}`);
40
+ results.push({ step: `${name}=${coords}`, result: `ok (${result.label || name})` });
41
+ }
42
+
43
+ const polygon = requireGgbSuccess(await ggbEval(page, 'Hexagon=Polygon(V1,V2,V3,V4,V5,V6)'), 'Failed to create hexagon polygon');
44
+ results.push({ step: 'Hexagon=Polygon(V1,V2,V3,V4,V5,V6)', result: `ok (${polygon.label || 'hexagon created'})` });
45
+
46
+ const objectCount = await ggbWaitForObjectCount(page, 7);
47
+ const objects = await ggbListObjects(page);
48
+ const screenshotPath = path.join(os.tmpdir(), 'opencli-geogebra-hexagon.png');
49
+ try {
50
+ await page.screenshot({ path: screenshotPath });
51
+ } catch (err) {
52
+ throw new CommandExecutionError(`Failed to capture GeoGebra screenshot: ${err?.message || err}`);
53
+ }
54
+
55
+ if (Array.isArray(objects) && objects.length > 0) {
56
+ results.push({ step: `canvas has ${objectCount} objects`, result: objects.map(o => `${o.name}(${o.type})`).join(', ') });
57
+ }
58
+ results.push({ step: 'screenshot', result: screenshotPath });
59
+
60
+ return results;
61
+ },
62
+ });
@@ -0,0 +1,72 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { ensureApplet, ggbGetProperty, normalizeLabel, unwrapBridgeEnvelope } from './utils.js';
4
+
5
+ cli({
6
+ site: 'geogebra',
7
+ name: 'info',
8
+ access: 'read',
9
+ description: 'Get detailed properties of a GeoGebra object',
10
+ domain: 'www.geogebra.org',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ example: 'opencli geogebra info --name A',
15
+ args: [
16
+ { name: 'name', required: true, help: 'Object label (e.g. A, c1, poly1)' },
17
+ ],
18
+ columns: ['property', 'value'],
19
+ func: async (page, kwargs) => {
20
+ const objName = normalizeLabel(kwargs.name, 'name');
21
+ await ensureApplet(page);
22
+
23
+ let exists;
24
+ try {
25
+ exists = unwrapBridgeEnvelope(await page.evaluate(`
26
+ (name => {
27
+ try {
28
+ if (typeof ggbApplet === 'undefined' || typeof ggbApplet.getObjectType !== 'function') {
29
+ return { error: 'ggbApplet is not ready' };
30
+ }
31
+ return { ok: true, exists: ggbApplet.getObjectType(name) !== '' };
32
+ } catch (err) {
33
+ return { error: err?.message || String(err) };
34
+ }
35
+ })
36
+ (${JSON.stringify(objName)})
37
+ `));
38
+ } catch (err) {
39
+ throw new CommandExecutionError(`Failed to inspect GeoGebra object: ${err?.message || err}`);
40
+ }
41
+ if (!exists || typeof exists !== 'object' || Array.isArray(exists)) {
42
+ throw new CommandExecutionError('GeoGebra object existence probe returned malformed result');
43
+ }
44
+ if (exists.error) {
45
+ throw new CommandExecutionError(`Failed to inspect GeoGebra object: ${exists.error}`);
46
+ }
47
+ if (exists.ok !== true || typeof exists.exists !== 'boolean') {
48
+ throw new CommandExecutionError('GeoGebra object existence probe returned malformed result');
49
+ }
50
+ if (exists.exists === false) {
51
+ throw new EmptyResultError(`geogebra info ${objName}`, `Object "${objName}" not found on the canvas.`);
52
+ }
53
+
54
+ const properties = ['type', 'value', 'definition', 'command', 'caption', 'visible', 'color'];
55
+ const rows = [];
56
+ for (const prop of properties) {
57
+ const val = await ggbGetProperty(page, objName, prop);
58
+ rows.push({ property: prop, value: String(val ?? '') });
59
+ }
60
+
61
+ // For point-like objects, also include coordinates
62
+ const objType = await ggbGetProperty(page, objName, 'type');
63
+ if (objType === 'point') {
64
+ const x = await ggbGetProperty(page, objName, 'xcoord');
65
+ const y = await ggbGetProperty(page, objName, 'ycoord');
66
+ rows.push({ property: 'x', value: String(x ?? '') });
67
+ rows.push({ property: 'y', value: String(y ?? '') });
68
+ }
69
+
70
+ return rows;
71
+ },
72
+ });
@@ -0,0 +1,35 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { ensureApplet, ggbListObjects } from './utils.js';
4
+
5
+ cli({
6
+ site: 'geogebra',
7
+ name: 'list',
8
+ access: 'read',
9
+ description: 'List all geometric objects on the GeoGebra canvas',
10
+ domain: 'www.geogebra.org',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ args: [
15
+ { name: 'type', required: false, help: 'Filter by object type (e.g. "point", "line", "circle")' },
16
+ ],
17
+ columns: ['name', 'type', 'value', 'visible'],
18
+ func: async (page, kwargs) => {
19
+ const filterType = kwargs.type == null || kwargs.type === ''
20
+ ? ''
21
+ : String(kwargs.type).trim().toLowerCase();
22
+ if (filterType && !/^[a-z-]+$/.test(filterType)) {
23
+ throw new ArgumentError('type must be a GeoGebra object type like point, line, or circle');
24
+ }
25
+ await ensureApplet(page);
26
+ const objects = await ggbListObjects(page, filterType);
27
+ if (!Array.isArray(objects) || objects.length === 0) {
28
+ throw new EmptyResultError(
29
+ 'geogebra list',
30
+ 'No objects found on the canvas. Fresh runs start a blank session; use one "eval" call, or inspect an already-bound tab through the browser workspace commands.',
31
+ );
32
+ }
33
+ return objects;
34
+ },
35
+ });
@@ -0,0 +1,60 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ensureApplet, ggbEval, ggbListObjects, ggbWaitForObjectCount, normalizeNumber, requireGgbSuccess } from './utils.js';
6
+
7
+ cli({
8
+ site: 'geogebra',
9
+ name: 'triangle',
10
+ access: 'write',
11
+ description: 'Draw an equilateral triangle from a horizontal base segment',
12
+ domain: 'www.geogebra.org',
13
+ strategy: Strategy.PUBLIC,
14
+ browser: true,
15
+ navigateBefore: false,
16
+ example: 'opencli geogebra triangle --size 4',
17
+ args: [
18
+ { name: 'size', required: false, default: '2', help: 'Side length of the triangle (default: 2)' },
19
+ ],
20
+ columns: ['step', 'result'],
21
+ func: async (page, kwargs) => {
22
+ const size = normalizeNumber(kwargs.size, 'size', { defaultValue: 2, positive: true });
23
+ await ensureApplet(page);
24
+ const results = [];
25
+
26
+ const r1 = requireGgbSuccess(await ggbEval(page, 'A=(0,0)'), 'Failed to create point A');
27
+ results.push({ step: 'base point A=(0,0)', result: `ok (${r1.label || 'A'})` });
28
+
29
+ const r2 = requireGgbSuccess(await ggbEval(page, `B=(${size},0)`), 'Failed to create point B');
30
+ results.push({ step: `base point B=(${size},0)`, result: `ok (${r2.label || 'B'})` });
31
+
32
+ const r3 = requireGgbSuccess(await ggbEval(page, 'c=Circle(A,B)'), 'Failed to create circle c');
33
+ results.push({ step: 'c=Circle(A,B)', result: `ok (${r3.label || 'c'})` });
34
+
35
+ const r4 = requireGgbSuccess(await ggbEval(page, 'd=Circle(B,A)'), 'Failed to create circle d');
36
+ results.push({ step: 'd=Circle(B,A)', result: `ok (${r4.label || 'd'})` });
37
+
38
+ const r5 = requireGgbSuccess(await ggbEval(page, 'C=Intersect(c,d,1)'), 'Failed to create point C');
39
+ results.push({ step: 'C=Intersect(c,d,1)', result: `ok (${r5.label || 'C'})` });
40
+
41
+ const r6 = requireGgbSuccess(await ggbEval(page, 'Polygon(A,B,C)'), 'Failed to create triangle polygon');
42
+ results.push({ step: 'Polygon(A,B,C)', result: `ok (${r6.label || 'triangle created'})` });
43
+
44
+ const objectCount = await ggbWaitForObjectCount(page, 5);
45
+ const objects = await ggbListObjects(page);
46
+ const screenshotPath = path.join(os.tmpdir(), 'opencli-geogebra-triangle.png');
47
+ try {
48
+ await page.screenshot({ path: screenshotPath });
49
+ } catch (err) {
50
+ throw new CommandExecutionError(`Failed to capture GeoGebra screenshot: ${err?.message || err}`);
51
+ }
52
+ results.push({
53
+ step: `canvas has ${objectCount} objects`,
54
+ result: objects.map((obj) => `${obj.name}(${obj.type})`).join(', '),
55
+ });
56
+ results.push({ step: 'screenshot', result: screenshotPath });
57
+
58
+ return results;
59
+ },
60
+ });
@@ -0,0 +1,271 @@
1
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ /**
4
+ * Shared utilities for GeoGebra adapters.
5
+ *
6
+ * GeoGebra Geometry exposes a `ggbApplet` JavaScript API on the page after
7
+ * the GWT-compiled app initializes. All adapters share the same pattern:
8
+ * navigate → wait for applet → call API via page.evaluate().
9
+ */
10
+
11
+ const GEOGEBRA_URL = 'https://www.geogebra.org/geometry';
12
+ const APPLET_WAIT_MS = 15_000;
13
+
14
+ export function unwrapBridgeEnvelope(value) {
15
+ if (value && typeof value === 'object' && 'data' in value && 'session' in value) {
16
+ return value.data;
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function isPlainObject(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+
25
+ export function normalizeLabel(value, label = 'label') {
26
+ const normalized = String(value ?? '').trim();
27
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(normalized)) {
28
+ throw new ArgumentError(`${label} must be an ASCII GeoGebra label like A, B1, or poly_1`);
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ export function normalizeLabelList(value, label, min, max = Infinity) {
34
+ const parts = String(value ?? '').split(',').map(s => s.trim()).filter(Boolean);
35
+ if (parts.length < min || parts.length > max) {
36
+ throw new ArgumentError(`${label} must contain ${min === max ? min : `${min}-${max}`} comma-separated labels`);
37
+ }
38
+ return parts.map((part, idx) => normalizeLabel(part, `${label}[${idx + 1}]`));
39
+ }
40
+
41
+ export function normalizeNumber(value, label, { defaultValue, positive = false } = {}) {
42
+ const raw = value == null || value === '' ? defaultValue : value;
43
+ const number = Number(raw);
44
+ if (!Number.isFinite(number) || (positive && number <= 0)) {
45
+ throw new ArgumentError(`${label} must be a ${positive ? 'positive ' : ''}finite number`);
46
+ }
47
+ return number;
48
+ }
49
+
50
+ export function normalizeCoords(value) {
51
+ const parts = String(value ?? '').split(',').map(s => s.trim());
52
+ if (parts.length !== 2) {
53
+ throw new ArgumentError('coords must be in "x,y" format (e.g. "1,2")');
54
+ }
55
+ return parts.map((part, idx) => normalizeNumber(part, idx === 0 ? 'x' : 'y'));
56
+ }
57
+
58
+ export function requireGgbSuccess(result, message) {
59
+ if (!isPlainObject(result)) {
60
+ throw new CommandExecutionError(`${message}: malformed GeoGebra result`);
61
+ }
62
+ if (!result.ok) {
63
+ throw new CommandExecutionError(result.error || message);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Navigate to GeoGebra Geometry (if not already there) and wait for
70
+ * the ggbApplet API to become available.
71
+ */
72
+ export async function ensureApplet(page) {
73
+ let currentUrl = '';
74
+ try {
75
+ currentUrl = await page.getCurrentUrl();
76
+ } catch {
77
+ currentUrl = '';
78
+ }
79
+ // If already on the geometry page, check if applet is ready without re-navigating
80
+ if (currentUrl?.includes('geogebra.org/geometry')) {
81
+ try {
82
+ const ready = unwrapBridgeEnvelope(await page.evaluate(`typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function'`));
83
+ if (ready) return;
84
+ } catch (err) {
85
+ throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
86
+ }
87
+ }
88
+ // Navigate to GeoGebra Geometry
89
+ try {
90
+ await page.goto(GEOGEBRA_URL);
91
+ } catch (err) {
92
+ throw new CommandExecutionError(`Failed to load GeoGebra Geometry: ${err?.message || err}`);
93
+ }
94
+
95
+ let ready;
96
+ try {
97
+ ready = unwrapBridgeEnvelope(await page.evaluate(`
98
+ (async () => {
99
+ const deadline = Date.now() + ${APPLET_WAIT_MS};
100
+ while (Date.now() < deadline) {
101
+ if (typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function') {
102
+ return true;
103
+ }
104
+ await new Promise(r => setTimeout(r, 500));
105
+ }
106
+ return false;
107
+ })()
108
+ `));
109
+ } catch (err) {
110
+ throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
111
+ }
112
+ if (ready !== true) {
113
+ throw new CommandExecutionError('ggbApplet not available after waiting. Make sure the GeoGebra Geometry page is fully loaded.');
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Execute a GeoGebra command string via ggbApplet.evalCommandGetLabels.
119
+ * evalCommandGetLabels both executes the command and returns the created
120
+ * object label(s). We use it instead of evalCommand to avoid double-execution.
121
+ * Returns { ok, label } where label is the resulting object label(s).
122
+ */
123
+ export async function ggbEval(page, cmd) {
124
+ let result;
125
+ try {
126
+ result = unwrapBridgeEnvelope(await page.evaluate(`
127
+ (cmd => {
128
+ if (typeof ggbApplet === 'undefined' || typeof ggbApplet.evalCommandGetLabels !== 'function') {
129
+ return { ok: false, label: '', beforeCount: 0, afterCount: 0, error: 'ggbApplet is not ready' };
130
+ }
131
+ const collectNames = () => {
132
+ let names = ggbApplet.getAllObjectNames();
133
+ if (typeof names === 'string') {
134
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
135
+ }
136
+ return Array.isArray(names) ? names : [];
137
+ };
138
+ const beforeCount = collectNames().length;
139
+ const label = ggbApplet.evalCommandGetLabels(cmd);
140
+ const afterCount = collectNames().length;
141
+ const dialogText = [...document.querySelectorAll('[role="dialog"], .gwt-DialogBox')]
142
+ .map(node => node.textContent?.trim() || '')
143
+ .find(text => /error|unknown command|错误|未知的指令/i.test(text)) || '';
144
+ return {
145
+ ok: label !== '' || afterCount > beforeCount,
146
+ label,
147
+ beforeCount,
148
+ afterCount,
149
+ error: dialogText || null,
150
+ };
151
+ })(${JSON.stringify(cmd)})
152
+ `));
153
+ } catch (err) {
154
+ throw new CommandExecutionError(`Failed to execute GeoGebra command: ${err?.message || err}`);
155
+ }
156
+ if (!isPlainObject(result) || typeof result.ok !== 'boolean') {
157
+ throw new CommandExecutionError('GeoGebra command returned malformed result');
158
+ }
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * List all currently known GeoGebra objects, optionally filtered by type.
164
+ */
165
+ export async function ggbListObjects(page, filterType) {
166
+ const normalizedFilter = filterType ? String(filterType).toLowerCase() : '';
167
+ let objects;
168
+ try {
169
+ objects = unwrapBridgeEnvelope(await page.evaluate(`
170
+ (filterType => {
171
+ const api = ggbApplet;
172
+ let names = api.getAllObjectNames();
173
+ if (typeof names === 'string') {
174
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
175
+ }
176
+ if (!Array.isArray(names)) return { error: 'Object names are not an array' };
177
+ const result = [];
178
+ for (const name of names) {
179
+ try {
180
+ const type = api.getObjectType(name);
181
+ if (!type) return { error: 'Object has no type', name };
182
+ if (filterType && type.toLowerCase() !== filterType) continue;
183
+ result.push({
184
+ name,
185
+ type,
186
+ value: api.getValueString(name) || '',
187
+ visible: api.getVisible(name),
188
+ });
189
+ } catch (err) {
190
+ return { error: err?.message || String(err), name };
191
+ }
192
+ }
193
+ return result;
194
+ })(${JSON.stringify(normalizedFilter)})
195
+ `));
196
+ } catch (err) {
197
+ throw new CommandExecutionError(`Failed to list GeoGebra objects: ${err?.message || err}`);
198
+ }
199
+ if (objects && typeof objects === 'object' && !Array.isArray(objects) && objects.error) {
200
+ const nameSuffix = objects.name ? ` for ${objects.name}` : '';
201
+ throw new CommandExecutionError(`Failed to list GeoGebra objects${nameSuffix}: ${objects.error}`);
202
+ }
203
+ if (!Array.isArray(objects)) {
204
+ throw new CommandExecutionError('GeoGebra object list returned malformed result');
205
+ }
206
+ return objects;
207
+ }
208
+
209
+ /**
210
+ * Poll until the object count reaches the requested minimum.
211
+ */
212
+ export async function ggbWaitForObjectCount(page, minCount, timeoutMs = 4_000) {
213
+ const normalizedMinCount = normalizeNumber(minCount, 'minCount', { positive: true });
214
+ const normalizedTimeoutMs = normalizeNumber(timeoutMs, 'timeoutMs', { positive: true });
215
+ let count;
216
+ try {
217
+ count = unwrapBridgeEnvelope(await page.evaluate(`
218
+ (async () => {
219
+ const deadline = Date.now() + ${normalizedTimeoutMs};
220
+ while (Date.now() < deadline) {
221
+ let names = ggbApplet.getAllObjectNames();
222
+ if (typeof names === 'string') {
223
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
224
+ }
225
+ if (Array.isArray(names) && names.length >= ${normalizedMinCount}) {
226
+ return names.length;
227
+ }
228
+ await new Promise(resolve => setTimeout(resolve, 200));
229
+ }
230
+ let names = ggbApplet.getAllObjectNames();
231
+ if (typeof names === 'string') {
232
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
233
+ }
234
+ return Array.isArray(names) ? names.length : 0;
235
+ })()
236
+ `));
237
+ } catch (err) {
238
+ throw new CommandExecutionError(`Failed waiting for GeoGebra object count: ${err?.message || err}`);
239
+ }
240
+ if (!Number.isFinite(Number(count))) {
241
+ throw new CommandExecutionError('GeoGebra object count returned malformed result');
242
+ }
243
+ return Number(count);
244
+ }
245
+
246
+ /**
247
+ * Read a property from a GeoGebra object.
248
+ */
249
+ export async function ggbGetProperty(page, objName, property) {
250
+ try {
251
+ return unwrapBridgeEnvelope(await page.evaluate(`
252
+ (objName, property) => {
253
+ const api = ggbApplet;
254
+ switch (property) {
255
+ case 'type': return api.getObjectType(objName);
256
+ case 'value': return api.getValueString(objName);
257
+ case 'color': return api.getColor(objName);
258
+ case 'visible': return api.getVisible(objName);
259
+ case 'caption': return api.getCaption(objName) || '';
260
+ case 'xcoord': return api.getXcoord(objName);
261
+ case 'ycoord': return api.getYcoord(objName);
262
+ case 'definition': return api.getDefinitionString(objName);
263
+ case 'command': return api.getCommandString(objName);
264
+ default: return null;
265
+ }
266
+ }
267
+ `, objName, property));
268
+ } catch (err) {
269
+ throw new CommandExecutionError(`Failed to read GeoGebra object property: ${err?.message || err}`);
270
+ }
271
+ }