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 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-01-30 14:00:53 +08:00
parent 5353161b1d
commit 44d0cef838
2 changed files with 456 additions and 0 deletions

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});