Files
AgentHub/client/src/components/lanes/__tests__/FileExplorer.test.tsx
T
nntrivi2001 26952ae0d9 feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes
- 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.
2026-10-22 14:34:50 +07:00

613 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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();
});
});