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

@@ -6,7 +6,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import type { AiProvider } from "@/api/ai-api";
import type { AiProvider } from "../../src/frontend/ai-api";
const api = vi.hoisted(() => ({
createAiProvider: vi.fn(),
@@ -16,7 +16,7 @@ const api = vi.hoisted(() => ({
updateAiProvider: vi.fn(),
}));
vi.mock("@/api/ai-api", () => api);
vi.mock("../../src/frontend/ai-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
@@ -24,7 +24,7 @@ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
import { AiProviderSettings } from "../../src/frontend/AiProviderSettings";
const provider: AiProvider = {
id: 7,
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
// A rail panel may reuse one of the plugin's tab ids.
const panels = [...declared(contributes.panels), ...tabs];
const cards = declared(contributes.dashboardCards);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id);
}
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import {
humanize,
mentionLabel,
toolLabel,
} from "@/features/ai/labels";
} from "../../src/frontend/labels";
import { AI_TOOLS } from "../../src/backend/tools/catalog.js";
describe("humanize", () => {
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
const getAiStatus = vi.fn();
vi.mock("../../src/frontend/ai-api", () => ({ getAiStatus }));
const { useAiAvailability, notifyAiStatusChanged } =
await import("../../src/frontend/use-ai-availability");
beforeEach(() => {
getAiStatus.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useAiAvailability", () => {
it("starts hidden and unloaded before the status answers", () => {
getAiStatus.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useAiAvailability());
expect(result.current).toEqual({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
});
it("reports both gates open when the admin and the user enabled it", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(true);
expect(result.current.userEnabled).toBe(true);
});
it("keeps userEnabled false when the user has not opted in", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: false,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(true);
expect(result.current.userEnabled).toBe(false);
});
it("the admin kill switch wins over the user opt-in", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: false,
enabled: true,
allowReadOnlyCommands: true,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(false);
expect(result.current.userEnabled).toBe(false);
});
it("treats a failed status call as off", async () => {
getAiStatus.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.globallyEnabled).toBe(false);
expect(result.current.userEnabled).toBe(false);
});
it("re-reads the status when notified", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.globallyEnabled).toBe(true));
getAiStatus.mockResolvedValue({
globallyEnabled: false,
enabled: true,
allowReadOnlyCommands: false,
});
act(() => notifyAiStatusChanged());
await waitFor(() => expect(result.current.globallyEnabled).toBe(false));
expect(result.current.userEnabled).toBe(false);
});
it("also re-reads on the older hiddenRailTabsChanged event", async () => {
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: false,
allowReadOnlyCommands: false,
});
const { result } = renderHook(() => useAiAvailability());
await waitFor(() => expect(result.current.loaded).toBe(true));
getAiStatus.mockResolvedValue({
globallyEnabled: true,
enabled: true,
allowReadOnlyCommands: false,
});
act(() => window.dispatchEvent(new Event("hiddenRailTabsChanged")));
await waitFor(() => expect(result.current.userEnabled).toBe(true));
});
});