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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user