544 lines
20 KiB
TypeScript
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');
|
|
});
|
|
});
|