Files
AgentHub/client/src/components/lanes/__tests__/FileExplorer.test.tsx
T
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00

748 lines
30 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, leaves a repo's .git out of a
* folder upload with one notice, 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); opens an HTML file as the page
* instead of downloading it; New → Clone repository starts a background
* clone (`background: true`), keeps a pre-start refusal in the form, and a
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
* @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 CloneJob, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
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,
newUploadId: (() => {
let n = 0;
return () => `upload-id-${String(++n).padStart(8, "0")}`;
})(),
api: {
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneJobs: vi.fn(async () => ({ jobs: [] })),
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(),
uploadStaged: vi.fn(),
uploadBatchStaged: vi.fn(),
dropUpload: vi.fn(async () => null),
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 }],
};
const job: CloneJob = {
id: "j1",
kind: "workspace",
state: "queued",
url: "git@host:o/repo.git",
name: "repo",
parent: "/w/one",
target: "/w/one/repo",
laneId: null,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: { laneId: 9, path: "/w/one/repo" },
createdAt: "",
finishedAt: null,
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
};
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
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("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
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.uploadStaged)
.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.uploadStaged).toHaveBeenLastCalledWith(
1,
"a.txt",
file,
"keep",
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
// The retry names the SAME upload, so the staged bytes are not sent again,
// and the staged upload is dropped once answered.
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
expect(second).toBe(first);
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
});
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.uploadBatchStaged).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.uploadBatchStaged).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.uploadStaged).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.uploadStaged).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.uploadStaged).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
const f = new File(["hi"], rel.split("/").pop()!);
Object.defineProperty(f, "webkitRelativePath", { value: rel });
return f;
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).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.uploadStaged).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("leaves a workspace whose folder was deleted out of the places", async () => {
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
],
counts: {},
} as never);
open();
await screen.findByText("a.txt");
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
await screen.findByText("one", { selector: "aside span" });
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
});
it("drops the last folder's entries when the next one cannot be read", async () => {
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
if (p === "src") throw new Error("ENOENT: no such file or directory");
return root;
});
open();
await screen.findByText("a.txt");
fireEvent.doubleClick(row("src"));
await screen.findByText(/ENOENT/);
expect(screen.queryByText("a.txt")).toBeNull();
});
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 in the background", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } 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,
background: true,
})
);
// Accepted: the dialog steps aside, the app-wide dock takes over.
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
});
it("re-reads the folder and workspaces when a background clone ends", async () => {
open();
await screen.findByText("a.txt");
const lists = vi.mocked(api.files.list).mock.calls.length;
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
});
it("treats an older server's synchronous answer as a finished clone", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
} 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"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() => 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({ job } 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"));
// A refusal before the clone starts stays in the form, with a login row.
const login = await within(dialog).findByTestId("fx-clone-login");
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a 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",
background: true,
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();
});
});