From 15d8e96b4909fd085d44b20c3843e87fc950ab2b Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Mon, 11 Aug 2025 23:05:14 +0200 Subject: [PATCH] Enhance testing framework by introducing a unified test runner in run-all.js, allowing for streamlined execution of multiple test scripts. Update package.json to include new test commands for individual test scripts and the consolidated runner. Add comprehensive test cases for list_files, read_file, and ripgrep functionalities, improving overall test coverage and error handling. --- package.json | 6 +- tests/run-all.js | 32 +++++++ tests/run-listfiles-tests.js | 160 +++++++++++++++++++++++++++++++ tests/run-readfile-tests.js | 150 +++++++++++++++++++++++++++++ tests/run-ripgrep-tests.js | 178 +++++++++++++++++++++++++++++++++++ tools/ripgrep.js | 14 ++- 6 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 tests/run-all.js create mode 100644 tests/run-listfiles-tests.js create mode 100644 tests/run-readfile-tests.js create mode 100644 tests/run-ripgrep-tests.js diff --git a/package.json b/package.json index e5861ab..b12a533 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,11 @@ }, "scripts": { "start": "node cli.js", - "test": "node tests/run-tests.js" + "test": "node tests/run-all.js", + "test:patch": "node tests/run-tests.js", + "test:readfile": "node tests/run-readfile-tests.js", + "test:listfiles": "node tests/run-listfiles-tests.js", + "test:ripgrep": "node tests/run-ripgrep-tests.js" }, "keywords": [], "author": "", diff --git a/tests/run-all.js b/tests/run-all.js new file mode 100644 index 0000000..c76d5cf --- /dev/null +++ b/tests/run-all.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const tests = [ + 'tests/run-tests.js', + 'tests/run-readfile-tests.js', + 'tests/run-listfiles-tests.js', + 'tests/run-ripgrep-tests.js', +]; + +function runOne(scriptPath) { + return new Promise((resolve) => { + const abs = path.resolve(process.cwd(), scriptPath); + const child = spawn(process.execPath, [abs], { stdio: 'inherit' }); + child.on('close', (code) => resolve({ script: scriptPath, code })); + child.on('error', (err) => resolve({ script: scriptPath, code: 1, error: err })); + }); +} + +async function main() { + let anyFailed = false; + for (const t of tests) { + const res = await runOne(t); + if (res.code !== 0) anyFailed = true; + } + process.exit(anyFailed ? 1 : 0); +} + +main().catch((err) => { console.error('Fatal error in run-all:', err); process.exit(1); }); + + diff --git a/tests/run-listfiles-tests.js b/tests/run-listfiles-tests.js new file mode 100644 index 0000000..52b1d7a --- /dev/null +++ b/tests/run-listfiles-tests.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { run as runListFiles } from '../tools/list_files.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const chrootRoot = '/home/seb/src/aiTools/tmp'; +const sandboxRoot = path.resolve(chrootRoot, 'listfiles-tests'); + +async function rimraf(dir) { + await fs.rm(dir, { recursive: true, force: true }); +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writeFiles(baseDir, filesMap) { + for (const [rel, content] of Object.entries(filesMap || {})) { + const filePath = path.resolve(baseDir, rel); + await ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, content, 'utf8'); + } +} + +function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +function expectEqual(actual, expected, label) { + const toStr = (v) => typeof v === 'string' ? v : JSON.stringify(v); + if (toStr(actual) !== toStr(expected)) { + const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<>')); + throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(toStr(expected))}\nActual:\n${ellipsize(toStr(actual))}`); + } +} + +async function runCase(index, testCase) { + const idx = String(index + 1).padStart(2, '0'); + const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`); + await ensureDir(chrootRoot); + await rimraf(caseDir); + await ensureDir(caseDir); + + await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {})); + + const args = await testCase.args({ dir: caseDir }); + const result = await runListFiles(args); + + if (testCase.expect?.errorRegex) { + if (!testCase.expect.errorRegex.test(result.err || '')) { + throw new Error(`Error regex mismatch. Got: ${result.err}`); + } + } else { + // Expect cwd and files + let expectedCwd; + if (testCase.expect.cwdFromArgs === true) { + expectedCwd = (args.path === '' || args.path === '/') ? '.' : args.path; + } else if (testCase.expect.cwdFromArgs === 'file') { + expectedCwd = path.dirname(args.path || '.') || '.'; + } else { + expectedCwd = testCase.expect.cwd; + } + expectEqual(result.cwd, expectedCwd, 'cwd'); + expectEqual(result.files, JSON.stringify(testCase.expect.files), 'files'); + } +} + +function cases() { + const list = []; + + // 1. List empty dir depth 0 + list.push({ + name: 'list empty dir depth 0', + before: {}, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }), + expect: { cwdFromArgs: true, files: [] } + }); + + // 2. List single file + list.push({ + name: 'list single file', + before: { 'a.txt': 'A' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), depth: 1, includeHidden: false }), + expect: { cwdFromArgs: 'file', files: [['a.txt', 'f', 1]] } + }); + + // 3. Directory with nested structure depth 1 + list.push({ + name: 'nested depth 1', + before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }), + expect: { cwdFromArgs: true, files: [['sub', 'd', null], ['sub/inner', 'd', null], ['sub/x.txt', 'f', 1], ['z.txt', 'f', 1]] } + }); + + // 4. Depth unlimited (-1) + list.push({ + name: 'depth unlimited', + before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: -1, includeHidden: false }), + expect: { cwdFromArgs: true, files: [ + ['sub', 'd', null], + ['sub/inner', 'd', null], + ['sub/inner/y.txt', 'f', 1], + ['sub/x.txt', 'f', 1], + ['z.txt', 'f', 1], + ] } + }); + + // 5. Include hidden + list.push({ + name: 'include hidden', + before: { '.hidden': 'h', 'v.txt': 'v' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: true }), + expect: { cwdFromArgs: true, files: [['.hidden', 'f', 1], ['v.txt', 'f', 1]] } + }); + + // 6. Non-existent path -> error + list.push({ + name: 'nonexistent path error', + before: {}, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope')), depth: 1, includeHidden: false }), + expect: { errorRegex: /Path does not exist:/ } + }); + + return list; +} + +async function main() { + const all = cases(); + await ensureDir(sandboxRoot); + let passed = 0; + let failed = 0; + + for (let i = 0; i < all.length; i++) { + const tc = all[i]; + const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`; + try { + await runCase(i, tc); + console.log(`✓ ${label}`); + passed++; + } catch (err) { + console.error(`✗ ${label}`); + console.error(String(err?.stack || err)); + failed++; + } + } + + console.log(''); + console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error('Fatal error in list_files test runner:', err); + process.exit(1); +}); + + diff --git a/tests/run-readfile-tests.js b/tests/run-readfile-tests.js new file mode 100644 index 0000000..f826058 --- /dev/null +++ b/tests/run-readfile-tests.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { run as runReadFile } from '../tools/read_file.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const chrootRoot = '/home/seb/src/aiTools/tmp'; +const sandboxRoot = path.resolve(chrootRoot, 'readfile-tests'); + +async function rimraf(dir) { + await fs.rm(dir, { recursive: true, force: true }); +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writeFiles(baseDir, filesMap) { + for (const [rel, content] of Object.entries(filesMap || {})) { + const filePath = path.resolve(baseDir, rel); + await ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, content, 'utf8'); + } +} + +function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +function expectEqual(actual, expected, label) { + if (actual !== expected) { + const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<>')); + throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`); + } +} + +function expectRegex(actual, re, label) { + if (!re.test(actual)) { + throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`); + } +} + +async function runCase(index, testCase) { + const idx = String(index + 1).padStart(2, '0'); + const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`); + await ensureDir(chrootRoot); + await rimraf(caseDir); + await ensureDir(caseDir); + + await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {})); + + const args = await testCase.args({ dir: caseDir }); + const result = await runReadFile(args); + if (testCase.expect?.equals !== undefined) { + expectEqual(result, testCase.expect.equals, 'Tool result'); + } + if (testCase.expect?.errorRegex) { + expectRegex(result, testCase.expect.errorRegex, 'Error'); + } +} + +function cases() { + const list = []; + + // 1. Read entire small file + list.push({ + name: 'read entire file', + before: { 'a.txt': 'A\nB\nC' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 400 }), + expect: { equals: 'A\nB\nC' } + }); + + // 2. Skip first line, read next 1 + list.push({ + name: 'skip and read one', + before: { 'a.txt': 'A\nB\nC' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 1, linesToRead: 1 }), + expect: { equals: 'B' } + }); + + // 3. linesToRead 0 defaults to 400 + list.push({ + name: 'linesToRead zero defaults', + before: { 'a.txt': 'L1\nL2\nL3' }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 0 }), + expect: { equals: 'L1\nL2\nL3' } + }); + + // 4. Missing file -> error string + list.push({ + name: 'missing file error', + before: {}, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope.txt')), linesToSkip: 0, linesToRead: 100 }), + expect: { errorRegex: /read_file error:/ } + }); + + // 5. Path outside chroot -> error + list.push({ + name: 'path outside chroot error', + before: {}, + args: async () => ({ path: '../../etc/passwd', linesToSkip: 0, linesToRead: 100 }), + expect: { errorRegex: /read_file error: Path outside of allowed directory/ } + }); + + // 6. Large file truncated to 400 lines + list.push({ + name: 'truncate to 400 lines', + before: async () => { + const many = Array.from({ length: 450 }, (_, i) => `L${i + 1}`).join('\n'); + return { 'big.txt': many }; + }, + args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'big.txt')), linesToSkip: 0, linesToRead: 99999 }), + expect: { equals: Array.from({ length: 400 }, (_, i) => `L${i + 1}`).join('\n') } + }); + + return list; +} + +async function main() { + const all = cases(); + await ensureDir(sandboxRoot); + let passed = 0; + let failed = 0; + + for (let i = 0; i < all.length; i++) { + const tc = all[i]; + const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`; + try { + await runCase(i, tc); + console.log(`✓ ${label}`); + passed++; + } catch (err) { + console.error(`✗ ${label}`); + console.error(String(err?.stack || err)); + failed++; + } + } + + console.log(''); + console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error('Fatal error in read_file test runner:', err); + process.exit(1); +}); + + diff --git a/tests/run-ripgrep-tests.js b/tests/run-ripgrep-tests.js new file mode 100644 index 0000000..b919d43 --- /dev/null +++ b/tests/run-ripgrep-tests.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { run as runRipgrep } from '../tools/ripgrep.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const chrootRoot = '/home/seb/src/aiTools/tmp'; +const sandboxRoot = path.resolve(chrootRoot, 'rg-tests'); + +async function rimraf(dir) { + await fs.rm(dir, { recursive: true, force: true }); +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writeFiles(baseDir, filesMap) { + for (const [rel, content] of Object.entries(filesMap || {})) { + const filePath = path.resolve(baseDir, rel); + await ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, content, 'utf8'); + } +} + +function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +function expectEqual(actual, expected, label) { + if (actual !== expected) { + const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<>')); + throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`); + } +} + +function expectRegex(actual, re, label) { + if (!re.test(actual)) { + throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`); + } +} + +async function runCase(index, testCase) { + const idx = String(index + 1).padStart(2, '0'); + const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`); + await ensureDir(chrootRoot); + await rimraf(caseDir); + await ensureDir(caseDir); + + // Setup initial files + await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {})); + + const args = await testCase.args({ dir: caseDir }); + + let threw = false; + let output = ''; + try { + output = await runRipgrep(args); + } catch (err) { + threw = true; + output = err?.message || String(err); + } + + if (testCase.expect?.error) { + if (!threw && typeof output === 'string') { + // We expect error formatting to be returned as string starting with 'ripgrep error:' + expectRegex(output, testCase.expect.error, 'Error string'); + } else if (threw) { + expectRegex(output, testCase.expect.error, 'Thrown error'); + } + } else { + if (typeof testCase.expect?.equals === 'string') { + expectEqual(output, testCase.expect.equals, 'Tool result'); + } + if (typeof testCase.expect?.lineCount === 'number') { + const lines = output ? output.split('\n').filter(Boolean) : []; + expectEqual(lines.length, testCase.expect.lineCount, 'Line count'); + } + } +} + +function cases() { + const list = []; + + // 1. Simple case-sensitive match + list.push({ + name: 'simple case-sensitive match', + before: { 'a.txt': 'Hello\nWorld\nhello again' }, + args: async ({ dir }) => ({ pattern: 'Hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }), + expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '01-simple-case-sensitive-match/a.txt'))}:1:Hello` } + }); + + // 2. Case-insensitive matches + list.push({ + name: 'case-insensitive matches', + before: { 'a.txt': 'Hello\nWorld\nhello again' }, + args: async ({ dir }) => ({ pattern: 'hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: true }), + expect: { equals: [ + `${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:1:Hello`, + `${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:3:hello again` + ].join('\n') } + }); + + // 3. filePattern filter to subdir and extension + list.push({ + name: 'filePattern filter', + before: { 'sub/b.md': 'Alpha\nbeta\nGamma' }, + args: async ({ dir }) => ({ pattern: 'Alpha', filePattern: path.relative(chrootRoot, path.join(dir, 'sub/*.md')), n_flag: true, i_flag: false }), + expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '03-filepattern-filter/sub/b.md'))}:1:Alpha` } + }); + + // 4. No matches -> empty string + list.push({ + name: 'no matches returns empty', + before: { 'a.txt': 'x\ny' }, + args: async ({ dir }) => ({ pattern: 'nomatch', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }), + expect: { equals: '' } + }); + + // 5. Output limited to 200 lines + list.push({ + name: 'limit to 200 lines', + before: async () => { + const many = Array.from({ length: 250 }, (_, i) => `line${i + 1}`).join('\n'); + return { 'long.txt': many }; + }, + args: async ({ dir }) => ({ pattern: 'line', filePattern: path.relative(chrootRoot, path.join(dir, 'long.txt')), n_flag: true, i_flag: false }), + expect: { lineCount: 200 } + }); + + // 6. Invalid regex pattern -> error line + list.push({ + name: 'invalid regex pattern', + before: { 'a.txt': 'text' }, + args: async ({ dir }) => ({ pattern: '[', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }), + expect: { error: /ripgrep error:/ } + }); + + return list; +} + +async function main() { + const all = cases(); + await ensureDir(sandboxRoot); + const results = []; + let passed = 0; + let failed = 0; + + for (let i = 0; i < all.length; i++) { + const tc = all[i]; + const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`; + try { + await runCase(i, tc); + console.log(`✓ ${label}`); + results.push({ name: tc.name, ok: true }); + passed++; + } catch (err) { + console.error(`✗ ${label}`); + console.error(String(err?.stack || err)); + results.push({ name: tc.name, ok: false, error: String(err?.message || err) }); + failed++; + } + } + + console.log(''); + console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error('Fatal error in ripgrep test runner:', err); + process.exit(1); +}); + + diff --git a/tools/ripgrep.js b/tools/ripgrep.js index ba0650a..9fdfd5d 100644 --- a/tools/ripgrep.js +++ b/tools/ripgrep.js @@ -27,8 +27,8 @@ export async function run(args) { rgArgs.push('-g', filePattern); } - // Add separator and pattern - rgArgs.push('--', pattern); + // Add separator, pattern, and explicit search path '.' so rg scans the chroot cwd + rgArgs.push('--', pattern, '.'); try { const proc = spawnSync('rg', rgArgs, { @@ -51,9 +51,13 @@ export async function run(args) { return `ripgrep error: exit ${proc.status}, ${proc.stderr}`; } - // Limit to 200 lines - const lines = output.split('\n'); - const limitedOutput = lines.slice(0, 200).join('\n'); + // Normalize paths (strip leading './') and limit to 200 lines + const lines = output.split('\n').map((l) => l.replace(/^\.\//, '')); + let limitedOutput = lines.slice(0, 200).join('\n'); + // Remove a single trailing newline if present to align with tests + if (limitedOutput.endsWith('\n')) { + limitedOutput = limitedOutput.replace(/\n$/, ''); + } return limitedOutput; } catch (error) {