mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -1,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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
HIDEABLE_RAIL_IDS,
|
||||
PROMOTABLE_IDS,
|
||||
hideableRailIds,
|
||||
promotableIds,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
rightDockableIds,
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/src/frontend/workspaceUtils";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import { isCapturableTabType } from "@/shell/shell-layout";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
afterEach(() => resetRegisteredRailItems());
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
@@ -56,21 +60,17 @@ describe("RAIL_ITEMS", () => {
|
||||
"sftp",
|
||||
"snippets",
|
||||
"macros",
|
||||
"fleets",
|
||||
"tailscale",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"split-screen",
|
||||
"workspaces",
|
||||
"local-terminal",
|
||||
"network_graph",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes every rail item as hideable", () => {
|
||||
expect(HIDEABLE_RAIL_IDS).toEqual(RAIL_ITEMS.map((item) => item.id));
|
||||
it("exposes every visible rail item as hideable", () => {
|
||||
expect(hideableRailIds()).toEqual(
|
||||
visibleRailItems().map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks exactly the four mobile primary slots", () => {
|
||||
@@ -89,17 +89,15 @@ describe("RAIL_ITEMS", () => {
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives PROMOTABLE_IDS from the promotable flag", () => {
|
||||
it("derives promotableIds from the promotable flag", () => {
|
||||
// The header button and the hint both gate on this list, so a drift here
|
||||
// silently hides the feature for that panel.
|
||||
expect(PROMOTABLE_IDS).toEqual(
|
||||
expect(promotableIds()).toEqual(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id),
|
||||
@@ -112,36 +110,88 @@ describe("RAIL_ITEMS", () => {
|
||||
for (const item of [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]) {
|
||||
if (!item.promotable) continue;
|
||||
expect(
|
||||
WORKSPACE_CAPTURABLE_TYPES,
|
||||
isCapturableTabType(item.id),
|
||||
`${item.id} is promotable but not workspace-capturable`,
|
||||
).toContain(item.id as TabType);
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the mounted-but-hidden panels out of the right dock", () => {
|
||||
// Hosts, credentials and fleets stay mounted while hidden and share editing
|
||||
// state, so a second live instance in the right dock would fight the first.
|
||||
for (const id of ["hosts", "credentials", "fleets"]) {
|
||||
for (const id of ["hosts", "credentials"]) {
|
||||
expect(
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
rightDockableIds(),
|
||||
`${id} must not be right-dockable`,
|
||||
).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("only offers reference panels in the right dock", () => {
|
||||
expect(RIGHT_DOCKABLE_IDS).toEqual([
|
||||
expect(rightDockableIds()).toEqual([
|
||||
"connections",
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registered rail items", () => {
|
||||
const item = (id: string, extra: object = {}) => ({
|
||||
id,
|
||||
icon: Boxes,
|
||||
labelKey: `nav.${id}`,
|
||||
pluginId: "p",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("places items after their anchor, keeping their own order", () => {
|
||||
registerRailItem(item("second", { after: "macros", order: 2 }));
|
||||
registerRailItem(item("first", { after: "macros", order: 1 }));
|
||||
const ids = visibleRailItems().map((entry) => entry.id);
|
||||
const at = ids.indexOf("macros");
|
||||
expect(ids.slice(at + 1, at + 3)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("goes to the end when its anchor does not exist", () => {
|
||||
registerRailItem(item("orphan", { after: "nowhere" }));
|
||||
expect(visibleRailItems().at(-1)?.id).toBe("orphan");
|
||||
});
|
||||
|
||||
it("feeds its flags into the promotable, dock and hideable lists", () => {
|
||||
registerRailItem(item("docked", { promotable: true, rightDockable: true }));
|
||||
registerRailItem(item("pinned", { hideable: false }));
|
||||
expect(promotableIds()).toContain("docked");
|
||||
expect(rightDockableIds()).toContain("docked");
|
||||
expect(hideableRailIds()).toContain("docked");
|
||||
expect(hideableRailIds()).not.toContain("pinned");
|
||||
});
|
||||
|
||||
it("drops a hidden item everywhere without unregistering it", () => {
|
||||
registerRailItem(item("off", { hidden: true, promotable: true }));
|
||||
expect(visibleRailItems().map((entry) => entry.id)).not.toContain("off");
|
||||
expect(hideableRailIds()).not.toContain("off");
|
||||
expect(promotableIds()).not.toContain("off");
|
||||
});
|
||||
|
||||
it("labels a registered item by its own key", () => {
|
||||
registerRailItem(item("labelled"));
|
||||
expect(railItemLabel("labelled", (key) => `t:${key}`)).toBe(
|
||||
"t:nav.labelled",
|
||||
);
|
||||
});
|
||||
|
||||
it("disposes only the entry it registered", () => {
|
||||
const dispose = registerRailItem(item("same", { order: 1 }));
|
||||
registerRailItem(item("same", { order: 2 }));
|
||||
dispose();
|
||||
expect(visibleRailItems().map((entry) => entry.id)).toContain("same");
|
||||
});
|
||||
});
|
||||
|
||||
describe("railItemLabel", () => {
|
||||
it("translates known destinations", () => {
|
||||
expect(railItemLabel("hosts", () => "Hosts")).toBe("Hosts");
|
||||
|
||||
@@ -141,9 +141,7 @@ describe("role permission groups", () => {
|
||||
it("shows a plugin permission's title beside its raw id", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(
|
||||
screen.getByText("plugins.automations.permissions.run.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("automations:permissions.run.title")).toBeTruthy();
|
||||
expect(screen.getByText("automations.run")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { registerHostAction } from "@/sidebar/host-contributions";
|
||||
import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
|
||||
|
||||
const { markTabSurfaceUsedMock, preloadTabSurfaceMock } = vi.hoisted(() => ({
|
||||
@@ -154,12 +155,26 @@ describe("HostItem density parity", () => {
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes RDP, VNC, and Telnet quick-launch buttons in %s density",
|
||||
"exposes plugin connect actions as quick-launch buttons in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("hosts.connectRdp")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectVnc")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectTelnet")).toBeTruthy();
|
||||
const Icon = (() => null) as never;
|
||||
const disposers = ["desk", "screen"].map((id) =>
|
||||
registerHostAction({
|
||||
id,
|
||||
titleKey: `fixture.${id}`,
|
||||
icon: Icon,
|
||||
kind: "connect",
|
||||
tabType: id,
|
||||
when: () => true,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("fixture.desk")).toBeTruthy();
|
||||
expect(screen.getByTitle("fixture.screen")).toBeTruthy();
|
||||
} finally {
|
||||
disposers.forEach((dispose) => dispose());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function host(overrides: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "10.0.0.5",
|
||||
name: "nas",
|
||||
enableSsh: false,
|
||||
enableWebUi: false,
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
const webActions = (h: Host) =>
|
||||
getSshActions(h).filter((action) => action.type === "web-endpoint");
|
||||
|
||||
describe("the sidebar Web UI entry", () => {
|
||||
it("is absent when the feature is off, even with endpoints configured", () => {
|
||||
expect(
|
||||
webActions(
|
||||
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is absent when enabled but no endpoints exist", () => {
|
||||
expect(
|
||||
webActions(host({ enableWebUi: true, webUiConfig: { endpoints: [] } })),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appears without SSH, unlike every other entry", () => {
|
||||
// A direct endpoint needs no SSH at all; SSH matters only per-endpoint,
|
||||
// for tunnel access, which the open route enforces.
|
||||
const actions = getSshActions(
|
||||
host({
|
||||
enableSsh: false,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
}),
|
||||
);
|
||||
expect(actions.filter((a) => a.type === "web-endpoint")).toHaveLength(1);
|
||||
expect(actions.filter((a) => a.type === "terminal")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is ONE entry carrying the label and id when there is a single endpoint", () => {
|
||||
const [action] = webActions(
|
||||
host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
|
||||
}),
|
||||
);
|
||||
expect(action.label).toBe("Proxmox");
|
||||
expect(action.endpointId).toBe("e1");
|
||||
});
|
||||
|
||||
it("is still ONE entry, with no id, when there are several", () => {
|
||||
// A host may declare up to 16; a row of 16 identical globes is unusable.
|
||||
// No endpointId is what makes the click surface a picker instead.
|
||||
const endpoints = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
||||
);
|
||||
const actions = webActions(
|
||||
host({ enableWebUi: true, webUiConfig: { endpoints } }),
|
||||
);
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0].endpointId).toBeUndefined();
|
||||
expect(actions[0].label).toBe("Web UI");
|
||||
});
|
||||
|
||||
it("comes last, after the SSH connection actions", () => {
|
||||
const actions = getSshActions(
|
||||
host({
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
} as Partial<Host>),
|
||||
);
|
||||
expect(actions[actions.length - 1].type).toBe("web-endpoint");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user