From 44d0cef838a30b69a32ccdd68d0910af0700b9b0 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 30 Jan 2026 14:00:53 +0800 Subject: [PATCH] test(tools): add unit tests for glob and param-helpers Add tests for: - Glob tool pattern matching, limits, and ignore patterns - Parameter helpers for string/number parsing and JSON results Co-Authored-By: Claude Opus 4.5 --- src/agent/tools/glob.test.ts | 218 ++++++++++++++++++++ src/agent/tools/web/param-helpers.test.ts | 238 ++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 src/agent/tools/glob.test.ts create mode 100644 src/agent/tools/web/param-helpers.test.ts diff --git a/src/agent/tools/glob.test.ts b/src/agent/tools/glob.test.ts new file mode 100644 index 00000000..83bd7f67 --- /dev/null +++ b/src/agent/tools/glob.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createGlobTool, type GlobResult } from "./glob.js"; + +describe("glob", () => { + const testDir = join(tmpdir(), `multica-glob-test-${Date.now()}`); + + beforeEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + mkdirSync(testDir, { recursive: true }); + + // Create test file structure + mkdirSync(join(testDir, "src"), { recursive: true }); + mkdirSync(join(testDir, "src/components"), { recursive: true }); + mkdirSync(join(testDir, "test"), { recursive: true }); + + writeFileSync(join(testDir, "package.json"), "{}"); + writeFileSync(join(testDir, "src/index.ts"), "export {}"); + writeFileSync(join(testDir, "src/utils.ts"), "export {}"); + writeFileSync(join(testDir, "src/components/Button.tsx"), "export {}"); + writeFileSync(join(testDir, "src/components/Input.tsx"), "export {}"); + writeFileSync(join(testDir, "test/index.test.ts"), "test()"); + writeFileSync(join(testDir, ".config"), "hidden"); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + }); + + describe("createGlobTool", () => { + it("should create a glob tool with correct properties", () => { + const tool = createGlobTool(testDir); + + expect(tool.name).toBe("glob"); + expect(tool.label).toBe("Glob"); + expect(tool.description).toContain("Find files matching a glob pattern"); + expect(tool.execute).toBeInstanceOf(Function); + }); + + it("should find files matching simple pattern", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute("test-id", { pattern: "*.json" }, new AbortController().signal); + + expect(result.details.files).toContain("package.json"); + expect(result.details.count).toBe(1); + expect(result.details.truncated).toBe(false); + }); + + it("should find TypeScript files recursively", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute("test-id", { pattern: "**/*.ts" }, new AbortController().signal); + + expect(result.details.files).toContain("src/index.ts"); + expect(result.details.files).toContain("src/utils.ts"); + expect(result.details.files).toContain("test/index.test.ts"); + expect(result.details.count).toBe(3); + }); + + it("should find TSX files in specific directory", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "src/components/*.tsx" }, + new AbortController().signal + ); + + expect(result.details.files).toContain("src/components/Button.tsx"); + expect(result.details.files).toContain("src/components/Input.tsx"); + expect(result.details.count).toBe(2); + }); + + it("should include dotfiles", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute("test-id", { pattern: ".*" }, new AbortController().signal); + + expect(result.details.files).toContain(".config"); + }); + + it("should respect limit parameter", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "**/*", limit: 2 }, + new AbortController().signal + ); + + expect(result.details.count).toBe(2); + expect(result.details.truncated).toBe(true); + }); + + it("should respect ignore patterns", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "**/*.ts", ignore: ["test/**"] }, + new AbortController().signal + ); + + expect(result.details.files).toContain("src/index.ts"); + expect(result.details.files).not.toContain("test/index.test.ts"); + }); + + it("should use custom cwd", async () => { + const tool = createGlobTool("/other/path"); + const result = await tool.execute( + "test-id", + { pattern: "*.ts", cwd: join(testDir, "src") }, + new AbortController().signal + ); + + expect(result.details.files).toContain("index.ts"); + expect(result.details.files).toContain("utils.ts"); + }); + + it("should throw error for empty pattern", async () => { + const tool = createGlobTool(testDir); + + await expect( + tool.execute("test-id", { pattern: "" }, new AbortController().signal) + ).rejects.toThrow("Pattern must not be empty"); + }); + + it("should throw error for whitespace-only pattern", async () => { + const tool = createGlobTool(testDir); + + await expect( + tool.execute("test-id", { pattern: " " }, new AbortController().signal) + ).rejects.toThrow("Pattern must not be empty"); + }); + + it("should throw error for non-existent directory", async () => { + const tool = createGlobTool(testDir); + + await expect( + tool.execute("test-id", { pattern: "*.ts", cwd: "/non/existent/path" }, new AbortController().signal) + ).rejects.toThrow("Directory not found"); + }); + + it("should throw error when cwd is a file", async () => { + const tool = createGlobTool(testDir); + const filePath = join(testDir, "package.json"); + + await expect( + tool.execute("test-id", { pattern: "*.ts", cwd: filePath }, new AbortController().signal) + ).rejects.toThrow("Path is not a directory"); + }); + + it("should return message when no files match", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "**/*.xyz" }, + new AbortController().signal + ); + + expect(result.details.count).toBe(0); + expect(result.details.files).toHaveLength(0); + expect(result.content[0].text).toContain("No files found"); + }); + + it("should sort files by modification time (most recent first)", async () => { + // Create files with different modification times + const laterFile = join(testDir, "later.ts"); + writeFileSync(laterFile, "// created later"); + + // Wait a bit to ensure different mtime + await new Promise((resolve) => setTimeout(resolve, 100)); + + const latestFile = join(testDir, "latest.ts"); + writeFileSync(latestFile, "// created latest"); + + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "*.ts" }, + new AbortController().signal + ); + + // The latest file should be first + expect(result.details.files[0]).toBe("latest.ts"); + }); + + it("should use default limit of 100", async () => { + // Create more than 100 files + for (let i = 0; i < 110; i++) { + writeFileSync(join(testDir, `file${i}.txt`), "content"); + } + + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "*.txt" }, + new AbortController().signal + ); + + expect(result.details.count).toBe(100); + expect(result.details.truncated).toBe(true); + }); + + it("should limit to max 1000 files", async () => { + const tool = createGlobTool(testDir); + const result = await tool.execute( + "test-id", + { pattern: "**/*", limit: 5000 }, + new AbortController().signal + ); + + // The limit should be capped at 1000 + expect(result.details.count).toBeLessThanOrEqual(1000); + }); + }); +}); diff --git a/src/agent/tools/web/param-helpers.test.ts b/src/agent/tools/web/param-helpers.test.ts new file mode 100644 index 00000000..28bda65d --- /dev/null +++ b/src/agent/tools/web/param-helpers.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from "vitest"; +import { readStringParam, readNumberParam, jsonResult } from "./param-helpers.js"; + +describe("param-helpers", () => { + describe("readStringParam", () => { + it("should return string value when present", () => { + const params = { name: "test" }; + const result = readStringParam(params, "name"); + expect(result).toBe("test"); + }); + + it("should trim whitespace by default", () => { + const params = { name: " test " }; + const result = readStringParam(params, "name"); + expect(result).toBe("test"); + }); + + it("should not trim when trim is false", () => { + const params = { name: " test " }; + const result = readStringParam(params, "name", { trim: false }); + expect(result).toBe(" test "); + }); + + it("should return undefined for missing key", () => { + const params = {}; + const result = readStringParam(params, "name"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for non-string value", () => { + const params = { name: 123 }; + const result = readStringParam(params, "name"); + expect(result).toBeUndefined(); + }); + + it("should throw when required and missing", () => { + const params = {}; + expect(() => readStringParam(params, "name", { required: true })).toThrow("name required"); + }); + + it("should throw when required and not a string", () => { + const params = { name: null }; + expect(() => readStringParam(params, "name", { required: true })).toThrow("name required"); + }); + + it("should use custom label in error message", () => { + const params = {}; + expect(() => + readStringParam(params, "name", { required: true, label: "Username" }) + ).toThrow("Username required"); + }); + + it("should return undefined for empty string when not allowEmpty", () => { + const params = { name: "" }; + const result = readStringParam(params, "name"); + expect(result).toBeUndefined(); + }); + + it("should return empty string when allowEmpty is true", () => { + const params = { name: "" }; + const result = readStringParam(params, "name", { allowEmpty: true }); + expect(result).toBe(""); + }); + + it("should return undefined for whitespace-only when trimmed", () => { + const params = { name: " " }; + const result = readStringParam(params, "name"); + expect(result).toBeUndefined(); + }); + + it("should throw for required empty string", () => { + const params = { name: "" }; + expect(() => readStringParam(params, "name", { required: true })).toThrow("name required"); + }); + }); + + describe("readNumberParam", () => { + it("should return number value when present", () => { + const params = { count: 42 }; + const result = readNumberParam(params, "count"); + expect(result).toBe(42); + }); + + it("should parse string numbers", () => { + const params = { count: "42" }; + const result = readNumberParam(params, "count"); + expect(result).toBe(42); + }); + + it("should parse float numbers from string", () => { + const params = { value: "3.14" }; + const result = readNumberParam(params, "value"); + expect(result).toBe(3.14); + }); + + it("should return undefined for missing key", () => { + const params = {}; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for non-numeric string", () => { + const params = { count: "abc" }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for NaN", () => { + const params = { count: NaN }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for Infinity", () => { + const params = { count: Infinity }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should throw when required and missing", () => { + const params = {}; + expect(() => readNumberParam(params, "count", { required: true })).toThrow("count required"); + }); + + it("should throw when required and invalid", () => { + const params = { count: "invalid" }; + expect(() => readNumberParam(params, "count", { required: true })).toThrow("count required"); + }); + + it("should use custom label in error message", () => { + const params = {}; + expect(() => + readNumberParam(params, "count", { required: true, label: "Item Count" }) + ).toThrow("Item Count required"); + }); + + it("should truncate to integer when integer option is true", () => { + const params = { count: 3.9 }; + const result = readNumberParam(params, "count", { integer: true }); + expect(result).toBe(3); + }); + + it("should truncate negative float to integer", () => { + const params = { count: -3.9 }; + const result = readNumberParam(params, "count", { integer: true }); + expect(result).toBe(-3); + }); + + it("should handle empty string", () => { + const params = { count: "" }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should handle whitespace-only string", () => { + const params = { count: " " }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should trim string before parsing", () => { + const params = { count: " 42 " }; + const result = readNumberParam(params, "count"); + expect(result).toBe(42); + }); + + it("should return undefined for object value", () => { + const params = { count: { value: 42 } }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for array value", () => { + const params = { count: [1, 2, 3] }; + const result = readNumberParam(params, "count"); + expect(result).toBeUndefined(); + }); + + it("should handle zero correctly", () => { + const params = { count: 0 }; + const result = readNumberParam(params, "count"); + expect(result).toBe(0); + }); + + it("should handle negative numbers", () => { + const params = { count: -10 }; + const result = readNumberParam(params, "count"); + expect(result).toBe(-10); + }); + }); + + describe("jsonResult", () => { + it("should return formatted JSON result", () => { + const payload = { name: "test", value: 42 }; + const result = jsonResult(payload); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2)); + expect(result.details).toBe(payload); + }); + + it("should handle array payload", () => { + const payload = [1, 2, 3]; + const result = jsonResult(payload); + + expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2)); + expect(result.details).toBe(payload); + }); + + it("should handle string payload", () => { + const payload = "simple string"; + const result = jsonResult(payload); + + expect(result.content[0].text).toBe('"simple string"'); + expect(result.details).toBe(payload); + }); + + it("should handle null payload", () => { + const result = jsonResult(null); + + expect(result.content[0].text).toBe("null"); + expect(result.details).toBeNull(); + }); + + it("should handle nested objects", () => { + const payload = { + user: { name: "test", settings: { theme: "dark" } }, + items: [1, 2, 3], + }; + const result = jsonResult(payload); + + expect(result.content[0].text).toContain("user"); + expect(result.content[0].text).toContain("settings"); + expect(result.details).toBe(payload); + }); + }); +});