627 lines
16 KiB
JavaScript
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);
|
|
});
|
|
|
|
|