feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user