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

+43 -10
View File
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -7,6 +7,10 @@ vi.mock("react-i18next", () => ({
import { HostStatusCard } from "@/dashboard/DashboardTab";
import type { Host } from "@/types/ui-types";
import {
registerHostAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
afterEach(cleanup);
@@ -42,20 +46,49 @@ describe("HostStatusCard", () => {
});
});
describe("dashboard protocol routing", () => {
describe("dashboard host routing", () => {
const Icon = (() => null) as never;
beforeEach(() => {
registerHostAction({
id: "desk-connect",
titleKey: "x",
icon: Icon,
kind: "connect",
priority: 50,
tabType: "desk",
when: (host) => (host as Record<string, unknown>).enableDesk === true,
});
registerHostAction({
id: "shell-connect",
titleKey: "x",
icon: Icon,
kind: "connect",
priority: 100,
tabType: "shell",
when: (host) => (host as Record<string, unknown>).enableShell === true,
});
registerHostAction({
id: "stats",
titleKey: "x",
icon: Icon,
kind: "open",
overview: true,
tabType: "stats",
when: (host) => (host as Record<string, unknown>).enableShell === true,
});
});
afterEach(resetHostContributions);
it.each([
[{ enableRdp: true }, "rdp"],
[{ enableVnc: true }, "vnc"],
[{ enableTelnet: true }, "telnet"],
[{ enableSsh: true, enableRdp: true }, "host-metrics"],
] as const)("opens the enabled protocol for %j", (protocols, expected) => {
[{ enableDesk: true }, "desk"],
[{ enableShell: true, enableDesk: true }, "stats"],
] as const)("opens the right view for %j", (flags, expected) => {
const host = {
id: "1",
name: "Remote host",
ip: "192.0.2.1",
enableSsh: false,
...protocols,
} as Host;
...flags,
} as unknown as Host;
const onOpenTab = vi.fn();
render(
<HostStatusCard
@@ -20,7 +20,7 @@ describe("registerDashboardCard seam", () => {
registerDashboardCard({
id: FAKE_CARD_ID,
titleKey: "nav.fakePluginItem",
render: () => null,
component: () => null,
});
expect(registeredDashboardCardList().map((c) => c.id)).toContain(
@@ -38,20 +38,26 @@ vi.mock("@xterm/addon-fit", () => ({
},
}));
vi.mock(
"../../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx",
() => ({
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
}),
);
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
import {
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
import { REMOTE_DISPLAY_SLOT } from "@/features/remote-display/RemoteDisplay";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
function setSearch(search: string) {
window.history.pushState({}, "", `/?${search}`);
}
beforeEach(() => {
setPermissionsForTesting([]);
registerSlotContribution(REMOTE_DISPLAY_SLOT, {
actionId: "fixture.display",
titleKey: "x",
kind: "component",
component: () => <div data-testid="remote-display" />,
});
api.resolveShareLink.mockReset();
api.resolveShareLink.mockImplementation(async () => {
throw new Error("resolveShareLink not mocked for this test");
@@ -60,6 +66,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
resetActionRegistry();
});
describe("SharedSessionView", () => {
@@ -109,7 +116,7 @@ describe("SharedSessionView", () => {
});
});
it("renders the guacamole display for a resolved rdp share", async () => {
it("renders the plugin remote display for a resolved rdp share", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockResolvedValue({
protocol: "rdp",
@@ -121,7 +128,7 @@ describe("SharedSessionView", () => {
render(<SharedSessionView />);
await waitFor(() => {
expect(screen.getByTestId("guacamole-display")).toBeTruthy();
expect(screen.getByTestId("remote-display")).toBeTruthy();
});
expect(
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
@@ -949,19 +949,25 @@ describe("TerminalToolbar Phase 1", () => {
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
it("invokes the action with the terminal buffer text when permitted", async () => {
it("invokes the action with the terminal slot api when permitted", async () => {
const handler = contribute();
perms.granted = ["ai.services.use"];
const getBufferText = vi.fn(() => "$ whoami\nroot");
const slotApi = {
host: undefined,
getBufferText,
openDock: vi.fn(),
runCommand: vi.fn(),
};
renderToolbar({ getBufferText });
renderToolbar({ slotApi });
expect(getBufferText).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "AI Assistant" }),
);
expect(handler).toHaveBeenCalledWith("$ whoami\nroot");
expect(handler).toHaveBeenCalledWith(slotApi);
});
it("hides the slot when the host has contributed actions disabled", () => {
@@ -1,109 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
const getAiStatus = vi.fn();
vi.mock("@/api/ai-api", () => ({ getAiStatus }));
const { useAiAvailability, notifyAiStatusChanged } =
await import("../../hooks/use-ai-availability.js");
beforeEach(() => {
getAiStatus.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useAiAvailability", () => {
it("starts hidden and unloaded before the status answers", () => {
getAiStatus.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useAiAvailability());
expect(result.current).toEqual({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
});
it("reports both gates open when the admin and the user enabled it", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(true);
expect(result.current.userEnabled).toBe(true);
});
it("keeps userEnabled false when the user has not opted in", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: false,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(true);
expect(result.current.userEnabled).toBe(false);
});
it("the admin kill switch wins over the user opt-in", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: false,
enabled: true,
allowReadOnlyCommands: true,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(false);
expect(result.current.userEnabled).toBe(false);
});
it("treats a failed status call as off", async () => {
getAiStatus.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(false);
expect(result.current.userEnabled).toBe(false);
});
it("re-reads the status when notified", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.globallyEnabled).toBe(true));
getAiStatus.mockResolvedValue({
globallyEnabled: false,
enabled: true,
allowReadOnlyCommands: false,
});
act(() => notifyAiStatusChanged());
await waitFor(() => expect(result.current.globallyEnabled).toBe(false));
expect(result.current.userEnabled).toBe(false);
});
it("also re-reads on the older hiddenRailTabsChanged event", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: false,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
act(() => window.dispatchEvent(new Event("hiddenRailTabsChanged")));
await waitFor(() => expect(result.current.userEnabled).toBe(true));
});
});
@@ -30,24 +30,15 @@ describe("ONBOARDING_STEPS", () => {
});
it("has no add-a-host step, which used to close onboarding mid-flow", () => {
const ids = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
const ids = relevantSteps({}).map((s) => s.id);
expect(ids).not.toContain("first-host");
});
it("ends on the done step", () => {
const ids = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
const ids = relevantSteps({}).map((s) => s.id);
expect(ids[ids.length - 1]).toBe("done");
});
it("only offers the AI step when an admin has enabled the assistant", () => {
// A user on an instance with AI switched off should never be told the
// feature exists, so the step has to disappear rather than render disabled.
const off = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
const on = relevantSteps({ aiGloballyEnabled: true }).map((s) => s.id);
expect(off).not.toContain("ai");
expect(on).toContain("ai");
});
it("tours the feature set after the setup choices", () => {
const ids = ONBOARDING_STEPS.map((s) => s.id);
for (const id of ["features", "workflow", "security"]) {
@@ -60,16 +51,8 @@ describe("ONBOARDING_STEPS", () => {
describe("onboarding step body translations", () => {
const KEYS: Record<string, string[]> = {
welcome: ["hosts", "terminal", "files"],
feature: [
"files",
"desktop",
"tunnels",
"docker",
"snippets",
"automations",
"metrics",
],
workflow: ["palette", "split", "dock", "workspaces"],
feature: ["files", "tunnels", "snippets"],
workflow: ["palette", "split", "dock"],
security: ["credentials", "twofa", "identity", "sharing"],
done: ["settings", "rerun", "docs"],
};
@@ -97,12 +80,6 @@ describe("onboarding step body translations", () => {
"featuresIntro",
"workflowIntro",
"securityIntro",
"aiIntro",
"aiEnableTitle",
"aiEnableDesc",
"aiSkipTitle",
"aiSkipDesc",
"aiNote",
"doneDesc",
]) {
expect(lookup(`onboarding.${key}`), key).toBeTypeOf("string");
@@ -0,0 +1,70 @@
/**
* A bundle built by the termix-plugin CLI, loaded the way the shell loads it,
* renders hooks against the shell's own React.
*
* The CLI leaves react, react/jsx-runtime and the SDK as bare imports; in the
* browser the import map resolves them to the shell's copies. Here the test
* runner resolves them to the same modules the test itself uses, so a hook
* call only works if the bundle really did leave React out. A bundled second
* React fails with "Invalid hook call".
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
renderWithApp,
type FrontendPluginModule,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { resetPanels } from "@/shell/panel-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
const ROOT = path.resolve(__dirname, "../../../..");
const FIXTURE = path.join(__dirname, "fixtures", "hook-plugin");
// Inside the repo, so the built bundle's bare imports resolve to its packages.
const WORK = path.join(ROOT, "node_modules", ".tmp", "cli-bundle-test");
const CLI = path.join(ROOT, "packages", "plugin-sdk", "cli", "index.mjs");
let bundle = "";
let rendered: RenderedPluginApp | null = null;
beforeAll(() => {
fs.rmSync(WORK, { recursive: true, force: true });
fs.mkdirSync(path.dirname(WORK), { recursive: true });
fs.cpSync(FIXTURE, WORK, { recursive: true });
execFileSync(process.execPath, [CLI, "build"], { cwd: WORK, stdio: "pipe" });
bundle = fs.readFileSync(path.join(WORK, "dist", "frontend.js"), "utf8");
}, 60_000);
afterAll(async () => {
await rendered?.deactivate();
resetPanels();
resetPluginStore();
fs.rmSync(WORK, { recursive: true, force: true });
});
describe("a CLI-built plugin bundle", () => {
it("leaves React and the SDK out of the bundle", () => {
expect(bundle).toMatch(/from\s*"react"/);
expect(bundle).toMatch(/from\s*"@termix\/plugin-sdk\/frontend"/);
expect(bundle).not.toMatch(/react\.production|react\.development/);
});
it("renders hooks against the shell's React", async () => {
const module = (await import(
/* @vite-ignore */ path.join(WORK, "dist", "frontend.js")
)) as FrontendPluginModule;
rendered = await renderWithApp(module, {
manifest: JSON.parse(
fs.readFileSync(path.join(WORK, "manifest.json"), "utf8"),
),
locales: { title: "Clicks" },
});
const button = rendered.renderPanel("hook-panel").querySelector("button")!;
expect(button.textContent).toBe("Clicks: 0");
fireEvent.click(button);
expect(button.textContent).toBe("Clicks: 1");
});
});
@@ -0,0 +1,14 @@
{
"id": "hook-fixture",
"name": "Hook fixture",
"version": "1.0.0",
"description": "Built by the CLI in a test to prove a bundle shares the shell's React.",
"author": { "name": "Termix" },
"license": "MIT",
"category": "Productivity",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": ["ui:surface"],
"contributes": {
"panels": [{ "id": "hook-panel", "titleKey": "title" }]
}
}
@@ -0,0 +1 @@
export function activate(): void {}
@@ -0,0 +1,16 @@
import { useState } from "react";
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
function Counter() {
const { t } = useTranslation();
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{t("title")}: {count}
</button>
);
}
export function activate(app: TermixApp): void {
app.registerPanel("hook-panel", Counter);
}
+263
View File
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Puzzle } from "lucide-react";
import type { FrontendModule, TermixApp } from "@termix/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
import {
configurePluginLoader,
isPluginFrontendActive,
orderForActivation,
resetPluginLoader,
syncPlugins,
} from "@/plugin-host/loader";
import {
getPluginRecord,
getPluginStoreState,
resetPluginStore,
} from "@/plugin-host/plugin-store";
import {
resetRegisteredRailItems,
visibleRailItems,
} from "@/sidebar/rail-items";
import { getTabType, resetTabTypes } from "@/shell/tab-registry";
function summary(
id: string,
overrides: Partial<PluginSummary> = {},
): PluginSummary {
return {
id,
name: id,
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
assetVersion: "v1",
contributes: { panels: [{ id: `${id}-panel`, titleKey: "nav.hosts" }] },
...overrides,
};
}
/** A plugin that registers a rail item named after itself. */
function railPlugin(id: string, calls: string[] = []): FrontendModule {
return {
activate(app: TermixApp) {
calls.push(`activate:${id}`);
app.registerRailItem({
id: `${id}-panel`,
icon: Puzzle,
titleKey: "nav.hosts",
});
},
deactivate() {
calls.push(`deactivate:${id}`);
},
};
}
function railIds(): string[] {
return visibleRailItems().map((item) => item.id);
}
let plugins: PluginSummary[] = [];
let modules: Record<string, FrontendModule> = {};
beforeEach(() => {
plugins = [];
modules = {};
configurePluginLoader({
fetchPlugins: async () => plugins,
importFrontend: async (entry) => {
const module = modules[entry.id];
if (!module) throw new Error(`no module for ${entry.id}`);
return module;
},
loadLocale: async () => null,
injectCss: () => null,
});
});
afterEach(async () => {
await resetPluginLoader();
resetPluginStore();
resetRegisteredRailItems();
resetTabTypes();
vi.restoreAllMocks();
});
describe("plugin loader", () => {
it("activates an enabled plugin and deactivates it when disabled, without a reload", async () => {
const calls: string[] = [];
modules.alpha = railPlugin("alpha", calls);
plugins = [summary("alpha")];
await syncPlugins();
expect(railIds()).toContain("alpha-panel");
expect(isPluginFrontendActive("alpha")).toBe(true);
plugins = [summary("alpha", { enabled: false })];
await syncPlugins();
expect(railIds()).not.toContain("alpha-panel");
expect(calls).toEqual(["activate:alpha", "deactivate:alpha"]);
expect(getPluginRecord("alpha")?.frontend).toBe("inactive");
});
it("re-activates the same module after disable then enable", async () => {
const calls: string[] = [];
modules.alpha = railPlugin("alpha", calls);
plugins = [summary("alpha")];
await syncPlugins();
plugins = [summary("alpha", { enabled: false })];
await syncPlugins();
plugins = [summary("alpha")];
await syncPlugins();
expect(railIds()).toContain("alpha-panel");
expect(calls).toEqual([
"activate:alpha",
"deactivate:alpha",
"activate:alpha",
]);
});
it("disposes registrations even when deactivate throws", async () => {
modules.alpha = {
activate: railPlugin("alpha").activate,
deactivate: () => {
throw new Error("boom");
},
};
vi.spyOn(console, "error").mockImplementation(() => {});
plugins = [summary("alpha")];
await syncPlugins();
plugins = [];
await syncPlugins();
expect(railIds()).not.toContain("alpha-panel");
});
it("isolates a plugin whose activate throws", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
modules.broken = {
activate(app) {
app.registerRailItem({
id: "broken-panel",
icon: Puzzle,
titleKey: "nav.hosts",
});
throw new Error("activate failed");
},
};
modules.healthy = railPlugin("healthy");
plugins = [summary("broken"), summary("healthy")];
await syncPlugins();
expect(getPluginRecord("broken")?.frontend).toBe("failed");
expect(getPluginRecord("broken")?.error).toBe("activate failed");
// What it registered before throwing is cleaned up.
expect(railIds()).not.toContain("broken-panel");
expect(railIds()).toContain("healthy-panel");
});
it("marks a plugin failed when its bundle cannot be imported", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
plugins = [summary("missing")];
await syncPlugins();
expect(getPluginRecord("missing")?.frontend).toBe("failed");
});
it("does not retry the same broken bundle, but does a new one", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const activate = vi.fn(() => {
throw new Error("nope");
});
modules.flaky = { activate };
plugins = [summary("flaky")];
await syncPlugins();
await syncPlugins();
expect(activate).toHaveBeenCalledTimes(1);
modules.flaky = railPlugin("flaky");
plugins = [summary("flaky", { assetVersion: "v2" })];
await syncPlugins();
expect(getPluginRecord("flaky")?.frontend).toBe("active");
});
it("refuses to register a view the manifest does not declare", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
modules.sneaky = {
activate(app) {
app.registerTab("undeclared", () => null);
},
};
plugins = [summary("sneaky")];
await syncPlugins();
expect(getTabType("undeclared")).toBeUndefined();
expect(getPluginRecord("sneaky")?.error).toMatch(/not declared/);
});
it("skips server-failed and frontend-less plugins", async () => {
modules.down = railPlugin("down");
modules.headless = railPlugin("headless");
plugins = [
summary("down", { state: "failed" }),
summary("headless", { frontend: false }),
];
await syncPlugins();
expect(railIds()).not.toContain("down-panel");
expect(railIds()).not.toContain("headless-panel");
});
it("settles even when the plugin list cannot be fetched", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
configurePluginLoader({
fetchPlugins: async () => {
throw new Error("offline");
},
});
await syncPlugins();
expect(getPluginStoreState().settled).toBe(true);
});
});
describe("activation order", () => {
it("activates a dependency before its dependent", async () => {
const calls: string[] = [];
modules.child = railPlugin("child", calls);
modules.parent = railPlugin("parent", calls);
plugins = [
summary("child", { dependencies: { parent: "^1.0.0" } }),
summary("parent"),
];
await syncPlugins();
expect(calls).toEqual(["activate:parent", "activate:child"]);
});
it("blocks a plugin whose hard dependency is off", async () => {
modules.child = railPlugin("child");
plugins = [
summary("child", { dependencies: { parent: "^1.0.0" } }),
summary("parent", { enabled: false }),
];
await syncPlugins();
expect(getPluginRecord("child")?.frontend).toBe("blocked");
expect(railIds()).not.toContain("child-panel");
});
it("runs without an optional dependency", () => {
const { order, blocked } = orderForActivation([
summary("child", { optionalDependencies: { gone: "^1.0.0" } }),
]);
expect(order.map((entry) => entry.id)).toEqual(["child"]);
expect(blocked.size).toBe(0);
});
it("blocks both sides of a dependency cycle", () => {
const { order, blocked } = orderForActivation([
summary("a", { dependencies: { b: "*" } }),
summary("b", { dependencies: { a: "*" } }),
]);
expect(order).toEqual([]);
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
});
});
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Puzzle } from "lucide-react";
import {
usePermission,
useTranslation,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { NavigationVisibilityToggles } from "@/sidebar/NavigationVisibilityToggles";
import {
hostActionsFor,
listHostActions,
defaultConnectAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import {
makeHostSshSubTabs,
makeHostTabs,
resetHostEditorSections,
} from "@/sidebar/HostManagerTabs";
import {
listPaletteEntries,
paletteEntriesFor,
resetPaletteEntries,
} from "@/shell/palette-registry";
import { shell } from "@/plugin-host/shell-bridge";
import { resetRegisteredRailItems } from "@/sidebar/rail-items";
import { resetTabTypes } from "@/shell/tab-registry";
import { resetPanels } from "@/shell/panel-registry";
import { resetDashboardCards } from "@/dashboard/dashboard-cards-registry";
import { resetSettingsComponents } from "@/settings/settings-components";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import type { Host } from "@/types/ui-types";
const MANIFEST: Partial<PluginManifest> = {
id: "fixture",
name: "Fixture",
contributes: {
tabs: [
{
id: "fixture-tab",
titleKey: "title",
icon: "Puzzle",
openFrom: ["rail"],
},
],
panels: [{ id: "fixture-panel", titleKey: "title" }],
dashboardCards: [{ id: "fixture-card", titleKey: "title" }],
permissions: [{ name: "use", titleKey: "title", descriptionKey: "title" }],
},
};
const LOCALES = { title: "Fixture title", greeting: "Hello from fixture" };
function Counter() {
const { t } = useTranslation();
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{t("greeting")} {count}
</button>
);
}
function PermissionProbe() {
return <span>{usePermission("use") ? "allowed" : "denied"}</span>;
}
function activate(app: TermixApp) {
app.registerRailItem({
id: "fixture-panel",
icon: Puzzle,
titleKey: "title",
});
app.registerPanel("fixture-panel", () => <Counter />);
app.registerTab("fixture-tab", ({ host }) => (
<span>tab for {host?.name ?? "nobody"}</span>
));
app.registerHostEditorSection({
id: "fixture-section",
group: "ssh",
titleKey: "title",
order: 15,
component: ({ setField }) => (
<button type="button" onClick={() => setField("fixture", true)}>
section
</button>
),
});
app.registerHostAction({
id: "fixture-connect",
titleKey: "title",
icon: Puzzle,
kind: "connect",
priority: 500,
tabType: "fixture-tab",
when: (host) => host.enableFixture === true,
});
app.registerPaletteEntry({
id: "fixture-entry",
titleKey: "title",
scope: "host",
when: (host) => host?.enableFixture === true,
run: (shell, host) => shell.openTab(host ?? null, "fixture-tab"),
});
app.registerDashboardCard({
id: "fixture-card",
titleKey: "title",
component: ({ shell }) => (
<button
type="button"
onClick={() => shell.openSingletonTab("fixture-tab")}
>
card
</button>
),
});
app.registerSettingsComponent("probe", PermissionProbe);
}
let rendered: RenderedPluginApp | null = null;
async function mount(permissions: string[] = []) {
rendered = await renderWithApp(
{ activate },
{ manifest: MANIFEST, locales: LOCALES, permissions },
);
return rendered;
}
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
resetRegisteredRailItems();
resetTabTypes();
resetPanels();
resetDashboardCards();
resetSettingsComponents();
resetActionRegistry();
resetHostContributions();
resetHostEditorSections();
resetPaletteEntries();
resetPluginStore();
});
const host = (overrides: Partial<Host> = {}) =>
({ id: "1", name: "web-01", ip: "10.0.0.1", port: 22, ...overrides }) as Host;
describe("registries through the app object", () => {
it("records everything the plugin registered", async () => {
const app = await mount();
expect(app.registered.railItems()).toEqual([
{ id: "fixture-panel", hidden: undefined },
]);
expect(app.registered.tabs()).toEqual(["fixture-tab"]);
expect(app.registered.panels()).toEqual(["fixture-panel"]);
expect(app.registered.hostEditorSections()).toEqual(["fixture-section"]);
expect(app.registered.hostActions()).toEqual([
{ id: "fixture-connect", tabType: "fixture-tab" },
]);
expect(app.registered.dashboardCards()).toEqual(["fixture-card"]);
expect(app.registered.settingsComponents()).toEqual(["probe"]);
});
it("removes every registration on deactivate", async () => {
const app = await mount();
await app.deactivate();
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
rendered = null;
});
it("renders a panel with hooks bound to the plugin", async () => {
const app = await mount();
const container = app.renderPanel("fixture-panel");
const button = container.querySelector("button")!;
expect(button.textContent).toBe("Hello from fixture 0");
fireEvent.click(button);
expect(button.textContent).toBe("Hello from fixture 1");
});
it("renders a tab with the generic tab props", async () => {
const app = await mount();
const container = app.renderTab("fixture-tab", { host: host() });
expect(container.textContent).toBe("tab for web-01");
});
it("lists a rail item in the Navigation visibility toggles and toggles it", async () => {
await mount();
const onChange = vi.fn();
render(
<NavigationVisibilityToggles hidden={new Set()} onChange={onChange} />,
);
const row = screen.getByTestId("nav-toggle-fixture-panel");
expect(row.textContent).toContain("Fixture title");
fireEvent.click(row.querySelector("button, [role=switch]")!);
expect(onChange).toHaveBeenCalledWith(new Set(["fixture-panel"]));
});
it("drops the toggle when the plugin goes away", async () => {
const app = await mount();
const { rerender } = render(
<NavigationVisibilityToggles hidden={new Set()} onChange={() => {}} />,
);
expect(screen.queryByTestId("nav-toggle-fixture-panel")).not.toBeNull();
await app.deactivate();
rendered = null;
rerender(
<NavigationVisibilityToggles hidden={new Set()} onChange={() => {}} />,
);
expect(screen.queryByTestId("nav-toggle-fixture-panel")).toBeNull();
});
it("adds a host editor section to the SSH group in order", async () => {
await mount();
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
expect(ids.slice(0, 3)).toEqual(["ssh", "terminal", "fixture-section"]);
expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain(
"fixture-section",
);
});
it("renders a host editor section with the editor's setters", async () => {
const app = await mount();
const setField = vi.fn();
const container = app.renderHostEditorSection("fixture-section", {
setField,
});
fireEvent.click(container.querySelector("button")!);
expect(setField).toHaveBeenCalledWith("fixture", true);
});
it("offers a host action only where it applies, and picks it as the default connect", async () => {
await mount();
const on = host({ enableFixture: true } as Partial<Host>);
const off = host();
expect(hostActionsFor(listHostActions(), on).map((a) => a.id)).toEqual([
"fixture-connect",
]);
expect(hostActionsFor(listHostActions(), off)).toEqual([]);
expect(defaultConnectAction(listHostActions(), on)?.id).toBe(
"fixture-connect",
);
expect(resolveHostTabType(on)).toBe("fixture-tab");
});
it("offers a per-host palette entry that runs against the shell", async () => {
const app = await mount();
const target = host({ enableFixture: true } as Partial<Host>);
const entries = paletteEntriesFor(listPaletteEntries(), "host", target);
expect(entries.map((entry) => entry.id)).toEqual(["fixture-entry"]);
expect(paletteEntriesFor(listPaletteEntries(), "host", host())).toEqual([]);
entries[0].run(shell, target);
expect(app.shellCalls).toEqual([
{ method: "openTab", args: [target, "fixture-tab"] },
]);
});
it("renders a dashboard card that can reach the shell", async () => {
const app = await mount();
const container = app.renderDashboardCard("fixture-card");
fireEvent.click(container.querySelector("button")!);
expect(app.shellCalls).toEqual([
{ method: "openSingletonTab", args: ["fixture-tab"] },
]);
});
it("renders a custom settings field from the registered component", async () => {
await mount(["fixture.use"]);
render(
<SettingsFieldRow
pluginId="fixture"
field={{ key: "probe", type: "custom", component: "probe" }}
values={{}}
setValue={() => {}}
running
/>,
);
await waitFor(() => expect(screen.getByText("allowed")).toBeTruthy());
});
it("resolves a short permission name against the plugin's namespace", async () => {
const app = await mount([]);
const container = app.renderSettingsComponent("probe");
expect(container.textContent).toBe("denied");
});
});
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { ComponentSlot } from "@/shell/ActionSlot";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import { resetPanels } from "@/shell/panel-registry";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
import { pluginKey } from "@/lib/plugin-i18n";
const mounted: RenderedPluginApp[] = [];
afterEach(async () => {
while (mounted.length > 0) await mounted.pop()!.deactivate();
resetActionRegistry();
resetPanels();
resetPluginStore();
});
function Label() {
const { t } = useTranslation();
return <span>{t("label")}</span>;
}
describe("component slots", () => {
function contribute(permission?: string) {
return async (app: TermixApp) => {
if (permission) {
app.registerAction("slotty.panel", () => {}, { permission });
}
app.registerSlotContribution("owner.slot", {
actionId: "slotty.panel",
titleKey: "label",
kind: "component",
component: ({ who }: Record<string, unknown>) => (
<span>contributed for {String(who)}</span>
),
when: (context) => context.show !== false,
});
};
}
const manifest = {
id: "slotty",
contributes: {
permissions: [{ name: "use", titleKey: "l", descriptionKey: "l" }],
},
};
it("renders nothing when nobody contributed", () => {
setPermissionsForTesting([]);
const { container } = render(<ComponentSlot slotId="owner.slot" />);
expect(container.innerHTML).toBe("");
});
it("renders nothing when the user lacks the permission", async () => {
mounted.push(
await renderWithApp(
{ activate: contribute("use") },
{ manifest, permissions: [] },
),
);
const { container } = render(
<ComponentSlot slotId="owner.slot" props={{ who: "me" }} />,
);
expect(container.innerHTML).toBe("");
});
it("renders the contribution with the owner's props when permitted", async () => {
mounted.push(
await renderWithApp(
{ activate: contribute("use") },
{ manifest, permissions: ["slotty.use"] },
),
);
render(<ComponentSlot slotId="owner.slot" props={{ who: "me" }} />);
expect(await screen.findByText("contributed for me")).toBeTruthy();
});
it("honours a contribution's when", async () => {
mounted.push(await renderWithApp({ activate: contribute() }, { manifest }));
const { container } = render(
<ComponentSlot slotId="owner.slot" when={{ show: false }} />,
);
expect(container.innerHTML).toBe("");
});
});
describe("plugin translations", () => {
it("keeps two plugins' identical keys apart", async () => {
for (const [id, text] of [
["first", "From the first plugin"],
["second", "From the second plugin"],
] as const) {
const app = await renderWithApp(
{
activate(app) {
app.registerPanel(`${id}-panel`, Label);
},
},
{
manifest: {
id,
contributes: { panels: [{ id: `${id}-panel`, titleKey: "label" }] },
},
locales: { label: text },
},
);
mounted.push(app);
}
const [first, second] = mounted;
expect(first.renderPanel("first-panel").textContent).toBe(
"From the first plugin",
);
expect(second.renderPanel("second-panel").textContent).toBe(
"From the second plugin",
);
});
it("falls back to core strings for keys the plugin does not define", async () => {
mounted.push(
await renderWithApp(
{
activate(app) {
app.registerPanel("borrow-panel", () => {
const { t } = useTranslation();
return <span>{t("common.save")}</span>;
});
},
},
{
manifest: {
id: "borrow",
contributes: { panels: [{ id: "borrow-panel", titleKey: "x" }] },
},
locales: {},
},
),
);
expect(mounted[0].renderPanel("borrow-panel").textContent).toBe(
i18n.t("common.save"),
);
});
it("qualifies manifest keys with the plugin's namespace", () => {
expect(pluginKey("tailscale", "settings.apiKey.label")).toBe(
"tailscale:settings.apiKey.label",
);
expect(pluginKey("tailscale", "other:key")).toBe("other:key");
});
});
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
resetPluginStore,
setFrontendState,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
import {
unregisteredViewStatus,
viewOwner,
} from "@/plugin-host/view-ownership";
import { renderTabContent } from "@/shell/tabUtils";
import {
canRestoreTabType,
isPersistentTabType,
registerTabType,
resetTabTypes,
} from "@/shell/tab-registry";
import {
registerDashboardCard,
resetDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
import { PluginCardSlot } from "@/dashboard/DashboardTab";
import type { Host, Tab } from "@/types/ui-types";
const shell = {
openTab: () => {},
openSingletonTab: () => {},
closeTab: () => {},
renameTab: () => {},
openFileInEditor: () => {},
openFileManager: () => {},
openTerminalTab: () => {},
openRailView: () => {},
closeRailView: () => {},
};
function owner(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "gizmo",
name: "Gizmo",
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
contributes: {
tabs: [
{ id: "gizmo-tab", titleKey: "t", icon: "Box", openFrom: ["rail"] },
],
panels: [{ id: "gizmo-panel", titleKey: "t" }],
dashboardCards: [{ id: "gizmo-card", titleKey: "t" }],
},
...overrides,
};
}
const savedTab: Tab = {
id: "gizmo-1",
instanceId: "gizmo-1",
type: "gizmo-tab",
label: "Gizmo",
openedAt: 0,
host: { id: "1", name: "web-01" } as Host,
};
afterEach(() => {
resetPluginStore();
resetTabTypes();
resetDashboardCards();
});
describe("view ownership", () => {
it("finds the owner of a tab, panel or card from its manifest", () => {
setPluginSummaries([owner()]);
expect(viewOwner("tab", "gizmo-tab")?.summary.id).toBe("gizmo");
expect(viewOwner("panel", "gizmo-panel")?.summary.id).toBe("gizmo");
// A tab id doubles as a panel when a rail panel is promoted.
expect(viewOwner("panel", "gizmo-tab")?.summary.id).toBe("gizmo");
expect(viewOwner("card", "gizmo-card")?.summary.id).toBe("gizmo");
expect(viewOwner("tab", "someone-else")).toBeUndefined();
});
it("explains why a view is absent", () => {
setPluginSummaries([owner({ enabled: false })]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("disabled");
setPluginSummaries([owner({ state: "failed" })]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("failed");
setPluginSummaries([owner()]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("loading");
setFrontendState("gizmo", "active");
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("missing");
expect(unregisteredViewStatus("tab", "nobody").status).toBe("missing");
});
it("keeps a saved tab of a disabled plugin and restores it as a placeholder", () => {
setPluginSummaries([owner({ enabled: false })]);
expect(isPersistentTabType("gizmo-tab")).toBe(true);
expect(canRestoreTabType("gizmo-tab", savedTab.host)).toBe(true);
render(<>{renderTabContent(savedTab, { shell })}</>);
const placeholder = screen.getByTestId("plugin-view-placeholder");
expect(placeholder.dataset.status).toBe("disabled");
expect(placeholder.textContent).toContain("plugins.runtime.needsPlugin");
});
it("shows a not-installed placeholder for a tab nobody owns, without throwing", () => {
setPluginSummaries([]);
render(<>{renderTabContent({ ...savedTab, type: "gone" }, { shell })}</>);
expect(screen.getByTestId("plugin-view-placeholder").dataset.status).toBe(
"missing",
);
});
it("swaps the placeholder for the real tab once the plugin registers it", () => {
setPluginSummaries([owner()]);
registerTabType({
id: "gizmo-tab",
pluginId: "gizmo",
component: ({ host }) => <span>gizmo on {host?.name}</span>,
});
render(<>{renderTabContent(savedTab, { shell })}</>);
expect(screen.getByText("gizmo on web-01")).toBeTruthy();
});
it("keeps a dashboard card's slot while its plugin is off", () => {
setPluginSummaries([owner({ enabled: false })]);
const view = render(
<PluginCardSlot
id="gizmo-card"
isVisible
onOpenSingletonTab={() => {}}
/>,
);
expect(screen.getByTestId("plugin-view-placeholder").dataset.status).toBe(
"disabled",
);
act(() => {
setPluginSummaries([owner()]);
registerDashboardCard({
id: "gizmo-card",
pluginId: "gizmo",
titleKey: "t",
component: () => <span>gizmo card</span>,
});
});
view.rerender(
<PluginCardSlot
id="gizmo-card"
isVisible
onOpenSingletonTab={() => {}}
/>,
);
expect(screen.getByText("gizmo card")).toBeTruthy();
});
});
+11 -11
View File
@@ -14,8 +14,8 @@ import {
syncHostPluginsTab,
} from "@/settings/host-plugins-tab";
import {
getRegisteredHostEditorTab,
unregisterHostEditorTab,
getHostEditorSection,
unregisterHostEditorSection,
} from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections";
@@ -47,23 +47,23 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
}
beforeEach(() => {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
});
afterEach(() => {
cleanup();
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
});
describe("syncHostPluginsTab", () => {
it("registers the tab when a plugin contributes host settings", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("ignores a plugin with no host settings", () => {
@@ -81,11 +81,11 @@ describe("syncHostPluginsTab", () => {
it("removes the tab once the last contributor goes", () => {
syncHostPluginsTab([plugin()]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
syncHostPluginsTab([plugin({ enabled: false })]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("registers for a plugin declaring only an enable switch", () => {
@@ -112,8 +112,8 @@ describe("HostPluginSections", () => {
/>,
);
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
expect(screen.getByText("docker:enableDocker.label")).toBeTruthy();
expect(screen.queryByText("docker:socket.label")).toBeNull();
});
it("shows the fields once the enable switch is on", () => {
@@ -125,7 +125,7 @@ describe("HostPluginSections", () => {
/>,
);
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
expect(screen.getByText("docker:socket.label")).toBeTruthy();
});
it("reports a change against the right plugin", () => {
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { PERSISTENT_TAB_TYPES } from "@/AppShell";
describe("PERSISTENT_TAB_TYPES", () => {
it("excludes web-endpoint", () => {
// Not merely scope. The backend closes an idle tunnel after ten minutes
// and re-binds a fresh kernel-assigned port on the next open, so a
// restored web-endpoint tab could never hold a valid URL -- and the
// endpoint may have been edited or deleted meanwhile besides. Restoring
// one would mean re-opening the tunnel on restore, which is a feature.
//
// This test exists so nobody adds it later for symmetry and ships tabs
// that restore broken.
expect(PERSISTENT_TAB_TYPES).not.toContain("web-endpoint");
});
it("still persists the tab types that can be restored", () => {
// Guards the guard: an empty or gutted list would pass the assertion above
// vacuously.
expect(PERSISTENT_TAB_TYPES).toContain("terminal");
expect(PERSISTENT_TAB_TYPES).toContain("tunnel");
expect(PERSISTENT_TAB_TYPES.length).toBeGreaterThanOrEqual(8);
});
});
-256
View File
@@ -1,256 +0,0 @@
/**
* Disabling a plugin has to remove the surfaces it owns, and disabling one
* plugin must not touch another's.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { Puzzle } from "lucide-react";
import {
applyPluginState,
isPluginEnabled,
isTabTypeAvailable,
resetPluginState,
} from "@/shell/pluginLoader";
import {
registerRailItem,
unregisterRailItem,
visibleRailItems,
} from "@/sidebar/rail-items";
import type { PluginSummary } from "@/api/plugins-api";
vi.mock("@/api/plugins-api", () => ({
getPlugins: vi.fn().mockResolvedValue([]),
setPluginEnabled: vi.fn(),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "ssh-terminal",
name: "SSH Terminal",
version: "1.0.0",
tier: "bundled",
source: "bundled",
enabled: true,
state: "active",
lastError: null,
contributes: {
tabs: [
{
id: "terminal",
titleKey: "nav.terminal",
icon: "SquareTerminal",
openFrom: ["host-context-menu", "palette"],
},
],
},
capabilities: [],
grantedCapabilities: [],
...overrides,
};
}
function aiPlugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return plugin({
id: "ai",
name: "AI Assistant",
contributes: {
tabs: [
{
id: "ai",
titleKey: "nav.ai",
icon: "Sparkles",
openFrom: ["rail", "palette"],
},
],
},
...overrides,
});
}
function remoteDesktopPlugin(
overrides: Partial<PluginSummary> = {},
): PluginSummary {
return plugin({
id: "remote-desktop",
name: "Remote Desktop",
contributes: {
tabs: [
{
id: "rdp",
titleKey: "hosts.tabRdp",
icon: "Monitor",
openFrom: ["host-context-menu", "palette"],
},
{
id: "vnc",
titleKey: "hosts.tabVnc",
icon: "MousePointerClick",
openFrom: ["host-context-menu", "palette"],
},
{
id: "telnet",
titleKey: "hosts.tabTelnet",
icon: "Terminal",
openFrom: ["host-context-menu", "palette"],
},
],
},
...overrides,
});
}
describe("plugin state applied to the shell", () => {
afterEach(() => {
resetPluginState();
unregisterRailItem("other-plugin-tab");
});
it("allows terminal tabs while the plugin is enabled", () => {
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("blocks terminal tabs when the plugin is disabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
});
it("allows them again when the plugin is re-enabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("leaves unrelated tab types alone", () => {
applyPluginState([plugin({ enabled: false })]);
// Disabling the terminal must not take the rest of the app with it.
for (const type of ["files", "docker", "tunnel", "rdp", "host-metrics"]) {
expect(isTabTypeAvailable(type)).toBe(true);
}
});
it("leaves another plugin's rail item alone when the terminal is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
applyPluginState([
plugin({ enabled: false }),
plugin({
id: "other-plugin",
name: "Other",
tier: "community",
source: "registry",
enabled: true,
contributes: null,
}),
]);
expect(isTabTypeAvailable("terminal")).toBe(false);
// The core ask: disabling one plugin must not disturb another.
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
});
it("unregisters a plugin-registered tab when that plugin is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
applyPluginState([
plugin({
id: "other-plugin",
name: "Other",
enabled: false,
contributes: {
tabs: [
{
id: "other-plugin-tab",
titleKey: "nav.otherPlugin",
icon: "Puzzle",
openFrom: ["rail"],
},
],
},
}),
]);
expect(visibleRailItems().map((i) => i.id)).not.toContain(
"other-plugin-tab",
);
});
it("allows the ai tab while the plugin is enabled", () => {
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("blocks the ai tab when the plugin is disabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
});
it("allows the ai tab again when the plugin is re-enabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("disabling ai does not affect the terminal, and vice versa", () => {
applyPluginState([aiPlugin({ enabled: false }), plugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(false);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("allows rdp/vnc/telnet tabs while remote-desktop is enabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
expect(isTabTypeAvailable("rdp")).toBe(true);
expect(isTabTypeAvailable("vnc")).toBe(true);
expect(isTabTypeAvailable("telnet")).toBe(true);
});
it("blocks rdp/vnc/telnet tabs when remote-desktop is disabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
expect(isTabTypeAvailable("rdp")).toBe(false);
expect(isTabTypeAvailable("vnc")).toBe(false);
expect(isTabTypeAvailable("telnet")).toBe(false);
});
it("allows rdp/vnc/telnet tabs again when remote-desktop is re-enabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
expect(isTabTypeAvailable("rdp")).toBe(false);
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
expect(isTabTypeAvailable("rdp")).toBe(true);
expect(isTabTypeAvailable("vnc")).toBe(true);
expect(isTabTypeAvailable("telnet")).toBe(true);
});
it("disabling remote-desktop does not affect the terminal, and vice versa", () => {
applyPluginState([
remoteDesktopPlugin({ enabled: false }),
plugin({ enabled: true }),
]);
expect(isTabTypeAvailable("rdp")).toBe(false);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("reports enablement, defaulting to true before the first load", () => {
expect(isPluginEnabled("ssh-terminal")).toBe(true);
applyPluginState([plugin({ enabled: false })]);
expect(isPluginEnabled("ssh-terminal")).toBe(false);
expect(isPluginEnabled("never-heard-of-it")).toBe(true);
});
});
+382
View File
@@ -0,0 +1,382 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildLayoutPayload as buildWorkspacePayload,
buildLayoutTabSnapshots as buildWorkspaceTabSnapshots,
remapSlotIds,
resolveLayoutTabTarget as resolveWorkspaceTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
// Plugin tab types take part in layouts only while registered, so the ones
// these cases use are registered the way their plugins would.
beforeEach(() => {
registerTabType({ id: "terminal", component: () => null, persistent: true });
registerTabType({
id: "fleet-inventory",
component: () => null,
singleton: true,
hostless: true,
});
registerTabType({
id: "web-endpoint",
component: () => null,
inLayouts: false,
});
});
afterEach(() => {
vi.unstubAllGlobals();
resetTabTypes();
});
function makeHost(overrides: Partial<Host> = {}): Host {
return {
id: "1",
name: "web-01",
username: "root",
ip: "10.0.0.1",
port: 22,
folder: "",
online: true,
cpu: null,
ram: null,
lastAccess: "2026-01-01T00:00:00.000Z",
authType: "password",
enableTerminal: true,
enableCommandHistory: true,
syncId: "sync-web-01",
...overrides,
} as Host;
}
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: "tab-1",
instanceId: "instance-1",
type: "terminal",
label: "web-01",
openedAt: 0,
...overrides,
} as Tab;
}
describe("buildWorkspaceTabSnapshots", () => {
it("falls back when randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2" })];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(2);
expect(snapshots[0].slotId).toBeTruthy();
expect(snapshots[1].slotId).not.toBe(snapshots[0].slotId);
});
it("captures host-bound and singleton tabs with generated slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host, label: "web-01" }),
makeTab({ id: "t2", type: "tunnel", label: "Tunnels" }),
];
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
tabs,
genSlotId,
);
expect(snapshots).toHaveLength(2);
expect(snapshots[0]).toMatchObject({
slotId: "slot-0",
type: "terminal",
hostSyncId: "sync-web-01",
hostNameSnapshot: "web-01",
});
expect(snapshots[1]).toMatchObject({
slotId: "slot-1",
type: "tunnel",
hostSyncId: null,
});
expect(slotIdByTabId.get("t1")).toBe("slot-0");
expect(slotIdByTabId.get("t2")).toBe("slot-1");
});
it("excludes rail-view pseudo tabs (host-manager, user-profile, admin-settings)", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "host-manager" }),
makeTab({ id: "t2", type: "user-profile" }),
makeTab({ id: "t3", type: "admin-settings" }),
makeTab({ id: "t4", type: "tunnel" }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("tunnel");
});
it("excludes the dashboard tab - it's a permanent fallback, not a meaningful part of a saved arrangement", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "dashboard" }),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("terminal");
});
it("captures serialConfig only for serial tabs", () => {
const tabs: Tab[] = [
makeTab({
id: "t1",
type: "serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
}),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots[0].serialConfig).toEqual({
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
});
expect(snapshots[0].hostSyncId).toBeNull();
expect(snapshots[1].serialConfig).toBeUndefined();
});
});
describe("remapSlotIds", () => {
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
const map = new Map([
["slot-a", "new-tab-1"],
["slot-b", "new-tab-2"],
]);
const result = remapSlotIds(
["slot-a", null, "slot-b", "slot-missing"],
map,
);
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
});
});
describe("resolveWorkspaceTabTarget", () => {
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
it("resolves a serial snapshot with a config as 'serial'", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "serial",
});
});
it("skips a serial snapshot missing its config", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a host-bound snapshot by matching syncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "web-01",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("host");
expect((result as { host: Host }).host.syncId).toBe("sync-web-01");
});
it("skips a host-bound snapshot whose host no longer exists", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "deleted-host",
hostSyncId: "sync-gone",
hostNameSnapshot: "deleted-host",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a hostless singleton type without a hostSyncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "tunnel",
label: "Tunnels",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "singleton",
host: undefined,
});
});
it("resolves a singleton type WITH a resolved host as singleton+host (e.g. fleet-inventory)", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "fleet-inventory",
label: "Fleet",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("singleton");
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
});
it("skips a non-singleton, non-serial type with no resolvable host", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "files",
label: "files",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
});
describe("buildWorkspacePayload", () => {
it("builds a full payload including pane assignment remapped to slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host }),
makeTab({ id: "t2", type: "files", host }),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "t1",
splitMode: "2-way",
paneTabIds: ["t1", "t2", null, null, null, null],
rowSizes: [100],
rowColSizes: [[50, 50]],
genSlotId,
});
expect(payload.version).toBe(1);
expect(payload.activeSlotId).toBe("slot-0");
expect(payload.paneTabIds).toEqual([
"slot-0",
"slot-1",
null,
null,
null,
null,
]);
expect(payload.splitMode).toBe("2-way");
expect(payload.rowSizes).toEqual([100]);
expect(payload.rowColSizes).toEqual([[50, 50]]);
});
it("sets activeSlotId to null when the active tab is not capturable", () => {
const payload = buildWorkspacePayload({
tabs: [makeTab({ id: "t1", type: "host-manager" })],
activeTabId: "t1",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.activeSlotId).toBeNull();
expect(payload.tabs).toHaveLength(0);
});
it("round-trips the sidebar arrangement", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
sidebar: {
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
},
});
expect(payload.sidebar).toEqual({
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
});
});
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.sidebar).toBeUndefined();
expect(payload.version).toBe(1);
});
});
describe("plugin tab types in layouts", () => {
it("leaves out a tab type that opts out of layouts", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "w", type: "web-endpoint" }),
makeTab({ id: "t", type: "terminal" }),
]);
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
});
it("leaves out a plugin tab type nobody registered", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "x", type: "some-plugin-tab" }),
]);
expect(snapshots).toEqual([]);
});
it("carries a tab's plugin data and reads the older fleetId field", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "f", type: "fleet-inventory", data: { fleetId: 3 } }),
]);
expect(snapshotData(snapshots[0])).toEqual({ fleetId: 3 });
expect(
snapshotData({
slotId: "s",
type: "fleet-inventory",
label: "",
fleetId: 7,
}),
).toEqual({ fleetId: 7 });
});
});
@@ -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");
});
});