mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -1,186 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { WebEndpoint, WebUiConfig } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HostEditorWebUiSection } from "@/sidebar/HostEditorWebUiSection";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "external",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
config: WebUiConfig = { endpoints: [] },
|
||||
{
|
||||
enableWebUi = true,
|
||||
tunnelAvailable = true,
|
||||
}: { enableWebUi?: boolean; tunnelAvailable?: boolean } = {},
|
||||
) {
|
||||
const setField = vi.fn();
|
||||
render(
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={enableWebUi}
|
||||
webUiConfig={config}
|
||||
tunnelAvailable={tunnelAvailable}
|
||||
setField={setField}
|
||||
/>,
|
||||
);
|
||||
return setField;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isElectron.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostEditorWebUiSection", () => {
|
||||
it("hides the endpoint list until the feature is enabled", () => {
|
||||
setup({ endpoints: [endpoint()] }, { enableWebUi: false });
|
||||
expect(screen.queryByLabelText("hosts.webUiLabel")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiAddEndpoint"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an endpoint with a non-colliding label", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint({ label: "hosts.webUiNewEndpointLabel" })],
|
||||
});
|
||||
fireEvent.click(screen.getByText("hosts.webUiAddEndpoint"));
|
||||
|
||||
const [, value] = setField.mock.calls[0];
|
||||
const added = (value as WebUiConfig).endpoints[1];
|
||||
// Labels identify an endpoint in the sidebar picker, so a fresh row must
|
||||
// not silently duplicate one already there.
|
||||
expect(added.label).toBe("hosts.webUiNewEndpointLabel 2");
|
||||
expect(added.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stops adding at the cap", () => {
|
||||
const many = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `E${i}` }),
|
||||
);
|
||||
setup({ endpoints: many });
|
||||
expect(screen.getByText("hosts.webUiAddEndpoint")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses to commit a port the normalizer would drop", () => {
|
||||
// Number("") is 0, which the normalizer rejects -- committing it would
|
||||
// silently discard the endpoint on save with no error shown.
|
||||
const setField = setup({ endpoints: [endpoint()] });
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
expect(setField).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "9000" },
|
||||
});
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].port).toBe(
|
||||
9000,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the bind-host fields only for a tunnel endpoint", () => {
|
||||
setup({ endpoints: [endpoint({ access: "direct" })] });
|
||||
expect(screen.queryByLabelText("hosts.bindHost")).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
expect(screen.getByLabelText("hosts.bindHost")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("hosts.webUiLocalPort")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns that a non-loopback bind is unauthenticated exposure", () => {
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(screen.getByText("hosts.webUiBindHostExposed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not warn about exposure for a loopback bind", () => {
|
||||
setup({
|
||||
endpoints: [endpoint({ access: "tunnel", bindHost: "127.0.0.1" })],
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiBindHostExposed"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time that a loopback bind is unreachable from a browser", () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
// Said while configuring, not only when the tab fails to load.
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostUnreachable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time about the session-cookie collision", () => {
|
||||
// The page host in jsdom is "localhost", which HAS a loopback alias, so
|
||||
// force a host that does not to reach the second refusal.
|
||||
isElectron.mockReturnValue(false);
|
||||
const realLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: "termix.example.com" }),
|
||||
});
|
||||
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostSharesSessionCookie"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains why tunnelling is unavailable rather than leaving a dead option", () => {
|
||||
// A disabled control with no stated reason reads as the dropdown being
|
||||
// broken.
|
||||
setup({ endpoints: [endpoint()] }, { tunnelAvailable: false });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiAccessTunnelUnavailable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a row the normalizer would drop", () => {
|
||||
setup({ endpoints: [endpoint({ label: " " })] });
|
||||
expect(
|
||||
screen.getByText(/hosts.webUiErrorLabelRequired/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes an endpoint", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint(), endpoint({ id: "e2", label: "NAS" })],
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("hosts.webUiRemoveEndpoint")[0]);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].id).toBe(
|
||||
"e2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SSH_GROUP_TABS, makeHostSshSubTabs } from "@/sidebar/HostManagerTabs";
|
||||
|
||||
describe("the Web UI editor tab", () => {
|
||||
it("is an SSH sub-tab, beside Docker and Tunnels", () => {
|
||||
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
||||
expect(ids).toContain("web-ui");
|
||||
// Placed after docker, before proxmox -- the grouping the user approved.
|
||||
expect(ids.indexOf("web-ui")).toBe(ids.indexOf("docker") + 1);
|
||||
});
|
||||
|
||||
it("is in the SSH group, so it follows SSH availability", () => {
|
||||
// Deliberate asymmetry with the sidebar, which gates on enableWebUi alone
|
||||
// because a direct endpoint needs no SSH. A host with SSH off cannot
|
||||
// configure endpoints; that is accepted, not an oversight.
|
||||
expect(SSH_GROUP_TABS.has("web-ui")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
getRegisteredHostEditorTab,
|
||||
getHostEditorSection,
|
||||
hostEditorSectionList,
|
||||
isSshGroupTab,
|
||||
makeHostSshSubTabs,
|
||||
registerHostEditorTab,
|
||||
registeredHostEditorTabList,
|
||||
unregisterHostEditorTab,
|
||||
makeHostTabs,
|
||||
registerHostEditorSection,
|
||||
resetHostEditorSections,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
|
||||
const FAKE_TAB_ID = "__test_plugin_host_editor_tab__";
|
||||
@@ -14,46 +16,73 @@ function FakeTabComponent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("registerHostEditorTab seam", () => {
|
||||
afterEach(() => {
|
||||
unregisterHostEditorTab(FAKE_TAB_ID);
|
||||
});
|
||||
afterEach(() => resetHostEditorSections());
|
||||
|
||||
describe("host editor sections", () => {
|
||||
it("is not registered by default", () => {
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
|
||||
expect(registeredHostEditorTabList()).toEqual([]);
|
||||
expect(getHostEditorSection(FAKE_TAB_ID)).toBeUndefined();
|
||||
expect(hostEditorSectionList()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers and unregisters a plugin host editor tab", () => {
|
||||
registerHostEditorTab({
|
||||
it("registers and disposes a section", () => {
|
||||
const dispose = registerHostEditorSection({
|
||||
id: FAKE_TAB_ID,
|
||||
group: "ssh",
|
||||
labelKey: "nav.fakePluginItem",
|
||||
icon: <Puzzle />,
|
||||
icon: Puzzle,
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
expect(getHostEditorSection(FAKE_TAB_ID)?.component).toBe(FakeTabComponent);
|
||||
dispose();
|
||||
expect(getHostEditorSection(FAKE_TAB_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)?.component).toBe(
|
||||
FakeTabComponent,
|
||||
);
|
||||
expect(registeredHostEditorTabList().map((t) => t.id)).toContain(
|
||||
it("puts an SSH-group section in the second strip, by order", () => {
|
||||
registerHostEditorSection({
|
||||
id: FAKE_TAB_ID,
|
||||
group: "ssh",
|
||||
labelKey: "nav.fakePluginItem",
|
||||
order: 1000,
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
||||
expect(ids.at(-1)).toBe(FAKE_TAB_ID);
|
||||
// What made registered SSH-group tabs unreachable before: the editor
|
||||
// only recognised a fixed list of SSH-group ids.
|
||||
expect(isSshGroupTab(FAKE_TAB_ID)).toBe(true);
|
||||
expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain(
|
||||
FAKE_TAB_ID,
|
||||
);
|
||||
|
||||
unregisterHostEditorTab(FAKE_TAB_ID);
|
||||
|
||||
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends a registered tab after the built-in SSH sub-tabs", () => {
|
||||
registerHostEditorTab({
|
||||
it("shows a top-level section only for the protocols it asks for", () => {
|
||||
registerHostEditorSection({
|
||||
id: FAKE_TAB_ID,
|
||||
group: "top",
|
||||
labelKey: "nav.fakePluginItem",
|
||||
icon: <Puzzle />,
|
||||
order: 20,
|
||||
visible: (protocols) => !!protocols.enableFake,
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
const tabs = (protocols: Record<string, boolean>) =>
|
||||
makeHostTabs((key) => key, protocols).map((tab) => tab.id);
|
||||
expect(tabs({})).toEqual(["general", "ssh"]);
|
||||
expect(tabs({ enableFake: true })).toEqual(["general", "ssh", FAKE_TAB_ID]);
|
||||
expect(isSshGroupTab(FAKE_TAB_ID)).toBe(false);
|
||||
});
|
||||
|
||||
const ids: string[] = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
|
||||
expect(ids).toContain(FAKE_TAB_ID);
|
||||
expect(ids.indexOf(FAKE_TAB_ID)).toBe(ids.length - 1);
|
||||
it("treats a section whose visible throws as hidden", () => {
|
||||
registerHostEditorSection({
|
||||
id: FAKE_TAB_ID,
|
||||
group: "top",
|
||||
labelKey: "nav.fakePluginItem",
|
||||
visible: () => {
|
||||
throw new Error("broken plugin");
|
||||
},
|
||||
component: FakeTabComponent,
|
||||
});
|
||||
expect(makeHostTabs((key) => key, {}).map((tab) => tab.id)).not.toContain(
|
||||
FAKE_TAB_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Validates the plugin extension seam introduced for a future plugin loader:
|
||||
* registerRailItem/unregisterRailItem in rail-items.ts, and
|
||||
* registerTabComponent/unregisterTabComponent in tabUtils.tsx.
|
||||
*
|
||||
* This does NOT test a real feature. There is no plugin loader yet -- this
|
||||
* registers a fake rail item and a fake tab component, confirms they render
|
||||
* through the normal AppRail/tab-content paths, then unregisters them. If
|
||||
* this test ever needs deleting because the seam changed shape, that's fine;
|
||||
* it exists to prove the seam works, not to protect a shipped feature.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
registerRailItem,
|
||||
unregisterRailItem,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import {
|
||||
registerTabComponent,
|
||||
unregisterTabComponent,
|
||||
renderTabContent,
|
||||
} from "@/shell/tabUtils";
|
||||
import { AppRail } from "@/sidebar/AppRail";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-ai-availability", () => ({
|
||||
useAiAvailability: () => ({ userEnabled: false }),
|
||||
}));
|
||||
|
||||
const FAKE_TAB_ID = "__test_plugin_tab__";
|
||||
|
||||
function FakePluginTabComponent({ tab }: { tab: Tab }) {
|
||||
return <div data-testid="fake-plugin-tab">plugin tab for {tab.id}</div>;
|
||||
}
|
||||
|
||||
describe("plugin extension seam", () => {
|
||||
afterEach(() => {
|
||||
unregisterRailItem(FAKE_TAB_ID);
|
||||
unregisterTabComponent(FAKE_TAB_ID);
|
||||
});
|
||||
|
||||
it("merges a runtime-registered rail item into visibleRailItems", () => {
|
||||
expect(visibleRailItems().map((i) => i.id)).not.toContain(FAKE_TAB_ID);
|
||||
|
||||
registerRailItem({
|
||||
id: FAKE_TAB_ID,
|
||||
icon: Puzzle,
|
||||
labelKey: "nav.fakePluginItem",
|
||||
kind: "tab",
|
||||
});
|
||||
|
||||
expect(visibleRailItems().map((i) => i.id)).toContain(FAKE_TAB_ID);
|
||||
|
||||
unregisterRailItem(FAKE_TAB_ID);
|
||||
expect(visibleRailItems().map((i) => i.id)).not.toContain(FAKE_TAB_ID);
|
||||
});
|
||||
|
||||
it("renders a registered rail item as a clickable button in AppRail", async () => {
|
||||
registerRailItem({
|
||||
id: FAKE_TAB_ID,
|
||||
icon: Puzzle,
|
||||
labelKey: "nav.fakePluginItem",
|
||||
kind: "tab",
|
||||
});
|
||||
|
||||
const onOpenTab = vi.fn();
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
splitMode="none"
|
||||
username="test"
|
||||
isAdmin={false}
|
||||
onRailClick={vi.fn()}
|
||||
onOpenTab={onOpenTab}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText("nav.fakePluginItem"));
|
||||
screen.getByText("nav.fakePluginItem").closest("button")?.click();
|
||||
expect(onOpenTab).toHaveBeenCalledWith(FAKE_TAB_ID);
|
||||
});
|
||||
|
||||
it("opens a registered plugin tab id through the tab-component registry", async () => {
|
||||
const loader = vi.fn().mockResolvedValue({
|
||||
default: FakePluginTabComponent,
|
||||
});
|
||||
registerTabComponent(FAKE_TAB_ID, loader);
|
||||
|
||||
const tab: Tab = {
|
||||
id: "tab-1",
|
||||
instanceId: "instance-1",
|
||||
type: FAKE_TAB_ID,
|
||||
label: "Fake plugin tab",
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
|
||||
const content = renderTabContent(tab);
|
||||
render(<>{content}</>);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("fake-plugin-tab")).toBeTruthy(),
|
||||
);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
|
||||
unregisterTabComponent(FAKE_TAB_ID);
|
||||
});
|
||||
|
||||
it("renders nothing for an unregistered plugin tab id", () => {
|
||||
const tab: Tab = {
|
||||
id: "tab-2",
|
||||
instanceId: "instance-2",
|
||||
type: "__totally_unknown_tab_type__",
|
||||
label: "Unknown tab",
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(renderTabContent(tab)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
isQuickConnectHost,
|
||||
quickConnectGuacHost,
|
||||
quickConnectHostToPayload,
|
||||
} from "../../sidebar/quick-connect-host";
|
||||
|
||||
@@ -87,14 +86,6 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
rdpPassword: "pw",
|
||||
domain: "CORP",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
ip: "10.0.0.2",
|
||||
connectionType: "rdp",
|
||||
rdpPort: 3390,
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "pw",
|
||||
domain: "CORP",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a VNC host with the password on the VNC fields", () => {
|
||||
@@ -111,10 +102,5 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
vncPort: 5901,
|
||||
vncPassword: "vncpw",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
connectionType: "vnc",
|
||||
vncPort: 5901,
|
||||
vncPassword: "vncpw",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
HIDEABLE_RAIL_IDS,
|
||||
PROMOTABLE_IDS,
|
||||
hideableRailIds,
|
||||
promotableIds,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
rightDockableIds,
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/src/frontend/workspaceUtils";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import { isCapturableTabType } from "@/shell/shell-layout";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
afterEach(() => resetRegisteredRailItems());
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
@@ -56,21 +60,17 @@ describe("RAIL_ITEMS", () => {
|
||||
"sftp",
|
||||
"snippets",
|
||||
"macros",
|
||||
"fleets",
|
||||
"tailscale",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"split-screen",
|
||||
"workspaces",
|
||||
"local-terminal",
|
||||
"network_graph",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes every rail item as hideable", () => {
|
||||
expect(HIDEABLE_RAIL_IDS).toEqual(RAIL_ITEMS.map((item) => item.id));
|
||||
it("exposes every visible rail item as hideable", () => {
|
||||
expect(hideableRailIds()).toEqual(
|
||||
visibleRailItems().map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks exactly the four mobile primary slots", () => {
|
||||
@@ -89,17 +89,15 @@ describe("RAIL_ITEMS", () => {
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives PROMOTABLE_IDS from the promotable flag", () => {
|
||||
it("derives promotableIds from the promotable flag", () => {
|
||||
// The header button and the hint both gate on this list, so a drift here
|
||||
// silently hides the feature for that panel.
|
||||
expect(PROMOTABLE_IDS).toEqual(
|
||||
expect(promotableIds()).toEqual(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id),
|
||||
@@ -112,36 +110,88 @@ describe("RAIL_ITEMS", () => {
|
||||
for (const item of [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]) {
|
||||
if (!item.promotable) continue;
|
||||
expect(
|
||||
WORKSPACE_CAPTURABLE_TYPES,
|
||||
isCapturableTabType(item.id),
|
||||
`${item.id} is promotable but not workspace-capturable`,
|
||||
).toContain(item.id as TabType);
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the mounted-but-hidden panels out of the right dock", () => {
|
||||
// Hosts, credentials and fleets stay mounted while hidden and share editing
|
||||
// state, so a second live instance in the right dock would fight the first.
|
||||
for (const id of ["hosts", "credentials", "fleets"]) {
|
||||
for (const id of ["hosts", "credentials"]) {
|
||||
expect(
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
rightDockableIds(),
|
||||
`${id} must not be right-dockable`,
|
||||
).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("only offers reference panels in the right dock", () => {
|
||||
expect(RIGHT_DOCKABLE_IDS).toEqual([
|
||||
expect(rightDockableIds()).toEqual([
|
||||
"connections",
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registered rail items", () => {
|
||||
const item = (id: string, extra: object = {}) => ({
|
||||
id,
|
||||
icon: Boxes,
|
||||
labelKey: `nav.${id}`,
|
||||
pluginId: "p",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("places items after their anchor, keeping their own order", () => {
|
||||
registerRailItem(item("second", { after: "macros", order: 2 }));
|
||||
registerRailItem(item("first", { after: "macros", order: 1 }));
|
||||
const ids = visibleRailItems().map((entry) => entry.id);
|
||||
const at = ids.indexOf("macros");
|
||||
expect(ids.slice(at + 1, at + 3)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("goes to the end when its anchor does not exist", () => {
|
||||
registerRailItem(item("orphan", { after: "nowhere" }));
|
||||
expect(visibleRailItems().at(-1)?.id).toBe("orphan");
|
||||
});
|
||||
|
||||
it("feeds its flags into the promotable, dock and hideable lists", () => {
|
||||
registerRailItem(item("docked", { promotable: true, rightDockable: true }));
|
||||
registerRailItem(item("pinned", { hideable: false }));
|
||||
expect(promotableIds()).toContain("docked");
|
||||
expect(rightDockableIds()).toContain("docked");
|
||||
expect(hideableRailIds()).toContain("docked");
|
||||
expect(hideableRailIds()).not.toContain("pinned");
|
||||
});
|
||||
|
||||
it("drops a hidden item everywhere without unregistering it", () => {
|
||||
registerRailItem(item("off", { hidden: true, promotable: true }));
|
||||
expect(visibleRailItems().map((entry) => entry.id)).not.toContain("off");
|
||||
expect(hideableRailIds()).not.toContain("off");
|
||||
expect(promotableIds()).not.toContain("off");
|
||||
});
|
||||
|
||||
it("labels a registered item by its own key", () => {
|
||||
registerRailItem(item("labelled"));
|
||||
expect(railItemLabel("labelled", (key) => `t:${key}`)).toBe(
|
||||
"t:nav.labelled",
|
||||
);
|
||||
});
|
||||
|
||||
it("disposes only the entry it registered", () => {
|
||||
const dispose = registerRailItem(item("same", { order: 1 }));
|
||||
registerRailItem(item("same", { order: 2 }));
|
||||
dispose();
|
||||
expect(visibleRailItems().map((entry) => entry.id)).toContain("same");
|
||||
});
|
||||
});
|
||||
|
||||
describe("railItemLabel", () => {
|
||||
it("translates known destinations", () => {
|
||||
expect(railItemLabel("hosts", () => "Hosts")).toBe("Hosts");
|
||||
|
||||
@@ -141,9 +141,7 @@ describe("role permission groups", () => {
|
||||
it("shows a plugin permission's title beside its raw id", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(
|
||||
screen.getByText("plugins.automations.permissions.run.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("automations:permissions.run.title")).toBeTruthy();
|
||||
expect(screen.getByText("automations.run")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { registerHostAction } from "@/sidebar/host-contributions";
|
||||
import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
|
||||
|
||||
const { markTabSurfaceUsedMock, preloadTabSurfaceMock } = vi.hoisted(() => ({
|
||||
@@ -154,12 +155,26 @@ describe("HostItem density parity", () => {
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes RDP, VNC, and Telnet quick-launch buttons in %s density",
|
||||
"exposes plugin connect actions as quick-launch buttons in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("hosts.connectRdp")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectVnc")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectTelnet")).toBeTruthy();
|
||||
const Icon = (() => null) as never;
|
||||
const disposers = ["desk", "screen"].map((id) =>
|
||||
registerHostAction({
|
||||
id,
|
||||
titleKey: `fixture.${id}`,
|
||||
icon: Icon,
|
||||
kind: "connect",
|
||||
tabType: id,
|
||||
when: () => true,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("fixture.desk")).toBeTruthy();
|
||||
expect(screen.getByTitle("fixture.screen")).toBeTruthy();
|
||||
} finally {
|
||||
disposers.forEach((dispose) => dispose());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function host(overrides: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "10.0.0.5",
|
||||
name: "nas",
|
||||
enableSsh: false,
|
||||
enableWebUi: false,
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
const webActions = (h: Host) =>
|
||||
getSshActions(h).filter((action) => action.type === "web-endpoint");
|
||||
|
||||
describe("the sidebar Web UI entry", () => {
|
||||
it("is absent when the feature is off, even with endpoints configured", () => {
|
||||
expect(
|
||||
webActions(
|
||||
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is absent when enabled but no endpoints exist", () => {
|
||||
expect(
|
||||
webActions(host({ enableWebUi: true, webUiConfig: { endpoints: [] } })),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appears without SSH, unlike every other entry", () => {
|
||||
// A direct endpoint needs no SSH at all; SSH matters only per-endpoint,
|
||||
// for tunnel access, which the open route enforces.
|
||||
const actions = getSshActions(
|
||||
host({
|
||||
enableSsh: false,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
}),
|
||||
);
|
||||
expect(actions.filter((a) => a.type === "web-endpoint")).toHaveLength(1);
|
||||
expect(actions.filter((a) => a.type === "terminal")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is ONE entry carrying the label and id when there is a single endpoint", () => {
|
||||
const [action] = webActions(
|
||||
host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
|
||||
}),
|
||||
);
|
||||
expect(action.label).toBe("Proxmox");
|
||||
expect(action.endpointId).toBe("e1");
|
||||
});
|
||||
|
||||
it("is still ONE entry, with no id, when there are several", () => {
|
||||
// A host may declare up to 16; a row of 16 identical globes is unusable.
|
||||
// No endpointId is what makes the click surface a picker instead.
|
||||
const endpoints = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
||||
);
|
||||
const actions = webActions(
|
||||
host({ enableWebUi: true, webUiConfig: { endpoints } }),
|
||||
);
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0].endpointId).toBeUndefined();
|
||||
expect(actions[0].label).toBe("Web UI");
|
||||
});
|
||||
|
||||
it("comes last, after the SSH connection actions", () => {
|
||||
const actions = getSshActions(
|
||||
host({
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
} as Partial<Host>),
|
||||
);
|
||||
expect(actions[actions.length - 1].type).toBe("web-endpoint");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user