diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b6df87..e99d711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f00d33c..7870729 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index 8e015ea..b3b0854 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "publishConfig": { "access": "public" }, - "dependencies": { + "dependencies": {}, + "optionalDependencies": { "@vscode/ripgrep": "^1.17.0" }, "devDependencies": { diff --git a/src/tests/helpers/testFs.ts b/src/tests/helpers/testFs.ts new file mode 100644 index 0000000..b603155 --- /dev/null +++ b/src/tests/helpers/testFs.ts @@ -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); + } + } +} diff --git a/src/tests/permissions-analyzer.test.ts b/src/tests/permissions-analyzer.test.ts index c335e7d..4a62f72 100644 --- a/src/tests/permissions-analyzer.test.ts +++ b/src/tests/permissions-analyzer.test.ts @@ -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" }, diff --git a/src/tests/permissions-checker.test.ts b/src/tests/permissions-checker.test.ts index f9d6f90..8534d30 100644 --- a/src/tests/permissions-checker.test.ts +++ b/src/tests/permissions-checker.test.ts @@ -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/**)"], diff --git a/src/tests/permissions-matcher.test.ts b/src/tests/permissions-matcher.test.ts index 364630d..16ceb34 100644 --- a/src/tests/permissions-matcher.test.ts +++ b/src/tests/permissions-matcher.test.ts @@ -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( diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts index 3746ea3..3228d83 100644 --- a/src/tests/permissions.test.ts +++ b/src/tests/permissions.test.ts @@ -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" }, diff --git a/src/tests/tools/bash-background.test.ts b/src/tests/tools/bash-background.test.ts new file mode 100644 index 0000000..a4e9773 --- /dev/null +++ b/src/tests/tools/bash-background.test.ts @@ -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); + }); +}); diff --git a/src/tests/tools/bash.test.ts b/src/tests/tools/bash.test.ts new file mode 100644 index 0000000..3893f09 --- /dev/null +++ b/src/tests/tools/bash.test.ts @@ -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(); + }); +}); diff --git a/src/tests/tools/edit.test.ts b/src/tests/tools/edit.test.ts new file mode 100644 index 0000000..74f46ab --- /dev/null +++ b/src/tests/tools/edit.test.ts @@ -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); + }); +}); diff --git a/src/tests/tools/exitplanmode.test.ts b/src/tests/tools/exitplanmode.test.ts new file mode 100644 index 0000000..fe82c21 --- /dev/null +++ b/src/tests/tools/exitplanmode.test.ts @@ -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"); + }); +}); diff --git a/src/tests/tools/glob.test.ts b/src/tests/tools/glob.test.ts new file mode 100644 index 0000000..421909d --- /dev/null +++ b/src/tests/tools/glob.test.ts @@ -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([]); + }); +}); diff --git a/src/tests/tools/grep.test.ts b/src/tests/tools/grep.test.ts new file mode 100644 index 0000000..f6047b9 --- /dev/null +++ b/src/tests/tools/grep.test.ts @@ -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; + } + } + }); +}); diff --git a/src/tests/tools/ls.test.ts b/src/tests/tools/ls.test.ts new file mode 100644 index 0000000..6afb3ea --- /dev/null +++ b/src/tests/tools/ls.test.ts @@ -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/); + }); +}); diff --git a/src/tests/tools/multiedit.test.ts b/src/tests/tools/multiedit.test.ts new file mode 100644 index 0000000..dd906ec --- /dev/null +++ b/src/tests/tools/multiedit.test.ts @@ -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); + }); +}); diff --git a/src/tests/tools/read.test.ts b/src/tests/tools/read.test.ts new file mode 100644 index 0000000..391e65c --- /dev/null +++ b/src/tests/tools/read.test.ts @@ -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"); + }); +}); diff --git a/src/tests/tools/todowrite.test.ts b/src/tests/tools/todowrite.test.ts new file mode 100644 index 0000000..6d7c07c --- /dev/null +++ b/src/tests/tools/todowrite.test.ts @@ -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/); + }); +}); diff --git a/src/tests/tools/write.test.ts b/src/tests/tools/write.test.ts new file mode 100644 index 0000000..848f713 --- /dev/null +++ b/src/tests/tools/write.test.ts @@ -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); + }); +});