Integrate terminal input handling into CLI tool using terminal-kit. Implement global key handler for CTRL-C and prompt user for input before initiating the LLM loop. Remove outdated test scripts for improved codebase clarity.
This commit is contained in:
32
cli.js
32
cli.js
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
|
import terminalKit from 'terminal-kit';
|
||||||
|
|
||||||
//npm install tiktoken
|
//npm install tiktoken
|
||||||
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
|
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
|
||||||
@@ -8,8 +9,6 @@ import OpenAI from 'openai';
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resourceUsage } from 'node:process';
|
|
||||||
|
|
||||||
|
|
||||||
function renderUsage(usage) {
|
function renderUsage(usage) {
|
||||||
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
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 __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() {
|
async function loadTools() {
|
||||||
const toolsDir = path.join(__dirname, "tools");
|
const toolsDir = path.join(__dirname, "tools");
|
||||||
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
|
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
|
||||||
@@ -49,11 +66,13 @@ async function loadTools() {
|
|||||||
return Object.fromEntries(toolEntries);
|
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;
|
||||||
let counter = 0;
|
// Block for user input before kicking off the LLM loop
|
||||||
async function streamOnce(openai, userText) {
|
const userText = await askUserForInput();
|
||||||
|
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText || '');
|
||||||
|
async function streamOnce(openai, userText) {
|
||||||
const toolsByFile = await loadTools();
|
const toolsByFile = await loadTools();
|
||||||
let previousResponseId;
|
let previousResponseId;
|
||||||
|
|
||||||
@@ -145,4 +164,5 @@ async function streamOnce(openai, userText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//console.log('OPENAI STREAM FINISHED');
|
//console.log('OPENAI STREAM FINISHED');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
170
package-lock.json
generated
170
package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
"resolve-pkg-maps": "^1.0.0",
|
"resolve-pkg-maps": "^1.0.0",
|
||||||
|
"terminal-kit": "^3.1.2",
|
||||||
"tr46": "^0.0.3",
|
"tr46": "^0.0.3",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
@@ -53,6 +54,20 @@
|
|||||||
"whatwg-url": "^5.0.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.8",
|
"version": "0.25.8",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
|
"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"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -555,6 +576,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -852,6 +882,33 @@
|
|||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -888,6 +945,43 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"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": {
|
"node_modules/openai": {
|
||||||
"version": "4.104.0",
|
"version": "4.104.0",
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.20.3",
|
"version": "4.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||||
@@ -1011,6 +1175,12 @@
|
|||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "4.0.0-beta.3",
|
"version": "4.0.0-beta.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "cli.js",
|
"main": "cli.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"terminal-kit": "^3.1.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"agentkeepalive": "^4.6.0",
|
"agentkeepalive": "^4.6.0",
|
||||||
|
|||||||
41
src/example.js
Normal file
41
src/example.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
94
src/stat-vit-term.js
Normal file
94
src/stat-vit-term.js
Normal file
@@ -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;
|
||||||
@@ -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); });
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ?? '<<undefined>>'));
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ?? '<<undefined>>'));
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ?? '<<undefined>>'));
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ?? '<<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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user