#!/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 ?? '<>')}\nActual:\n${ellipsize(actual ?? '<>')}`); } } 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); });