Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/clis/twitter/post.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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');
});
});
113 changes: 82 additions & 31 deletions src/clis/twitter/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
}
Expand Down