feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
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",
});
});
});
+73 -23
View File
@@ -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");
});
});