onbuzz 3.6.1 → 3.6.2

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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/__test-utils__/fixtures/malformedJson.js +31 -0
  3. package/src/__test-utils__/globalSetup.js +9 -0
  4. package/src/__test-utils__/globalTeardown.js +12 -0
  5. package/src/__test-utils__/mockFactories.js +101 -0
  6. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -0
  7. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -0
  8. package/src/analyzers/__tests__/ESLintAnalyzer.test.js +271 -0
  9. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -0
  10. package/src/analyzers/__tests__/PrettierFormatter.test.js +197 -0
  11. package/src/analyzers/__tests__/PythonAnalyzer.test.js +208 -0
  12. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -0
  13. package/src/analyzers/__tests__/SparrowAnalyzer.test.js +270 -0
  14. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -0
  15. package/src/core/__tests__/agentPool.test.js +601 -0
  16. package/src/core/__tests__/agentScheduler.test.js +576 -0
  17. package/src/core/__tests__/contextManager.test.js +252 -0
  18. package/src/core/__tests__/flowExecutor.test.js +262 -0
  19. package/src/core/__tests__/messageProcessor.test.js +627 -0
  20. package/src/core/__tests__/orchestrator.test.js +257 -0
  21. package/src/core/__tests__/stateManager.test.js +375 -0
  22. package/src/core/agentPool.js +11 -1
  23. package/src/index.js +25 -9
  24. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +3 -5
  25. package/src/services/__tests__/agentActivityService.test.js +319 -0
  26. package/src/services/__tests__/apiKeyManager.test.js +206 -0
  27. package/src/services/__tests__/benchmarkService.test.js +184 -0
  28. package/src/services/__tests__/budgetService.test.js +211 -0
  29. package/src/services/__tests__/contextInjectionService.test.js +205 -0
  30. package/src/services/__tests__/conversationCompactionService.test.js +280 -0
  31. package/src/services/__tests__/credentialVault.test.js +469 -0
  32. package/src/services/__tests__/errorHandler.test.js +314 -0
  33. package/src/services/__tests__/fileAttachmentService.test.js +278 -0
  34. package/src/services/__tests__/flowContextService.test.js +199 -0
  35. package/src/services/__tests__/memoryService.test.js +450 -0
  36. package/src/services/__tests__/modelRouterService.test.js +388 -0
  37. package/src/services/__tests__/modelsService.test.js +261 -0
  38. package/src/services/__tests__/portRegistry.test.js +123 -0
  39. package/src/services/__tests__/projectDetector.test.js +34 -0
  40. package/src/services/__tests__/promptService.test.js +242 -0
  41. package/src/services/__tests__/qualityInspector.test.js +97 -0
  42. package/src/services/__tests__/scheduleService.test.js +308 -0
  43. package/src/services/__tests__/serviceRegistry.test.js +74 -0
  44. package/src/services/__tests__/skillsService.test.js +402 -0
  45. package/src/services/__tests__/tokenCountingService.test.js +48 -0
  46. package/src/tools/__tests__/agentCommunicationTool.test.js +500 -0
  47. package/src/tools/__tests__/agentDelayTool.test.js +342 -0
  48. package/src/tools/__tests__/asyncToolManager.test.js +344 -0
  49. package/src/tools/__tests__/baseTool.test.js +420 -0
  50. package/src/tools/__tests__/codeMapTool.test.js +348 -0
  51. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -0
  52. package/src/tools/__tests__/fileTreeTool.test.js +274 -0
  53. package/src/tools/__tests__/filesystemTool.test.js +717 -0
  54. package/src/tools/__tests__/helpTool.test.js +204 -0
  55. package/src/tools/__tests__/jobDoneTool.test.js +296 -0
  56. package/src/tools/__tests__/memoryTool.test.js +297 -0
  57. package/src/tools/__tests__/seekTool.test.js +282 -0
  58. package/src/tools/__tests__/skillsTool.test.js +226 -0
  59. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -0
  60. package/src/tools/__tests__/taskManagerTool.test.js +725 -0
  61. package/src/tools/__tests__/terminalTool.test.js +384 -0
  62. package/src/tools/__tests__/userPromptTool.test.js +297 -0
  63. package/src/tools/__tests__/webTool.e2e.test.js +25 -11
  64. package/src/tools/webTool.js +6 -12
  65. package/src/types/__tests__/agent.test.js +499 -0
  66. package/src/types/__tests__/contextReference.test.js +606 -0
  67. package/src/types/__tests__/conversation.test.js +555 -0
  68. package/src/types/__tests__/toolCommand.test.js +584 -0
  69. package/src/types/contextReference.js +1 -1
  70. package/src/utilities/__tests__/attachmentValidator.test.js +80 -0
  71. package/src/utilities/__tests__/configManager.test.js +397 -0
  72. package/src/utilities/__tests__/constants.test.js +49 -0
  73. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -0
  74. package/src/utilities/__tests__/fileProcessor.test.js +104 -0
  75. package/src/utilities/__tests__/jsonRepair.test.js +104 -0
  76. package/src/utilities/__tests__/logger.test.js +129 -0
  77. package/src/utilities/__tests__/platformUtils.test.js +87 -0
  78. package/src/utilities/__tests__/structuredFileValidator.test.js +263 -0
  79. package/src/utilities/__tests__/tagParser.test.js +887 -0
  80. package/src/utilities/__tests__/toolConstants.test.js +94 -0
  81. package/src/utilities/tagParser.js +2 -2
  82. package/src/tools/browserTool.js +0 -897
  83. package/src/utilities/platformUtils.test.js +0 -98
@@ -0,0 +1,348 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock fs before import
5
+ const mockFsPromises = {
6
+ stat: jest.fn(),
7
+ readFile: jest.fn(),
8
+ readdir: jest.fn()
9
+ };
10
+
11
+ jest.unstable_mockModule('fs', () => ({
12
+ default: { promises: mockFsPromises, readFileSync: jest.fn(() => { throw new Error('no file'); }) },
13
+ promises: mockFsPromises,
14
+ readFileSync: jest.fn(() => { throw new Error('no file'); })
15
+ }));
16
+
17
+ // Mock constants
18
+ jest.unstable_mockModule('../../utilities/constants.js', () => ({
19
+ TOOL_STATUS: { PENDING: 'pending', EXECUTING: 'executing', COMPLETED: 'completed', FAILED: 'failed' },
20
+ OPERATION_STATUS: { NOT_FOUND: 'not_found' },
21
+ ERROR_TYPES: {},
22
+ SYSTEM_DEFAULTS: { MAX_TOOL_EXECUTION_TIME: 300000 }
23
+ }));
24
+
25
+ const { default: CodeMapTool } = await import('../codeMapTool.js');
26
+
27
+ describe('CodeMapTool', () => {
28
+ let tool;
29
+ let logger;
30
+ const context = { projectDir: '/project', agentId: 'agent-1' };
31
+
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ logger = createMockLogger();
35
+ tool = new CodeMapTool({}, logger);
36
+ });
37
+
38
+ test('constructor sets metadata correctly', () => {
39
+ expect(tool.id).toBe('code-map');
40
+ expect(tool.requiresProject).toBe(true);
41
+ expect(tool.isAsync).toBe(true);
42
+ });
43
+
44
+ test('getDescription mentions skeleton and read-range', () => {
45
+ const desc = tool.getDescription();
46
+ expect(desc).toContain('skeleton');
47
+ expect(desc).toContain('read-range');
48
+ });
49
+
50
+ test('getRequiredParameters returns action', () => {
51
+ expect(tool.getRequiredParameters()).toEqual(['action']);
52
+ });
53
+
54
+ test('parseParameters parses JSON content', () => {
55
+ const content = JSON.stringify({
56
+ action: 'skeleton',
57
+ path: 'src/',
58
+ level: 'B.0'
59
+ });
60
+ const result = tool.parseParameters(content);
61
+ expect(result.action).toBe('skeleton');
62
+ expect(result.path).toBe('src/');
63
+ expect(result.level).toBe('B.0');
64
+ });
65
+
66
+ test('parseParameters parses nested parameters JSON', () => {
67
+ const content = JSON.stringify({
68
+ parameters: { action: 'read-range', filePath: 'index.js', startLine: 1, endLine: 10 }
69
+ });
70
+ const result = tool.parseParameters(content);
71
+ expect(result.action).toBe('read-range');
72
+ expect(result.filePath).toBe('index.js');
73
+ expect(result.startLine).toBe(1);
74
+ expect(result.endLine).toBe(10);
75
+ });
76
+
77
+ test('parseParameters parses XML content', () => {
78
+ const content = '<action>skeleton</action><path>src/</path><level>A.0</level>';
79
+ const result = tool.parseParameters(content);
80
+ expect(result.action).toBe('skeleton');
81
+ expect(result.path).toBe('src/');
82
+ expect(result.level).toBe('A.0');
83
+ });
84
+
85
+ test('parseParameters returns parseError on bad JSON', () => {
86
+ const result = tool.parseParameters('{ broken');
87
+ expect(result).toHaveProperty('parseError');
88
+ });
89
+
90
+ test('customValidateParameters rejects missing action', () => {
91
+ const result = tool.customValidateParameters({});
92
+ expect(result.valid).toBe(false);
93
+ });
94
+
95
+ test('customValidateParameters rejects invalid action', () => {
96
+ const result = tool.customValidateParameters({ action: 'invalid' });
97
+ expect(result.valid).toBe(false);
98
+ });
99
+
100
+ test('customValidateParameters requires path for skeleton', () => {
101
+ const result = tool.customValidateParameters({ action: 'skeleton' });
102
+ expect(result.valid).toBe(false);
103
+ expect(result.errors.some(e => e.includes('path'))).toBe(true);
104
+ });
105
+
106
+ test('customValidateParameters rejects invalid level', () => {
107
+ const result = tool.customValidateParameters({ action: 'skeleton', path: 'src/', level: 'X.9' });
108
+ expect(result.valid).toBe(false);
109
+ });
110
+
111
+ test('customValidateParameters requires filePath/startLine/endLine for read-range', () => {
112
+ const result = tool.customValidateParameters({ action: 'read-range' });
113
+ expect(result.valid).toBe(false);
114
+ expect(result.errors.length).toBeGreaterThanOrEqual(3);
115
+ });
116
+
117
+ test('customValidateParameters rejects endLine < startLine', () => {
118
+ const result = tool.customValidateParameters({
119
+ action: 'read-range', filePath: 'a.js', startLine: 10, endLine: 5
120
+ });
121
+ expect(result.valid).toBe(false);
122
+ });
123
+
124
+ test('customValidateParameters rejects range exceeding max', () => {
125
+ const result = tool.customValidateParameters({
126
+ action: 'read-range', filePath: 'a.js', startLine: 1, endLine: 600
127
+ });
128
+ expect(result.valid).toBe(false);
129
+ });
130
+
131
+ test('customValidateParameters accepts valid skeleton params', () => {
132
+ const result = tool.customValidateParameters({ action: 'skeleton', path: 'src/' });
133
+ expect(result.valid).toBe(true);
134
+ });
135
+
136
+ test('execute skeleton on single JS file', async () => {
137
+ const jsContent = [
138
+ 'import express from "express";',
139
+ '',
140
+ 'export class App {',
141
+ ' constructor() {}',
142
+ ' start() {',
143
+ ' console.log("started");',
144
+ ' }',
145
+ '}',
146
+ '',
147
+ 'export function main() {',
148
+ ' return new App();',
149
+ '}'
150
+ ].join('\n');
151
+
152
+ mockFsPromises.stat.mockResolvedValue({
153
+ isFile: () => true,
154
+ isDirectory: () => false,
155
+ size: jsContent.length
156
+ });
157
+ mockFsPromises.readFile.mockResolvedValue(jsContent);
158
+
159
+ const result = await tool.execute(
160
+ { action: 'skeleton', path: 'src/app.js', level: 'B.0' },
161
+ context
162
+ );
163
+
164
+ expect(result.success).toBe(true);
165
+ expect(result.action).toBe('skeleton');
166
+ expect(result.totalFiles).toBeGreaterThanOrEqual(1);
167
+ expect(result.totalEntries).toBeGreaterThanOrEqual(1);
168
+ });
169
+
170
+ test('execute skeleton on directory with JS files', async () => {
171
+ // First stat: directory check
172
+ mockFsPromises.stat
173
+ .mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true }) // path stat
174
+ .mockResolvedValueOnce({ size: 100 }); // file stat
175
+
176
+ // Discover files - readdir for root
177
+ mockFsPromises.readdir.mockResolvedValueOnce([
178
+ { name: 'index.js', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false }
179
+ ]);
180
+
181
+ mockFsPromises.readFile
182
+ .mockRejectedValueOnce(new Error('no .gitignore')) // _loadGitignoreRules
183
+ .mockResolvedValueOnce('export function hello() { return 1; }\n'); // file content
184
+
185
+ const result = await tool.execute(
186
+ { action: 'skeleton', path: 'src/', level: 'A.0' },
187
+ context
188
+ );
189
+
190
+ expect(result.success).toBe(true);
191
+ expect(result.action).toBe('skeleton');
192
+ });
193
+
194
+ test('execute skeleton returns empty when no supported files', async () => {
195
+ mockFsPromises.stat.mockResolvedValue({ isFile: () => false, isDirectory: () => true });
196
+ mockFsPromises.readdir.mockResolvedValue([]);
197
+ mockFsPromises.readFile.mockRejectedValue(new Error('no file'));
198
+
199
+ const result = await tool.execute(
200
+ { action: 'skeleton', path: 'empty/' },
201
+ context
202
+ );
203
+
204
+ expect(result.success).toBe(true);
205
+ expect(result.totalFiles).toBe(0);
206
+ expect(result.message).toContain('No supported files');
207
+ });
208
+
209
+ test('execute skeleton throws for non-existent path', async () => {
210
+ mockFsPromises.stat.mockRejectedValue(new Error('ENOENT'));
211
+
212
+ await expect(tool.execute(
213
+ { action: 'skeleton', path: 'missing/' },
214
+ context
215
+ )).rejects.toThrow('Path not found');
216
+ });
217
+
218
+ test('execute read-range returns formatted lines', async () => {
219
+ const content = 'line1\nline2\nline3\nline4\nline5\n';
220
+ mockFsPromises.readFile.mockResolvedValue(content);
221
+
222
+ const result = await tool.execute(
223
+ { action: 'read-range', filePath: 'src/index.js', startLine: 2, endLine: 4 },
224
+ context
225
+ );
226
+
227
+ expect(result.success).toBe(true);
228
+ expect(result.action).toBe('read-range');
229
+ expect(result.linesReturned).toBe(3);
230
+ expect(result.content).toContain('line2');
231
+ expect(result.content).toContain('line3');
232
+ expect(result.content).toContain('line4');
233
+ });
234
+
235
+ test('execute read-range throws when startLine exceeds file length', async () => {
236
+ mockFsPromises.readFile.mockResolvedValue('line1\nline2\n');
237
+
238
+ await expect(tool.execute(
239
+ { action: 'read-range', filePath: 'a.js', startLine: 100, endLine: 110 },
240
+ context
241
+ )).rejects.toThrow('exceeds file length');
242
+ });
243
+
244
+ test('execute read-range throws for missing file', async () => {
245
+ mockFsPromises.readFile.mockRejectedValue(new Error('ENOENT'));
246
+
247
+ await expect(tool.execute(
248
+ { action: 'read-range', filePath: 'missing.js', startLine: 1, endLine: 5 },
249
+ context
250
+ )).rejects.toThrow('File not found');
251
+ });
252
+
253
+ test('execute throws on unknown action', async () => {
254
+ await expect(tool.execute(
255
+ { action: 'unknown' },
256
+ context
257
+ )).rejects.toThrow('Unknown action');
258
+ });
259
+
260
+ test('_langOf detects python files', () => {
261
+ expect(tool._langOf('script.py')).toBe('python');
262
+ expect(tool._langOf('app.js')).toBe('js');
263
+ expect(tool._langOf('component.tsx')).toBe('js');
264
+ });
265
+
266
+ test('_parseJS extracts exported functions', () => {
267
+ const lines = [
268
+ 'export function hello() {',
269
+ ' return 1;',
270
+ '}'
271
+ ];
272
+ const entries = tool._parseJS(lines, { publicOnly: true, withComments: false, includeImports: false });
273
+ expect(entries.length).toBeGreaterThanOrEqual(1);
274
+ expect(entries[0].kind).toBe('signature');
275
+ });
276
+
277
+ test('_parseJS extracts imports when includeImports is true', () => {
278
+ const lines = [
279
+ 'import express from "express";',
280
+ 'const x = require("path");',
281
+ 'export function hello() {}'
282
+ ];
283
+ const entries = tool._parseJS(lines, { publicOnly: false, withComments: false, includeImports: true });
284
+ const imports = entries.filter(e => e.kind === 'import');
285
+ expect(imports.length).toBe(2);
286
+ });
287
+
288
+ test('_parseJS includes comments when withComments is true', () => {
289
+ const lines = [
290
+ '/** My doc */',
291
+ 'export function hello() {}'
292
+ ];
293
+ const entries = tool._parseJS(lines, { publicOnly: false, withComments: true, includeImports: false });
294
+ const comments = entries.filter(e => e.kind === 'comment');
295
+ expect(comments.length).toBeGreaterThanOrEqual(1);
296
+ });
297
+
298
+ test('_parsePython extracts def and class', () => {
299
+ const lines = [
300
+ 'class MyClass:',
301
+ ' def __init__(self):',
302
+ ' pass',
303
+ '',
304
+ 'def public_func():',
305
+ ' return 1'
306
+ ];
307
+ const entries = tool._parsePython(lines, { publicOnly: false, withComments: false, includeImports: false });
308
+ const sigs = entries.filter(e => e.kind === 'signature');
309
+ expect(sigs.length).toBeGreaterThanOrEqual(2);
310
+ });
311
+
312
+ test('_parsePython respects publicOnly', () => {
313
+ const lines = [
314
+ 'def public_func():',
315
+ ' pass',
316
+ 'def _private_func():',
317
+ ' pass'
318
+ ];
319
+ const entries = tool._parsePython(lines, { publicOnly: true, withComments: false, includeImports: false });
320
+ const sigs = entries.filter(e => e.kind === 'signature');
321
+ expect(sigs.length).toBe(1);
322
+ expect(sigs[0].text).toContain('public_func');
323
+ });
324
+
325
+ test('_parseGitignore parses rules', () => {
326
+ const content = '# comment\nnode_modules/\n*.log\n!important.log';
327
+ const rules = tool._parseGitignore(content, '');
328
+ expect(rules.length).toBe(3);
329
+ expect(rules[2].negate).toBe(true);
330
+ });
331
+
332
+ test('_gitignorePatternToRegex handles ** patterns', () => {
333
+ const re = tool._gitignorePatternToRegex('**/test');
334
+ expect(re).toContain('(.+/)?');
335
+ });
336
+
337
+ test('execute skeleton on unsupported file type throws', async () => {
338
+ mockFsPromises.stat.mockResolvedValue({
339
+ isFile: () => true,
340
+ isDirectory: () => false
341
+ });
342
+
343
+ await expect(tool.execute(
344
+ { action: 'skeleton', path: 'data.json' },
345
+ context
346
+ )).rejects.toThrow('Unsupported file type');
347
+ });
348
+ });
@@ -0,0 +1,309 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock fs/promises
5
+ const fsMock = {
6
+ readFile: jest.fn(),
7
+ writeFile: jest.fn().mockResolvedValue(undefined),
8
+ stat: jest.fn().mockResolvedValue({ size: 1000 }),
9
+ access: jest.fn().mockResolvedValue(undefined)
10
+ };
11
+ jest.unstable_mockModule('fs', () => ({
12
+ promises: fsMock,
13
+ default: { promises: fsMock }
14
+ }));
15
+
16
+ const { default: FileContentReplaceTool } = await import('../fileContentReplaceTool.js');
17
+
18
+ describe('FileContentReplaceTool', () => {
19
+ let tool;
20
+ let logger;
21
+
22
+ beforeEach(() => {
23
+ logger = createMockLogger();
24
+ tool = new FileContentReplaceTool({}, logger);
25
+ jest.clearAllMocks();
26
+ });
27
+
28
+ describe('constructor', () => {
29
+ test('should set correct id and metadata', () => {
30
+ expect(tool.id).toBe('file-content-replace');
31
+ expect(tool.requiresProject).toBe(true);
32
+ expect(tool.isAsync).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe('getDescription', () => {
37
+ test('should return description with usage info', () => {
38
+ const desc = tool.getDescription();
39
+ expect(desc).toContain('File Content Replace');
40
+ expect(desc).toContain('oldContent');
41
+ expect(desc).toContain('newContent');
42
+ });
43
+ });
44
+
45
+ describe('getRequiredParameters', () => {
46
+ test('should require files', () => {
47
+ expect(tool.getRequiredParameters()).toContain('files');
48
+ });
49
+ });
50
+
51
+ describe('parseParameters', () => {
52
+ test('should parse JSON format', () => {
53
+ const json = JSON.stringify({
54
+ files: [{
55
+ path: 'test.js',
56
+ replacements: [{ oldContent: 'old', newContent: 'new' }]
57
+ }]
58
+ });
59
+ const result = tool.parseParameters(json);
60
+ expect(result.files).toHaveLength(1);
61
+ expect(result.files[0].replacements[0].mode).toBe('trim');
62
+ });
63
+
64
+ test('should parse XML format', () => {
65
+ const xml = `<file path="test.js"><replace><old-content>old</old-content><new-content>new</new-content></replace></file>`;
66
+ const result = tool.parseParameters(xml);
67
+ expect(result.files).toHaveLength(1);
68
+ expect(result.files[0].path).toBe('test.js');
69
+ });
70
+
71
+ test('should throw on invalid JSON', () => {
72
+ expect(() => tool.parseParameters('{invalid}')).toThrow();
73
+ });
74
+ });
75
+
76
+ describe('parseJSON', () => {
77
+ test('should set default mode to trim', () => {
78
+ const result = tool.parseJSON(JSON.stringify({
79
+ files: [{ path: 'a.js', replacements: [{ oldContent: 'x', newContent: 'y' }] }]
80
+ }));
81
+ expect(result.files[0].replacements[0].mode).toBe('trim');
82
+ });
83
+
84
+ test('should throw when files is not an array', () => {
85
+ expect(() => tool.parseJSON('{"files": "not-array"}')).toThrow('files');
86
+ });
87
+ });
88
+
89
+ describe('applyTrimMode', () => {
90
+ test('should trim whitespace in trim mode', () => {
91
+ expect(tool.applyTrimMode(' hello ', 'trim')).toBe('hello');
92
+ });
93
+
94
+ test('should only trim newlines in newlines mode', () => {
95
+ expect(tool.applyTrimMode('\n hello \n', 'newlines')).toBe(' hello ');
96
+ });
97
+
98
+ test('should not modify in none mode', () => {
99
+ expect(tool.applyTrimMode(' hello ', 'none')).toBe(' hello ');
100
+ });
101
+ });
102
+
103
+ describe('countOccurrences', () => {
104
+ test('should count multiple occurrences', () => {
105
+ expect(tool.countOccurrences('abcabcabc', 'abc')).toBe(3);
106
+ });
107
+
108
+ test('should return 0 for no matches', () => {
109
+ expect(tool.countOccurrences('hello', 'xyz')).toBe(0);
110
+ });
111
+
112
+ test('should return 0 for empty substring', () => {
113
+ expect(tool.countOccurrences('hello', '')).toBe(0);
114
+ });
115
+ });
116
+
117
+ describe('parseLineRanges', () => {
118
+ test('should parse single line number', () => {
119
+ const result = tool.parseLineRanges('5');
120
+ expect(result.has(5)).toBe(true);
121
+ expect(result.size).toBe(1);
122
+ });
123
+
124
+ test('should parse comma-separated numbers', () => {
125
+ const result = tool.parseLineRanges('1,3,5');
126
+ expect(result.has(1)).toBe(true);
127
+ expect(result.has(3)).toBe(true);
128
+ expect(result.has(5)).toBe(true);
129
+ });
130
+
131
+ test('should parse ranges', () => {
132
+ const result = tool.parseLineRanges('5-8');
133
+ expect(result.has(5)).toBe(true);
134
+ expect(result.has(6)).toBe(true);
135
+ expect(result.has(7)).toBe(true);
136
+ expect(result.has(8)).toBe(true);
137
+ });
138
+
139
+ test('should parse mixed format', () => {
140
+ const result = tool.parseLineRanges('1,3-5,10');
141
+ expect(result.size).toBe(5);
142
+ });
143
+
144
+ test('should handle empty string', () => {
145
+ const result = tool.parseLineRanges('');
146
+ expect(result.size).toBe(0);
147
+ });
148
+
149
+ test('should handle null', () => {
150
+ const result = tool.parseLineRanges(null);
151
+ expect(result.size).toBe(0);
152
+ });
153
+ });
154
+
155
+ describe('applyReplacement', () => {
156
+ test('should replace all occurrences without line limit', async () => {
157
+ const result = await tool.applyReplacement('hello world hello', 'hello', 'hi', null, 'none');
158
+ expect(result.newContent).toBe('hi world hi');
159
+ expect(result.count).toBe(2);
160
+ });
161
+
162
+ test('should return 0 count when content not found', async () => {
163
+ const result = await tool.applyReplacement('hello world', 'xyz', 'abc', null, 'none');
164
+ expect(result.count).toBe(0);
165
+ expect(result.newContent).toBe('hello world');
166
+ });
167
+
168
+ test('should respect line limits', async () => {
169
+ const content = 'line1 old\nline2 old\nline3 old';
170
+ const result = await tool.applyReplacement(content, 'old', 'new', '2', 'none');
171
+ expect(result.count).toBe(1);
172
+ expect(result.newContent).toBe('line1 old\nline2 new\nline3 old');
173
+ });
174
+ });
175
+
176
+ describe('generateDiff', () => {
177
+ test('should return "No differences" for identical content', () => {
178
+ const result = tool.generateDiff('hello', 'hello');
179
+ expect(result).toBe('No differences');
180
+ });
181
+
182
+ test('should show changed lines', () => {
183
+ const original = 'line1\nold line\nline3';
184
+ const modified = 'line1\nnew line\nline3';
185
+ const result = tool.generateDiff(original, modified);
186
+ expect(result).toContain('- old line');
187
+ expect(result).toContain('+ new line');
188
+ });
189
+ });
190
+
191
+ describe('generateSummary', () => {
192
+ test('should format stats correctly', () => {
193
+ const summary = tool.generateSummary({
194
+ filesProcessed: 3,
195
+ filesModified: 2,
196
+ totalReplacements: 5,
197
+ backupsCreated: 2,
198
+ errors: 0
199
+ });
200
+ expect(summary).toContain('3 file(s)');
201
+ expect(summary).toContain('5');
202
+ });
203
+ });
204
+
205
+ describe('customValidateParameters', () => {
206
+ test('should reject non-array files', () => {
207
+ expect(() => tool.customValidateParameters({ files: 'not-array' })).toThrow();
208
+ });
209
+
210
+ test('should reject empty files array', () => {
211
+ expect(() => tool.customValidateParameters({ files: [] })).toThrow();
212
+ });
213
+
214
+ test('should reject file without path', () => {
215
+ expect(() => tool.customValidateParameters({
216
+ files: [{ replacements: [{ oldContent: 'a', newContent: 'b' }] }]
217
+ })).toThrow();
218
+ });
219
+
220
+ test('should reject path traversal', () => {
221
+ expect(() => tool.customValidateParameters({
222
+ files: [{ path: '../secret/file.js', replacements: [{ oldContent: 'a', newContent: 'b' }] }]
223
+ })).toThrow('traversal');
224
+ });
225
+
226
+ test('should reject invalid mode', () => {
227
+ expect(() => tool.customValidateParameters({
228
+ files: [{ path: 'a.js', replacements: [{ oldContent: 'a', newContent: 'b', mode: 'invalid' }] }]
229
+ })).toThrow('Invalid mode');
230
+ });
231
+
232
+ test('should accept valid params', () => {
233
+ const result = tool.customValidateParameters({
234
+ files: [{ path: 'a.js', replacements: [{ oldContent: 'a', newContent: 'b' }] }]
235
+ });
236
+ expect(result.valid).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe('isPathAccessible', () => {
241
+ test('should allow paths within working directory', () => {
242
+ const result = tool.isPathAccessible('/project/src/app.js', '/project', {});
243
+ expect(result).toBe(true);
244
+ });
245
+
246
+ test('should reject paths outside working directory', () => {
247
+ const result = tool.isPathAccessible('/other/secret.js', '/project', {});
248
+ expect(result).toBe(false);
249
+ });
250
+
251
+ test('should allow paths in writeEnabledDirectories', () => {
252
+ const result = tool.isPathAccessible('/shared/file.js', '/project', {
253
+ writeEnabledDirectories: ['/shared']
254
+ });
255
+ expect(result).toBe(true);
256
+ });
257
+ });
258
+
259
+ describe('execute', () => {
260
+ test('should process files and return results', async () => {
261
+ fsMock.access.mockResolvedValue(undefined);
262
+ fsMock.stat.mockResolvedValue({ size: 100 });
263
+ fsMock.readFile.mockResolvedValue('const x = "old";');
264
+ fsMock.writeFile.mockResolvedValue(undefined);
265
+
266
+ const result = await tool.execute({
267
+ files: [{
268
+ path: 'test.js',
269
+ replacements: [{ oldContent: 'old', newContent: 'new', mode: 'none' }]
270
+ }]
271
+ }, { projectDir: '/project' });
272
+
273
+ expect(result.success).toBe(true);
274
+ expect(result.statistics.totalReplacements).toBe(1);
275
+ });
276
+
277
+ test('should handle string params by parsing', async () => {
278
+ fsMock.access.mockResolvedValue(undefined);
279
+ fsMock.stat.mockResolvedValue({ size: 100 });
280
+ fsMock.readFile.mockResolvedValue('hello old world');
281
+ fsMock.writeFile.mockResolvedValue(undefined);
282
+
283
+ const result = await tool.execute(
284
+ JSON.stringify({ files: [{ path: 'a.js', replacements: [{ oldContent: 'old', newContent: 'new' }] }] }),
285
+ { projectDir: '/project' }
286
+ );
287
+ expect(result.success).toBe(true);
288
+ });
289
+
290
+ test('should handle file not found error', async () => {
291
+ fsMock.access.mockRejectedValue(new Error('ENOENT'));
292
+
293
+ const result = await tool.execute({
294
+ files: [{
295
+ path: 'missing.js',
296
+ replacements: [{ oldContent: 'a', newContent: 'b', mode: 'none' }]
297
+ }]
298
+ }, { projectDir: '/project' });
299
+
300
+ expect(result.statistics.errors).toBe(1);
301
+ });
302
+ });
303
+
304
+ describe('cleanup', () => {
305
+ test('should complete without error', async () => {
306
+ await expect(tool.cleanup('op-1')).resolves.not.toThrow();
307
+ });
308
+ });
309
+ });