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