fix(FN-3794): pass unload context and isolate WhatsApp sessions per project

- Extend PluginOnUnload to receive runtime context and wire ctx through plugin-loader unload hooks
- Scope WhatsApp chat plugin connections by project root to avoid cross-project session leakage
- Update plugin authoring docs and add a patch changeset for @runfusion/fusion
- Align plugin test suites across WhatsApp and example/runtime plugins with the new onUnload context contract

Fusion-Task-Id: FN-3794
This commit is contained in:
Fusion
2026-05-09 00:50:47 -07:00
committed by gsxdsm
parent b05d011c67
commit c744ef83c2
11 changed files with 166 additions and 18 deletions

View File

@@ -185,7 +185,7 @@ describe("ci-status plugin", () => {
await plugin.hooks.onLoad?.(ctx as any);
vi.clearAllMocks();
await plugin.hooks.onUnload?.();
await plugin.hooks.onUnload?.(ctx as any);
expect(ctx.logger.info).toHaveBeenCalledWith("CI Status plugin unloaded");
});

View File

@@ -51,6 +51,6 @@ describe("even realities plugin", () => {
const res = await statusRoute?.handler({}, ctx);
expect(res).toMatchObject({ status: 200, body: { connected: true } });
await plugin.hooks?.onUnload?.();
await plugin.hooks?.onUnload?.(ctx);
});
});

View File

@@ -144,6 +144,7 @@ describe("openclaw-runtime plugin", () => {
});
it("onUnload does not throw", () => {
expect(() => plugin.hooks!.onUnload?.()).not.toThrow();
const ctx = createMockContext() as any;
expect(() => plugin.hooks!.onUnload?.(ctx)).not.toThrow();
});
});

View File

@@ -12,7 +12,7 @@
},
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
"test": "pnpm dlx vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/core": "workspace:*",

View File

@@ -1,5 +1,34 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { PluginContext } from "@fusion/plugin-sdk";
const connectionInstances: Array<{
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
getStatus: ReturnType<typeof vi.fn>;
requestPairingCode: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
}> = [];
vi.mock("../connection.js", () => {
const ctor = vi.fn((ctx: PluginContext) => {
const root = ctx.taskStore.getRootDir();
const instance = {
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
getStatus: vi.fn(() => ({ state: "open", jid: root })),
requestPairingCode: vi.fn(async () => "123-456"),
logout: vi.fn(async () => {}),
};
connectionInstances.push(instance);
return instance;
});
(ctor as unknown as { splitMessageForWhatsapp: (text: string) => string[] }).splitMessageForWhatsapp =
(text: string) => (text.length > 4096 ? [text.slice(0, 4096), text.slice(4096, 8192), text.slice(8192)] : [text]);
return { WhatsAppConnection: ctor };
});
import plugin, { ensureSchema, getDedupeRetentionDays, markProcessed, splitMessageForWhatsapp, wasProcessed } from "../index.js";
import { WhatsAppConnection } from "../connection.js";
function createInMemoryDb() {
const dedupe = new Map<string, { sender: string; receivedAt: string }>();
@@ -36,6 +65,10 @@ function createInMemoryDb() {
}
describe("whatsapp plugin", () => {
beforeEach(() => {
connectionInstances.length = 0;
vi.clearAllMocks();
});
it("registers schema init hook", () => {
expect(plugin.hooks?.onSchemaInit).toBeDefined();
});
@@ -67,6 +100,66 @@ describe("whatsapp plugin", () => {
});
});
describe("multi-project isolation", () => {
it("keeps project contexts isolated with shared plugin id", async () => {
const db = createInMemoryDb();
const makeCtx = (rootDir: string): PluginContext => ({
pluginId: "fusion-plugin-whatsapp-chat",
settings: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
taskStore: {
getRootDir: () => rootDir,
getPluginStore: () => ({
db,
}),
} as unknown as PluginContext["taskStore"],
});
const ctxA = makeCtx("/repo-a");
const ctxB = makeCtx("/repo-b");
await plugin.hooks!.onLoad!(ctxA);
await plugin.hooks!.onLoad!(ctxB);
expect(WhatsAppConnection).toHaveBeenCalledTimes(2);
expect(connectionInstances[0]?.start).toHaveBeenCalledTimes(1);
expect(connectionInstances[1]?.start).toHaveBeenCalledTimes(1);
const statusRoute = plugin.routes!.find((route) => route.method === "GET" && route.path === "/status")!;
const statusA = await statusRoute.handler({} as never, ctxA) as { status: number; body: unknown };
const statusB = await statusRoute.handler({} as never, ctxB) as { status: number; body: unknown };
expect(statusA.status).toBe(200);
expect((statusA.body as { jid: string }).jid).toBe("/repo-a");
expect(statusB.status).toBe(200);
expect((statusB.body as { jid: string }).jid).toBe("/repo-b");
await plugin.hooks!.onUnload!(ctxA);
expect(connectionInstances[0]?.stop).toHaveBeenCalledTimes(1);
expect(connectionInstances[1]?.stop).not.toHaveBeenCalled();
const afterUnloadA = await statusRoute.handler({} as never, ctxA) as { status: number; body: unknown };
const afterUnloadB = await statusRoute.handler({} as never, ctxB) as { status: number; body: unknown };
expect(afterUnloadA.status).toBe(503);
expect(afterUnloadB.status).toBe(200);
expect((afterUnloadB.body as { jid: string }).jid).toBe("/repo-b");
await plugin.hooks!.onUnload!(ctxB);
expect(connectionInstances[1]?.stop).toHaveBeenCalledTimes(1);
const finalStatusA = await statusRoute.handler({} as never, ctxA) as { status: number; body: unknown };
const finalStatusB = await statusRoute.handler({} as never, ctxB) as { status: number; body: unknown };
expect(finalStatusA.status).toBe(503);
expect(finalStatusB.status).toBe(503);
});
});
describe("markProcessed retention", () => {
it("prunes rows older than retention and keeps recent rows", () => {
const db = createInMemoryDb();

View File

@@ -50,6 +50,10 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
const connections = new Map<string, WhatsAppConnection>();
function getConnectionKey(ctx: PluginContext): string {
return `${ctx.taskStore.getRootDir()}::${ctx.pluginId}`;
}
export function getSettingString(settings: Record<string, unknown>, key: string): string | undefined {
const value = settings[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -159,7 +163,7 @@ function getDbFromTaskStore(ctx: PluginContext): PluginDb {
}
function getConnectionOrResponse(ctx: PluginContext): { connection?: WhatsAppConnection; error?: PluginRouteResponse } {
const connection = connections.get(ctx.pluginId);
const connection = connections.get(getConnectionKey(ctx));
if (!connection) {
return { error: { status: 503, body: { error: "WhatsApp connection is not initialized" } } };
}
@@ -242,14 +246,15 @@ const plugin: FusionPlugin = definePlugin({
onLoad: async (ctx) => {
const db = getDbFromTaskStore(ctx);
const connection = new WhatsAppConnection(ctx, plugin.manifest.version, generateReply, db);
connections.set(ctx.pluginId, connection);
connections.set(getConnectionKey(ctx), connection);
await connection.start();
},
onUnload: async () => {
for (const [pluginId, connection] of connections.entries()) {
await connection.stop();
connections.delete(pluginId);
}
onUnload: async (ctx) => {
const connectionKey = getConnectionKey(ctx);
const connection = connections.get(connectionKey);
if (!connection) return;
await connection.stop();
connections.delete(connectionKey);
},
},
});