spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/tools/grep.test.ts4 * Description: Unit tests for the grep tool — fallback and ripgrep paths, ignore behavior, caps and spill.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { execSync } from "node:child_process";11import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";12import { afterEach, beforeEach, describe, expect, it } from "vitest";13import { createGrepTool } from "../../src/tools/index.js";14import type { TestHarness } from "./helpers.js";15import { fixture, makeHarness } from "./helpers.js";1617let h: TestHarness;18const grep = createGrepTool({ ripgrep: "never" });1920function hasRipgrep(): boolean {21 try {22 execSync("command -v rg", { stdio: "ignore" });23 return true;24 } catch {25 return false;26 }27}2829beforeEach(() => {30 h = makeHarness();31});3233afterEach(() => {34 rmSync(h.dir, { recursive: true, force: true });35});3637describe("grep tool (pure-JS fallback)", () => {38 it("finds matches grouped by file with line numbers", async () => {39 fixture(h, "src/engine.ts", "export class ContextEngine {\n private x = 1;\n}\n");40 fixture(h, "src/agent.ts", "import { ContextEngine } from './engine.js';\n");41 const result = await grep.execute({ pattern: "ContextEngine" }, h.ctx);42 expect(result.isError).toBeUndefined();43 expect(result.content).toContain('2 matches in 2 files for "ContextEngine":');44 expect(result.content).toContain("src/engine.ts");45 expect(result.content).toContain(" 1: export class ContextEngine {");46 expect(result.content).toContain("src/agent.ts");47 expect(result.metadata?.matches).toBe(2);48 expect(result.metadata?.files).toBe(2);49 });5051 it("filters with include globs", async () => {52 fixture(h, "a.ts", "needle\n");53 fixture(h, "b.py", "needle\n");54 const result = await grep.execute({ pattern: "needle", include: "*.ts" }, h.ctx);55 expect(result.content).toContain("1 match in 1 file");56 expect(result.content).toContain("a.ts");57 expect(result.content).not.toContain("b.py");58 });5960 it("honors .gitignore", async () => {61 fixture(h, ".gitignore", "generated/\n");62 fixture(h, "generated/out.ts", "needle\n");63 fixture(h, "src/in.ts", "needle\n");64 const result = await grep.execute({ pattern: "needle" }, h.ctx);65 expect(result.content).toContain("src/in.ts");66 expect(result.content).not.toContain("generated/out.ts");67 expect(result.metadata?.matches).toBe(1);68 });6970 it("skips binary files instead of failing", async () => {71 fixture(h, "ok.ts", "needle\n");72 const abs = `${h.dir}/bin.dat`;73 const bytes = Buffer.alloc(64);74 bytes.write("needle", 10, "utf8");75 writeFileSync(abs, bytes);76 const result = await grep.execute({ pattern: "needle" }, h.ctx);77 expect(result.isError).toBeUndefined();78 expect(result.metadata?.matches).toBe(1);79 });8081 it("reports zero matches with guidance", async () => {82 fixture(h, "a.ts", "nothing here\n");83 const result = await grep.execute({ pattern: "qzqzqz" }, h.ctx);84 expect(result.isError).toBeUndefined();85 expect(result.content).toBe(86 `No matches for "qzqzqz" in ${h.dir}. Check the regex or broaden the scope with path/include.`,87 );88 });8990 it("suggests the case-insensitive hit when casing is the only difference", async () => {91 fixture(h, "a.ts", "const contextEngine = 1;\n");92 const result = await grep.execute({ pattern: "ContextEngine" }, h.ctx);93 expect(result.content).toContain('did you mean "contextEngine"?');94 });9596 it("caps at 100 matching lines and spills the full results", async () => {97 const body = Array.from({ length: 150 }, (_, i) => `needle ${i}`).join("\n");98 fixture(h, "many.txt", `${body}\n`);99 const result = await grep.execute({ pattern: "needle" }, h.ctx);100 expect(result.content).toContain('Showing first 100 of 150 matching lines for "needle":');101 expect(result.content).toContain("[Results truncated. Full results: ");102 expect(result.content).toContain("or use a more specific pattern, path, or include filter.]");103 const spill = result.metadata?.truncation?.spillPath;104 expect(spill).toBeDefined();105 expect(readFileSync(spill!, "utf8").split("\n")).toHaveLength(150);106 expect(result.metadata?.truncation?.omittedLines).toBe(50);107 });108109 it("rejects invalid regular expressions before executing", async () => {110 const result = await grep.execute({ pattern: "([unclosed" }, h.ctx);111 expect(result.isError).toBe(true);112 expect(result.content).toContain('Invalid regular expression "([unclosed"');113 });114115 it("searches a specific subdirectory via path", async () => {116 fixture(h, "src/x.ts", "needle\n");117 fixture(h, "docs/y.md", "needle\n");118 const result = await grep.execute({ pattern: "needle", path: "src" }, h.ctx);119 expect(result.metadata?.matches).toBe(1);120 expect(result.content).toContain("src/x.ts");121 });122});123124describe.runIf(hasRipgrep())("grep tool (ripgrep path)", () => {125 const rgGrep = createGrepTool({ ripgrep: "auto" });126127 it("finds matches through the rg binary", async () => {128 fixture(h, "src/engine.ts", "export class ContextEngine {\n");129 const result = await rgGrep.execute({ pattern: "ContextEngine" }, h.ctx);130 expect(result.isError).toBeUndefined();131 expect(result.content).toContain('1 match in 1 file for "ContextEngine":');132 expect(result.content).toContain("src/engine.ts");133 expect(result.content).toContain(" 1: export class ContextEngine {");134 });135136 it("honors .gitignore through rg", async () => {137 // rg only honors .gitignore inside a git repository — mark the root as one.138 mkdirSync(`${h.dir}/.git`, { recursive: true });139 fixture(h, ".gitignore", "generated/\n");140 fixture(h, "generated/out.ts", "needle\n");141 fixture(h, "src/in.ts", "needle\n");142 const result = await rgGrep.execute({ pattern: "needle" }, h.ctx);143 expect(result.content).toContain("src/in.ts");144 expect(result.content).not.toContain("generated/out.ts");145 });146});147