Files
AgentHub/client/src/components/lanes/__tests__/FolderBrowseModal.test.tsx
T
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00

207 lines
8.2 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string, string[]> = {
"/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(
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
);
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",
]);
});
});