/ clis / twitter / reply.test.js
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  });