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
139 lines
5.2 KiB
TypeScript
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"));
|
|
});
|
|
});
|