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

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

@@ -0,0 +1,70 @@
/**
* A bundle built by the termix-plugin CLI, loaded the way the shell loads it,
* renders hooks against the shell's own React.
*
* The CLI leaves react, react/jsx-runtime and the SDK as bare imports; in the
* browser the import map resolves them to the shell's copies. Here the test
* runner resolves them to the same modules the test itself uses, so a hook
* call only works if the bundle really did leave React out. A bundled second
* React fails with "Invalid hook call".
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
renderWithApp,
type FrontendPluginModule,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { resetPanels } from "@/shell/panel-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
const ROOT = path.resolve(__dirname, "../../../..");
const FIXTURE = path.join(__dirname, "fixtures", "hook-plugin");
// Inside the repo, so the built bundle's bare imports resolve to its packages.
const WORK = path.join(ROOT, "node_modules", ".tmp", "cli-bundle-test");
const CLI = path.join(ROOT, "packages", "plugin-sdk", "cli", "index.mjs");
let bundle = "";
let rendered: RenderedPluginApp | null = null;
beforeAll(() => {
fs.rmSync(WORK, { recursive: true, force: true });
fs.mkdirSync(path.dirname(WORK), { recursive: true });
fs.cpSync(FIXTURE, WORK, { recursive: true });
execFileSync(process.execPath, [CLI, "build"], { cwd: WORK, stdio: "pipe" });
bundle = fs.readFileSync(path.join(WORK, "dist", "frontend.js"), "utf8");
}, 60_000);
afterAll(async () => {
await rendered?.deactivate();
resetPanels();
resetPluginStore();
fs.rmSync(WORK, { recursive: true, force: true });
});
describe("a CLI-built plugin bundle", () => {
it("leaves React and the SDK out of the bundle", () => {
expect(bundle).toMatch(/from\s*"react"/);
expect(bundle).toMatch(/from\s*"@termix\/plugin-sdk\/frontend"/);
expect(bundle).not.toMatch(/react\.production|react\.development/);
});
it("renders hooks against the shell's React", async () => {
const module = (await import(
/* @vite-ignore */ path.join(WORK, "dist", "frontend.js")
)) as FrontendPluginModule;
rendered = await renderWithApp(module, {
manifest: JSON.parse(
fs.readFileSync(path.join(WORK, "manifest.json"), "utf8"),
),
locales: { title: "Clicks" },
});
const button = rendered.renderPanel("hook-panel").querySelector("button")!;
expect(button.textContent).toBe("Clicks: 0");
fireEvent.click(button);
expect(button.textContent).toBe("Clicks: 1");
});
});
@@ -0,0 +1,14 @@
{
"id": "hook-fixture",
"name": "Hook fixture",
"version": "1.0.0",
"description": "Built by the CLI in a test to prove a bundle shares the shell's React.",
"author": { "name": "Termix" },
"license": "MIT",
"category": "Productivity",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": ["ui:surface"],
"contributes": {
"panels": [{ "id": "hook-panel", "titleKey": "title" }]
}
}
@@ -0,0 +1 @@
export function activate(): void {}
@@ -0,0 +1,16 @@
import { useState } from "react";
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
function Counter() {
const { t } = useTranslation();
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{t("title")}: {count}
</button>
);
}
export function activate(app: TermixApp): void {
app.registerPanel("hook-panel", Counter);
}
+263
View File
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Puzzle } from "lucide-react";
import type { FrontendModule, TermixApp } from "@termix/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
import {
configurePluginLoader,
isPluginFrontendActive,
orderForActivation,
resetPluginLoader,
syncPlugins,
} from "@/plugin-host/loader";
import {
getPluginRecord,
getPluginStoreState,
resetPluginStore,
} from "@/plugin-host/plugin-store";
import {
resetRegisteredRailItems,
visibleRailItems,
} from "@/sidebar/rail-items";
import { getTabType, resetTabTypes } from "@/shell/tab-registry";
function summary(
id: string,
overrides: Partial<PluginSummary> = {},
): PluginSummary {
return {
id,
name: id,
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
assetVersion: "v1",
contributes: { panels: [{ id: `${id}-panel`, titleKey: "nav.hosts" }] },
...overrides,
};
}
/** A plugin that registers a rail item named after itself. */
function railPlugin(id: string, calls: string[] = []): FrontendModule {
return {
activate(app: TermixApp) {
calls.push(`activate:${id}`);
app.registerRailItem({
id: `${id}-panel`,
icon: Puzzle,
titleKey: "nav.hosts",
});
},
deactivate() {
calls.push(`deactivate:${id}`);
},
};
}
function railIds(): string[] {
return visibleRailItems().map((item) => item.id);
}
let plugins: PluginSummary[] = [];
let modules: Record<string, FrontendModule> = {};
beforeEach(() => {
plugins = [];
modules = {};
configurePluginLoader({
fetchPlugins: async () => plugins,
importFrontend: async (entry) => {
const module = modules[entry.id];
if (!module) throw new Error(`no module for ${entry.id}`);
return module;
},
loadLocale: async () => null,
injectCss: () => null,
});
});
afterEach(async () => {
await resetPluginLoader();
resetPluginStore();
resetRegisteredRailItems();
resetTabTypes();
vi.restoreAllMocks();
});
describe("plugin loader", () => {
it("activates an enabled plugin and deactivates it when disabled, without a reload", async () => {
const calls: string[] = [];
modules.alpha = railPlugin("alpha", calls);
plugins = [summary("alpha")];
await syncPlugins();
expect(railIds()).toContain("alpha-panel");
expect(isPluginFrontendActive("alpha")).toBe(true);
plugins = [summary("alpha", { enabled: false })];
await syncPlugins();
expect(railIds()).not.toContain("alpha-panel");
expect(calls).toEqual(["activate:alpha", "deactivate:alpha"]);
expect(getPluginRecord("alpha")?.frontend).toBe("inactive");
});
it("re-activates the same module after disable then enable", async () => {
const calls: string[] = [];
modules.alpha = railPlugin("alpha", calls);
plugins = [summary("alpha")];
await syncPlugins();
plugins = [summary("alpha", { enabled: false })];
await syncPlugins();
plugins = [summary("alpha")];
await syncPlugins();
expect(railIds()).toContain("alpha-panel");
expect(calls).toEqual([
"activate:alpha",
"deactivate:alpha",
"activate:alpha",
]);
});
it("disposes registrations even when deactivate throws", async () => {
modules.alpha = {
activate: railPlugin("alpha").activate,
deactivate: () => {
throw new Error("boom");
},
};
vi.spyOn(console, "error").mockImplementation(() => {});
plugins = [summary("alpha")];
await syncPlugins();
plugins = [];
await syncPlugins();
expect(railIds()).not.toContain("alpha-panel");
});
it("isolates a plugin whose activate throws", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
modules.broken = {
activate(app) {
app.registerRailItem({
id: "broken-panel",
icon: Puzzle,
titleKey: "nav.hosts",
});
throw new Error("activate failed");
},
};
modules.healthy = railPlugin("healthy");
plugins = [summary("broken"), summary("healthy")];
await syncPlugins();
expect(getPluginRecord("broken")?.frontend).toBe("failed");
expect(getPluginRecord("broken")?.error).toBe("activate failed");
// What it registered before throwing is cleaned up.
expect(railIds()).not.toContain("broken-panel");
expect(railIds()).toContain("healthy-panel");
});
it("marks a plugin failed when its bundle cannot be imported", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
plugins = [summary("missing")];
await syncPlugins();
expect(getPluginRecord("missing")?.frontend).toBe("failed");
});
it("does not retry the same broken bundle, but does a new one", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const activate = vi.fn(() => {
throw new Error("nope");
});
modules.flaky = { activate };
plugins = [summary("flaky")];
await syncPlugins();
await syncPlugins();
expect(activate).toHaveBeenCalledTimes(1);
modules.flaky = railPlugin("flaky");
plugins = [summary("flaky", { assetVersion: "v2" })];
await syncPlugins();
expect(getPluginRecord("flaky")?.frontend).toBe("active");
});
it("refuses to register a view the manifest does not declare", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
modules.sneaky = {
activate(app) {
app.registerTab("undeclared", () => null);
},
};
plugins = [summary("sneaky")];
await syncPlugins();
expect(getTabType("undeclared")).toBeUndefined();
expect(getPluginRecord("sneaky")?.error).toMatch(/not declared/);
});
it("skips server-failed and frontend-less plugins", async () => {
modules.down = railPlugin("down");
modules.headless = railPlugin("headless");
plugins = [
summary("down", { state: "failed" }),
summary("headless", { frontend: false }),
];
await syncPlugins();
expect(railIds()).not.toContain("down-panel");
expect(railIds()).not.toContain("headless-panel");
});
it("settles even when the plugin list cannot be fetched", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
configurePluginLoader({
fetchPlugins: async () => {
throw new Error("offline");
},
});
await syncPlugins();
expect(getPluginStoreState().settled).toBe(true);
});
});
describe("activation order", () => {
it("activates a dependency before its dependent", async () => {
const calls: string[] = [];
modules.child = railPlugin("child", calls);
modules.parent = railPlugin("parent", calls);
plugins = [
summary("child", { dependencies: { parent: "^1.0.0" } }),
summary("parent"),
];
await syncPlugins();
expect(calls).toEqual(["activate:parent", "activate:child"]);
});
it("blocks a plugin whose hard dependency is off", async () => {
modules.child = railPlugin("child");
plugins = [
summary("child", { dependencies: { parent: "^1.0.0" } }),
summary("parent", { enabled: false }),
];
await syncPlugins();
expect(getPluginRecord("child")?.frontend).toBe("blocked");
expect(railIds()).not.toContain("child-panel");
});
it("runs without an optional dependency", () => {
const { order, blocked } = orderForActivation([
summary("child", { optionalDependencies: { gone: "^1.0.0" } }),
]);
expect(order.map((entry) => entry.id)).toEqual(["child"]);
expect(blocked.size).toBe(0);
});
it("blocks both sides of a dependency cycle", () => {
const { order, blocked } = orderForActivation([
summary("a", { dependencies: { b: "*" } }),
summary("b", { dependencies: { a: "*" } }),
]);
expect(order).toEqual([]);
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
});
});
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Puzzle } from "lucide-react";
import {
usePermission,
useTranslation,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { NavigationVisibilityToggles } from "@/sidebar/NavigationVisibilityToggles";
import {
hostActionsFor,
listHostActions,
defaultConnectAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import {
makeHostSshSubTabs,
makeHostTabs,
resetHostEditorSections,
} from "@/sidebar/HostManagerTabs";
import {
listPaletteEntries,
paletteEntriesFor,
resetPaletteEntries,
} from "@/shell/palette-registry";
import { shell } from "@/plugin-host/shell-bridge";
import { resetRegisteredRailItems } from "@/sidebar/rail-items";
import { resetTabTypes } from "@/shell/tab-registry";
import { resetPanels } from "@/shell/panel-registry";
import { resetDashboardCards } from "@/dashboard/dashboard-cards-registry";
import { resetSettingsComponents } from "@/settings/settings-components";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import type { Host } from "@/types/ui-types";
const MANIFEST: Partial<PluginManifest> = {
id: "fixture",
name: "Fixture",
contributes: {
tabs: [
{
id: "fixture-tab",
titleKey: "title",
icon: "Puzzle",
openFrom: ["rail"],
},
],
panels: [{ id: "fixture-panel", titleKey: "title" }],
dashboardCards: [{ id: "fixture-card", titleKey: "title" }],
permissions: [{ name: "use", titleKey: "title", descriptionKey: "title" }],
},
};
const LOCALES = { title: "Fixture title", greeting: "Hello from fixture" };
function Counter() {
const { t } = useTranslation();
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{t("greeting")} {count}
</button>
);
}
function PermissionProbe() {
return <span>{usePermission("use") ? "allowed" : "denied"}</span>;
}
function activate(app: TermixApp) {
app.registerRailItem({
id: "fixture-panel",
icon: Puzzle,
titleKey: "title",
});
app.registerPanel("fixture-panel", () => <Counter />);
app.registerTab("fixture-tab", ({ host }) => (
<span>tab for {host?.name ?? "nobody"}</span>
));
app.registerHostEditorSection({
id: "fixture-section",
group: "ssh",
titleKey: "title",
order: 15,
component: ({ setField }) => (
<button type="button" onClick={() => setField("fixture", true)}>
section
</button>
),
});
app.registerHostAction({
id: "fixture-connect",
titleKey: "title",
icon: Puzzle,
kind: "connect",
priority: 500,
tabType: "fixture-tab",
when: (host) => host.enableFixture === true,
});
app.registerPaletteEntry({
id: "fixture-entry",
titleKey: "title",
scope: "host",
when: (host) => host?.enableFixture === true,
run: (shell, host) => shell.openTab(host ?? null, "fixture-tab"),
});
app.registerDashboardCard({
id: "fixture-card",
titleKey: "title",
component: ({ shell }) => (
<button
type="button"
onClick={() => shell.openSingletonTab("fixture-tab")}
>
card
</button>
),
});
app.registerSettingsComponent("probe", PermissionProbe);
}
let rendered: RenderedPluginApp | null = null;
async function mount(permissions: string[] = []) {
rendered = await renderWithApp(
{ activate },
{ manifest: MANIFEST, locales: LOCALES, permissions },
);
return rendered;
}
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
resetRegisteredRailItems();
resetTabTypes();
resetPanels();
resetDashboardCards();
resetSettingsComponents();
resetActionRegistry();
resetHostContributions();
resetHostEditorSections();
resetPaletteEntries();
resetPluginStore();
});
const host = (overrides: Partial<Host> = {}) =>
({ id: "1", name: "web-01", ip: "10.0.0.1", port: 22, ...overrides }) as Host;
describe("registries through the app object", () => {
it("records everything the plugin registered", async () => {
const app = await mount();
expect(app.registered.railItems()).toEqual([
{ id: "fixture-panel", hidden: undefined },
]);
expect(app.registered.tabs()).toEqual(["fixture-tab"]);
expect(app.registered.panels()).toEqual(["fixture-panel"]);
expect(app.registered.hostEditorSections()).toEqual(["fixture-section"]);
expect(app.registered.hostActions()).toEqual([
{ id: "fixture-connect", tabType: "fixture-tab" },
]);
expect(app.registered.dashboardCards()).toEqual(["fixture-card"]);
expect(app.registered.settingsComponents()).toEqual(["probe"]);
});
it("removes every registration on deactivate", async () => {
const app = await mount();
await app.deactivate();
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
rendered = null;
});
it("renders a panel with hooks bound to the plugin", async () => {
const app = await mount();
const container = app.renderPanel("fixture-panel");
const button = container.querySelector("button")!;
expect(button.textContent).toBe("Hello from fixture 0");
fireEvent.click(button);
expect(button.textContent).toBe("Hello from fixture 1");
});
it("renders a tab with the generic tab props", async () => {
const app = await mount();
const container = app.renderTab("fixture-tab", { host: host() });
expect(container.textContent).toBe("tab for web-01");
});
it("lists a rail item in the Navigation visibility toggles and toggles it", async () => {
await mount();
const onChange = vi.fn();
render(
<NavigationVisibilityToggles hidden={new Set()} onChange={onChange} />,
);
const row = screen.getByTestId("nav-toggle-fixture-panel");
expect(row.textContent).toContain("Fixture title");
fireEvent.click(row.querySelector("button, [role=switch]")!);
expect(onChange).toHaveBeenCalledWith(new Set(["fixture-panel"]));
});
it("drops the toggle when the plugin goes away", async () => {
const app = await mount();
const { rerender } = render(
<NavigationVisibilityToggles hidden={new Set()} onChange={() => {}} />,
);
expect(screen.queryByTestId("nav-toggle-fixture-panel")).not.toBeNull();
await app.deactivate();
rendered = null;
rerender(
<NavigationVisibilityToggles hidden={new Set()} onChange={() => {}} />,
);
expect(screen.queryByTestId("nav-toggle-fixture-panel")).toBeNull();
});
it("adds a host editor section to the SSH group in order", async () => {
await mount();
const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
expect(ids.slice(0, 3)).toEqual(["ssh", "terminal", "fixture-section"]);
expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain(
"fixture-section",
);
});
it("renders a host editor section with the editor's setters", async () => {
const app = await mount();
const setField = vi.fn();
const container = app.renderHostEditorSection("fixture-section", {
setField,
});
fireEvent.click(container.querySelector("button")!);
expect(setField).toHaveBeenCalledWith("fixture", true);
});
it("offers a host action only where it applies, and picks it as the default connect", async () => {
await mount();
const on = host({ enableFixture: true } as Partial<Host>);
const off = host();
expect(hostActionsFor(listHostActions(), on).map((a) => a.id)).toEqual([
"fixture-connect",
]);
expect(hostActionsFor(listHostActions(), off)).toEqual([]);
expect(defaultConnectAction(listHostActions(), on)?.id).toBe(
"fixture-connect",
);
expect(resolveHostTabType(on)).toBe("fixture-tab");
});
it("offers a per-host palette entry that runs against the shell", async () => {
const app = await mount();
const target = host({ enableFixture: true } as Partial<Host>);
const entries = paletteEntriesFor(listPaletteEntries(), "host", target);
expect(entries.map((entry) => entry.id)).toEqual(["fixture-entry"]);
expect(paletteEntriesFor(listPaletteEntries(), "host", host())).toEqual([]);
entries[0].run(shell, target);
expect(app.shellCalls).toEqual([
{ method: "openTab", args: [target, "fixture-tab"] },
]);
});
it("renders a dashboard card that can reach the shell", async () => {
const app = await mount();
const container = app.renderDashboardCard("fixture-card");
fireEvent.click(container.querySelector("button")!);
expect(app.shellCalls).toEqual([
{ method: "openSingletonTab", args: ["fixture-tab"] },
]);
});
it("renders a custom settings field from the registered component", async () => {
await mount(["fixture.use"]);
render(
<SettingsFieldRow
pluginId="fixture"
field={{ key: "probe", type: "custom", component: "probe" }}
values={{}}
setValue={() => {}}
running
/>,
);
await waitFor(() => expect(screen.getByText("allowed")).toBeTruthy());
});
it("resolves a short permission name against the plugin's namespace", async () => {
const app = await mount([]);
const container = app.renderSettingsComponent("probe");
expect(container.textContent).toBe("denied");
});
});
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { ComponentSlot } from "@/shell/ActionSlot";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import { resetPanels } from "@/shell/panel-registry";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
import { pluginKey } from "@/lib/plugin-i18n";
const mounted: RenderedPluginApp[] = [];
afterEach(async () => {
while (mounted.length > 0) await mounted.pop()!.deactivate();
resetActionRegistry();
resetPanels();
resetPluginStore();
});
function Label() {
const { t } = useTranslation();
return <span>{t("label")}</span>;
}
describe("component slots", () => {
function contribute(permission?: string) {
return async (app: TermixApp) => {
if (permission) {
app.registerAction("slotty.panel", () => {}, { permission });
}
app.registerSlotContribution("owner.slot", {
actionId: "slotty.panel",
titleKey: "label",
kind: "component",
component: ({ who }: Record<string, unknown>) => (
<span>contributed for {String(who)}</span>
),
when: (context) => context.show !== false,
});
};
}
const manifest = {
id: "slotty",
contributes: {
permissions: [{ name: "use", titleKey: "l", descriptionKey: "l" }],
},
};
it("renders nothing when nobody contributed", () => {
setPermissionsForTesting([]);
const { container } = render(<ComponentSlot slotId="owner.slot" />);
expect(container.innerHTML).toBe("");
});
it("renders nothing when the user lacks the permission", async () => {
mounted.push(
await renderWithApp(
{ activate: contribute("use") },
{ manifest, permissions: [] },
),
);
const { container } = render(
<ComponentSlot slotId="owner.slot" props={{ who: "me" }} />,
);
expect(container.innerHTML).toBe("");
});
it("renders the contribution with the owner's props when permitted", async () => {
mounted.push(
await renderWithApp(
{ activate: contribute("use") },
{ manifest, permissions: ["slotty.use"] },
),
);
render(<ComponentSlot slotId="owner.slot" props={{ who: "me" }} />);
expect(await screen.findByText("contributed for me")).toBeTruthy();
});
it("honours a contribution's when", async () => {
mounted.push(await renderWithApp({ activate: contribute() }, { manifest }));
const { container } = render(
<ComponentSlot slotId="owner.slot" when={{ show: false }} />,
);
expect(container.innerHTML).toBe("");
});
});
describe("plugin translations", () => {
it("keeps two plugins' identical keys apart", async () => {
for (const [id, text] of [
["first", "From the first plugin"],
["second", "From the second plugin"],
] as const) {
const app = await renderWithApp(
{
activate(app) {
app.registerPanel(`${id}-panel`, Label);
},
},
{
manifest: {
id,
contributes: { panels: [{ id: `${id}-panel`, titleKey: "label" }] },
},
locales: { label: text },
},
);
mounted.push(app);
}
const [first, second] = mounted;
expect(first.renderPanel("first-panel").textContent).toBe(
"From the first plugin",
);
expect(second.renderPanel("second-panel").textContent).toBe(
"From the second plugin",
);
});
it("falls back to core strings for keys the plugin does not define", async () => {
mounted.push(
await renderWithApp(
{
activate(app) {
app.registerPanel("borrow-panel", () => {
const { t } = useTranslation();
return <span>{t("common.save")}</span>;
});
},
},
{
manifest: {
id: "borrow",
contributes: { panels: [{ id: "borrow-panel", titleKey: "x" }] },
},
locales: {},
},
),
);
expect(mounted[0].renderPanel("borrow-panel").textContent).toBe(
i18n.t("common.save"),
);
});
it("qualifies manifest keys with the plugin's namespace", () => {
expect(pluginKey("tailscale", "settings.apiKey.label")).toBe(
"tailscale:settings.apiKey.label",
);
expect(pluginKey("tailscale", "other:key")).toBe("other:key");
});
});
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
resetPluginStore,
setFrontendState,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
import {
unregisteredViewStatus,
viewOwner,
} from "@/plugin-host/view-ownership";
import { renderTabContent } from "@/shell/tabUtils";
import {
canRestoreTabType,
isPersistentTabType,
registerTabType,
resetTabTypes,
} from "@/shell/tab-registry";
import {
registerDashboardCard,
resetDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
import { PluginCardSlot } from "@/dashboard/DashboardTab";
import type { Host, Tab } from "@/types/ui-types";
const shell = {
openTab: () => {},
openSingletonTab: () => {},
closeTab: () => {},
renameTab: () => {},
openFileInEditor: () => {},
openFileManager: () => {},
openTerminalTab: () => {},
openRailView: () => {},
closeRailView: () => {},
};
function owner(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "gizmo",
name: "Gizmo",
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
contributes: {
tabs: [
{ id: "gizmo-tab", titleKey: "t", icon: "Box", openFrom: ["rail"] },
],
panels: [{ id: "gizmo-panel", titleKey: "t" }],
dashboardCards: [{ id: "gizmo-card", titleKey: "t" }],
},
...overrides,
};
}
const savedTab: Tab = {
id: "gizmo-1",
instanceId: "gizmo-1",
type: "gizmo-tab",
label: "Gizmo",
openedAt: 0,
host: { id: "1", name: "web-01" } as Host,
};
afterEach(() => {
resetPluginStore();
resetTabTypes();
resetDashboardCards();
});
describe("view ownership", () => {
it("finds the owner of a tab, panel or card from its manifest", () => {
setPluginSummaries([owner()]);
expect(viewOwner("tab", "gizmo-tab")?.summary.id).toBe("gizmo");
expect(viewOwner("panel", "gizmo-panel")?.summary.id).toBe("gizmo");
// A tab id doubles as a panel when a rail panel is promoted.
expect(viewOwner("panel", "gizmo-tab")?.summary.id).toBe("gizmo");
expect(viewOwner("card", "gizmo-card")?.summary.id).toBe("gizmo");
expect(viewOwner("tab", "someone-else")).toBeUndefined();
});
it("explains why a view is absent", () => {
setPluginSummaries([owner({ enabled: false })]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("disabled");
setPluginSummaries([owner({ state: "failed" })]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("failed");
setPluginSummaries([owner()]);
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("loading");
setFrontendState("gizmo", "active");
expect(unregisteredViewStatus("tab", "gizmo-tab").status).toBe("missing");
expect(unregisteredViewStatus("tab", "nobody").status).toBe("missing");
});
it("keeps a saved tab of a disabled plugin and restores it as a placeholder", () => {
setPluginSummaries([owner({ enabled: false })]);
expect(isPersistentTabType("gizmo-tab")).toBe(true);
expect(canRestoreTabType("gizmo-tab", savedTab.host)).toBe(true);
render(<>{renderTabContent(savedTab, { shell })}</>);
const placeholder = screen.getByTestId("plugin-view-placeholder");
expect(placeholder.dataset.status).toBe("disabled");
expect(placeholder.textContent).toContain("plugins.runtime.needsPlugin");
});
it("shows a not-installed placeholder for a tab nobody owns, without throwing", () => {
setPluginSummaries([]);
render(<>{renderTabContent({ ...savedTab, type: "gone" }, { shell })}</>);
expect(screen.getByTestId("plugin-view-placeholder").dataset.status).toBe(
"missing",
);
});
it("swaps the placeholder for the real tab once the plugin registers it", () => {
setPluginSummaries([owner()]);
registerTabType({
id: "gizmo-tab",
pluginId: "gizmo",
component: ({ host }) => <span>gizmo on {host?.name}</span>,
});
render(<>{renderTabContent(savedTab, { shell })}</>);
expect(screen.getByText("gizmo on web-01")).toBeTruthy();
});
it("keeps a dashboard card's slot while its plugin is off", () => {
setPluginSummaries([owner({ enabled: false })]);
const view = render(
<PluginCardSlot
id="gizmo-card"
isVisible
onOpenSingletonTab={() => {}}
/>,
);
expect(screen.getByTestId("plugin-view-placeholder").dataset.status).toBe(
"disabled",
);
act(() => {
setPluginSummaries([owner()]);
registerDashboardCard({
id: "gizmo-card",
pluginId: "gizmo",
titleKey: "t",
component: () => <span>gizmo card</span>,
});
});
view.rerender(
<PluginCardSlot
id="gizmo-card"
isVisible
onOpenSingletonTab={() => {}}
/>,
);
expect(screen.getByText("gizmo card")).toBeTruthy();
});
});