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

+266
View File
@@ -0,0 +1,266 @@
/**
* The Termix side of @termix/plugin-sdk/testing's renderWithApp.
*
* Activates a plugin against the real registries, the real app object and
* the real SDK bridge, then renders whatever it registered with Testing
* Library. Shell calls are recorded rather than performed, since there is no
* AppShell in a unit test.
*/
import type { ReactElement } from "react";
import { render } from "@testing-library/react";
import type { PluginManifest } from "@termix/plugin-sdk/frontend";
import type {
FrontendPluginModule,
RenderWithAppOptions,
RenderedPluginApp,
ShellCall,
} from "@termix/plugin-sdk/testing";
import i18n from "@/i18n/i18n";
import type { PluginSummary } from "@/api/plugins-api";
import { ThemeProvider } from "@/components/theme-provider";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import {
getTabType,
listTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { getPanel, listPanels } from "@/shell/panel-registry";
import { listRegisteredRailItems } from "@/sidebar/rail-items";
import { listHostActions } from "@/sidebar/host-contributions";
import {
getHostEditorSection,
hostEditorSectionList,
} from "@/sidebar/HostManagerTabs";
import {
getRegisteredDashboardCard,
registeredDashboardCardList,
} from "@/dashboard/dashboard-cards-registry";
import {
getSettingsComponent,
listSettingsComponents,
} from "@/settings/settings-components";
import { getSlotContributions, listActions } from "@/shell/action-registry";
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge } from "./bridge";
import { setPluginSummaries, setFrontendState } from "./plugin-store";
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args });
};
return {
openTab: record("openTab"),
openSingletonTab: record("openSingletonTab"),
closeTab: record("closeTab"),
renameTab: record("renameTab"),
openFileInEditor: record("openFileInEditor"),
openFileManager: record("openFileManager"),
openTerminalTab: record("openTerminalTab"),
openRailView: record("openRailView"),
closeRailView: record("closeRailView"),
saveQuickConnect: async (...args) => {
calls.push({ method: "saveQuickConnect", args });
},
};
}
function wrap(element: ReactElement): HTMLElement {
return render(
<ThemeProvider defaultTheme="dark" storageKey="termix-test-theme">
{element}
</ThemeProvider>,
).container;
}
function missing(kind: string, id: string): never {
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
}
export async function renderPlugin(
plugin: FrontendPluginModule,
options: RenderWithAppOptions = {},
): Promise<RenderedPluginApp> {
const pluginId = options.pluginId ?? options.manifest?.id ?? "test-plugin";
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
installPluginHostBridge();
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
setShellHosts((options.hosts ?? []) as unknown as Host[]);
const shellCalls: ShellCall[] = [];
const shell = recordingShell(shellCalls);
setShellCallbacks(shell);
const summary: PluginSummary = {
id: pluginId,
name: manifest.name,
version: manifest.version,
enabled: true,
state: "active",
contributes: (manifest.contributes ?? null) as PluginSummary["contributes"],
frontend: true,
};
setPluginSummaries([summary]);
if (options.locales) {
i18n.addResourceBundle(
"en",
pluginId,
options.locales as Record<string, unknown>,
true,
true,
);
}
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
await plugin.activate(handle.app);
setFrontendState(pluginId, "active");
const mine = <T extends { pluginId?: string }>(items: T[]) =>
items.filter((item) => item.pluginId === pluginId);
const tabRecord = (type: string, host?: Host): Tab => ({
id: `${type}-test`,
instanceId: `${type}-test`,
type,
label: type,
host,
openedAt: 0,
});
return {
app: handle.app,
shellCalls,
registered: {
railItems: () =>
mine(listRegisteredRailItems()).map((item) => ({
id: item.id,
hidden: item.hidden,
})),
tabs: () => mine(listTabTypes()).map((def) => def.id),
panels: () => mine(listPanels()).map((panel) => panel.id),
hostActions: () =>
mine(listHostActions()).map((action) => ({
id: action.id,
tabType: action.tabType,
})),
hostEditorSections: () =>
mine(hostEditorSectionList()).map((section) => section.id),
dashboardCards: () =>
mine(registeredDashboardCardList()).map((card) => card.id),
settingsComponents: () =>
listSettingsComponents()
.filter((key) => key.startsWith(`${pluginId}:`))
.map((key) => key.slice(pluginId.length + 1)),
slot: (slotId) =>
mine(getSlotContributions(slotId)).map(
(contribution) => contribution.actionId,
),
actions: () => mine(listActions()).map((action) => action.id),
},
renderTab(type, props = {}) {
const def = getTabType(type) ?? missing("a tab", type);
const Component = def.component;
const host = props.host as Host | undefined;
return wrap(
<Component
tab={tabRecord(type, host)}
host={host}
label={type}
isVisible
isFocusedPane
handleRef={null}
shell={shell}
{...props}
/>,
);
},
renderPanel(id, props = {}) {
const panel = getPanel(id) ?? missing("a panel", id);
const Component = panel.component;
return wrap(
<Component
active
shell={shell}
setEditing={() => {}}
placement="left"
{...props}
/>,
);
},
renderDashboardCard(id) {
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
const Component = card.component;
return wrap(<Component isVisible shell={shell} />);
},
renderHostEditorSection(id, props = {}) {
const section =
getHostEditorSection(id) ?? missing("a host editor section", id);
const Component = section.component;
return wrap(
<Component
form={{}}
setField={() => {}}
updateForm={() => {}}
protocols={{}}
{...props}
/>,
);
},
renderSettingsComponent(componentId, props = {}) {
const Component =
getSettingsComponent(pluginId, componentId) ??
missing("a settings component", componentId);
return wrap(
<Component
pluginId={pluginId}
values={{}}
setValue={() => {}}
running
{...props}
/>,
);
},
renderSlot(slotId, props = {}) {
return wrap(
<>
<ActionSlot slotId={slotId} />
<ComponentSlot slotId={slotId} props={props} />
</>,
);
},
async deactivate() {
try {
await plugin.deactivate?.();
} finally {
handle.dispose();
setFrontendState(pluginId, "inactive");
}
},
};
}