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("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), initReactI18next: { type: "3rdParty", init: () => {} }, })); vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ ...(await importOriginal()), 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("loads file manager-enabled hosts into both host pickers", async () => { render(); 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(); 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(); 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(); 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(); 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(); 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(); 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, ); }); }); });