Files
fusion/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx
gsxdsm 0f2bfa546c FN-7629: add enable/disable control for built-in runtime plugins
Adds a durable Plugin Manager toggle to enable/disable built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) that persists across restarts, replacing the dead-end "Built-in metadata only" CTA.

- renderBuiltinPluginSection now renders an enable/disable toggle for runtime built-ins regardless of installed status
- Disabling a not-yet-installed built-in first installs it (mirroring CLI's ensureBundledPluginInstalled) then immediately disables it, so a plugin_installs row + disabled project state exists with no new persistence primitive needed
- HermesRuntimeCard/OpenClawRuntimeCard/PaperclipRuntimeCard now show "Disabled in Plugin Manager" instead of a stale detected/connected status when disabled
- Added i18n strings across all locales and updated docs
- Added changeset for @runfusion/fusion (minor)
- Expanded plugin-loader and PluginManager test coverage for the new disable/enable flows

Files changed:
 .changeset/fn-7629-builtin-runtime-disable.md      |   7 +
 docs/dashboard-guide.md                            |   1 +
 docs/plugin-management.md                          |   4 +
 packages/core/src/__tests__/plugin-loader.test.ts  |  46 ++++++
 .../dashboard/app/components/HermesRuntimeCard.tsx |  40 ++++-
 .../app/components/OpenClawRuntimeCard.tsx         |  32 +++-
 .../app/components/PaperclipRuntimeCard.tsx        |  32 +++-
 .../dashboard/app/components/PluginManager.css     |  34 +++++
 .../dashboard/app/components/PluginManager.tsx     | 167 +++++++++++++++------
 .../components/__tests__/PluginManager.test.tsx    |  12 +-
 .../__tests__/PluginManager.toggle.test.tsx        | 104 ++++++++++++-
 packages/i18n/locales/en/app.json                  |   4 +
 packages/i18n/locales/es/app.json                  |   4 +
 packages/i18n/locales/fr/app.json                  |   4 +
 packages/i18n/locales/ko/app.json                  |   4 +
 packages/i18n/locales/zh-CN/app.json               |   4 +
 packages/i18n/locales/zh-TW/app.json               |   4 +
 packages/i18n/src/resources.d.ts                   |   4 +
 18 files changed, 440 insertions(+), 67 deletions(-)

Fusion-Task-Id: FN-7629
Fusion-Task-Lineage: 2a25bb1f-3f73-4273-8769-af05c61778f9
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-07 22:04:53 -07:00

220 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PluginManager } from "../PluginManager";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() => Promise.resolve({})),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
updatePluginSettings: vi.fn(() => Promise.resolve({})),
reloadPlugin: vi.fn(() => Promise.resolve({})),
fetchPluginSetupStatus: vi.fn(() => Promise.resolve({ hasSetup: false })),
installPluginSetup: vi.fn(() => Promise.resolve({ success: true })),
updatePlugin: vi.fn(() => Promise.resolve({})),
rescanPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/", parentPath: null, entries: [] })),
}));
import { fetchPlugins, disablePlugin, enablePlugin, installPlugin } from "../../api";
import { BUILTIN_PLUGINS } from "../PluginManager";
const addToast = vi.fn();
function plugin(enabled: boolean) {
return {
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled,
path: "/plugins/plugin-a",
settings: {},
settingsSchema: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
beforeEach(() => {
vi.clearAllMocks();
const styleEl = document.createElement("style");
styleEl.setAttribute("data-test-id", "all-app-css");
styleEl.textContent = loadAllAppCss();
document.head.appendChild(styleEl);
const esInstance = {
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
onmessage: null,
};
const MockES = vi.fn(function MockEventSource() {
return esInstance;
}) as unknown as typeof EventSource;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
vi.stubGlobal("EventSource", MockES);
});
afterEach(() => {
cleanup();
document.querySelector('[data-test-id="all-app-css"]')?.remove();
vi.restoreAllMocks();
});
function builtinPlugin(id: string, enabled: boolean) {
const builtin = BUILTIN_PLUGINS.find((p) => p.id === id)!;
return {
id: builtin.id,
name: builtin.name,
version: "1.0.0",
state: "started" as const,
enabled,
path: builtin.path ?? "/plugins/unknown",
settings: {},
settingsSchema: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("PluginManager toggle switch", () => {
it("keeps checkbox focusable but visually hidden", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
render(<PluginManager addToast={addToast} />);
const checkbox = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
const styles = getComputedStyle(checkbox);
expect(styles.position).toBe("absolute");
expect(styles.opacity).toBe("0");
expect(styles.pointerEvents).toBe("none");
});
it("toggles by clicking the label/slider control", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
render(<PluginManager addToast={addToast} />);
const checkbox = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
const label = checkbox.closest("label.toggle-switch") as HTMLLabelElement;
await userEvent.click(label);
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("renders slider next to input and reflects enabled state", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
const first = render(<PluginManager addToast={addToast} />);
const enabled = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
expect(enabled).toBeChecked();
expect(enabled.nextElementSibling).toHaveClass("toggle-slider");
first.unmount();
vi.mocked(fetchPlugins).mockResolvedValue([plugin(false)]);
render(<PluginManager addToast={addToast} />);
const disabled = await screen.findByRole("checkbox", { name: "Enable Test Plugin A" });
expect(disabled).not.toBeChecked();
expect(disabled.nextElementSibling).toHaveClass("toggle-slider");
});
});
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — regression coverage for the durable built-in runtime enable/disable control.
* Symptom: with no plugin_installs row for Hermes/Paperclip/OpenClaw/Droid (fetchPlugins -> []),
* the built-in runtime rows dead-ended at a static "Built-in metadata only"/install-only CTA and
* offered no way to disable the runtime. Assert every runtime built-in exposes an interactive
* toggle in every data state, and clicking disable on a not-installed runtime persists via
* installPlugin + disablePlugin (the durable register-then-disable path).
*/
describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () => {
const RUNTIME_BUILTINS = BUILTIN_PLUGINS.filter((p) => p.category === "runtime");
async function builtinSection() {
return within(await screen.findByLabelText("Built-in plugin recommendations"));
}
it("renders an interactive, checked toggle for every runtime built-in when not installed", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
render(<PluginManager addToast={addToast} />);
const section = await builtinSection();
for (const runtime of RUNTIME_BUILTINS) {
const toggle = await section.findByRole("checkbox", { name: `Disable ${runtime.name}` });
expect(toggle).toBeChecked();
expect(toggle.closest("label.toggle-switch")).not.toBeNull();
}
// No dead-end "Built-in metadata only" label for any runtime built-in row
// (the metadata-only dead-end may still legitimately appear for non-runtime
// built-ins like Agent Browser, which this task does not change).
for (const runtime of RUNTIME_BUILTINS) {
const row = section.getByText(runtime.name).closest(".plugin-builtins-item") as HTMLElement;
expect(within(row).queryByText("Built-in metadata only")).not.toBeInTheDocument();
}
});
it("disabling a not-installed runtime built-in registers it then disables it (durable path)", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(installPlugin).mockResolvedValue(builtinPlugin("fusion-plugin-hermes-runtime", true));
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Hermes Runtime" });
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
});
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
});
});
it("toggles an installed, enabled runtime built-in via the standard disable path (no re-install)", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-paperclip-runtime", true)]);
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Paperclip Runtime" });
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-paperclip-runtime", undefined);
});
expect(installPlugin).not.toHaveBeenCalled();
});
it("toggles an installed, disabled runtime built-in back on via the standard enable path", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-openclaw-runtime", false)]);
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Enable OpenClaw Runtime" });
expect(toggle).not.toBeChecked();
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(enablePlugin).toHaveBeenCalledWith("fusion-plugin-openclaw-runtime", undefined);
});
});
});