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