onbuzz 3.6.1 → 3.6.3

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 (84) 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__/fileSystemTool.test.js +717 -0
  53. package/src/tools/__tests__/fileTreeTool.test.js +274 -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
  84. /package/src/tools/{filesystemTool.js → fileSystemTool.js} +0 -0
@@ -0,0 +1,717 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // ── Mock fs/promises BEFORE importing FileSystemTool ──────────────
5
+ const fsMock = {
6
+ readFile: jest.fn(),
7
+ writeFile: jest.fn().mockResolvedValue(undefined),
8
+ appendFile: jest.fn().mockResolvedValue(undefined),
9
+ unlink: jest.fn().mockResolvedValue(undefined),
10
+ mkdir: jest.fn().mockResolvedValue(undefined),
11
+ readdir: jest.fn().mockResolvedValue([]),
12
+ stat: jest.fn(),
13
+ access: jest.fn(),
14
+ copyFile: jest.fn().mockResolvedValue(undefined),
15
+ rename: jest.fn().mockResolvedValue(undefined)
16
+ };
17
+
18
+ jest.unstable_mockModule('fs/promises', () => ({ default: fsMock, ...fsMock }));
19
+
20
+ // ── Mock TagParser ────────────────────────────────────────────────
21
+ jest.unstable_mockModule('../../utilities/tagParser.js', () => ({
22
+ default: class MockTagParser {
23
+ static extractTagsWithAttributes() { return []; }
24
+ parseAttributes(str) {
25
+ const attrs = {};
26
+ const matches = str.matchAll(/([\w-]+)=["']([^"']+)["']/g);
27
+ for (const m of matches) attrs[m[1]] = m[2];
28
+ return attrs;
29
+ }
30
+ }
31
+ }));
32
+
33
+ // ── Mock DirectoryAccessManager ───────────────────────────────────
34
+ jest.unstable_mockModule('../../utilities/directoryAccessManager.js', () => ({
35
+ default: class MockDAM {
36
+ constructor() {}
37
+ createDirectoryAccess(cfg) { return cfg; }
38
+ getWorkingDirectory(cfg) { return cfg?.workingDirectory || '/tmp/test'; }
39
+ validateReadAccess() { return { allowed: true }; }
40
+ validateWriteAccess() { return { allowed: true }; }
41
+ createRelativePath(p) { return p.replace(/^\/tmp\/test\/?/, '') || p; }
42
+ }
43
+ }));
44
+
45
+ // ── Mock structuredFileValidator ──────────────────────────────────
46
+ jest.unstable_mockModule('../../utilities/structuredFileValidator.js', () => ({
47
+ validateForToolResponse: jest.fn().mockReturnValue(null)
48
+ }));
49
+
50
+ // ── Mock jsonRepair ───────────────────────────────────────────────
51
+ jest.unstable_mockModule('../../utilities/jsonRepair.js', () => ({
52
+ createTruncationNotice: jest.fn().mockReturnValue(null),
53
+ getFileExtension: jest.fn((p) => {
54
+ const m = p.match(/\.([^.]+)$/);
55
+ return m ? m[1] : '';
56
+ })
57
+ }));
58
+
59
+ // ── Mock constants ────────────────────────────────────────────────
60
+ jest.unstable_mockModule('../../utilities/constants.js', () => ({
61
+ TOOL_STATUS: { SUCCESS: 'success', ERROR: 'error' },
62
+ FILE_EXTENSIONS: {},
63
+ SYSTEM_DEFAULTS: { MAX_FILE_SIZE: 10 * 1024 * 1024, MAX_TOOL_EXECUTION_TIME: 30000 }
64
+ }));
65
+
66
+ // ── Mock BaseTool ─────────────────────────────────────────────────
67
+ jest.unstable_mockModule('../baseTool.js', () => ({
68
+ BaseTool: class {
69
+ constructor() {
70
+ this.id = 'filesystem';
71
+ this.config = {};
72
+ this.logger = null;
73
+ this.requiresProject = false;
74
+ this.isAsync = false;
75
+ this.timeout = 30000;
76
+ this.maxConcurrentOperations = 1;
77
+ this.builtinDelay = 0;
78
+ this.activeOperations = new Map();
79
+ this.operationHistory = [];
80
+ this.isEnabled = true;
81
+ this.lastUsed = null;
82
+ this.usageCount = 0;
83
+ }
84
+ }
85
+ }));
86
+
87
+ const { default: FileSystemTool } = await import('../fileSystemTool.js');
88
+
89
+ // ── Helpers ───────────────────────────────────────────────────────
90
+ function createTestSetup() {
91
+ const logger = createMockLogger();
92
+ const tool = new FileSystemTool({}, logger);
93
+ tool.logger = logger;
94
+
95
+ const context = {
96
+ projectDir: '/tmp/test',
97
+ agentId: 'test-agent',
98
+ directoryAccess: {
99
+ workingDirectory: '/tmp/test',
100
+ writeEnabledDirectories: ['/tmp/test'],
101
+ restrictToProject: true
102
+ }
103
+ };
104
+
105
+ return { tool, context, logger };
106
+ }
107
+
108
+ function mockStatResult(overrides = {}) {
109
+ return {
110
+ size: 100,
111
+ mtime: new Date('2024-01-01'),
112
+ atime: new Date('2024-01-01'),
113
+ birthtime: new Date('2024-01-01'),
114
+ mode: 0o644,
115
+ isDirectory: () => false,
116
+ isFile: () => true,
117
+ isSymbolicLink: () => false,
118
+ ...overrides
119
+ };
120
+ }
121
+
122
+ beforeEach(() => {
123
+ jest.clearAllMocks();
124
+ // Default happy-path mocks
125
+ fsMock.stat.mockResolvedValue(mockStatResult());
126
+ fsMock.readFile.mockResolvedValue('file content');
127
+ fsMock.access.mockResolvedValue(undefined);
128
+ fsMock.readdir.mockResolvedValue([]);
129
+ });
130
+
131
+ describe('FileSystemTool', () => {
132
+ // ── constructor ─────────────────────────────────────────────────
133
+ describe('constructor', () => {
134
+ test('initializes with default settings', () => {
135
+ const tool = new FileSystemTool({});
136
+ expect(tool.requiresProject).toBe(true);
137
+ expect(tool.blockedExtensions).toContain('.exe');
138
+ expect(tool.operationHistory).toEqual([]);
139
+ });
140
+ });
141
+
142
+ // ── getDescription ──────────────────────────────────────────────
143
+ describe('getDescription', () => {
144
+ test('returns description mentioning supported actions', () => {
145
+ const { tool } = createTestSetup();
146
+ const desc = tool.getDescription();
147
+ expect(desc).toContain('read');
148
+ expect(desc).toContain('write');
149
+ expect(desc).toContain('delete');
150
+ });
151
+ });
152
+
153
+ // ── customValidateParameters ────────────────────────────────────
154
+ describe('customValidateParameters', () => {
155
+ test('valid for correct read action', () => {
156
+ const { tool } = createTestSetup();
157
+ const result = tool.customValidateParameters({
158
+ actions: [{ type: 'read', filePath: 'src/index.js' }]
159
+ });
160
+ expect(result.valid).toBe(true);
161
+ });
162
+
163
+ test('invalid when actions array is empty', () => {
164
+ const { tool } = createTestSetup();
165
+ const result = tool.customValidateParameters({ actions: [] });
166
+ expect(result.valid).toBe(false);
167
+ });
168
+
169
+ test('invalid when filePath missing for read', () => {
170
+ const { tool } = createTestSetup();
171
+ const result = tool.customValidateParameters({
172
+ actions: [{ type: 'read' }]
173
+ });
174
+ expect(result.valid).toBe(false);
175
+ });
176
+
177
+ test('invalid when outputPath missing for write', () => {
178
+ const { tool } = createTestSetup();
179
+ const result = tool.customValidateParameters({
180
+ actions: [{ type: 'write', content: 'hello' }]
181
+ });
182
+ expect(result.valid).toBe(false);
183
+ });
184
+
185
+ test('invalid when content undefined for write', () => {
186
+ const { tool } = createTestSetup();
187
+ const result = tool.customValidateParameters({
188
+ actions: [{ type: 'write', outputPath: 'file.js' }]
189
+ });
190
+ expect(result.valid).toBe(false);
191
+ });
192
+
193
+ test('invalid when content null for write', () => {
194
+ const { tool } = createTestSetup();
195
+ const result = tool.customValidateParameters({
196
+ actions: [{ type: 'write', outputPath: 'file.js', content: null }]
197
+ });
198
+ expect(result.valid).toBe(false);
199
+ });
200
+
201
+ test('valid for write with empty string content', () => {
202
+ const { tool } = createTestSetup();
203
+ const result = tool.customValidateParameters({
204
+ actions: [{ type: 'write', outputPath: 'file.js', content: '' }]
205
+ });
206
+ expect(result.valid).toBe(true);
207
+ });
208
+
209
+ test('invalid for copy without sourcePath', () => {
210
+ const { tool } = createTestSetup();
211
+ const result = tool.customValidateParameters({
212
+ actions: [{ type: 'copy', destPath: 'b.js' }]
213
+ });
214
+ expect(result.valid).toBe(false);
215
+ });
216
+
217
+ test('invalid for copy without destPath', () => {
218
+ const { tool } = createTestSetup();
219
+ const result = tool.customValidateParameters({
220
+ actions: [{ type: 'copy', sourcePath: 'a.js' }]
221
+ });
222
+ expect(result.valid).toBe(false);
223
+ });
224
+
225
+ test('invalid for create-dir without directory', () => {
226
+ const { tool } = createTestSetup();
227
+ const result = tool.customValidateParameters({
228
+ actions: [{ type: 'create-dir' }]
229
+ });
230
+ expect(result.valid).toBe(false);
231
+ });
232
+
233
+ test('invalid for unknown action type', () => {
234
+ const { tool } = createTestSetup();
235
+ const result = tool.customValidateParameters({
236
+ actions: [{ type: 'teleport' }]
237
+ });
238
+ expect(result.valid).toBe(false);
239
+ });
240
+
241
+ test('rejects blocked file extension', () => {
242
+ const { tool } = createTestSetup();
243
+ const result = tool.customValidateParameters({
244
+ actions: [{ type: 'read', filePath: 'virus.exe' }]
245
+ });
246
+ expect(result.valid).toBe(false);
247
+ });
248
+ });
249
+
250
+ // ── execute - parameter validation ──────────────────────────────
251
+ describe('execute - parameter validation', () => {
252
+ test('throws when params is not an object', async () => {
253
+ const { tool, context } = createTestSetup();
254
+ await expect(tool.execute(null, context)).rejects.toThrow('params must be an object');
255
+ });
256
+
257
+ test('throws when actions is missing', async () => {
258
+ const { tool, context } = createTestSetup();
259
+ await expect(tool.execute({}, context)).rejects.toThrow('actions is required');
260
+ });
261
+
262
+ test('throws when actions is not an array', async () => {
263
+ const { tool, context } = createTestSetup();
264
+ await expect(tool.execute({ actions: 'not-array' }, context)).rejects.toThrow('actions must be an array');
265
+ });
266
+
267
+ test('throws when actions is empty', async () => {
268
+ const { tool, context } = createTestSetup();
269
+ await expect(tool.execute({ actions: [] }, context)).rejects.toThrow('actions array is empty');
270
+ });
271
+ });
272
+
273
+ // ── execute - read ──────────────────────────────────────────────
274
+ describe('execute - read', () => {
275
+ test('reads file content successfully', async () => {
276
+ const { tool, context } = createTestSetup();
277
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 42 }));
278
+ fsMock.readFile.mockResolvedValueOnce('hello world');
279
+
280
+ const result = await tool.execute({
281
+ actions: [{ type: 'read', filePath: 'src/index.js' }]
282
+ }, context);
283
+
284
+ expect(result.success).toBe(true);
285
+ expect(result.actions[0].success).toBe(true);
286
+ expect(result.actions[0].content).toBe('hello world');
287
+ expect(result.actions[0].action).toBe('read');
288
+ });
289
+
290
+ test('handles file not found error', async () => {
291
+ const { tool, context } = createTestSetup();
292
+ fsMock.stat.mockRejectedValueOnce(new Error('ENOENT: no such file'));
293
+
294
+ const result = await tool.execute({
295
+ actions: [{ type: 'read', filePath: 'nonexistent.js' }]
296
+ }, context);
297
+
298
+ expect(result.success).toBe(false);
299
+ expect(result.actions[0].success).toBe(false);
300
+ expect(result.actions[0].error).toContain('Failed to read');
301
+ });
302
+
303
+ test('rejects file too large', async () => {
304
+ const { tool, context } = createTestSetup();
305
+ tool.maxFileSize = 100;
306
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 999 }));
307
+
308
+ const result = await tool.execute({
309
+ actions: [{ type: 'read', filePath: 'large.js' }]
310
+ }, context);
311
+
312
+ expect(result.actions[0].success).toBe(false);
313
+ expect(result.actions[0].error).toContain('too large');
314
+ });
315
+ });
316
+
317
+ // ── execute - write ─────────────────────────────────────────────
318
+ describe('execute - write', () => {
319
+ test('writes file content successfully', async () => {
320
+ const { tool, context } = createTestSetup();
321
+ const content = 'console.log("hi");';
322
+ // access throws (file does not exist yet)
323
+ fsMock.access.mockRejectedValueOnce(new Error('ENOENT'));
324
+ fsMock.stat.mockResolvedValue(mockStatResult({ size: Buffer.byteLength(content) }));
325
+ fsMock.readFile.mockResolvedValue(content);
326
+
327
+ const result = await tool.execute({
328
+ actions: [{ type: 'write', outputPath: 'new.js', content }]
329
+ }, context);
330
+
331
+ expect(result.success).toBe(true);
332
+ expect(result.actions[0].success).toBe(true);
333
+ expect(result.actions[0].action).toBe('write');
334
+ expect(result.actions[0].verified).toBe(true);
335
+ expect(fsMock.writeFile).toHaveBeenCalled();
336
+ });
337
+
338
+ test('creates parent directories', async () => {
339
+ const { tool, context } = createTestSetup();
340
+ const content = 'data';
341
+ fsMock.access.mockRejectedValueOnce(new Error('ENOENT'));
342
+ fsMock.stat.mockResolvedValue(mockStatResult({ size: Buffer.byteLength(content) }));
343
+ fsMock.readFile.mockResolvedValue(content);
344
+
345
+ await tool.execute({
346
+ actions: [{ type: 'write', outputPath: 'deep/path/file.js', content }]
347
+ }, context);
348
+
349
+ expect(fsMock.mkdir).toHaveBeenCalledWith(
350
+ expect.any(String),
351
+ { recursive: true }
352
+ );
353
+ });
354
+
355
+ test('creates backup when file exists', async () => {
356
+ const { tool, context } = createTestSetup();
357
+ const content = 'updated';
358
+ // access succeeds (file exists)
359
+ fsMock.access.mockResolvedValueOnce(undefined);
360
+ fsMock.stat.mockResolvedValue(mockStatResult({ size: Buffer.byteLength(content) }));
361
+ fsMock.readFile.mockResolvedValue(content);
362
+
363
+ const result = await tool.execute({
364
+ actions: [{ type: 'write', outputPath: 'existing.js', content }]
365
+ }, context);
366
+
367
+ expect(fsMock.copyFile).toHaveBeenCalled();
368
+ expect(result.actions[0].backupPath).not.toBeNull();
369
+ });
370
+
371
+ test('rejects content too large', async () => {
372
+ const { tool, context } = createTestSetup();
373
+ tool.maxFileSize = 10;
374
+ const content = 'A'.repeat(100);
375
+
376
+ const result = await tool.execute({
377
+ actions: [{ type: 'write', outputPath: 'big.js', content }]
378
+ }, context);
379
+
380
+ expect(result.actions[0].success).toBe(false);
381
+ expect(result.actions[0].error).toContain('too large');
382
+ });
383
+ });
384
+
385
+ // ── execute - append ────────────────────────────────────────────
386
+ describe('execute - append', () => {
387
+ test('appends to existing file', async () => {
388
+ const { tool, context } = createTestSetup();
389
+ const appendContent = '\nnew line';
390
+ const fullContent = 'old content\nnew line';
391
+ fsMock.stat
392
+ .mockResolvedValueOnce(mockStatResult({ size: 11 })) // before append
393
+ .mockResolvedValueOnce(mockStatResult({ size: 11 + Buffer.byteLength(appendContent) })); // after
394
+ fsMock.readFile.mockResolvedValueOnce(fullContent);
395
+
396
+ const result = await tool.execute({
397
+ actions: [{ type: 'append', filePath: 'log.txt', content: appendContent }]
398
+ }, context);
399
+
400
+ expect(result.actions[0].success).toBe(true);
401
+ expect(result.actions[0].action).toBe('append');
402
+ expect(fsMock.appendFile).toHaveBeenCalled();
403
+ });
404
+
405
+ test('creates file if it does not exist', async () => {
406
+ const { tool, context } = createTestSetup();
407
+ const content = 'first line';
408
+ fsMock.stat
409
+ .mockRejectedValueOnce(new Error('ENOENT')) // file doesn't exist
410
+ .mockResolvedValueOnce(mockStatResult({ size: Buffer.byteLength(content) })); // after write
411
+ fsMock.readFile.mockResolvedValueOnce(content);
412
+
413
+ const result = await tool.execute({
414
+ actions: [{ type: 'append', filePath: 'new.log', content }]
415
+ }, context);
416
+
417
+ expect(result.actions[0].success).toBe(true);
418
+ });
419
+ });
420
+
421
+ // ── execute - delete ────────────────────────────────────────────
422
+ describe('execute - delete', () => {
423
+ test('deletes file with backup', async () => {
424
+ const { tool, context } = createTestSetup();
425
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 50 }));
426
+
427
+ const result = await tool.execute({
428
+ actions: [{ type: 'delete', filePath: 'old.js' }]
429
+ }, context);
430
+
431
+ expect(result.actions[0].success).toBe(true);
432
+ expect(result.actions[0].action).toBe('delete');
433
+ expect(fsMock.unlink).toHaveBeenCalled();
434
+ expect(fsMock.copyFile).toHaveBeenCalled(); // backup
435
+ });
436
+
437
+ test('handles missing file error', async () => {
438
+ const { tool, context } = createTestSetup();
439
+ fsMock.stat.mockRejectedValueOnce(new Error('ENOENT'));
440
+
441
+ const result = await tool.execute({
442
+ actions: [{ type: 'delete', filePath: 'gone.js' }]
443
+ }, context);
444
+
445
+ expect(result.actions[0].success).toBe(false);
446
+ expect(result.actions[0].error).toContain('Failed to delete');
447
+ });
448
+ });
449
+
450
+ // ── execute - copy ──────────────────────────────────────────────
451
+ describe('execute - copy', () => {
452
+ test('copies file to destination', async () => {
453
+ const { tool, context } = createTestSetup();
454
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 200 }));
455
+
456
+ const result = await tool.execute({
457
+ actions: [{ type: 'copy', sourcePath: 'a.js', destPath: 'b.js' }]
458
+ }, context);
459
+
460
+ expect(result.actions[0].success).toBe(true);
461
+ expect(result.actions[0].action).toBe('copy');
462
+ expect(fsMock.copyFile).toHaveBeenCalled();
463
+ expect(fsMock.mkdir).toHaveBeenCalled(); // dest dir
464
+ });
465
+
466
+ test('rejects source file too large', async () => {
467
+ const { tool, context } = createTestSetup();
468
+ tool.maxFileSize = 10;
469
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 999 }));
470
+
471
+ const result = await tool.execute({
472
+ actions: [{ type: 'copy', sourcePath: 'big.js', destPath: 'copy.js' }]
473
+ }, context);
474
+
475
+ expect(result.actions[0].success).toBe(false);
476
+ expect(result.actions[0].error).toContain('too large');
477
+ });
478
+ });
479
+
480
+ // ── execute - move ──────────────────────────────────────────────
481
+ describe('execute - move', () => {
482
+ test('moves file successfully', async () => {
483
+ const { tool, context } = createTestSetup();
484
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 150 }));
485
+
486
+ const result = await tool.execute({
487
+ actions: [{ type: 'move', sourcePath: 'old.js', destPath: 'new.js' }]
488
+ }, context);
489
+
490
+ expect(result.actions[0].success).toBe(true);
491
+ expect(result.actions[0].action).toBe('move');
492
+ expect(fsMock.rename).toHaveBeenCalled();
493
+ });
494
+ });
495
+
496
+ // ── execute - create-dir ────────────────────────────────────────
497
+ describe('execute - create-dir', () => {
498
+ test('creates directory recursively', async () => {
499
+ const { tool, context } = createTestSetup();
500
+
501
+ const result = await tool.execute({
502
+ actions: [{ type: 'create-dir', directory: 'src/components/ui' }]
503
+ }, context);
504
+
505
+ expect(result.actions[0].success).toBe(true);
506
+ expect(result.actions[0].action).toBe('create-dir');
507
+ expect(fsMock.mkdir).toHaveBeenCalledWith(
508
+ expect.any(String),
509
+ { recursive: true }
510
+ );
511
+ });
512
+ });
513
+
514
+ // ── execute - list ──────────────────────────────────────────────
515
+ describe('execute - list', () => {
516
+ test('lists directory contents with file info', async () => {
517
+ const { tool, context } = createTestSetup();
518
+ fsMock.readdir.mockResolvedValueOnce([
519
+ { name: 'file1.js', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
520
+ { name: 'subdir', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }
521
+ ]);
522
+ fsMock.stat
523
+ .mockResolvedValueOnce(mockStatResult({ size: 100 }))
524
+ .mockResolvedValueOnce(mockStatResult({ size: 0, isDirectory: () => true }));
525
+
526
+ const result = await tool.execute({
527
+ actions: [{ type: 'list', directory: 'src' }]
528
+ }, context);
529
+
530
+ expect(result.actions[0].success).toBe(true);
531
+ expect(result.actions[0].totalItems).toBe(2);
532
+ expect(result.actions[0].files).toBe(1);
533
+ expect(result.actions[0].directories).toBe(1);
534
+ });
535
+ });
536
+
537
+ // ── execute - exists ────────────────────────────────────────────
538
+ describe('execute - exists', () => {
539
+ test('returns true when file exists', async () => {
540
+ const { tool, context } = createTestSetup();
541
+ fsMock.stat.mockResolvedValueOnce(mockStatResult());
542
+
543
+ const result = await tool.execute({
544
+ actions: [{ type: 'exists', filePath: 'file.js' }]
545
+ }, context);
546
+
547
+ expect(result.actions[0].success).toBe(true);
548
+ expect(result.actions[0].exists).toBe(true);
549
+ expect(result.actions[0].type).toBe('file');
550
+ });
551
+
552
+ test('returns false when file does not exist', async () => {
553
+ const { tool, context } = createTestSetup();
554
+ const err = new Error('not found');
555
+ err.code = 'ENOENT';
556
+ fsMock.stat.mockRejectedValueOnce(err);
557
+
558
+ const result = await tool.execute({
559
+ actions: [{ type: 'exists', filePath: 'missing.js' }]
560
+ }, context);
561
+
562
+ expect(result.actions[0].success).toBe(true);
563
+ expect(result.actions[0].exists).toBe(false);
564
+ });
565
+
566
+ test('detects directory type', async () => {
567
+ const { tool, context } = createTestSetup();
568
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({
569
+ isDirectory: () => true, isFile: () => false
570
+ }));
571
+
572
+ const result = await tool.execute({
573
+ actions: [{ type: 'exists', filePath: 'src' }]
574
+ }, context);
575
+
576
+ expect(result.actions[0].type).toBe('directory');
577
+ });
578
+ });
579
+
580
+ // ── execute - stats ─────────────────────────────────────────────
581
+ describe('execute - stats', () => {
582
+ test('returns file metadata', async () => {
583
+ const { tool, context } = createTestSetup();
584
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({
585
+ size: 1234,
586
+ isDirectory: () => false,
587
+ isSymbolicLink: () => false
588
+ }));
589
+
590
+ const result = await tool.execute({
591
+ actions: [{ type: 'stats', filePath: 'package.json' }]
592
+ }, context);
593
+
594
+ expect(result.actions[0].success).toBe(true);
595
+ expect(result.actions[0].stats.size).toBe(1234);
596
+ expect(result.actions[0].stats.type).toBe('file');
597
+ expect(result.actions[0].stats.lastModified).toBeDefined();
598
+ expect(result.actions[0].stats.created).toBeDefined();
599
+ });
600
+
601
+ test('handles stat error', async () => {
602
+ const { tool, context } = createTestSetup();
603
+ fsMock.stat.mockRejectedValueOnce(new Error('Permission denied'));
604
+
605
+ const result = await tool.execute({
606
+ actions: [{ type: 'stats', filePath: 'secret.js' }]
607
+ }, context);
608
+
609
+ expect(result.actions[0].success).toBe(false);
610
+ expect(result.actions[0].error).toContain('Failed to get stats');
611
+ });
612
+ });
613
+
614
+ // ── execute - unknown action type ───────────────────────────────
615
+ describe('execute - unknown action', () => {
616
+ test('returns error for unknown action type', async () => {
617
+ const { tool, context } = createTestSetup();
618
+ // Bypass validation to test execute switch
619
+ tool.customValidateParameters = jest.fn().mockReturnValue({ valid: true, errors: [] });
620
+
621
+ const result = await tool.execute({
622
+ actions: [{ type: 'teleport' }]
623
+ }, context);
624
+
625
+ expect(result.actions[0].success).toBe(false);
626
+ expect(result.actions[0].error).toContain('Unknown action type');
627
+ });
628
+ });
629
+
630
+ // ── execute - multiple actions ──────────────────────────────────
631
+ describe('execute - multiple actions', () => {
632
+ test('executes multiple actions and reports partial failures', async () => {
633
+ const { tool, context } = createTestSetup();
634
+ // First action succeeds
635
+ fsMock.stat.mockResolvedValueOnce(mockStatResult({ size: 10 }));
636
+ fsMock.readFile.mockResolvedValueOnce('content');
637
+ // Second action fails
638
+ fsMock.stat.mockRejectedValueOnce(new Error('ENOENT'));
639
+
640
+ const result = await tool.execute({
641
+ actions: [
642
+ { type: 'read', filePath: 'good.js' },
643
+ { type: 'read', filePath: 'bad.js' }
644
+ ]
645
+ }, context);
646
+
647
+ expect(result.success).toBe(false); // not all succeeded
648
+ expect(result.successfulActions).toBe(1);
649
+ expect(result.failedActions).toBe(1);
650
+ expect(result.warning).toContain('1 of 2');
651
+ });
652
+ });
653
+
654
+ // ── isAllowedFileExtension ──────────────────────────────────────
655
+ describe('isAllowedFileExtension', () => {
656
+ test('blocks .exe files', () => {
657
+ const { tool } = createTestSetup();
658
+ expect(tool.isAllowedFileExtension('virus.exe')).toBe(false);
659
+ });
660
+
661
+ test('blocks .bat files', () => {
662
+ const { tool } = createTestSetup();
663
+ expect(tool.isAllowedFileExtension('script.bat')).toBe(false);
664
+ });
665
+
666
+ test('allows .js files', () => {
667
+ const { tool } = createTestSetup();
668
+ expect(tool.isAllowedFileExtension('app.js')).toBe(true);
669
+ });
670
+
671
+ test('respects allowedExtensions whitelist', () => {
672
+ const tool = new FileSystemTool({ allowedExtensions: ['.js', '.ts'] });
673
+ expect(tool.isAllowedFileExtension('style.css')).toBe(false);
674
+ expect(tool.isAllowedFileExtension('app.js')).toBe(true);
675
+ });
676
+ });
677
+
678
+ // ── addToHistory ────────────────────────────────────────────────
679
+ describe('addToHistory', () => {
680
+ test('records operation in history', () => {
681
+ const { tool } = createTestSetup();
682
+ tool.addToHistory(
683
+ { type: 'read', filePath: 'test.js' },
684
+ { success: true, size: 42 },
685
+ 'agent-1'
686
+ );
687
+ expect(tool.operationHistory).toHaveLength(1);
688
+ expect(tool.operationHistory[0].action).toBe('read');
689
+ });
690
+
691
+ test('trims history to 200 entries', () => {
692
+ const { tool } = createTestSetup();
693
+ for (let i = 0; i < 210; i++) {
694
+ tool.addToHistory({ type: 'read', filePath: `f${i}.js` }, { success: true }, 'a');
695
+ }
696
+ expect(tool.operationHistory.length).toBe(200);
697
+ });
698
+ });
699
+
700
+ // ── getSupportedActions ─────────────────────────────────────────
701
+ describe('getSupportedActions', () => {
702
+ test('returns all supported action names', () => {
703
+ const { tool } = createTestSetup();
704
+ const actions = tool.getSupportedActions();
705
+ expect(actions).toContain('read');
706
+ expect(actions).toContain('write');
707
+ expect(actions).toContain('append');
708
+ expect(actions).toContain('delete');
709
+ expect(actions).toContain('copy');
710
+ expect(actions).toContain('move');
711
+ expect(actions).toContain('create-dir');
712
+ expect(actions).toContain('list');
713
+ expect(actions).toContain('exists');
714
+ expect(actions).toContain('stats');
715
+ });
716
+ });
717
+ });