Files
toolLooper/tests/run-tests.js

627 lines
16 KiB
JavaScript

#!/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 runPatch } from '../tools/patch_files.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sandboxRoot = path.resolve(repoRoot, 'tmp', 'patch-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');
}
}
async function readFileIfExists(filePath) {
try {
return await fs.readFile(filePath, 'utf8');
} catch {
return undefined;
}
}
function buildPatch(lines, useCRLF = false) {
const eol = useCRLF ? '\r\n' : '\n';
return lines.join(eol);
}
function begin() { return '*** Begin Patch'; }
function end() { return '*** End Patch'; }
function addFile(p) { return `*** Add File: ${p}`; }
function updateFile(p) { return `*** Update File: ${p}`; }
function moveTo(p) { return `*** Move to: ${p}`; }
function endOfFile() { return '*** End of File'; }
function k(line) { return ` ${line}`; }
function d(line) { return `-${line}`; }
function a(line) { return `+${line}`; }
function at(atLine = '') { return atLine ? `@@ ${atLine}` : '@@'; }
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.length > 400 ? s.slice(0, 400) + '…' : s);
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected ?? '<<undefined>>')}\nActual:\n${ellipsize(actual ?? '<<undefined>>')}`);
}
}
async function runCase(index, testCase) {
const idx = String(index + 1).padStart(2, '0');
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
await rimraf(caseDir);
await ensureDir(caseDir);
// Setup initial files
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
const patchText = await testCase.patch({ dir: caseDir });
let threw = false;
let errorMessage = '';
try {
const result = await runPatch({ patch: patchText });
if (testCase.expect?.resultEquals) {
expectEqual(result, testCase.expect.resultEquals, 'Tool result');
}
} catch (err) {
threw = true;
errorMessage = err?.message || String(err);
}
if (testCase.expect?.error) {
if (!threw) {
throw new Error(`Expected error matching ${testCase.expect.error} but call succeeded`);
}
const re = typeof testCase.expect.error === 'string' ? new RegExp(testCase.expect.error.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : testCase.expect.error;
if (!re.test(errorMessage)) {
throw new Error(`Error did not match.\nExpected: ${re}\nActual: ${errorMessage}`);
}
} else if (threw) {
throw new Error(`Unexpected error: ${errorMessage}`);
}
if (testCase.expect?.files) {
for (const [rel, expectedContent] of Object.entries(testCase.expect.files)) {
const filePath = path.resolve(caseDir, rel);
const content = await readFileIfExists(filePath);
if (content === undefined) {
throw new Error(`Expected file missing: ${rel}`);
}
expectEqual(content, expectedContent, `Content of ${rel}`);
}
}
if (testCase.expect?.exists) {
for (const rel of testCase.expect.exists) {
const filePath = path.resolve(caseDir, rel);
if (!fsSync.existsSync(filePath)) {
throw new Error(`Expected path to exist: ${rel}`);
}
}
}
if (testCase.expect?.notExists) {
for (const rel of testCase.expect.notExists) {
const filePath = path.resolve(caseDir, rel);
if (fsSync.existsSync(filePath)) {
throw new Error(`Expected path NOT to exist: ${rel}`);
}
}
}
}
function cases() {
const list = [];
// 1. Add simple file
list.push({
name: 'add simple file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'file1.txt')),
a('hello'),
a('world'),
end(),
]),
expect: {
files: { 'file1.txt': 'hello\nworld' }
}
});
// 2. Add nested directories
list.push({
name: 'add nested directories',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'a/b/c.txt')),
a('alpha'),
a('beta'),
end(),
]),
expect: {
files: { 'a/b/c.txt': 'alpha\nbeta' }
}
});
// 3. Update simple replacement
list.push({
name: 'update simple replacement',
before: { 'greet.txt': 'line1\nreplace me\nline3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'greet.txt')),
k('line1'),
d('replace me'),
a('replaced'),
k('line3'),
end(),
]),
expect: {
files: { 'greet.txt': 'line1\nreplaced\nline3' }
}
});
// 4. Update multiple chunks
list.push({
name: 'update multiple chunks',
before: { 'multi.txt': 'l1\nl2\nl3\nl4\nl5\nl6\nl7' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'multi.txt')),
k('l1'),
k('l2'),
d('l3'),
a('L3'),
k('l4'),
k('l5'),
d('l6'),
a('L6'),
k('l7'),
end(),
]),
expect: {
files: { 'multi.txt': 'l1\nl2\nL3\nl4\nl5\nL6\nl7' }
}
});
// 5. Insert at beginning
list.push({
name: 'insert at beginning',
before: { 'begin.txt': 'B1\nB2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'begin.txt')),
a('A0'),
k('B1'),
k('B2'),
end(),
]),
expect: { files: { 'begin.txt': 'A0\nB1\nB2' } }
});
// 6. Insert at end with EOF marker
list.push({
name: 'insert at end with EOF',
before: { 'end.txt': 'E1\nE2' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'end.txt')),
k('E1'),
k('E2'),
a('E3'),
endOfFile(),
end(),
]),
expect: { files: { 'end.txt': 'E1\nE2\nE3' } }
});
// 7. Delete a line
list.push({
name: 'delete a line',
before: { 'delete.txt': 'X\nY\nZ' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'delete.txt')),
k('X'),
d('Y'),
k('Z'),
end(),
]),
expect: { files: { 'delete.txt': 'X\nZ' } }
});
// 8. Whitespace rstrip match
list.push({
name: 'rstrip whitespace match',
before: { 'ws.txt': 'foo \nbar' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'ws.txt')),
k('foo'), // matches 'foo ' via rstrip
k('bar'),
end(),
]),
expect: { files: { 'ws.txt': 'foo \nbar' } }
});
// 9. Trim match
list.push({
name: 'trim whitespace match',
before: { 'trim.txt': ' alpha \n beta' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'trim.txt')),
k('alpha'), // matches ' alpha ' via trim
k(' beta'),
end(),
]),
expect: { files: { 'trim.txt': ' alpha \n beta' } }
});
// 10. Use def_str to anchor (do not duplicate the anchor line in context)
list.push({
name: 'def_str anchor',
before: { 'code.js': 'function a() {}\nfunction greet() {\n return 1;\n}\nfunction z() {}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'code.js')),
at('function greet() {'),
// context starts at the next line after the def_str match
d(' return 1;'),
a(' return 2;'),
k('}'),
end(),
]),
expect: { files: { 'code.js': 'function a() {}\nfunction greet() {\n return 2;\n}\nfunction z() {}' } }
});
// 11. Bare @@ marker allowed
list.push({
name: 'bare @@ marker',
before: { 'marker.txt': 'L1\nL2\nL3' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'marker.txt')),
at(),
k('L1'),
d('L2'),
a('X2'),
k('L3'),
end(),
]),
expect: { files: { 'marker.txt': 'L1\nX2\nL3' } }
});
// 12. Move/rename file with content change
list.push({
name: 'move and change',
before: { 'mv.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'mv.txt')),
moveTo(path.resolve(dir, 'moved/mv.txt')),
k('A'),
d('B'),
a('C'),
end(),
]),
expect: { files: { 'moved/mv.txt': 'A\nC' }, notExists: ['mv.txt'] }
});
// 13. Delete file
list.push({
name: 'delete file',
before: { 'del/me.txt': 'bye' },
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'del/me.txt')}`,
end(),
]),
expect: { notExists: ['del/me.txt'] }
});
// 14. Combined add/update/delete
list.push({
name: 'combined operations',
before: { 'combo/u.txt': 'X\nY', 'combo/d.txt': 'gone' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'combo/u.txt')),
k('X'),
a('Z'),
k('Y'),
`*** Delete File: ${path.resolve(dir, 'combo/d.txt')}`,
addFile(path.resolve(dir, 'combo/a.txt')),
a('new'),
end(),
]),
expect: { files: { 'combo/u.txt': 'X\nZ\nY', 'combo/a.txt': 'new' }, notExists: ['combo/d.txt'] }
});
// 15. Add with CRLF patch
list.push({
name: 'add with CRLF patch',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'crlf/add.txt')),
a('one'),
a('two'),
end(),
], true),
expect: { files: { 'crlf/add.txt': 'one\ntwo' } }
});
// 16. Update with CRLF patch
list.push({
name: 'update with CRLF patch',
before: { 'crlf/up.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'crlf/up.txt')),
k('A'),
d('B'),
a('C'),
end(),
], true),
expect: { files: { 'crlf/up.txt': 'A\nC' } }
});
// 17. Ambiguous content resolved by def_str
list.push({
name: 'ambiguous resolved by def_str',
before: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 2;\n}' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'amb.js')),
at('function target() {'),
k('function target() {'),
d(' let x = 2;'),
a(' let x = 42;'),
k('}'),
end(),
]),
expect: { files: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 42;\n}' } }
});
// 18. Update missing file -> error
list.push({
name: 'update missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'missing.txt')),
k('x'),
end(),
]),
expect: { error: /Patch error: Update File Error - missing file:/ }
});
// 19. Delete missing file -> error
list.push({
name: 'delete missing file error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
`*** Delete File: ${path.resolve(dir, 'nope.txt')}`,
end(),
]),
expect: { error: /Patch error: Delete File Error - missing file:/ }
});
// 20. Add existing file -> error
list.push({
name: 'add existing file error',
before: { 'exists.txt': 'already' },
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'exists.txt')),
a('new'),
end(),
]),
expect: { error: /Patch error: Add File Error - file already exists:/ }
});
// 21. Duplicate update -> error
list.push({
name: 'duplicate update error',
before: { 'dup.txt': 'X' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
updateFile(path.resolve(dir, 'dup.txt')),
k('X'),
end(),
]),
expect: { error: /Patch error: Duplicate update for file:/ }
});
// 22. Invalid line in update section -> error
list.push({
name: 'invalid line in section error',
before: { 'bad.txt': 'A\nB' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'bad.txt')),
k('A'),
'?weird',
k('B'),
end(),
]),
expect: { error: /Patch error: Invalid Line:/ }
});
// 23. Missing end sentinel -> error
list.push({
name: 'missing end sentinel error',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'x.txt')),
a('x'),
// Intentionally no end sentinel here; we'll strip it below
]).replace(/\*\*\* End Patch$/, ''),
expect: { error: /Patch error: Invalid patch text - missing sentinels/ }
});
// 24. Unknown line while parsing -> error
list.push({
name: 'unknown line while parsing error',
before: {},
patch: async () => buildPatch([
begin(),
'some random line',
end(),
]),
expect: { error: /Patch error: Unknown line while parsing:/ }
});
// 25. Add empty file (no + lines)
list.push({
name: 'add empty file',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'empty.txt')),
end(),
]),
expect: { files: { 'empty.txt': '' } }
});
// 26. Replace whole file contents
list.push({
name: 'replace whole file',
before: { 'whole.txt': 'a\nb\nc' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'whole.txt')),
d('a'),
d('b'),
d('c'),
a('x'),
a('y'),
end(),
]),
expect: { files: { 'whole.txt': 'x\ny' } }
});
// 27. Multiple file updates
list.push({
name: 'multi-file updates',
before: { 'm1.txt': '1', 'm2.txt': 'A' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'm1.txt')),
d('1'),
a('2'),
updateFile(path.resolve(dir, 'm2.txt')),
d('A'),
a('B'),
end(),
]),
expect: { files: { 'm1.txt': '2', 'm2.txt': 'B' } }
});
// 28. Rename only (no content changes)
list.push({
name: 'rename only',
before: { 'r/from.txt': 'same' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'r/from.txt')),
moveTo(path.resolve(dir, 'r/to.txt')),
end(),
]),
expect: { files: { 'r/to.txt': 'same' }, notExists: ['r/from.txt'] }
});
// 29. EOF context matching at end
list.push({
name: 'EOF context matching',
before: { 'eof.txt': 'first\nsecond\nthird' },
patch: async ({ dir }) => buildPatch([
begin(),
updateFile(path.resolve(dir, 'eof.txt')),
k('second'),
a('inserted'),
k('third'),
endOfFile(),
end(),
]),
expect: { files: { 'eof.txt': 'first\nsecond\ninserted\nthird' } }
});
// 30. Add multiple files in single patch
list.push({
name: 'add multiple files',
before: {},
patch: async ({ dir }) => buildPatch([
begin(),
addFile(path.resolve(dir, 'multi/a.txt')),
a('A'),
addFile(path.resolve(dir, 'multi/b.txt')),
a('B'),
addFile(path.resolve(dir, 'multi/c.txt')),
a('C'),
end(),
]),
expect: { files: { 'multi/a.txt': 'A', 'multi/b.txt': 'B', 'multi/c.txt': 'C' } }
});
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 test runner:', err);
process.exit(1);
});