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 });