mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
278 lines
8.9 KiB
TypeScript
278 lines
8.9 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { toast } from "sonner";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { SftpTransferTab } from "../../src/frontend/SftpTransferTab";
|
|
|
|
const api = vi.hoisted(() => ({
|
|
confirmBeforeTrash: undefined as boolean | undefined,
|
|
addTransferRecent: vi.fn(),
|
|
browseSSHDirectory: vi.fn(),
|
|
changeSSHPermissions: vi.fn(),
|
|
createSSHFolder: vi.fn(),
|
|
deleteSSHItem: vi.fn(),
|
|
ensureSSHSessionForHost: vi.fn(),
|
|
getSSHHosts: vi.fn(),
|
|
getTransferProgressPercent: vi.fn(() => undefined),
|
|
renameSSHItem: vi.fn(),
|
|
transferToHost: vi.fn(),
|
|
beginTransferProgressMonitoring: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../src/frontend/api/transfer-api", () => ({
|
|
addTransferRecent: api.addTransferRecent,
|
|
getTransferProgressPercent: api.getTransferProgressPercent,
|
|
transferToHost: api.transferToHost,
|
|
}));
|
|
|
|
vi.mock("../../src/frontend/api/hosts", () => ({
|
|
getSSHHosts: api.getSSHHosts,
|
|
}));
|
|
|
|
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
|
|
browseSSHDirectory: api.browseSSHDirectory,
|
|
changeSSHPermissions: api.changeSSHPermissions,
|
|
createSSHFolder: api.createSSHFolder,
|
|
deleteSSHItem: api.deleteSSHItem,
|
|
ensureSSHSessionForHost: api.ensureSSHSessionForHost,
|
|
renameSSHItem: api.renameSSHItem,
|
|
}));
|
|
|
|
vi.mock("../../src/frontend/transferProgressMonitor", () => ({
|
|
beginTransferProgressMonitoring: api.beginTransferProgressMonitoring,
|
|
}));
|
|
|
|
vi.mock("../../src/frontend/transferMetricsFormat", () => ({
|
|
createFormatTransferMetrics: () => () => "",
|
|
}));
|
|
|
|
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
|
|
useSettings: () => ({
|
|
values: { confirmBeforeTrash: api.confirmBeforeTrash },
|
|
}),
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { success: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
api.confirmBeforeTrash = undefined;
|
|
api.getSSHHosts.mockResolvedValue([
|
|
{
|
|
id: 1,
|
|
name: "prod",
|
|
ip: "10.0.0.1",
|
|
connectionType: "ssh",
|
|
pluginSettings: {
|
|
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "backup",
|
|
ip: "10.0.0.2",
|
|
connectionType: "ssh",
|
|
pluginSettings: {
|
|
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
|
},
|
|
},
|
|
]);
|
|
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
|
|
state: "ready",
|
|
sessionId: String(host.id),
|
|
}));
|
|
api.browseSSHDirectory.mockImplementation(
|
|
async (sessionId: string, path: string) => ({
|
|
status: "ok",
|
|
path,
|
|
files: [
|
|
{
|
|
name: `remote-${sessionId}.txt`,
|
|
type: "file",
|
|
size: 24,
|
|
modified: "2026-07-18T10:30:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
api.transferToHost.mockResolvedValue({ transferId: "transfer-1" });
|
|
api.beginTransferProgressMonitoring.mockReturnValue({
|
|
toastId: "toast-1",
|
|
waitForCompletion: Promise.resolve({
|
|
transferId: "transfer-1",
|
|
status: "success",
|
|
phase: "transferring",
|
|
}),
|
|
});
|
|
});
|
|
|
|
async function selectHosts() {
|
|
const selects = await screen.findAllByRole("combobox");
|
|
fireEvent.change(selects[0], { target: { value: "1" } });
|
|
fireEvent.change(selects[1], { target: { value: "2" } });
|
|
await screen.findByText("remote-1.txt");
|
|
await screen.findByText("remote-2.txt");
|
|
}
|
|
|
|
describe("SftpTransferTab", () => {
|
|
it("opens the server login directory when no default path is configured", async () => {
|
|
const hosts = await api.getSSHHosts();
|
|
api.getSSHHosts.mockResolvedValue(
|
|
hosts.map((host: object) => ({ ...host, pluginSettings: {} })),
|
|
);
|
|
api.browseSSHDirectory.mockImplementation(
|
|
async (sessionId: string, path: string) => ({
|
|
status: "ok",
|
|
path: path === "." ? `/srv/home-${sessionId}` : path,
|
|
files: [
|
|
{
|
|
name: `remote-${sessionId}.txt`,
|
|
type: "file",
|
|
path: `/srv/home-${sessionId}/remote-${sessionId}.txt`,
|
|
size: 1,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
await waitFor(() => {
|
|
expect(api.browseSSHDirectory).toHaveBeenCalledWith("1", ".");
|
|
expect(api.browseSSHDirectory).toHaveBeenCalledWith("2", ".");
|
|
});
|
|
expect(await screen.findByDisplayValue("/srv/home-1")).toBeTruthy();
|
|
expect(await screen.findByDisplayValue("/srv/home-2")).toBeTruthy();
|
|
});
|
|
|
|
it("loads file manager-enabled hosts into both host pickers", async () => {
|
|
render(<SftpTransferTab />);
|
|
const selects = await screen.findAllByRole("combobox");
|
|
expect(selects).toHaveLength(2);
|
|
expect(api.getSSHHosts).toHaveBeenCalled();
|
|
});
|
|
|
|
it("copies a source server file to the destination server via the context menu", async () => {
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
|
|
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
|
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
|
|
|
await waitFor(() => {
|
|
// A single file transfer sends the full destination path, filename
|
|
// included, rather than just the directory. See #1304: sending a file on
|
|
// its own used to drop it.
|
|
expect(api.transferToHost).toHaveBeenCalledWith(
|
|
"1",
|
|
["/srv/remote-1.txt"],
|
|
"2",
|
|
"/srv/remote-1.txt",
|
|
false,
|
|
"auto",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("records the destination as a transfer recent after a successful copy", async () => {
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
|
|
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
|
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
|
|
|
await waitFor(() => {
|
|
expect(api.addTransferRecent).toHaveBeenCalledWith(1, 2, "/srv", "/srv");
|
|
});
|
|
});
|
|
|
|
it("blocks a same-host transfer where the destination is inside the source path", async () => {
|
|
render(<SftpTransferTab />);
|
|
const selects = await screen.findAllByRole("combobox");
|
|
fireEvent.change(selects[0], { target: { value: "1" } });
|
|
fireEvent.change(selects[1], { target: { value: "1" } });
|
|
const rows = await screen.findAllByText("remote-1.txt");
|
|
|
|
// Destination pane starts at the same "/srv" listing as the source, so
|
|
// browsing into the selected source file's own path (as if it were a
|
|
// folder) makes the destination nested inside the source selection.
|
|
const destPathInput = screen.getAllByDisplayValue("/srv")[1];
|
|
fireEvent.change(destPathInput, {
|
|
target: { value: "/srv/remote-1.txt" },
|
|
});
|
|
fireEvent.keyDown(destPathInput, { key: "Enter" });
|
|
await waitFor(() =>
|
|
expect(api.browseSSHDirectory).toHaveBeenCalledTimes(3),
|
|
);
|
|
|
|
fireEvent.contextMenu(rows[0]);
|
|
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
|
|
|
await waitFor(() => {
|
|
expect(toast.error).toHaveBeenCalledWith(
|
|
"sftpTransfer.destinationInsideSource",
|
|
);
|
|
});
|
|
expect(api.transferToHost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renames a remote file from the row context menu", async () => {
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
|
|
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
|
await userEvent.click(screen.getByText("sftpTransfer.rename"));
|
|
const nameInput = screen.getByDisplayValue("remote-1.txt");
|
|
await userEvent.clear(nameInput);
|
|
await userEvent.type(nameInput, "renamed.txt");
|
|
await userEvent.click(screen.getByText("sftpTransfer.save"));
|
|
|
|
await waitFor(() => {
|
|
expect(api.renameSSHItem).toHaveBeenCalledWith(
|
|
"1",
|
|
"/srv/remote-1.txt",
|
|
"renamed.txt",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("moves files to trash directly when confirmation is disabled", async () => {
|
|
api.confirmBeforeTrash = false;
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
|
await userEvent.click(screen.getByText("sftpTransfer.delete"));
|
|
await waitFor(() =>
|
|
expect(api.deleteSSHItem).toHaveBeenCalledWith(
|
|
"1",
|
|
"/srv/remote-1.txt",
|
|
false,
|
|
),
|
|
);
|
|
expect(screen.queryByText("sftpTransfer.deleteSelectedItems")).toBeNull();
|
|
});
|
|
|
|
it("deletes a remote file after confirming", async () => {
|
|
render(<SftpTransferTab />);
|
|
await selectHosts();
|
|
|
|
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
|
await userEvent.click(screen.getByText("sftpTransfer.delete"));
|
|
expect(api.deleteSSHItem).not.toHaveBeenCalled();
|
|
const confirmButtons = await screen.findAllByText("sftpTransfer.delete");
|
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(api.deleteSSHItem).toHaveBeenCalledWith(
|
|
"1",
|
|
"/srv/remote-1.txt",
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
});
|