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

+179 -21
View File
@@ -29,7 +29,8 @@ dependencies. Entry points:
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
@@ -271,11 +272,145 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
### 8. UI
A plugin frontend is a built ESM bundle loaded at runtime by the core loader.
It registers everything through the `app` object: rail items, panels, tabs,
host editor sections, host actions and badges, context menu items, palette
entries, dashboard cards, homepage widgets, settings components, action slots,
login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.**
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
no imports from `plugins/`; everything a plugin shows goes through the `app`
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
plugin id in `src/ui` outside tests, apart from the entries in
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host protocol
data like `rdp`, which Phase B moves).
#### The loader
`src/ui/plugin-host/loader.ts` runs after login and again whenever plugin
state may have changed: the `termix:plugins-changed` window event (the admin
toggle fires it) and window focus, throttled to 30 seconds. There is no push
channel yet. Each pass:
1. Fetches `GET /plugins` (`frontend`, `css`, `assetVersion`, `locales`,
`dependencies` and `contributes` per plugin).
2. Loads every plugin's locales into the i18next namespace `<id>`, enabled or
not, because the role editor and the plugin list need disabled plugins'
titles.
3. Orders enabled plugins by dependencies. A plugin whose hard dependency is
missing or off is skipped.
4. For each plugin with a frontend: injects `frontend.css`, imports
`/plugin-assets/<id>/frontend.js?v=<assetVersion>` and calls `activate(app)`.
In dev the workspace plugins are imported from
`plugins/<id>/src/frontend/index.tsx` through Vite, so HMR works.
5. Deactivates plugins that went away: runs `deactivate()` then every
disposer. No reload.
A plugin that throws while importing or activating is marked failed, its
partial registrations are disposed, and the rest of the app carries on. The
same `assetVersion` is not retried until it changes.
Anonymous shared-session and collab pages have no session. They load only
plugins whose manifest sets `contributes.guest: true`, from the public
`GET /plugins/public`, and `app.guest` is true there.
#### Shared modules and the import map
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
shared module, writes a stable unhashed shim for each at
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping
the bare names to those shims. In dev the map points at Vite's virtual
modules instead. The map is inline, so both nginx configs allow it by hash in
`script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the
hash is out of date and `--write` updates it. The map changes only when the
list of shared modules does, which includes the legacy-core modules plugins
import.
Electron loads `index.html` from `file://`. The map uses relative URLs, and
plugin bundles come from the embedded backend
(`http://localhost:30001/plugin-assets/...`), which is why `/plugin-assets`
sends `Access-Control-Allow-Origin: *`.
#### Serving bundles
`GET /plugin-assets/<id>/<path>` serves the directory holding a plugin's
frontend bundle and its `locales/` directory, nothing else: never
`backend.js`, never a path outside those two, only `.js`, `.css`, `.json` and
`.map`. It is unauthenticated like `/assets`, since `import()` cannot send the
bearer header and bundles are not secret. With `?v=` the response is cached
as immutable; without it, `no-cache`. nginx has a `^~ /plugin-assets/` block.
#### The app object
Every `register*` call returns a disposer and is also disposed automatically
when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense.
| Member | What it does |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Typed now, wired by A8 |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`. The SDK
has no runtime dependencies: the hooks delegate to a host bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
(declared by ssh-terminal), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
and `session.remoteDisplay`. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
#### View ownership
Who owns a tab, panel or card comes from manifests, so it is known even for a
disabled plugin whose code never loaded. A saved tab or dashboard card whose
plugin is disabled, failed, still loading or not installed renders "This
needs the <name> plugin" (`PluginViewPlaceholder`) and is never dropped from
the saved layout. Tab restore waits for the first plugin pass, so a restored
tab does not race its plugin.
#### The `ui` entry
`@termix/plugin-sdk/ui` is public API, implemented by
`src/ui/plugin-host/sdk-ui.ts`. Adding to it is a contract change; removing
from it is a breaking one. Today it exports: alert, alert-dialog, badge,
button, card, checkbox, dialog, dropdown-menu, input, label, password-input,
select, select2, separator, switch, textarea, tooltip, section-card,
metric-card, charts, the card grid, `ConnectionScreen` and the connection
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot` and `ComponentSlot`. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
#### Strings
A plugin's strings live in `plugins/<id>/locales/en.json` and load into the
`<id>` namespace, with core's `translation` as the fallback, so a plugin can
still use `common.*`. Manifest keys (`titleKey`, `labelKey`) resolve in the
plugin namespace; `pluginKey(id, key)` in core returns `<id>:<key>`.
Translations go to `plugins/<id>/locales/translated/<xx_YY>.json`, which is
where Crowdin writes them. Only `en.json` is edited by hand.
#### Type checking
`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests
with bundler resolution, and is part of `npm run type-check`.
### 9. Cross-plugin use
@@ -314,8 +449,12 @@ capability, registry, service, secret and shutdown suites.
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
capabilities the way the runtime does, `createFakeContext()` for tests that do
not care about the gates, and `renderWithApp()` for frontend tests (**A7**
finishes it).
not care about the gates, and `renderWithApp(plugin, options)` for frontend
tests. `renderWithApp` activates the plugin against core's real registries,
records what it registered and the shell calls it made, renders any tab,
panel, card, host editor section, settings component or slot, and
`deactivate()` disposes it all. Every bundled plugin has a
`tests/frontend/activate.test.tsx` built on it.
### 12. Auth
@@ -339,8 +478,8 @@ plugins/<id>/
tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json
vitest.config.ts one line, the SDK preset
src/backend/index.ts exports activate(ctx) and deactivate()
src/frontend/index.tsx exports activate(app) and deactivate() (A7)
locales/en.json English strings; Crowdin translates the rest
src/frontend/index.tsx exports activate(app) and deactivate()
locales/en.json English strings; Crowdin writes locales/translated/
migrations/{sqlite,postgres,mysql}/ owned tables, one .sql per dialect
migrations/snapshot.json diff basis for the generator
src/backend/tables.ts defineTable() definitions
@@ -545,17 +684,23 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
appear in the plugin's own `contributes.permissions`. A dotted id is taken as
given, because the validator cannot know which other plugins exist.
`contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey,
icon? }`) declare the views a plugin owns besides its tabs; the app object
refuses to register a view the manifest does not declare. `contributes.guest`
opts a plugin into anonymous guest pages. Action contributions and slots
accept the kinds `button` and `component`.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. A6 and A7 reshape them.
secret registries read them at activation. Phase B reshapes them.
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
by `contributes.permissions`. A plugin no longer picks its own group name.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
`backend`/`frontend`/`platforms` fields), `contributes.dashboardCards`,
`contributes.settingsPanel`, `contributes.apiPrefix`, and
`backend`/`frontend`/`platforms` fields), the v1 shape of
`contributes.dashboardCards`, `contributes.settingsPanel`, `contributes.apiPrefix`, and
`process:transport-owner` (the marker for the tier that no longer exists).
---
@@ -767,11 +912,12 @@ and that is bundled into its output.
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
`@anthropic-ai/sdk`, `drizzle-orm`.
**Frontend**: `react`, `react-dom`, `react/jsx-runtime`, `@termix/plugin-sdk`,
`i18next`, `react-i18next`, and the UI libraries core provides today
(`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`,
`guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these
through the SDK `ui` entry.
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the
import map (see UI). Everything else, including `lucide-react`, `axios`,
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
it.
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
@@ -787,6 +933,13 @@ and rewrites to the compiled output path (`../../../backend/backend/...`, or
so a source file at any nesting collapses to the same prefix. D1 deletes that
file.
Frontends do the same with `@/`: in the browser build, `@/x` and
`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or
`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared
module so plugin and shell use one instance of each core module. The CLI
refuses the rewrite outside a Termix checkout. Every such import is counted
in the `plugin-frontend-to-core` direction below.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
@@ -798,7 +951,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| ------------------------------------------------ | --------- | --------- | ---------- |
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin components | Warning | 16 files | A7 |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 109 files | D1 |
| A plugin importing core by relative path | Warning | 70 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
@@ -831,5 +985,9 @@ Known specifics:
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
a port any more (A4).
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
- The terminal component and `TerminalTabContent` stay in core until the
terminal's Phase B step (session manager, split view and file-manager
callbacks). ssh-terminal registers them through the legacy alias.
- Host data columns (`enableDocker`, `enableRdp`, `guacamoleConfig` and so on)
stay in core types until Phase B moves them. Only UI branches on them left
the shell.
+18 -1
View File
@@ -17,6 +17,17 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.js",
];
/** Whether the plugin sits in a Termix checkout, i.e. is a bundled plugin. */
function insideTermixCheckout(cwd) {
let dir = path.resolve(cwd);
for (;;) {
if (fs.existsSync(path.join(dir, "src", "ui", "plugin-host"))) return true;
const parent = path.dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
@@ -58,7 +69,13 @@ export async function build({ cwd }) {
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
plugins: [legacyCoreImports({ pluginId, platform: "browser" })],
plugins: [
legacyCoreImports({
pluginId,
platform: "browser",
insideTermix: insideTermixCheckout(cwd),
}),
],
});
}
+9 -12
View File
@@ -25,24 +25,21 @@ export const BACKEND_EXTERNALS = [
"drizzle-orm/*",
];
/**
* Resolved by the page's import map to the shell's own copies, because these
* must be one instance: React, i18next, the toast store, and the SDK entries
* that share state with core. Anything else a plugin imports is bundled.
*/
export const FRONTEND_EXTERNALS = [
"react",
"react-dom",
"react/jsx-runtime",
"react-dom/client",
"@termix/plugin-sdk",
"@termix/plugin-sdk/*",
"i18next",
"react-i18next",
// Core-provided UI libraries. A7 serves these through the SDK ui entry.
"lucide-react",
"sonner",
"axios",
"cytoscape",
"react-cytoscapejs",
"guacamole-common-js",
"react-xtermjs",
"@xterm/*",
// Legacy: the shell's own modules, still reached by alias. A7 removes.
"@/*",
"@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui",
// Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes.
"@termix/legacy-core/*",
];
@@ -12,6 +12,8 @@
* Delete this file in D1, once every plugin reaches core through the SDK.
*/
import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs";
// "../../../src/backend/" or "../../../../src/backend/", any depth.
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
// Shared types. Mostly erased at compile, but automations imports a real
@@ -27,10 +29,36 @@ const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
const COMPILED_CORE = "../../../backend/backend/";
const COMPILED_TYPES = "../../../backend/types/";
export function legacyCoreImports({ pluginId, platform }) {
const CORE_ALIAS = /^@\//;
export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
return {
name: "legacy-core-imports",
setup(build) {
// The shell's own modules, reached as "@/..." or by relative path.
// Bundling them would give the plugin a second copy of the shell's
// contexts and API client, so each becomes a bare specifier the page's
// import map resolves to the shell's own module.
if (platform === "browser") {
const toLegacy = (args) => {
const specifier = legacyCoreSpecifier(args.path);
if (!specifier) return null;
if (!insideTermix) {
return {
errors: [
{
text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`,
},
],
};
}
return { path: specifier, external: true };
};
build.onResolve({ filter: CORE_ALIAS }, toLegacy);
build.onResolve({ filter: CORE_UI }, toLegacy);
build.onResolve({ filter: CORE_TYPES }, toLegacy);
}
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
external: true,
@@ -41,16 +69,6 @@ export function legacyCoreImports({ pluginId, platform }) {
external: true,
}));
// The shell's own modules. Only plugins/docker's frontend entry reaches
// core this way; every other frontend uses the "@/" alias, which is
// externalized by name. A7 removes both.
if (platform === "browser") {
build.onResolve({ filter: CORE_UI }, (args) => ({
path: args.path,
external: true,
}));
}
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
const [, other] = CROSS_PLUGIN.exec(args.path);
if (other === "src" || other === pluginId) return null;
@@ -0,0 +1,62 @@
/**
* Legacy core modules a bundled plugin frontend still imports.
*
* Bundled plugin frontends predate the SDK and import the shell's own modules
* as "@/..." (or, in a couple of places, by relative path into src/). Bundling
* those would give the plugin a second copy of the shell's contexts and API
* client, so the CLI rewrites each one to a bare specifier under
* "@termix/legacy-core/" and core's build exposes exactly that module through
* the import map. Both sides use this one normalizer so they cannot disagree.
*
* Only plugins built inside a Termix checkout may do this. D1 deletes it.
*/
export const LEGACY_CORE_PREFIX = "@termix/legacy-core/";
const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/;
function clean(rest) {
return rest.replace(EXTENSION, "").replace(/\/+$/, "");
}
/**
* "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button"
* "@/types" -> "@termix/legacy-core/types"
* "@/types/connection-log" -> "@termix/legacy-core/types/connection-log"
* "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x"
* Anything else -> null
*/
export function legacyCoreSpecifier(specifier) {
if (specifier.startsWith("@/")) {
const rest = clean(specifier.slice(2));
if (rest === "types" || rest.startsWith("types/")) {
return LEGACY_CORE_PREFIX + rest;
}
return `${LEGACY_CORE_PREFIX}ui/${rest}`;
}
const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier);
if (relative) {
return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`);
}
return null;
}
/**
* Every value import in a source file that names a legacy core module.
* Type-only imports are skipped: they are erased and never reach the map.
*/
export function findLegacyCoreImports(source) {
const found = new Set();
const patterns = [
/^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm,
/^\s*import\s+["']([^"']+)["']/gm,
/import\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
const normalized = legacyCoreSpecifier(match[1]);
if (normalized) found.add(normalized);
}
}
return found;
}
@@ -263,6 +263,33 @@
}
}
},
"guest": { "type": "boolean" },
"panels": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"dashboardCards": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"actions": {
"type": "array",
"items": {
@@ -296,7 +323,7 @@
},
"kind": {
"type": "string",
"enum": ["button"]
"enum": ["button", "component"]
}
}
}
@@ -317,7 +344,7 @@
"minItems": 1,
"items": {
"type": "string",
"enum": ["button"]
"enum": ["button", "component"]
}
},
"descriptionKey": {
+614 -11
View File
@@ -1,29 +1,478 @@
/**
* The frontend contract: what a plugin's frontend entry receives.
* The frontend contract: what a plugin's frontend entry receives, and the
* hooks its components use.
*
* Stubs only. A7 builds the real loader and fills these in, at which point a
* plugin registers every surface it contributes through this object and the
* shell stops carrying plugin ids of its own.
* A plugin frontend is `src/frontend/index.tsx` exporting `activate(app)` and
* optionally `deactivate()`. Everything it contributes goes through `app`, and
* everything registered there is removed when the plugin is disabled, without
* a page reload.
*
* This module has no runtime dependencies. The hooks delegate to a host that
* core installs at startup (`__setPluginHost`), because React, i18next and the
* shell's state all live in core and must be one instance across the shell
* and every plugin bundle.
*/
import type { ComponentType, ReactNode, Ref } from "react";
import type { PluginManifest } from "./manifest.js";
export type Disposer = () => void;
/** A Lucide icon or anything with the same props. */
export type IconComponent = ComponentType<{
className?: string;
size?: number | string;
strokeWidth?: number | string;
}>;
/**
* A host as the shell holds it. Only the identifying fields are typed; the
* rest of the record is passed through as the shell has it, so a plugin
* that needs a feature column reads it by name.
*/
export interface PluginHostRecord {
id: string;
name: string;
ip: string;
port: number;
username?: string;
folder?: string;
tags?: string[];
[key: string]: unknown;
}
/** An open tab as the shell holds it. `data` is the plugin's own payload. */
export interface PluginTabRecord {
id: string;
type: string;
label: string;
host?: PluginHostRecord;
data?: Record<string, unknown>;
[key: string]: unknown;
}
export interface OpenTabOptions {
label?: string;
/** Open a second tab even when one for this host and type exists. */
forceNewTab?: boolean;
/** Plugin payload, stored on the tab and restored with it. */
data?: Record<string, unknown>;
}
/** A saved arrangement of tabs, used by workspaces. Opaque to plugins. */
export interface ShellLayout {
version: number;
[key: string]: unknown;
}
/** What a plugin may ask of the shell. */
export interface ShellApi {
openTab: (
host: PluginHostRecord | null,
type: string,
options?: OpenTabOptions,
) => void;
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
openFileInEditor: (host: PluginHostRecord, path: string) => void;
openFileManager: (host: PluginHostRecord, path?: string) => void;
openTerminalTab: (host: PluginHostRecord, path?: string) => void;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
closeRailView: (id: string) => void;
}
export interface TabsApi extends Pick<
ShellApi,
"openTab" | "openSingletonTab" | "closeTab"
> {
/** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null;
/**
* Replaces the open tabs with a saved layout. `name` labels a restored
* split. Resolves with the tabs that could not be reopened, e.g. because
* their host was deleted.
*/
applyLayout: (
layout: ShellLayout,
options?: { name?: string },
) => Promise<{ skipped: string[] }>;
/** Called whenever tabs open, close or move. */
onChange: (listener: () => void) => Disposer;
/** Called once the shell has restored its tabs after login. */
onReady: (listener: () => void) => Disposer;
}
// ---------------------------------------------------------------------------
// Registrations
// ---------------------------------------------------------------------------
export interface RailItemContribution {
/** Must be declared in manifest contributes.tabs or contributes.panels. */
id: string;
icon: IconComponent;
titleKey: string;
/** "panel" opens the left sidebar, "tab" opens a tab. Default "panel". */
kind?: "panel" | "tab";
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
hideable?: boolean;
/** Can also open as a full-width tab. */
promotable?: boolean;
/** Can open in the right dock. */
rightDockable?: boolean;
/** Shown on the mobile bar's primary row. */
mobilePrimary?: boolean;
/** Desktop app only. */
electronOnly?: boolean;
separatorAfter?: boolean;
/** Hidden without being unregistered, e.g. while a feature is switched off. */
hidden?: boolean;
/** Place it after this rail id rather than at the end. */
after?: string;
/** Lower sorts first among plugin items. */
order?: number;
}
export interface PanelProps {
/** Whether the panel is the one currently shown. */
active: boolean;
shell: ShellApi;
/** Tells the shell the panel is editing, which widens the sidebar. */
setEditing: (editing: boolean) => void;
/** Type of the focused tab, if any. */
activeTabType?: string;
/** Where the panel is rendered. */
placement: "left" | "right" | "tab";
}
export interface PanelOptions {
/** Keep the panel mounted after the user switches away. */
keepMounted?: boolean;
}
export interface TabProps {
tab: PluginTabRecord;
host?: PluginHostRecord;
/** The host in the shape the connection APIs take. */
sshHost?: Record<string, unknown>;
label: string;
isVisible: boolean;
isFocusedPane: boolean;
/** Forward to a component exposing `refresh()` or `disconnect()`. */
handleRef: Ref<unknown>;
shell: ShellApi;
}
export interface StandaloneViewProps {
hostId?: string;
view: string;
params: URLSearchParams;
}
export interface TabOptions {
icon?: IconComponent;
/** Label for singleton tabs and anywhere the type is named. */
titleKey?: string;
/** Needs a host; renders `noHostMessageKey` without one. */
requiresHost?: boolean;
noHostMessageKey?: string;
/** Reopened after login. */
persistent?: boolean;
/** One tab of this type at a time, keyed by type. */
singleton?: boolean;
/** A live session: asks before closing and can be refreshed. */
session?: boolean;
/** Restorable without a host. */
hostless?: boolean;
/** Decides whether a saved tab for this host may be restored. */
restore?: (host: PluginHostRecord) => boolean;
/** Recent-activity types that open this tab. */
activityTypes?: string[];
/** Rendered for `?view=<type>` full-screen links. */
standalone?: ComponentType<StandaloneViewProps>;
/** Extra `?view=` names that also open `standalone`. */
standaloneViews?: string[];
/** Wrap in the readable-width frame used by panels opened as tabs. */
panelFrame?: boolean;
/** False keeps the tab out of saved layouts and workspaces. Default true. */
inLayouts?: boolean;
/** Warms the tab's code before it is opened. */
preload?: () => Promise<unknown>;
}
export interface HostEditorSectionProps {
// The editor form is a plain object; a section reads the fields it owns.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
/** Sets several fields at once, computed from the latest form. */
updateForm: (
patch: (form: Record<string, unknown>) => Record<string, unknown>,
) => void;
host?: PluginHostRecord;
credentials?: unknown[];
snippets?: unknown[];
/** Which connection protocols are switched on for this host. */
protocols: Record<string, boolean>;
}
export interface HostEditorSectionContribution {
id: string;
/** "top" sits in the main tab strip, "ssh" under the SSH group. */
group: "top" | "ssh";
titleKey: string;
icon?: IconComponent;
/**
* Position among the group's tabs, core ones included. Top: General 0,
* SSH 10. SSH group: General 0, Terminal 10, Tunnels 20, Files 60.
*/
order?: number;
/** Whether to offer the tab, from the host's enabled protocols. */
visible?: (protocols: Record<string, boolean>) => boolean;
component: ComponentType<HostEditorSectionProps>;
}
export interface HostActionContribution {
id: string;
titleKey: string;
icon: IconComponent;
/**
* "connect" is a way to open a session (terminal, RDP) and is offered as a
* host's default action by priority. "open" opens a tool for the host.
*/
kind: "connect" | "open";
/** Higher wins when choosing a host's default connect action. */
priority?: number;
/** Opened with shell.openTab when `run` is absent. */
tabType?: string;
when: (host: PluginHostRecord) => boolean;
run?: (host: PluginHostRecord, shell: ShellApi) => void;
/** Shows a quick button on the host row. Default true. */
tray?: boolean;
/** `?view=` name for "Copy link". */
copyUrlView?: string;
/** Where a host overview (the dashboard's host status list) sends a click. */
overview?: boolean;
/**
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
* actions default to 100 and sit after a separator.
*/
order?: number;
/** A label worked out per host, e.g. a single endpoint's name. */
label?: (host: PluginHostRecord) => string | undefined;
/** Several targets: two or more become a picker, one runs directly. */
items?: (host: PluginHostRecord) => {
id: string;
label: string;
run: (host: PluginHostRecord, shell: ShellApi) => void;
}[];
}
export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
component: ComponentType<{ host: PluginHostRecord }>;
}
export interface HostContextMenuItemContribution {
id: string;
titleKey: string;
icon?: IconComponent;
when: (host: PluginHostRecord) => boolean;
run: (host: PluginHostRecord, shell: ShellApi) => void;
}
export interface PaletteEntryContribution {
id: string;
titleKey: string;
icon?: IconComponent;
keywords?: string[];
/** "global" entries stand alone, "host" entries appear under a host. */
scope: "global" | "host";
when?: (host?: PluginHostRecord) => boolean;
run: (shell: ShellApi, host?: PluginHostRecord) => void;
}
export interface DashboardCardProps {
isVisible: boolean;
shell: ShellApi;
}
export interface DashboardCardContribution {
/** Must be declared in manifest contributes.dashboardCards. */
id: string;
titleKey: string;
defaultHeight?: number;
component: ComponentType<DashboardCardProps>;
}
export interface HomepageWidgetContribution<C = Record<string, unknown>> {
id: string;
name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editFormComponent?: ComponentType<any>;
}
export interface SettingsComponentProps {
pluginId: string;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
running: boolean;
}
export type ActionHandler = (...args: never[]) => unknown;
export interface ActionSlotDefinition {
id: string;
/** Contribution kinds the slot renders: "button", "component". */
accepts: string[];
}
export interface SlotContribution {
actionId: string;
titleKey: string;
/** Secondary text, for slots that show one (onboarding cards). */
descriptionKey?: string;
icon?: IconComponent;
kind?: "button" | "component" | string;
/** For kind "component". Receives the slot owner's props. */
component?: ComponentType<Record<string, unknown>>;
/** Extra condition on top of the action's permission. */
when?: (context: Record<string, unknown>) => boolean;
order?: number;
}
export interface SshAuthEditorProps {
form: Record<string, unknown>;
setField: (key: string, value: unknown) => void;
}
export interface SshAuthEditorContribution {
/** The value stored in the host's authType. */
authType: string;
titleKey: string;
hintKey?: string;
component?: ComponentType<SshAuthEditorProps>;
}
/** A7 declares these; A8 wires them into the login screen. */
export interface LoginMethodContribution {
id: string;
titleKey: string;
icon?: IconComponent;
component: ComponentType<Record<string, unknown>>;
}
export interface SecondFactorContribution {
id: string;
titleKey: string;
component: ComponentType<Record<string, unknown>>;
}
/** The subset of axios a plugin uses, rooted at /plugin-api/<id>/. */
export interface PluginApiClient {
get<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
delete<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
post<T = unknown>(
url: string,
body?: unknown,
config?: unknown,
): Promise<{ data: T }>;
put<T = unknown>(
url: string,
body?: unknown,
config?: unknown,
): Promise<{ data: T }>;
patch<T = unknown>(
url: string,
body?: unknown,
config?: unknown,
): Promise<{ data: T }>;
}
export interface PluginWsTarget {
url: string;
protocols?: string[];
}
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
}
/**
* Registration surface for a plugin frontend.
*
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
* badges, context menu items, palette entries, dashboard cards, homepage
* widgets, settings components, action slots, login/2FA UI and SSH auth
* editors. Everything registered here is removed when the plugin is disabled.
* Registration surface for a plugin frontend. Every `register*` returns a
* disposer, and anything not disposed by hand is disposed when the plugin is
* disabled.
*/
export interface TermixApp extends TermixAppInfo {
/**
* True on anonymous guest pages (a shared-session link). Only plugins with
* contributes.guest run there, and they should register just what a guest
* sees: there is no user, so API calls needing a login will fail.
*/
readonly guest: boolean;
registerRailItem: (item: RailItemContribution) => Disposer;
registerPanel: (
id: string,
component: ComponentType<PanelProps>,
options?: PanelOptions,
) => Disposer;
registerTab: (
type: string,
component: ComponentType<TabProps>,
options?: TabOptions,
) => Disposer;
registerHostEditorSection: (
section: HostEditorSectionContribution,
) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
) => Disposer;
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
registerDashboardCard: (card: DashboardCardContribution) => Disposer;
registerHomepageWidget: (widget: HomepageWidgetContribution) => Disposer;
registerSettingsComponent: (
componentId: string,
component: ComponentType<SettingsComponentProps>,
) => Disposer;
registerAction: (
id: string,
handler: ActionHandler,
options?: { permission?: string },
) => Disposer;
declareActionSlot: (slot: ActionSlotDefinition) => Disposer;
registerSlotContribution: (
slotId: string,
contribution: SlotContribution,
) => Disposer;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
api: PluginApiClient;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: (
path: string,
options?: { origin?: unknown },
) => Promise<PluginWsTarget | null>;
tabs: TabsApi;
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: () => void) => void;
onDispose: (dispose: Disposer) => void;
}
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
@@ -37,4 +486,158 @@ export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
return plugin;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
export type TranslateFn = (
key: string,
options?: Record<string, unknown> | string,
) => string;
export interface CurrentUser {
userId: string;
username: string;
isAdmin: boolean;
}
export interface ToastApi {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
warning: (message: string) => void;
}
export type SettingsScope = "admin" | "user" | "host";
export interface SettingsState {
values: Record<string, unknown>;
loaded: boolean;
save: (values: Record<string, unknown>) => Promise<void>;
}
/**
* What core implements behind the hooks. Internal: a plugin never calls this.
*/
export interface PluginHostBridge {
usePluginId: () => string;
useTranslation: (pluginId: string) => {
t: TranslateFn;
language: string;
};
usePermission: (pluginId: string, permission: string) => boolean;
useSettings: (
pluginId: string,
scope: SettingsScope,
hostId?: number | string,
) => SettingsState;
useHost: (hostId: string | number | undefined) => PluginHostRecord | null;
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
useCurrentUser: () => CurrentUser | null;
useTheme: () => { theme: "light" | "dark" };
toast: ToastApi;
getApi: (pluginId: string) => PluginApiClient;
useTabs: () => TabsApi;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
}
let host: PluginHostBridge | null = null;
/** Called once by core. Not part of the plugin API. */
export function __setPluginHost(bridge: PluginHostBridge | null): void {
host = bridge;
}
/**
* Set by the shared test setup, so a plugin component rendered on its own in a
* unit test still has hooks. A global rather than an import, because the test
* setup and the component can load different copies of this module.
*/
const TEST_HOST_KEY = "__termixTestPluginHost";
function requireHost(): PluginHostBridge {
const fallback = (globalThis as Record<string, unknown>)[TEST_HOST_KEY] as
PluginHostBridge | undefined;
if (!host && fallback) return fallback;
if (!host) {
throw new Error(
"@termix/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
);
}
return host;
}
/** The id of the plugin that registered the component being rendered. */
export function usePluginId(): string {
return requireHost().usePluginId();
}
/**
* Translation bound to this plugin's namespace. Keys the plugin does not
* define fall back to core's shared strings (common.*, hosts.*).
*/
export function useTranslation(): { t: TranslateFn; language: string } {
const bridge = requireHost();
return bridge.useTranslation(bridge.usePluginId());
}
/**
* Whether the current user holds a permission. A short name resolves to this
* plugin's own namespace, a dotted id is taken as given. UI only: the backend
* route checks again.
*/
export function usePermission(permission: string): boolean {
const bridge = requireHost();
return bridge.usePermission(bridge.usePluginId(), permission);
}
export function useSettings(
scope: SettingsScope,
hostId?: number | string,
): SettingsState {
const bridge = requireHost();
return bridge.useSettings(bridge.usePluginId(), scope, hostId);
}
export function useHost(
hostId: string | number | undefined,
): PluginHostRecord | null {
return requireHost().useHost(hostId);
}
export function useHosts(): { hosts: PluginHostRecord[]; loaded: boolean } {
return requireHost().useHosts();
}
export function useCurrentUser(): CurrentUser | null {
return requireHost().useCurrentUser();
}
export function useTheme(): { theme: "light" | "dark" } {
return requireHost().useTheme();
}
export function useToast(): ToastApi {
return requireHost().toast;
}
export function usePluginApi(): PluginApiClient {
const bridge = requireHost();
return bridge.getApi(bridge.usePluginId());
}
export function useTabs(): TabsApi {
return requireHost().useTabs();
}
/**
* Runs an action another plugin registered, from anywhere in a plugin's code.
* Resolves undefined when nobody registered it, so an optional dependency
* that is switched off reads as "nothing there".
*/
export function invokeAction(id: string, ...args: unknown[]): Promise<unknown> {
return requireHost().invokeAction(id, ...args);
}
export type { PluginManifest } from "./manifest.js";
export type { ReactNode };
+54 -1
View File
@@ -31,7 +31,7 @@ export const PLUGIN_CATEGORIES = [
export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const;
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
export const ACTION_CONTRIBUTION_KINDS = ["button", "component"] as const;
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
@@ -97,6 +97,17 @@ export interface PluginTabContribution {
openFrom: string[];
}
/**
* A sidebar panel or dashboard card the plugin's frontend registers. Declared
* here so core knows who owns a saved view even while the plugin is off and
* its code never loaded.
*/
export interface PluginViewContribution {
id: string;
titleKey: string;
icon?: string;
}
/**
* One role permission a plugin contributes.
*
@@ -224,6 +235,13 @@ export interface PluginSettingsContribution {
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[];
/**
* The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set.
*/
guest?: boolean;
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissions?: PluginPermissionContribution[];
@@ -302,6 +320,9 @@ const ALLOWED_TOP_LEVEL = new Set([
const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"panels",
"dashboardCards",
"guest",
"actions",
"actionSlots",
"permissions",
@@ -650,6 +671,14 @@ function validateContributes(
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors);
if (
contributes.guest !== undefined &&
typeof contributes.guest !== "boolean"
) {
errors.push('Field "contributes.guest" must be a boolean');
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
@@ -884,6 +913,30 @@ function validateTabs(tabs: unknown, errors: string[]): void {
});
}
function validateViews(views: unknown, field: string, errors: string[]): void {
if (views === undefined) return;
if (!Array.isArray(views)) {
errors.push(`Field "contributes.${field}" must be an array`);
return;
}
const seen = new Set<string>();
views.forEach((raw, index) => {
const where = `contributes.${field}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["id", "titleKey", "icon"], where, errors);
if (requireString(raw.id, `${where}.id`, errors)) {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.titleKey, `${where}.titleKey`, errors);
if (raw.icon !== undefined)
requireString(raw.icon, `${where}.icon`, errors);
});
}
function validatePermissions(
permissions: unknown,
pluginId: string | undefined,
+79 -34
View File
@@ -443,43 +443,88 @@ export function createMockCtx(
}
/**
* A TermixApp double for frontend tests.
* Rendering a plugin frontend in a test.
*
* A7 builds the real registration surface; until then this records what a
* plugin's frontend entry registered, which is all a test can assert about a
* loader that does not exist yet. Deliberately free of any render library so
* a plugin only needs testing-library if its own tests render something.
* renderWithApp activates the plugin against the real registration surface,
* so a test sees exactly what the shell would: which rail items, tabs, host
* actions and cards it registered, and each of those rendered with the SDK
* hooks working. It is implemented by the Termix host, which is where the
* registries live; the vitest preset points "@termix/plugin-host/testing" at
* core's implementation.
*/
export interface FakeTermixApp {
export interface RenderWithAppOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Role permissions the current user holds. */
permissions?: string[];
isAdmin?: boolean;
/** Hosts useHosts() returns. */
hosts?: Array<Record<string, unknown>>;
/** The plugin's locales/en.json, loaded into its namespace. */
locales?: Record<string, unknown>;
/** Render as an anonymous guest page. */
guest?: boolean;
}
/** A call a plugin made on the shell, recorded instead of performed. */
export interface ShellCall {
method: string;
args: unknown[];
}
export interface RenderedPluginApp {
app: TermixApp;
/** Cleanups the plugin handed to app.onDispose. */
disposals: Array<() => void>;
}
export function renderWithApp(
options: { pluginId?: string; manifest?: Partial<PluginManifest> } = {},
): FakeTermixApp {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void> = [];
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;
const app: TermixApp = {
pluginId,
manifest,
onDispose: (dispose) => disposals.push(dispose),
/** Everything this plugin registered, by kind. */
registered: {
railItems: () => Array<{ id: string; hidden?: boolean }>;
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
hostEditorSections: () => string[];
dashboardCards: () => string[];
settingsComponents: () => string[];
slot: (slotId: string) => string[];
actions: () => string[];
};
/** Shell calls made by the plugin's code. */
shellCalls: ShellCall[];
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
renderDashboardCard: (id: string) => HTMLElement;
renderHostEditorSection: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
renderSettingsComponent: (
componentId: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a slot the way its owner would. */
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
/** Runs deactivate and every disposer, as disabling the plugin does. */
deactivate: () => Promise<void>;
}
return { app, disposals };
export type FrontendPluginModule = {
activate: (app: TermixApp) => void | Promise<void>;
deactivate?: () => void | Promise<void>;
};
interface PluginTestHost {
renderPlugin: (
plugin: FrontendPluginModule,
options: RenderWithAppOptions,
) => Promise<RenderedPluginApp>;
}
const TEST_HOST = "@termix/plugin-host/testing";
export async function renderWithApp(
plugin: FrontendPluginModule,
options: RenderWithAppOptions = {},
): Promise<RenderedPluginApp> {
// A variable specifier keeps bundlers from resolving it at build time; the
// test runner resolves it through its alias.
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options);
}
+40
View File
@@ -63,6 +63,46 @@ if (typeof globalThis.localStorage === "undefined") {
});
}
/**
* A plain plugin host for components a test renders on its own: keys come
* back as text, every permission is held, and nothing reaches a server.
* renderWithApp installs Termix's real host instead, which takes precedence.
*/
const noop = () => {};
const offline = () =>
Promise.reject(new Error("No plugin API in a unit test; mock it"));
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
usePluginId: () => "test-plugin",
useTranslation: () => ({
t: (key: string) => key,
language: "en",
}),
usePermission: () => true,
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
useHost: () => null,
useHosts: () => ({ hosts: [], loaded: true }),
useCurrentUser: () => null,
useTheme: () => ({ theme: "dark" }),
toast: { success: noop, error: noop, info: noop, warning: noop },
getApi: () => ({
get: offline,
delete: offline,
post: offline,
put: offline,
patch: offline,
}),
useTabs: () => ({
openTab: noop,
openSingletonTab: noop,
closeTab: noop,
getLayout: () => null,
applyLayout: async () => ({ skipped: [] }),
onChange: () => noop,
onReady: () => noop,
}),
invokeAction: async () => undefined,
};
afterEach(() => {
vi.restoreAllMocks();
});
+24
View File
@@ -48,9 +48,33 @@ export function pluginVitestConfig(
);
const repoRoot = findRepoRoot(pluginDir);
// Inside a Termix checkout the SDK's browser entries resolve to source and
// the test host is core's, so renderWithApp exercises the real registries
// and the plugin shares one SDK instance with them.
const alias: Record<string, string> = {
...(repoRoot
? {
"@termix/plugin-sdk/frontend": path.join(
repoRoot,
"packages",
"plugin-sdk",
"src",
"frontend.ts",
),
"@termix/plugin-sdk/ui": path.join(
repoRoot,
"src",
"ui",
"plugin-host",
"sdk-ui.ts",
),
"@termix/plugin-host/testing": path.join(
repoRoot,
"src",
"ui",
"plugin-host",
"testing-host.tsx",
),
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}