reply.test.js
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 { getRegistry } from '@jackwener/opencli/registry'; 6 import { __test__ } from './reply.js'; 7 function createPageMock(evaluateResults, overrides = {}) { 8 const evaluate = vi.fn(); 9 for (const result of evaluateResults) { 10 evaluate.mockResolvedValueOnce(result); 11 } 12 return { 13 goto: vi.fn().mockResolvedValue(undefined), 14 evaluate, 15 snapshot: vi.fn().mockResolvedValue(undefined), 16 click: vi.fn().mockResolvedValue(undefined), 17 typeText: vi.fn().mockResolvedValue(undefined), 18 pressKey: vi.fn().mockResolvedValue(undefined), 19 scrollTo: vi.fn().mockResolvedValue(undefined), 20 getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), 21 wait: vi.fn().mockResolvedValue(undefined), 22 tabs: vi.fn().mockResolvedValue([]), 23 selectTab: vi.fn().mockResolvedValue(undefined), 24 networkRequests: vi.fn().mockResolvedValue([]), 25 consoleMessages: vi.fn().mockResolvedValue([]), 26 scroll: vi.fn().mockResolvedValue(undefined), 27 autoScroll: vi.fn().mockResolvedValue(undefined), 28 installInterceptor: vi.fn().mockResolvedValue(undefined), 29 getInterceptedRequests: vi.fn().mockResolvedValue([]), 30 getCookies: vi.fn().mockResolvedValue([]), 31 screenshot: vi.fn().mockResolvedValue(''), 32 waitForCapture: vi.fn().mockResolvedValue(undefined), 33 ...overrides, 34 }; 35 } 36 describe('twitter reply command', () => { 37 it('uses the dedicated reply composer for text-only replies too', async () => { 38 const cmd = getRegistry().get('twitter/reply'); 39 expect(cmd?.func).toBeTypeOf('function'); 40 const page = createPageMock([ 41 { ok: true, message: 'Reply posted successfully.' }, 42 ]); 43 const result = await cmd.func(page, { 44 url: 'https://x.com/_kop6/status/2040254679301718161?s=20', 45 text: 'text-only reply', 46 }); 47 expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 }); 48 expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); 49 expect(result).toEqual([ 50 { 51 status: 'success', 52 message: 'Reply posted successfully.', 53 text: 'text-only reply', 54 }, 55 ]); 56 }); 57 it('uploads a local image through the dedicated reply composer when --image is provided', async () => { 58 const cmd = getRegistry().get('twitter/reply'); 59 expect(cmd?.func).toBeTypeOf('function'); 60 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-')); 61 const imagePath = path.join(tempDir, 'qr.png'); 62 fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); 63 const setFileInput = vi.fn().mockResolvedValue(undefined); 64 const page = createPageMock([ 65 { ok: true, previewCount: 1 }, 66 { ok: true, message: 'Reply posted successfully.' }, 67 ], { 68 setFileInput, 69 }); 70 const result = await cmd.func(page, { 71 url: 'https://x.com/_kop6/status/2040254679301718161?s=20', 72 text: 'reply with image', 73 image: imagePath, 74 }); 75 expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 }); 76 expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); 77 expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 }); 78 expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]'); 79 expect(result).toEqual([ 80 { 81 status: 'success', 82 message: 'Reply posted successfully.', 83 text: 'reply with image', 84 image: imagePath, 85 }, 86 ]); 87 }); 88 it('downloads a remote image before uploading when --image-url is provided', async () => { 89 const cmd = getRegistry().get('twitter/reply'); 90 expect(cmd?.func).toBeTypeOf('function'); 91 const fetchMock = vi.fn().mockResolvedValue({ 92 ok: true, 93 headers: { 94 get: vi.fn().mockReturnValue('image/png'), 95 }, 96 arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer), 97 }); 98 vi.stubGlobal('fetch', fetchMock); 99 const setFileInput = vi.fn().mockResolvedValue(undefined); 100 const page = createPageMock([ 101 { ok: true, previewCount: 1 }, 102 { ok: true, message: 'Reply posted successfully.' }, 103 ], { 104 setFileInput, 105 }); 106 const result = await cmd.func(page, { 107 url: 'https://x.com/_kop6/status/2040254679301718161?s=20', 108 text: 'reply with remote image', 109 'image-url': 'https://example.com/qr', 110 }); 111 expect(fetchMock).toHaveBeenCalledWith('https://example.com/qr'); 112 expect(setFileInput).toHaveBeenCalledTimes(1); 113 const uploadedPath = setFileInput.mock.calls[0][0][0]; 114 expect(uploadedPath).toMatch(/opencli-twitter-reply-.*\/image\.png$/); 115 expect(fs.existsSync(uploadedPath)).toBe(false); 116 expect(result).toEqual([ 117 { 118 status: 'success', 119 message: 'Reply posted successfully.', 120 text: 'reply with remote image', 121 'image-url': 'https://example.com/qr', 122 }, 123 ]); 124 vi.unstubAllGlobals(); 125 }); 126 it('rejects invalid image paths early', async () => { 127 await expect(() => __test__.resolveImagePath('/tmp/does-not-exist.png')) 128 .toThrow('Image file not found'); 129 }); 130 it('rejects using --image and --image-url together', async () => { 131 const cmd = getRegistry().get('twitter/reply'); 132 expect(cmd?.func).toBeTypeOf('function'); 133 const page = createPageMock([]); 134 await expect(cmd.func(page, { 135 url: 'https://x.com/_kop6/status/2040254679301718161?s=20', 136 text: 'nope', 137 image: '/tmp/a.png', 138 'image-url': 'https://example.com/a.png', 139 })).rejects.toThrow('Use either --image or --image-url, not both.'); 140 }); 141 it('extracts tweet ids from both user and i/status URLs', () => { 142 expect(__test__.extractTweetId('https://x.com/_kop6/status/2040254679301718161?s=20')).toBe('2040254679301718161'); 143 expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143'); 144 expect(__test__.buildReplyComposerUrl('https://x.com/i/status/2040318731105313143')) 145 .toBe('https://x.com/compose/post?in_reply_to=2040318731105313143'); 146 }); 147 it('prefers content-type when resolving remote image extensions', () => { 148 expect(__test__.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp'); 149 expect(__test__.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg'); 150 }); 151 });