@dpesch/mantisbt-mcp-server 1.10.2 → 1.10.6
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/CHANGELOG.md +34 -0
- package/README.de.md +2 -1
- package/README.md +2 -1
- package/dist/client.js +0 -15
- package/dist/tools/files.js +14 -15
- package/docs/cookbook.de.md +1 -1
- package/docs/cookbook.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -344
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- package/vitest.config.ts +0 -8
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { MantisClient } from '../../src/client.js';
|
|
3
|
-
import { MetadataCache } from '../../src/cache.js';
|
|
4
|
-
import { registerResources } from '../../src/resources/index.js';
|
|
5
|
-
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Inline fixtures
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
const USER_FIXTURE = { id: 1, name: 'jsmith', real_name: 'John Smith', email: 'jsmith@example.com' };
|
|
12
|
-
|
|
13
|
-
const PROJECTS_FIXTURE = [
|
|
14
|
-
{ id: 10, name: 'Alpha', enabled: true },
|
|
15
|
-
{ id: 11, name: 'Beta', enabled: true },
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
// Simulates a raw MantisBT API response with extra fields that must be stripped
|
|
19
|
-
const PROJECTS_RAW_FIXTURE = [
|
|
20
|
-
{
|
|
21
|
-
id: 10,
|
|
22
|
-
name: 'Alpha',
|
|
23
|
-
enabled: true,
|
|
24
|
-
status: { id: 10, name: 'development', label: 'Entwicklung' },
|
|
25
|
-
view_state: { id: 10, name: 'public', label: 'Öffentlich' },
|
|
26
|
-
custom_fields: [
|
|
27
|
-
{ id: 1, name: 'Reklamieren', type: 'checkbox', default_value: '', possible_values: 'Ja' },
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const ENUM_FIXTURE = {
|
|
33
|
-
configs: [
|
|
34
|
-
{ option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
|
|
35
|
-
{ option: 'status_enum_string', value: '10:new,80:resolved,90:closed' },
|
|
36
|
-
{ option: 'priority_enum_string', value: '10:none,30:normal,60:immediate' },
|
|
37
|
-
{ option: 'resolution_enum_string', value: '10:open,20:fixed' },
|
|
38
|
-
{ option: 'reproducibility_enum_string', value: '10:always,70:have not tried' },
|
|
39
|
-
],
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Setup
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
let mockServer: MockMcpServer;
|
|
47
|
-
let client: MantisClient;
|
|
48
|
-
let cache: MetadataCache;
|
|
49
|
-
|
|
50
|
-
beforeEach(() => {
|
|
51
|
-
mockServer = new MockMcpServer();
|
|
52
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
53
|
-
cache = new MetadataCache('/tmp/cache-resources-test', 86400);
|
|
54
|
-
registerResources(mockServer as never, client, cache);
|
|
55
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterEach(async () => {
|
|
59
|
-
vi.unstubAllGlobals();
|
|
60
|
-
await cache.invalidate();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// Registration
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
describe('resource registration', () => {
|
|
68
|
-
it('registers mantis://me', () => {
|
|
69
|
-
expect(mockServer.hasResourceRegistered('mantis://me')).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('registers mantis://projects', () => {
|
|
73
|
-
expect(mockServer.hasResourceRegistered('mantis://projects')).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('registers mantis://enums', () => {
|
|
77
|
-
expect(mockServer.hasResourceRegistered('mantis://enums')).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// mantis://me
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
describe('mantis://me', () => {
|
|
86
|
-
it('returns a single content item with application/json', async () => {
|
|
87
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(USER_FIXTURE)));
|
|
88
|
-
|
|
89
|
-
const result = await mockServer.callResource('mantis://me');
|
|
90
|
-
|
|
91
|
-
expect(result.contents).toHaveLength(1);
|
|
92
|
-
expect(result.contents[0]!.mimeType).toBe('application/json');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('sets uri to mantis://me', async () => {
|
|
96
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(USER_FIXTURE)));
|
|
97
|
-
|
|
98
|
-
const result = await mockServer.callResource('mantis://me');
|
|
99
|
-
|
|
100
|
-
expect(result.contents[0]!.uri).toBe('mantis://me');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('returns valid JSON with id and name', async () => {
|
|
104
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(USER_FIXTURE)));
|
|
105
|
-
|
|
106
|
-
const result = await mockServer.callResource('mantis://me');
|
|
107
|
-
|
|
108
|
-
const parsed = JSON.parse(result.contents[0]!.text) as { id: number; name: string };
|
|
109
|
-
expect(parsed.id).toBe(USER_FIXTURE.id);
|
|
110
|
-
expect(parsed.name).toBe(USER_FIXTURE.name);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// mantis://projects
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
describe('mantis://projects', () => {
|
|
119
|
-
it('returns a single content item with application/json', async () => {
|
|
120
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_FIXTURE })));
|
|
121
|
-
|
|
122
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
123
|
-
|
|
124
|
-
expect(result.contents).toHaveLength(1);
|
|
125
|
-
expect(result.contents[0]!.mimeType).toBe('application/json');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('sets uri to mantis://projects', async () => {
|
|
129
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_FIXTURE })));
|
|
130
|
-
|
|
131
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
132
|
-
|
|
133
|
-
expect(result.contents[0]!.uri).toBe('mantis://projects');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('returns a JSON array of projects from live API when cache is empty', async () => {
|
|
137
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_FIXTURE })));
|
|
138
|
-
|
|
139
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
140
|
-
|
|
141
|
-
const parsed = JSON.parse(result.contents[0]!.text) as unknown[];
|
|
142
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
143
|
-
expect(parsed).toHaveLength(2);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('returns minified JSON (no indentation)', async () => {
|
|
147
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_FIXTURE })));
|
|
148
|
-
|
|
149
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
150
|
-
|
|
151
|
-
expect(result.contents[0]!.text).not.toContain('\n');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('strips custom_fields from live API response', async () => {
|
|
155
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_RAW_FIXTURE })));
|
|
156
|
-
|
|
157
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
158
|
-
|
|
159
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Array<Record<string, unknown>>;
|
|
160
|
-
expect(parsed[0]).not.toHaveProperty('custom_fields');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('preserves label on status and view_state from live API response', async () => {
|
|
164
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_RAW_FIXTURE })));
|
|
165
|
-
|
|
166
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
167
|
-
|
|
168
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Array<Record<string, unknown>>;
|
|
169
|
-
expect((parsed[0]!['status'] as Record<string, unknown>)['label']).toBe('Entwicklung');
|
|
170
|
-
expect((parsed[0]!['view_state'] as Record<string, unknown>)['label']).toBe('Öffentlich');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('serves from cache without calling the API when cache is valid', async () => {
|
|
174
|
-
await cache.save({
|
|
175
|
-
timestamp: Date.now(),
|
|
176
|
-
projects: PROJECTS_FIXTURE,
|
|
177
|
-
byProject: {},
|
|
178
|
-
tags: [],
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const result = await mockServer.callResource('mantis://projects');
|
|
182
|
-
|
|
183
|
-
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
184
|
-
const parsed = JSON.parse(result.contents[0]!.text) as unknown[];
|
|
185
|
-
expect(parsed).toHaveLength(2);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
// mantis://projects/{id}
|
|
191
|
-
// ---------------------------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
describe('mantis://projects/{id}', () => {
|
|
194
|
-
it('registers the template resource', () => {
|
|
195
|
-
expect(mockServer.hasResourceRegistered('mantis://projects/{id}')).toBe(true);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('returns combined project view from cache (users, versions, categories)', async () => {
|
|
199
|
-
const projectsFixture = [{ id: 10, name: 'Alpha', enabled: true }];
|
|
200
|
-
await cache.save({
|
|
201
|
-
timestamp: Date.now(),
|
|
202
|
-
projects: projectsFixture,
|
|
203
|
-
byProject: {
|
|
204
|
-
10: {
|
|
205
|
-
users: [{ id: 1, name: 'jsmith' }],
|
|
206
|
-
versions: [{ id: 5, name: '1.0.0', released: true }],
|
|
207
|
-
categories: [{ id: 3, name: 'Bugs' }],
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
tags: [],
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
const result = await mockServer.callResource('mantis://projects/10');
|
|
214
|
-
|
|
215
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, unknown>;
|
|
216
|
-
expect(parsed['id']).toBe(10);
|
|
217
|
-
expect(parsed['name']).toBe('Alpha');
|
|
218
|
-
expect(parsed['users']).toHaveLength(1);
|
|
219
|
-
expect(parsed['versions']).toHaveLength(1);
|
|
220
|
-
expect(parsed['categories']).toHaveLength(1);
|
|
221
|
-
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('fetches live data in parallel when cache is cold', async () => {
|
|
225
|
-
vi.mocked(fetch)
|
|
226
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ projects: [{ id: 10, name: 'Alpha', categories: [{ id: 3, name: 'Bugs' }] }] })))
|
|
227
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [{ id: 1, name: 'jsmith' }] })))
|
|
228
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ versions: [{ id: 5, name: '1.0.0' }] })));
|
|
229
|
-
|
|
230
|
-
const result = await mockServer.callResource('mantis://projects/10');
|
|
231
|
-
|
|
232
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, unknown>;
|
|
233
|
-
expect(parsed['id']).toBe(10);
|
|
234
|
-
expect((parsed['users'] as unknown[]).length).toBeGreaterThan(0);
|
|
235
|
-
expect((parsed['versions'] as unknown[]).length).toBeGreaterThan(0);
|
|
236
|
-
expect((parsed['categories'] as unknown[]).length).toBeGreaterThan(0);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('strips [All Projects] prefix from category names on live fetch', async () => {
|
|
240
|
-
vi.mocked(fetch)
|
|
241
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ projects: [{ id: 10, name: 'Alpha', categories: [{ id: 1, name: '[All Projects] General' }] }] })))
|
|
242
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [] })))
|
|
243
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ versions: [] })));
|
|
244
|
-
|
|
245
|
-
const result = await mockServer.callResource('mantis://projects/10');
|
|
246
|
-
|
|
247
|
-
const parsed = JSON.parse(result.contents[0]!.text) as { categories: Array<{ name: string }> };
|
|
248
|
-
expect(parsed.categories[0]!.name).toBe('General');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('returns minified JSON', async () => {
|
|
252
|
-
await cache.save({
|
|
253
|
-
timestamp: Date.now(),
|
|
254
|
-
projects: [{ id: 10, name: 'Alpha' }],
|
|
255
|
-
byProject: { 10: { users: [], versions: [], categories: [] } },
|
|
256
|
-
tags: [],
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
const result = await mockServer.callResource('mantis://projects/10');
|
|
260
|
-
|
|
261
|
-
expect(result.contents[0]!.text).not.toContain('\n');
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// ---------------------------------------------------------------------------
|
|
266
|
-
// mantis://enums
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
describe('mantis://enums', () => {
|
|
270
|
-
it('returns a single content item with application/json', async () => {
|
|
271
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
|
|
272
|
-
|
|
273
|
-
const result = await mockServer.callResource('mantis://enums');
|
|
274
|
-
|
|
275
|
-
expect(result.contents).toHaveLength(1);
|
|
276
|
-
expect(result.contents[0]!.mimeType).toBe('application/json');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('sets uri to mantis://enums', async () => {
|
|
280
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
|
|
281
|
-
|
|
282
|
-
const result = await mockServer.callResource('mantis://enums');
|
|
283
|
-
|
|
284
|
-
expect(result.contents[0]!.uri).toBe('mantis://enums');
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('returns parsed enum groups with id and name entries', async () => {
|
|
288
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
|
|
289
|
-
|
|
290
|
-
const result = await mockServer.callResource('mantis://enums');
|
|
291
|
-
|
|
292
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
|
|
293
|
-
for (const key of ['severity', 'priority', 'status', 'resolution', 'reproducibility']) {
|
|
294
|
-
expect(Array.isArray(parsed[key])).toBe(true);
|
|
295
|
-
expect(parsed[key]!.length).toBeGreaterThan(0);
|
|
296
|
-
expect(parsed[key]![0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('parses severity values correctly from fixture', async () => {
|
|
301
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
|
|
302
|
-
|
|
303
|
-
const result = await mockServer.callResource('mantis://enums');
|
|
304
|
-
|
|
305
|
-
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
|
|
306
|
-
expect(parsed['severity']).toContainEqual({ id: 10, name: 'feature' });
|
|
307
|
-
expect(parsed['severity']).toContainEqual({ id: 50, name: 'minor' });
|
|
308
|
-
});
|
|
309
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { Embedder } from '../../src/search/embedder.js';
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// Mock @huggingface/transformers (dynamic import)
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
const mockPipelineFn = vi.fn(async (texts: string | string[]) => {
|
|
9
|
-
if (Array.isArray(texts)) {
|
|
10
|
-
return texts.map(() => ({ data: new Float32Array(4).fill(0.1), dims: [1, 4] }));
|
|
11
|
-
}
|
|
12
|
-
return { data: new Float32Array(4).fill(0.1), dims: [4] };
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const mockPipelineFactory = vi.fn(async (_task: string, _model: string, _opts?: unknown) => mockPipelineFn);
|
|
16
|
-
|
|
17
|
-
vi.mock('@huggingface/transformers', () => ({
|
|
18
|
-
pipeline: mockPipelineFactory,
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
vi.clearAllMocks();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Thread configuration
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
describe('Embedder – thread configuration', () => {
|
|
30
|
-
it('passes intra_op_num_threads=1 by default', async () => {
|
|
31
|
-
const embedder = new Embedder('test-model');
|
|
32
|
-
await embedder.embed('hello');
|
|
33
|
-
|
|
34
|
-
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
35
|
-
'feature-extraction',
|
|
36
|
-
'test-model',
|
|
37
|
-
expect.objectContaining({
|
|
38
|
-
session_options: { intra_op_num_threads: 1, inter_op_num_threads: 1 },
|
|
39
|
-
}),
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('passes configured numThreads to intra_op_num_threads; inter stays 1', async () => {
|
|
44
|
-
const embedder = new Embedder('test-model', 4);
|
|
45
|
-
await embedder.embed('hello');
|
|
46
|
-
|
|
47
|
-
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
48
|
-
'feature-extraction',
|
|
49
|
-
'test-model',
|
|
50
|
-
expect.objectContaining({
|
|
51
|
-
session_options: { intra_op_num_threads: 4, inter_op_num_threads: 1 },
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('loads the pipeline only once (lazy singleton)', async () => {
|
|
57
|
-
const embedder = new Embedder('test-model', 1);
|
|
58
|
-
await embedder.embed('first');
|
|
59
|
-
await embedder.embed('second');
|
|
60
|
-
|
|
61
|
-
expect(mockPipelineFactory).toHaveBeenCalledTimes(1);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Embedder default — numThreads omitted
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
describe('Embedder – numThreads default', () => {
|
|
70
|
-
it('uses intra_op_num_threads=1 when numThreads is not passed', async () => {
|
|
71
|
-
const embedder = new Embedder('m');
|
|
72
|
-
await embedder.embed('x');
|
|
73
|
-
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
74
|
-
'feature-extraction',
|
|
75
|
-
'm',
|
|
76
|
-
expect.objectContaining({
|
|
77
|
-
session_options: expect.objectContaining({ intra_op_num_threads: 1 }),
|
|
78
|
-
}),
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { extractTerms, highlightText, extractSnippet } from '../../src/search/highlight.js';
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// extractTerms
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
describe('extractTerms', () => {
|
|
9
|
-
it('splits by whitespace and returns terms of length >= 3', () => {
|
|
10
|
-
expect(extractTerms('login error')).toEqual(['login', 'error']);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('filters out terms shorter than 3 characters', () => {
|
|
14
|
-
expect(extractTerms('a db login')).toEqual(['login']);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('deduplicates terms case-insensitively', () => {
|
|
18
|
-
const terms = extractTerms('Login login LOGIN');
|
|
19
|
-
expect(terms).toHaveLength(1);
|
|
20
|
-
expect(terms[0]!.toLowerCase()).toBe('login');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('sorts longest terms first to prevent partial-match overlap', () => {
|
|
24
|
-
const terms = extractTerms('err error errors');
|
|
25
|
-
expect(terms[0]).toBe('errors');
|
|
26
|
-
expect(terms[1]).toBe('error');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns empty array for empty query', () => {
|
|
30
|
-
expect(extractTerms('')).toEqual([]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('returns empty array when all terms are too short', () => {
|
|
34
|
-
expect(extractTerms('a ab')).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('trims whitespace', () => {
|
|
38
|
-
expect(extractTerms(' login error ')).toEqual(['login', 'error']);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// highlightText
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
describe('highlightText', () => {
|
|
47
|
-
it('wraps a matching term in **bold**', () => {
|
|
48
|
-
expect(highlightText('Login failed', ['login'])).toBe('**Login** failed');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('is case-insensitive', () => {
|
|
52
|
-
expect(highlightText('CRASH on startup', ['crash'])).toBe('**CRASH** on startup');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('highlights multiple terms', () => {
|
|
56
|
-
const result = highlightText('Login error occurred', ['login', 'error']);
|
|
57
|
-
expect(result).toBe('**Login** **error** occurred');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('returns original text when no terms match', () => {
|
|
61
|
-
expect(highlightText('Something unrelated', ['crash'])).toBe('Something unrelated');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('returns original text when terms array is empty', () => {
|
|
65
|
-
expect(highlightText('Login failed', [])).toBe('Login failed');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('does not match term as substring within a word (word-boundary-aware)', () => {
|
|
69
|
-
// "or" should NOT match inside "error"
|
|
70
|
-
expect(highlightText('error occurred', ['or'])).toBe('error occurred');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('escapes special regex characters in terms', () => {
|
|
74
|
-
// Term with special regex chars should not throw
|
|
75
|
-
expect(() => highlightText('test (foo)', ['(foo)'])).not.toThrow();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('highlights all occurrences of a term', () => {
|
|
79
|
-
const result = highlightText('login and login again', ['login']);
|
|
80
|
-
expect(result).toBe('**login** and **login** again');
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// extractSnippet
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
describe('extractSnippet', () => {
|
|
89
|
-
it('returns full text highlighted when text is short', () => {
|
|
90
|
-
const text = 'Login error on startup';
|
|
91
|
-
const result = extractSnippet(text, ['login']);
|
|
92
|
-
expect(result).toBe('**Login** error on startup');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('returns first ~300 chars around the first match for long text', () => {
|
|
96
|
-
const prefix = 'x'.repeat(200);
|
|
97
|
-
const text = `${prefix} login error ${' unrelated text'.repeat(30)}`;
|
|
98
|
-
const result = extractSnippet(text, ['login']);
|
|
99
|
-
expect(result).toContain('**login**');
|
|
100
|
-
expect(result.length).toBeLessThanOrEqual(350); // snippet + some overhead
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('centers snippet around first match', () => {
|
|
104
|
-
const padding = 'word '.repeat(60); // ~300 chars before the match
|
|
105
|
-
const text = `${padding}crash happens here ${'and more text '.repeat(30)}`;
|
|
106
|
-
const result = extractSnippet(text, ['crash']);
|
|
107
|
-
expect(result).toContain('**crash**');
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('returns first 300 chars (no highlight) when no term matches', () => {
|
|
111
|
-
const text = 'a'.repeat(600);
|
|
112
|
-
const result = extractSnippet(text, ['nomatch']);
|
|
113
|
-
expect(result).toBe('a'.repeat(300) + '…');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('returns full text (no truncation) when text is shorter than 300 chars and no match', () => {
|
|
117
|
-
const text = 'Short text with no match';
|
|
118
|
-
const result = extractSnippet(text, ['nomatch']);
|
|
119
|
-
expect(result).toBe(text);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('respects custom contextChars parameter', () => {
|
|
123
|
-
const padding = 'x'.repeat(100);
|
|
124
|
-
const text = `${padding} crash ${padding}`;
|
|
125
|
-
const result = extractSnippet(text, ['crash'], 50);
|
|
126
|
-
expect(result).toContain('**crash**');
|
|
127
|
-
expect(result.length).toBeLessThanOrEqual(150);
|
|
128
|
-
});
|
|
129
|
-
});
|