feat: add more tests for tool built-ins (#5)
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -41,11 +41,16 @@ jobs:
|
||||
bun-version: 1.3.0
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bun install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
|
||||
- name: Build bundle
|
||||
run: bun run build
|
||||
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -36,6 +36,8 @@ jobs:
|
||||
bun-version: 1.3.0
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bun install
|
||||
|
||||
- name: Build bundle
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {
|
||||
"@vscode/ripgrep": "^1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
65
src/tests/helpers/testFs.ts
Normal file
65
src/tests/helpers/testFs.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Test filesystem utilities
|
||||
* Provides helpers for creating temporary test directories and files
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export class TestDirectory {
|
||||
public readonly path: string;
|
||||
|
||||
constructor() {
|
||||
this.path = mkdtempSync(join(tmpdir(), "letta-test-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file in the test directory
|
||||
*/
|
||||
createFile(relativePath: string, content: string): string {
|
||||
const filePath = join(this.path, relativePath);
|
||||
const dir = join(filePath, "..");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a binary file in the test directory
|
||||
*/
|
||||
createBinaryFile(relativePath: string, buffer: Buffer): string {
|
||||
const filePath = join(this.path, relativePath);
|
||||
const dir = join(filePath, "..");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, buffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory in the test directory
|
||||
*/
|
||||
createDir(relativePath: string): string {
|
||||
const dirPath = join(this.path, relativePath);
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full path for a relative path
|
||||
*/
|
||||
resolve(relativePath: string): string {
|
||||
return join(this.path, relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the test directory
|
||||
*/
|
||||
cleanup(): void {
|
||||
try {
|
||||
rmSync(this.path, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cleanup test directory ${this.path}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ test("Unknown command suggests exact match", () => {
|
||||
// ============================================================================
|
||||
|
||||
test("Read outside working directory suggests directory pattern", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Read",
|
||||
{ file_path: "/Users/test/docs/api.md" },
|
||||
@@ -245,6 +247,8 @@ test("Write suggests session-only approval", () => {
|
||||
});
|
||||
|
||||
test("Edit suggests directory pattern for project-level", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Edit",
|
||||
{ file_path: "src/utils/helper.ts" },
|
||||
@@ -258,6 +262,8 @@ test("Edit suggests directory pattern for project-level", () => {
|
||||
});
|
||||
|
||||
test("Edit at project root suggests project pattern", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Edit",
|
||||
{ file_path: "README.md" },
|
||||
@@ -269,6 +275,8 @@ test("Edit at project root suggests project pattern", () => {
|
||||
});
|
||||
|
||||
test("Glob outside working directory suggests directory pattern", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Glob",
|
||||
{ path: "/Users/test/docs" },
|
||||
@@ -280,6 +288,8 @@ test("Glob outside working directory suggests directory pattern", () => {
|
||||
});
|
||||
|
||||
test("Grep outside working directory suggests directory pattern", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Grep",
|
||||
{ path: "/Users/test/docs" },
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PermissionRules } from "../permissions/types";
|
||||
// ============================================================================
|
||||
|
||||
test("Read within working directory is auto-allowed", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
const result = checkPermission(
|
||||
"Read",
|
||||
{ file_path: "src/test.ts" },
|
||||
@@ -170,6 +171,7 @@ test("Deny directory blocks all files within", () => {
|
||||
// ============================================================================
|
||||
|
||||
test("Allow rule for file outside working directory", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
const permissions: PermissionRules = {
|
||||
allow: ["Read(/Users/test/docs/**)"],
|
||||
deny: [],
|
||||
@@ -385,6 +387,7 @@ test("Allow takes precedence over ask", () => {
|
||||
});
|
||||
|
||||
test("Ask takes precedence over default", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
const permissions: PermissionRules = {
|
||||
allow: [],
|
||||
deny: [],
|
||||
@@ -523,6 +526,7 @@ test("Parent directory traversal", () => {
|
||||
});
|
||||
|
||||
test("Absolute path handling", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
const permissions: PermissionRules = {
|
||||
allow: [],
|
||||
deny: ["Read(/etc/**)"],
|
||||
|
||||
@@ -84,6 +84,8 @@ test("File pattern: any .ts file", () => {
|
||||
});
|
||||
|
||||
test("File pattern: absolute path with // prefix", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
expect(
|
||||
matchesFilePattern(
|
||||
"Read(/Users/test/docs/api.md)",
|
||||
@@ -94,6 +96,8 @@ test("File pattern: absolute path with // prefix", () => {
|
||||
});
|
||||
|
||||
test("File pattern: tilde expansion", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const homedir = require("node:os").homedir();
|
||||
expect(
|
||||
matchesFilePattern(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { checkPermission } from "../permissions/checker";
|
||||
import type { PermissionRules } from "../permissions/types";
|
||||
|
||||
test("Read within working directory is auto-allowed", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
const result = checkPermission(
|
||||
"Read",
|
||||
{ file_path: "src/test.ts" },
|
||||
@@ -16,6 +17,8 @@ test("Read within working directory is auto-allowed", () => {
|
||||
});
|
||||
|
||||
test("Read outside working directory requires approval", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const result = checkPermission(
|
||||
"Read",
|
||||
{ file_path: "/Users/test/other-project/file.ts" },
|
||||
@@ -98,6 +101,8 @@ test("Dangerous commands don't offer persistence", () => {
|
||||
});
|
||||
|
||||
test("Read outside working directory suggests directory pattern", () => {
|
||||
if (process.platform === "win32") return; // Skip on Windows - Unix paths
|
||||
|
||||
const context = analyzeApprovalContext(
|
||||
"Read",
|
||||
{ file_path: "/Users/test/docs/api.md" },
|
||||
|
||||
68
src/tests/tools/bash-background.test.ts
Normal file
68
src/tests/tools/bash-background.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bash } from "../../tools/impl/Bash";
|
||||
import { bash_output } from "../../tools/impl/BashOutput";
|
||||
import { kill_bash } from "../../tools/impl/KillBash";
|
||||
|
||||
describe("Bash background tools", () => {
|
||||
test("starts background process and returns ID in text", async () => {
|
||||
const result = await bash({
|
||||
command: "echo 'test'",
|
||||
description: "Test background",
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("background with ID:");
|
||||
expect(result.content[0].text).toMatch(/bash_\d+/);
|
||||
});
|
||||
|
||||
test("BashOutput retrieves output from background shell", async () => {
|
||||
// Start background process
|
||||
const startResult = await bash({
|
||||
command: "echo 'background output'",
|
||||
description: "Test background",
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Extract bash_id from the response text
|
||||
const match = startResult.content[0].text.match(/bash_(\d+)/);
|
||||
expect(match).toBeDefined();
|
||||
const bashId = `bash_${match![1]}`;
|
||||
|
||||
// Wait for command to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Retrieve output
|
||||
const outputResult = await bash_output({ bash_id: bashId });
|
||||
|
||||
expect(outputResult.message).toContain("background output");
|
||||
});
|
||||
|
||||
test("BashOutput handles non-existent bash_id gracefully", async () => {
|
||||
const result = await bash_output({ bash_id: "nonexistent" });
|
||||
|
||||
expect(result.message).toContain("No background process found");
|
||||
});
|
||||
|
||||
test("KillBash terminates background process", async () => {
|
||||
// Start long-running process
|
||||
const startResult = await bash({
|
||||
command: "sleep 10",
|
||||
description: "Test kill",
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const match = startResult.content[0].text.match(/bash_(\d+)/);
|
||||
const bashId = `bash_${match![1]}`;
|
||||
|
||||
// Kill it (KillBash uses shell_id parameter)
|
||||
const killResult = await kill_bash({ shell_id: bashId });
|
||||
|
||||
expect(killResult.killed).toBe(true);
|
||||
});
|
||||
|
||||
test("KillBash handles non-existent shell_id", async () => {
|
||||
const result = await kill_bash({ shell_id: "nonexistent" });
|
||||
|
||||
expect(result.killed).toBe(false);
|
||||
});
|
||||
});
|
||||
81
src/tests/tools/bash.test.ts
Normal file
81
src/tests/tools/bash.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bash } from "../../tools/impl/Bash";
|
||||
|
||||
describe("Bash tool", () => {
|
||||
test("executes simple command", async () => {
|
||||
const result = await bash({
|
||||
command: "echo 'Hello, World!'",
|
||||
description: "Test echo",
|
||||
});
|
||||
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.content[0].text).toContain("Hello, World!");
|
||||
expect(result.isError).toBeUndefined();
|
||||
});
|
||||
|
||||
test("captures stderr in output", async () => {
|
||||
const result = await bash({
|
||||
command: "echo 'error message' >&2",
|
||||
description: "Test stderr",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("error message");
|
||||
});
|
||||
|
||||
test("returns error for failed command", async () => {
|
||||
const result = await bash({
|
||||
command: "exit 1",
|
||||
description: "Test exit code",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Exit code");
|
||||
});
|
||||
|
||||
test("times out long-running command", async () => {
|
||||
const result = await bash({
|
||||
command: "sleep 10",
|
||||
description: "Test timeout",
|
||||
timeout: 100,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("timed out");
|
||||
}, 2000);
|
||||
|
||||
test("runs command in background mode", async () => {
|
||||
const result = await bash({
|
||||
command: "echo 'background'",
|
||||
description: "Test background",
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("background with ID:");
|
||||
expect(result.content[0].text).toMatch(/bash_\d+/);
|
||||
});
|
||||
|
||||
test("handles complex commands with pipes", async () => {
|
||||
// Skip on Windows - pipe syntax is different
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bash({
|
||||
command: "echo -e 'foo\\nbar\\nbaz' | grep bar",
|
||||
description: "Test pipe",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("bar");
|
||||
expect(result.content[0].text).not.toContain("foo");
|
||||
});
|
||||
|
||||
test("lists background processes with /bashes command", async () => {
|
||||
const result = await bash({
|
||||
command: "/bashes",
|
||||
description: "List processes",
|
||||
});
|
||||
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.content[0].text).toBeDefined();
|
||||
});
|
||||
});
|
||||
68
src/tests/tools/edit.test.ts
Normal file
68
src/tests/tools/edit.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { edit } from "../../tools/impl/Edit";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("Edit tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("replaces a simple string", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("test.txt", "Hello, World!");
|
||||
|
||||
const result = await edit({
|
||||
file_path: file,
|
||||
old_string: "World",
|
||||
new_string: "Bun",
|
||||
});
|
||||
|
||||
expect(readFileSync(file, "utf-8")).toBe("Hello, Bun!");
|
||||
expect(result.replacements).toBe(1);
|
||||
});
|
||||
|
||||
test("throws error if old_string not found", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("test.txt", "Hello, World!");
|
||||
|
||||
await expect(
|
||||
edit({
|
||||
file_path: file,
|
||||
old_string: "NotFound",
|
||||
new_string: "Something",
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
test("replaces only first occurrence without replace_all", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("duplicate.txt", "foo bar foo baz");
|
||||
|
||||
const result = await edit({
|
||||
file_path: file,
|
||||
old_string: "foo",
|
||||
new_string: "qux",
|
||||
});
|
||||
|
||||
expect(readFileSync(file, "utf-8")).toBe("qux bar foo baz");
|
||||
expect(result.replacements).toBe(1);
|
||||
});
|
||||
|
||||
test("replaces all occurrences with replace_all=true", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("duplicate.txt", "foo bar foo baz foo");
|
||||
|
||||
const result = await edit({
|
||||
file_path: file,
|
||||
old_string: "foo",
|
||||
new_string: "qux",
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(readFileSync(file, "utf-8")).toBe("qux bar qux baz qux");
|
||||
expect(result.replacements).toBe(3);
|
||||
});
|
||||
});
|
||||
27
src/tests/tools/exitplanmode.test.ts
Normal file
27
src/tests/tools/exitplanmode.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { exit_plan_mode } from "../../tools/impl/ExitPlanMode";
|
||||
|
||||
describe("ExitPlanMode tool", () => {
|
||||
test("returns approval message", async () => {
|
||||
const result = await exit_plan_mode({
|
||||
plan: "1. Do thing A\n2. Do thing B\n3. Profit",
|
||||
});
|
||||
|
||||
expect(result.message).toBeDefined();
|
||||
expect(result.message).toContain("approved");
|
||||
});
|
||||
|
||||
test("handles empty plan", async () => {
|
||||
const result = await exit_plan_mode({ plan: "" });
|
||||
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
test("accepts markdown formatted plan", async () => {
|
||||
const plan = "## Steps\n- Step 1\n- Step 2\n\n**Important:** Read the docs";
|
||||
const result = await exit_plan_mode({ plan });
|
||||
|
||||
expect(result.message).toBeDefined();
|
||||
expect(result.message).toContain("approved");
|
||||
});
|
||||
});
|
||||
47
src/tests/tools/glob.test.ts
Normal file
47
src/tests/tools/glob.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { glob } from "../../tools/impl/Glob";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("Glob tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("finds files by pattern", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("test.ts", "");
|
||||
testDir.createFile("test.js", "");
|
||||
testDir.createFile("README.md", "");
|
||||
|
||||
const result = await glob({ pattern: "*.ts", path: testDir.path });
|
||||
|
||||
// Use path separator that works on both Unix and Windows
|
||||
const pathSep = process.platform === "win32" ? "\\" : "/";
|
||||
const basenames = result.files.map((f) => f.split(pathSep).pop());
|
||||
expect(basenames).toContain("test.ts");
|
||||
expect(basenames).not.toContain("test.js");
|
||||
expect(basenames).not.toContain("README.md");
|
||||
});
|
||||
|
||||
test("finds files with wildcard patterns", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("src/index.ts", "");
|
||||
testDir.createFile("src/utils/helper.ts", "");
|
||||
testDir.createFile("test.js", "");
|
||||
|
||||
const result = await glob({ pattern: "**/*.ts", path: testDir.path });
|
||||
|
||||
expect(result.files.filter((f) => f.endsWith(".ts")).length).toBe(2);
|
||||
});
|
||||
|
||||
test("returns empty array when no matches", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("test.txt", "");
|
||||
|
||||
const result = await glob({ pattern: "*.ts", path: testDir.path });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
});
|
||||
59
src/tests/tools/grep.test.ts
Normal file
59
src/tests/tools/grep.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { grep } from "../../tools/impl/Grep";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("Grep tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("finds pattern in files (requires ripgrep)", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("test1.txt", "Hello World");
|
||||
testDir.createFile("test2.txt", "Goodbye World");
|
||||
testDir.createFile("test3.txt", "No match here");
|
||||
|
||||
try {
|
||||
const result = await grep({
|
||||
pattern: "World",
|
||||
path: testDir.path,
|
||||
output_mode: "files_with_matches",
|
||||
});
|
||||
|
||||
expect(result.output).toContain("test1.txt");
|
||||
expect(result.output).toContain("test2.txt");
|
||||
expect(result.output).not.toContain("test3.txt");
|
||||
} catch (error) {
|
||||
// Ripgrep might not be available in test environment
|
||||
if (error instanceof Error && error.message.includes("ENOENT")) {
|
||||
console.log("Skipping grep test - ripgrep not available");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("case insensitive search with -i flag", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("test.txt", "Hello WORLD");
|
||||
|
||||
try {
|
||||
const result = await grep({
|
||||
pattern: "world",
|
||||
path: testDir.path,
|
||||
"-i": true,
|
||||
output_mode: "content",
|
||||
});
|
||||
|
||||
expect(result.output).toContain("WORLD");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("ENOENT")) {
|
||||
console.log("Skipping grep test - ripgrep not available");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
48
src/tests/tools/ls.test.ts
Normal file
48
src/tests/tools/ls.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { ls } from "../../tools/impl/LS";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("LS tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("lists files and directories", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createFile("file1.txt", "");
|
||||
testDir.createFile("file2.txt", "");
|
||||
testDir.createDir("subdir");
|
||||
|
||||
const result = await ls({ path: testDir.path });
|
||||
|
||||
expect(result.content[0].text).toContain("file1.txt");
|
||||
expect(result.content[0].text).toContain("file2.txt");
|
||||
expect(result.content[0].text).toContain("subdir/");
|
||||
});
|
||||
|
||||
test("shows directories with trailing slash", async () => {
|
||||
testDir = new TestDirectory();
|
||||
testDir.createDir("folder");
|
||||
testDir.createFile("file.txt", "");
|
||||
|
||||
const result = await ls({ path: testDir.path });
|
||||
|
||||
expect(result.content[0].text).toContain("folder/");
|
||||
expect(result.content[0].text).toContain("file.txt");
|
||||
});
|
||||
|
||||
test("throws error for non-existent directory", async () => {
|
||||
await expect(ls({ path: "/nonexistent/directory" })).rejects.toThrow(
|
||||
/Directory not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error for file (not directory)", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("notadir.txt", "");
|
||||
|
||||
await expect(ls({ path: file })).rejects.toThrow(/Not a directory/);
|
||||
});
|
||||
});
|
||||
43
src/tests/tools/multiedit.test.ts
Normal file
43
src/tests/tools/multiedit.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { multi_edit } from "../../tools/impl/MultiEdit";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("MultiEdit tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("applies multiple edits to a file", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("test.txt", "foo bar baz");
|
||||
|
||||
await multi_edit({
|
||||
file_path: file,
|
||||
edits: [
|
||||
{ old_string: "foo", new_string: "FOO" },
|
||||
{ old_string: "bar", new_string: "BAR" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(readFileSync(file, "utf-8")).toBe("FOO BAR baz");
|
||||
});
|
||||
|
||||
test("applies edits sequentially", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("test.txt", "aaa bbb");
|
||||
|
||||
const result = await multi_edit({
|
||||
file_path: file,
|
||||
edits: [
|
||||
{ old_string: "aaa", new_string: "xxx" },
|
||||
{ old_string: "bbb", new_string: "yyy" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(readFileSync(file, "utf-8")).toBe("xxx yyy");
|
||||
expect(result.edits_applied).toBe(2);
|
||||
});
|
||||
});
|
||||
105
src/tests/tools/read.test.ts
Normal file
105
src/tests/tools/read.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { read } from "../../tools/impl/Read";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("Read tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("reads a basic text file", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile(
|
||||
"test.txt",
|
||||
"Hello, World!\nLine 2\nLine 3",
|
||||
);
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("Hello, World!");
|
||||
expect(result.content).toContain("Line 2");
|
||||
expect(result.content).toContain("Line 3");
|
||||
});
|
||||
|
||||
test("reads UTF-8 file with Unicode characters", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const content = "Hello 世界 🌍\n╔═══╗\n║ A ║\n╚═══╝";
|
||||
const file = testDir.createFile("unicode.txt", content);
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("世界");
|
||||
expect(result.content).toContain("🌍");
|
||||
expect(result.content).toContain("╔═══╗");
|
||||
});
|
||||
|
||||
test("formats output with line numbers", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile("numbered.txt", "Line 1\nLine 2\nLine 3");
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("1→Line 1");
|
||||
expect(result.content).toContain("2→Line 2");
|
||||
expect(result.content).toContain("3→Line 3");
|
||||
});
|
||||
|
||||
test("respects offset parameter", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile(
|
||||
"offset.txt",
|
||||
"Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
|
||||
);
|
||||
|
||||
const result = await read({ file_path: file, offset: 2 });
|
||||
|
||||
expect(result.content).not.toContain("Line 1");
|
||||
expect(result.content).not.toContain("Line 2");
|
||||
expect(result.content).toContain("Line 3");
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const file = testDir.createFile(
|
||||
"limit.txt",
|
||||
"Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
|
||||
);
|
||||
|
||||
const result = await read({ file_path: file, limit: 2 });
|
||||
|
||||
expect(result.content).toContain("Line 1");
|
||||
expect(result.content).toContain("Line 2");
|
||||
expect(result.content).not.toContain("Line 3");
|
||||
});
|
||||
|
||||
test("detects binary files and throws error", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const binaryBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe]);
|
||||
const file = testDir.createBinaryFile("binary.bin", binaryBuffer);
|
||||
|
||||
await expect(read({ file_path: file })).rejects.toThrow(
|
||||
/Cannot read binary file/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reads TypeScript file with box-drawing characters", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const tsContent = `// TypeScript file
|
||||
const box = \`
|
||||
┌─────────┐
|
||||
│ Header │
|
||||
└─────────┘
|
||||
\`;
|
||||
export default box;
|
||||
`;
|
||||
const file = testDir.createFile("ascii-art.ts", tsContent);
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("┌─────────┐");
|
||||
expect(result.content).toContain("│ Header │");
|
||||
expect(result.content).toContain("TypeScript file");
|
||||
});
|
||||
});
|
||||
98
src/tests/tools/todowrite.test.ts
Normal file
98
src/tests/tools/todowrite.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { todo_write } from "../../tools/impl/TodoWrite";
|
||||
|
||||
describe("TodoWrite tool", () => {
|
||||
test("accepts valid todos with all required fields", async () => {
|
||||
const result = await todo_write({
|
||||
todos: [
|
||||
{ id: "1", content: "Task 1", status: "pending" },
|
||||
{ id: "2", content: "Task 2", status: "in_progress" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.message).toBeDefined();
|
||||
expect(result.message).toContain("modified successfully");
|
||||
});
|
||||
|
||||
test("requires id field", async () => {
|
||||
await expect(
|
||||
todo_write({
|
||||
todos: [
|
||||
// @ts-expect-error - testing invalid input
|
||||
{ content: "Missing id", status: "pending" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/id string/);
|
||||
});
|
||||
|
||||
test("requires content field", async () => {
|
||||
await expect(
|
||||
todo_write({
|
||||
todos: [
|
||||
// @ts-expect-error - testing invalid input
|
||||
{ id: "1", status: "pending" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/content string/);
|
||||
});
|
||||
|
||||
test("requires status field", async () => {
|
||||
await expect(
|
||||
todo_write({
|
||||
todos: [
|
||||
// @ts-expect-error - testing invalid input
|
||||
{ id: "1", content: "Test" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/valid status/);
|
||||
});
|
||||
|
||||
test("validates status values", async () => {
|
||||
await expect(
|
||||
todo_write({
|
||||
todos: [
|
||||
// @ts-expect-error - testing invalid status
|
||||
{ id: "1", content: "Test", status: "invalid" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/valid status/);
|
||||
});
|
||||
|
||||
test("handles empty todo list", async () => {
|
||||
const result = await todo_write({ todos: [] });
|
||||
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
test("accepts optional priority field", async () => {
|
||||
const result = await todo_write({
|
||||
todos: [
|
||||
{
|
||||
id: "1",
|
||||
content: "High priority task",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "Low priority task",
|
||||
status: "pending",
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.message).toContain("modified successfully");
|
||||
});
|
||||
|
||||
test("validates priority values", async () => {
|
||||
await expect(
|
||||
todo_write({
|
||||
todos: [
|
||||
// @ts-expect-error - testing invalid priority
|
||||
{ id: "1", content: "Test", status: "pending", priority: "urgent" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/priority must be/);
|
||||
});
|
||||
});
|
||||
51
src/tests/tools/write.test.ts
Normal file
51
src/tests/tools/write.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { write } from "../../tools/impl/Write";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
describe("Write tool", () => {
|
||||
let testDir: TestDirectory;
|
||||
|
||||
afterEach(() => {
|
||||
testDir?.cleanup();
|
||||
});
|
||||
|
||||
test("creates a new file", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const filePath = testDir.resolve("new.txt");
|
||||
|
||||
await write({ file_path: filePath, content: "Hello, World!" });
|
||||
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf-8")).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("overwrites existing file", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const filePath = testDir.createFile("existing.txt", "Old content");
|
||||
|
||||
await write({ file_path: filePath, content: "New content" });
|
||||
|
||||
expect(readFileSync(filePath, "utf-8")).toBe("New content");
|
||||
});
|
||||
|
||||
test("creates nested directories automatically", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const filePath = testDir.resolve("nested/deep/file.txt");
|
||||
|
||||
await write({ file_path: filePath, content: "Nested file" });
|
||||
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf-8")).toBe("Nested file");
|
||||
});
|
||||
|
||||
test("writes UTF-8 content correctly", async () => {
|
||||
testDir = new TestDirectory();
|
||||
const filePath = testDir.resolve("unicode.txt");
|
||||
const content = "Hello 世界 🌍\n╔═══╗";
|
||||
|
||||
await write({ file_path: filePath, content });
|
||||
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(content);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user