feat(FN-2255): add plugin runtime discovery contracts

- Extend core/plugin-sdk types with runtime manifest metadata, runtime factory, and runtime registration exports
- Add runtime validation in plugin manifest parsing, including runtimeId slug and semver checks
- Add PluginLoader.getPluginRuntimes() and PluginRunner runtime cache/invalidation plumbing across plugin lifecycle events
- Expand plugin loader/runner test coverage for runtime discovery and cache behavior, and document runtime registration in PLUGIN_AUTHORING.md
This commit is contained in:
Fusion
2026-04-22 13:01:25 -07:00
committed by gsxdsm
parent 2873bc0388
commit c187c670e1
10 changed files with 1304 additions and 14 deletions

View File

@@ -33,6 +33,7 @@ describe("PluginRunner", () => {
getPluginTools: ReturnType<typeof vi.fn>;
getPluginRoutes: ReturnType<typeof vi.fn>;
getPluginUiSlots: ReturnType<typeof vi.fn>;
getPluginRuntimes: ReturnType<typeof vi.fn>;
getLoadedPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
loadPlugin: ReturnType<typeof vi.fn>;
@@ -85,6 +86,7 @@ describe("PluginRunner", () => {
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
loadPlugin: vi.fn().mockResolvedValue({}),
@@ -491,6 +493,251 @@ describe("PluginRunner", () => {
});
});
describe("getPluginRuntimes()", () => {
it("should return empty array when no plugins have runtimes", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
const runtimes = pluginRunner.getPluginRuntimes();
expect(runtimes).toEqual([]);
});
it("should return cached runtimes after plugins load", async () => {
const mockRuntimes = [
{
pluginId: "test-plugin",
runtime: {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code",
},
factory: async () => ({}),
},
},
];
mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes);
await pluginRunner.init();
const runtimes1 = pluginRunner.getPluginRuntimes();
const runtimes2 = pluginRunner.getPluginRuntimes();
expect(runtimes1).toEqual(mockRuntimes);
expect(runtimes2).toBe(runtimes1); // Same reference (cached)
});
it("should invalidate cache on plugin:reloaded event", async () => {
const mockRuntimes = [
{
pluginId: "test-plugin",
runtime: {
metadata: {
runtimeId: "runtime-v1",
name: "Runtime V1",
},
factory: async () => ({}),
},
},
];
mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes);
await pluginRunner.init();
const runtimes1 = pluginRunner.getPluginRuntimes();
expect(runtimes1).toEqual(mockRuntimes);
// Simulate plugin:reloaded event that invalidates cache
const reloadHandler = mockPluginLoader.on.mock.calls.find(
call => call[0] === "plugin:reloaded"
)?.[1];
if (reloadHandler) {
reloadHandler({ pluginId: "test-plugin" });
}
// Next call should rebuild cache
const newRuntimes = [
{
pluginId: "test-plugin",
runtime: {
metadata: {
runtimeId: "runtime-v2",
name: "Runtime V2",
},
factory: async () => ({}),
},
},
];
mockPluginLoader.getPluginRuntimes.mockReturnValue(newRuntimes);
const runtimes2 = pluginRunner.getPluginRuntimes();
expect(runtimes2).toEqual(newRuntimes);
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:enabled event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:enabled event
const enabledHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:enabled"
)?.[1];
const newPlugin = {
id: "new-plugin",
name: "New Plugin",
version: "1.0.0",
path: "/test/path",
enabled: true,
state: "stopped" as const,
settings: {},
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
if (enabledHandler) {
enabledHandler(newPlugin);
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:disabled event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:disabled event
const disabledHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:disabled"
)?.[1];
const plugin = {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/test/path",
enabled: true,
state: "stopped" as const,
settings: {},
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
if (disabledHandler) {
disabledHandler(plugin);
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:stateChanged event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:stateChanged event
const stateHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:stateChanged"
)?.[1];
if (stateHandler) {
stateHandler();
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:updated event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:updated event
const updatedHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:updated"
)?.[1];
if (updatedHandler) {
updatedHandler();
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on reloadPlugin()", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Call reloadPlugin
await pluginRunner.reloadPlugin("test-plugin");
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:loaded event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:loaded event
const loadedHandler = mockPluginLoader.on.mock.calls.find(
call => call[0] === "plugin:loaded"
)?.[1];
if (loadedHandler) {
loadedHandler({ pluginId: "test-plugin" });
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:unloaded event", async () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
await pluginRunner.init();
// Get initial runtimes
pluginRunner.getPluginRuntimes();
// Simulate plugin:unloaded event
const unloadedHandler = mockPluginLoader.on.mock.calls.find(
call => call[0] === "plugin:unloaded"
)?.[1];
if (unloadedHandler) {
unloadedHandler({ pluginId: "test-plugin" });
}
// Next call should rebuild cache
pluginRunner.getPluginRuntimes();
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
});
});
describe("getLoader() / getStore()", () => {
it("should return the plugin loader", () => {
const loader = pluginRunner.getLoader();