lib.test.ts•25.1 kB
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import fs from "fs/promises";
import path from "path";
import os from "os";
import {
// File editing functions
applyFileEdits,
createUnifiedDiff,
// Pure utility functions
formatSize,
// File operations
getFileStats,
headFile,
normalizeLineEndings,
readFileContent,
// Search & filtering functions
searchFilesWithValidation,
setAllowedDirectories,
tailFile,
// Security & validation functions
validatePath,
writeFileContent,
} from "../lib.js";
// Mock fs module
jest.mock("fs/promises");
const mockFs = fs as jest.Mocked<typeof fs>;
describe("Lib Functions", () => {
beforeEach(() => {
jest.clearAllMocks();
// Set up allowed directories for tests
const allowedDirs = process.platform === "win32"
? ["C:\\Users\\test", "C:\\temp", "C:\\allowed"]
: ["/home/user", "/tmp", "/allowed"];
setAllowedDirectories(allowedDirs);
});
afterEach(() => {
jest.restoreAllMocks();
// Clear allowed directories after tests
setAllowedDirectories([]);
});
describe("Pure Utility Functions", () => {
describe("formatSize", () => {
it("formats bytes correctly", () => {
expect(formatSize(0)).toBe("0 B");
expect(formatSize(512)).toBe("512 B");
expect(formatSize(1024)).toBe("1.00 KB");
expect(formatSize(1536)).toBe("1.50 KB");
expect(formatSize(1048576)).toBe("1.00 MB");
expect(formatSize(1073741824)).toBe("1.00 GB");
expect(formatSize(1099511627776)).toBe("1.00 TB");
});
it("handles edge cases", () => {
expect(formatSize(1023)).toBe("1023 B");
expect(formatSize(1025)).toBe("1.00 KB");
expect(formatSize(1048575)).toBe("1024.00 KB");
});
it("handles very large numbers beyond TB", () => {
// The function only supports up to TB, so very large numbers will show as TB
expect(formatSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1024.00 TB");
expect(formatSize(Number.MAX_SAFE_INTEGER)).toContain("TB");
});
it("handles negative numbers", () => {
// Negative numbers will result in NaN for the log calculation
expect(formatSize(-1024)).toContain("NaN");
expect(formatSize(-0)).toBe("0 B");
});
it("handles decimal numbers", () => {
expect(formatSize(1536.5)).toBe("1.50 KB");
expect(formatSize(1023.9)).toBe("1023.9 B");
});
it("handles very small positive numbers", () => {
expect(formatSize(1)).toBe("1 B");
expect(formatSize(0.5)).toBe("0.5 B");
expect(formatSize(0.1)).toBe("0.1 B");
});
});
describe("normalizeLineEndings", () => {
it("converts CRLF to LF", () => {
expect(normalizeLineEndings("line1\r\nline2\r\nline3")).toBe(
"line1\nline2\nline3",
);
});
it("leaves LF unchanged", () => {
expect(normalizeLineEndings("line1\nline2\nline3")).toBe(
"line1\nline2\nline3",
);
});
it("handles mixed line endings", () => {
expect(normalizeLineEndings("line1\r\nline2\nline3\r\n")).toBe(
"line1\nline2\nline3\n",
);
});
it("handles empty string", () => {
expect(normalizeLineEndings("")).toBe("");
});
});
describe("createUnifiedDiff", () => {
it("creates diff for simple changes", () => {
const original = "line1\nline2\nline3";
const modified = "line1\nmodified line2\nline3";
const diff = createUnifiedDiff(original, modified, "test.txt");
expect(diff).toContain("--- test.txt");
expect(diff).toContain("+++ test.txt");
expect(diff).toContain("-line2");
expect(diff).toContain("+modified line2");
});
it("handles CRLF normalization", () => {
const original = "line1\r\nline2\r\n";
const modified = "line1\nmodified line2\n";
const diff = createUnifiedDiff(original, modified);
expect(diff).toContain("-line2");
expect(diff).toContain("+modified line2");
});
it("handles identical content", () => {
const content = "line1\nline2\nline3";
const diff = createUnifiedDiff(content, content);
// Should not contain any +/- lines for identical content (excluding header lines)
expect(
diff.split("\n").filter((line: string) =>
line.startsWith("+++") || line.startsWith("---")
),
).toHaveLength(2);
expect(
diff.split("\n").filter((line: string) =>
line.startsWith("+") && !line.startsWith("+++")
),
).toHaveLength(0);
expect(
diff.split("\n").filter((line: string) =>
line.startsWith("-") && !line.startsWith("---")
),
).toHaveLength(0);
});
it("handles empty content", () => {
const diff = createUnifiedDiff("", "");
expect(diff).toContain("--- file");
expect(diff).toContain("+++ file");
});
it("handles default filename parameter", () => {
const diff = createUnifiedDiff("old", "new");
expect(diff).toContain("--- file");
expect(diff).toContain("+++ file");
});
it("handles custom filename", () => {
const diff = createUnifiedDiff("old", "new", "custom.txt");
expect(diff).toContain("--- custom.txt");
expect(diff).toContain("+++ custom.txt");
});
});
});
describe("Security & Validation Functions", () => {
describe("validatePath", () => {
// Use Windows-compatible paths for testing
const allowedDirs = process.platform === "win32"
? ["C:\\Users\\test", "C:\\temp"]
: ["/home/user", "/tmp"];
beforeEach(() => {
mockFs.realpath.mockImplementation(async (path: any) =>
path.toString()
);
});
it("validates allowed paths", async () => {
const testPath = process.platform === "win32"
? "C:\\Users\\test\\file.txt"
: "/home/user/file.txt";
const result = await validatePath(testPath);
expect(result).toBe(testPath);
});
it("rejects disallowed paths", async () => {
const testPath = process.platform === "win32"
? "C:\\Windows\\System32\\file.txt"
: "/etc/passwd";
await expect(validatePath(testPath))
.rejects.toThrow("Access denied - path outside allowed directories");
});
it("handles non-existent files by checking parent directory", async () => {
const newFilePath = process.platform === "win32"
? "C:\\Users\\test\\newfile.txt"
: "/home/user/newfile.txt";
const parentPath = process.platform === "win32"
? "C:\\Users\\test"
: "/home/user";
// Create an error with the ENOENT code that the implementation checks for
const enoentError = new Error("ENOENT") as NodeJS.ErrnoException;
enoentError.code = "ENOENT";
mockFs.realpath
.mockRejectedValueOnce(enoentError)
.mockResolvedValueOnce(parentPath);
const result = await validatePath(newFilePath);
expect(result).toBe(path.resolve(newFilePath));
});
it("rejects when parent directory does not exist", async () => {
const newFilePath = process.platform === "win32"
? "C:\\Users\\test\\nonexistent\\newfile.txt"
: "/home/user/nonexistent/newfile.txt";
// Create errors with the ENOENT code
const enoentError1 = new Error("ENOENT") as NodeJS.ErrnoException;
enoentError1.code = "ENOENT";
const enoentError2 = new Error("ENOENT") as NodeJS.ErrnoException;
enoentError2.code = "ENOENT";
mockFs.realpath
.mockRejectedValueOnce(enoentError1)
.mockRejectedValueOnce(enoentError2);
await expect(validatePath(newFilePath))
.rejects.toThrow("Parent directory does not exist");
});
});
});
describe("File Operations", () => {
describe("getFileStats", () => {
it("returns file statistics", async () => {
const mockStats = {
size: 1024,
birthtime: new Date("2023-01-01"),
mtime: new Date("2023-01-02"),
atime: new Date("2023-01-03"),
isDirectory: () => false,
isFile: () => true,
mode: 0o644,
};
mockFs.stat.mockResolvedValueOnce(mockStats as any);
const result = await getFileStats("/test/file.txt");
expect(result).toEqual({
size: 1024,
created: new Date("2023-01-01"),
modified: new Date("2023-01-02"),
accessed: new Date("2023-01-03"),
isDirectory: false,
isFile: true,
permissions: "644",
});
});
it("handles directory statistics", async () => {
const mockStats = {
size: 4096,
birthtime: new Date("2023-01-01"),
mtime: new Date("2023-01-02"),
atime: new Date("2023-01-03"),
isDirectory: () => true,
isFile: () => false,
mode: 0o755,
};
mockFs.stat.mockResolvedValueOnce(mockStats as any);
const result = await getFileStats("/test/dir");
expect(result.isDirectory).toBe(true);
expect(result.isFile).toBe(false);
expect(result.permissions).toBe("755");
});
});
describe("readFileContent", () => {
it("reads file with default encoding", async () => {
mockFs.readFile.mockResolvedValueOnce("file content");
const result = await readFileContent("/test/file.txt");
expect(result).toBe("file content");
expect(mockFs.readFile).toHaveBeenCalledWith("/test/file.txt", "utf-8");
});
it("reads file with custom encoding", async () => {
mockFs.readFile.mockResolvedValueOnce("file content");
const result = await readFileContent("/test/file.txt", "ascii");
expect(result).toBe("file content");
expect(mockFs.readFile).toHaveBeenCalledWith("/test/file.txt", "ascii");
});
});
describe("writeFileContent", () => {
it("writes file content", async () => {
mockFs.writeFile.mockResolvedValueOnce(undefined);
await writeFileContent("/test/file.txt", "new content");
expect(mockFs.writeFile).toHaveBeenCalledWith(
"/test/file.txt",
"new content",
{ encoding: "utf-8", flag: "wx" },
);
});
});
});
describe("Search & Filtering Functions", () => {
describe("searchFilesWithValidation", () => {
beforeEach(() => {
mockFs.realpath.mockImplementation(async (path: any) =>
path.toString()
);
});
it("excludes files matching exclude patterns", async () => {
const mockEntries = [
{ name: "test.txt", isDirectory: () => false },
{ name: "test.log", isDirectory: () => false },
{ name: "node_modules", isDirectory: () => true },
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
const testDir = process.platform === "win32"
? "C:\\allowed\\dir"
: "/allowed/dir";
const allowedDirs = process.platform === "win32"
? ["C:\\allowed"]
: ["/allowed"];
// Mock realpath to return the same path for validation to pass
mockFs.realpath.mockImplementation(async (inputPath: any) => {
const pathStr = inputPath.toString();
// Return the path as-is for validation
return pathStr;
});
const result = await searchFilesWithValidation(
testDir,
"*test*",
allowedDirs,
{ excludePatterns: ["*.log", "node_modules"] },
);
const expectedResult = process.platform === "win32"
? "C:\\allowed\\dir\\test.txt"
: "/allowed/dir/test.txt";
expect(result).toEqual([expectedResult]);
});
it("handles validation errors during search", async () => {
const mockEntries = [
{ name: "test.txt", isDirectory: () => false },
{ name: "invalid_file.txt", isDirectory: () => false },
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
// Mock validatePath to throw error for invalid_file.txt
mockFs.realpath.mockImplementation(async (path: any) => {
if (path.toString().includes("invalid_file.txt")) {
throw new Error("Access denied");
}
return path.toString();
});
const testDir = process.platform === "win32"
? "C:\\allowed\\dir"
: "/allowed/dir";
const allowedDirs = process.platform === "win32"
? ["C:\\allowed"]
: ["/allowed"];
const result = await searchFilesWithValidation(
testDir,
"*test*",
allowedDirs,
{},
);
// Should only return the valid file, skipping the invalid one
const expectedResult = process.platform === "win32"
? "C:\\allowed\\dir\\test.txt"
: "/allowed/dir/test.txt";
expect(result).toEqual([expectedResult]);
});
it("handles complex exclude patterns with wildcards", async () => {
const mockEntries = [
{ name: "test.txt", isDirectory: () => false },
{ name: "test.backup", isDirectory: () => false },
{ name: "important_test.js", isDirectory: () => false },
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
const testDir = process.platform === "win32"
? "C:\\allowed\\dir"
: "/allowed/dir";
const allowedDirs = process.platform === "win32"
? ["C:\\allowed"]
: ["/allowed"];
const result = await searchFilesWithValidation(
testDir,
"*test*",
allowedDirs,
{ excludePatterns: ["*.backup"] },
);
const expectedResults = process.platform === "win32"
? [
"C:\\allowed\\dir\\test.txt",
"C:\\allowed\\dir\\important_test.js",
]
: [
"/allowed/dir/test.txt",
"/allowed/dir/important_test.js",
];
expect(result).toEqual(expectedResults);
});
});
});
describe("File Editing Functions", () => {
describe("applyFileEdits", () => {
beforeEach(() => {
mockFs.readFile.mockResolvedValue("line1\nline2\nline3\n");
mockFs.writeFile.mockResolvedValue(undefined);
});
it("applies simple text replacement", async () => {
const edits = [
{ oldText: "line2", newText: "modified line2" },
];
mockFs.rename.mockResolvedValueOnce(undefined);
const result = await applyFileEdits("/test/file.txt", edits, false);
expect(result).toContain("modified line2");
// Should write to temporary file then rename
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"line1\nmodified line2\nline3\n",
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"/test/file.txt",
);
});
it("handles dry run mode", async () => {
const edits = [
{ oldText: "line2", newText: "modified line2" },
];
const result = await applyFileEdits("/test/file.txt", edits, true);
expect(result).toContain("modified line2");
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
it("applies multiple edits sequentially", async () => {
const edits = [
{ oldText: "line1", newText: "first line" },
{ oldText: "line3", newText: "third line" },
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits("/test/file.txt", edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"first line\nline2\nthird line\n",
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"/test/file.txt",
);
});
it("handles whitespace-flexible matching", async () => {
mockFs.readFile.mockResolvedValue(" line1\n line2\n line3\n");
const edits = [
{ oldText: "line2", newText: "modified line2" },
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits("/test/file.txt", edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
" line1\n modified line2\n line3\n",
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"/test/file.txt",
);
});
it("throws error for non-matching edits", async () => {
const edits = [
{ oldText: "nonexistent line", newText: "replacement" },
];
await expect(applyFileEdits("/test/file.txt", edits, false))
.rejects.toThrow("Could not find exact match for edit");
});
it("handles complex multi-line edits with indentation", async () => {
mockFs.readFile.mockResolvedValue(
'function test() {\n console.log("hello");\n return true;\n}',
);
const edits = [
{
oldText: ' console.log("hello");\n return true;',
newText:
' console.log("world");\n console.log("test");\n return false;',
},
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits("/test/file.js", edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
'function test() {\n console.log("world");\n console.log("test");\n return false;\n}',
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
"/test/file.js",
);
});
it("handles edits with different indentation patterns", async () => {
mockFs.readFile.mockResolvedValue(
" if (condition) {\n doSomething();\n }",
);
const edits = [
{
oldText: "doSomething();",
newText: "doSomethingElse();\n doAnotherThing();",
},
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits("/test/file.js", edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
" if (condition) {\n doSomethingElse();\n doAnotherThing();\n }",
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
"/test/file.js",
);
});
it("handles CRLF line endings in file content", async () => {
mockFs.readFile.mockResolvedValue("line1\r\nline2\r\nline3\r\n");
const edits = [
{ oldText: "line2", newText: "modified line2" },
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits("/test/file.txt", edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"line1\nmodified line2\nline3\n",
"utf-8",
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
"/test/file.txt",
);
});
});
describe("tailFile", () => {
it("handles empty files", async () => {
mockFs.stat.mockResolvedValue({ size: 0 } as any);
const result = await tailFile("/test/empty.txt", 5);
expect(result).toBe("");
expect(mockFs.open).not.toHaveBeenCalled();
});
it("calls stat to check file size", async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);
// Mock file handle with proper typing
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await tailFile("/test/file.txt", 2);
expect(mockFs.stat).toHaveBeenCalledWith("/test/file.txt");
expect(mockFs.open).toHaveBeenCalledWith("/test/file.txt", "r");
});
it("handles files with content and returns last lines", async () => {
mockFs.stat.mockResolvedValue({ size: 50 } as any);
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
// Simulate reading file content in chunks
mockFileHandle.read
.mockResolvedValueOnce({
bytesRead: 20,
buffer: Buffer.from("line3\nline4\nline5\n"),
})
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await tailFile("/test/file.txt", 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it("handles read errors gracefully", async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await tailFile("/test/file.txt", 5);
expect(mockFileHandle.close).toHaveBeenCalled();
});
});
describe("headFile", () => {
it("opens file for reading", async () => {
// Mock file handle with proper typing
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await headFile("/test/file.txt", 2);
expect(mockFs.open).toHaveBeenCalledWith("/test/file.txt", "r");
});
it("handles files with content and returns first lines", async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
// Simulate reading file content with newlines
mockFileHandle.read
.mockResolvedValueOnce({
bytesRead: 20,
buffer: Buffer.from("line1\nline2\nline3\n"),
})
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile("/test/file.txt", 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it("handles files with leftover content", async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
// Simulate reading file content without final newline
mockFileHandle.read
.mockResolvedValueOnce({
bytesRead: 15,
buffer: Buffer.from("line1\nline2\nend"),
})
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile("/test/file.txt", 5);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it("handles reaching requested line count", async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn(),
} as any;
// Simulate reading exactly the requested number of lines
mockFileHandle.read
.mockResolvedValueOnce({
bytesRead: 12,
buffer: Buffer.from("line1\nline2\n"),
})
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile("/test/file.txt", 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
});
});
});