@jackwener/opencli 1.7.17 → 1.7.19

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 (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -161,12 +161,25 @@ async function submitTweet(page, text) {
161
161
  const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
162
162
  const expectedText = normalize(expected);
163
163
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
164
+ const statusUrl = (root = document) => {
165
+ const links = Array.from(root.querySelectorAll('a[href*="/status/"]'));
166
+ for (const link of links) {
167
+ const href = link.href || link.getAttribute('href') || '';
168
+ if (!href) continue;
169
+ try {
170
+ const url = new URL(href, window.location.origin);
171
+ const match = url.pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)/);
172
+ if (match) return { url: url.href, id: match[1] };
173
+ } catch {}
174
+ }
175
+ return {};
176
+ };
164
177
  for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
165
178
  await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
166
179
  const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
167
180
  .filter((el) => visible(el));
168
181
  const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
169
- if (successToast) return { ok: true, message: 'Tweet posted successfully.' };
182
+ if (successToast) return { ok: true, message: 'Tweet posted successfully.', ...statusUrl(successToast) };
170
183
  const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
171
184
  if (alert) return { ok: false, message: (alert.textContent || 'Tweet failed to post.').trim() };
172
185
 
@@ -175,7 +188,7 @@ async function submitTweet(page, text) {
175
188
  const hasMedia = !!document.querySelector('[data-testid="attachments"], [data-testid="tweetPhoto"]')
176
189
  || document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length > 0;
177
190
  if (!composerStillHasText && !hasMedia) {
178
- return { ok: true, message: 'Tweet posted successfully.' };
191
+ return { ok: true, message: 'Tweet posted successfully.', ...statusUrl() };
179
192
  }
180
193
  }
181
194
  return { ok: false, message: 'Tweet submission did not complete before timeout.' };
@@ -194,7 +207,7 @@ cli({
194
207
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
195
208
  { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
196
209
  ],
197
- columns: ['status', 'message', 'text'],
210
+ columns: ['status', 'message', 'text', 'id', 'url'],
198
211
  func: async (page, kwargs) => {
199
212
  if (!page)
200
213
  throw new CommandExecutionError('Browser session required for twitter post');
@@ -231,6 +244,12 @@ cli({
231
244
 
232
245
  await page.wait(1);
233
246
  const result = await submitTweet(page, text);
234
- return [{ status: result?.ok ? 'success' : 'failed', message: result?.message ?? 'Tweet failed to post.', text }];
247
+ return [{
248
+ status: result?.ok ? 'success' : 'failed',
249
+ message: result?.message ?? 'Tweet failed to post.',
250
+ text,
251
+ ...(result?.id ? { id: result.id } : {}),
252
+ ...(result?.url ? { url: result.url } : {}),
253
+ }];
235
254
  }
236
255
  });
@@ -46,6 +46,11 @@ function makePage(evaluateResults = [], overrides = {}) {
46
46
  describe('twitter post command', () => {
47
47
  const getCommand = () => getRegistry().get('twitter/post');
48
48
 
49
+ it('registers created tweet id/url columns', () => {
50
+ const command = getCommand();
51
+ expect(command?.columns).toEqual(['status', 'message', 'text', 'id', 'url']);
52
+ });
53
+
49
54
  it('posts text-only tweet successfully through the current compose route', async () => {
50
55
  const command = getCommand();
51
56
  const page = makePage([
@@ -63,6 +68,31 @@ describe('twitter post command', () => {
63
68
  expect(page.insertText).toHaveBeenCalledWith('hello world');
64
69
  });
65
70
 
71
+ it('returns the created tweet URL from the success toast when available', async () => {
72
+ const command = getCommand();
73
+ const page = makePage([
74
+ { ok: true },
75
+ { ok: true },
76
+ { ok: true },
77
+ {
78
+ ok: true,
79
+ message: 'Tweet posted successfully.',
80
+ id: '2054239044884693381',
81
+ url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
82
+ },
83
+ ]);
84
+
85
+ const result = await command.func(page, { text: 'with url' });
86
+
87
+ expect(result).toEqual([{
88
+ status: 'success',
89
+ message: 'Tweet posted successfully.',
90
+ text: 'with url',
91
+ id: '2054239044884693381',
92
+ url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
93
+ }]);
94
+ });
95
+
66
96
  it('returns failed when text area not found', async () => {
67
97
  const command = getCommand();
68
98
  const page = makePage([
@@ -1,6 +1,6 @@
1
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { resolveTwitterQueryId } from './shared.js';
3
+ import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
5
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
6
6
  cli({
@@ -17,18 +17,26 @@ cli({
17
17
  ],
18
18
  columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
19
19
  func: async (page, kwargs) => {
20
- let username = (kwargs.username || '').replace(/^@/, '');
21
- // If no username, detect the logged-in user
20
+ const rawUsername = String(kwargs.username ?? '').trim();
21
+ let username = normalizeTwitterScreenName(rawUsername);
22
+ if (rawUsername && !username) {
23
+ throw new ArgumentError('twitter profile username must be a valid Twitter/X handle', 'Example: opencli twitter profile @jack');
24
+ }
25
+ // If no username, detect the logged-in user.
26
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
27
+ // unwrap so the href string is usable downstream.
22
28
  if (!username) {
23
29
  await page.goto('https://x.com/home');
24
30
  await page.wait({ selector: '[data-testid="primaryColumn"]' });
25
- const href = await page.evaluate(`() => {
31
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
26
32
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
27
33
  return link ? link.getAttribute('href') : null;
28
- }`);
29
- if (!href)
34
+ }`));
35
+ if (!href || typeof href !== 'string')
36
+ throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
37
+ username = normalizeTwitterScreenName(href);
38
+ if (!username)
30
39
  throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
31
- username = href.replace('/', '');
32
40
  }
33
41
  // Navigate directly to the user's profile page (gives us cookie context)
34
42
  await page.goto(`https://x.com/${username}`);
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4
+ import './profile.js';
5
+
6
+ describe('twitter profile command', () => {
7
+ it('rejects invalid explicit usernames before navigation', async () => {
8
+ const command = getRegistry().get('twitter/profile');
9
+ const page = {
10
+ goto: vi.fn(),
11
+ wait: vi.fn(),
12
+ getCookies: vi.fn(),
13
+ evaluate: vi.fn(),
14
+ };
15
+
16
+ await expect(command.func(page, { username: 'viewer/extra' })).rejects.toBeInstanceOf(ArgumentError);
17
+ expect(page.goto).not.toHaveBeenCalled();
18
+ expect(page.getCookies).not.toHaveBeenCalled();
19
+ expect(page.evaluate).not.toHaveBeenCalled();
20
+ });
21
+
22
+ it('rejects route-like AppTabBar hrefs instead of navigating to that route profile', async () => {
23
+ const command = getRegistry().get('twitter/profile');
24
+ const page = {
25
+ goto: vi.fn().mockResolvedValue(undefined),
26
+ wait: vi.fn().mockResolvedValue(undefined),
27
+ getCookies: vi.fn(),
28
+ evaluate: vi.fn(async (script) => {
29
+ if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
30
+ throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
31
+ }),
32
+ };
33
+
34
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
35
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
36
+ expect(page.goto).toHaveBeenCalledTimes(1);
37
+ expect(page.getCookies).not.toHaveBeenCalled();
38
+ });
39
+ });
@@ -10,6 +10,10 @@ import {
10
10
  resolveImagePath,
11
11
  } from './utils.js';
12
12
 
13
+ const COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"]';
14
+ const SUBMIT_POLL_MS = 500;
15
+ const SUBMIT_TIMEOUT_MS = 15_000;
16
+
13
17
  function buildReplyComposerUrl(rawUrl) {
14
18
  // Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
15
19
  // (silent: matched `/status/1234567` on substring `/status/123` and
@@ -19,7 +23,36 @@ function buildReplyComposerUrl(rawUrl) {
19
23
  return `https://x.com/compose/post?in_reply_to=${target.id}`;
20
24
  }
21
25
 
22
- async function submitReply(page, text) {
26
+ function isPromiseCollectedError(err) {
27
+ const msg = err instanceof Error ? err.message : String(err);
28
+ return msg.includes('Promise was collected');
29
+ }
30
+
31
+ async function openReplyComposer(page, rawUrl) {
32
+ await page.goto(buildReplyComposerUrl(rawUrl), { waitUntil: 'load', settleMs: 2500 });
33
+ try {
34
+ await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
35
+ return { ok: true };
36
+ } catch {
37
+ // X sometimes leaves /compose/post?in_reply_to=<id> on the Home
38
+ // timeline behind a loading dialog. Fall back to the canonical tweet
39
+ // page and click the visible Reply action there.
40
+ await page.goto(rawUrl, { waitUntil: 'load', settleMs: 2500 });
41
+ const clicked = await page.evaluate(`(() => {
42
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
43
+ const buttons = Array.from(document.querySelectorAll('[data-testid="reply"]'));
44
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
45
+ if (!btn) return { ok: false, message: 'Could not find the reply button on the target tweet.' };
46
+ btn.click();
47
+ return { ok: true };
48
+ })()`);
49
+ if (!clicked?.ok) return clicked;
50
+ await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
51
+ return { ok: true };
52
+ }
53
+ }
54
+
55
+ async function insertReplyText(page, text) {
23
56
  return page.evaluate(`(async () => {
24
57
  try {
25
58
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
@@ -44,23 +77,109 @@ async function submitReply(page, text) {
44
77
  }
45
78
 
46
79
  await new Promise(r => setTimeout(r, 1000));
80
+ const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
81
+ const actual = box.innerText || box.textContent || '';
82
+ if (!normalize(actual).includes(normalize(textToInsert))) {
83
+ return { ok: false, message: 'Could not verify reply text in the composer after typing.', actualText: actual };
84
+ }
85
+ return { ok: true };
86
+ } catch (e) {
87
+ return { ok: false, message: e.toString() };
88
+ }
89
+ })()`);
90
+ }
47
91
 
92
+ async function clickReplyButton(page) {
93
+ return page.evaluate(`(() => {
94
+ try {
95
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
48
96
  const buttons = Array.from(
49
97
  document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
50
98
  );
51
- const btn = buttons.find((el) => visible(el) && !el.disabled);
99
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
52
100
  if (!btn) {
53
101
  return { ok: false, message: 'Reply button is disabled or not found.' };
54
102
  }
55
103
 
56
104
  btn.click();
57
- return { ok: true, message: 'Reply posted successfully.' };
105
+ return { ok: true };
58
106
  } catch (e) {
59
107
  return { ok: false, message: e.toString() };
60
108
  }
61
109
  })()`);
62
110
  }
63
111
 
112
+ async function detectReplySent(page) {
113
+ return page.evaluate(`(() => {
114
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
115
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
116
+ .filter((el) => visible(el));
117
+ const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
118
+ if (!successToast) return { ok: false };
119
+ const link = successToast.querySelector('a[href*="/status/"]');
120
+ return {
121
+ ok: true,
122
+ message: 'Reply posted successfully.',
123
+ url: link?.href || link?.getAttribute('href') || undefined
124
+ };
125
+ })()`);
126
+ }
127
+
128
+ async function waitForReplySent(page, text) {
129
+ const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
130
+ try {
131
+ return await page.evaluate(`(async () => {
132
+ const expected = ${JSON.stringify(text)};
133
+ const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
134
+ const expectedText = normalize(expected);
135
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
136
+ for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
137
+ await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
138
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
139
+ .filter((el) => visible(el));
140
+ const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
141
+ if (successToast) {
142
+ const link = successToast.querySelector('a[href*="/status/"]');
143
+ return {
144
+ ok: true,
145
+ message: 'Reply posted successfully.',
146
+ url: link?.href || link?.getAttribute('href') || undefined
147
+ };
148
+ }
149
+ const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
150
+ if (alert) return { ok: false, message: (alert.textContent || 'Reply failed to post.').trim() };
151
+
152
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
153
+ const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText));
154
+ if (!composerStillHasText) return { ok: true, message: 'Reply posted successfully.' };
155
+ }
156
+ return { ok: false, message: 'Reply submission did not complete before timeout.' };
157
+ })()`);
158
+ } catch (err) {
159
+ // X may route the SPA immediately after click, making CDP collect the
160
+ // polling promise even though the reply was submitted. If the page now
161
+ // shows the success toast, report success instead of a false negative.
162
+ if (!isPromiseCollectedError(err)) throw err;
163
+ await page.wait(2);
164
+ const recovered = await detectReplySent(page);
165
+ if (recovered?.ok) return recovered;
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ async function submitReply(page, text) {
171
+ const typed = await insertReplyText(page, text);
172
+ if (!typed?.ok) return typed;
173
+ let clicked;
174
+ try {
175
+ clicked = await clickReplyButton(page);
176
+ } catch (err) {
177
+ if (!isPromiseCollectedError(err)) throw err;
178
+ }
179
+ if (clicked && !clicked.ok) return clicked;
180
+ return waitForReplySent(page, text);
181
+ }
182
+
64
183
  cli({
65
184
  site: 'twitter',
66
185
  name: 'reply',
@@ -75,7 +194,7 @@ cli({
75
194
  { name: 'image', help: 'Optional local image path to attach to the reply' },
76
195
  { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
77
196
  ],
78
- columns: ['status', 'message', 'text'],
197
+ columns: ['status', 'message', 'text', 'url'],
79
198
  func: async (page, kwargs) => {
80
199
  if (!page)
81
200
  throw new CommandExecutionError('Browser session required for twitter reply');
@@ -92,21 +211,24 @@ cli({
92
211
  localImagePath = downloaded.absPath;
93
212
  cleanupDir = downloaded.cleanupDir;
94
213
  }
95
- // Dedicated composer is more reliable than the inline tweet page reply box.
96
- await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
97
- await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
214
+ // Dedicated composer is normally more reliable than the inline
215
+ // tweet page reply box, but X occasionally leaves that route on the
216
+ // Home timeline behind a loading dialog. openReplyComposer falls
217
+ // back to the target tweet's visible Reply action.
218
+ const composer = await openReplyComposer(page, kwargs.url);
219
+ if (!composer?.ok) {
220
+ return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }];
221
+ }
98
222
  if (localImagePath) {
99
223
  await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
100
224
  await attachComposerImage(page, localImagePath);
101
225
  }
102
226
  const result = await submitReply(page, kwargs.text);
103
- if (result.ok) {
104
- await page.wait(3); // Wait for network submission to complete
105
- }
106
227
  return [{
107
228
  status: result.ok ? 'success' : 'failed',
108
229
  message: result.message,
109
230
  text: kwargs.text,
231
+ ...(result.url ? { url: result.url } : {}),
110
232
  ...(kwargs.image ? { image: kwargs.image } : {}),
111
233
  ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
112
234
  }];
@@ -119,4 +241,5 @@ cli({
119
241
  });
120
242
  export const __test__ = {
121
243
  buildReplyComposerUrl,
244
+ isPromiseCollectedError,
122
245
  };
@@ -13,6 +13,8 @@ describe('twitter reply command', () => {
13
13
  const cmd = getRegistry().get('twitter/reply');
14
14
  expect(cmd?.func).toBeTypeOf('function');
15
15
  const page = createPageMock([
16
+ { ok: true },
17
+ { ok: true },
16
18
  { ok: true, message: 'Reply posted successfully.' },
17
19
  ]);
18
20
  const result = await cmd.func(page, {
@@ -38,6 +40,8 @@ describe('twitter reply command', () => {
38
40
  const setFileInput = vi.fn().mockResolvedValue(undefined);
39
41
  const page = createPageMock([
40
42
  { ok: true, previewCount: 1 },
43
+ { ok: true },
44
+ { ok: true },
41
45
  { ok: true, message: 'Reply posted successfully.' },
42
46
  ], {
43
47
  setFileInput,
@@ -74,6 +78,8 @@ describe('twitter reply command', () => {
74
78
  const setFileInput = vi.fn().mockResolvedValue(undefined);
75
79
  const page = createPageMock([
76
80
  { ok: true, previewCount: 1 },
81
+ { ok: true },
82
+ { ok: true },
77
83
  { ok: true, message: 'Reply posted successfully.' },
78
84
  ], {
79
85
  setFileInput,
@@ -102,6 +108,55 @@ describe('twitter reply command', () => {
102
108
  ]);
103
109
  vi.unstubAllGlobals();
104
110
  });
111
+ it('falls back to the target tweet page when the dedicated composer route does not expose a textarea', async () => {
112
+ const cmd = getRegistry().get('twitter/reply');
113
+ expect(cmd?.func).toBeTypeOf('function');
114
+ const wait = vi.fn()
115
+ .mockRejectedValueOnce(new Error('Selector not found: [data-testid="tweetTextarea_0"]'))
116
+ .mockResolvedValue(undefined);
117
+ const page = createPageMock([
118
+ { ok: true }, // click target tweet page Reply button
119
+ { ok: true }, // insert reply text
120
+ { ok: true }, // click composer Reply button
121
+ { ok: true, message: 'Reply posted successfully.' }, // submit completed
122
+ ], { wait });
123
+
124
+ const url = 'https://x.com/_kop6/status/2040254679301718161?s=20';
125
+ const result = await cmd.func(page, { url, text: 'fallback reply' });
126
+
127
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
128
+ expect(page.goto).toHaveBeenNthCalledWith(2, url, { waitUntil: 'load', settleMs: 2500 });
129
+ expect(page.evaluate.mock.calls[0][0]).toContain('[data-testid="reply"]');
130
+ expect(wait).toHaveBeenLastCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
131
+ expect(result).toEqual([{ status: 'success', message: 'Reply posted successfully.', text: 'fallback reply' }]);
132
+ });
133
+ it('treats an X success toast as success after a Promise was collected error', async () => {
134
+ const cmd = getRegistry().get('twitter/reply');
135
+ expect(cmd?.func).toBeTypeOf('function');
136
+ const evaluate = vi.fn()
137
+ .mockResolvedValueOnce({ ok: true }) // insert reply text
138
+ .mockResolvedValueOnce({ ok: true }) // click Reply
139
+ .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}'))
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ message: 'Reply posted successfully.',
143
+ url: 'https://x.com/me/status/123',
144
+ });
145
+ const page = createPageMock([], { evaluate });
146
+
147
+ const result = await cmd.func(page, {
148
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
149
+ text: 'toast recovery',
150
+ });
151
+
152
+ expect(page.wait).toHaveBeenCalledWith(2);
153
+ expect(result).toEqual([{
154
+ status: 'success',
155
+ message: 'Reply posted successfully.',
156
+ text: 'toast recovery',
157
+ url: 'https://x.com/me/status/123',
158
+ }]);
159
+ });
105
160
  it('rejects using --image and --image-url together', async () => {
106
161
  const cmd = getRegistry().get('twitter/reply');
107
162
  expect(cmd?.func).toBeTypeOf('function');