@jackwener/opencli 1.7.14 → 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.
- package/cli-manifest.json +215 -45
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +598 -0
- package/dist/src/help.d.ts +32 -0
- package/dist/src/help.js +145 -0
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
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';
|
|
2
5
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
6
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
7
|
import { __test__ } from './quote.js';
|
|
@@ -6,11 +9,6 @@ import './quote.js';
|
|
|
6
9
|
import { createPageMock } from '../test-utils.js';
|
|
7
10
|
|
|
8
11
|
describe('twitter quote helpers', () => {
|
|
9
|
-
it('extracts tweet ids from both user and i/status URLs', () => {
|
|
10
|
-
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
|
|
11
|
-
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
12
|
it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
|
|
15
13
|
const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
|
|
16
14
|
// The full source URL is round-tripped via encodeURIComponent — decoding it
|
|
@@ -48,11 +46,14 @@ describe('twitter quote command', () => {
|
|
|
48
46
|
const script = page.evaluate.mock.calls[0][0];
|
|
49
47
|
// Quote-attachment guard: the script must verify the quoted card rendered
|
|
50
48
|
// before submitting; otherwise we'd silently post a plain tweet without
|
|
51
|
-
// the quote attachment.
|
|
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
52
|
expect(script).toContain('Quote target did not render');
|
|
53
53
|
expect(script).toContain('document.execCommand');
|
|
54
54
|
expect(script).toContain('tweetButton');
|
|
55
|
-
expect(script).toContain('
|
|
55
|
+
expect(script).toContain('__twHasLinkToTarget(document)');
|
|
56
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
56
57
|
expect(script).toContain('Quote tweet submission did not complete before timeout');
|
|
57
58
|
expect(script).toContain('[role="alert"], [data-testid="toast"]');
|
|
58
59
|
expect(result).toEqual([
|
|
@@ -64,6 +65,93 @@ describe('twitter quote command', () => {
|
|
|
64
65
|
]);
|
|
65
66
|
});
|
|
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
|
+
|
|
67
155
|
it('returns a failed row when the quote target fails to render', async () => {
|
|
68
156
|
const cmd = getRegistry().get('twitter/quote');
|
|
69
157
|
expect(cmd?.func).toBeTypeOf('function');
|
package/clis/twitter/reply.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
localImagePath =
|
|
242
|
-
cleanupDir =
|
|
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:
|
|
249
|
-
await
|
|
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
|
-
|
|
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(
|
|
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('
|
|
114
|
-
expect(__test__.
|
|
115
|
-
|
|
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(
|
|
121
|
-
expect(
|
|
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
|
});
|
package/clis/twitter/retweet.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { parseTweetUrl } from './shared.js';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
4
|
|
|
5
5
|
cli({
|
|
6
6
|
site: 'twitter',
|
|
@@ -22,18 +22,11 @@ cli({
|
|
|
22
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return match?.[1] === tweetId;
|
|
31
|
-
} catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
);
|
|
36
|
-
// Poll for the tweet to render
|
|
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.
|
|
37
30
|
let attempts = 0;
|
|
38
31
|
let retweetBtn = null;
|
|
39
32
|
let unretweetBtn = null;
|
|
@@ -62,7 +55,9 @@ cli({
|
|
|
62
55
|
// Step 1: click Retweet button → opens menu
|
|
63
56
|
retweetBtn.click();
|
|
64
57
|
|
|
65
|
-
// Step 2: wait for the confirm menu item
|
|
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.
|
|
66
61
|
let confirmBtn = null;
|
|
67
62
|
for (let i = 0; i < 20; i++) {
|
|
68
63
|
await new Promise(r => setTimeout(r, 250));
|
|
@@ -24,8 +24,12 @@ describe('twitter retweet command', () => {
|
|
|
24
24
|
expect(script).toContain('retweetBtn.click()');
|
|
25
25
|
expect(script).toContain("document.querySelector('[data-testid=\"retweetConfirm\"]')");
|
|
26
26
|
expect(script).toContain('confirmBtn.click()');
|
|
27
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
28
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
29
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
30
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
31
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
27
32
|
expect(script).toContain("document.querySelectorAll('article')");
|
|
28
|
-
expect(script).toContain('match?.[1] === tweetId');
|
|
29
33
|
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
30
34
|
// Idempotency probe: when already retweeted ([data-testid="unretweet"] present),
|
|
31
35
|
// the script returns ok:true with an "already retweeted" message.
|