@ever-works/exa-plugin 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,313 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import type { PluginContext, SearchOptions, ContentExtractionOptions } from '@ever-works/plugin';
3
-
4
- const { searchMock, getContentsMock, ExaCtorMock } = vi.hoisted(() => {
5
- const search = vi.fn();
6
- const getContents = vi.fn();
7
- const ctor = vi.fn().mockImplementation(function () {
8
- return { search, getContents };
9
- });
10
- return { searchMock: search, getContentsMock: getContents, ExaCtorMock: ctor };
11
- });
12
-
13
- vi.mock('exa-js', () => ({
14
- Exa: ExaCtorMock
15
- }));
16
-
17
- const { ExaSearchPlugin } = await import('../exa.plugin.js');
18
-
19
- const buildContext = (settings: Record<string, unknown> = {}): PluginContext =>
20
- ({
21
- pluginId: 'exa',
22
- logger: {
23
- log: vi.fn(),
24
- debug: vi.fn(),
25
- warn: vi.fn(),
26
- error: vi.fn()
27
- },
28
- getSettings: vi.fn().mockResolvedValue(settings)
29
- }) as unknown as PluginContext;
30
-
31
- describe('ExaSearchPlugin', () => {
32
- let plugin: ExaSearchPlugin;
33
-
34
- beforeEach(() => {
35
- vi.clearAllMocks();
36
- plugin = new ExaSearchPlugin();
37
- });
38
-
39
- describe('metadata', () => {
40
- it('exposes stable identity fields', () => {
41
- expect(plugin.id).toBe('exa');
42
- expect(plugin.name).toBe('Exa');
43
- expect(plugin.version).toBe('1.0.0');
44
- expect(plugin.category).toBe('search');
45
- expect(plugin.providerName).toBe('Exa');
46
- });
47
-
48
- it('declares both search and content-extractor capabilities', () => {
49
- expect(plugin.capabilities).toEqual(['search', 'content-extractor']);
50
- });
51
-
52
- it('uses hybrid configuration mode', () => {
53
- expect(plugin.configurationMode).toBe('hybrid');
54
- });
55
- });
56
-
57
- describe('settingsSchema', () => {
58
- it('requires apiKey and marks it as user-scoped secret', () => {
59
- expect(plugin.settingsSchema.required).toContain('apiKey');
60
- const props = plugin.settingsSchema.properties as Record<string, Record<string, unknown>>;
61
- expect(props.apiKey['x-secret']).toBe(true);
62
- expect(props.apiKey['x-envVar']).toBe('PLUGIN_EXA_API_KEY');
63
- expect(props.apiKey['x-scope']).toBe('user');
64
- });
65
-
66
- it('exposes searchType, maxResults, and category settings with bounds', () => {
67
- const props = plugin.settingsSchema.properties as Record<string, Record<string, unknown>>;
68
- expect(props.searchType.enum).toEqual(['auto', 'neural', 'keyword']);
69
- expect(props.searchType.default).toBe('auto');
70
- expect(props.maxResults.default).toBe(10);
71
- expect(props.maxResults.minimum).toBe(1);
72
- expect(props.maxResults.maximum).toBe(100);
73
- expect(props.category.enum).toContain('research paper');
74
- });
75
- });
76
-
77
- describe('search', () => {
78
- const opts = (overrides: Partial<SearchOptions> = {}): SearchOptions => ({
79
- query: 'hello',
80
- settings: { apiKey: 'k' },
81
- ...overrides
82
- });
83
-
84
- it('throws when apiKey is missing', async () => {
85
- await expect(plugin.search({ query: 'hello', settings: {} })).rejects.toThrow(/API key not configured/i);
86
- expect(ExaCtorMock).not.toHaveBeenCalled();
87
- });
88
-
89
- it('maps SDK results to SearchResult shape', async () => {
90
- searchMock.mockResolvedValueOnce({
91
- results: [
92
- {
93
- title: 'Example',
94
- url: 'https://example.com',
95
- publishedDate: '2026-01-01',
96
- author: 'Alice',
97
- favicon: 'https://example.com/favicon.ico'
98
- }
99
- ]
100
- });
101
- const r = await plugin.search(opts());
102
-
103
- expect(ExaCtorMock).toHaveBeenCalledWith('k');
104
- expect(r.results).toHaveLength(1);
105
- expect(r.results[0]).toMatchObject({
106
- title: 'Example',
107
- url: 'https://example.com',
108
- publishedDate: '2026-01-01',
109
- source: 'Alice',
110
- faviconUrl: 'https://example.com/favicon.ico',
111
- position: 1
112
- });
113
- });
114
-
115
- it('forwards numResults from limit then maxResults setting then default', async () => {
116
- searchMock.mockResolvedValueOnce({ results: [] });
117
- await plugin.search(opts({ limit: 7 }));
118
- expect(searchMock.mock.calls[0][1]).toMatchObject({ numResults: 7, type: 'auto' });
119
-
120
- searchMock.mockResolvedValueOnce({ results: [] });
121
- await plugin.search(opts({ settings: { apiKey: 'k', maxResults: 25 } }));
122
- expect(searchMock.mock.calls[1][1]).toMatchObject({ numResults: 25 });
123
-
124
- searchMock.mockResolvedValueOnce({ results: [] });
125
- await plugin.search(opts());
126
- expect(searchMock.mock.calls[2][1]).toMatchObject({ numResults: 10 });
127
- });
128
-
129
- it('forwards include/excludeDomains and category', async () => {
130
- searchMock.mockResolvedValueOnce({ results: [] });
131
- await plugin.search(
132
- opts({
133
- includeDomains: ['a.com'],
134
- excludeDomains: ['b.com'],
135
- settings: { apiKey: 'k', category: 'research paper' }
136
- })
137
- );
138
- expect(searchMock.mock.calls[0][1]).toMatchObject({
139
- includeDomains: ['a.com'],
140
- excludeDomains: ['b.com'],
141
- category: 'research paper'
142
- });
143
- });
144
-
145
- it('translates timeRange into startPublishedDate ISO string', async () => {
146
- searchMock.mockResolvedValueOnce({ results: [] });
147
- await plugin.search(opts({ timeRange: 'week' }));
148
- const call = searchMock.mock.calls[0][1];
149
- expect(call.startPublishedDate).toMatch(/^\d{4}-\d{2}-\d{2}T/);
150
- });
151
-
152
- it('skips date filter when timeRange is "all"', async () => {
153
- searchMock.mockResolvedValueOnce({ results: [] });
154
- await plugin.search(opts({ timeRange: 'all' }));
155
- expect(searchMock.mock.calls[0][1].startPublishedDate).toBeUndefined();
156
- });
157
-
158
- it('forwards the configured searchType (neural/keyword)', async () => {
159
- searchMock.mockResolvedValueOnce({ results: [] });
160
- await plugin.search(opts({ settings: { apiKey: 'k', searchType: 'neural' } }));
161
- expect(searchMock.mock.calls[0][1]).toMatchObject({ type: 'neural' });
162
- });
163
-
164
- it('logs and rethrows on SDK failure', async () => {
165
- const ctx = buildContext();
166
- await plugin.onLoad(ctx);
167
- searchMock.mockRejectedValueOnce(new Error('boom'));
168
- await expect(plugin.search(opts())).rejects.toThrow(/boom/);
169
- expect(ctx.logger.error).toHaveBeenCalled();
170
- });
171
- });
172
-
173
- describe('isAvailable', () => {
174
- it('returns false without context', async () => {
175
- expect(await plugin.isAvailable()).toBe(false);
176
- });
177
-
178
- it('reflects whether settings has an apiKey', async () => {
179
- await plugin.onLoad(buildContext({ apiKey: 'k' }));
180
- expect(await plugin.isAvailable()).toBe(true);
181
-
182
- await plugin.onLoad(buildContext({}));
183
- expect(await plugin.isAvailable()).toBe(false);
184
- });
185
- });
186
-
187
- describe('validateConnection', () => {
188
- it('fails fast without apiKey', async () => {
189
- const r = await plugin.validateConnection({});
190
- expect(r.success).toBe(false);
191
- expect(r.message).toMatch(/not configured/i);
192
- });
193
-
194
- it('returns success when SDK search works', async () => {
195
- searchMock.mockResolvedValueOnce({ results: [] });
196
- const r = await plugin.validateConnection({ apiKey: 'k' });
197
- expect(r.success).toBe(true);
198
- });
199
-
200
- it('returns failure when SDK search rejects', async () => {
201
- searchMock.mockRejectedValueOnce(new Error('bad key'));
202
- const r = await plugin.validateConnection({ apiKey: 'k' });
203
- expect(r.success).toBe(false);
204
- expect(r.message).toMatch(/bad key/);
205
- });
206
- });
207
-
208
- describe('extract', () => {
209
- const opts = (overrides: Partial<ContentExtractionOptions> = {}): ContentExtractionOptions => ({
210
- url: 'https://example.com/post',
211
- settings: { apiKey: 'k' },
212
- ...overrides
213
- });
214
-
215
- it('returns success with text and word count', async () => {
216
- getContentsMock.mockResolvedValueOnce({
217
- results: [{ url: 'https://example.com/post', title: 'Title', text: 'one two three' }]
218
- });
219
- const r = await plugin.extract(opts());
220
- expect(r.success).toBe(true);
221
- expect(r.title).toBe('Title');
222
- expect(r.content).toBe('one two three');
223
- expect(r.wordCount).toBe(3);
224
- });
225
-
226
- it('exposes finalUrl when SDK returns a different URL', async () => {
227
- getContentsMock.mockResolvedValueOnce({
228
- results: [{ url: 'https://example.com/redirected', title: 't', text: 'a' }]
229
- });
230
- const r = await plugin.extract(opts());
231
- expect(r.finalUrl).toBe('https://example.com/redirected');
232
- });
233
-
234
- it('returns failure when no results are produced', async () => {
235
- getContentsMock.mockResolvedValueOnce({ results: [] });
236
- const r = await plugin.extract(opts());
237
- expect(r.success).toBe(false);
238
- expect(r.error).toMatch(/No content extracted/);
239
- });
240
-
241
- it('returns failure when SDK throws', async () => {
242
- getContentsMock.mockRejectedValueOnce(new Error('extract failed'));
243
- const r = await plugin.extract(opts());
244
- expect(r.success).toBe(false);
245
- expect(r.error).toMatch(/extract failed/);
246
- });
247
- });
248
-
249
- describe('extractBatch', () => {
250
- it('maps results back to requested URLs by index', async () => {
251
- getContentsMock.mockResolvedValueOnce({
252
- results: [
253
- { url: 'https://a.example', title: 'A', text: 'one two' },
254
- { url: 'https://b.example', title: 'B', text: 'three four five' }
255
- ]
256
- });
257
- const r = await plugin.extractBatch(['https://a.example', 'https://b.example'], {
258
- settings: { apiKey: 'k' }
259
- });
260
- expect(r).toHaveLength(2);
261
- expect(r[0]).toMatchObject({ success: true, url: 'https://a.example', wordCount: 2 });
262
- expect(r[1]).toMatchObject({ success: true, url: 'https://b.example', wordCount: 3 });
263
- });
264
-
265
- it('returns per-URL failures when the batch call throws', async () => {
266
- getContentsMock.mockRejectedValueOnce(new Error('batch failed'));
267
- const r = await plugin.extractBatch(['https://a', 'https://b'], { settings: { apiKey: 'k' } });
268
- expect(r).toHaveLength(2);
269
- expect(r.every((x) => x.success === false)).toBe(true);
270
- });
271
- });
272
-
273
- describe('canExtract / getSupportedFormats', () => {
274
- it('accepts http and https URLs', async () => {
275
- expect(await plugin.canExtract('https://example.com')).toBe(true);
276
- expect(await plugin.canExtract('http://example.com')).toBe(true);
277
- });
278
-
279
- it('rejects non-http schemes and invalid URLs', async () => {
280
- expect(await plugin.canExtract('ftp://example.com')).toBe(false);
281
- expect(await plugin.canExtract('garbage')).toBe(false);
282
- });
283
-
284
- it('exposes only the text format', () => {
285
- expect(plugin.getSupportedFormats()).toEqual(['text']);
286
- });
287
- });
288
-
289
- describe('lifecycle', () => {
290
- it('logs on load and clears context on unload', async () => {
291
- const ctx = buildContext();
292
- await plugin.onLoad(ctx);
293
- expect(ctx.logger.log).toHaveBeenCalledWith('Exa Plugin loaded');
294
- await plugin.onUnload();
295
- expect(await plugin.isAvailable()).toBe(false);
296
- });
297
- });
298
-
299
- describe('healthCheck + manifest', () => {
300
- it('reports healthy', async () => {
301
- const h = await plugin.healthCheck();
302
- expect(h.status).toBe('healthy');
303
- });
304
-
305
- it('returns a manifest aligned with plugin metadata', () => {
306
- const m = plugin.getManifest();
307
- expect(m.id).toBe('exa');
308
- expect(m.category).toBe('search');
309
- expect(m.capabilities).toEqual(['search', 'content-extractor']);
310
- expect(m.builtIn).toBe(true);
311
- });
312
- });
313
- });