26952ae0d9
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users/<id>/image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat.
613 lines
24 KiB
TypeScript
613 lines
24 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
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(<FileExplorer open laneId={1} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||
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<typeof userEvent.setup>) => {
|
||
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(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||
await screen.findByText("a.txt");
|
||
await user.click(screen.getByRole("button", { name: "New" }));
|
||
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||
});
|
||
});
|