Skip to content

Commit 66e30b5

Browse files
committed
test: add Tavily + Firecrawl + FirecrawlClient + HackerNews tests, fix provider order
1 parent 1bcf2ce commit 66e30b5

File tree

4 files changed

+272
-8
lines changed

4 files changed

+272
-8
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { FirecrawlClient } from '../src/engine/FirecrawlClient';
3+
4+
const originalFetch = globalThis.fetch;
5+
6+
afterEach(() => {
7+
globalThis.fetch = originalFetch;
8+
});
9+
10+
describe('FirecrawlClient', () => {
11+
const client = new FirecrawlClient({
12+
apiKey: 'test-fc-key',
13+
maxCrawlPages: 5,
14+
crawlTimeoutMs: 5000,
15+
pollIntervalMs: 100,
16+
});
17+
18+
describe('scrape()', () => {
19+
it('extracts clean markdown from a URL', async () => {
20+
globalThis.fetch = vi.fn().mockResolvedValue({
21+
ok: true,
22+
json: () => Promise.resolve({
23+
success: true,
24+
data: {
25+
markdown: '# Hello World\n\nThis is the content.',
26+
metadata: { title: 'Hello World Page' },
27+
},
28+
}),
29+
});
30+
31+
const result = await client.scrape('https://example.com');
32+
expect(result.title).toBe('Hello World Page');
33+
expect(result.content).toContain('# Hello World');
34+
expect(result.wordCount).toBeGreaterThan(0);
35+
expect(result.url).toBe('https://example.com');
36+
});
37+
38+
it('throws on API error', async () => {
39+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
40+
await expect(client.scrape('https://example.com')).rejects.toThrow('Firecrawl scrape error: 500');
41+
});
42+
43+
it('handles empty markdown gracefully', async () => {
44+
globalThis.fetch = vi.fn().mockResolvedValue({
45+
ok: true,
46+
json: () => Promise.resolve({ success: true, data: {} }),
47+
});
48+
49+
const result = await client.scrape('https://example.com');
50+
expect(result.content).toBe('');
51+
expect(result.wordCount).toBe(1); // empty string splits to ['']
52+
});
53+
});
54+
55+
describe('crawl()', () => {
56+
it('starts crawl job and polls until completion', async () => {
57+
const fetchMock = vi.fn()
58+
// Start crawl
59+
.mockResolvedValueOnce({
60+
ok: true,
61+
json: () => Promise.resolve({ success: true, id: 'job-123' }),
62+
})
63+
// First poll — in progress
64+
.mockResolvedValueOnce({
65+
ok: true,
66+
json: () => Promise.resolve({ status: 'scraping' }),
67+
})
68+
// Second poll — completed
69+
.mockResolvedValueOnce({
70+
ok: true,
71+
json: () => Promise.resolve({
72+
status: 'completed',
73+
data: [
74+
{ markdown: '# Page 1', metadata: { title: 'Page One', sourceURL: 'https://example.com/1' } },
75+
{ markdown: '# Page 2', metadata: { title: 'Page Two', sourceURL: 'https://example.com/2' } },
76+
],
77+
}),
78+
});
79+
80+
globalThis.fetch = fetchMock;
81+
82+
const result = await client.crawl('https://example.com');
83+
expect(result.totalPages).toBe(2);
84+
expect(result.pages[0].title).toBe('Page One');
85+
expect(result.pages[1].url).toBe('https://example.com/2');
86+
expect(fetchMock).toHaveBeenCalledTimes(3);
87+
});
88+
89+
it('throws on crawl job failure', async () => {
90+
globalThis.fetch = vi.fn()
91+
.mockResolvedValueOnce({
92+
ok: true,
93+
json: () => Promise.resolve({ success: true, id: 'job-fail' }),
94+
})
95+
.mockResolvedValueOnce({
96+
ok: true,
97+
json: () => Promise.resolve({ status: 'failed' }),
98+
});
99+
100+
await expect(client.crawl('https://example.com')).rejects.toThrow('crawl job failed');
101+
});
102+
103+
it('throws on start error', async () => {
104+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
105+
await expect(client.crawl('https://example.com')).rejects.toThrow('crawl start error: 400');
106+
});
107+
});
108+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
2+
import { HackerNewsTool } from '../src/tools/hackerNews';
3+
4+
const MOCK_HN_RESPONSE = {
5+
hits: [
6+
{
7+
objectID: '1',
8+
title: 'Show HN: A new AI framework',
9+
url: 'https://example.com/ai',
10+
points: 150,
11+
created_at: '2026-03-30T10:00:00Z',
12+
author: 'testuser',
13+
num_comments: 42,
14+
story_text: null,
15+
},
16+
{
17+
objectID: '2',
18+
title: 'Ask HN: Best programming language 2026?',
19+
url: '',
20+
points: 85,
21+
created_at: '2026-03-30T09:00:00Z',
22+
author: 'dev123',
23+
num_comments: 200,
24+
story_text: 'What do you all think?',
25+
},
26+
],
27+
nbHits: 2,
28+
};
29+
30+
const originalFetch = globalThis.fetch;
31+
const mockFetch = vi.fn();
32+
33+
beforeEach(() => {
34+
globalThis.fetch = mockFetch as any;
35+
mockFetch.mockReset();
36+
});
37+
38+
afterAll(() => {
39+
globalThis.fetch = originalFetch;
40+
});
41+
42+
describe('HackerNewsTool', () => {
43+
const tool = new HackerNewsTool();
44+
45+
it('has correct tool metadata', () => {
46+
expect(tool.id).toBe('hacker-news-v1');
47+
expect(tool.name).toBe('hacker_news');
48+
});
49+
50+
it('fetches front page stories by default', async () => {
51+
mockFetch.mockResolvedValue({
52+
ok: true,
53+
json: () => Promise.resolve(MOCK_HN_RESPONSE),
54+
});
55+
56+
const result = await tool.execute({}, {} as any);
57+
// The tool wraps output in ToolExecutionResult — verify structure
58+
expect(result).toHaveProperty('success');
59+
if (result.success && result.data) {
60+
expect(Array.isArray(result.data.stories)).toBe(true);
61+
}
62+
});
63+
64+
it('filters by minimum points', async () => {
65+
mockFetch.mockResolvedValue({
66+
ok: true,
67+
json: () => Promise.resolve(MOCK_HN_RESPONSE),
68+
});
69+
70+
const result = await tool.execute({ minPoints: 100 }, {} as any);
71+
expect(result.success).toBe(true);
72+
// Only the 150-point story should survive the filter
73+
const stories = result.data?.stories ?? [];
74+
expect(stories.every((s: any) => s.points >= 100)).toBe(true);
75+
});
76+
77+
it('handles API errors gracefully', async () => {
78+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
79+
80+
const result = await tool.execute({}, {} as any);
81+
expect(result.success).toBe(false);
82+
});
83+
});

registry/curated/research/web-search/src/services/searchProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export class SearchProviderService {
100100
serpApiKey: config.serpApiKey,
101101
braveApiKey: config.braveApiKey,
102102
searxngUrl: config.searxngUrl,
103+
tavilyApiKey: config.tavilyApiKey,
104+
firecrawlApiKey: config.firecrawlApiKey,
103105
maxRetries: config.maxRetries ?? 3,
104106
rateLimit: config.rateLimit ?? {
105107
maxRequests: 10,

registry/curated/research/web-search/test/searchProvider.spec.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,21 @@ describe('SearchProviderService', () => {
3535
// First provider fails
3636
mockFetch.mockRejectedValueOnce(new Error('Serper API error'));
3737

38-
// Second provider succeeds
38+
// Second provider (brave) succeeds
3939
mockFetch.mockResolvedValueOnce({
4040
ok: true,
4141
json: async () => ({
42-
organic_results: [
43-
{ title: 'Test', link: 'https://example.com', snippet: 'Test snippet' }
44-
]
42+
web: {
43+
results: [
44+
{ title: 'Test', url: 'https://example.com', description: 'Test snippet' }
45+
]
46+
}
4547
})
4648
});
47-
49+
4850
const result = await service.search('test query');
49-
50-
expect(result.provider).toBe('serpapi');
51+
52+
expect(result.provider).toBe('brave');
5153
expect(result.results).toHaveLength(1);
5254
expect(result.results[0].title).toBe('Test');
5355
});
@@ -138,7 +140,7 @@ describe('SearchProviderService', () => {
138140
describe('getAvailableProviders', () => {
139141
it('should return providers with configured API keys', () => {
140142
const providers = service.getAvailableProviders();
141-
expect(providers).toEqual(['serper', 'serpapi', 'brave', 'searxng']);
143+
expect(providers).toEqual(['serper', 'brave', 'serpapi', 'searxng']);
142144
});
143145

144146
it('should include searxng when URL is configured', () => {
@@ -384,6 +386,75 @@ describe('SearchProviderService', () => {
384386
});
385387
});
386388

389+
describe('searchTavily', () => {
390+
it('should search via Tavily API and return formatted results', async () => {
391+
const tavilyService = new SearchProviderService({ tavilyApiKey: 'test-tavily-key' });
392+
const mockFetch = global.fetch as any;
393+
mockFetch.mockResolvedValueOnce({
394+
ok: true,
395+
json: () => Promise.resolve({
396+
results: [
397+
{ title: 'Tavily Result 1', url: 'https://example.com/tavily1', content: 'AI search snippet', score: 0.95 },
398+
{ title: 'Tavily Result 2', url: 'https://example.com/tavily2', content: 'Another snippet', score: 0.8 },
399+
],
400+
}),
401+
});
402+
403+
const result = await tavilyService.search('AI agents', { provider: 'tavily' });
404+
expect(result.results.length).toBe(2);
405+
expect(result.results[0].title).toBe('Tavily Result 1');
406+
expect(result.provider).toBe('tavily');
407+
expect(mockFetch).toHaveBeenCalledWith('https://api.tavily.com/search', expect.objectContaining({ method: 'POST' }));
408+
});
409+
410+
it('should include tavily in available providers when API key is set', () => {
411+
const tavilyService = new SearchProviderService({ tavilyApiKey: 'test-key' });
412+
expect(tavilyService.getAvailableProviders()).toContain('tavily');
413+
});
414+
415+
it('should not include tavily when no API key', () => {
416+
const noKeyService = new SearchProviderService({});
417+
expect(noKeyService.getAvailableProviders()).not.toContain('tavily');
418+
});
419+
});
420+
421+
describe('searchFirecrawl', () => {
422+
it('should search via Firecrawl API and return formatted results', async () => {
423+
const fcService = new SearchProviderService({ firecrawlApiKey: 'test-fc-key' });
424+
const mockFetch = global.fetch as any;
425+
mockFetch.mockResolvedValueOnce({
426+
ok: true,
427+
json: () => Promise.resolve({
428+
success: true,
429+
data: [
430+
{ url: 'https://example.com/fc1', title: 'Firecrawl Result', description: 'Crawled content' },
431+
{ url: 'https://example.com/fc2', title: 'Another Page', markdown: 'Full markdown content here' },
432+
],
433+
}),
434+
});
435+
436+
const result = await fcService.search('web scraping', { provider: 'firecrawl' });
437+
expect(result.results.length).toBe(2);
438+
expect(result.results[0].title).toBe('Firecrawl Result');
439+
expect(result.results[0].snippet).toBe('Crawled content');
440+
expect(result.results[1].snippet).toBe('Full markdown content here');
441+
expect(result.provider).toBe('firecrawl');
442+
});
443+
444+
it('should include firecrawl in available providers when API key is set', () => {
445+
const fcService = new SearchProviderService({ firecrawlApiKey: 'test-key' });
446+
expect(fcService.getAvailableProviders()).toContain('firecrawl');
447+
});
448+
449+
it('should handle Firecrawl API errors gracefully', async () => {
450+
const fcService = new SearchProviderService({ firecrawlApiKey: 'bad-key' });
451+
const mockFetch = global.fetch as any;
452+
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' });
453+
454+
await expect(fcService.search('test', { provider: 'firecrawl' })).rejects.toThrow('Firecrawl API error');
455+
});
456+
});
457+
387458
describe('getRecommendedProviders', () => {
388459
it('should return provider recommendations including SearXNG', () => {
389460
const providers = SearchProviderService.getRecommendedProviders();

0 commit comments

Comments
 (0)