/** * @file FolderBrowseModal.test.tsx * @description Unit tests for the in-app folder browser laid out like a * native picker: a click selects and Select picks that folder (else the * current one), a double-click opens, Backspace and the breadcrumb go up, * the filter narrows the list, Places open Home and a workspace, the path * field (Ctrl+L) opens a typed path, New folder makes one and selects it, * Show hidden re-lists with dot entries, and a Select is remembered as Recent. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { api } from "../../../lib/api"; import { FolderBrowseModal } from "../FolderBrowseModal"; vi.mock("../../../lib/api", () => ({ api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } }, })); const TREE: Record = { "/home/u": ["proj", "notes"], "/home/u/proj": ["api", "web"], "/home/u/proj/api": [], "/home/u/proj/web": [], "/home/u/notes": [], "/srv/ws": [], }; function listing(p: string, hidden = false) { const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])]; return { path: p, parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/", home: "/home/u", entries: names.map((n, i) => ({ name: n, path: `${p}/${n}`, isGitRepo: n === "api", mtime: 1_700_000_000_000 + i, })), }; } beforeEach(() => { localStorage.clear(); vi.mocked(api.lanes.browse) .mockReset() .mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => { const at = p || "/home/u"; if (!(at in TREE)) throw new Error("path does not exist"); return listing(at, hidden); }); vi.mocked(api.lanes.mkdir).mockReset(); vi.mocked(api.lanes.list) .mockReset() .mockResolvedValue({ lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }], counts: {}, } as never); }); const open = (onSelect = vi.fn(), initialPath?: string) => { render( ); return screen.findByRole("dialog", { name: "Browse for a folder" }); }; const option = (dialog: HTMLElement, name: string) => within(dialog).findByRole("option", { name: new RegExp(name) }); describe("FolderBrowseModal", () => { it("opens an admin in their own home; the Server folder tick opens the host's", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.browse).mockImplementation(async (p, _f, _h, host) => ({ ...listing(p || (host ? "/home/u" : "/srv/ws"), false), home: host ? "/home/u" : "/srv/ws", hostHome: "/home/u", })); const dialog = await open(); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, false) ); const tick = within(dialog).getByTestId("browse-host-folder"); expect(tick).not.toBeChecked(); await user.click(tick); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, true) ); expect(tick).toBeChecked(); }); it("shows no Server folder tick without a host home in the listing", async () => { const dialog = await open(); expect((await within(dialog).findAllByRole("option")).length).toBeGreaterThan(0); expect(within(dialog).queryByTestId("browse-host-folder")).toBeNull(); }); it("click selects, double-click opens, Select picks the selection else the folder", async () => { const onSelect = vi.fn(); const dialog = await open(onSelect); await userEvent.click(await option(dialog, "proj")); expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true"); await userEvent.dblClick(await option(dialog, "proj")); await option(dialog, "web"); await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" })); expect(onSelect).toHaveBeenCalledWith("/home/u/proj"); }); it("Enter opens the selected folder and Backspace goes back up to it", async () => { const dialog = await open(); const list = within(dialog).getByRole("listbox"); await userEvent.click(await option(dialog, "proj")); await userEvent.keyboard("{Enter}"); await option(dialog, "api"); list.focus(); await userEvent.keyboard("{Backspace}"); await waitFor(() => expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute( "aria-selected", "true" ) ); }); it("the breadcrumb, Back and Forward move between folders", async () => { const dialog = await open(vi.fn(), "/home/u/proj/api"); await userEvent.click(within(dialog).getByRole("button", { name: "proj" })); await option(dialog, "web"); await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ })); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false, false) ); await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ })); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false, false) ); // Above the user's own folder (it answered no parent) nothing is a link. await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ })); await option(dialog, "notes"); expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled(); }); it("the filter narrows the list, and Enter on a single match opens it", async () => { const dialog = await open(); await option(dialog, "notes"); await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro"); expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull(); await userEvent.keyboard("{Enter}"); await option(dialog, "web"); }); it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => { const dialog = await open(vi.fn(), "/home/u/proj"); await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ })); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false, false) ); await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ })); await option(dialog, "notes"); await userEvent.keyboard("{Control>}l{/Control}"); const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" }); await userEvent.clear(field); await userEvent.type(field, "/home/u/proj/web{Enter}"); await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false, false) ); }); it("New folder makes one in the current folder and selects it", async () => { vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => { TREE[parent] = [...(TREE[parent] ?? []), name]; return { path: `${parent}/${name}` }; }); const dialog = await open(); await option(dialog, "proj"); await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ })); await userEvent.type( within(dialog).getByRole("textbox", { name: "Folder name" }), "fresh{Enter}" ); expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh"); await waitFor(async () => expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true") ); TREE["/home/u"] = ["proj", "notes"]; }); it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => { const onSelect = vi.fn(); const dialog = await open(onSelect); await option(dialog, "proj"); expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull(); await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ })); await option(dialog, "\\.cfg"); await userEvent.dblClick(await option(dialog, "proj")); await option(dialog, "web"); await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" })); expect(onSelect).toHaveBeenCalledWith("/home/u/proj"); expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([ "/home/u/proj", ]); }); });