diff --git a/cli.js b/cli.js index 1ef3e26..b65667b 100644 --- a/cli.js +++ b/cli.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import 'dotenv/config'; import OpenAI from 'openai'; +import terminalKit from 'terminal-kit'; //npm install tiktoken //csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9 @@ -8,8 +9,6 @@ import OpenAI from 'openai'; import { promises as fs } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { resourceUsage } from 'node:process'; - function renderUsage(usage) { const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens; @@ -34,6 +33,24 @@ function printIndented(indentNum, ...args) { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const term = terminalKit.terminal; + +// Global key handler so CTRL-C works everywhere (input fields, loops, etc.). +// Disable mouse tracking so terminal mouse wheel keeps controlling scrollback. +term.grabInput({ mouse: false }); +term.on('key', (name) => { + if (name === 'CTRL_C') { + term.grabInput(false); + term.processExit(0); + } +}); + +async function askUserForInput() { + term.cyan("Enter your request: "); + const input = await term.inputField({ mouse: false }).promise; + return input; +} + async function loadTools() { const toolsDir = path.join(__dirname, "tools"); const dirents = await fs.readdir(toolsDir, { withFileTypes: true }); @@ -49,100 +66,103 @@ async function loadTools() { return Object.fromEntries(toolEntries); } -streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), 'Erstelle eine React Project für eine Abovverwaltung. Mui, Sqllite, Express, Nodejs, KEIN Typescript, Aber ESM import. webpack, kein vite. HRM, nodemon'); +while(true){ + let counter = 0; + // Block for user input before kicking off the LLM loop + const userText = await askUserForInput(); + await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText || ''); + async function streamOnce(openai, userText) { + const toolsByFile = await loadTools(); + let previousResponseId; -let counter = 0; -async function streamOnce(openai, userText) { - const toolsByFile = await loadTools(); - let previousResponseId; + let input = [ + {"role": "developer", "content": [ {"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.` }] }, + {"role": "user", "content": [ {"type": "input_text","text": userText } ]}, + ] - let input = [ - {"role": "developer", "content": [ {"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.` }] }, - {"role": "user", "content": [ {"type": "input_text","text": userText } ]}, - ] + while(input.length > 0){ - while(input.length > 0){ - - const call = { - model: 'gpt-5-mini', - input: input, - text: { format: { type: 'text' }, verbosity: 'high' }, - reasoning: { effort: 'medium', summary: 'detailed' }, - tools: Object.values(toolsByFile).map(t => t.def), - store: true, - } - if(previousResponseId) call.previous_response_id = previousResponseId; - - console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------" - ,"\n",counter++,"\n",'----INPUT-----------------' - ,"\n",call.input.map(i => JSON.stringify(i)),"\n", - '--------CALL-------------',"\n"); - const stream = await openai.responses.stream(call); - stream.on('response.created', (event) => { - previousResponseId = event.response.id; - }); - stream.on('response.reasoning_summary_text.delta', (event) => { - process.stdout.write('o') - }); - stream.on('response.reasoning_summary_text.done', () => { - process.stdout.write('\n'); - //clear on next delta - }); - - stream.on('response.output_text.delta', (event) => { - process.stdout.write('.') - }); - - - stream.on('response.output_item.added', (event) => { - if(event.item && event.item.type === 'function_call'){ - //console.log('function call:', event.item); + const call = { + model: 'gpt-5-mini', + input: input, + text: { format: { type: 'text' }, verbosity: 'high' }, + reasoning: { effort: 'medium', summary: 'detailed' }, + tools: Object.values(toolsByFile).map(t => t.def), + store: true, } - }); - stream.on('response.function_call_arguments.delta', (event) => { - process.stdout.write('x'); - }); + if(previousResponseId) call.previous_response_id = previousResponseId; - const functionCalls = []; + console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------" + ,"\n",counter++,"\n",'----INPUT-----------------' + ,"\n",call.input.map(i => JSON.stringify(i)),"\n", + '--------CALL-------------',"\n"); + const stream = await openai.responses.stream(call); + stream.on('response.created', (event) => { + previousResponseId = event.response.id; + }); + stream.on('response.reasoning_summary_text.delta', (event) => { + process.stdout.write('o') + }); + stream.on('response.reasoning_summary_text.done', () => { + process.stdout.write('\n'); + //clear on next delta + }); - stream.on('response.output_item.done', async (event) => { - if(event.item && event.item.type === 'function_call'){ - const id = event.item.call_id; - const name = event.item.name; - let args = {}; - try { - args = JSON.parse(event.item.arguments); - } catch (e){ - console.error('Error parsing arguments:', e, event.item.arguments); + stream.on('response.output_text.delta', (event) => { + process.stdout.write('.') + }); + + + stream.on('response.output_item.added', (event) => { + if(event.item && event.item.type === 'function_call'){ + //console.log('function call:', event.item); } - console.log(' function call:', id, name); - functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) }); + }); + stream.on('response.function_call_arguments.delta', (event) => { + process.stdout.write('x'); + }); + + const functionCalls = []; + + stream.on('response.output_item.done', async (event) => { + if(event.item && event.item.type === 'function_call'){ + const id = event.item.call_id; + const name = event.item.name; + let args = {}; + try { + args = JSON.parse(event.item.arguments); + } catch (e){ + console.error('Error parsing arguments:', e, event.item.arguments); + } + console.log(' function call:', id, name); + functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) }); + } + }); + + stream.on('response.completed', async (event) => { + printIndented(10,renderUsage(event.response.usage)); + if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10, "Textresult:",event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text)); + }); + + await Array.fromAsync(stream); + input=[]; + + for (const call of functionCalls) { + //try { + const result = await call.promise; + input.push({ + type: "function_call_output", + call_id: call.id, + output: JSON.stringify(result), + }) + printIndented(10,'function call result:',result); + //} catch (err) { + // console.error('Error in function call:', call.name, err); + //} } - }); - - stream.on('response.completed', async (event) => { - printIndented(10,renderUsage(event.response.usage)); - if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10, "Textresult:",event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text)); - }); - - await Array.fromAsync(stream); - input=[]; - - for (const call of functionCalls) { - //try { - const result = await call.promise; - input.push({ - type: "function_call_output", - call_id: call.id, - output: JSON.stringify(result), - }) - printIndented(10,'function call result:',result); - //} catch (err) { - // console.error('Error in function call:', call.name, err); - //} } - } - //console.log('OPENAI STREAM FINISHED'); -} + //console.log('OPENAI STREAM FINISHED'); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c20fa85..ad68b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "node-fetch": "^2.7.0", "openai": "^4.104.0", "resolve-pkg-maps": "^1.0.0", + "terminal-kit": "^3.1.2", "tr46": "^0.0.3", "tsx": "^4.20.3", "typescript": "^5.9.2", @@ -53,6 +54,20 @@ "whatwg-url": "^5.0.0" } }, + "node_modules/@cronvel/get-pixels": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@cronvel/get-pixels/-/get-pixels-3.4.1.tgz", + "integrity": "sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g==", + "license": "MIT", + "dependencies": { + "jpeg-js": "^0.4.4", + "ndarray": "^1.0.19", + "ndarray-pack": "^1.1.1", + "node-bitmap": "0.0.1", + "omggif": "^1.0.10", + "pngjs": "^6.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", @@ -543,6 +558,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -555,6 +576,15 @@ "node": ">= 0.8" } }, + "node_modules/cwise-compiler": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", + "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==", + "license": "MIT", + "dependencies": { + "uniq": "^1.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -852,6 +882,33 @@ "ms": "^2.0.0" } }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/lazyness": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/lazyness/-/lazyness-1.2.0.tgz", + "integrity": "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -888,6 +945,43 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ndarray": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", + "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, + "node_modules/ndarray-pack": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ndarray-pack/-/ndarray-pack-1.2.1.tgz", + "integrity": "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==", + "license": "MIT", + "dependencies": { + "cwise-compiler": "^1.1.2", + "ndarray": "^1.0.13" + } + }, + "node_modules/nextgen-events": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nextgen-events/-/nextgen-events-1.5.3.tgz", + "integrity": "sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-bitmap": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz", + "integrity": "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==", + "engines": { + "node": ">=v0.6.5" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -928,6 +1022,12 @@ } } }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", @@ -958,6 +1058,15 @@ } } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -967,12 +1076,67 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/seventh": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/seventh/-/seventh-0.9.4.tgz", + "integrity": "sha512-O85mosi4sOfxG+slvqy0j7zLuFD4ylUgEMt7Pvt9Q/wnwNwG/6MNnHKzV9JkAoPoPM26t/DLFn17p7o7u5kIBA==", + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.5" + }, + "engines": { + "node": ">=16.13.0" + } + }, + "node_modules/string-kit": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/string-kit/-/string-kit-0.19.2.tgz", + "integrity": "sha512-o5rhsZy4WS76+uMc4fkcQYM7dcdxe8wKCoLeLqCcGZxbUmtawkBE8G0JS6ooBnBOy+j1MpZ1IgWIuojIr71vPw==", + "license": "MIT", + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/terminal-kit": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-3.1.2.tgz", + "integrity": "sha512-ro2FyU4A+NwA74DLTYTnoCFYuFpgV1aM07IS6MPrJeajoI2hwF44EdUqjoTmKEl6srYDWtbVkc/b1C16iUnxFQ==", + "license": "MIT", + "dependencies": { + "@cronvel/get-pixels": "^3.4.1", + "chroma-js": "^2.4.2", + "lazyness": "^1.2.0", + "ndarray": "^1.0.19", + "nextgen-events": "^1.5.3", + "seventh": "^0.9.2", + "string-kit": "^0.19.0", + "tree-kit": "^0.8.7" + }, + "engines": { + "node": ">=16.13.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.8.8.tgz", + "integrity": "sha512-L7zwpXp0/Nha6mljVcVOnhhxuCkFRWmt26wza3TKnyMBewid4F2vyiVdcSsw41ZoG1Wj+3lM48Er9lhttbxfLA==", + "license": "MIT", + "engines": { + "node": ">=16.13.0" + } + }, "node_modules/tsx": { "version": "4.20.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", @@ -1011,6 +1175,12 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", diff --git a/package.json b/package.json index b12a533..971df7b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "main": "cli.js", "dependencies": { + "terminal-kit": "^3.1.2", "dotenv": "^16.4.5", "abort-controller": "^3.0.0", "agentkeepalive": "^4.6.0", diff --git a/src/example.js b/src/example.js new file mode 100644 index 0000000..cdf05cf --- /dev/null +++ b/src/example.js @@ -0,0 +1,41 @@ +// example.js +import StatVitTerm from './stat-vit-term.js'; + +const termSession = new StatVitTerm(); + +termSession.on('std', (data) => { + // Entferne ANSI Escape Sequenzen für saubere Ausgabe + const cleanData = data.replace(/\u001b\[[0-9;]*m/g, '').replace(/\u001b\][0-9;]*\u0007/g, ''); + process.stdout.write(cleanData); +}); + +termSession.on('err', (data) => { + process.stderr.write(data); +}); + +termSession.on('close', (code) => { + console.log(`\nTerminal closed with code: ${code}`); +}); + +termSession.on('ready', () => { + console.log('Terminal ready\n'); + + // Test mit einem Befehl, der wirklich lange läuft + console.log('--- Starte langen Prozess ---'); + termSession.input('sleep 10 &\n'); // Hintergrundprozess + + setTimeout(() => { + console.log('\n--- Jobs anzeigen ---'); + termSession.input('jobs\n'); + }, 1000); + + setTimeout(() => { + console.log('\n--- Alle Prozesse stoppen ---'); + termSession.input('^C'); // Das sollte alle Prozesse stoppen + }, 2000); + + setTimeout(() => { + console.log('\n--- Terminal beenden ---'); + termSession.kill(); + }, 3000); +}); \ No newline at end of file diff --git a/src/stat-vit-term.js b/src/stat-vit-term.js new file mode 100644 index 0000000..b175290 --- /dev/null +++ b/src/stat-vit-term.js @@ -0,0 +1,94 @@ +// stat-vit-term.js +import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; + +class StatVitTerm extends EventEmitter { + constructor(options = {}) { + super(); + this.options = { + shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', + cwd: process.cwd(), + env: process.env, + ...options + }; + this.process = null; + this.isRunning = false; + this._init(); + } + + _init() { + try { + this.process = spawn(this.options.shell, [], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: this.options.cwd, + env: this.options.env + }); + + this.process.stdout.on('data', (data) => { + this.emit('std', data.toString()); + }); + + this.process.stderr.on('data', (data) => { + this.emit('std', data.toString()); // stderr als std behandeln + }); + + this.process.on('close', (code) => { + this.isRunning = false; + this.emit('close', code); + }); + + this.process.on('error', (err) => { + this.emit('err', err.message); + }); + + this.isRunning = true; + setTimeout(() => { + this.emit('ready'); + }, 100); + + } catch (err) { + this.emit('err', err.message); + } + } + + input(data) { + if (!this.isRunning || !this.process) { + this.emit('err', 'Terminal is not running'); + return false; + } + + try { + if (data === '^C') { + // WICHTIG: Sende Ctrl+C direkt an stdin + this.process.stdin.write('\x03'); + return true; + } else if (data === '^Z') { + this.process.stdin.write('\x1A'); + return true; + } + + this.process.stdin.write(data); + return true; + } catch (err) { + this.emit('err', err.message); + return false; + } + } + + kill() { + if (this.process) { + this.process.kill('SIGTERM'); + this.isRunning = false; + } + } + + get pid() { + return this.process ? this.process.pid : null; + } + + get running() { + return this.isRunning; + } +} + +export default StatVitTerm; \ No newline at end of file diff --git a/tests/run-all.js b/tests/run-all.js deleted file mode 100644 index c76d5cf..0000000 --- a/tests/run-all.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/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 deleted file mode 100644 index 89ce5d2..0000000 --- a/tests/run-listfiles-tests.js +++ /dev/null @@ -1,200 +0,0 @@ -#!/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:/ } - }); - - // 7. Hidden excluded when includeHidden=false - list.push({ - name: 'hidden excluded by default', - before: { '.hidden': 'h', 'shown.txt': 's' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }), - expect: { cwdFromArgs: true, files: [['shown.txt', 'f', 1]] } - }); - - // 8. Depth 0 shows only top-level entries - list.push({ - name: 'depth 0 top-level only', - before: { 'a.txt': 'A', 'sub/b.txt': 'B' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }), - expect: { cwdFromArgs: true, files: [['a.txt', 'f', 1], ['sub', 'd', null]] } - }); - - // 9. Pass hidden file path with includeHidden=false (excluded) - list.push({ - name: 'hidden file path excluded when flag false', - before: { '.only.txt': 'x' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: false }), - expect: { cwdFromArgs: 'file', files: [] } - }); - - // 10. Pass hidden file path with includeHidden=true (included) - list.push({ - name: 'hidden file path included when flag true', - before: { '.only.txt': 'x' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: true }), - expect: { cwdFromArgs: 'file', files: [['.only.txt', 'f', 1]] } - }); - - // 11. Path normalization outside chroot -> error - list.push({ - name: 'outside chroot error', - before: {}, - args: async () => ({ path: '../../etc', depth: 1, includeHidden: false }), - expect: { errorRegex: /Path escapes chroot boundary/ } - }); - - 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 deleted file mode 100644 index fe882da..0000000 --- a/tests/run-readfile-tests.js +++ /dev/null @@ -1,182 +0,0 @@ -#!/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') } - }); - - // 7. Skip beyond file length -> empty - list.push({ - name: 'skip beyond length returns empty', - before: { 's.txt': 'A\nB' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 's.txt')), linesToSkip: 10, linesToRead: 5 }), - expect: { equals: '' } - }); - - // 8. Skip to last line and read one - list.push({ - name: 'skip to last line and read one', - before: { 't.txt': 'L1\nL2\nL3' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 't.txt')), linesToSkip: 2, linesToRead: 1 }), - expect: { equals: 'L3' } - }); - - // 9. Read exactly N lines from middle - list.push({ - name: 'read middle two lines', - before: { 'u.txt': 'A\nB\nC\nD' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'u.txt')), linesToSkip: 1, linesToRead: 2 }), - expect: { equals: 'B\nC' } - }); - - // 10. Empty file read -> empty string - list.push({ - name: 'empty file read', - before: { 'empty.txt': '' }, - args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'empty.txt')), linesToSkip: 0, linesToRead: 100 }), - expect: { equals: '' } - }); - - 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 deleted file mode 100644 index 7488983..0000000 --- a/tests/run-ripgrep-tests.js +++ /dev/null @@ -1,238 +0,0 @@ -#!/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:/ } - }); - - // 7. No line numbers (n_flag false) - list.push({ - name: 'no line numbers', - before: { 'a.txt': 'foo\nbar\nfoo' }, - args: async ({ dir }) => ({ pattern: 'foo', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: false, i_flag: false }), - expect: { equals: [ - `${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo`, - `${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo` - ].join('\n') } - }); - - // 8. filePattern include-only to exclude .md (tool supports single -g, so include *.txt) - list.push({ - name: 'filePattern include-only excludes md', - before: { 'a.txt': 'hit', 'b.md': 'hit' }, - args: async ({ dir }) => ({ pattern: 'hit', filePattern: path.relative(chrootRoot, path.join(dir, '**/*.txt')), n_flag: true, i_flag: false }), - expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '08-filepattern-include-only-excludes-md/a.txt'))}:1:hit` } - }); - - // 9. Empty filePattern searches all (we'll scope to the case dir by pattern and path shape) - list.push({ - name: 'empty filePattern searches all', - before: { 'x.js': 'Hello', 'y.txt': 'Hello' }, - 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, '09-empty-filepattern-searches-all/x.js'))}:1:Hello`, - `${path.relative(chrootRoot, path.join(sandboxRoot, '09-empty-filepattern-searches-all/y.txt'))}:1:Hello` - ].join('\n') } - }); - - // 10. Anchored regex - list.push({ - name: 'anchored regex', - before: { 'a.txt': 'Hello\nHello world\nHello' }, - 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, '10-anchored-regex/a.txt'))}:1:Hello`, - `${path.relative(chrootRoot, path.join(sandboxRoot, '10-anchored-regex/a.txt'))}:3:Hello` - ].join('\n') } - }); - - // 11. Special regex characters - list.push({ - name: 'special regex characters', - before: { 'a.txt': 'a+b?c\\d and a+b?c\\d' }, - args: async ({ dir }) => ({ pattern: 'a\\+b\\?c\\\\d', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }), - expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '11-special-regex-characters/a.txt'))}:1:a+b?c\\d and a+b?c\\d` } - }); - - // 12. Multiple files across dirs deterministic order - list.push({ - name: 'multi dirs deterministic', - before: { 'b/b.txt': 'X', 'a/a.txt': 'X' }, - args: async ({ dir }) => ({ pattern: '^X$', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }), - expect: { equals: [ - `${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/a/a.txt'))}:1:X`, - `${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/b/b.txt'))}:1:X` - ].join('\n') } - }); - - 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/tests/run-tests.js b/tests/run-tests.js deleted file mode 100644 index 8a6105c..0000000 --- a/tests/run-tests.js +++ /dev/null @@ -1,626 +0,0 @@ -#!/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); -}); - -