Files
fusion/packages/dashboard/src/__tests__/file-service.test.ts
Fusion 86b427c126 feat(FN-2315): add hidden markdown toggle for project documents
- Extend dashboard file service and routes to support includeHidden when scanning markdown files
- Update API client, markdown hook, and Documents view UI to expose a hidden files toggle
- Add coverage for hidden-file filtering in file-service, hook, and DocumentsView tests
- Stabilize related SettingsModal and TaskForm tests and document toggle behavior in the dashboard README
2026-04-24 08:55:02 -07:00

2124 lines
70 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
FileServiceError,
listFiles,
readFile,
writeFile,
listProjectFiles,
readProjectFile,
writeProjectFile,
listWorkspaceFiles,
readWorkspaceFile,
writeWorkspaceFile,
searchWorkspaceFiles,
listProjectMarkdownFiles,
scanMarkdownFiles,
copyWorkspaceFile,
moveWorkspaceFile,
deleteWorkspaceFile,
renameWorkspaceFile,
getWorkspaceFileForDownload,
getWorkspaceFolderForZip,
MAX_FILE_SIZE,
} from "../file-service.js";
import type { TaskStore } from "@fusion/core";
// Mock node:fs/promises - use vi.hoisted for proper hoisting with ES modules
const { mockReaddir, mockReadFile, mockWriteFile, mockStat, mockCopyFile, mockRename, mockRm, mockMkdir, mockAccess } = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockCopyFile: vi.fn(),
mockRename: vi.fn(),
mockRm: vi.fn(),
mockMkdir: vi.fn(),
mockAccess: vi.fn(),
}));
// Mock node:fs
const { mockExistsSync } = vi.hoisted(() => ({
mockExistsSync: vi.fn(),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
default: {
...actual,
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
access: mockAccess,
},
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
access: mockAccess,
};
});
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
default: {
...actual,
existsSync: mockExistsSync,
},
existsSync: mockExistsSync,
};
});
describe("FileServiceError", () => {
it("constructor sets code and name correctly", () => {
const error = new FileServiceError("Test message", "ETEST");
expect(error.message).toBe("Test message");
expect(error.code).toBe("ETEST");
expect(error.name).toBe("FileServiceError");
});
it("is an instance of Error", () => {
const error = new FileServiceError("Test", "ETEST");
expect(error).toBeInstanceOf(Error);
});
});
describe("MAX_FILE_SIZE", () => {
it("is 1MB (1024 * 1024 bytes)", () => {
expect(MAX_FILE_SIZE).toBe(1024 * 1024);
expect(MAX_FILE_SIZE).toBe(1048576);
});
});
describe("path traversal protection", () => {
const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn();
const mockStore = {
getTask: mockGetTask,
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetTask.mockReset();
mockGetRootDir.mockReset();
mockStat.mockReset();
mockReaddir.mockReset();
mockExistsSync.mockReset();
});
describe("via listProjectFiles", () => {
it("rejects path traversal attacks (../)", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(listProjectFiles(mockStore, "../secret.txt")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "../secret.txt")).rejects.toThrow("Path traversal detected");
});
it("rejects absolute paths", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(listProjectFiles(mockStore, "/etc/passwd")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "/etc/passwd")).rejects.toThrow("Absolute paths not allowed");
});
it("rejects paths with null bytes", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(listProjectFiles(mockStore, "file\0.txt")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "file\0.txt")).rejects.toThrow("Invalid characters");
});
it("rejects URL-encoded path traversal", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "%2e%2e%2fsecret.txt")).rejects.toThrow("Path traversal detected");
});
});
describe("via readProjectFile", () => {
it("rejects path traversal attacks", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(readProjectFile(mockStore, "../.env")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "../.env")).rejects.toThrow("Path traversal detected");
});
it("rejects absolute paths", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(readProjectFile(mockStore, "/etc/passwd")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "/etc/passwd")).rejects.toThrow("Absolute paths not allowed");
});
});
describe("via writeProjectFile", () => {
it("rejects path traversal attacks", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(writeProjectFile(mockStore, "../.env", "evil")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "../.env", "evil")).rejects.toThrow("Path traversal detected");
});
it("rejects null bytes in path", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(writeProjectFile(mockStore, "file\0.txt", "content")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "file\0.txt", "content")).rejects.toThrow("Invalid characters");
});
it("rejects absolute paths", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(writeProjectFile(mockStore, "/etc/crontab", "evil")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "/etc/crontab", "evil")).rejects.toThrow("Absolute paths not allowed");
});
});
describe("via listFiles (task)", () => {
it("rejects path traversal in task context", async () => {
mockGetRootDir.mockReturnValue("/project");
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
await expect(listFiles(mockStore, "FN-123", "../other-task")).rejects.toThrow(FileServiceError);
await expect(listFiles(mockStore, "FN-123", "../other-task")).rejects.toThrow("Path traversal detected");
});
it("rejects absolute paths in task context", async () => {
mockGetRootDir.mockReturnValue("/project");
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
await expect(listFiles(mockStore, "FN-123", "/etc/passwd")).rejects.toThrow(FileServiceError);
});
});
describe("via readFile (task)", () => {
it("rejects path traversal when reading task files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
await expect(readFile(mockStore, "FN-123", "../../secret.txt")).rejects.toThrow(FileServiceError);
await expect(readFile(mockStore, "FN-123", "../../secret.txt")).rejects.toThrow("Path traversal detected");
});
});
describe("via writeFile (task)", () => {
it("rejects path traversal when writing task files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
await expect(writeFile(mockStore, "FN-123", "../../outside.txt", "data")).rejects.toThrow(FileServiceError);
});
});
describe("via workspace operations", () => {
it("rejects path traversal in workspace file listing", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(listWorkspaceFiles(mockStore, "project", "../../outside")).rejects.toThrow(FileServiceError);
});
it("rejects path traversal in workspace file read", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(readWorkspaceFile(mockStore, "project", "../.env")).rejects.toThrow(FileServiceError);
});
it("rejects path traversal in workspace file write", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(writeWorkspaceFile(mockStore, "project", "../.env", "data")).rejects.toThrow(FileServiceError);
});
it("rejects absolute paths in workspace file read", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(readWorkspaceFile(mockStore, "project", "/etc/passwd")).rejects.toThrow(FileServiceError);
});
});
describe("complex path traversal patterns", () => {
it("rejects nested path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(listProjectFiles(mockStore, "foo/../../secret")).rejects.toThrow(FileServiceError);
});
it("rejects parent directory at root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(listProjectFiles(mockStore, "..")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "../")).rejects.toThrow(FileServiceError);
});
it("allows valid relative paths with dots", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
mockReaddir.mockResolvedValue([]);
// Should resolve ./src to src and succeed
const result = await listProjectFiles(mockStore, "./src");
expect(result.path).toBe("src");
expect(result.entries).toEqual([]);
});
it("allows paths containing single dots in middle", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
mockReaddir.mockResolvedValue([]);
// Should resolve src/./components to src/components and succeed
const result = await listProjectFiles(mockStore, "src/./components");
expect(result.path).toBe("src/components");
expect(result.entries).toEqual([]);
});
});
});
describe("listProjectFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockReaddir.mockReset();
});
it("throws FileServiceError with ENOENT for missing directory", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockRejectedValue({ code: "ENOENT" });
await expect(listProjectFiles(mockStore, "missing")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "missing")).rejects.toThrow("Directory not found");
});
it("throws FileServiceError with ENOTDIR for non-directory", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
});
await expect(listProjectFiles(mockStore, "file.txt")).rejects.toThrow(FileServiceError);
await expect(listProjectFiles(mockStore, "file.txt")).rejects.toThrow("Not a directory");
});
});
describe("readProjectFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("enforces max file size limit (1MB)", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isFile: () => true,
size: MAX_FILE_SIZE + 1,
mtime: new Date(),
});
await expect(readProjectFile(mockStore, "large.bin")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "large.bin")).rejects.toThrow("File too large");
});
it("throws FileServiceError with ENOENT for missing file", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockRejectedValue({ code: "ENOENT" });
await expect(readProjectFile(mockStore, "missing.txt")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "missing.txt")).rejects.toThrow("File not found");
});
it("throws FileServiceError when path is not a file", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
await expect(readProjectFile(mockStore, "src")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "src")).rejects.toThrow("Not a file");
});
it("requires file path", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(readProjectFile(mockStore, "")).rejects.toThrow(FileServiceError);
await expect(readProjectFile(mockStore, "")).rejects.toThrow("File path is required");
});
});
describe("writeProjectFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockWriteFile.mockReset();
});
it("prevents writing to directories", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
await expect(writeProjectFile(mockStore, "src", "content")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "src", "content")).rejects.toThrow("Cannot write to directory");
});
it("validates parent directory exists", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // File doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // Parent doesn't exist (this should throw)
await expect(writeProjectFile(mockStore, "missing/file.txt", "content")).rejects.toThrow("Parent directory does not exist");
});
it("throws when parent is not a directory", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // File doesn't exist
.mockResolvedValueOnce({
isDirectory: () => false,
isFile: () => true,
}); // Parent is a file
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent directory does not exist");
});
it("requires file path", async () => {
mockGetRootDir.mockReturnValue("/test/project");
await expect(writeProjectFile(mockStore, "", "content")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "", "content")).rejects.toThrow("File path is required");
});
it("enforces max content size", async () => {
mockGetRootDir.mockReturnValue("/test/project");
const largeContent = "x".repeat(MAX_FILE_SIZE + 1);
await expect(writeProjectFile(mockStore, "large.txt", largeContent)).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "large.txt", largeContent)).rejects.toThrow("Content too large");
});
});
describe("task file operations", () => {
const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn();
const mockStore = {
getTask: mockGetTask,
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetTask.mockReset();
mockGetRootDir.mockReset();
mockStat.mockReset();
mockReaddir.mockReset();
mockReadFile.mockReset();
mockWriteFile.mockReset();
mockExistsSync.mockReset();
mockAccess.mockReset();
});
describe("getTaskBasePath", () => {
it("returns worktree path if it exists", async () => {
const worktreePath = "/worktrees/kb-123";
mockGetTask.mockResolvedValue({
id: "FN-123",
worktree: worktreePath,
});
mockAccess.mockResolvedValue(undefined);
mockStat.mockResolvedValue({
isFile: () => true,
size: 100,
mtime: new Date("2024-01-01"),
});
mockReadFile.mockResolvedValue("Task content");
const result = await readFile(mockStore, "FN-123", "PROMPT.md");
expect(mockReadFile).toHaveBeenCalledWith(
"/worktrees/kb-123/PROMPT.md",
"utf-8",
);
expect(result.content).toBe("Task content");
});
it("falls back to task directory when worktree doesn't exist", async () => {
mockGetTask.mockResolvedValue({
id: "FN-123",
worktree: "/missing/worktree",
});
mockGetRootDir.mockReturnValue("/project");
mockAccess.mockRejectedValue(new Error("not found"));
mockStat.mockResolvedValue({
isFile: () => true,
size: 100,
mtime: new Date("2024-01-01"),
});
mockReadFile.mockResolvedValue("Task content");
await readFile(mockStore, "FN-123", "PROMPT.md");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});
it("falls back to task directory when worktree is undefined", async () => {
mockGetTask.mockResolvedValue({
id: "FN-123",
worktree: undefined,
});
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
size: 100,
mtime: new Date("2024-01-01"),
});
mockReadFile.mockResolvedValue("Task content");
await readFile(mockStore, "FN-123", "PROMPT.md");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});
it("throws ENOTASK for missing task (ENOENT)", async () => {
mockGetTask.mockRejectedValue({ code: "ENOENT" });
await expect(readFile(mockStore, "KB-999", "file.txt")).rejects.toThrow(FileServiceError);
});
it("throws ENOTASK for task not found error message", async () => {
mockGetTask.mockRejectedValue(new Error("Task not found"));
await expect(readFile(mockStore, "KB-999", "file.txt")).rejects.toThrow(FileServiceError);
});
});
});
describe("workspace operations", () => {
const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn();
const mockStore = {
getTask: mockGetTask,
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetTask.mockReset();
mockGetRootDir.mockReset();
mockStat.mockReset();
mockReaddir.mockReset();
mockReadFile.mockReset();
mockWriteFile.mockReset();
mockExistsSync.mockReset();
});
describe("listWorkspaceFiles", () => {
it('"project" workspace resolves to project root', async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
mockReaddir.mockResolvedValue([
{ name: "src", isDirectory: () => true, isFile: () => false },
]);
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
size: 0,
mtime: new Date(),
});
const result = await listWorkspaceFiles(mockStore, "project");
expect(result.entries).toHaveLength(1);
expect(result.entries[0].name).toBe("src");
});
it("task ID workspace resolves to task path", async () => {
mockGetTask.mockResolvedValue({ id: "FN-456", worktree: undefined });
mockGetRootDir.mockReturnValue("/project");
// First call: stat on the directory
// Second call: stat on PROMPT.md entry
mockStat
.mockResolvedValueOnce({
isDirectory: () => true,
isFile: () => false,
size: 0,
mtime: new Date(),
})
.mockResolvedValueOnce({
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
});
mockReaddir.mockResolvedValue([
{ name: "PROMPT.md", isDirectory: () => false, isFile: () => true },
]);
const result = await listWorkspaceFiles(mockStore, "FN-456");
expect(result.entries).toHaveLength(1);
expect(result.entries[0].name).toBe("PROMPT.md");
});
});
describe("readWorkspaceFile", () => {
it("reads file from project workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
size: 50,
mtime: new Date("2024-01-01"),
});
mockReadFile.mockResolvedValue("File contents");
const result = await readWorkspaceFile(mockStore, "project", "README.md");
expect(result.content).toBe("File contents");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/README.md",
"utf-8",
);
});
it("reads file from task workspace", async () => {
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
size: 200,
mtime: new Date("2024-02-01"),
});
mockReadFile.mockResolvedValue("Task description");
const result = await readWorkspaceFile(mockStore, "FN-123", "PROMPT.md");
expect(result.content).toBe("Task description");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});
});
describe("writeWorkspaceFile", () => {
it("writes file to project workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockRejectedValueOnce({ code: "ENOENT" })
.mockResolvedValueOnce({ isDirectory: () => true });
mockWriteFile.mockResolvedValue(undefined);
mockStat.mockResolvedValue({
size: 20,
mtime: new Date("2024-03-01"),
});
const result = await writeWorkspaceFile(mockStore, "project", "notes.txt", "My notes");
expect(result.success).toBe(true);
expect(mockWriteFile).toHaveBeenCalledWith(
"/project/notes.txt",
"My notes",
"utf-8",
);
});
it("writes file to task workspace", async () => {
mockGetTask.mockResolvedValue({ id: "FN-123", worktree: undefined });
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockRejectedValueOnce({ code: "ENOENT" })
.mockResolvedValueOnce({ isDirectory: () => true });
mockWriteFile.mockResolvedValue(undefined);
mockStat.mockResolvedValue({
size: 30,
mtime: new Date("2024-04-01"),
});
const result = await writeWorkspaceFile(mockStore, "FN-123", "output.txt", "Task output");
expect(result.success).toBe(true);
expect(mockWriteFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/FN-123/output.txt",
"Task output",
"utf-8",
);
});
});
});
describe("hidden files visibility", () => {
const mockGetTask = vi.fn();
const mockGetRootDir = vi.fn();
const mockStore = {
getTask: mockGetTask,
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetTask.mockReset();
mockGetRootDir.mockReset();
mockStat.mockReset();
mockReaddir.mockReset();
mockExistsSync.mockReset();
});
describe("listWorkspaceFiles", () => {
it("includes hidden files (dotfiles) in workspace listing", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat for the directory itself, then for each entry
mockStat
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 42, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 200, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 15, mtime: new Date() });
mockReaddir.mockResolvedValue([
{ name: ".env.example", isDirectory: () => false, isFile: () => true },
{ name: ".gitignore", isDirectory: () => false, isFile: () => true },
{ name: "README.md", isDirectory: () => false, isFile: () => true },
]);
const result = await listWorkspaceFiles(mockStore, "project");
expect(result.entries).toHaveLength(3);
const names = result.entries.map((e) => e.name);
expect(names).toContain(".env.example");
expect(names).toContain(".gitignore");
expect(names).toContain("README.md");
});
it("includes hidden directories (dot-directories) in workspace listing", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() });
mockReaddir.mockResolvedValue([
{ name: ".changeset", isDirectory: () => true, isFile: () => false },
{ name: ".github", isDirectory: () => true, isFile: () => false },
{ name: "src", isDirectory: () => true, isFile: () => false },
]);
const result = await listWorkspaceFiles(mockStore, "project");
expect(result.entries).toHaveLength(3);
const names = result.entries.map((e) => e.name);
expect(names).toContain(".changeset");
expect(names).toContain(".github");
expect(names).toContain("src");
// All should be type directory
expect(result.entries.every((e) => e.type === "directory")).toBe(true);
});
it("includes mixed hidden and non-hidden entries with correct sort order", async () => {
mockGetRootDir.mockReturnValue("/project");
// Directory stat + 6 entry stats = 7 mock returns
const dirStat = { isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() };
const fileStat = (size: number) => ({ isDirectory: () => false, isFile: () => true, size, mtime: new Date() });
mockStat
.mockResolvedValueOnce(dirStat) // directory itself
.mockResolvedValueOnce(dirStat) // .github (dir)
.mockResolvedValueOnce(dirStat) // .changeset (dir)
.mockResolvedValueOnce(dirStat) // src (dir)
.mockResolvedValueOnce(fileStat(10)) // .env (file)
.mockResolvedValueOnce(fileStat(300)) // README.md (file)
.mockResolvedValueOnce(fileStat(50)); // package.json (file)
mockReaddir.mockResolvedValue([
{ name: ".env", isDirectory: () => false, isFile: () => true },
{ name: ".github", isDirectory: () => true, isFile: () => false },
{ name: "README.md", isDirectory: () => false, isFile: () => true },
{ name: "src", isDirectory: () => true, isFile: () => false },
{ name: ".changeset", isDirectory: () => true, isFile: () => false },
{ name: "package.json", isDirectory: () => false, isFile: () => true },
]);
const result = await listWorkspaceFiles(mockStore, "project");
expect(result.entries).toHaveLength(6);
// Sort order: directories first, then files, both alphabetically
const types = result.entries.map((e) => e.type);
const lastDirIdx = types.lastIndexOf("directory");
const firstFileIdx = types.indexOf("file");
expect(lastDirIdx).toBeLessThan(firstFileIdx);
// Hidden entries are present
const names = result.entries.map((e) => e.name);
expect(names).toContain(".env");
expect(names).toContain(".github");
expect(names).toContain(".changeset");
});
});
describe("listProjectFiles", () => {
it("includes hidden files and directories in project listing", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 18, mtime: new Date() })
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() });
mockReaddir.mockResolvedValue([
{ name: ".editorconfig", isDirectory: () => false, isFile: () => true },
{ name: ".husky", isDirectory: () => true, isFile: () => false },
]);
const result = await listProjectFiles(mockStore);
expect(result.entries).toHaveLength(2);
const names = result.entries.map((e) => e.name);
expect(names).toContain(".editorconfig");
expect(names).toContain(".husky");
});
});
});
describe("URL-encoded characters handling", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("decodes URL-encoded characters safely in file paths", async () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat.mockResolvedValue({
isFile: () => true,
size: 100,
mtime: new Date("2024-01-01"),
});
mockReadFile.mockResolvedValue("Content");
await readProjectFile(mockStore, "file%20name.txt");
// Should decode %20 to space and look for the file
expect(mockReadFile).toHaveBeenCalledWith(
"/test/project/file name.txt",
"utf-8",
);
});
});
// ── File Operation Tests (Copy, Move, Delete, Rename) ──────────────
describe("copyWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockCopyFile.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("copies a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat for source (exists), stat for destination (ENOENT), stat for dest parent (exists)
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false, size: 100 }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockCopyFile.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src/file.ts", "src/file-copy.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Copied");
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/file.ts", "/project/src/file-copy.ts");
});
it("copies a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true }) // source is dir
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
// readdir for the source directory
mockReaddir.mockResolvedValue([
{ name: "sub.ts", isDirectory: () => false },
]);
// copyFile for the file inside
mockCopyFile.mockResolvedValue(undefined);
mockMkdir.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src", "src-copy");
expect(result.success).toBe(true);
expect(mockMkdir).toHaveBeenCalledWith("/project/src-copy", { recursive: true });
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/sub.ts", "/project/src-copy/sub.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
it("rejects missing destination path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("Destination path is required");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(copyWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects when destination parent does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // dest parent doesn't exist
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "nonexistent/file.ts")).rejects.toThrow("Destination parent directory does not exist");
});
it("rejects operating on workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
// Even though validatePath would resolve "." to the root, the function checks for it
await expect(copyWorkspaceFile(mockStore, "project", ".", "dest")).rejects.toThrow("Cannot operate on workspace root");
});
});
describe("moveWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
});
it("moves a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockRename.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(moveWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
});
describe("deleteWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRm.mockReset();
});
it("deletes a file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/old.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Deleted");
expect(mockRm).toHaveBeenCalledWith("/project/src/old.ts");
});
it("deletes a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/olddir");
expect(result.success).toBe(true);
expect(mockRm).toHaveBeenCalledWith("/project/src/olddir", { recursive: true });
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects deleting workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", ".")).rejects.toThrow("Cannot delete workspace root");
});
it("rejects missing file path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(deleteWorkspaceFile(mockStore, "project", "missing.ts")).rejects.toThrow("Not found");
});
});
describe("renameWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
});
it("renames a file", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat: source exists, dest doesn't exist
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }); // dest doesn't exist
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Renamed");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("renames a directory", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isDirectory: () => true })
.mockRejectedValueOnce({ code: "ENOENT" });
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "src/olddir", "newdir");
expect(result.success).toBe(true);
expect(mockRename).toHaveBeenCalledWith("/project/src/olddir", "/project/src/newdir");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "../secret", "newname")).rejects.toThrow("Path traversal");
});
it("rejects new name with path separator", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub/name.ts")).rejects.toThrow("path separators");
});
it("rejects new name with backslash", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub\\name.ts")).rejects.toThrow("path separators");
});
it("rejects empty new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("New name is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(renameWorkspaceFile(mockStore, "project", "missing.ts", "new.ts")).rejects.toThrow("Not found");
});
it("rejects when a file with the new name already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("already exists");
});
it("rejects renaming workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", ".", "newname")).rejects.toThrow("Cannot rename workspace root");
});
it("rejects null bytes in new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "bad\0name")).rejects.toThrow("path separators");
});
});
describe("getWorkspaceFileForDownload", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns file info for download", async () => {
const mtime = new Date("2024-01-15");
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2048,
mtime,
});
const result = await getWorkspaceFileForDownload(mockStore, "project", "src/file.ts");
expect(result.absolutePath).toBe("/project/src/file.ts");
expect(result.fileName).toBe("file.ts");
expect(result.stats.size).toBe(2048);
expect(result.stats.isFile).toBe(true);
});
it("rejects directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
await expect(getWorkspaceFileForDownload(mockStore, "project", "src")).rejects.toThrow("Not a file");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects non-existent file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValue({ code: "ENOENT" });
await expect(getWorkspaceFileForDownload(mockStore, "project", "missing.ts")).rejects.toThrow("File not found");
});
it("rejects downloading workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root");
});
});
describe("getWorkspaceFolderForZip", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns directory info for zip download", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
const result = await getWorkspaceFolderForZip(mockStore, "project", "src");
expect(result.absolutePath).toBe("/project/src");
expect(result.dirName).toBe("src");
});
it("rejects files (not directories)", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
});
await expect(getWorkspaceFolderForZip(mockStore, "project", "file.ts")).rejects.toThrow("Not a directory");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "")).rejects.toThrow("Directory path is required");
});
it("rejects downloading workspace root as ZIP", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root as ZIP");
});
});
describe("moveWorkspaceFile EXDEV fallback", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("falls back to copy+delete on cross-device move (EXDEV)", async () => {
mockGetRootDir.mockReturnValue("/project");
// Source exists, destination doesn't exist, dest parent exists
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source exists (move check)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (move check)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (move check)
// copyWorkspaceFile will be called with same paths
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source (copy)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (copy)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (copy)
// deleteWorkspaceFile will validate
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }); // source exists (delete)
// rename throws EXDEV
mockRename.mockRejectedValue({ code: "EXDEV" });
mockCopyFile.mockResolvedValue(undefined);
mockRm.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "file.ts", "moved.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockCopyFile).toHaveBeenCalledWith("/project/file.ts", "/project/moved.ts");
expect(mockRm).toHaveBeenCalledWith("/project/file.ts");
});
});
describe("searchWorkspaceFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockReaddir.mockReset();
});
it("returns matching files filtered by query", async () => {
mockGetRootDir.mockReturnValue("/project");
// Mock: root has one subdir and one file
mockReaddir.mockResolvedValueOnce([
{ name: "src", isDirectory: () => true, isFile: () => false },
{ name: "file.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
// Mock: src directory has a matching file
mockReaddir.mockResolvedValueOnce([
{ name: "index.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await searchWorkspaceFiles(mockStore, "project", "index");
expect(result.files).toContainEqual({ path: "src/index.ts", name: "index.ts" });
expect(result.files).not.toContainEqual({ path: "file.ts", name: "file.ts" });
});
it("excludes common directories (node_modules, .git, etc.)", async () => {
mockGetRootDir.mockReturnValue("/project");
// Root has a subdir that will be excluded
mockReaddir.mockResolvedValueOnce([
{ name: "node_modules", isDirectory: () => true, isFile: () => false },
{ name: ".git", isDirectory: () => true, isFile: () => false },
{ name: "src", isDirectory: () => true, isFile: () => false },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
// Only src should be walked
mockReaddir.mockResolvedValueOnce([
{ name: "app.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await searchWorkspaceFiles(mockStore, "project", "app");
expect(result.files).toContainEqual({ path: "src/app.ts", name: "app.ts" });
expect(mockReaddir).toHaveBeenCalledTimes(2); // root + src only
});
it("limits results to 50 matches maximum", async () => {
mockGetRootDir.mockReturnValue("/project");
// Return 60 files to test the limit
const manyFiles = Array.from({ length: 60 }, (_, i) => ({
name: `file${i}.ts`,
isDirectory: () => false,
isFile: () => true,
}));
mockReaddir.mockResolvedValueOnce(manyFiles as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await searchWorkspaceFiles(mockStore, "project", "file");
expect(result.files.length).toBe(50);
});
it("does not throw when a subdirectory cannot be read", async () => {
mockGetRootDir.mockReturnValue("/project");
// Root has two directories
mockReaddir.mockResolvedValueOnce([
{ name: "good", isDirectory: () => true, isFile: () => false },
{ name: "bad", isDirectory: () => true, isFile: () => false },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
// good/ directory is accessible and has a matching file
mockReaddir.mockResolvedValueOnce([
{ name: "target.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
// bad/ directory throws EACCES
mockReaddir.mockRejectedValueOnce({ code: "EACCES" });
// Should complete without throwing
const result = await searchWorkspaceFiles(mockStore, "project", "target");
expect(result.files).toContainEqual({ path: "good/target.ts", name: "target.ts" });
});
it("returns empty array when no files match", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValueOnce([
{ name: "src", isDirectory: () => true, isFile: () => false },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
// src directory has no matching files
mockReaddir.mockResolvedValueOnce([
{ name: "other.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await searchWorkspaceFiles(mockStore, "project", "nonexistent");
expect(result.files).toEqual([]);
});
it("supports case-insensitive matching", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValueOnce([
{ name: "MyComponent.tsx", isDirectory: () => false, isFile: () => true },
{ name: "another.ts", isDirectory: () => false, isFile: () => true },
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await searchWorkspaceFiles(mockStore, "project", "mycomp");
expect(result.files).toContainEqual({ path: "MyComponent.tsx", name: "MyComponent.tsx" });
});
});
describe("listProjectMarkdownFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
function directoryEntry(name: string) {
return {
name,
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
function fileEntry(name: string) {
return {
name,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
beforeEach(() => {
mockGetRootDir.mockReset();
mockReaddir.mockReset();
mockStat.mockReset();
});
it("returns markdown files from root and nested directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry("notes.txt"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md") || targetPath.endsWith("guide.md")) {
return {
isFile: () => true,
isDirectory: () => false,
size: 128,
mtime: new Date("2024-01-01T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result).toEqual({
files: [
{
path: "docs/guide.md",
name: "guide.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
},
{
path: "README.md",
name: "README.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
},
],
});
});
it("skips node_modules, .git, .fusion, and dist directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry("node_modules"),
directoryEntry(".git"),
directoryEntry(".fusion"),
directoryEntry("dist"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("allowed.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files).toEqual([
{
path: "docs/allowed.md",
name: "allowed.md",
size: 64,
mtime: "2024-01-02T00:00:00.000Z",
},
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/node_modules", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/dist", { withFileTypes: true });
});
it("omits hidden markdown files and markdown files inside hidden directories by default", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry(".secret.md"),
directoryEntry("docs"),
directoryEntry(".hidden"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files.map((entry) => entry.path)).toEqual([
"docs/guide.md",
"README.md",
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.hidden", { withFileTypes: true });
});
it("includes hidden markdown files and hidden directories when showHidden is true", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry(".secret.md"),
directoryEntry("docs"),
directoryEntry(".hidden"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore, { showHidden: true });
expect(result.files.map((entry) => entry.path)).toEqual([
".hidden/internal.md",
".secret.md",
"docs/guide.md",
"README.md",
]);
});
it("keeps hard-excluded directories hidden even when showHidden is true", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry(".git"),
directoryEntry(".fusion"),
directoryEntry(".hidden"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore, { showHidden: true });
expect(result.files.map((entry) => entry.path)).toEqual([
".hidden/internal.md",
"docs/guide.md",
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
});
it("returns an empty list when no markdown files exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([
fileEntry("package.json"),
fileEntry("notes.txt"),
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await listProjectMarkdownFiles(mockStore);
expect(result).toEqual({ files: [] });
expect(mockStat).not.toHaveBeenCalled();
});
it("sorts markdown results alphabetically by path", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("zeta.md"),
directoryEntry("docs"),
fileEntry("alpha.md"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("middle.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 33,
mtime: new Date("2024-01-03T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files.map((entry) => entry.path)).toEqual([
"alpha.md",
"docs/middle.md",
"zeta.md",
]);
});
});
describe("scanMarkdownFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
function directoryEntry(name: string) {
return {
name,
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
function fileEntry(name: string) {
return {
name,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
function symlinkEntry(name: string) {
return {
name,
isDirectory: () => false,
isFile: () => false,
isSymbolicLink: () => true,
};
}
beforeEach(() => {
mockGetRootDir.mockReset();
mockReaddir.mockReset();
mockStat.mockReset();
mockReadFile.mockReset();
});
it("finds markdown files in project root and nested directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry("notes.txt"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("CONTRIBUTING.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md") || targetPath.endsWith("CONTRIBUTING.md")) {
return {
isFile: () => true,
isDirectory: () => false,
size: 128,
mtime: new Date("2024-01-01T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md")) {
return "Root readme";
}
if (targetPath.endsWith("CONTRIBUTING.md")) {
return "Contribution guide";
}
throw { code: "ENOENT" };
});
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([
{
path: "docs/CONTRIBUTING.md",
name: "CONTRIBUTING.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
contentPreview: "Contribution guide",
},
{
path: "README.md",
name: "README.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
contentPreview: "Root readme",
},
]);
});
it("excludes markdown files in blocked directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry(".git"),
directoryEntry("node_modules"),
directoryEntry(".fusion"),
directoryEntry("dist"),
directoryEntry("build"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("README.md")];
}
throw { code: "ENOENT" };
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 32,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("Allowed file");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("docs/README.md");
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/node_modules", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/dist", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/build", { withFileTypes: true });
});
it("respects maxDepth when scanning nested directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [directoryEntry("level-1")];
}
if (targetPath === "/project/level-1") {
return [directoryEntry("level-2")];
}
if (targetPath === "/project/level-1/level-2") {
return [fileEntry("deep.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 42,
mtime: new Date("2024-01-03T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("Deep file");
const shallowResult = await scanMarkdownFiles(mockStore, { maxDepth: 1 });
expect(shallowResult).toEqual([]);
const deepResult = await scanMarkdownFiles(mockStore, { maxDepth: 2 });
expect(deepResult).toHaveLength(1);
expect(deepResult[0].path).toBe("level-1/level-2/deep.md");
});
it("skips files that exceed max file size", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([fileEntry("LARGE.md")]);
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2 * 1024 * 1024,
mtime: new Date("2024-01-04T00:00:00.000Z"),
});
const result = await scanMarkdownFiles(mockStore, { maxFileSize: 1024 * 1024 });
expect(result).toEqual([]);
expect(mockReadFile).not.toHaveBeenCalled();
});
it("caps content preview to 200 characters", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([fileEntry("README.md")]);
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 200,
mtime: new Date("2024-01-05T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("a".repeat(250));
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].contentPreview).toBe("a".repeat(200));
expect(result[0].contentPreview.length).toBe(200);
});
it("follows symlinked directories when they point to markdown files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [symlinkEntry("docs-link")];
}
if (targetPath === "/project/docs-link") {
return [fileEntry("linked.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project/docs-link") {
return {
isDirectory: () => true,
isFile: () => false,
size: 0,
mtime: new Date("2024-01-06T00:00:00.000Z"),
};
}
if (targetPath === "/project/docs-link/linked.md") {
return {
isDirectory: () => false,
isFile: () => true,
size: 77,
mtime: new Date("2024-01-06T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockResolvedValue("Linked markdown content");
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([
{
path: "docs-link/linked.md",
name: "linked.md",
size: 77,
mtime: "2024-01-06T00:00:00.000Z",
contentPreview: "Linked markdown content",
},
]);
expect(mockReaddir).toHaveBeenCalledWith("/project/docs-link", { withFileTypes: true });
});
it("returns an empty list when root directory has no entries", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([]);
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([]);
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it("finds markdown files at depth 4 and deeper within maxDepth", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") return [directoryEntry("level-1")];
if (targetPath === "/project/level-1") return [directoryEntry("level-2")];
if (targetPath === "/project/level-1/level-2") return [directoryEntry("level-3")];
if (targetPath === "/project/level-1/level-2/level-3") return [directoryEntry("level-4")];
if (targetPath === "/project/level-1/level-2/level-3/level-4") return [fileEntry("deep.md")];
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 55,
mtime: new Date("2024-01-07T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("deep markdown");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("level-1/level-2/level-3/level-4/deep.md");
});
it("does not treat directories with .md in the name as markdown files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry("readme.md-backup"),
fileEntry("actual.md"),
];
}
if (targetPath === "/project/readme.md-backup") {
return [fileEntry("notes.txt")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project/actual.md") {
return {
isFile: () => true,
isDirectory: () => false,
size: 18,
mtime: new Date("2024-01-08T00:00:00.000Z"),
};
}
if (targetPath === "/project/readme.md-backup/notes.txt") {
return {
isFile: () => true,
isDirectory: () => false,
size: 18,
mtime: new Date("2024-01-08T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockResolvedValue("Actual markdown file");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("actual.md");
expect(result[0].name).toBe("actual.md");
});
it("returns files sorted by relative path", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("z-last.md"),
directoryEntry("docs"),
fileEntry("a-first.md"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("middle.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 90,
mtime: new Date("2024-01-06T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("content");
const result = await scanMarkdownFiles(mockStore);
expect(result.map((entry) => entry.path)).toEqual([
"a-first.md",
"docs/middle.md",
"z-last.md",
]);
});
});