diff --git a/src/clis/twitter/post.test.ts b/src/clis/twitter/post.test.ts new file mode 100644 index 00000000..3a176624 --- /dev/null +++ b/src/clis/twitter/post.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '../../registry.js'; +import './post.js'; + +vi.mock('node:fs', () => ({ + statSync: vi.fn((p: string, _opts?: any) => { + if (p.includes('missing')) return null; + return { isFile: () => true }; + }), +})); + +vi.mock('node:path', () => ({ + resolve: vi.fn((p: string) => `/abs/${p}`), +})); + +function makePage(overrides: Record = {}) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ ok: true }), + setFileInput: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('twitter post command', () => { + const getCommand = () => getRegistry().get('twitter/post'); + + it('posts text-only tweet successfully', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: true }) // type text + .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post + }); + + const result = await command!.func!(page as any, { text: 'hello world' }); + + expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hello world' }]); + expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/tweet'); + }); + + it('returns failed when text area not found', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: false, message: 'Could not find the tweet composer text area.' }), + }); + + const result = await command!.func!(page as any, { text: 'hello' }); + + expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area.', text: 'hello' }]); + }); + + it('throws when more than 4 images', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn().mockResolvedValueOnce({ ok: true }), // type text + }); + + await expect( + command!.func!(page as any, { text: 'hi', images: 'a.png,b.png,c.png,d.png,e.png' }), + ).rejects.toThrow('Too many images: 5 (max 4)'); + }); + + it('throws when image file does not exist', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn().mockResolvedValueOnce({ ok: true }), + }); + + await expect( + command!.func!(page as any, { text: 'hi', images: 'missing.png' }), + ).rejects.toThrow('Not a valid file'); + }); + + it('throws when page.setFileInput is not available', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn().mockResolvedValueOnce({ ok: true }), + setFileInput: undefined, + }); + + await expect( + command!.func!(page as any, { text: 'hi', images: 'a.png' }), + ).rejects.toThrow('Browser extension does not support file upload'); + }); + + it('posts with images when upload completes', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: true }) // type text + .mockResolvedValueOnce(true) // upload polling returns true + .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post + }); + + const result = await command!.func!(page as any, { text: 'with images', images: 'a.png,b.png' }); + + expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with images' }]); + expect(page.setFileInput).toHaveBeenCalled(); + + // Verify the upload polling script checks attachments and group count + const uploadScript = page.evaluate.mock.calls[1][0] as string; + expect(uploadScript).toContain('[data-testid="attachments"]'); + expect(uploadScript).toContain('[role="group"]'); + expect(uploadScript).toContain('!== 2'); // 2 images + }); + + it('returns failed when image upload times out', async () => { + const command = getCommand(); + const page = makePage({ + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: true }) // type text + .mockResolvedValueOnce(false), // upload polling returns false (timeout) + }); + + const result = await command!.func!(page as any, { text: 'timeout', images: 'a.png' }); + + expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]); + }); + + it('throws when no browser session', async () => { + const command = getCommand(); + + await expect( + command!.func!(null, { text: 'hi' }), + ).rejects.toThrow('Browser session required for twitter post'); + }); +}); diff --git a/src/clis/twitter/post.ts b/src/clis/twitter/post.ts index 3fde8bca..a97f063c 100644 --- a/src/clis/twitter/post.ts +++ b/src/clis/twitter/post.ts @@ -11,6 +11,7 @@ cli({ browser: true, args: [ { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' }, + { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' }, ], columns: ['status', 'message', 'text'], func: async (page: IPage | null, kwargs: any) => { @@ -20,49 +21,99 @@ cli({ await page.goto('https://x.com/compose/tweet'); await page.wait(3); // Wait for the modal and React app to hydrate - // 2. Automate typing and clicking - const result = await page.evaluate(`(async () => { + // 2. Type the text + const typeResult = await page.evaluate(`(async () => { try { - // Find the active text area const box = document.querySelector('[data-testid="tweetTextarea_0"]'); - if (box) { - box.focus(); - // Simulate a paste event to properly handle newlines in Draft.js/React - const textToInsert = ${JSON.stringify(kwargs.text)}; - const dataTransfer = new DataTransfer(); - dataTransfer.setData('text/plain', textToInsert); - box.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: dataTransfer, - bubbles: true, - cancelable: true - })); - } else { - return { ok: false, message: 'Could not find the tweet composer text area.' }; - } - - // Wait a brief moment for the button state to update - await new Promise(r => setTimeout(r, 1000)); - - // Click the post button + if (!box) return { ok: false, message: 'Could not find the tweet composer text area.' }; + box.focus(); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', ${JSON.stringify(kwargs.text)}); + box.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true + })); + return { ok: true }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + if (!typeResult.ok) { + return [{ status: 'failed', message: typeResult.message, text: kwargs.text }]; + } + + // 3. Attach images if provided + if (kwargs.images) { + const nodePath = await import('node:path'); + const nodeFs = await import('node:fs'); + const imagePaths = String(kwargs.images).split(',').map((s: string) => s.trim()).filter(Boolean); + + if (imagePaths.length > 4) { + throw new CommandExecutionError(`Too many images: ${imagePaths.length} (max 4)`); + } + + const absPaths = imagePaths.map((p: string) => { + const absPath = nodePath.resolve(p); + const stat = nodeFs.statSync(absPath, { throwIfNoEntry: false }); + if (!stat || !stat.isFile()) { + throw new CommandExecutionError(`Not a valid file: ${absPath}`); + } + return absPath; + }); + + if (!page.setFileInput) { + throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.'); + } + try { + await page.setFileInput(absPaths, 'input[data-testid="fileInput"]'); + } catch { + throw new CommandExecutionError('Failed to attach images. The extension may not support file input.'); + } + + // Poll until image upload completes (attachments rendered & tweet button enabled) or timeout + const imageCount = absPaths.length; + const uploaded = await page.evaluate(`(async () => { + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 1000)); + const container = document.querySelector('[data-testid="attachments"]'); + if (!container) continue; + const groups = container.querySelectorAll('[role="group"]'); + if (groups.length !== ${imageCount}) continue; + const btn = document.querySelector('[data-testid="tweetButton"]'); + if (btn && !btn.disabled) return true; + const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]'); + if (inlineBtn && !inlineBtn.disabled) return true; + } + return false; + })()`); + + if (!uploaded) { + return [{ status: 'failed', message: 'Image upload timed out (30s).', text: kwargs.text }]; + } + } + + // 4. Click the post button + await page.wait(1); + const result = await page.evaluate(`(async () => { + try { const btn = document.querySelector('[data-testid="tweetButton"]'); if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; - } else { - // Sometimes it's rendered inline depending on the viewport - const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]'); - if (inlineBtn && !inlineBtn.disabled) { - inlineBtn.click(); - return { ok: true, message: 'Tweet posted successfully.' }; - } - return { ok: false, message: 'Tweet button is disabled or not found.' }; } + const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]'); + if (inlineBtn && !inlineBtn.disabled) { + inlineBtn.click(); + return { ok: true, message: 'Tweet posted successfully.' }; + } + return { ok: false, message: 'Tweet button is disabled or not found.' }; } catch (e) { return { ok: false, message: e.toString() }; } })()`); - // 3. Wait a few seconds for the network request to finish sending if (result.ok) { await page.wait(3); }