/** * @file Tests for the workspace File Explorer modal: it lists a folder and * opens sub-folders, makes a folder inline, deletes into the Trash without a * confirm, copies in one workspace and pastes into another (`fromLane`), * asks Replace / Keep both / Skip when an upload's name is taken, refuses a * batch over 500 MB before sending anything, restores from the user's own * Trash, opens an admin's Trash on one folder per user (counted, an unreadable * one saying why), opens each user's Trash from its folder (asking before a * purge there, sending the item's store) and * shows a Trash that could not be read as an error, never as empty; nests a * user's workspaces under their home in the sidebar (under a fold-only row * when the viewer may not open that home). * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FileExplorer } from "../FileExplorer"; import { api, type FsListing } from "../../../lib/api"; import { toast } from "../../../lib/toast"; import { confirmDialog } from "../../../lib/confirm"; vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) })); vi.mock("../../../lib/api", () => ({ gitTarget: (lane: number, session?: string | null, source?: number | null) => source ? { lane, source } : session ? { lane, session } : lane, isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p, api: { lanes: { list: vi.fn(), clone: vi.fn(), gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`, }, files: { homes: vi.fn(), downloadUrl: (t: unknown, p: string) => `/dl/${JSON.stringify(t)}/${p}`, list: vi.fn(), mkdir: vi.fn(), newFile: vi.fn(), rename: vi.fn(), move: vi.fn(), copy: vi.fn(), remove: vi.fn(), trash: vi.fn(), allTrash: vi.fn(), restore: vi.fn(), purge: vi.fn(), upload: vi.fn(), uploadBatch: vi.fn(), uploadCheck: vi.fn(), zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`, }, }, })); vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); const root: FsListing = { path: "", parent: null, truncated: false, entries: [ { name: "src", path: "src", type: "dir", size: null, mtime: 0 }, { name: "a.txt", path: "a.txt", type: "file", size: 12, mtime: 0 }, ], }; const src: FsListing = { path: "src", parent: "", truncated: false, entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }], }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [ { id: 1, title: "one", cwd: "/w/one" }, { id: 2, title: "two", cwd: "/w/two" }, ], counts: {}, } as never); vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root)); vi.mocked(api.files.trash).mockResolvedValue({ items: [] }); vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] }); vi.mocked(api.files.homes).mockResolvedValue({ homes: [ { id: "me", label: "An", readOnly: false }, { id: "bob", label: "Bob", readOnly: true }, ], }); }); const open = () => render(); const row = (name: string) => screen.getAllByTestId("fx-row").find((r) => r.textContent?.includes(name))!; describe("FileExplorer", () => { it("hides dot files until Ctrl+H, like the Linux file manager", async () => { vi.mocked(api.files.list).mockResolvedValue({ ...root, entries: [ ...root.entries, { name: ".env.example", path: ".env.example", type: "file", size: 1, mtime: 0 }, ], }); open(); await screen.findByText("a.txt"); expect(screen.queryByText(".env.example")).toBeNull(); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "h", ctrlKey: true }); expect(await screen.findByText(".env.example")).toBeInTheDocument(); }); it("shows a labelled action bar for a selection, and goes Back after opening a folder", async () => { const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click(row("a.txt")); const bar = screen.getByTestId("fx-actionbar"); expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument(); expect(within(bar).getByRole("button", { name: /Move to Trash/ })).toBeInTheDocument(); fireEvent.doubleClick(row("src")); await screen.findByText("x.js"); await user.click(screen.getByRole("button", { name: "Back" })); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, "")); }); it("lists a folder and opens a sub-folder on double-click", async () => { open(); expect(await screen.findByText("a.txt")).toBeInTheDocument(); fireEvent.doubleClick(row("src")); expect(await screen.findByText("x.js")).toBeInTheDocument(); expect(api.files.list).toHaveBeenLastCalledWith(1, "src"); }); it("makes a folder inline", async () => { vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click(screen.getByRole("button", { name: "New" })); await user.click(screen.getByRole("menuitem", { name: "New folder" })); const name = screen.getByLabelText("Name"); await user.clear(name); await user.type(name, "docs{Enter}"); await waitFor(() => expect(api.files.mkdir).toHaveBeenCalledWith(1, "docs")); }); it("deletes into the Trash without asking", async () => { vi.mocked(api.files.remove).mockResolvedValue({ trashed: ["id1"] }); open(); await screen.findByText("a.txt"); fireEvent.click(row("a.txt")); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" }); await waitFor(() => expect(api.files.remove).toHaveBeenCalledWith(1, ["a.txt"])); expect(screen.queryByRole("alertdialog")).toBeNull(); }); it("copies here and pastes into another workspace", async () => { vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); fireEvent.click(row("a.txt")); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true }); await user.click(await screen.findByRole("button", { name: "two" })); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, "")); await user.click(screen.getByRole("button", { name: /Paste 1 item/ })); await waitFor(() => expect(api.files.copy).toHaveBeenCalledWith(2, ["a.txt"], "", { fromLane: 1, conflict: "fail", }) ); }); it("asks about a taken name on upload, and sends the choice", async () => { vi.mocked(api.files.upload) .mockRejectedValueOnce( Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] }) ) .mockResolvedValueOnce({ path: "a (1).txt" }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const file = new File(["hi"], "a.txt"); await user.upload(screen.getByTestId("fx-upload-files"), file); const ask = await screen.findByTestId("fx-conflict"); await user.click(within(ask).getByRole("button", { name: "Keep both" })); await waitFor(() => expect(api.files.upload).toHaveBeenLastCalledWith( 1, "a.txt", file, "keep", expect.any(Function) ) ); }); it("sends many small files as one batch, asking once about every taken name", async () => { vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true }); vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n)); await user.upload(screen.getByTestId("fx-upload-files"), files); const ask = await screen.findByTestId("fx-conflict"); await user.click(within(ask).getByRole("button", { name: "Replace" })); await waitFor(() => expect(api.files.uploadBatch).toHaveBeenCalledWith( 1, "", files.map((file) => ({ file, rel: file.name })), "replace", expect.any(Function) ) ); expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]); expect(api.files.upload).not.toHaveBeenCalled(); }); it("falls back to one request per file when the server cannot take a batch", async () => { vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false }); vi.mocked(api.files.upload).mockResolvedValue({ path: "x" }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n)); await user.upload(screen.getByTestId("fx-upload-files"), files); await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2)); expect(api.files.uploadBatch).not.toHaveBeenCalled(); }); it("refuses a batch over 500 MB before sending anything", async () => { open(); await screen.findByText("a.txt"); const big = new File(["x"], "big.bin"); Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 }); fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } }); await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(api.files.upload).not.toHaveBeenCalled(); }); it("restores from the Trash", async () => { vi.mocked(api.files.trash).mockResolvedValue({ items: [ { id: "abc12345", name: "a.txt", laneId: 1, path: "a.txt", isDir: false, deletedAt: "2026-10-14T00:00:00Z", }, ], }); vi.mocked(api.files.restore).mockResolvedValue({ path: "a.txt", laneId: null }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click( within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" }) ); await user.click(await screen.findByRole("button", { name: /My Trash/ })); await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" })); const trashRow = await screen.findByTestId("fx-trash-row"); await user.click(within(trashRow).getByRole("button", { name: /Restore/ })); await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined) ); }); it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => { vi.mocked(api.files.allTrash).mockResolvedValue({ items: [ { id: "bob12345", name: "old", laneId: null, path: "old", isDir: true, deletedAt: "2026-10-15T08:00:00.000Z", store: "host", home: "bob", owner: "Bob", }, ], errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }], }); vi.mocked(api.files.trash).mockResolvedValue({ items: [ { id: "bob12345", name: "old", laneId: null, path: "old", isDir: true, deletedAt: "2026-10-15T08:00:00.000Z", store: "host", }, ], }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const trashes = screen.getByTestId("fx-trashes"); expect(within(trashes).getAllByRole("button")).toHaveLength(1); await user.click(within(trashes).getByRole("button", { name: "Trash" })); const folders = await screen.findAllByTestId("fx-trash-folder"); expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]); expect(screen.queryByTestId("fx-trash-row")).toBeNull(); await user.click(folders[1]!); await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" })); expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob"); const trashRow = await screen.findByTestId("fx-trash-row"); await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ })); await waitFor(() => expect(confirmDialog).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Bob's Trash") }) ) ); expect(api.files.purge).not.toHaveBeenCalled(); vi.mocked(confirmDialog).mockResolvedValueOnce(true); await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ })); await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host") ); // Back to the folders through the path bar. await user.click( within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", { name: "Trash", }) ); expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2); }); it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => { vi.mocked(api.files.allTrash).mockResolvedValue({ items: [ { id: "bob12345", name: "old", laneId: null, path: "old", isDir: true, deletedAt: "2026-10-15T08:00:00.000Z", store: "host", home: "bob", owner: "Bob", }, ], errors: [], }); vi.mocked(api.files.trash).mockResolvedValue({ items: [ { id: "bob12345", name: "old", laneId: null, path: "old", isDir: true, deletedAt: "2026-10-15T08:00:00.000Z", store: "host", }, ], }); vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click( within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" }) ); const folders = await screen.findAllByTestId("fx-trash-folder"); // My Trash is empty: its Empty Trash is disabled. fireEvent.contextMenu(folders[0]!); expect( within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Empty Trash", }) ).toHaveProperty("disabled", true); fireEvent.keyDown(document, { key: "Escape" }); fireEvent.contextMenu(folders[1]!); vi.mocked(confirmDialog).mockResolvedValueOnce(true); await user.click( within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Empty Trash", }) ); await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all")); expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull(); fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!); await user.click( within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" }) ); fireEvent.contextMenu(await screen.findByTestId("fx-trash-row")); await user.click( within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" }) ); await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host") ); }); it("right-clicks a sidebar workspace and a path crumb", async () => { const user = userEvent.setup(); open(); await screen.findByText("a.txt"); fireEvent.contextMenu(screen.getByRole("button", { name: "two" })); await user.click( within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" }) ); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, "")); fireEvent.contextMenu(screen.getByRole("button", { name: "Home" })); await user.click( within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" }) ); await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" })); }); it("shows why a Trash could not be read instead of an empty one", async () => { vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder")); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click( within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" }) ); await user.click(await screen.findByRole("button", { name: /My Trash/ })); expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy(); expect(screen.queryByText("The Trash is empty.")).toBeNull(); }); it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => { vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click(await screen.findByRole("button", { name: "Home" })); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith({ home: "me" }, "")); fireEvent.click(row("a.txt")); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true }); await user.click(screen.getByRole("button", { name: "one" })); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, "")); await user.click(screen.getByRole("button", { name: /Paste 1 item/ })); await waitFor(() => expect(api.files.copy).toHaveBeenCalledWith(1, ["a.txt"], "", { fromHome: "me", conflict: "fail", }) ); }); it("shows another user's home read-only: no New, Upload, Cut, Rename or Delete", async () => { const user = userEvent.setup(); open(); await screen.findByText("a.txt"); await user.click(await screen.findByRole("button", { name: "Bob" })); expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "New" })).toBeNull(); expect(screen.queryByRole("button", { name: "Upload" })).toBeNull(); await user.click(row("a.txt")); const bar = screen.getByTestId("fx-actionbar"); expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument(); expect(within(bar).getByRole("button", { name: /Copy/ })).toBeInTheDocument(); expect(within(bar).queryByRole("button", { name: /Cut|Rename|Move to Trash/ })).toBeNull(); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" }); expect(api.files.remove).not.toHaveBeenCalled(); }); it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => { localStorage.removeItem("agenthub.files.homesOpen"); vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [ { id: 1, title: "one", cwd: "/w/one" }, { id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" }, ], counts: {}, } as never); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" }); expect(toggle).toHaveAttribute("aria-expanded", "false"); const places = screen.getByRole("navigation", { name: "Workspaces" }); expect(within(places).queryByRole("button", { name: "two" })).toBeNull(); expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument(); await user.click(toggle); await user.click(within(places).getByRole("button", { name: "two" })); await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, "")); expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob"); }); it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => { localStorage.removeItem("agenthub.files.homesOpen"); vi.mocked(api.files.homes).mockResolvedValue({ homes: [{ id: "me", label: "An", readOnly: false }], }); vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [ { id: 1, title: "one", cwd: "/w/one" }, { id: 3, title: "three", cwd: "/w/three", owner_id: 9, owner_name: "cara", owner_display_name: "Cara", }, ], counts: {}, } as never); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const places = screen.getByRole("navigation", { name: "Workspaces" }); expect(within(places).getByText("Users")).toBeInTheDocument(); expect(within(places).queryByRole("button", { name: "three" })).toBeNull(); await user.click(within(places).getByRole("button", { name: /^Cara/ })); expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, ""); expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument(); }); }); describe("FileExplorer — New → Clone repository", () => { const openClone = async (user: ReturnType) => { await user.click(screen.getByRole("button", { name: "New" })); await user.click(screen.getByRole("menuitem", { name: "Clone repository" })); return screen.getByTestId("fx-clone-dialog"); }; it("clones into the open folder of the workspace and says where", async () => { vi.mocked(api.lanes.clone).mockResolvedValue({ lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" }, created: true, path: "/w/one/src/repo", } as never); const user = userEvent.setup(); open(); fireEvent.doubleClick(await screen.findByText("src")); await screen.findByText("x.js"); const dialog = await openClone(user); await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git"); expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument(); await user.click(within(dialog).getByTestId("fx-clone-submit")); await waitFor(() => expect(api.lanes.clone).toHaveBeenCalledWith({ url: "git@host:o/repo.git", parent: "/w/one/src", name: undefined, }) ); expect(toast.success).toHaveBeenCalled(); expect(screen.queryByTestId("fx-clone-dialog")).toBeNull(); }); it("asks for a login on ECREDENTIALS and sends it once", async () => { vi.mocked(api.lanes.clone) .mockRejectedValueOnce( Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } }) ) .mockResolvedValueOnce({ lane: { id: 9, title: "r", cwd: "/w/one/r" }, created: true, path: "/w/one/r", } as never); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); const dialog = await openClone(user); await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git"); await user.click(within(dialog).getByTestId("fx-clone-submit")); const login = await within(dialog).findByTestId("fx-clone-login"); await user.type(within(login).getByLabelText("Username"), "ann"); await user.type(within(login).getByLabelText("Password or access token"), "tok"); await user.click(within(dialog).getByTestId("fx-clone-submit")); await waitFor(() => expect(api.lanes.clone).toHaveBeenLastCalledWith( expect.objectContaining({ parent: "/w/one", credentials: { username: "ann", password: "tok", remember: true }, }) ) ); }); it("keeps typing in the dialog away from the explorer's shortcuts", async () => { vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] }); const user = userEvent.setup(); open(); await screen.findByText("a.txt"); fireEvent.click(row("a.txt")); const dialog = await openClone(user); const url = within(dialog).getByLabelText("Repository URL"); await user.type(url, "abc{Backspace}{Delete}"); expect(api.files.remove).not.toHaveBeenCalled(); expect(url).toHaveValue("ab"); }); it("is not offered in an extra folder", async () => { const user = userEvent.setup(); render(); await screen.findByText("a.txt"); await user.click(screen.getByRole("button", { name: "New" })); expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull(); }); });