import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../../utils/logger.js", () => ({ pluginLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), }, })); const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); describe("plugin event bus", () => { afterEach(() => { pluginEvents.clear(); vi.clearAllMocks(); }); it("delivers a payload to every subscriber of a topic", () => { const first = vi.fn(); const second = vi.fn(); pluginEvents.on("topic.a", first); pluginEvents.on("topic.a", second); pluginEvents.emit("topic.a", { value: 1 }); expect(first).toHaveBeenCalledWith({ value: 1 }); expect(second).toHaveBeenCalledWith({ value: 1 }); }); it("does not deliver across topics", () => { const listener = vi.fn(); pluginEvents.on("topic.a", listener); pluginEvents.emit("topic.b", {}); expect(listener).not.toHaveBeenCalled(); }); it("emitting a topic with no subscribers is a no-op", () => { expect(() => pluginEvents.emit("nobody.listening", {})).not.toThrow(); }); it("unsubscribes and cleans up the empty topic", () => { const listener = vi.fn(); const off = pluginEvents.on("topic.a", listener); expect(pluginEvents.listenerCount("topic.a")).toBe(1); off(); expect(pluginEvents.listenerCount("topic.a")).toBe(0); pluginEvents.emit("topic.a", {}); expect(listener).not.toHaveBeenCalled(); }); it("a throwing subscriber does not stop the others or the caller", async () => { const after = vi.fn(); pluginEvents.on("topic.a", () => { throw new Error("subscriber exploded"); }); pluginEvents.on("topic.a", after); expect(() => pluginEvents.emit("topic.a", {})).not.toThrow(); expect(after).toHaveBeenCalled(); const { pluginLogger } = await import("../../utils/logger.js"); expect(pluginLogger.error).toHaveBeenCalled(); }); it("a rejecting async subscriber is reported, not left unhandled", async () => { pluginEvents.on("topic.a", async () => { throw new Error("async subscriber exploded"); }); expect(() => pluginEvents.emit("topic.a", {})).not.toThrow(); await new Promise((resolve) => setImmediate(resolve)); const { pluginLogger } = await import("../../utils/logger.js"); expect(pluginLogger.error).toHaveBeenCalled(); }); it("a subscriber added during an emit does not receive that same emit", () => { const late = vi.fn(); pluginEvents.on("topic.a", () => { pluginEvents.on("topic.a", late); }); pluginEvents.emit("topic.a", {}); expect(late).not.toHaveBeenCalled(); pluginEvents.emit("topic.a", {}); expect(late).toHaveBeenCalledTimes(1); }); it("exposes the internal topic names the server publishes", () => { expect(TOPICS.hostSessionStatus).toBe("host.session.status"); expect(TOPICS.internalEvent).toBe("internal.event"); expect(TOPICS.hostStatus).toBe("host.status"); expect(TOPICS.hostKeyUpdated).toBe("host.key.updated"); }); }); describe("hosts publishers route through the bus", () => { // No vi.resetModules() here: the bus is a module singleton, and resetting // would hand these imports a different instance than the one subscribed to. afterEach(() => { pluginEvents.clear(); }); it("emitInternalEvent emits internal.event", async () => { const { emitInternalEvent } = await import("../../hosts/internal-events.js"); const listener = vi.fn(); pluginEvents.on(TOPICS.internalEvent, listener); emitInternalEvent("host_deleted", "user-1", 42, { a: 1 }); expect(listener).toHaveBeenCalledWith({ event: "host_deleted", userId: "user-1", hostId: 42, details: { a: 1 }, }); }); it("emitInternalEvent ignores a missing userId", async () => { const { emitInternalEvent } = await import("../../hosts/internal-events.js"); const listener = vi.fn(); pluginEvents.on(TOPICS.internalEvent, listener); emitInternalEvent("host_deleted", ""); expect(listener).not.toHaveBeenCalled(); }); it("hostSessionStatus publishes host.session.status on the refcount edges", async () => { const { HostSessionStatus } = await import("../../hosts/host-session-status.js"); const seen: unknown[] = []; pluginEvents.on(TOPICS.hostSessionStatus, (p) => seen.push(p)); const status = new HostSessionStatus(); const releaseFirst = status.register(7); const releaseSecond = status.register(7); // Only the 0 -> 1 edge publishes. expect(seen).toEqual([{ hostId: 7, online: true }]); releaseFirst(); expect(seen).toHaveLength(1); // ... and only the 1 -> 0 edge. releaseSecond(); expect(seen).toEqual([ { hostId: 7, online: true }, { hostId: 7, online: false }, ]); }); });