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

@@ -0,0 +1,186 @@
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 "../../src/frontend/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",
);
});
});
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
import type { Host } from "@/types/ui-types";
import type { WebEndpoint } from "@/types";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
const manifest = manifestJson as unknown as PluginManifest;
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;
}
let rendered: RenderedPluginApp | null = null;
async function webAction(target: Host) {
rendered ??= await renderWithApp(plugin, { manifest });
return hostActionsFor(listHostActions(), target).find(
(action) => action.id === "web-endpoint",
);
}
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("web-endpoint activate", () => {
it("registers its tab, host action and editor section", async () => {
rendered = await renderWithApp(plugin, { manifest });
expect(rendered.registered.tabs()).toEqual(["web-endpoint"]);
expect(rendered.registered.hostEditorSections()).toEqual(["web-ui"]);
});
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
expect(
await webAction(
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
),
).toBeUndefined();
});
it("offers no entry when enabled but no endpoints exist", async () => {
expect(
await webAction(
host({ enableWebUi: true, webUiConfig: { endpoints: [] } }),
),
).toBeUndefined();
});
it("appears without SSH", async () => {
const action = await webAction(
host({
enableSsh: false,
enableWebUi: true,
webUiConfig: { endpoints: [endpoint()] },
}),
);
expect(action).toBeDefined();
});
it("labels the entry with the endpoint when there is only one", async () => {
const target = host({
enableWebUi: true,
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
});
const action = await webAction(target);
expect(action?.label?.(target)).toBe("Proxmox");
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
});
it("stays one entry with a picker when there are several", async () => {
const endpoints = Array.from({ length: 16 }, (_, i) =>
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
);
const target = host({ enableWebUi: true, webUiConfig: { endpoints } });
const action = await webAction(target);
expect(action?.label?.(target)).toBeUndefined();
expect(action?.items?.(target)).toHaveLength(16);
});
it("opens an embedded endpoint as a tab carrying its id", async () => {
const target = host({
enableWebUi: true,
webUiConfig: { endpoints: [endpoint()] },
});
const action = await webAction(target);
const calls: unknown[][] = [];
const shell = {
openTab: (...args: unknown[]) => calls.push(args),
} as never;
action!.items!(target)[0].run(target, shell);
expect(calls).toEqual([
[
target,
"web-endpoint",
{ label: "Proxmox", data: { endpointId: "e1" } },
],
]);
});
});