mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: implement plugin management UI
This commit is contained in:
1 parent
88d7bc5525
commit
3ed5775ed5
52 files changed
+20072
-41
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { mergePlugins } from "@/plugins/plugin-model";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const [plugin] = mergePlugins(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "",
|
||||
author: "Termix",
|
||||
category: "",
|
||||
versions: [],
|
||||
latestVersion: "1.0.0",
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
updateAvailable: false,
|
||||
addedCapabilities: [],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
describe("PluginConsentPrompt", () => {
|
||||
it("lists notable capabilities and folds the low risk ones", () => {
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "install",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own", "ssh:connect", "ui:surface"],
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.capabilities.ssh:connect.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("plugins.capabilities.kv:own.title")).toBeNull();
|
||||
expect(screen.getByText("plugins.manager.consent.minor")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms and cancels", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "update",
|
||||
version: "1.1.0",
|
||||
capabilities: ["notify:send"],
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.manager.consent.updateExplain"),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("plugins.manager.consent.agreeUpdate"));
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing without a request", () => {
|
||||
const { container } = render(
|
||||
<PluginConsentPrompt
|
||||
request={null}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import { CapabilityRow } from "@/plugins/plugin-bits";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("CapabilityRow", () => {
|
||||
it("resolves capability ids that contain a colon", async () => {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
plugins: {
|
||||
capabilities: {
|
||||
"users:impersonate": {
|
||||
title: "Act as other users",
|
||||
consequence: "It can do anything a user can.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CapabilityRow capability="users:impersonate" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Act as other users")).toBeTruthy();
|
||||
expect(screen.getByText("It can do anything a user can.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
|
||||
import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
matchesQuery,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pluginStatus,
|
||||
sortPlugins,
|
||||
} from "@/plugins/plugin-model";
|
||||
|
||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: null,
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(
|
||||
overrides: Partial<RegistryPluginEntry> = {},
|
||||
): RegistryPluginEntry {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "Containers",
|
||||
author: "Termix",
|
||||
category: "Infrastructure",
|
||||
versions: [
|
||||
{
|
||||
version: "1.1.0",
|
||||
compatible: true,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
publishedAt: "2026-10-02",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
latestVersion: "1.1.0",
|
||||
installed: true,
|
||||
installedVersion: "1.0.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergePlugins", () => {
|
||||
it("joins an installed plugin with its registry entry", () => {
|
||||
const [row] = mergePlugins([summary()], [entry()]);
|
||||
expect(row).toMatchObject({
|
||||
installed: true,
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
capabilities: ["kv:own"],
|
||||
category: "Infrastructure",
|
||||
inRegistry: true,
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists registry plugins that are not installed with the latest capabilities", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[entry({ installed: false, installedVersion: null })],
|
||||
);
|
||||
expect(rows[0]).toMatchObject({
|
||||
installed: false,
|
||||
version: null,
|
||||
status: null,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps installed plugins when the registry is unreachable", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].inRegistry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginStatus", () => {
|
||||
it("maps loader and stored states", () => {
|
||||
expect(pluginStatus(summary({ state: "failed" }))).toBe("failed");
|
||||
expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked");
|
||||
expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe(
|
||||
"disabled",
|
||||
);
|
||||
expect(pluginStatus(summary())).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting and filtering", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "b", name: "Beta", category: "Monitoring" }),
|
||||
entry({
|
||||
id: "a",
|
||||
name: "Alpha",
|
||||
category: "Access",
|
||||
description: "Sign in",
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
compatible: true,
|
||||
capabilities: [],
|
||||
publishedAt: "2026-10-05",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
it("sorts by name, date and category", () => {
|
||||
expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("searches name, description and id", () => {
|
||||
expect(
|
||||
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
|
||||
).toEqual(["a"]);
|
||||
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
|
||||
});
|
||||
|
||||
it("collects categories", () => {
|
||||
expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("orders capabilities worst first", () => {
|
||||
expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([
|
||||
"credentials:read",
|
||||
"hosts:write",
|
||||
"kv:own",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts contributions", () => {
|
||||
expect(
|
||||
describeContributions({
|
||||
tabs: [{}, {}],
|
||||
settings: { admin: [] },
|
||||
auth: { loginMethods: [{}], secondFactors: [] },
|
||||
panels: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: "tabs", count: 2 },
|
||||
{ kind: "auth", count: 1 },
|
||||
{ kind: "settings", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats sizes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(2048)).toBe("2.0 KB");
|
||||
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
||||
});
|
||||
});
|
||||
@@ -63,9 +63,19 @@ describe("RAIL_ITEMS", () => {
|
||||
"connections",
|
||||
"quick-connect",
|
||||
"sync",
|
||||
"plugins",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the Plugins tab only for plugin managers", () => {
|
||||
const plugins = RAIL_ITEMS.find((item) => item.id === "plugins");
|
||||
expect(plugins).toMatchObject({
|
||||
kind: "tab",
|
||||
placement: "footer",
|
||||
permission: "admin.plugins.manage",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes every visible rail item as hideable", () => {
|
||||
expect(hideableRailIds()).toEqual(
|
||||
visibleRailItems().map((item) => item.id),
|
||||
|
||||
Reference in new issue
Block a user