mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user