@dpesch/mantisbt-mcp-server 1.10.3 → 1.11.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.de.md +11 -8
  3. package/README.md +11 -8
  4. package/dist/config.js +13 -0
  5. package/dist/index.js +12 -12
  6. package/dist/tools/files.js +11 -2
  7. package/dist/tools/issues.js +47 -15
  8. package/dist/tools/notes.js +15 -10
  9. package/docs/cookbook.de.md +67 -0
  10. package/docs/cookbook.md +67 -0
  11. package/docs/examples.de.md +8 -0
  12. package/docs/examples.md +8 -0
  13. package/package.json +1 -1
  14. package/server.json +2 -2
  15. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  16. package/.gitea/workflows/ci.yml +0 -95
  17. package/.github/workflows/ci.yml +0 -32
  18. package/scripts/hooks/pre-push.mjs +0 -220
  19. package/scripts/init.mjs +0 -95
  20. package/scripts/record-fixtures.ts +0 -160
  21. package/tests/cache.test.ts +0 -265
  22. package/tests/client.test.ts +0 -372
  23. package/tests/config.test.ts +0 -263
  24. package/tests/fixtures/get_current_user.json +0 -40
  25. package/tests/fixtures/get_issue.json +0 -133
  26. package/tests/fixtures/get_issue_enums.json +0 -67
  27. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  28. package/tests/fixtures/get_project_categories.json +0 -157
  29. package/tests/fixtures/get_project_versions.json +0 -3
  30. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  31. package/tests/fixtures/list_issues.json +0 -95
  32. package/tests/fixtures/list_projects.json +0 -65
  33. package/tests/helpers/mock-server.ts +0 -166
  34. package/tests/helpers/search-mocks.ts +0 -84
  35. package/tests/prompts/prompts.test.ts +0 -242
  36. package/tests/resources/resources.test.ts +0 -309
  37. package/tests/search/embedder.test.ts +0 -81
  38. package/tests/search/highlight.test.ts +0 -129
  39. package/tests/search/store.test.ts +0 -193
  40. package/tests/search/sync.test.ts +0 -249
  41. package/tests/search/tools.test.ts +0 -661
  42. package/tests/tools/config.test.ts +0 -212
  43. package/tests/tools/files.test.ts +0 -343
  44. package/tests/tools/issues.test.ts +0 -1180
  45. package/tests/tools/metadata.test.ts +0 -509
  46. package/tests/tools/monitors.test.ts +0 -101
  47. package/tests/tools/projects.test.ts +0 -338
  48. package/tests/tools/relationships.test.ts +0 -177
  49. package/tests/tools/string-coercion.test.ts +0 -317
  50. package/tests/tools/users.test.ts +0 -62
  51. package/tests/utils/date-filter.test.ts +0 -169
  52. package/tests/version-hint.test.ts +0 -230
  53. package/tsconfig.build.json +0 -8
  54. 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
- });