Files
fusion/packages/dashboard/app/components/__tests__/FileBrowser.test.tsx
gsxdsm 2719196703 feat(FN-943): add file browser context menu with copy, move, delete, rename, and download operations
- Add backend file operations (copy, move, delete, rename, download) in file-service.ts with workspace root protection
- Add API routes for file operations including directory download as zip via archiver
- Add client API functions in api.ts for all file operation endpoints
- Add context menu UI in FileBrowser component with operations dialog for confirming destructive actions
- Add CSS styles for context menu including danger items and dividers
- Add 33 FileBrowser context menu tests and 485+ file-service tests
- Fix pre-existing test failures in routes-diff, mission-e2e, and theme sync tests
2026-04-05 20:54:11 -07:00

460 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { FileBrowser } from "../FileBrowser";
import type { FileNode } from "../../api";
// ── Mocks ───────────────────────────────────────────────────────────────
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
Folder: (props: any) => <span data-testid="folder-icon" {...props} />,
File: (props: any) => <span data-testid="file-icon" {...props} />,
ChevronRight: (props: any) => <span data-testid="chevron" {...props} />,
Loader2: (props: any) => <span data-testid="loader" {...props} />,
Copy: (props: any) => <span data-testid="icon-copy" {...props} />,
Move: (props: any) => <span data-testid="icon-move" {...props} />,
Trash2: (props: any) => <span data-testid="icon-trash" {...props} />,
Pencil: (props: any) => <span data-testid="icon-pencil" {...props} />,
Download: (props: any) => <span data-testid="icon-download" {...props} />,
Archive: (props: any) => <span data-testid="icon-archive" {...props} />,
};
});
const mockCopyFile = vi.fn();
const mockMoveFile = vi.fn();
const mockDeleteFile = vi.fn();
const mockRenameFile = vi.fn();
vi.mock("../../api", () => ({
copyFile: (...args: any[]) => mockCopyFile(...args),
moveFile: (...args: any[]) => mockMoveFile(...args),
deleteFile: (...args: any[]) => mockDeleteFile(...args),
renameFile: (...args: any[]) => mockRenameFile(...args),
downloadFileUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download?workspace=test-ws`,
downloadZipUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
}));
// ── Test Data ───────────────────────────────────────────────────────────
const fileEntry: FileNode = {
name: "readme.md",
type: "file",
size: 1234,
mtime: "2026-01-15T10:30:00Z",
};
const dirEntry: FileNode = {
name: "src",
type: "directory",
mtime: "2026-01-14T08:00:00Z",
};
const sampleEntries: FileNode[] = [dirEntry, fileEntry];
// ── Helpers ─────────────────────────────────────────────────────────────
const defaultProps = {
entries: sampleEntries,
currentPath: ".",
onSelectFile: vi.fn(),
onNavigate: vi.fn(),
workspace: "test-ws",
onRefresh: vi.fn(),
};
function renderFileBrowser(overrides: Partial<typeof defaultProps> = {}) {
const props = { ...defaultProps, ...overrides };
return render(<FileBrowser {...props} />);
}
function contextMenuClick(entryName: string) {
const entry = screen.getByText(entryName).closest(".file-node");
if (!entry) throw new Error(`Entry not found: ${entryName}`);
fireEvent.contextMenu(entry, { clientX: 200, clientY: 300 });
}
// ── Tests ───────────────────────────────────────────────────────────────
describe("FileBrowser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
// ── Basic Rendering ─────────────────────────────────────────────────
it("renders file entries with names and sizes", () => {
renderFileBrowser();
expect(screen.getByText("readme.md")).toBeDefined();
expect(screen.getByText("src")).toBeDefined();
expect(screen.getByText("1.2 KB")).toBeDefined();
});
it("shows root path label", () => {
renderFileBrowser({ currentPath: "." });
expect(screen.getByText("Root")).toBeDefined();
});
it("shows current path when not root", () => {
renderFileBrowser({ currentPath: "packages/core" });
expect(screen.getByText("packages/core")).toBeDefined();
});
it("shows empty directory message when no entries", () => {
renderFileBrowser({ entries: [] });
expect(screen.getByText("(empty directory)")).toBeDefined();
});
it("shows loading state", () => {
renderFileBrowser({ entries: [], loading: true });
expect(screen.getByText("Loading files...")).toBeDefined();
});
it("shows error state with retry button", () => {
const onRetry = vi.fn();
renderFileBrowser({ entries: [], error: "Something broke", onRetry });
expect(screen.getByText(/Something broke/)).toBeDefined();
fireEvent.click(screen.getByText("Retry"));
expect(onRetry).toHaveBeenCalled();
});
// ── Navigation ──────────────────────────────────────────────────────
it("navigates into directory on click", () => {
const onNavigate = vi.fn();
renderFileBrowser({ onNavigate });
fireEvent.click(screen.getByText("src"));
expect(onNavigate).toHaveBeenCalledWith("src");
});
it("selects file on click", () => {
const onSelectFile = vi.fn();
renderFileBrowser({ onSelectFile });
fireEvent.click(screen.getByText("readme.md"));
expect(onSelectFile).toHaveBeenCalledWith("readme.md");
});
it("navigates into nested directory", () => {
const onNavigate = vi.fn();
renderFileBrowser({ currentPath: "packages", onNavigate });
fireEvent.click(screen.getByText("src"));
expect(onNavigate).toHaveBeenCalledWith("packages/src");
});
it("navigates up one level", () => {
const onNavigate = vi.fn();
renderFileBrowser({ currentPath: "packages/core/src", onNavigate });
const upButton = screen.getByText("Up one level");
fireEvent.click(upButton);
expect(onNavigate).toHaveBeenCalledWith("packages/core");
});
// ── Context Menu Appearance ─────────────────────────────────────────
it("shows context menu on right-click on a file", () => {
renderFileBrowser();
contextMenuClick("readme.md");
// Menu should be visible with the role
expect(screen.getByRole("menu")).toBeDefined();
});
it("shows context menu on right-click on a directory", () => {
renderFileBrowser();
contextMenuClick("src");
expect(screen.getByRole("menu")).toBeDefined();
});
// ── Context Menu Items for Files ────────────────────────────────────
it("shows file context menu with Download option (not Download as ZIP)", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByText("Download")).toBeDefined();
expect(screen.queryByText("Download as ZIP")).toBeNull();
});
it("shows Copy, Move, Rename, Delete for files", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByText("Copy")).toBeDefined();
expect(screen.getByText("Move")).toBeDefined();
expect(screen.getByText("Rename")).toBeDefined();
expect(screen.getByText("Delete")).toBeDefined();
});
// ── Context Menu Items for Directories ──────────────────────────────
it("shows directory context menu with Download as ZIP (not Download)", () => {
renderFileBrowser();
contextMenuClick("src");
expect(screen.getByText("Download as ZIP")).toBeDefined();
expect(screen.queryByText("Download")).toBeNull();
});
// ── Context Menu Closing ────────────────────────────────────────────
it("closes context menu on Escape key", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes context menu on overlay click", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const overlay = document.querySelector(".context-menu-overlay");
expect(overlay).not.toBeNull();
fireEvent.click(overlay!);
expect(screen.queryByRole("menu")).toBeNull();
});
// ── Download Actions ────────────────────────────────────────────────
it("opens download URL for file when Download is clicked", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Download"));
expect(openSpy).toHaveBeenCalledWith(
"/api/files/readme.md/download?workspace=test-ws",
"_blank"
);
openSpy.mockRestore();
});
it("opens download-zip URL for directory when Download as ZIP is clicked", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("src");
fireEvent.click(screen.getByText("Download as ZIP"));
expect(openSpy).toHaveBeenCalledWith(
"/api/files/src/download-zip?workspace=test-ws",
"_blank"
);
openSpy.mockRestore();
});
// ── Delete Dialog ───────────────────────────────────────────────────
it("shows delete confirmation dialog when Delete is clicked", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByText("Delete File")).toBeDefined();
expect(screen.getByText(/Are you sure you want to delete/)).toBeDefined();
});
it("shows directory delete warning for directories", () => {
renderFileBrowser();
contextMenuClick("src");
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByText("Delete Folder")).toBeDefined();
expect(screen.getByText(/recursively/)).toBeDefined();
});
it("calls deleteFile API and refreshes on delete confirm", async () => {
mockDeleteFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
// Click Delete in the context menu (role=menuitem)
const menuDelete = screen.getAllByText("Delete").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuDelete!);
// Click the danger Delete button in the confirmation dialog
const dangerBtn = document.querySelector(".btn-danger");
expect(dangerBtn).not.toBeNull();
fireEvent.click(dangerBtn!);
await waitFor(() => {
expect(mockDeleteFile).toHaveBeenCalledWith("test-ws", "readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
it("shows error when delete fails", async () => {
mockDeleteFile.mockRejectedValue(new Error("Delete failed"));
renderFileBrowser();
contextMenuClick("readme.md");
// Click Delete in the menu
const menuItems = screen.getAllByText("Delete");
fireEvent.click(menuItems[0]);
// Click Delete in the dialog
const dialogBtn = screen.getAllByText("Delete");
const deleteButton = dialogBtn.find(
(el) => el.closest("button")?.classList.contains("btn-danger")
);
if (deleteButton) {
fireEvent.click(deleteButton.closest("button")!);
}
await waitFor(() => {
expect(screen.getByText("Delete failed")).toBeDefined();
});
});
it("closes delete dialog on Cancel", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Delete"));
// Click Cancel in the dialog
const cancelButtons = screen.getAllByText("Cancel");
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(screen.queryByText("Delete File")).toBeNull();
});
// ── Rename Dialog ───────────────────────────────────────────────────
it("shows rename dialog with pre-filled name", () => {
renderFileBrowser();
contextMenuClick("readme.md");
// Click Rename in the context menu (role=menuitem)
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
const input = screen.getByPlaceholderText("New name") as HTMLInputElement;
expect(input.value).toBe("readme.md");
});
it("calls renameFile API and refreshes on rename confirm", async () => {
mockRenameFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
// Click Rename in the menu
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
// Type new name in the dialog input
const input = screen.getByPlaceholderText("New name");
fireEvent.change(input, { target: { value: "new-readme.md" } });
// Click Rename in the dialog
const dialogRename = screen.getAllByText("Rename").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogRename!.closest("button")!);
await waitFor(() => {
expect(mockRenameFile).toHaveBeenCalledWith("test-ws", "readme.md", "new-readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
it("closes rename dialog on Cancel", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Rename"));
const cancelButtons = screen.getAllByText("Cancel");
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(screen.queryByPlaceholderText("New name")).toBeNull();
});
// ── Copy Dialog ─────────────────────────────────────────────────────
it("shows copy dialog with destination input", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuCopy = screen.getAllByText("Copy").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuCopy!);
expect(screen.getByPlaceholderText("Destination path")).toBeDefined();
});
it("calls copyFile API and refreshes on copy confirm", async () => {
mockCopyFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
const menuCopy = screen.getAllByText("Copy").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuCopy!);
const input = screen.getByPlaceholderText("Destination path");
fireEvent.change(input, { target: { value: "backup/readme.md" } });
const dialogCopy = screen.getAllByText("Copy").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogCopy!.closest("button")!);
await waitFor(() => {
expect(mockCopyFile).toHaveBeenCalledWith("test-ws", "readme.md", "backup/readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
// ── Move Dialog ─────────────────────────────────────────────────────
it("shows move dialog with destination input", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuMove = screen.getAllByText("Move").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuMove!);
expect(screen.getByPlaceholderText("Destination path")).toBeDefined();
});
it("calls moveFile API and refreshes on move confirm", async () => {
mockMoveFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
const menuMove = screen.getAllByText("Move").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuMove!);
const input = screen.getByPlaceholderText("Destination path");
fireEvent.change(input, { target: { value: "docs/readme.md" } });
const dialogMove = screen.getAllByText("Move").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogMove!.closest("button")!);
await waitFor(() => {
expect(mockMoveFile).toHaveBeenCalledWith("test-ws", "readme.md", "docs/readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
// ── Error Handling ──────────────────────────────────────────────────
it("shows error in dialog when API call fails", async () => {
mockRenameFile.mockRejectedValue(new Error("Something went wrong"));
renderFileBrowser();
contextMenuClick("readme.md");
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
const input = screen.getByPlaceholderText("New name");
fireEvent.change(input, { target: { value: "new-name.md" } });
const dialogRename = screen.getAllByText("Rename").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogRename!.closest("button")!);
await waitFor(() => {
expect(screen.getByText("Something went wrong")).toBeDefined();
});
});
it("closes dialog on Escape from dialog", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
expect(screen.getByPlaceholderText("New name")).toBeDefined();
fireEvent.keyDown(screen.getByPlaceholderText("New name"), { key: "Escape" });
expect(screen.queryByPlaceholderText("New name")).toBeNull();
});
});