@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
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { extractListEntry, parseListsManagement } from './lists.js';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { extractListEntry, isOwnedSubscribedEntry, parseListsManagement } from './lists.js';
3
5
 
4
6
  describe('twitter lists parser', () => {
5
7
  it('extracts a list entry with full metadata', () => {
@@ -56,7 +58,7 @@ describe('twitter lists parser', () => {
56
58
  expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull();
57
59
  });
58
60
 
59
- it('parses ListsManagementPageTimeline payload instructions', () => {
61
+ it('parses ListsManagementPageTimeline payload instructions (real shape: nested module items)', () => {
60
62
  const payload = {
61
63
  data: {
62
64
  viewer: {
@@ -64,23 +66,92 @@ describe('twitter lists parser', () => {
64
66
  timeline: {
65
67
  instructions: [
66
68
  {
69
+ type: 'TimelineAddEntries',
67
70
  entries: [
68
71
  {
69
- entryId: 'owned-list-1',
72
+ entryId: 'owned-subscribed-list-module-0',
70
73
  content: {
71
- itemContent: {
72
- list: { id_str: '1', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public' },
73
- },
74
+ entryType: 'TimelineTimelineModule',
75
+ items: [
76
+ {
77
+ entryId: 'owned-subscribed-list-module-0-list-1',
78
+ item: {
79
+ itemContent: {
80
+ itemType: 'TimelineTwitterList',
81
+ list: { id_str: '1', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' },
82
+ },
83
+ },
84
+ },
85
+ {
86
+ entryId: 'owned-subscribed-list-module-0-list-2',
87
+ item: {
88
+ itemContent: {
89
+ itemType: 'TimelineTwitterList',
90
+ list: { id_str: '2', name: 'Anthropic Team', member_count: 10, subscriber_count: 0, mode: 'Public' },
91
+ },
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ },
101
+ },
102
+ },
103
+ },
104
+ };
105
+ const result = parseListsManagement(payload, new Set());
106
+ expect(result).toHaveLength(2);
107
+ expect(result[0]).toMatchObject({ id: '1', name: 'AI & Agents', mode: 'private' });
108
+ expect(result[1]).toMatchObject({ id: '2', name: 'Anthropic Team', mode: 'public' });
109
+ });
110
+
111
+ it('skips "Discover new Lists" recommendations (list-to-follow-module-*)', () => {
112
+ // 真实 X.com /<user>/lists 响应:Discover 推荐 + Your Lists 同 instruction,
113
+ // 区别只在 entry.entryId 前缀。Parser 必须按前缀剔除推荐。
114
+ const payload = {
115
+ data: {
116
+ viewer: {
117
+ list_management_timeline: {
118
+ timeline: {
119
+ instructions: [
120
+ {
121
+ type: 'TimelineAddEntries',
122
+ entries: [
123
+ {
124
+ entryId: 'list-to-follow-module-2050754937725386752',
125
+ content: {
126
+ entryType: 'TimelineTimelineModule',
127
+ items: [
128
+ {
129
+ entryId: 'list-to-follow-module-XYZ-list-1597593475389984769',
130
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1597593475389984769', name: 'Crypto', member_count: 44, subscriber_count: 8947, mode: 'Public' } } },
131
+ },
132
+ {
133
+ entryId: 'list-to-follow-module-XYZ-list-1499395616262217730',
134
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1499395616262217730', name: 'Crypto Blockchain', member_count: 24, subscriber_count: 1166, mode: 'Public' } } },
135
+ },
136
+ ],
74
137
  },
75
138
  },
76
139
  {
77
- entryId: 'subscribed-list-2',
140
+ entryId: 'owned-subscribed-list-module-0',
78
141
  content: {
79
- itemContent: {
80
- list: { id_str: '2', name: 'AI', member_count: 15, subscriber_count: 0, mode: 'Private' },
81
- },
142
+ entryType: 'TimelineTimelineModule',
143
+ items: [
144
+ {
145
+ entryId: 'owned-subscribed-list-module-0-list-2044679538156912976',
146
+ item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '2044679538156912976', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' } } },
147
+ },
148
+ ],
82
149
  },
83
150
  },
151
+ {
152
+ entryId: 'cursor-bottom-2050754937725386750',
153
+ content: { entryType: 'TimelineTimelineCursor' },
154
+ },
84
155
  ],
85
156
  },
86
157
  ],
@@ -90,9 +161,19 @@ describe('twitter lists parser', () => {
90
161
  },
91
162
  };
92
163
  const result = parseListsManagement(payload, new Set());
93
- expect(result).toHaveLength(2);
94
- expect(result[0]).toMatchObject({ id: '1', name: 'Crypto', mode: 'public' });
95
- expect(result[1]).toMatchObject({ id: '2', name: 'AI', mode: 'private' });
164
+ expect(result).toHaveLength(1);
165
+ expect(result[0]).toMatchObject({ id: '2044679538156912976', name: 'AI & Agents' });
166
+ // No Crypto/Blockchain leakage
167
+ expect(result.find(l => l.name === 'Crypto')).toBeUndefined();
168
+ expect(result.find(l => l.name === 'Crypto Blockchain')).toBeUndefined();
169
+ });
170
+
171
+ it('isOwnedSubscribedEntry classifies entryIds', () => {
172
+ expect(isOwnedSubscribedEntry({ entryId: 'owned-subscribed-list-module-0' })).toBe(true);
173
+ expect(isOwnedSubscribedEntry({ entryId: 'list-to-follow-module-2050754937725386752' })).toBe(false);
174
+ expect(isOwnedSubscribedEntry({ entryId: 'cursor-bottom-XYZ' })).toBe(false);
175
+ expect(isOwnedSubscribedEntry({})).toBe(false);
176
+ expect(isOwnedSubscribedEntry({ entryId: null })).toBe(false);
96
177
  });
97
178
 
98
179
  it('returns empty list for malformed payload', () => {
@@ -100,13 +181,23 @@ describe('twitter lists parser', () => {
100
181
  expect(parseListsManagement({ data: {} }, new Set())).toEqual([]);
101
182
  });
102
183
 
103
- it('dedupes across repeated entries', () => {
104
- const entryA = { content: { itemContent: { list: { id_str: '1', name: 'A' } } } };
184
+ it('dedupes across repeated entries within owned-subscribed module', () => {
185
+ const itemA = {
186
+ entryId: 'owned-subscribed-list-module-0-list-1',
187
+ item: { itemContent: { list: { id_str: '1', name: 'A' } } },
188
+ };
105
189
  const payload = {
106
190
  data: {
107
191
  viewer: {
108
192
  list_management_timeline: {
109
- timeline: { instructions: [{ entries: [entryA, entryA] }] },
193
+ timeline: {
194
+ instructions: [{
195
+ entries: [{
196
+ entryId: 'owned-subscribed-list-module-0',
197
+ content: { items: [itemA, itemA] },
198
+ }],
199
+ }],
200
+ },
110
201
  },
111
202
  },
112
203
  },
@@ -114,4 +205,49 @@ describe('twitter lists parser', () => {
114
205
  const result = parseListsManagement(payload, new Set());
115
206
  expect(result).toHaveLength(1);
116
207
  });
208
+
209
+ it('fails malformed command payloads as parser drift instead of empty success', async () => {
210
+ const command = getRegistry().get('twitter/lists');
211
+ const page = {
212
+ getCookies: async () => [{ name: 'ct0', value: 'token' }],
213
+ evaluate: async (script) => {
214
+ if (script.includes('placeholder.json')) return null;
215
+ return { data: {} };
216
+ },
217
+ };
218
+
219
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
220
+ });
221
+
222
+ it('treats a recommendation-only timeline as a true empty result', async () => {
223
+ const command = getRegistry().get('twitter/lists');
224
+ const page = {
225
+ getCookies: async () => [{ name: 'ct0', value: 'token' }],
226
+ evaluate: async (script) => {
227
+ if (script.includes('placeholder.json')) return null;
228
+ return {
229
+ data: {
230
+ viewer: {
231
+ list_management_timeline: {
232
+ timeline: {
233
+ instructions: [{
234
+ entries: [{
235
+ entryId: 'list-to-follow-module-1',
236
+ content: {
237
+ items: [{
238
+ item: { itemContent: { list: { id_str: '9', name: 'Recommended' } } },
239
+ }],
240
+ },
241
+ }],
242
+ }],
243
+ },
244
+ },
245
+ },
246
+ },
247
+ };
248
+ },
249
+ };
250
+
251
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
252
+ });
117
253
  });
@@ -72,14 +72,14 @@ cli({
72
72
  return;
73
73
  let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
74
74
  let actionText = 'Notification';
75
- let author = 'unknown';
75
+ let author = '';
76
76
  let text = '';
77
77
  let urlStr = '';
78
78
  if (item.__typename === 'TimelineNotification') {
79
79
  text = item.rich_message?.text || item.message?.text || '';
80
80
  const fromUser = item.template?.from_users?.[0]?.user_results?.result;
81
81
  // Twitter moved screen_name from legacy to core
82
- author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
82
+ author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || '';
83
83
  urlStr = item.notification_url?.url || '';
84
84
  actionText = item.notification_icon || 'Activity';
85
85
  const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
@@ -94,14 +94,14 @@ cli({
94
94
  else if (item.__typename === 'TweetNotification') {
95
95
  const tweet = item.tweet_result?.result;
96
96
  const tweetUser = tweet?.core?.user_results?.result;
97
- author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
97
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
98
98
  text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
99
99
  actionText = 'Mention/Reply';
100
100
  urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
101
101
  }
102
102
  else if (item.__typename === 'Tweet') {
103
103
  const tweetUser = item.core?.user_results?.result;
104
- author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
104
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
105
105
  text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
106
106
  actionText = 'Mention';
107
107
  urlStr = `https://x.com/i/status/${item.rest_id}`;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { cli, Strategy } from '@jackwener/opencli/registry';
4
4
  import { CommandExecutionError } from '@jackwener/opencli/errors';
5
+ import { isRecoverableFileInputError } from './utils.js';
5
6
 
6
7
  const MAX_IMAGES = 4;
7
8
  const UPLOAD_POLL_MS = 500;
@@ -142,6 +143,55 @@ async function waitForImageUpload(page, expectedCount) {
142
143
  })()`);
143
144
  }
144
145
 
146
+ async function attachImagesViaDataTransfer(page, absPaths) {
147
+ const files = absPaths.map((absPath) => {
148
+ const ext = path.extname(absPath).toLowerCase();
149
+ const mime = ext === '.png'
150
+ ? 'image/png'
151
+ : ext === '.gif'
152
+ ? 'image/gif'
153
+ : ext === '.webp'
154
+ ? 'image/webp'
155
+ : 'image/jpeg';
156
+ return {
157
+ name: path.basename(absPath),
158
+ mime,
159
+ base64: fs.readFileSync(absPath).toString('base64'),
160
+ };
161
+ });
162
+ const upload = await page.evaluate(`(() => {
163
+ const input = document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)});
164
+ if (!input) return { ok: false, error: 'No file input found' };
165
+ const dt = new DataTransfer();
166
+ for (const file of ${JSON.stringify(files)}) {
167
+ const bin = atob(file.base64);
168
+ const bytes = new Uint8Array(bin.length);
169
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
170
+ dt.items.add(new File([bytes], file.name, { type: file.mime }));
171
+ }
172
+ let assigned = false;
173
+ try {
174
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false, configurable: true });
175
+ assigned = input.files && input.files.length >= ${JSON.stringify(absPaths.length)};
176
+ } catch(e) {
177
+ try {
178
+ const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files');
179
+ if (nativeInputFileSetter && nativeInputFileSetter.set) {
180
+ nativeInputFileSetter.set.call(input, dt.files);
181
+ assigned = input.files && input.files.length >= ${JSON.stringify(absPaths.length)};
182
+ }
183
+ } catch(e2) { /* ignore */ }
184
+ }
185
+ if (!assigned) return { ok: false, error: 'Could not assign files to input' };
186
+ input.dispatchEvent(new Event('change', { bubbles: true }));
187
+ input.dispatchEvent(new Event('input', { bubbles: true }));
188
+ return { ok: true };
189
+ })()`);
190
+ if (!upload?.ok) {
191
+ throw new CommandExecutionError(`Image upload failed (base64 fallback): ${upload?.error ?? 'unknown error'}`);
192
+ }
193
+ }
194
+
145
195
  async function submitTweet(page, text) {
146
196
  const clickResult = await page.evaluate(`(async () => {
147
197
  try {
@@ -224,11 +274,19 @@ cli({
224
274
  // Attach media before inserting text. Uploading media after Draft.js has
225
275
  // text can re-render/reset the editor, causing image-only posts.
226
276
  if (absPaths.length > 0) {
227
- if (!page.setFileInput) {
228
- throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
229
- }
230
277
  await page.wait({ selector: FILE_INPUT_SELECTOR, timeout: 20 });
231
- await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
278
+ if (page.setFileInput) {
279
+ try {
280
+ await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
281
+ } catch (err) {
282
+ if (!isRecoverableFileInputError(err)) {
283
+ throw err;
284
+ }
285
+ await attachImagesViaDataTransfer(page, absPaths);
286
+ }
287
+ } else {
288
+ await attachImagesViaDataTransfer(page, absPaths);
289
+ }
232
290
  const uploadState = await waitForImageUpload(page, absPaths.length);
233
291
  if (!uploadState?.ok) {
234
292
  return [{ status: 'failed', message: uploadState?.message ?? `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text }];
@@ -11,6 +11,7 @@ vi.mock('node:fs', async (importOriginal) => {
11
11
  return undefined;
12
12
  return { isFile: () => true };
13
13
  }),
14
+ readFileSync: vi.fn(() => Buffer.from([0x89, 0x50, 0x4e, 0x47])),
14
15
  };
15
16
  });
16
17
 
@@ -123,10 +124,41 @@ describe('twitter post command', () => {
123
124
  await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
124
125
  });
125
126
 
126
- it('throws when page.setFileInput is not available', async () => {
127
+ it('falls back to DataTransfer upload when page.setFileInput is not available', async () => {
127
128
  const command = getCommand();
128
- const page = makePage([], { setFileInput: undefined });
129
- await expect(command.func(page, { text: 'hi', images: 'a.png' })).rejects.toThrow('Browser extension does not support file upload');
129
+ const page = makePage([
130
+ { ok: true }, // DataTransfer fallback
131
+ { ok: true, previewCount: 1 }, // upload polling
132
+ { ok: true }, // focus composer
133
+ { ok: true }, // verify native insertText
134
+ { ok: true }, // click post
135
+ { ok: true, message: 'Tweet posted successfully.' },
136
+ ], { setFileInput: undefined });
137
+
138
+ const result = await command.func(page, { text: 'hi', images: 'a.png' });
139
+
140
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hi' }]);
141
+ expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
142
+ expect(page.evaluate.mock.calls[0][0]).toContain('Could not assign files to input');
143
+ });
144
+
145
+ it('falls back to DataTransfer upload when CDP rejects file input as not allowed', async () => {
146
+ const command = getCommand();
147
+ const setFileInput = vi.fn().mockRejectedValue(new Error('NotAllowedError: Not allowed'));
148
+ const page = makePage([
149
+ { ok: true }, // DataTransfer fallback
150
+ { ok: true, previewCount: 1 }, // upload polling
151
+ { ok: true }, // focus composer
152
+ { ok: true }, // verify native insertText
153
+ { ok: true }, // click post
154
+ { ok: true, message: 'Tweet posted successfully.' },
155
+ ], { setFileInput });
156
+
157
+ const result = await command.func(page, { text: 'with fallback', images: 'a.png' });
158
+
159
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with fallback' }]);
160
+ expect(setFileInput).toHaveBeenCalledWith(['/abs/a.png'], 'input[type="file"][data-testid="fileInput"]');
161
+ expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
130
162
  });
131
163
 
132
164
  it('uploads images before inserting text so media re-renders cannot erase the tweet text', async () => {
@@ -1,8 +1,48 @@
1
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
5
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
6
+
7
+ function isPlainObject(value) {
8
+ return value != null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+
11
+ function stringField(value) {
12
+ return typeof value === 'string' ? value : '';
13
+ }
14
+
15
+ export function mapTwitterProfileResult(result, screenName) {
16
+ if (!isPlainObject(result)) {
17
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is malformed`);
18
+ }
19
+ const hasLegacy = isPlainObject(result.legacy);
20
+ const hasCore = isPlainObject(result.core);
21
+ if (!hasLegacy && !hasCore) {
22
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile fields`);
23
+ }
24
+ const legacy = hasLegacy ? result.legacy : {};
25
+ const core = hasCore ? result.core : {};
26
+ if (!stringField(core.screen_name) && !stringField(legacy.screen_name) && !stringField(core.name) && !stringField(legacy.name) && !stringField(core.created_at) && !stringField(legacy.created_at)) {
27
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile identity fields`);
28
+ }
29
+ const location = isPlainObject(result.location) ? result.location : {};
30
+ const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
31
+ return [{
32
+ screen_name: stringField(core.screen_name) || stringField(legacy.screen_name) || screenName,
33
+ name: stringField(core.name) || stringField(legacy.name),
34
+ bio: stringField(legacy.description),
35
+ location: stringField(location.location) || stringField(legacy.location),
36
+ url: stringField(expandedUrl),
37
+ followers: legacy.followers_count || 0,
38
+ following: legacy.friends_count || 0,
39
+ tweets: legacy.statuses_count || 0,
40
+ likes: legacy.favourites_count || 0,
41
+ verified: Boolean(result.is_blue_verified || legacy.verified),
42
+ created_at: stringField(core.created_at) || stringField(legacy.created_at),
43
+ }];
44
+ }
45
+
6
46
  cli({
7
47
  site: 'twitter',
8
48
  name: 'profile',
@@ -46,7 +86,7 @@ cli({
46
86
  if (!ct0)
47
87
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
48
88
  const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
49
- const result = await page.evaluate(`
89
+ const rawResult = unwrapBrowserResult(await page.evaluate(`
50
90
  async () => {
51
91
  const screenName = "${username}";
52
92
  const ct0 = ${JSON.stringify(ct0)};
@@ -82,34 +122,47 @@ cli({
82
122
  + encodeURIComponent(variables)
83
123
  + '&features=' + encodeURIComponent(features);
84
124
 
85
- const resp = await fetch(url, {headers, credentials: 'include'});
86
- if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
87
- const d = await resp.json();
125
+ let resp;
126
+ try {
127
+ resp = await fetch(url, {headers, credentials: 'include'});
128
+ } catch (error) {
129
+ return {ok: false, error: 'Twitter profile request failed: ' + String(error && error.message || error)};
130
+ }
131
+ if (!resp.ok) {
132
+ return {
133
+ ok: false,
134
+ auth: resp.status === 401 || resp.status === 403,
135
+ error: 'HTTP ' + resp.status,
136
+ hint: 'User may not exist, auth may be required, or queryId expired'
137
+ };
138
+ }
139
+ let d;
140
+ try {
141
+ d = await resp.json();
142
+ } catch (error) {
143
+ return {ok: false, error: 'Twitter profile response was not JSON: ' + String(error && error.message || error)};
144
+ }
88
145
 
89
146
  const result = d.data?.user?.result;
90
- if (!result) return {error: 'User @' + screenName + ' not found'};
91
-
92
- const legacy = result.legacy || {};
93
- const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
94
-
95
- return [{
96
- screen_name: legacy.screen_name || screenName,
97
- name: legacy.name || '',
98
- bio: legacy.description || '',
99
- location: legacy.location || '',
100
- url: expandedUrl,
101
- followers: legacy.followers_count || 0,
102
- following: legacy.friends_count || 0,
103
- tweets: legacy.statuses_count || 0,
104
- likes: legacy.favourites_count || 0,
105
- verified: result.is_blue_verified || legacy.verified || false,
106
- created_at: legacy.created_at || '',
107
- }];
147
+ if (!result) return {ok: false, notFound: true, error: 'User @' + screenName + ' not found'};
148
+ return {ok: true, result};
108
149
  }
109
- `);
110
- if (result?.error) {
111
- throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
150
+ `));
151
+ if (!isPlainObject(rawResult)) {
152
+ throw new CommandExecutionError('Twitter profile response payload is malformed');
153
+ }
154
+ if (!rawResult.ok) {
155
+ const message = rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : '');
156
+ if (rawResult.auth) {
157
+ throw new AuthRequiredError('x.com', message);
158
+ }
159
+ if (rawResult.notFound) {
160
+ throw new EmptyResultError('twitter profile', message);
161
+ }
162
+ throw new CommandExecutionError(message);
112
163
  }
113
- return result || [];
164
+ return mapTwitterProfileResult(rawResult.result, username);
114
165
  }
115
166
  });
167
+
168
+ export const __test__ = { mapTwitterProfileResult };
@@ -1,9 +1,73 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4
- import './profile.js';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { __test__ } from './profile.js';
5
5
 
6
6
  describe('twitter profile command', () => {
7
+ it('maps current result.core profile fields while preserving legacy fallback fields', () => {
8
+ const rows = __test__.mapTwitterProfileResult({
9
+ core: {
10
+ screen_name: 'AstroHanRay',
11
+ name: 'AstroHan',
12
+ created_at: 'Sun Mar 20 00:00:00 +0000 2011',
13
+ },
14
+ legacy: {
15
+ screen_name: null,
16
+ name: null,
17
+ description: 'bio text',
18
+ location: 'legacy location',
19
+ followers_count: 117,
20
+ friends_count: 12,
21
+ statuses_count: 30,
22
+ favourites_count: 4,
23
+ verified: false,
24
+ entities: { url: { urls: [{ expanded_url: 'https://example.com' }] } },
25
+ },
26
+ location: { location: 'core location' },
27
+ is_blue_verified: true,
28
+ }, 'fallback');
29
+
30
+ expect(rows).toEqual([{
31
+ screen_name: 'AstroHanRay',
32
+ name: 'AstroHan',
33
+ bio: 'bio text',
34
+ location: 'core location',
35
+ url: 'https://example.com',
36
+ followers: 117,
37
+ following: 12,
38
+ tweets: 30,
39
+ likes: 4,
40
+ verified: true,
41
+ created_at: 'Sun Mar 20 00:00:00 +0000 2011',
42
+ }]);
43
+ });
44
+
45
+ it('falls back to legacy profile fields for older UserByScreenName responses', () => {
46
+ const rows = __test__.mapTwitterProfileResult({
47
+ legacy: {
48
+ screen_name: 'legacy_user',
49
+ name: 'Legacy Name',
50
+ created_at: 'Wed Jan 01 00:00:00 +0000 2020',
51
+ location: 'legacy location',
52
+ },
53
+ }, 'fallback');
54
+
55
+ expect(rows[0]).toMatchObject({
56
+ screen_name: 'legacy_user',
57
+ name: 'Legacy Name',
58
+ created_at: 'Wed Jan 01 00:00:00 +0000 2020',
59
+ location: 'legacy location',
60
+ });
61
+ });
62
+
63
+ it('throws typed when the profile result is structurally malformed', () => {
64
+ expect(() => __test__.mapTwitterProfileResult(null, 'jack')).toThrow(CommandExecutionError);
65
+ expect(() => __test__.mapTwitterProfileResult([], 'jack')).toThrow(CommandExecutionError);
66
+ expect(() => __test__.mapTwitterProfileResult({}, 'jack')).toThrow(CommandExecutionError);
67
+ expect(() => __test__.mapTwitterProfileResult({ __typename: 'UserUnavailable' }, 'jack')).toThrow(CommandExecutionError);
68
+ expect(() => __test__.mapTwitterProfileResult({ legacy: {}, core: {} }, 'jack')).toThrow(CommandExecutionError);
69
+ });
70
+
7
71
  it('rejects invalid explicit usernames before navigation', async () => {
8
72
  const command = getRegistry().get('twitter/profile');
9
73
  const page = {
@@ -36,4 +100,51 @@ describe('twitter profile command', () => {
36
100
  expect(page.goto).toHaveBeenCalledTimes(1);
37
101
  expect(page.getCookies).not.toHaveBeenCalled();
38
102
  });
103
+
104
+ it('unwraps Browser Bridge envelopes around UserByScreenName payloads', async () => {
105
+ const command = getRegistry().get('twitter/profile');
106
+ const page = {
107
+ goto: vi.fn().mockResolvedValue(undefined),
108
+ wait: vi.fn().mockResolvedValue(undefined),
109
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
110
+ evaluate: vi.fn()
111
+ .mockResolvedValueOnce(null)
112
+ .mockResolvedValueOnce({
113
+ session: 'site:twitter',
114
+ data: {
115
+ ok: true,
116
+ result: {
117
+ core: { screen_name: 'core_user', name: 'Core User', created_at: 'now' },
118
+ legacy: { description: 'bio' },
119
+ },
120
+ },
121
+ }),
122
+ };
123
+
124
+ await expect(command.func(page, { username: 'core_user' })).resolves.toEqual([
125
+ expect.objectContaining({
126
+ screen_name: 'core_user',
127
+ name: 'Core User',
128
+ bio: 'bio',
129
+ created_at: 'now',
130
+ }),
131
+ ]);
132
+ });
133
+
134
+ it('maps GraphQL auth and not-found envelopes to typed failures', async () => {
135
+ const command = getRegistry().get('twitter/profile');
136
+ const createPage = (payload) => ({
137
+ goto: vi.fn().mockResolvedValue(undefined),
138
+ wait: vi.fn().mockResolvedValue(undefined),
139
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
140
+ evaluate: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(payload),
141
+ });
142
+
143
+ await expect(command.func(createPage({ ok: false, auth: true, error: 'HTTP 401' }), { username: 'jack' }))
144
+ .rejects.toBeInstanceOf(AuthRequiredError);
145
+ await expect(command.func(createPage({ ok: false, notFound: true, error: 'User @missing not found' }), { username: 'missing' }))
146
+ .rejects.toBeInstanceOf(EmptyResultError);
147
+ await expect(command.func(createPage({ session: 'site:twitter', data: [] }), { username: 'jack' }))
148
+ .rejects.toBeInstanceOf(CommandExecutionError);
149
+ });
39
150
  });