mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: { permissions?: string[]; users?: string[] } = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["file-manager.use"],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers.js";
|
||||
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
// transfer_recent as core's SQLite bootstrap created them before 2.9.0.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_file_manager_recent_user ON file_manager_recent (user_id, host_id);
|
||||
|
||||
CREATE TABLE file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_file_manager_pinned_user ON file_manager_pinned (user_id, host_id);
|
||||
|
||||
CREATE TABLE file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_file_manager_shortcuts_user ON file_manager_shortcuts (user_id, host_id);
|
||||
|
||||
CREATE TABLE transfer_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
source_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
dest_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
dest_path TEXT NOT NULL,
|
||||
dest_path_label TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_transfer_recent_user ON transfer_recent (user_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting the four file-manager tables", () => {
|
||||
it("keeps every existing row when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1')",
|
||||
);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO file_manager_recent (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 1, "notes.txt", "/home/user/notes.txt");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO file_manager_pinned (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 1, "logs", "/var/log");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO file_manager_shortcuts (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 1, "Home", "/home/user");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO transfer_recent (user_id, source_host_id, dest_host_id, dest_path, dest_path_label) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 1, 2, "/backups", "Backups");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_file_manager_tables"]);
|
||||
|
||||
for (const legacy of [
|
||||
"file_manager_recent",
|
||||
"file_manager_pinned",
|
||||
"file_manager_shortcuts",
|
||||
"transfer_recent",
|
||||
]) {
|
||||
expect(tableExists(legacy)).toBe(false);
|
||||
}
|
||||
for (const adopted of [
|
||||
"p_file_manager_recent",
|
||||
"p_file_manager_pinned",
|
||||
"p_file_manager_shortcuts",
|
||||
"p_file_manager_transfer_recent",
|
||||
]) {
|
||||
expect(tableExists(adopted)).toBe(true);
|
||||
}
|
||||
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT name, path FROM p_file_manager_recent").all(),
|
||||
).toEqual([{ name: "notes.txt", path: "/home/user/notes.txt" }]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT name, path FROM p_file_manager_pinned").all(),
|
||||
).toEqual([{ name: "logs", path: "/var/log" }]);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT name, path FROM p_file_manager_shortcuts")
|
||||
.all(),
|
||||
).toEqual([{ name: "Home", path: "/home/user" }]);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT dest_path, dest_path_label FROM p_file_manager_transfer_recent",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ dest_path: "/backups", dest_path_label: "Backups" }]);
|
||||
|
||||
expect(indexOn("p_file_manager_recent")).toEqual([
|
||||
"idx_file_manager_recent_user",
|
||||
]);
|
||||
expect(indexOn("p_file_manager_pinned")).toEqual([
|
||||
"idx_file_manager_pinned_user",
|
||||
]);
|
||||
expect(indexOn("p_file_manager_shortcuts")).toEqual([
|
||||
"idx_file_manager_shortcuts_user",
|
||||
]);
|
||||
expect(indexOn("p_file_manager_transfer_recent")).toEqual([
|
||||
"idx_transfer_recent_user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("file_manager_recent")).toBe(false);
|
||||
expect(tableExists("p_file_manager_recent")).toBe(true);
|
||||
expect(tableExists("p_file_manager_pinned")).toBe(true);
|
||||
expect(tableExists("p_file_manager_shortcuts")).toBe(true);
|
||||
expect(tableExists("p_file_manager_transfer_recent")).toBe(true);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_file_manager_recent").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("recent/pinned/shortcuts", () => {
|
||||
it("round-trips a recent file", async () => {
|
||||
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
|
||||
const add = await server.request("POST", "/recent", {
|
||||
body: { hostId: 1, path: "/etc/hosts", name: "hosts" },
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
|
||||
const list = await server.request("GET", "/recent?hostId=1");
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body).toEqual([
|
||||
expect.objectContaining({ name: "hosts", path: "/etc/hosts" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to pin the same path twice", async () => {
|
||||
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
|
||||
const first = await server.request("POST", "/pinned", {
|
||||
body: { hostId: 1, path: "/var/log", name: "logs" },
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await server.request("POST", "/pinned", {
|
||||
body: { hostId: 1, path: "/var/log", name: "logs" },
|
||||
});
|
||||
expect(second.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("file-manager.use", () => {
|
||||
it("refuses every route without it", async () => {
|
||||
await server.close();
|
||||
server = await startServer({ permissions: [] });
|
||||
|
||||
for (const [method, path] of [
|
||||
["GET", "/status?sessionId=x"],
|
||||
["POST", "/connect"],
|
||||
["GET", "/recent?hostId=1"],
|
||||
["POST", "/recent"],
|
||||
["GET", "/pinned?hostId=1"],
|
||||
["POST", "/pinned"],
|
||||
["GET", "/shortcuts?hostId=1"],
|
||||
["POST", "/shortcuts"],
|
||||
["GET", "/activeTransfers"],
|
||||
] as const) {
|
||||
const res = await server.request(method, path, {
|
||||
body: { hostId: 1, path: "/tmp", name: "tmp" },
|
||||
});
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
expect(res.body.required).toBe("file-manager.use");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { LocalFilePane } from "../../src/frontend/LocalFilePane";
|
||||
import {
|
||||
LOCAL_FILES_DRAG_MIME,
|
||||
REMOTE_FILES_DRAG_MIME,
|
||||
} from "../../src/frontend/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
// jsdom has no layout, so render every row instead of a virtual window.
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 34,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: index * 34,
|
||||
size: 34,
|
||||
})),
|
||||
measureElement: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const sonnerToast = vi.hoisted(() => {
|
||||
const fn = vi.fn() as ReturnType<typeof vi.fn> & Record<string, unknown>;
|
||||
fn.success = vi.fn();
|
||||
fn.error = vi.fn();
|
||||
fn.info = vi.fn();
|
||||
fn.warning = vi.fn();
|
||||
fn.loading = vi.fn();
|
||||
fn.dismiss = vi.fn();
|
||||
return fn;
|
||||
});
|
||||
vi.mock("sonner", () => ({ toast: sonnerToast }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.count !== undefined ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
const HOME = "/Users/max";
|
||||
|
||||
const entries: LocalFileEntry[] = [
|
||||
{
|
||||
name: "projects",
|
||||
path: `${HOME}/projects`,
|
||||
type: "directory",
|
||||
size: 0,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
path: `${HOME}/notes.txt`,
|
||||
type: "file",
|
||||
size: 1234,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: ".zshrc",
|
||||
path: `${HOME}/.zshrc`,
|
||||
type: "file",
|
||||
size: 42,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
function installElectronApi() {
|
||||
const list = vi.fn(async (dirPath: string) => ({
|
||||
success: true as const,
|
||||
path: dirPath,
|
||||
parent:
|
||||
dirPath === "/" ? null : dirPath.split("/").slice(0, -1).join("/") || "/",
|
||||
entries: dirPath === HOME ? entries : [],
|
||||
}));
|
||||
const api = {
|
||||
isElectron: true,
|
||||
localFs: {
|
||||
home: vi.fn(async () => ({
|
||||
success: true as const,
|
||||
home: HOME,
|
||||
separator: "/",
|
||||
platform: "darwin",
|
||||
})),
|
||||
list,
|
||||
mkdir: vi.fn(),
|
||||
createFile: vi.fn(async () => ({ success: true as const, path: "" })),
|
||||
rename: vi.fn(async () => ({ success: true as const, path: "" })),
|
||||
trash: vi.fn(async () => ({
|
||||
success: true as const,
|
||||
trashed: 1,
|
||||
failed: [],
|
||||
})),
|
||||
ensureDir: vi.fn(),
|
||||
walk: vi.fn(),
|
||||
reveal: vi.fn(),
|
||||
open: vi.fn(),
|
||||
},
|
||||
localTransfer: {
|
||||
upload: vi.fn(),
|
||||
download: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
onProgress: vi.fn(() => () => {}),
|
||||
},
|
||||
};
|
||||
(window as unknown as { electronAPI: unknown }).electronAPI = api;
|
||||
(window as unknown as { IS_ELECTRON: boolean }).IS_ELECTRON = true;
|
||||
return api;
|
||||
}
|
||||
|
||||
function makeDataTransfer(
|
||||
types: string[],
|
||||
data: Record<string, string> = {},
|
||||
): DataTransfer {
|
||||
const store: Record<string, string> = { ...data };
|
||||
return {
|
||||
types,
|
||||
dropEffect: "none",
|
||||
effectAllowed: "all",
|
||||
files: [] as unknown as FileList,
|
||||
items: [] as unknown as DataTransferItemList,
|
||||
getData: (type: string) => store[type] ?? "",
|
||||
setData: (type: string, value: string) => {
|
||||
store[type] = value;
|
||||
if (!types.includes(type)) types.push(type);
|
||||
},
|
||||
clearData: vi.fn(),
|
||||
setDragImage: vi.fn(),
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
describe("LocalFilePane", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
installElectronApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it("lists the home directory on mount and remembers the last path", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("fileManager.localItemCount:3"),
|
||||
).toBeInTheDocument();
|
||||
expect(localStorage.getItem("termix:file-manager:local-pane:path")).toBe(
|
||||
HOME,
|
||||
);
|
||||
});
|
||||
|
||||
it("navigates into a folder on double click", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(`[data-local-path="${HOME}/projects"]`);
|
||||
expect(row).not.toBeNull();
|
||||
fireEvent.click(row!, { detail: 2 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.list).toHaveBeenCalledWith(`${HOME}/projects`),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts remote-grid drops and hands paths to the parent", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const pane = screen.getByTestId("local-file-pane");
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log", "/srv/app/dir"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
|
||||
fireEvent.dragEnter(pane, { dataTransfer });
|
||||
expect(
|
||||
screen.getByText("fileManager.dropToDownloadHere"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.drop(pane, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log", "/srv/app/dir"],
|
||||
HOME,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("fileManager.dropToDownloadHere"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops onto a folder row download into that folder", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const folderRow = document.querySelector(
|
||||
`[data-local-path="${HOME}/projects"]`,
|
||||
)!;
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
fireEvent.dragOver(folderRow, { dataTransfer });
|
||||
fireEvent.drop(folderRow, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log"],
|
||||
`${HOME}/projects`,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides dotfiles when hidden files are toggled off", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle("fileManager.localHideHidden"));
|
||||
expect(
|
||||
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText("fileManager.localItemCount:2"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a context menu for a row with entry actions", async () => {
|
||||
const onUploadToRemote = vi.fn();
|
||||
render(
|
||||
<LocalFilePane
|
||||
onRemoteItemsDropped={vi.fn()}
|
||||
onUploadToRemote={onUploadToRemote}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
|
||||
const menu = screen.getByTestId("local-file-context-menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.localOpen")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.rename")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("fileManager.localMoveToTrash"),
|
||||
).toBeInTheDocument();
|
||||
// Background-only actions are not offered for a row.
|
||||
expect(screen.queryByText("fileManager.newFolder")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.localUploadToRemote"));
|
||||
expect(onUploadToRemote).toHaveBeenCalledWith([`${HOME}/notes.txt`]);
|
||||
expect(
|
||||
screen.queryByTestId("local-file-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("acts on the whole selection when right-clicking a selected row", async () => {
|
||||
const onUploadToRemote = vi.fn();
|
||||
render(
|
||||
<LocalFilePane
|
||||
onRemoteItemsDropped={vi.fn()}
|
||||
onUploadToRemote={onUploadToRemote}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const projects = document.querySelector(
|
||||
`[data-local-path="${HOME}/projects"]`,
|
||||
)!;
|
||||
const notes = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.click(projects);
|
||||
fireEvent.click(notes, { metaKey: true });
|
||||
fireEvent.contextMenu(notes, { clientX: 40, clientY: 50 });
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.localUploadToRemoteMany:2"));
|
||||
expect(onUploadToRemote).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([`${HOME}/projects`, `${HOME}/notes.txt`]),
|
||||
);
|
||||
expect(onUploadToRemote.mock.calls[0][0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("offers folder-level actions on the background and creates a file", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const body = screen
|
||||
.getByText("fileManager.localItemCount:3")
|
||||
.closest('[data-testid="local-file-pane"]')!
|
||||
.querySelector(".thin-scrollbar")!;
|
||||
fireEvent.contextMenu(body, { clientX: 100, clientY: 200 });
|
||||
|
||||
expect(screen.getByText("fileManager.newFolder")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.refresh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("fileManager.rename")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.newFile"));
|
||||
const input = screen.getByPlaceholderText("fileManager.newFile");
|
||||
fireEvent.change(input, { target: { value: "todo.md" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.createFile).toHaveBeenCalledWith(HOME, "todo.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renames inline from the context menu", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(screen.getByText("fileManager.rename"));
|
||||
|
||||
const input = screen.getByDisplayValue("notes.txt");
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.rename).toHaveBeenCalledWith(
|
||||
`${HOME}/notes.txt`,
|
||||
"renamed.txt",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for confirmation before moving to the trash", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(screen.getByText("fileManager.localMoveToTrash"));
|
||||
|
||||
// Nothing is trashed until the toast's confirm action is clicked.
|
||||
expect(api.localFs.trash).not.toHaveBeenCalled();
|
||||
expect(sonnerToast).toHaveBeenCalledWith(
|
||||
"fileManager.localTrashConfirmSingle",
|
||||
expect.objectContaining({ action: expect.anything() }),
|
||||
);
|
||||
const call = sonnerToast.mock.calls.find(
|
||||
(c) => c[0] === "fileManager.localTrashConfirmSingle",
|
||||
)!;
|
||||
(call[1] as { action: { onClick: () => void } }).action.onClick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.trash).toHaveBeenCalledWith([`${HOME}/notes.txt`]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a pinned .. entry that goes up on double click and accepts drops", async () => {
|
||||
const api = installElectronApi();
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const parentRow = document.querySelector("[data-parent-entry]");
|
||||
expect(parentRow).not.toBeNull();
|
||||
// It is the first row, above the real entries.
|
||||
const firstRow = screen
|
||||
.getByTestId("local-file-pane")
|
||||
.querySelector(".thin-scrollbar > *");
|
||||
expect(firstRow).toBe(parentRow);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
fireEvent.dragOver(parentRow!, { dataTransfer });
|
||||
fireEvent.drop(parentRow!, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log"],
|
||||
"/Users",
|
||||
);
|
||||
|
||||
fireEvent.click(parentRow!, { detail: 2 });
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.list).toHaveBeenCalledWith("/Users"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the header context menu hide and show columns", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const kindHeader = screen.getByText("fileManager.kind");
|
||||
const kindCellsBefore = document.querySelectorAll(
|
||||
`[data-local-path="${HOME}/notes.txt"] > span`,
|
||||
).length;
|
||||
|
||||
fireEvent.contextMenu(kindHeader, { clientX: 300, clientY: 60 });
|
||||
const menu = screen.getByTestId("column-visibility-menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("menuitemcheckbox", { name: "fileManager.kind" }),
|
||||
);
|
||||
|
||||
// The sortable header button is gone; only the menu's checkbox remains.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "fileManager.kind" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelectorAll(`[data-local-path="${HOME}/notes.txt"] > span`)
|
||||
.length,
|
||||
).toBe(kindCellsBefore - 1);
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("termix:file-manager:columns:local:hidden")!,
|
||||
),
|
||||
).toEqual(["kind"]);
|
||||
});
|
||||
|
||||
it("ignores OS file drags (those belong to the remote grid)", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const pane = screen.getByTestId("local-file-pane");
|
||||
const dataTransfer = makeDataTransfer(["Files"]);
|
||||
fireEvent.dragEnter(pane, { dataTransfer });
|
||||
expect(
|
||||
screen.queryByText("fileManager.dropToDownloadHere"),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.drop(pane, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts a typed payload on drags that start in the pane", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(`[data-local-path="${HOME}/notes.txt"]`);
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
const dataTransfer = makeDataTransfer([]);
|
||||
fireEvent.dragStart(row!, { dataTransfer });
|
||||
expect(dataTransfer.types).toContain(LOCAL_FILES_DRAG_MIME);
|
||||
expect(JSON.parse(dataTransfer.getData("text/plain"))).toEqual({
|
||||
type: "local_files",
|
||||
paths: [`${HOME}/notes.txt`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
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(() => ({
|
||||
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("@/main-axios", () => ({
|
||||
addTransferRecent: api.addTransferRecent,
|
||||
getSSHHosts: api.getSSHHosts,
|
||||
getTransferProgressPercent: api.getTransferProgressPercent,
|
||||
transferToHost: api.transferToHost,
|
||||
}));
|
||||
|
||||
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("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getSSHHosts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "prod",
|
||||
ip: "10.0.0.1",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
defaultPath: "/srv",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "backup",
|
||||
ip: "10.0.0.2",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
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(<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("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"));
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("activates and registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const tabs = declared(contributes.tabs);
|
||||
const panels = [...declared(contributes.panels), ...tabs];
|
||||
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
|
||||
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
|
||||
});
|
||||
|
||||
it("registers the files and sftp tabs", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.tabs().sort()).toEqual(["files", "sftp"]);
|
||||
});
|
||||
|
||||
it("registers the sftp rail item pointing at the sftp tab", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const railItems = rendered.registered.railItems();
|
||||
expect(railItems.map((item) => item.id)).toContain("sftp");
|
||||
});
|
||||
|
||||
it("registers a files host action opening the files tab", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const hostActions = rendered.registered.hostActions();
|
||||
expect(hostActions).toContainEqual(
|
||||
expect.objectContaining({ id: "files", tabType: "files" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the files host editor section", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.hostEditorSections()).toContain("files");
|
||||
});
|
||||
|
||||
it("registers files.openHost and files.openEditor actions", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"files.openEditor",
|
||||
"files.openHost",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
expect(app.registered.hostActions()).toEqual([]);
|
||||
expect(app.registered.hostEditorSections()).toEqual([]);
|
||||
expect(app.registered.dashboardCards()).toEqual([]);
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fileManagerApiMock = vi.hoisted(() => ({
|
||||
post: vi.fn(async () => ({ data: { complete: false } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: { defaults: {} },
|
||||
fileManagerApi: { defaults: {} },
|
||||
getFileManagerApiForSession: () => fileManagerApiMock,
|
||||
handleApiError: (error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
setSessionOrigin: vi.fn(),
|
||||
clearSessionOrigin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/connection-origin", () => ({
|
||||
resolveConnectionOrigin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/frontend-logger", () => ({
|
||||
fileLogger: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/file-list-request-cache", () => ({
|
||||
getCachedFileList: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/file-content-request-cache", () => ({
|
||||
getCachedFileContent: vi.fn(),
|
||||
invalidateCachedFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadSSHFile } from "../../../src/frontend/api/ssh-file-operations-api";
|
||||
|
||||
describe("chunked SSH file uploads", () => {
|
||||
beforeEach(() => {
|
||||
fileManagerApiMock.post.mockClear();
|
||||
});
|
||||
|
||||
it("sends raw chunks with the byte offset expected by the server", async () => {
|
||||
const fileSize = 1.5 * 1024 * 1024 * 1024 + 1;
|
||||
const file = {
|
||||
size: fileSize,
|
||||
slice: vi.fn(() => new Blob(["chunk"])),
|
||||
} as unknown as File;
|
||||
|
||||
await uploadSSHFile("session-1", "/uploads", "archive.img", file);
|
||||
|
||||
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
|
||||
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/ssh/uploadFileChunk",
|
||||
expect.any(Blob),
|
||||
{
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
path: "/uploads",
|
||||
fileName: "archive.img",
|
||||
offset: 0,
|
||||
totalSize: fileSize,
|
||||
},
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
timeout: 0,
|
||||
},
|
||||
);
|
||||
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
|
||||
"/ssh/uploadFileChunk",
|
||||
expect.any(Blob),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { TransferProgressBar } from "../../../src/frontend/components/TransferProgressBar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TransferProgressBar", () => {
|
||||
it("exposes determinate progress and drives the fill with a transform", () => {
|
||||
const { container } = render(
|
||||
<TransferProgressBar value={42} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
const progress = screen.getByRole("progressbar", {
|
||||
name: "Uploading archive",
|
||||
});
|
||||
expect(progress.getAttribute("aria-valuenow")).toBe("42");
|
||||
expect(progress.getAttribute("aria-busy")).toBe("false");
|
||||
expect(
|
||||
container
|
||||
.querySelector<HTMLElement>(".transfer-progress-fill")
|
||||
?.style.getPropertyValue("--transfer-progress"),
|
||||
).toBe("0.42");
|
||||
});
|
||||
|
||||
it("clamps invalid percentages before exposing them", () => {
|
||||
const { rerender } = render(
|
||||
<TransferProgressBar value={140} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"100",
|
||||
);
|
||||
|
||||
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"0",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks unknown and stalled progress without inventing a percentage", () => {
|
||||
const { container } = render(
|
||||
<TransferProgressBar label="Reconnecting" stalled />,
|
||||
);
|
||||
|
||||
const progress = screen.getByRole("progressbar");
|
||||
expect(progress.hasAttribute("aria-valuenow")).toBe(false);
|
||||
expect(progress.getAttribute("aria-busy")).toBe("true");
|
||||
expect(progress.getAttribute("data-stalled")).toBe("true");
|
||||
expect(
|
||||
container
|
||||
.querySelector(".transfer-progress-fill")
|
||||
?.classList.contains("transfer-progress-indeterminate"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
container
|
||||
.querySelector(".transfer-progress-fill")
|
||||
?.classList.contains("transfer-progress-stalled"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatFileSize,
|
||||
runWithConcurrency,
|
||||
} from "../../src/frontend/file-manager-utils.js";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("returns a dash for undefined or null", () => {
|
||||
expect(formatFileSize(undefined)).toBe("-");
|
||||
expect(formatFileSize(null as unknown as number)).toBe("-");
|
||||
});
|
||||
|
||||
it("returns 0 B for zero", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("formats bytes without decimals", () => {
|
||||
expect(formatFileSize(512)).toBe("512 B");
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("formats kilobytes with one decimal under 10", () => {
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB");
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("rounds to whole numbers at or above 10 units", () => {
|
||||
expect(formatFileSize(10 * 1024)).toBe("10 KB");
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
|
||||
});
|
||||
|
||||
it("scales up to larger units", () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
|
||||
});
|
||||
|
||||
it("limits concurrent work", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const completed: number[] = [];
|
||||
|
||||
await runWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
completed.push(item);
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
expect(maxActive).toBe(2);
|
||||
expect(completed.sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFilePreviewKind } from "../../src/frontend/file-preview-preload";
|
||||
|
||||
describe("resolveFilePreviewKind", () => {
|
||||
it.each([
|
||||
["photo.webp", "image"],
|
||||
["movie.webm", "video"],
|
||||
["voice.m4a", "audio"],
|
||||
["README.md", "markdown"],
|
||||
["manual.pdf", "pdf"],
|
||||
["notes.txt", "text"],
|
||||
["app.tsx", "code"],
|
||||
["Dockerfile", "code"],
|
||||
["LICENSE", "unknown"],
|
||||
] as const)("classifies %s as %s", (filename, expected) => {
|
||||
expect(resolveFilePreviewKind(filename)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useDragAndDrop } from "../../../src/frontend/hooks/useDragAndDrop.js";
|
||||
|
||||
function makeEntry(name: string, isDirectory: boolean) {
|
||||
return { name, isDirectory, isFile: !isDirectory } as FileSystemEntry;
|
||||
}
|
||||
|
||||
function makeDropEvent(entries: FileSystemEntry[], files: File[] = []) {
|
||||
const items = entries.map((entry) => ({
|
||||
webkitGetAsEntry: () => entry,
|
||||
}));
|
||||
|
||||
const dataTransfer = {
|
||||
items,
|
||||
files: Object.assign(files, { item: (i: number) => files[i] }),
|
||||
};
|
||||
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer,
|
||||
} as unknown as React.DragEvent;
|
||||
}
|
||||
|
||||
describe("useDragAndDrop", () => {
|
||||
it("hands directory entries to onItemsDropped", () => {
|
||||
const onItemsDropped = vi.fn();
|
||||
const onFilesDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onItemsDropped }),
|
||||
);
|
||||
|
||||
const dir = makeEntry("myfolder", true);
|
||||
act(() => result.current.dragHandlers.onDrop(makeDropEvent([dir])));
|
||||
|
||||
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
|
||||
expect(onFilesDropped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads entries before state updates clear dataTransfer", () => {
|
||||
const onItemsDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped: vi.fn(), onItemsDropped }),
|
||||
);
|
||||
|
||||
const dir = makeEntry("myfolder", true);
|
||||
const event = makeDropEvent([dir]);
|
||||
|
||||
// Mimic the browser neutering dataTransfer once the handler unwinds.
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDrop(event);
|
||||
(event.dataTransfer as unknown as { items: unknown[] }).items = [];
|
||||
});
|
||||
|
||||
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
|
||||
});
|
||||
|
||||
it("falls back to plain file upload when no directory is dropped", () => {
|
||||
const onFilesDropped = vi.fn();
|
||||
const onItemsDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onItemsDropped }),
|
||||
);
|
||||
|
||||
const file = new File(["hi"], "a.txt");
|
||||
act(() =>
|
||||
result.current.dragHandlers.onDrop(
|
||||
makeDropEvent([makeEntry("a.txt", false)], [file]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onItemsDropped).not.toHaveBeenCalled();
|
||||
expect(onFilesDropped).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects files over the size limit", () => {
|
||||
const onError = vi.fn();
|
||||
const onFilesDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onError, maxFileSize: 1 }),
|
||||
);
|
||||
|
||||
const big = new File(["x"], "big.bin");
|
||||
Object.defineProperty(big, "size", { value: 5 * 1024 * 1024 });
|
||||
|
||||
act(() => result.current.dragHandlers.onDrop(makeDropEvent([], [big])));
|
||||
|
||||
expect(onFilesDropped).not.toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFileSelection } from "../../../src/frontend/hooks/useFileSelection.js";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
};
|
||||
|
||||
const f = (name: string): FileItem => ({
|
||||
name,
|
||||
type: "file",
|
||||
path: `/dir/${name}`,
|
||||
});
|
||||
|
||||
describe("useFileSelection", () => {
|
||||
it("single-selects, replacing prior selection", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
act(() => result.current.selectFile(f("b")));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
expect(result.current.getSelectedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-selects and toggles off on repeat", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
act(() => result.current.selectFile(f("b"), true));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("reports isSelected by path", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
expect(result.current.isSelected(f("z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("selects a contiguous range regardless of direction", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b"), f("c"), f("d")];
|
||||
act(() => result.current.selectRange(files, files[3], files[1]));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects all and clears", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b")];
|
||||
act(() => result.current.selectAll(files));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.clearSelection());
|
||||
expect(result.current.getSelectedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleSelection adds then removes", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { usePaneWidth } from "../../../src/frontend/hooks/usePaneWidth";
|
||||
|
||||
const KEY = "test:pane-width";
|
||||
|
||||
function mouse(type: string, clientX: number) {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
}
|
||||
|
||||
const opts = { storageKey: KEY, defaultWidth: 224, minWidth: 160 };
|
||||
|
||||
describe("usePaneWidth", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts at the default and restores a stored width", () => {
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
|
||||
localStorage.setItem(KEY, "300");
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(300);
|
||||
localStorage.setItem(KEY, "10"); // below min → ignored
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
|
||||
});
|
||||
|
||||
it("follows the drag, clamps to the minimum, and persists on release", () => {
|
||||
const { result } = renderHook(() => usePaneWidth(opts));
|
||||
act(() => {
|
||||
result.current.startResize({
|
||||
clientX: 400,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.isResizing).toBe(true);
|
||||
act(() => mouse("mousemove", 460));
|
||||
expect(result.current.width).toBe(284);
|
||||
act(() => mouse("mousemove", 100));
|
||||
expect(result.current.width).toBe(160);
|
||||
act(() => mouse("mouseup", 100));
|
||||
expect(result.current.isResizing).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBe("160");
|
||||
});
|
||||
|
||||
it("resets to the default", () => {
|
||||
localStorage.setItem(KEY, "300");
|
||||
const { result } = renderHook(() => usePaneWidth(opts));
|
||||
act(() => result.current.resetWidth());
|
||||
expect(result.current.width).toBe(224);
|
||||
expect(localStorage.getItem(KEY)).toBe("224");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useResizableColumns } from "../../../src/frontend/hooks/useResizableColumns";
|
||||
|
||||
const columns = [
|
||||
{ key: "modified", labelKey: "m", defaultWidth: 120, minWidth: 70 },
|
||||
{ key: "size", labelKey: "s", defaultWidth: 80, minWidth: 56, maxWidth: 200 },
|
||||
];
|
||||
const STORAGE_KEY = "test:columns";
|
||||
|
||||
function mouse(type: string, clientX: number) {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
}
|
||||
|
||||
describe("useResizableColumns", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts from defaults and builds a grid template with a flexible name column", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.gridTemplateColumns).toBe(
|
||||
"minmax(140px, 1fr) 120px 80px",
|
||||
);
|
||||
});
|
||||
|
||||
it("widens a column when its left-edge handle is dragged left and persists", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
const handle = result.current.getHandleProps("size");
|
||||
act(() => {
|
||||
handle.onMouseDown({
|
||||
clientX: 500,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.resizingKey).toBe("size");
|
||||
act(() => mouse("mousemove", 460));
|
||||
expect(result.current.widths.size).toBe(120);
|
||||
act(() => mouse("mouseup", 460));
|
||||
expect(result.current.resizingKey).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
|
||||
modified: 120,
|
||||
size: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps to min/max and restores stored widths", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ modified: 10, size: 9999, junk: "x" }),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.widths).toEqual({ modified: 70, size: 200 });
|
||||
});
|
||||
|
||||
it("hides and shows columns, dropping them from the template", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
act(() => result.current.toggleColumn("size"));
|
||||
expect(result.current.isVisible("size")).toBe(false);
|
||||
expect(result.current.gridTemplateColumns).toBe("minmax(140px, 1fr) 120px");
|
||||
expect(result.current.visibleColumns.map((c) => c.key)).toEqual([
|
||||
"modified",
|
||||
]);
|
||||
expect(JSON.parse(localStorage.getItem(`${STORAGE_KEY}:hidden`)!)).toEqual([
|
||||
"size",
|
||||
]);
|
||||
|
||||
// A fresh mount restores the hidden set; toggling again shows it.
|
||||
const remount = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(remount.result.current.isVisible("size")).toBe(false);
|
||||
act(() => remount.result.current.toggleColumn("size"));
|
||||
expect(remount.result.current.gridTemplateColumns).toBe(
|
||||
"minmax(140px, 1fr) 120px 80px",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets a column to its default on double click", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ modified: 150 }));
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.widths.modified).toBe(150);
|
||||
act(() => {
|
||||
result.current.getHandleProps("modified").onDoubleClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.widths.modified).toBe(120);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).modified).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCachedFileContent,
|
||||
invalidateCachedFileContent,
|
||||
shouldPrefetchFileContent,
|
||||
} from "../../../src/frontend/lib/file-content-request-cache";
|
||||
|
||||
describe("file content request cache", () => {
|
||||
it("coalesces reads and reloads after invalidation", async () => {
|
||||
const loader = vi.fn(async () => ({ content: "hello", path: "/note" }));
|
||||
|
||||
await Promise.all([
|
||||
getCachedFileContent("session", "/note", loader),
|
||||
getCachedFileContent("session", "/note", loader),
|
||||
]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
|
||||
invalidateCachedFileContent("session", "/note");
|
||||
await getCachedFileContent("session", "/note", loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("only prefetches known small files on an unconstrained network", () => {
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "note", path: "/note", type: "file", size: 512 * 1024 },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "large", path: "/large", type: "file", size: 512 * 1024 + 1 },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "unknown", path: "/unknown", type: "file" },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "note", path: "/note", type: "file", size: 100 },
|
||||
0,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LOCAL_FILES_DRAG_MIME,
|
||||
REMOTE_FILES_DRAG_MIME,
|
||||
UnsafeLocalNameError,
|
||||
assertSafeLocalComponent,
|
||||
beginRemoteFilesDrag,
|
||||
buildLocalDestination,
|
||||
clampTransferConcurrency,
|
||||
describeLocalKind,
|
||||
getTransferConcurrency,
|
||||
runWithConcurrency,
|
||||
setTransferConcurrency,
|
||||
DEFAULT_TRANSFER_CONCURRENCY,
|
||||
TRANSFER_CONCURRENCY_STORAGE_KEY,
|
||||
formatLocalModified,
|
||||
isLocalFilesDrag,
|
||||
isRemoteFilesDrag,
|
||||
joinLocalPath,
|
||||
joinRemotePath,
|
||||
parseInternalFilesDragPayload,
|
||||
parseLocalFilesDragPayload,
|
||||
planRemoteDirectories,
|
||||
remoteBaseName,
|
||||
remoteDirForRelativePath,
|
||||
serializeLocalFilesDragPayload,
|
||||
sortLocalEntries,
|
||||
} from "../../src/frontend/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
describe("drag payloads", () => {
|
||||
it("round-trips local file payloads", () => {
|
||||
const raw = serializeLocalFilesDragPayload(["/Users/max/a.txt", "/tmp/b"]);
|
||||
expect(parseLocalFilesDragPayload(raw)).toEqual([
|
||||
"/Users/max/a.txt",
|
||||
"/tmp/b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects foreign or malformed payloads", () => {
|
||||
expect(parseLocalFilesDragPayload(null)).toBeNull();
|
||||
expect(parseLocalFilesDragPayload("not json")).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "internal_files", files: ["/x"] }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: [] }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: [1, "", "/ok"] }),
|
||||
),
|
||||
).toEqual(["/ok"]);
|
||||
});
|
||||
|
||||
it("parses the remote grid's internal payload", () => {
|
||||
expect(
|
||||
parseInternalFilesDragPayload(
|
||||
JSON.stringify({ type: "internal_files", files: ["/srv/a", "/srv/b"] }),
|
||||
),
|
||||
).toEqual(["/srv/a", "/srv/b"]);
|
||||
expect(
|
||||
parseInternalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: ["/x"] }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("recognises drag origins from dataTransfer types", () => {
|
||||
expect(
|
||||
isLocalFilesDrag({ types: [LOCAL_FILES_DRAG_MIME, "text/plain"] }),
|
||||
).toBe(true);
|
||||
expect(isLocalFilesDrag({ types: ["Files"] })).toBe(false);
|
||||
expect(isRemoteFilesDrag({ types: [REMOTE_FILES_DRAG_MIME] })).toBe(true);
|
||||
expect(isRemoteFilesDrag({ types: ["text/plain"] })).toBe(false);
|
||||
expect(isRemoteFilesDrag(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("joins remote paths without duplicate slashes", () => {
|
||||
expect(joinRemotePath("/", "a", "b")).toBe("/a/b");
|
||||
expect(joinRemotePath("/home/ubuntu/", "/proj/", "x.txt")).toBe(
|
||||
"/home/ubuntu/proj/x.txt",
|
||||
);
|
||||
expect(joinRemotePath("/home", "")).toBe("/home");
|
||||
});
|
||||
|
||||
it("joins local paths with the platform separator", () => {
|
||||
expect(joinLocalPath("/Users/max", "a.txt", "/")).toBe("/Users/max/a.txt");
|
||||
expect(joinLocalPath("/Users/max/", "a.txt", "/")).toBe("/Users/max/a.txt");
|
||||
expect(joinLocalPath("/", "a.txt", "/")).toBe("/a.txt");
|
||||
expect(joinLocalPath("C:\\Users\\max", "a.txt", "\\")).toBe(
|
||||
"C:\\Users\\max\\a.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives base names", () => {
|
||||
expect(remoteBaseName("/srv/app/file.log")).toBe("file.log");
|
||||
expect(remoteBaseName("/srv/app/")).toBe("app");
|
||||
expect(remoteBaseName("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("maps relative paths to their remote directory", () => {
|
||||
expect(remoteDirForRelativePath("/dst", "file.txt")).toBe("/dst");
|
||||
expect(remoteDirForRelativePath("/dst", "proj/src/main.rs")).toBe(
|
||||
"/dst/proj/src",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download destination safety", () => {
|
||||
const WIN = "\\";
|
||||
const POSIX = "/";
|
||||
|
||||
it("keeps ordinary nested folders under the selected directory (Windows)", () => {
|
||||
expect(
|
||||
buildLocalDestination(
|
||||
"C:\\Downloads\\selected",
|
||||
"docs/2026/report.pdf",
|
||||
WIN,
|
||||
),
|
||||
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
|
||||
expect(
|
||||
buildLocalDestination("C:\\Downloads\\selected\\", "a.txt", WIN),
|
||||
).toBe("C:\\Downloads\\selected\\a.txt");
|
||||
});
|
||||
|
||||
it("rejects backslash traversal in a POSIX file name on Windows", () => {
|
||||
// The reviewer's reproduction: "..\\outside.txt" is a legal POSIX name.
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", "..\\outside.txt", WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
expect(() =>
|
||||
buildLocalDestination(
|
||||
"C:\\Downloads\\selected",
|
||||
"sub/..\\..\\x.txt",
|
||||
WIN,
|
||||
),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", "dir\\file.txt", WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
});
|
||||
|
||||
it("rejects absolute and drive-qualified names on Windows", () => {
|
||||
for (const name of [
|
||||
"C:\\Windows\\evil.dll",
|
||||
"C:evil.txt",
|
||||
"D:",
|
||||
"\\\\server\\share\\x",
|
||||
"\\absolute.txt",
|
||||
]) {
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", name, WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects names Windows cannot store: reserved devices, trailing dots/spaces, control chars", () => {
|
||||
for (const name of [
|
||||
"CON",
|
||||
"nul.txt",
|
||||
"COM1",
|
||||
"report.",
|
||||
"report ",
|
||||
"a\u0007b",
|
||||
"q?.txt",
|
||||
"a|b",
|
||||
]) {
|
||||
expect(() => assertSafeLocalComponent(name, WIN)).toThrow(
|
||||
UnsafeLocalNameError,
|
||||
);
|
||||
}
|
||||
expect(assertSafeLocalComponent("console.log", WIN)).toBe("console.log");
|
||||
expect(assertSafeLocalComponent("nulled.txt", WIN)).toBe("nulled.txt");
|
||||
});
|
||||
|
||||
it("rejects traversal and separators on every platform, but allows POSIX-legal backslashes on POSIX", () => {
|
||||
for (const rel of ["..", "../x", "a/../../x", "./x", "a\0b", ""]) {
|
||||
expect(() => buildLocalDestination("/home/max/dl", rel, POSIX)).toThrow(
|
||||
UnsafeLocalNameError,
|
||||
);
|
||||
}
|
||||
// On macOS/Linux a backslash is just a character in a file name and the
|
||||
// result is still inside the selected folder.
|
||||
expect(
|
||||
buildLocalDestination("/home/max/dl", "..\\outside.txt", POSIX),
|
||||
).toBe("/home/max/dl/..\\outside.txt");
|
||||
expect(buildLocalDestination("/", "etc/hosts", POSIX)).toBe("/etc/hosts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRemoteDirectories", () => {
|
||||
it("lists every ancestor once, shallowest first", () => {
|
||||
const dirs = planRemoteDirectories(
|
||||
["proj/src/main.rs", "proj/README.md", "proj/src/lib/mod.rs", "top.txt"],
|
||||
["proj/empty", "other/nested/leaf"],
|
||||
);
|
||||
expect(dirs).toEqual([
|
||||
"other",
|
||||
"proj",
|
||||
"other/nested",
|
||||
"proj/empty",
|
||||
"proj/src",
|
||||
"other/nested/leaf",
|
||||
"proj/src/lib",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for flat file drops", () => {
|
||||
expect(planRemoteDirectories(["a.txt", "b.txt"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote rows dragged out of the grid", () => {
|
||||
it("allows both a move (within the grid) and a copy (download onto the local pane)", () => {
|
||||
const store: Record<string, string> = {};
|
||||
const dataTransfer: Pick<DataTransfer, "effectAllowed" | "setData"> = {
|
||||
effectAllowed: "uninitialized",
|
||||
setData: (type: string, value: string) => {
|
||||
store[type] = value;
|
||||
},
|
||||
};
|
||||
beginRemoteFilesDrag(dataTransfer, ["/srv/a.txt", "/srv/dir"]);
|
||||
// Chromium silently drops nothing when dropEffect ("copy" on the local
|
||||
// pane) is not part of effectAllowed, so "move" alone breaks downloads.
|
||||
expect(dataTransfer.effectAllowed).toBe("copyMove");
|
||||
expect(isRemoteFilesDrag({ types: Object.keys(store) })).toBe(true);
|
||||
expect(parseInternalFilesDragPayload(store["text/plain"])).toEqual([
|
||||
"/srv/a.txt",
|
||||
"/srv/dir",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local entry presentation", () => {
|
||||
const entry = (
|
||||
name: string,
|
||||
type: LocalFileEntry["type"],
|
||||
size = 0,
|
||||
modifiedTimestamp = 0,
|
||||
): LocalFileEntry => ({
|
||||
name,
|
||||
path: `/x/${name}`,
|
||||
type,
|
||||
size,
|
||||
modifiedTimestamp,
|
||||
hidden: name.startsWith("."),
|
||||
});
|
||||
|
||||
it("formats modified times like the remote grid (ls -l style)", () => {
|
||||
const now = new Date(2026, 8, 12, 10, 0); // Sep 12 2026
|
||||
expect(
|
||||
formatLocalModified(new Date(2026, 8, 11, 16, 25).getTime(), now),
|
||||
).toBe("Sep 11 16:25");
|
||||
// Single-digit days are space-padded, minutes zero-padded.
|
||||
expect(formatLocalModified(new Date(2026, 7, 7, 9, 5).getTime(), now)).toBe(
|
||||
"Aug 7 09:05",
|
||||
);
|
||||
// Older than six months: year instead of time, two spaces like ls.
|
||||
expect(
|
||||
formatLocalModified(new Date(2025, 11, 24, 18, 30).getTime(), now),
|
||||
).toBe("Dec 24 2025");
|
||||
expect(formatLocalModified(undefined, now)).toBe("--");
|
||||
expect(formatLocalModified(Number.NaN, now)).toBe("--");
|
||||
});
|
||||
|
||||
it("describes kinds", () => {
|
||||
expect(describeLocalKind(entry("docs", "directory"))).toBe("folder");
|
||||
expect(describeLocalKind(entry("archive.tar.GZ", "file"))).toBe("gz");
|
||||
expect(describeLocalKind(entry(".env", "file"))).toBe("file");
|
||||
expect(describeLocalKind(entry("Makefile", "file"))).toBe("file");
|
||||
expect(describeLocalKind(entry("ln", "link"))).toBe("link");
|
||||
});
|
||||
|
||||
it("sorts folders first, then by the chosen field", () => {
|
||||
const entries = [
|
||||
entry("b.txt", "file", 20, 2),
|
||||
entry("zeta", "directory", 0, 1),
|
||||
entry("a.txt", "file", 10, 3),
|
||||
entry("alpha", "directory", 0, 4),
|
||||
];
|
||||
expect(sortLocalEntries(entries, "name", "asc").map((e) => e.name)).toEqual(
|
||||
["alpha", "zeta", "a.txt", "b.txt"],
|
||||
);
|
||||
expect(
|
||||
sortLocalEntries(entries, "size", "desc").map((e) => e.name),
|
||||
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
|
||||
expect(
|
||||
sortLocalEntries(entries, "modified", "asc").map((e) => e.name),
|
||||
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallel transfers", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("clamps and persists the concurrency preference", () => {
|
||||
expect(getTransferConcurrency()).toBe(DEFAULT_TRANSFER_CONCURRENCY);
|
||||
expect(clampTransferConcurrency(0)).toBe(1);
|
||||
expect(clampTransferConcurrency(99)).toBe(8);
|
||||
expect(clampTransferConcurrency("3.7")).toBe(3);
|
||||
expect(clampTransferConcurrency("nope")).toBe(DEFAULT_TRANSFER_CONCURRENCY);
|
||||
expect(setTransferConcurrency(6)).toBe(6);
|
||||
expect(localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY)).toBe("6");
|
||||
expect(getTransferConcurrency()).toBe(6);
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
it("never runs more than `limit` workers at once and processes everything", async () => {
|
||||
const gates = Array.from({ length: 6 }, deferred);
|
||||
const started: number[] = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const run = runWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (i) => {
|
||||
started.push(i);
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await gates[i].promise;
|
||||
active -= 1;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(started).toEqual([0, 1, 2]); // exactly `limit` dispatched
|
||||
gates[1].resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(started).toEqual([0, 1, 2, 3]); // a free lane picks up the next item
|
||||
for (const g of gates) g.resolve();
|
||||
await run;
|
||||
expect(started).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(peak).toBe(3);
|
||||
});
|
||||
|
||||
it("stops dispatching once asked to, letting in-flight items finish", async () => {
|
||||
let stop = false;
|
||||
const done: number[] = [];
|
||||
await runWithConcurrency(
|
||||
[1, 2, 3, 4, 5],
|
||||
2,
|
||||
async (i) => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
done.push(i);
|
||||
if (i === 2) stop = true;
|
||||
},
|
||||
() => stop,
|
||||
);
|
||||
expect(done.length).toBeLessThan(5);
|
||||
expect(done).toContain(1);
|
||||
expect(done).toContain(2);
|
||||
});
|
||||
|
||||
it("does not reject the batch when a single item fails (the worker reports it)", async () => {
|
||||
const failed: number[] = [];
|
||||
await runWithConcurrency([1, 2, 3], 2, async (i) => {
|
||||
try {
|
||||
if (i === 2) throw new Error("boom");
|
||||
} catch {
|
||||
failed.push(i);
|
||||
}
|
||||
});
|
||||
expect(failed).toEqual([2]);
|
||||
});
|
||||
|
||||
it("handles an empty list and a limit larger than the list", async () => {
|
||||
let calls = 0;
|
||||
await runWithConcurrency([], 4, async () => {
|
||||
calls += 1;
|
||||
});
|
||||
await runWithConcurrency([1], 8, async () => {
|
||||
calls += 1;
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileItem } from "@/types/index";
|
||||
import {
|
||||
addOptimisticItem,
|
||||
childPath,
|
||||
removePaths,
|
||||
renameOptimisticItem,
|
||||
restoreItems,
|
||||
} from "../../src/frontend/optimistic-file-list";
|
||||
|
||||
const files: FileItem[] = [
|
||||
{ name: "alpha.txt", path: "/work/alpha.txt", type: "file", size: 10 },
|
||||
{ name: "docs", path: "/work/docs", type: "directory" },
|
||||
];
|
||||
|
||||
describe("optimistic file list updates", () => {
|
||||
it("builds child paths without duplicate separators", () => {
|
||||
expect(childPath("/", "file.txt")).toBe("/file.txt");
|
||||
expect(childPath("/work/", "file.txt")).toBe("/work/file.txt");
|
||||
});
|
||||
|
||||
it("adds and rolls back a provisional item", () => {
|
||||
const added = addOptimisticItem(files, "/work", "new.txt", "file", 0);
|
||||
expect(added.at(-1)).toMatchObject({
|
||||
name: "new.txt",
|
||||
path: "/work/new.txt",
|
||||
type: "file",
|
||||
size: 0,
|
||||
});
|
||||
expect(removePaths(added, new Set(["/work/new.txt"]))).toEqual(files);
|
||||
});
|
||||
|
||||
it("renames immediately and can reverse the same item", () => {
|
||||
const renamed = renameOptimisticItem(files, "/work/alpha.txt", "beta.txt");
|
||||
expect(renamed[0]).toMatchObject({
|
||||
name: "beta.txt",
|
||||
path: "/work/beta.txt",
|
||||
size: 10,
|
||||
});
|
||||
expect(
|
||||
renameOptimisticItem(renamed, "/work/beta.txt", "alpha.txt"),
|
||||
).toEqual(files);
|
||||
});
|
||||
|
||||
it("restores only failed deletions without duplicating survivors", () => {
|
||||
const remaining = removePaths(files, new Set(["/work/docs"]));
|
||||
expect(restoreItems(remaining, [files[1], files[0]])).toEqual(files);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSameHostTransferConflict,
|
||||
joinRemotePath,
|
||||
normalizeRemoteDir,
|
||||
} from "../../src/frontend/sftp-transfer-utils";
|
||||
|
||||
describe("sftp transfer utilities", () => {
|
||||
it("normalizes and joins remote paths", () => {
|
||||
expect(normalizeRemoteDir(" /home/user// ")).toBe("/home/user");
|
||||
expect(joinRemotePath("/home/user/", "docs/readme.md")).toBe(
|
||||
"/home/user/docs/readme.md",
|
||||
);
|
||||
expect(joinRemotePath("/", "tmp/file.txt")).toBe("/tmp/file.txt");
|
||||
expect(joinRemotePath("home/user", "logs")).toBe("/home/user/logs");
|
||||
});
|
||||
|
||||
it("detects same-host destination conflicts", () => {
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app")).toBe(true);
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app/logs")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/releases")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const success = vi.fn();
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => success(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios.ts", () => ({}));
|
||||
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
|
||||
markTransferNotified: vi.fn(),
|
||||
}));
|
||||
|
||||
import { showTransferCompletionToast } from "../../src/frontend/transferProgressMonitor.tsx";
|
||||
|
||||
describe("transfer completion toast", () => {
|
||||
beforeEach(() => {
|
||||
success.mockReset();
|
||||
});
|
||||
|
||||
it("clears the infinite duration inherited from the progress toast", () => {
|
||||
showTransferCompletionToast(
|
||||
{
|
||||
transferId: "transfer-1",
|
||||
status: "success",
|
||||
phase: "verifying",
|
||||
},
|
||||
((key: string) => key) as never,
|
||||
"progress-toast",
|
||||
);
|
||||
|
||||
expect(success).toHaveBeenCalledWith("transfer.transferSuccess", {
|
||||
id: "progress-toast",
|
||||
description: undefined,
|
||||
className: "!pr-10 !pl-4 transfer-progress-toast",
|
||||
duration: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user