@jackwener/opencli 1.7.13 → 1.7.15

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 (97) hide show
  1. package/cli-manifest.json +326 -44
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +167 -0
  41. package/clis/twitter/quote.test.js +194 -0
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +94 -0
  45. package/clis/twitter/retweet.test.js +73 -0
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +81 -0
  49. package/clis/twitter/shared.test.js +134 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +80 -0
  56. package/clis/twitter/unlike.test.js +75 -0
  57. package/clis/twitter/unretweet.js +94 -0
  58. package/clis/twitter/unretweet.test.js +73 -0
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/bridge.js +47 -45
  69. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  70. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  71. package/dist/src/browser/cdp.js +5 -0
  72. package/dist/src/browser/cdp.test.js +1 -0
  73. package/dist/src/browser/daemon-client.d.ts +3 -1
  74. package/dist/src/browser/find.d.ts +9 -1
  75. package/dist/src/browser/find.js +219 -0
  76. package/dist/src/browser/find.test.js +61 -1
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +13 -0
  79. package/dist/src/browser/page.test.js +28 -0
  80. package/dist/src/browser/target-errors.d.ts +3 -1
  81. package/dist/src/browser/target-errors.js +2 -0
  82. package/dist/src/browser/target-resolver.d.ts +14 -0
  83. package/dist/src/browser/target-resolver.js +28 -0
  84. package/dist/src/browser/visual-refs.d.ts +11 -0
  85. package/dist/src/browser/visual-refs.js +108 -0
  86. package/dist/src/browser.test.js +18 -0
  87. package/dist/src/build-manifest.d.ts +23 -0
  88. package/dist/src/build-manifest.js +34 -0
  89. package/dist/src/build-manifest.test.js +108 -1
  90. package/dist/src/cli.js +560 -58
  91. package/dist/src/cli.test.js +689 -1
  92. package/dist/src/commanderAdapter.js +23 -4
  93. package/dist/src/help.d.ts +36 -0
  94. package/dist/src/help.js +301 -5
  95. package/dist/src/types.d.ts +82 -0
  96. package/package.json +1 -1
  97. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,194 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
6
+ import { getRegistry } from '@jackwener/opencli/registry';
7
+ import { __test__ } from './quote.js';
8
+ import './quote.js';
9
+ import { createPageMock } from '../test-utils.js';
10
+
11
+ describe('twitter quote helpers', () => {
12
+ it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
13
+ const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
14
+ // The full source URL is round-tripped via encodeURIComponent — decoding it
15
+ // back must yield the original URL. This guards against accidental drops of
16
+ // query parameters or fragment characters in future refactors.
17
+ const parsed = new URL(composeUrl);
18
+ expect(parsed.origin + parsed.pathname).toBe('https://x.com/compose/post');
19
+ expect(parsed.searchParams.get('url')).toBe('https://x.com/alice/status/2040254679301718161?s=20');
20
+ });
21
+
22
+ it('rejects malformed URLs before any browser interaction', () => {
23
+ expect(() => __test__.buildQuoteComposerUrl('https://x.com/alice/home')).toThrow(/Could not extract tweet ID/);
24
+ expect(() => __test__.buildQuoteComposerUrl('not a url')).toThrow(/Invalid tweet URL/);
25
+ expect(() => __test__.buildQuoteComposerUrl('https://evil.com/?next=https://x.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
26
+ });
27
+ });
28
+
29
+ describe('twitter quote command', () => {
30
+ it('navigates to the quote composer and reports success when the script confirms', async () => {
31
+ const cmd = getRegistry().get('twitter/quote');
32
+ expect(cmd?.func).toBeTypeOf('function');
33
+ const page = createPageMock([
34
+ { ok: true, message: 'Quote tweet posted successfully.' },
35
+ ]);
36
+ const result = await cmd.func(page, {
37
+ url: 'https://x.com/alice/status/2040254679301718161',
38
+ text: 'great take',
39
+ });
40
+ expect(page.goto).toHaveBeenCalledWith(
41
+ 'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
42
+ { waitUntil: 'load', settleMs: 2500 },
43
+ );
44
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
45
+ expect(page.wait).toHaveBeenNthCalledWith(2, 3);
46
+ const script = page.evaluate.mock.calls[0][0];
47
+ // Quote-attachment guard: the script must verify the quoted card rendered
48
+ // before submitting; otherwise we'd silently post a plain tweet without
49
+ // the quote attachment. Detection now uses the shared helper's
50
+ // __twHasLinkToTarget(document) — JSDOM coverage in shared.test.js
51
+ // proves it does an exact (not substring) match on the status id.
52
+ expect(script).toContain('Quote target did not render');
53
+ expect(script).toContain('document.execCommand');
54
+ expect(script).toContain('tweetButton');
55
+ expect(script).toContain('__twHasLinkToTarget(document)');
56
+ expect(script).toContain('__twGetStatusIdFromHref');
57
+ expect(script).toContain('Quote tweet submission did not complete before timeout');
58
+ expect(script).toContain('[role="alert"], [data-testid="toast"]');
59
+ expect(result).toEqual([
60
+ {
61
+ status: 'success',
62
+ message: 'Quote tweet posted successfully.',
63
+ text: 'great take',
64
+ },
65
+ ]);
66
+ });
67
+
68
+ it('uploads a local image through the quote composer when --image is provided', async () => {
69
+ const cmd = getRegistry().get('twitter/quote');
70
+ expect(cmd?.func).toBeTypeOf('function');
71
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-quote-'));
72
+ const imagePath = path.join(tempDir, 'banner.png');
73
+ fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
74
+ const setFileInput = vi.fn().mockResolvedValue(undefined);
75
+ const page = createPageMock([
76
+ { ok: true, previewCount: 1 },
77
+ { ok: true, message: 'Quote tweet posted successfully.' },
78
+ ], {
79
+ setFileInput,
80
+ });
81
+ const result = await cmd.func(page, {
82
+ url: 'https://x.com/alice/status/2040254679301718161',
83
+ text: 'check this',
84
+ image: imagePath,
85
+ });
86
+ expect(page.goto).toHaveBeenCalledWith(
87
+ 'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
88
+ { waitUntil: 'load', settleMs: 2500 },
89
+ );
90
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
91
+ expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
92
+ expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
93
+ expect(result).toEqual([
94
+ {
95
+ status: 'success',
96
+ message: 'Quote tweet posted successfully.',
97
+ text: 'check this',
98
+ image: imagePath,
99
+ },
100
+ ]);
101
+ fs.rmSync(tempDir, { recursive: true, force: true });
102
+ });
103
+
104
+ it('downloads a remote image before uploading when --image-url is provided', async () => {
105
+ const cmd = getRegistry().get('twitter/quote');
106
+ expect(cmd?.func).toBeTypeOf('function');
107
+ const fetchMock = vi.fn().mockResolvedValue({
108
+ ok: true,
109
+ headers: {
110
+ get: vi.fn().mockReturnValue('image/png'),
111
+ },
112
+ arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer),
113
+ });
114
+ vi.stubGlobal('fetch', fetchMock);
115
+ const setFileInput = vi.fn().mockResolvedValue(undefined);
116
+ const page = createPageMock([
117
+ { ok: true, previewCount: 1 },
118
+ { ok: true, message: 'Quote tweet posted successfully.' },
119
+ ], {
120
+ setFileInput,
121
+ });
122
+ const result = await cmd.func(page, {
123
+ url: 'https://x.com/alice/status/2040254679301718161',
124
+ text: 'remote attach',
125
+ 'image-url': 'https://example.com/banner',
126
+ });
127
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/banner');
128
+ expect(setFileInput).toHaveBeenCalledTimes(1);
129
+ const uploadedPath = setFileInput.mock.calls[0][0][0];
130
+ expect(uploadedPath).toMatch(/opencli-twitter-.*\/image\.png$/);
131
+ // Per-call tmp dir is removed in the adapter's finally block.
132
+ expect(fs.existsSync(uploadedPath)).toBe(false);
133
+ expect(result).toEqual([
134
+ {
135
+ status: 'success',
136
+ message: 'Quote tweet posted successfully.',
137
+ text: 'remote attach',
138
+ 'image-url': 'https://example.com/banner',
139
+ },
140
+ ]);
141
+ vi.unstubAllGlobals();
142
+ });
143
+
144
+ it('rejects using --image and --image-url together', async () => {
145
+ const cmd = getRegistry().get('twitter/quote');
146
+ const page = createPageMock([]);
147
+ await expect(cmd.func(page, {
148
+ url: 'https://x.com/alice/status/2040254679301718161',
149
+ text: 'nope',
150
+ image: '/tmp/a.png',
151
+ 'image-url': 'https://example.com/a.png',
152
+ })).rejects.toThrow(CommandExecutionError);
153
+ });
154
+
155
+ it('returns a failed row when the quote target fails to render', async () => {
156
+ const cmd = getRegistry().get('twitter/quote');
157
+ expect(cmd?.func).toBeTypeOf('function');
158
+ const page = createPageMock([
159
+ { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' },
160
+ ]);
161
+ const result = await cmd.func(page, {
162
+ url: 'https://x.com/alice/status/2040254679301718161',
163
+ text: 'orphaned quote',
164
+ });
165
+ expect(result).toEqual([
166
+ {
167
+ status: 'failed',
168
+ message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.',
169
+ text: 'orphaned quote',
170
+ },
171
+ ]);
172
+ // Only the textarea wait should run when ok is false (no extra 3s post-submit wait).
173
+ expect(page.wait).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it('throws CommandExecutionError when no page is provided', async () => {
177
+ const cmd = getRegistry().get('twitter/quote');
178
+ await expect(cmd.func(undefined, {
179
+ url: 'https://x.com/alice/status/2040254679301718161',
180
+ text: 'hi',
181
+ })).rejects.toThrow(CommandExecutionError);
182
+ });
183
+
184
+ it('rejects invalid tweet URLs before navigation', async () => {
185
+ const cmd = getRegistry().get('twitter/quote');
186
+ const page = createPageMock([]);
187
+ await expect(cmd.func(page, {
188
+ url: 'https://x.com.evil.com/alice/status/2040254679301718161',
189
+ text: 'hi',
190
+ })).rejects.toThrow(ArgumentError);
191
+ expect(page.goto).not.toHaveBeenCalled();
192
+ expect(page.evaluate).not.toHaveBeenCalled();
193
+ });
194
+ });
@@ -1,174 +1,24 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import * as os from 'node:os';
4
3
  import { CommandExecutionError } from '@jackwener/opencli/errors';
5
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
6
- const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
7
- const SUPPORTED_IMAGE_EXTENSIONS = new Set([
8
- '.jpg',
9
- '.jpeg',
10
- '.png',
11
- '.gif',
12
- '.webp',
13
- ]);
14
- const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
15
- const CONTENT_TYPE_TO_EXTENSION = {
16
- 'image/jpeg': '.jpg',
17
- 'image/jpg': '.jpg',
18
- 'image/png': '.png',
19
- 'image/gif': '.gif',
20
- 'image/webp': '.webp',
21
- };
22
- function resolveImagePath(imagePath) {
23
- const absPath = path.resolve(imagePath);
24
- if (!fs.existsSync(absPath)) {
25
- throw new Error(`Image file not found: ${absPath}`);
26
- }
27
- const ext = path.extname(absPath).toLowerCase();
28
- if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
29
- throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
30
- }
31
- const stat = fs.statSync(absPath);
32
- if (stat.size > MAX_IMAGE_SIZE_BYTES) {
33
- throw new Error(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
34
- }
35
- return absPath;
36
- }
37
- function extractTweetId(url) {
38
- let pathname = '';
39
- try {
40
- pathname = new URL(url).pathname;
41
- }
42
- catch {
43
- throw new Error(`Invalid tweet URL: ${url}`);
44
- }
45
- const match = pathname.match(/\/status\/(\d+)/);
46
- if (!match?.[1]) {
47
- throw new Error(`Could not extract tweet ID from URL: ${url}`);
48
- }
49
- return match[1];
50
- }
51
- function buildReplyComposerUrl(url) {
52
- return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
53
- }
54
- function resolveImageExtension(url, contentType) {
55
- const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
56
- if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
57
- return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
58
- }
59
- try {
60
- const pathname = new URL(url).pathname;
61
- const ext = path.extname(pathname).toLowerCase();
62
- if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
63
- return ext;
64
- }
65
- catch {
66
- // Fall through to the final error below.
67
- }
68
- throw new Error(`Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
69
- 'Supported: jpg, jpeg, png, gif, webp');
70
- }
71
- async function downloadRemoteImage(imageUrl) {
72
- let parsed;
73
- try {
74
- parsed = new URL(imageUrl);
75
- }
76
- catch {
77
- throw new Error(`Invalid image URL: ${imageUrl}`);
78
- }
79
- if (!/^https?:$/.test(parsed.protocol)) {
80
- throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
81
- }
82
- const response = await fetch(imageUrl);
83
- if (!response.ok) {
84
- throw new Error(`Image download failed: HTTP ${response.status}`);
85
- }
86
- const contentLength = Number(response.headers.get('content-length') || '0');
87
- if (contentLength > MAX_IMAGE_SIZE_BYTES) {
88
- throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
89
- }
90
- const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
91
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
92
- const tmpPath = path.join(tmpDir, `image${ext}`);
93
- const buffer = Buffer.from(await response.arrayBuffer());
94
- if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
95
- fs.rmSync(tmpDir, { recursive: true, force: true });
96
- throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
97
- }
98
- fs.writeFileSync(tmpPath, buffer);
99
- return tmpPath;
100
- }
101
- async function attachReplyImage(page, absImagePath) {
102
- let uploaded = false;
103
- if (page.setFileInput) {
104
- try {
105
- await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
106
- uploaded = true;
107
- }
108
- catch (err) {
109
- const msg = err instanceof Error ? err.message : String(err);
110
- if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
111
- throw new Error(`Image upload failed: ${msg}`);
112
- }
113
- // setFileInput not supported by extension — fall through to base64 fallback
114
- }
115
- }
116
- if (!uploaded) {
117
- const ext = path.extname(absImagePath).toLowerCase();
118
- const mimeType = ext === '.png'
119
- ? 'image/png'
120
- : ext === '.gif'
121
- ? 'image/gif'
122
- : ext === '.webp'
123
- ? 'image/webp'
124
- : 'image/jpeg';
125
- const base64 = fs.readFileSync(absImagePath).toString('base64');
126
- if (base64.length > 500_000) {
127
- console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
128
- 'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
129
- 'or compress the image before attaching.');
130
- }
131
- const upload = await page.evaluate(`
132
- (() => {
133
- const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
134
- if (!input) return { ok: false, error: 'No file input found on page' };
135
-
136
- const binary = atob(${JSON.stringify(base64)});
137
- const bytes = new Uint8Array(binary.length);
138
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
139
-
140
- const dt = new DataTransfer();
141
- const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
142
- dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
5
+ import { parseTweetUrl } from './shared.js';
6
+ import {
7
+ COMPOSER_FILE_INPUT_SELECTOR,
8
+ attachComposerImage,
9
+ downloadRemoteImage,
10
+ resolveImagePath,
11
+ } from './utils.js';
143
12
 
144
- Object.defineProperty(input, 'files', { value: dt.files, writable: false });
145
- input.dispatchEvent(new Event('change', { bubbles: true }));
146
- input.dispatchEvent(new Event('input', { bubbles: true }));
147
- return { ok: true };
148
- })()
149
- `);
150
- if (!upload?.ok) {
151
- throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
152
- }
153
- }
154
- await page.wait(2);
155
- const uploadState = await page.evaluate(`
156
- (() => {
157
- const previewCount = document.querySelectorAll(
158
- '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
159
- ).length;
160
- const hasMedia = previewCount > 0
161
- || !!document.querySelector('[data-testid="attachments"]')
162
- || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
163
- /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
164
- );
165
- return { ok: hasMedia, previewCount };
166
- })()
167
- `);
168
- if (!uploadState?.ok) {
169
- throw new Error('Image upload failed: preview did not appear.');
170
- }
13
+ function buildReplyComposerUrl(rawUrl) {
14
+ // Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
15
+ // (silent: matched `/status/1234567` on substring `/status/123` and
16
+ // accepted any host). parseTweetUrl bubbles ArgumentError on
17
+ // malformed/off-domain inputs.
18
+ const target = parseTweetUrl(rawUrl);
19
+ return `https://x.com/compose/post?in_reply_to=${target.id}`;
171
20
  }
21
+
172
22
  async function submitReply(page, text) {
173
23
  return page.evaluate(`(async () => {
174
24
  try {
@@ -210,6 +60,7 @@ async function submitReply(page, text) {
210
60
  }
211
61
  })()`);
212
62
  }
63
+
213
64
  cli({
214
65
  site: 'twitter',
215
66
  name: 'reply',
@@ -229,24 +80,24 @@ cli({
229
80
  if (!page)
230
81
  throw new CommandExecutionError('Browser session required for twitter reply');
231
82
  if (kwargs.image && kwargs['image-url']) {
232
- throw new Error('Use either --image or --image-url, not both.');
83
+ throw new CommandExecutionError('Use either --image or --image-url, not both.');
233
84
  }
234
85
  let localImagePath;
235
86
  let cleanupDir;
236
87
  try {
237
88
  if (kwargs.image) {
238
89
  localImagePath = resolveImagePath(kwargs.image);
239
- }
240
- else if (kwargs['image-url']) {
241
- localImagePath = await downloadRemoteImage(kwargs['image-url']);
242
- cleanupDir = path.dirname(localImagePath);
90
+ } else if (kwargs['image-url']) {
91
+ const downloaded = await downloadRemoteImage(kwargs['image-url']);
92
+ localImagePath = downloaded.absPath;
93
+ cleanupDir = downloaded.cleanupDir;
243
94
  }
244
95
  // Dedicated composer is more reliable than the inline tweet page reply box.
245
96
  await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
246
97
  await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
247
98
  if (localImagePath) {
248
- await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
249
- await attachReplyImage(page, localImagePath);
99
+ await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
100
+ await attachComposerImage(page, localImagePath);
250
101
  }
251
102
  const result = await submitReply(page, kwargs.text);
252
103
  if (result.ok) {
@@ -259,8 +110,7 @@ cli({
259
110
  ...(kwargs.image ? { image: kwargs.image } : {}),
260
111
  ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
261
112
  }];
262
- }
263
- finally {
113
+ } finally {
264
114
  if (cleanupDir) {
265
115
  fs.rmSync(cleanupDir, { recursive: true, force: true });
266
116
  }
@@ -269,8 +119,4 @@ cli({
269
119
  });
270
120
  export const __test__ = {
271
121
  buildReplyComposerUrl,
272
- downloadRemoteImage,
273
- extractTweetId,
274
- resolveImageExtension,
275
- resolveImagePath,
276
122
  };
@@ -2,9 +2,12 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
5
6
  import { getRegistry } from '@jackwener/opencli/registry';
6
7
  import { __test__ } from './reply.js';
8
+ import { __test__ as utilsTest } from './utils.js';
7
9
  import { createPageMock } from '../test-utils.js';
10
+
8
11
  describe('twitter reply command', () => {
9
12
  it('uses the dedicated reply composer for text-only replies too', async () => {
10
13
  const cmd = getRegistry().get('twitter/reply');
@@ -83,7 +86,11 @@ describe('twitter reply command', () => {
83
86
  expect(fetchMock).toHaveBeenCalledWith('https://example.com/qr');
84
87
  expect(setFileInput).toHaveBeenCalledTimes(1);
85
88
  const uploadedPath = setFileInput.mock.calls[0][0][0];
86
- expect(uploadedPath).toMatch(/opencli-twitter-reply-.*\/image\.png$/);
89
+ // Tmp dir is created by utils.downloadRemoteImage with the
90
+ // 'opencli-twitter-' prefix; final extension comes from Content-Type.
91
+ expect(uploadedPath).toMatch(/opencli-twitter-.*\/image\.png$/);
92
+ // Per-call tmp dir is removed in the adapter's finally block, so the
93
+ // downloaded file no longer exists once the command returns.
87
94
  expect(fs.existsSync(uploadedPath)).toBe(false);
88
95
  expect(result).toEqual([
89
96
  {
@@ -95,10 +102,6 @@ describe('twitter reply command', () => {
95
102
  ]);
96
103
  vi.unstubAllGlobals();
97
104
  });
98
- it('rejects invalid image paths early', async () => {
99
- await expect(() => __test__.resolveImagePath('/tmp/does-not-exist.png'))
100
- .toThrow('Image file not found');
101
- });
102
105
  it('rejects using --image and --image-url together', async () => {
103
106
  const cmd = getRegistry().get('twitter/reply');
104
107
  expect(cmd?.func).toBeTypeOf('function');
@@ -108,16 +111,31 @@ describe('twitter reply command', () => {
108
111
  text: 'nope',
109
112
  image: '/tmp/a.png',
110
113
  'image-url': 'https://example.com/a.png',
111
- })).rejects.toThrow('Use either --image or --image-url, not both.');
114
+ })).rejects.toThrow(CommandExecutionError);
115
+ });
116
+ it('rejects malformed tweet URLs before any browser interaction', () => {
117
+ // buildReplyComposerUrl runs parseTweetUrl synchronously; substring matches
118
+ // and off-domain hosts now throw ArgumentError instead of silently
119
+ // producing a wrong-host /compose/post URL.
120
+ expect(() => __test__.buildReplyComposerUrl('https://x.com/alice/home')).toThrow(ArgumentError);
121
+ expect(() => __test__.buildReplyComposerUrl('https://x.com.evil.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
122
+ expect(() => __test__.buildReplyComposerUrl('not a url')).toThrow(ArgumentError);
112
123
  });
113
- it('extracts tweet ids from both user and i/status URLs', () => {
114
- expect(__test__.extractTweetId('https://x.com/_kop6/status/2040254679301718161?s=20')).toBe('2040254679301718161');
115
- expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
124
+ it('builds the reply composer URL for both /<user>/status/<id> and /i/status/<id> shapes', () => {
125
+ expect(__test__.buildReplyComposerUrl('https://x.com/_kop6/status/2040254679301718161?s=20'))
126
+ .toBe('https://x.com/compose/post?in_reply_to=2040254679301718161');
116
127
  expect(__test__.buildReplyComposerUrl('https://x.com/i/status/2040318731105313143'))
117
128
  .toBe('https://x.com/compose/post?in_reply_to=2040318731105313143');
118
129
  });
130
+ });
131
+
132
+ describe('twitter image helpers (utils.js)', () => {
133
+ it('rejects invalid image paths early', () => {
134
+ expect(() => utilsTest.resolveImagePath('/tmp/does-not-exist.png'))
135
+ .toThrow(ArgumentError);
136
+ });
119
137
  it('prefers content-type when resolving remote image extensions', () => {
120
- expect(__test__.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
121
- expect(__test__.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
138
+ expect(utilsTest.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
139
+ expect(utilsTest.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
122
140
  });
123
141
  });
@@ -0,0 +1,94 @@
1
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
4
+
5
+ cli({
6
+ site: 'twitter',
7
+ name: 'retweet',
8
+ access: 'write',
9
+ description: 'Retweet a specific tweet',
10
+ domain: 'x.com',
11
+ strategy: Strategy.UI,
12
+ browser: true,
13
+ args: [
14
+ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to retweet' },
15
+ ],
16
+ columns: ['status', 'message'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new CommandExecutionError('Browser session required for twitter retweet');
20
+ const target = parseTweetUrl(kwargs.url);
21
+ await page.goto(target.url);
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
+ const result = await page.evaluate(`(async () => {
24
+ try {
25
+ ${buildTwitterArticleScopeSource(target.id)}
26
+ // Poll for the tweet to render. State probes scoped to the article
27
+ // matching the requested status id — bare querySelector on a
28
+ // conversation page would silently grab the first article (e.g.
29
+ // the parent tweet) and retweet the wrong one.
30
+ let attempts = 0;
31
+ let retweetBtn = null;
32
+ let unretweetBtn = null;
33
+ let targetArticle = null;
34
+
35
+ while (attempts < 20) {
36
+ targetArticle = findTargetArticle();
37
+ unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
38
+ retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
39
+
40
+ if (unretweetBtn || retweetBtn) break;
41
+
42
+ await new Promise(r => setTimeout(r, 500));
43
+ attempts++;
44
+ }
45
+
46
+ // Already retweeted: idempotent success
47
+ if (unretweetBtn) {
48
+ return { ok: true, message: 'Tweet is already retweeted.' };
49
+ }
50
+
51
+ if (!retweetBtn) {
52
+ return { ok: false, message: 'Could not find the Retweet button on this tweet after waiting 10 seconds. Are you logged in?' };
53
+ }
54
+
55
+ // Step 1: click Retweet button → opens menu
56
+ retweetBtn.click();
57
+
58
+ // Step 2: wait for and click the confirm menu item. The confirm
59
+ // popover renders at the document root, not inside the article,
60
+ // so this lookup is intentionally document-scoped.
61
+ let confirmBtn = null;
62
+ for (let i = 0; i < 20; i++) {
63
+ await new Promise(r => setTimeout(r, 250));
64
+ confirmBtn = document.querySelector('[data-testid="retweetConfirm"]');
65
+ if (confirmBtn) break;
66
+ }
67
+ if (!confirmBtn) {
68
+ return { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' };
69
+ }
70
+ confirmBtn.click();
71
+ await new Promise(r => setTimeout(r, 1000));
72
+
73
+ // Verify success by checking if the 'unretweet' button appeared
74
+ const verifyArticle = findTargetArticle() || targetArticle;
75
+ const verifyBtn = verifyArticle?.querySelector('[data-testid="unretweet"]');
76
+ if (verifyBtn) {
77
+ return { ok: true, message: 'Tweet successfully retweeted.' };
78
+ } else {
79
+ return { ok: false, message: 'Retweet action was initiated but UI did not update as expected.' };
80
+ }
81
+ } catch (e) {
82
+ return { ok: false, message: e.toString() };
83
+ }
84
+ })()`);
85
+ if (result.ok) {
86
+ // Wait for the retweet network request to be processed
87
+ await page.wait(2);
88
+ }
89
+ return [{
90
+ status: result.ok ? 'success' : 'failed',
91
+ message: result.message
92
+ }];
93
+ }
94
+ });