maestro/src/engine/tools/x.test.ts
2026-06-03 05:08:00 +00:00

544 lines
20 KiB
TypeScript

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<typeof vi.fn>;
};
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<typeof vi.spyOn> | 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['toolsConfig']> = {}): 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<typeof vi.spyOn> | 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<string> }) => void> = [];
const fakePage = {
on: vi.fn((event: string, listener: (resp: { url: () => string; text: () => Promise<string> }) => 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');
});
});