Files
fusion/packages/cli/src/__tests__/plugin-dev.test.ts
gsxdsm 0a418e6875 FN-5844: add plugin dev loop and external authoring docs
Add a local plugin development loop plus publishable external plugin guidance.

- add `fn plugin dev` routing and implementation with supervised build, install, and hot-reload behavior
- export plugin loader/store helpers and add CLI tests for dev flow, pack shape validation, and scaffold docs coverage
- document external plugin authoring, update CLI/plugin authoring docs, and add a patch changeset for `@runfusion/fusion`

Files changed:
 .changeset/fn-5844-external-plugin-authoring.md    |   5 +
 docs/PLUGIN_AUTHORING.md                           |  10 +-
 docs/cli-reference.md                              |   3 +-
 docs/plugins/external-authoring.md                 | 114 +++++++++++
 packages/cli/src/__tests__/bin.test.ts             |  16 +-
 packages/cli/src/__tests__/plugin-dev.test.ts      | 138 ++++++++++++++
 .../cli/src/__tests__/plugin-pack-shape.test.ts    |  94 +++++++++
 packages/cli/src/__tests__/plugin-scaffold.test.ts |   3 +
 packages/cli/src/bin.ts                            |  16 +-
 packages/cli/src/commands/plugin-dev.ts            | 212 +++++++++++++++++++++
 packages/cli/src/commands/plugin-scaffold.ts       |   6 +-
 packages/cli/src/commands/plugin.ts                |   6 +-
 12 files changed, 613 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-5844
Fusion-Task-Lineage: 80afa0a0-225c-486d-901a-909d14e7b056
2026-06-01 19:40:28 -07:00

139 lines
5.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const pluginCommandMocks = vi.hoisted(() => {
const store = {
registerPlugin: vi.fn(async () => ({ id: "fusion-plugin-dev-test", enabled: true })),
};
const loader = {
loadPlugin: vi.fn(async () => undefined),
reloadPlugin: vi.fn(async () => undefined),
stopPlugin: vi.fn(async () => undefined),
};
return {
store,
loader,
createPluginStore: vi.fn(async () => store),
createPluginLoader: vi.fn(async () => ({ store, loader })),
resolvePluginEntryFile: vi.fn(async (dir: string) => join(dir, "dist", "index.js")),
loadManifestFromPath: vi.fn(async () => ({
manifest: {
id: "fusion-plugin-dev-test",
name: "Dev Test",
version: "0.1.0",
},
path: "/tmp/fusion-plugin-dev-test",
})),
};
});
vi.mock("../commands/plugin.js", () => ({
createPluginStore: pluginCommandMocks.createPluginStore,
createPluginLoader: pluginCommandMocks.createPluginLoader,
resolvePluginEntryFile: pluginCommandMocks.resolvePluginEntryFile,
loadManifestFromPath: pluginCommandMocks.loadManifestFromPath,
}));
const { runPluginDev } = await import("../commands/plugin-dev.js");
describe("runPluginDev", () => {
let tmpBase: string;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tmpBase = join(tmpdir(), `fn-plugin-dev-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpBase, { recursive: true });
mkdirSync(join(tmpBase, "dist"), { recursive: true });
writeFileSync(join(tmpBase, "dist", "index.js"), "export default {};\n");
vi.clearAllMocks();
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
rmSync(tmpBase, { recursive: true, force: true });
exitSpy.mockRestore();
logSpy.mockRestore();
errorSpy.mockRestore();
vi.useRealTimers();
});
it("builds before installing and loads the plugin in once mode", async () => {
const buildFn = vi.fn(async () => undefined);
const watchFn = vi.fn(() => ({ close: vi.fn() }));
await runPluginDev(tmpBase, { once: true, buildFn, watchFn });
expect(buildFn).toHaveBeenCalledOnce();
expect(buildFn).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.resolvePluginEntryFile).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.loadManifestFromPath).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.store.registerPlugin).toHaveBeenCalledWith({
manifest: {
id: "fusion-plugin-dev-test",
name: "Dev Test",
version: "0.1.0",
},
path: join(tmpBase, "dist", "index.js"),
aiScanOnLoad: false,
});
expect(pluginCommandMocks.loader.loadPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
expect(pluginCommandMocks.loader.stopPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
expect(watchFn).not.toHaveBeenCalled();
});
it("rebuilds and reloads on a watched source change", async () => {
vi.useFakeTimers();
exitSpy.mockImplementation((() => undefined) as typeof process.exit);
const buildFn = vi.fn(async () => undefined);
const close = vi.fn();
let onChange: (() => void) | undefined;
const watchFn = vi.fn((_dir: string, callback: () => void) => {
onChange = callback;
return { close };
});
const devPromise = runPluginDev(tmpBase, { buildFn, watchFn });
await vi.waitFor(() => expect(watchFn).toHaveBeenCalledWith(tmpBase, expect.any(Function)));
expect(onChange).toBeDefined();
onChange?.();
await vi.advanceTimersByTimeAsync(121);
await vi.waitFor(() => expect(pluginCommandMocks.loader.reloadPlugin).toHaveBeenCalledTimes(1));
expect(buildFn).toHaveBeenCalledTimes(2);
expect(pluginCommandMocks.loader.reloadPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
process.emit("SIGINT", "SIGINT");
await devPromise;
expect(close).toHaveBeenCalledOnce();
expect(pluginCommandMocks.loader.stopPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
});
it("exits non-zero when the plugin path is missing", async () => {
const missingPath = join(tmpBase, "missing");
expect(existsSync(missingPath)).toBe(false);
await expect(runPluginDev(missingPath, { once: true, buildFn: vi.fn() })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(`Plugin path does not exist: ${missingPath}`);
});
it("exits non-zero when manifest loading fails", async () => {
pluginCommandMocks.loadManifestFromPath.mockRejectedValueOnce(new Error("Plugin manifest not found"));
await expect(runPluginDev(tmpBase, { once: true, buildFn: vi.fn(async () => undefined) })).rejects.toThrow(
"process.exit:1",
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Plugin manifest not found"));
});
});