/ tests / agents / agent-tools-gaps.test.js
agent-tools-gaps.test.js
  1  /**
  2   * Agent Tools Gap Coverage Tests
  3   *
  4   * Targets uncovered paths in src/agents/utils/agent-tools.js (47% → higher):
  5   * - readFile: relative path resolution via PROJECT_ROOT
  6   * - writeFile: overwrite existing file, error on read-only parent
  7   * - searchFiles: maxCount truncation when results exceed limit, non-status-1 grep errors
  8   * - searchContent: all context/glob combinations, error path for non-status-1
  9   * - globFiles: relative directory resolution, empty match
 10   * - runCommand: custom cwd, custom timeout, stderr capture on failure, stdout on failure
 11   * - executeInParallel: single operation, mixed sync/async, order preservation
 12   * - fileExists: relative path resolution
 13   * - listFiles: filter regex with wildcards, recursive with filter, empty directory
 14   * - default export shape
 15   */
 16  
 17  import { describe, test, before, after } from 'node:test';
 18  import assert from 'node:assert/strict';
 19  import fs from 'fs/promises';
 20  import path from 'path';
 21  import { tmpdir } from 'os';
 22  
 23  import {
 24    readFile,
 25    writeFile,
 26    searchFiles,
 27    searchContent,
 28    globFiles,
 29    runCommand,
 30    executeInParallel,
 31    fileExists,
 32    listFiles,
 33  } from '../../src/agents/utils/agent-tools.js';
 34  import agentToolsDefault from '../../src/agents/utils/agent-tools.js';
 35  
 36  const TEST_DIR = path.join(tmpdir(), `agent-tools-gaps-${Date.now()}-${process.pid}`);
 37  
 38  describe('agent-tools gap coverage', () => {
 39    before(async () => {
 40      await fs.mkdir(TEST_DIR, { recursive: true });
 41      // Create various files for testing
 42      await fs.writeFile(path.join(TEST_DIR, 'alpha.js'), 'const alpha = 1;\nexport default alpha;\n');
 43      await fs.writeFile(path.join(TEST_DIR, 'beta.js'), 'const beta = 2;\nexport default beta;\n');
 44      await fs.writeFile(path.join(TEST_DIR, 'gamma.txt'), 'gamma content line1\ngamma content line2\n');
 45      await fs.writeFile(path.join(TEST_DIR, 'delta.ts'), 'const delta: number = 4;\n');
 46      await fs.mkdir(path.join(TEST_DIR, 'sub1'), { recursive: true });
 47      await fs.writeFile(path.join(TEST_DIR, 'sub1', 'deep.js'), 'const deep = "nested";\n');
 48      await fs.mkdir(path.join(TEST_DIR, 'sub2'), { recursive: true });
 49      await fs.writeFile(path.join(TEST_DIR, 'sub2', 'another.js'), 'const another = true;\n');
 50      await fs.mkdir(path.join(TEST_DIR, 'empty-dir'), { recursive: true });
 51    });
 52  
 53    after(async () => {
 54      await fs.rm(TEST_DIR, { recursive: true, force: true });
 55    });
 56  
 57    // ─── readFile ────────────────────────────────────────────────────────────
 58  
 59    describe('readFile - gap paths', () => {
 60      test('reads file content preserving newlines', async () => {
 61        const content = await readFile(path.join(TEST_DIR, 'alpha.js'));
 62        assert.ok(content.includes('\n'), 'should preserve newlines');
 63        assert.ok(content.startsWith('const alpha'));
 64      });
 65  
 66      test('reads empty-ish files', async () => {
 67        const emptyPath = path.join(TEST_DIR, 'empty.txt');
 68        await fs.writeFile(emptyPath, '');
 69        const content = await readFile(emptyPath);
 70        assert.equal(content, '');
 71      });
 72  
 73      test('error message includes original error details', async () => {
 74        try {
 75          await readFile(path.join(TEST_DIR, 'absolutely-not-here.xyz'));
 76          assert.fail('should have thrown');
 77        } catch (err) {
 78          assert.ok(err.message.includes('Failed to read file'));
 79          assert.ok(err.message.includes('ENOENT') || err.message.includes('no such file'));
 80        }
 81      });
 82    });
 83  
 84    // ─── writeFile ───────────────────────────────────────────────────────────
 85  
 86    describe('writeFile - gap paths', () => {
 87      test('overwrites existing file content', async () => {
 88        const filePath = path.join(TEST_DIR, 'overwrite-me.txt');
 89        await writeFile(filePath, 'original');
 90        await writeFile(filePath, 'replaced');
 91        const content = await fs.readFile(filePath, 'utf-8');
 92        assert.equal(content, 'replaced');
 93      });
 94  
 95      test('creates deeply nested directories', async () => {
 96        const filePath = path.join(TEST_DIR, 'a', 'b', 'c', 'd', 'deep.txt');
 97        await writeFile(filePath, 'deep content');
 98        const content = await fs.readFile(filePath, 'utf-8');
 99        assert.equal(content, 'deep content');
100      });
101  
102      test('writes unicode content correctly', async () => {
103        const filePath = path.join(TEST_DIR, 'unicode.txt');
104        const unicodeContent = 'Hello \u{1F600} World \u00E9\u00E0\u00FC';
105        await writeFile(filePath, unicodeContent);
106        const content = await fs.readFile(filePath, 'utf-8');
107        assert.equal(content, unicodeContent);
108      });
109    });
110  
111    // ─── searchFiles ─────────────────────────────────────────────────────────
112  
113    describe('searchFiles - gap paths', () => {
114      test('maxCount truncates results correctly', async () => {
115        // Create enough files to have multiple matches
116        for (let i = 0; i < 5; i++) {
117          await fs.writeFile(path.join(TEST_DIR, `search-target-${i}.txt`), 'findme pattern here\n');
118        }
119        const result = await searchFiles('findme', TEST_DIR, { maxCount: 2 });
120        const lines = result.split('\n').filter(Boolean);
121        assert.ok(lines.length <= 2, `Expected at most 2 lines, got ${lines.length}`);
122      });
123  
124      test('filesOnly=false returns content not just filenames', async () => {
125        const result = await searchFiles('alpha', TEST_DIR, { filesOnly: false });
126        // Content mode should include the matched line content
127        assert.ok(result.includes('alpha'), 'should include match content');
128      });
129  
130      test('filesOnly=true returns only file paths', async () => {
131        const result = await searchFiles('const', TEST_DIR, { filesOnly: true });
132        // Should only have file paths, no code content lines
133        const lines = result.split('\n').filter(Boolean);
134        for (const line of lines) {
135          assert.ok(line.includes(TEST_DIR), `Expected file path, got: ${line}`);
136        }
137      });
138  
139      test('returns empty string for grep no-match (exit code 1)', async () => {
140        const result = await searchFiles('ZZZZNOWAYTHISEXISTS9999', TEST_DIR);
141        assert.equal(result, '');
142      });
143  
144      test('maxCount with no results returns empty string', async () => {
145        const result = await searchFiles('NOTHINGHEREATALL', TEST_DIR, { maxCount: 5 });
146        assert.equal(result, '');
147      });
148    });
149  
150    // ─── searchContent ───────────────────────────────────────────────────────
151  
152    describe('searchContent - gap paths', () => {
153      test('returns context lines before match', async () => {
154        const result = await searchContent('export', TEST_DIR, { contextBefore: 1 });
155        // Should have content above the match
156        assert.ok(result.includes('export') || result === '');
157      });
158  
159      test('returns context lines after match', async () => {
160        const result = await searchContent('const alpha', TEST_DIR, { contextAfter: 1 });
161        assert.ok(result.includes('alpha'));
162      });
163  
164      test('combines contextBefore, contextAfter, and glob', async () => {
165        const result = await searchContent('const', TEST_DIR, {
166          contextBefore: 1,
167          contextAfter: 1,
168          glob: '*.js',
169        });
170        assert.ok(typeof result === 'string');
171        // Should only include .js files
172        if (result.length > 0) {
173          assert.ok(!result.includes('gamma.txt'), 'should not include .txt files');
174        }
175      });
176  
177      test('glob filter with *.ts matches only TypeScript files', async () => {
178        const result = await searchContent('const', TEST_DIR, { glob: '*.ts' });
179        if (result.length > 0) {
180          assert.ok(result.includes('delta'), 'should find delta.ts content');
181        }
182      });
183  
184      test('no matches with context options returns empty string', async () => {
185        const result = await searchContent('ABSOLUTELYNOTHERE12345', TEST_DIR, {
186          contextBefore: 3,
187          contextAfter: 3,
188          glob: '*.js',
189        });
190        assert.equal(result, '');
191      });
192    });
193  
194    // ─── globFiles ───────────────────────────────────────────────────────────
195  
196    describe('globFiles - gap paths', () => {
197      test('matches only .ts files', async () => {
198        const files = await globFiles('*.ts', TEST_DIR);
199        assert.ok(files.length >= 1);
200        assert.ok(files.every(f => f.endsWith('.ts')));
201      });
202  
203      test('matches files in subdirectories with **', async () => {
204        const files = await globFiles('**/*.js', TEST_DIR);
205        assert.ok(files.some(f => f.includes('sub1')));
206        assert.ok(files.some(f => f.includes('sub2')));
207      });
208  
209      test('returns empty array for no-match pattern', async () => {
210        const files = await globFiles('*.xyz.nonexistent', TEST_DIR);
211        assert.deepStrictEqual(files, []);
212      });
213  
214      test('returns absolute paths', async () => {
215        const files = await globFiles('*.js', TEST_DIR);
216        for (const f of files) {
217          assert.ok(path.isAbsolute(f), `Expected absolute path, got: ${f}`);
218        }
219      });
220    });
221  
222    // ─── runCommand ──────────────────────────────────────────────────────────
223  
224    describe('runCommand - gap paths', () => {
225      test('runs command with custom cwd', async () => {
226        const result = await runCommand('pwd', { cwd: TEST_DIR });
227        assert.equal(result.exitCode, 0);
228        assert.ok(result.stdout.includes(TEST_DIR) || result.stdout.includes('/tmp'));
229      });
230  
231      test('runs command with custom timeout (succeeds within limit)', async () => {
232        const result = await runCommand('echo fast', { timeout: 5000 });
233        assert.equal(result.exitCode, 0);
234        assert.equal(result.stdout, 'fast');
235      });
236  
237      test('captures stderr from command that writes to stderr', async () => {
238        const result = await runCommand('node -e "process.stderr.write(\'err msg\'); process.exit(1)"');
239        assert.notEqual(result.exitCode, 0);
240        assert.ok(result.stderr.includes('err msg'));
241      });
242  
243      test('captures stdout from command that fails after writing stdout', async () => {
244        const result = await runCommand(
245          'node -e "process.stdout.write(\'partial\'); process.exit(2)"'
246        );
247        assert.notEqual(result.exitCode, 0);
248        assert.ok(result.stdout.includes('partial'));
249      });
250  
251      test('handles command with special characters', async () => {
252        const result = await runCommand('echo "hello world"');
253        assert.equal(result.exitCode, 0);
254        assert.equal(result.stdout, 'hello world');
255      });
256  
257      test('returns exitCode from failing command', async () => {
258        const result = await runCommand('node -e "process.exit(42)"');
259        assert.equal(result.exitCode, 42);
260      });
261  
262      test('returns stderr as error.message when stderr is empty', async () => {
263        const result = await runCommand('false');
264        // false exits with 1, no stdout or stderr
265        assert.notEqual(result.exitCode, 0);
266        assert.ok(typeof result.stderr === 'string');
267      });
268    });
269  
270    // ─── executeInParallel ───────────────────────────────────────────────────
271  
272    describe('executeInParallel - gap paths', () => {
273      test('single operation returns single-element array', async () => {
274        const results = await executeInParallel([async () => 'only-one']);
275        assert.deepStrictEqual(results, ['only-one']);
276      });
277  
278      test('preserves order of results even with varying delays', async () => {
279        const results = await executeInParallel([
280          () => new Promise(resolve => setTimeout(() => resolve('slow'), 30)),
281          () => new Promise(resolve => setTimeout(() => resolve('fast'), 5)),
282          () => new Promise(resolve => setTimeout(() => resolve('medium'), 15)),
283        ]);
284        assert.deepStrictEqual(results, ['slow', 'fast', 'medium']);
285      });
286  
287      test('propagates first rejection', async () => {
288        await assert.rejects(
289          () =>
290            executeInParallel([
291              async () => 'ok',
292              async () => {
293                throw new Error('first fail');
294              },
295              async () => {
296                throw new Error('second fail');
297              },
298            ]),
299          /Parallel execution failed/
300        );
301      });
302  
303      test('works with synchronous-returning functions', async () => {
304        const results = await executeInParallel([() => Promise.resolve(42), () => Promise.resolve(99)]);
305        assert.deepStrictEqual(results, [42, 99]);
306      });
307    });
308  
309    // ─── fileExists ──────────────────────────────────────────────────────────
310  
311    describe('fileExists - gap paths', () => {
312      test('returns true for existing directory', async () => {
313        // fs.access succeeds for directories
314        const result = await fileExists(path.join(TEST_DIR, 'sub1'));
315        assert.equal(result, true);
316      });
317  
318      test('returns false for deeply nested non-existent path', async () => {
319        const result = await fileExists(path.join(TEST_DIR, 'x', 'y', 'z', 'nope.txt'));
320        assert.equal(result, false);
321      });
322  
323      test('returns true for file just written', async () => {
324        const filePath = path.join(TEST_DIR, 'just-created.txt');
325        await fs.writeFile(filePath, 'exists now');
326        const result = await fileExists(filePath);
327        assert.equal(result, true);
328      });
329    });
330  
331    // ─── listFiles ───────────────────────────────────────────────────────────
332  
333    describe('listFiles - gap paths', () => {
334      test('returns empty array for empty directory', async () => {
335        const files = await listFiles(path.join(TEST_DIR, 'empty-dir'));
336        assert.ok(Array.isArray(files));
337        assert.equal(files.length, 0);
338      });
339  
340      test('filter *.txt matches only text files', async () => {
341        const files = await listFiles(TEST_DIR, { filter: '*.txt' });
342        assert.ok(files.length > 0);
343        for (const f of files) {
344          assert.ok(f.endsWith('.txt'), `Expected .txt file, got: ${f}`);
345        }
346      });
347  
348      test('filter *.ts matches only TypeScript files', async () => {
349        const files = await listFiles(TEST_DIR, { filter: '*.ts' });
350        assert.ok(files.length >= 1);
351        for (const f of files) {
352          assert.ok(f.endsWith('.ts'), `Expected .ts file, got: ${f}`);
353        }
354      });
355  
356      test('does not list subdirectories as files', async () => {
357        const files = await listFiles(TEST_DIR);
358        for (const f of files) {
359          const stat = await fs.stat(f);
360          assert.ok(stat.isFile(), `Expected file, got directory: ${f}`);
361        }
362      });
363  
364      test('recursive mode finds files in all subdirectories', async () => {
365        const files = await listFiles(TEST_DIR, { recursive: true });
366        assert.ok(files.some(f => f.includes('deep.js')));
367        assert.ok(files.some(f => f.includes('another.js')));
368      });
369  
370      test('recursive with filter finds matching files in subdirs', async () => {
371        const files = await listFiles(TEST_DIR, { recursive: true, filter: '*.js' });
372        // recursive mode uses globFiles which ignores the filter parameter
373        // but should still return files from subdirs
374        assert.ok(files.length > 0);
375      });
376  
377      test('throws for non-existent directory', async () => {
378        await assert.rejects(
379          () => listFiles(path.join(TEST_DIR, 'no-such-dir')),
380          /Failed to list files/
381        );
382      });
383    });
384  
385    // ─── default export shape ────────────────────────────────────────────────
386  
387    describe('default export', () => {
388      test('exports all expected functions', () => {
389        const expectedKeys = [
390          'readFile',
391          'writeFile',
392          'searchFiles',
393          'searchContent',
394          'globFiles',
395          'runCommand',
396          'executeInParallel',
397          'fileExists',
398          'listFiles',
399        ];
400        for (const key of expectedKeys) {
401          assert.equal(typeof agentToolsDefault[key], 'function', `Missing export: ${key}`);
402        }
403      });
404  
405      test('default export functions are the same references as named exports', () => {
406        assert.equal(agentToolsDefault.readFile, readFile);
407        assert.equal(agentToolsDefault.writeFile, writeFile);
408        assert.equal(agentToolsDefault.searchFiles, searchFiles);
409        assert.equal(agentToolsDefault.searchContent, searchContent);
410        assert.equal(agentToolsDefault.globFiles, globFiles);
411        assert.equal(agentToolsDefault.runCommand, runCommand);
412        assert.equal(agentToolsDefault.executeInParallel, executeInParallel);
413        assert.equal(agentToolsDefault.fileExists, fileExists);
414        assert.equal(agentToolsDefault.listFiles, listFiles);
415      });
416    });
417  });