@ever-works/exa-plugin 1.0.0
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.
- package/LICENSE +661 -0
- package/README.md +78 -0
- package/package.json +78 -0
- package/src/__tests__/exa.plugin.spec.ts +313 -0
- package/src/exa.plugin.ts +406 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @ever-works/exa-plugin
|
|
2
|
+
|
|
3
|
+
Exa Plugin - Neural and keyword search using the Exa API
|
|
4
|
+
|
|
5
|
+
## Plugin metadata
|
|
6
|
+
|
|
7
|
+
| Field | Value |
|
|
8
|
+
| ------------ | ----------------------------- |
|
|
9
|
+
| ID | `exa` |
|
|
10
|
+
| Category | `search` |
|
|
11
|
+
| Capabilities | `search`, `content-extractor` |
|
|
12
|
+
| Author | Ever Works Team |
|
|
13
|
+
| License | AGPL-3.0 |
|
|
14
|
+
| Built-in | yes |
|
|
15
|
+
| Auto-enable | no |
|
|
16
|
+
|
|
17
|
+
## What does Exa do?
|
|
18
|
+
|
|
19
|
+
Exa is an AI-native search engine that understands meaning, not just keywords. It offers neural search (semantic understanding), keyword search (traditional matching), and an auto mode that picks the best approach for each query. It can also extract clean text content from web pages.
|
|
20
|
+
|
|
21
|
+
## Why use it?
|
|
22
|
+
|
|
23
|
+
- **Neural search** — finds results based on meaning, not just keyword matching
|
|
24
|
+
- **Content extraction** — pulls clean text from any web page URL
|
|
25
|
+
- **Category filtering** — restrict to companies, research papers, news, tweets, GitHub repos, or personal sites
|
|
26
|
+
- **Domain control** — include or exclude specific domains from results
|
|
27
|
+
- **Time filtering** — find results from the last day, week, month, or year
|
|
28
|
+
|
|
29
|
+
## How it works in Ever Works
|
|
30
|
+
|
|
31
|
+
When enabled and set as the active search provider, Exa is used during work generation to find information about each item. Its neural search mode is particularly useful for finding semantically relevant content that keyword-based engines might miss. The content extraction capability can pull text from web pages for enriching work items.
|
|
32
|
+
|
|
33
|
+
## Getting started
|
|
34
|
+
|
|
35
|
+
1. Create an account at [exa.ai](https://exa.ai)
|
|
36
|
+
2. Copy your API key from the Exa dashboard
|
|
37
|
+
3. Enter the key in the **API Key** field below
|
|
38
|
+
4. Choose your preferred search type (auto recommended)
|
|
39
|
+
5. Enable this plugin to use it for work generation
|
|
40
|
+
|
|
41
|
+
## Settings
|
|
42
|
+
|
|
43
|
+
- **API Key** — Your Exa API key (secret, scoped per user, also configurable via `PLUGIN_EXA_API_KEY`).
|
|
44
|
+
- **Search Type** — Search mode: `auto` (recommended), `neural` (semantic), or `keyword` (traditional).
|
|
45
|
+
- **Default Max Results** — Default number of results per search (1–100, default 10).
|
|
46
|
+
- **Category Filter** — Optionally restrict results to a specific category (company, research paper, news, tweet, personal site, github).
|
|
47
|
+
|
|
48
|
+
## Troubleshooting
|
|
49
|
+
|
|
50
|
+
| Symptom | Likely cause | Fix |
|
|
51
|
+
| -------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `401 Unauthorized` / `Authentication failed` | API key missing, revoked, or wrong key entered | Re-enter the **API Key** from the Exa dashboard, or set `PLUGIN_EXA_API_KEY` in the host environment for default fallback |
|
|
53
|
+
| `429 Too Many Requests` | Free-tier or paid quota exhausted on Exa | Throttle calls, wait for the quota reset, or upgrade the plan in the Exa dashboard |
|
|
54
|
+
| Empty / sparse results | Query is too restrictive, time-range or domain filters are too narrow | Broaden the query, relax `time_range` / `safesearch` / `include_domains` / `exclude_domains` filters, or raise `max_results` |
|
|
55
|
+
| Plugin not used during work generation | Another search plugin is set as the default for the `search` capability | In **Settings → Plugins**, set `exa` as the default for `search`, or disable competing search plugins |
|
|
56
|
+
| `Failed to extract content` for a URL | Page is gated by login, Cloudflare, or robots.txt; URL is malformed | Verify the URL is publicly reachable; for protected pages enable a different `content-extractor` plugin (`scrapfly`, `notion-extractor`, `pdf-extractor`) |
|
|
57
|
+
| `healthCheck` reports unhealthy | API key invalid OR Exa endpoint unreachable from the host | Verify the key with a manual `curl` against the documented endpoint and confirm outbound HTTPS is allowed by the firewall |
|
|
58
|
+
|
|
59
|
+
## Local development
|
|
60
|
+
|
|
61
|
+
This plugin ships built-in with the Ever Works platform. To work on it locally from the monorepo root:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm install
|
|
65
|
+
pnpm --filter @ever-works/exa-plugin build
|
|
66
|
+
pnpm --filter @ever-works/exa-plugin test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
- [Ever Works documentation](https://docs.ever.works)
|
|
72
|
+
- [Ever Works repository](https://github.com/ever-works/ever-works)
|
|
73
|
+
- [Plugin system](../../plugin/README.md)
|
|
74
|
+
- [Exa homepage](https://exa.ai)
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
AGPL-3.0
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ever-works/exa-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Exa Plugin - Neural and keyword search using the Exa API",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Ever Co. LTD",
|
|
7
|
+
"email": "ever@ever.co",
|
|
8
|
+
"url": "https://ever.co"
|
|
9
|
+
},
|
|
10
|
+
"license": "AGPL-3.0",
|
|
11
|
+
"private": false,
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.cjs",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"require": "./dist/index.cjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"exa-js": "^2.4.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@ever-works/plugin": "1.1.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"tsup": "^8.4.0",
|
|
32
|
+
"typescript": "^5.7.3",
|
|
33
|
+
"vitest": "^4.1.0",
|
|
34
|
+
"@ever-works/plugin": "1.1.0"
|
|
35
|
+
},
|
|
36
|
+
"everworks": {
|
|
37
|
+
"plugin": {
|
|
38
|
+
"id": "exa",
|
|
39
|
+
"name": "Exa",
|
|
40
|
+
"version": "1.0.0",
|
|
41
|
+
"category": "search",
|
|
42
|
+
"capabilities": [
|
|
43
|
+
"search",
|
|
44
|
+
"content-extractor"
|
|
45
|
+
],
|
|
46
|
+
"description": "AI-native search using the Exa API. Supports neural, keyword, and auto search modes with optional category filtering.",
|
|
47
|
+
"author": {
|
|
48
|
+
"name": "Ever Works Team"
|
|
49
|
+
},
|
|
50
|
+
"license": "AGPL-3.0",
|
|
51
|
+
"autoEnable": false,
|
|
52
|
+
"systemPlugin": false,
|
|
53
|
+
"builtIn": true,
|
|
54
|
+
"envVars": [
|
|
55
|
+
{
|
|
56
|
+
"name": "PLUGIN_EXA_API_KEY",
|
|
57
|
+
"required": false,
|
|
58
|
+
"secret": true,
|
|
59
|
+
"description": "Exa API key (optional - can be set via admin/user settings)"
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"distribution": "registry"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "restricted",
|
|
67
|
+
"registry": "https://registry.npmjs.org"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsc --noEmit && tsup",
|
|
71
|
+
"dev": "tsup --watch",
|
|
72
|
+
"type-check": "tsc --noEmit",
|
|
73
|
+
"clean": "rm -rf dist",
|
|
74
|
+
"test": "vitest run --passWithNoTests",
|
|
75
|
+
"test:watch": "vitest",
|
|
76
|
+
"test:coverage": "vitest run --coverage"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
});
|