/ clis / twitter / post.test.js
post.test.js
  1  import { describe, expect, it, vi } from 'vitest';
  2  import { getRegistry } from '@jackwener/opencli/registry';
  3  import './post.js';
  4  vi.mock('node:fs', async (importOriginal) => {
  5      const actual = await importOriginal();
  6      return {
  7          ...actual,
  8          statSync: vi.fn((p, _opts) => {
  9              if (String(p).includes('missing'))
 10                  return undefined;
 11              return { isFile: () => true };
 12          }),
 13      };
 14  });
 15  vi.mock('node:path', async (importOriginal) => {
 16      const actual = await importOriginal();
 17      return {
 18          ...actual,
 19          resolve: vi.fn((p) => `/abs/${p}`),
 20          extname: vi.fn((p) => {
 21              const m = p.match(/\.[^.]+$/);
 22              return m ? m[0] : '';
 23          }),
 24      };
 25  });
 26  function makePage(overrides = {}) {
 27      return {
 28          goto: vi.fn().mockResolvedValue(undefined),
 29          wait: vi.fn().mockResolvedValue(undefined),
 30          evaluate: vi.fn().mockResolvedValue({ ok: true }),
 31          setFileInput: vi.fn().mockResolvedValue(undefined),
 32          ...overrides,
 33      };
 34  }
 35  describe('twitter post command', () => {
 36      const getCommand = () => getRegistry().get('twitter/post');
 37      it('posts text-only tweet successfully', async () => {
 38          const command = getCommand();
 39          const page = makePage({
 40              evaluate: vi.fn()
 41                  .mockResolvedValueOnce({ ok: true })
 42                  .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }),
 43          });
 44          const result = await command.func(page, { text: 'hello world' });
 45          expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hello world' }]);
 46          expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/tweet');
 47      });
 48      it('returns failed when text area not found', async () => {
 49          const command = getCommand();
 50          const page = makePage({
 51              evaluate: vi.fn()
 52                  .mockResolvedValueOnce({ ok: false, message: 'Could not find the tweet composer text area.' }),
 53          });
 54          const result = await command.func(page, { text: 'hello' });
 55          expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area.', text: 'hello' }]);
 56      });
 57      it('throws when more than 4 images', async () => {
 58          const command = getCommand();
 59          const page = makePage();
 60          await expect(command.func(page, { text: 'hi', images: 'a.png,b.png,c.png,d.png,e.png' })).rejects.toThrow('Too many images: 5 (max 4)');
 61      });
 62      it('throws when image file does not exist', async () => {
 63          const command = getCommand();
 64          const page = makePage();
 65          await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
 66      });
 67      it('throws on unsupported image format', async () => {
 68          const command = getCommand();
 69          const page = makePage();
 70          await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
 71      });
 72      it('throws when page.setFileInput is not available', async () => {
 73          const command = getCommand();
 74          const page = makePage({
 75              evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
 76              setFileInput: undefined,
 77          });
 78          await expect(command.func(page, { text: 'hi', images: 'a.png' })).rejects.toThrow('Browser extension does not support file upload');
 79      });
 80      it('posts with images when upload completes', async () => {
 81          const command = getCommand();
 82          const page = makePage({
 83              evaluate: vi.fn()
 84                  .mockResolvedValueOnce({ ok: true }) // type text
 85                  .mockResolvedValueOnce(true) // upload polling returns true
 86                  .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post
 87          });
 88          const result = await command.func(page, { text: 'with images', images: 'a.png,b.png' });
 89          expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with images' }]);
 90          expect(page.setFileInput).toHaveBeenCalled();
 91          const uploadScript = page.evaluate.mock.calls[1][0];
 92          expect(uploadScript).toContain('[data-testid="attachments"]');
 93          expect(uploadScript).toContain('[role="group"]');
 94      });
 95      it('returns failed when image upload times out', async () => {
 96          const command = getCommand();
 97          const page = makePage({
 98              evaluate: vi.fn()
 99                  .mockResolvedValueOnce({ ok: true })
100                  .mockResolvedValueOnce(false),
101          });
102          const result = await command.func(page, { text: 'timeout', images: 'a.png' });
103          expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]);
104      });
105      it('validates images before navigating to compose page', async () => {
106          const command = getCommand();
107          const page = makePage();
108          await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
109          // Should NOT have navigated since validation happens first
110          expect(page.goto).not.toHaveBeenCalled();
111      });
112      it('throws when no browser session', async () => {
113          const command = getCommand();
114          await expect(command.func(null, { text: 'hi' })).rejects.toThrow('Browser session required for twitter post');
115      });
116  });