/* eslint-disable react-refresh/only-export-components */
/**
* The Termix side of @termix-ssh/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 {
HostActionContribution,
PluginManifest,
} from "@termix-ssh/plugin-sdk/frontend";
import type {
FrontendPluginModule,
RenderWithAppOptions,
RenderedPluginApp,
ShellCall,
} from "@termix-ssh/plugin-sdk/testing";
import i18n from "@/i18n/i18n";
import type { PluginSummary } from "@/api/plugins-api";
import { ThemeProvider } from "@/components/theme-provider";
import { SurfaceScope } from "@/components/surface/surface-scope";
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 { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
import { listHostProtocols } from "@/sidebar/host-protocols";
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 { listPaletteGroups, loadPaletteGroup } from "@/shell/palette-registry";
import {
listKeybindingActions,
listKeybindingDefaults,
} from "@/shell/keybinding-registry";
import {
getLoginMethodUI,
getSecondFactorUI,
listLoginMethodUIs,
listSecondFactorUIs,
} from "./auth-registry";
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
import { setRemoteServerUrlForTesting } from "./desktop";
import { setPluginSummaries, setFrontendState } from "./plugin-store";
import {
notifyShellReady,
resetShellBridge,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
} from "./shell-bridge";
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
import { getExtension, listExtensions } from "./extension-registry";
import { onboardingSteps } from "@/onboarding/onboarding-registry";
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args });
};
return {
openTab: record("openTab"),
openSingletonTab: record("openSingletonTab"),
connectHost: record("connectHost"),
closeTab: record("closeTab"),
renameTab: record("renameTab"),
openRailView: record("openRailView"),
closeRailView: record("closeRailView"),
openHostEditor: record("openHostEditor"),
saveQuickConnect: async (...args) => {
calls.push({ method: "saveQuickConnect", args });
},
};
}
function wrap(element: ReactElement): HTMLElement {
return render(
{element}
,
).container;
}
function missing(kind: string, id: string | number): never {
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
}
export async function renderPlugin(
plugin: FrontendPluginModule,
options: RenderWithAppOptions = {},
): Promise {
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();
resetShellBridge();
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
const hosts = (options.hosts ?? []) as unknown as Host[];
setShellHosts(hosts);
const shellCalls: ShellCall[] = [];
const shell = recordingShell(shellCalls);
setShellCallbacks(shell);
// Stands in for AppShell's layout: applying one runs the shell's own
// restore rules and records which tabs it would open.
let layout = options.layout ?? null;
const opened: Array<{ type: string; hostId?: number; label: string }> = [];
setShellLayoutProvider({
getLayout: () => layout,
applyLayout: async (next, applyOptions) => {
shellCalls.push({ method: "applyLayout", args: [next, applyOptions] });
layout = next;
opened.length = 0;
const skipped: string[] = [];
const tabs = (next.tabs ?? []) as WorkspaceTabSnapshot[];
for (const snapshot of tabs) {
const target = resolveLayoutTabTarget(snapshot, hosts);
if (target.kind === "skip") {
skipped.push(snapshot.hostNameSnapshot || snapshot.label);
continue;
}
opened.push({
type: snapshot.type,
hostId:
"host" in target && target.host
? Number(target.host.id)
: undefined,
label: snapshot.customLabel ?? snapshot.label,
});
}
return { skipped };
},
});
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,
true,
true,
);
}
setPluginApiForTesting(
pluginId,
(options.api as Parameters[1]) ?? null,
);
setRemoteServerUrlForTesting(options.remoteServerUrl ?? null);
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
await plugin.activate(handle.app);
setFrontendState(pluginId, "active");
if (options.ready) notifyShellReady();
const mine = (items: T[]) =>
items.filter((item) => item.pluginId === pluginId);
const unprefixed = (items: { id: string; pluginId?: string }[]) =>
mine(items).map((item) => item.id.slice(pluginId.length + 1));
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,
permission: item.permission,
})),
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,
})),
hostActionsFor: (host) =>
mine(
hostActionsFor(listHostActions(), host as Host),
) as unknown as HostActionContribution[],
hostProtocols: () =>
mine(listHostProtocols()).map((protocol) => protocol.id),
hostEditorSections: () =>
mine(hostEditorSectionList()).map((section) => section.id),
dashboardCards: () =>
mine(registeredDashboardCardList()).map((card) => card.id),
extensions: (pointId) =>
mine(listExtensions(pointId)).map((extension) => extension.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),
loginMethods: () => mine(listLoginMethodUIs()).map((method) => method.id),
secondFactors: () =>
mine(listSecondFactorUIs()).map((factor) => factor.id),
paletteGroups: () => mine(listPaletteGroups()).map((group) => group.id),
keybindingActions: () =>
mine(listKeybindingActions()).map((action) => action.id),
keybindingDefaults: () =>
mine(listKeybindingDefaults()).map((binding) => binding.id),
onboardingSteps: () => unprefixed(onboardingSteps.list()),
},
async loadPaletteGroup(id) {
const group =
listPaletteGroups().find((entry) => entry.id === id) ??
missing("a palette group", id);
return (await loadPaletteGroup(group)) as never;
},
renderTab(type, props = {}) {
const def = getTabType(type) ?? missing("a tab", type);
const Component = def.component;
const host = props.host as Host | undefined;
return wrap(
,
);
},
openedTabs: () => opened.map((tab) => ({ ...tab })),
renderOpenedTab(index) {
const tab = opened[index] ?? missing("an opened tab at index", index);
const def = getTabType(tab.type);
if (!def) {
// What tabUtils renders for a tab no running plugin registered.
return wrap();
}
const Component = def.component;
return wrap(
,
);
},
renderPanel(id, props = {}) {
const panel = getPanel(id) ?? missing("a panel", id);
const Component = panel.component;
return wrap(
{}}
placement="left"
{...props}
/>,
);
},
renderExtension(pointId, id, component = "view", props = {}) {
const extension = getExtension(pointId, id);
const Component =
extension?.pluginId === pluginId
? extension.components?.[component]
: undefined;
if (!Component) missing(`an extension "${component}" component`, id);
return wrap();
},
renderOnboardingStep(id, props = {}) {
const step =
onboardingSteps.get(`${pluginId}:${id}`) ??
missing("an onboarding step", id);
const Component = step.Component;
let canContinue = true;
let beforeNext: (() => boolean | Promise) | null = null;
const element = wrap(
{
canContinue = ok;
}}
setBeforeNext={(fn) => {
beforeNext = fn;
}}
{...props}
/>,
);
return {
element,
canContinue: () => canContinue,
next: async () => (beforeNext ? await beforeNext() : true),
};
},
renderDashboardCard(id) {
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
const Component = card.component;
return wrap();
},
renderHostEditorSection(id, props = {}) {
const section =
getHostEditorSection(id) ?? missing("a host editor section", id);
const Component = section.component;
return wrap(
{}}
updateForm={() => {}}
protocols={{}}
{...props}
/>,
);
},
renderSettingsComponent(componentId, props = {}) {
const Component =
getSettingsComponent(pluginId, componentId) ??
missing("a settings component", componentId);
return wrap(
{}}
running
{...props}
/>,
);
},
renderLoginMethod(id, props = {}) {
const method = getLoginMethodUI(id) ?? missing("a login method", id);
const Component = method.component;
return wrap(
{}}
startRedirect={async () => {}}
complete={async () => {}}
{...props}
/>,
);
},
renderSecondFactor(id, props = {}) {
const factor = getSecondFactorUI(id) ?? missing("a second factor", id);
const Component = factor.component;
return wrap(
{}}
challenge={async () => null}
cancel={() => {}}
{...props}
/>,
);
},
renderEnrollment(id) {
const entry = getSecondFactorUI(id) ?? getLoginMethodUI(id);
const Component =
entry?.enrollment ?? missing("an enrolment section", id);
return wrap();
},
renderSlot(slotId, props = {}) {
return wrap(
<>
>,
);
},
async deactivate() {
try {
await plugin.deactivate?.();
} finally {
handle.dispose();
setFrontendState(pluginId, "inactive");
setPluginApiForTesting(pluginId, null);
setRemoteServerUrlForTesting(null);
}
},
};
}
// Registries a shell-level plugin's own tests fill directly, since they
// render parts of the shell without activating a plugin.
export {
registerHostAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols";
export {
RAIL_ITEMS,
registerRailItem,
resetRegisteredRailItems,
} from "@/sidebar/rail-items";
export {
declareActionSlot,
registerAction,
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
export { trackTabHandle } from "@/shell/tab-handles";
export {
registerKeybindingAction,
registerKeybindingDefault,
resetKeybindingRegistry,
} from "@/shell/keybinding-registry";
export { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
export { HostPluginSections } from "@/settings/HostPluginSections";
export {
registerDashboardCard,
resetDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
export {
resetPluginStore,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
export { setPermissionsForTesting } from "@/hooks/use-permissions";
export { getRegisteredLayout } from "@/look/layout-registry";
export { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
export { ServerStatusProvider } from "@/lib/ServerStatusContext";
export { TabPortals } from "@/shell/stage/TabPortals";