import * as fs from 'fs'; import * as path from 'path'; import { tmpdir } from 'os'; import { EventEmitter } from 'events'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ToolContext } from './core.js'; const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), })); vi.mock('child_process', () => ({ spawn: spawnMock, })); import { executeTool, _resetVersionCheck, inferMediaExtension } from './x.js'; function makeWorkspace(): string { return fs.mkdtempSync(path.join(tmpdir(), 'maestro-x-')); } function makeContext(workspacePath: string, editAllowed: boolean = false): ToolContext { return { workspacePath, editAllowed, toolsConfig: { xCliCommand: ['twitter'], xAuthToken: 'auth-token', xCt0: 'ct0-token', xTimeout: 5, }, }; } function makeSpawnResult(options: { stdout?: string; stderr?: string; exitCode?: number; error?: Error }) { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType; }; child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = vi.fn(); setTimeout(() => { if (options.error) { child.emit('error', options.error); return; } if (options.stdout) child.stdout.emit('data', options.stdout); if (options.stderr) child.stderr.emit('data', options.stderr); child.emit('close', options.exitCode ?? 0); }, 0); return child; } describe('x tools', () => { let workspacePath = ''; afterEach(() => { if (workspacePath) { fs.rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; } spawnMock.mockReset(); _resetVersionCheck(); vi.restoreAllMocks(); }); it('runs XSearch with yaml output and optional save path', async () => { workspacePath = makeWorkspace(); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) // version check .mockReturnValueOnce(makeSpawnResult({ stdout: 'items:\n - id: 1\n' })); // actual call const result = await executeTool('XSearch', { query: 'llama.cpp thinking mode', limit: 5, output_path: 'output/x/search.yaml', }, makeContext(workspacePath, true)); expect(result).not.toBeNull(); expect(result?.isError).toBe(false); expect(result?.output).toContain('items:'); expect(result?.output).toContain('Saved to output/x/search.yaml'); // 2nd call is the actual search (1st is version check) expect(spawnMock).toHaveBeenNthCalledWith(2, 'twitter', ['search', 'llama.cpp thinking mode', '-t', 'Latest', '--max', '5', '--yaml'], expect.objectContaining({ cwd: workspacePath, env: expect.objectContaining({ TWITTER_AUTH_TOKEN: 'auth-token', TWITTER_CT0: 'ct0-token', }), })); expect(fs.readFileSync(path.join(workspacePath, 'output', 'x', 'search.yaml'), 'utf-8')).toContain('items:'); const historyPath = path.join(workspacePath, 'logs', 'x-cli-history.jsonl'); expect(fs.readFileSync(historyPath, 'utf-8')).toContain('"tool":"XSearch"'); }); it('runs XUserPosts with full text flag', async () => { workspacePath = makeWorkspace(); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: 'items:\n - text: hello\n' })); const result = await executeTool('XUserPosts', { username: 'openai', full_text: true, compact: true, }, makeContext(workspacePath)); expect(result?.isError).toBe(false); expect(spawnMock).toHaveBeenNthCalledWith(2, 'twitter', ['user-posts', 'openai', '--max', '20', '--yaml', '--full-text', '--compact'], expect.any(Object)); }); it('returns a helpful error when twitter-cli is missing', async () => { workspacePath = makeWorkspace(); const enoent = new Error('spawn twitter ENOENT'); spawnMock .mockReturnValueOnce(makeSpawnResult({ error: enoent })) // version check fails .mockReturnValueOnce(makeSpawnResult({ error: enoent })); // actual call fails const result = await executeTool('XPostDetail', { tweet: 'https://x.com/example/status/123', }, makeContext(workspacePath)); expect(result?.isError).toBe(true); expect(result?.output).toContain('Install twitter-cli'); }); it('rejects output_path in read-only movements', async () => { workspacePath = makeWorkspace(); const result = await executeTool('XSearch', { query: 'openai', output_path: 'output/x/openai.yaml', }, makeContext(workspacePath)); expect(result?.isError).toBe(true); expect(result?.output).toContain('edit-enabled movement'); expect(spawnMock).not.toHaveBeenCalled(); }); }); describe('inferMediaExtension', () => { it('uses path extension when present', () => { expect(inferMediaExtension('https://pbs.twimg.com/media/abc.jpg')).toBe('.jpg'); expect(inferMediaExtension('https://video.twimg.com/clip.mp4')).toBe('.mp4'); }); it('honors ?format= query for pbs.twimg URLs', () => { expect(inferMediaExtension('https://pbs.twimg.com/media/abc?format=png&name=large')).toBe('.png'); }); it('falls back to .bin for unrecognizable URLs', () => { expect(inferMediaExtension('https://example.com/path/no-ext')).toBe('.bin'); expect(inferMediaExtension('not-a-url')).toBe('.bin'); }); }); // ---- Media download integration via XPostDetail ---- describe('XPostDetail media download', () => { let workspacePath = ''; let fetchSpy: ReturnType | null = null; afterEach(() => { if (workspacePath) { fs.rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; } spawnMock.mockReset(); _resetVersionCheck(); fetchSpy?.mockRestore(); fetchSpy = null; vi.restoreAllMocks(); }); function mockFetch(buf: Buffer, contentLength?: number) { fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { return { ok: true, status: 200, headers: { get: (h: string) => h.toLowerCase() === 'content-length' ? String(contentLength ?? buf.byteLength) : null, }, arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer, } as unknown as Response; }); } function ctxWithMedia(workspacePath: string, overrides: Partial = {}): ToolContext { return { workspacePath, editAllowed: false, toolsConfig: { xCliCommand: ['twitter'], xAuthToken: 'auth-token', xCt0: 'ct0-token', xTimeout: 5, ...overrides, }, }; } const PHOTO_TWEET_YAML = `ok: true data: - id: '111' text: hello media: - type: photo url: https://pbs.twimg.com/media/abc.jpg?name=small `; it('downloads photo media to logs/x-media/{id}/0.jpg and adds localPath', async () => { workspacePath = makeWorkspace(); const buf = Buffer.from('fake-image-bytes'); mockFetch(buf); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); const result = await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath)); expect(result?.isError).toBe(false); const savedPath = path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg'); expect(fs.existsSync(savedPath)).toBe(true); expect(fs.readFileSync(savedPath).equals(buf)).toBe(true); expect(result?.output).toContain('localPath: logs/x-media/111/0.jpg'); expect(result?.output).toContain('bytes: ' + buf.byteLength); }); it('skips download entirely when xDownloadMedia=never', async () => { workspacePath = makeWorkspace(); fetchSpy = vi.spyOn(globalThis, 'fetch'); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); const result = await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath, { xDownloadMedia: 'never' })); expect(result?.isError).toBe(false); expect(fetchSpy).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media'))).toBe(false); expect(result?.output).not.toContain('localPath:'); }); it('aborts media fetch when CDN hangs past xMediaFetchTimeoutSeconds', async () => { workspacePath = makeWorkspace(); // fetch() that respects AbortSignal: rejects with a TimeoutError-like // error when the signal fires. Simulates a slow CDN that never sends // headers. This is the safety net that prevents a single stuck image // from blocking the entire tool call. fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { const signal = (init as RequestInit | undefined)?.signal as AbortSignal | undefined; return new Promise((_resolve, reject) => { if (!signal) return; // shouldn't happen — guard against test regression signal.addEventListener('abort', () => { const err = new Error('The operation was aborted'); (err as { name?: string }).name = 'TimeoutError'; reject(err); }); }); }); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); // Use a very short timeout (50ms) so the test doesn't actually wait 15s. const result = await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath, { xMediaFetchTimeoutSeconds: 0.05 })); // Tool returns success — text content is preserved, only the image was lost. // This is the desired graceful-degradation behaviour: text-and-metadata // still flow to the LLM even when a single asset can't be fetched. expect(result?.isError).toBe(false); expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg'))).toBe(false); expect(result?.output).not.toContain('localPath:'); }); it('skips media exceeding size cap (content-length)', async () => { workspacePath = makeWorkspace(); const buf = Buffer.alloc(10); // 報告 content-length は cap (1MB → 1*1024*1024 = 1048576) を超える 100MB mockFetch(buf, 100 * 1024 * 1024); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); const result = await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath, { xMediaMaxMb: 1 })); expect(result?.isError).toBe(false); // ファイルは保存されない expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg'))).toBe(false); expect(result?.output).not.toContain('localPath:'); }); it('downloads video poster only in thumbnail mode (default)', async () => { workspacePath = makeWorkspace(); mockFetch(Buffer.from('poster')); const VIDEO_YAML = `ok: true data: - id: '222' text: video media: - type: video url: https://pbs.twimg.com/ext_tw_video_thumb/poster.jpg variants: - bitrate: 832000 contentType: video/mp4 url: https://video.twimg.com/lo.mp4 - bitrate: 2176000 contentType: video/mp4 url: https://video.twimg.com/hi.mp4 `; spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML })); const result = await executeTool('XPostDetail', { tweet: '222' }, ctxWithMedia(workspacePath)); expect(result?.isError).toBe(false); expect(fetchSpy).toHaveBeenCalledTimes(1); const url = (fetchSpy!.mock.calls[0]![0] as string).toString(); expect(url).toContain('poster.jpg'); expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '222', '0.jpg'))).toBe(true); }); it('downloads highest-bitrate mp4 in video=full mode', async () => { workspacePath = makeWorkspace(); mockFetch(Buffer.from('mp4-bytes')); const VIDEO_YAML = `ok: true data: - id: '333' media: - type: video url: https://pbs.twimg.com/ext_tw_video_thumb/poster.jpg variants: - bitrate: 832000 contentType: video/mp4 url: https://video.twimg.com/lo.mp4 - bitrate: 2176000 contentType: video/mp4 url: https://video.twimg.com/hi.mp4 `; spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML })); const result = await executeTool('XPostDetail', { tweet: '333' }, ctxWithMedia(workspacePath, { xDownloadVideo: 'full' })); expect(result?.isError).toBe(false); const url = (fetchSpy!.mock.calls[0]![0] as string).toString(); expect(url).toBe('https://video.twimg.com/hi.mp4'); expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '333', '0.mp4'))).toBe(true); }); it('skips video entirely when xDownloadVideo=never', async () => { workspacePath = makeWorkspace(); fetchSpy = vi.spyOn(globalThis, 'fetch'); const VIDEO_YAML = `ok: true data: - id: '444' media: - type: video url: https://pbs.twimg.com/poster.jpg `; spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML })); const result = await executeTool('XPostDetail', { tweet: '444' }, ctxWithMedia(workspacePath, { xDownloadVideo: 'never' })); expect(result?.isError).toBe(false); expect(fetchSpy).not.toHaveBeenCalled(); }); it('is idempotent: re-running does not re-fetch existing files', async () => { workspacePath = makeWorkspace(); const buf = Buffer.from('cached'); mockFetch(buf); spawnMock .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })) .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); // 2 回目 await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath)); await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath)); expect(fetchSpy).toHaveBeenCalledTimes(1); // 1 回目だけ実 fetch、2 回目は既存ファイル流用 }); }); // ---- XFetchCardMedia tool (opt-in Playwright fallback for quiz/poll cards) ---- describe('XFetchCardMedia tool', () => { let workspacePath = ''; let fetchSpy: ReturnType | null = null; afterEach(() => { if (workspacePath) { fs.rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; } fetchSpy?.mockRestore(); fetchSpy = null; vi.doUnmock('./browser.js'); vi.resetModules(); vi.restoreAllMocks(); }); function ctxWithMedia(workspacePath: string): ToolContext { return { workspacePath, editAllowed: false, toolsConfig: { xCliCommand: ['twitter'], xAuthToken: 'auth-token', xCt0: 'ct0-token', xTimeout: 5, }, }; } /** * Mock the dynamically-imported browser module so the tool's Playwright * path returns deterministic URL captures without spinning up Chromium. * graphqlUrls flow through the response listener; domUrls flow through * page.evaluate() — both code paths in fetchCardMediaFromWebPage. */ function mockBrowserModule(opts: { graphqlUrls?: string[]; domUrls?: string[]; }): void { const responseListeners: Array<(resp: { url: () => string; text: () => Promise }) => void> = []; const fakePage = { on: vi.fn((event: string, listener: (resp: { url: () => string; text: () => Promise }) => void) => { if (event === 'response') responseListeners.push(listener); }), off: vi.fn(), setDefaultTimeout: vi.fn(), goto: vi.fn(async () => { for (const listener of responseListeners) { listener({ url: () => 'https://x.com/i/api/graphql/abc/TweetDetail', text: async () => JSON.stringify({ urls: opts.graphqlUrls ?? [] }), }); } }), waitForSelector: vi.fn(async () => {}), waitForLoadState: vi.fn(async () => {}), waitForTimeout: vi.fn(async () => {}), evaluate: vi.fn(async () => opts.domUrls ?? []), close: vi.fn(async () => {}), }; const fakeContext = { addCookies: vi.fn(async () => {}), newPage: vi.fn(async () => fakePage), close: vi.fn(async () => {}), }; const fakeBrowser = { newContext: vi.fn(async () => fakeContext), }; vi.doMock('./browser.js', () => ({ getCaptchaPoolBrowser: vi.fn(async () => fakeBrowser), })); } function mockImageFetch(buf: Buffer): void { fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: true, status: 200, headers: { get: (h: string) => h.toLowerCase() === 'content-length' ? String(buf.byteLength) : null, }, arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer, } as unknown as Response)); } it('downloads card images discovered via DOM scope to logs/x-media/{id}/', async () => { workspacePath = makeWorkspace(); mockBrowserModule({ // Quiz card with a card_img URL at small size — upgrade should bump to large domUrls: ['https://pbs.twimg.com/card_img/9999/quizimg?format=jpg&name=small'], }); mockImageFetch(Buffer.from('card-img-bytes')); // Re-import executeTool to pick up the mocked browser module const { executeTool: executeToolFresh } = await import('./x.js'); const result = await executeToolFresh( 'XFetchCardMedia', { tweet: 'https://x.com/someone/status/9999' }, ctxWithMedia(workspacePath), ); expect(result?.isError).toBe(false); expect(result?.output).toContain('saved 1 image(s)'); const saved = path.join(workspacePath, 'logs', 'x-media', '9999', '0.jpg'); expect(fs.existsSync(saved)).toBe(true); // Verify upgradePbsUrl normalized to name=large before fetch const fetchedUrl = (fetchSpy!.mock.calls[0]![0] as string).toString(); expect(fetchedUrl).toContain('name=large'); }); it('returns "no card media found" gracefully when both graphql and DOM yield zero URLs', async () => { workspacePath = makeWorkspace(); mockBrowserModule({ domUrls: [], graphqlUrls: [] }); fetchSpy = vi.spyOn(globalThis, 'fetch'); const { executeTool: executeToolFresh } = await import('./x.js'); const result = await executeToolFresh( 'XFetchCardMedia', { tweet: '12345' }, ctxWithMedia(workspacePath), ); expect(result?.isError).toBe(false); expect(result?.output).toContain('no card media found'); // Graceful exit: no fetch, no dir creation expect(fetchSpy).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media'))).toBe(false); }); it('rejects malformed tweet input without launching browser', async () => { workspacePath = makeWorkspace(); mockBrowserModule({ domUrls: ['will-not-be-called'] }); const { executeTool: executeToolFresh } = await import('./x.js'); const result = await executeToolFresh( 'XFetchCardMedia', { tweet: 'not-a-url-or-id' }, ctxWithMedia(workspacePath), ); expect(result?.isError).toBe(true); expect(result?.output).toContain('could not parse'); }); });