mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed: automation-events.ts, metrics/automation-bridge.ts and the hostSessionStatus singleton, now the host.session.status topic. Both invariants the old code relied on are preserved. Publishing stays fire-and-forget, so a failing subscriber cannot disturb a metrics poll or a host delete. The automations engine subscribes to the bus at scheduler start instead of being imported ad hoc, which keeps the edge one-directional: automations may import repositories, hosts modules must not import automations.
This commit is contained in:
1 parent
0a771c9600
commit
1393fa7581
9 files changed
+433
-52
No files matched your search
@@ -0,0 +1,185 @@
|
||||
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.hostMetrics).toBe("host.metrics");
|
||||
expect(TOPICS.hostStatus).toBe("host.status");
|
||||
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
|
||||
});
|
||||
});
|
||||
|
||||
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("notifyAutomationInternalEvent emits internal.event", async () => {
|
||||
const { notifyAutomationInternalEvent } =
|
||||
await import("../../hosts/automation-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 });
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
event: "host_deleted",
|
||||
userId: "user-1",
|
||||
hostId: 42,
|
||||
details: { a: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("notifyAutomationInternalEvent ignores a missing userId", async () => {
|
||||
const { notifyAutomationInternalEvent } =
|
||||
await import("../../hosts/automation-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", "");
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
|
||||
const { notifyAutomationStatus, notifyAutomationMetrics } =
|
||||
await import("../../hosts/metrics/automation-bridge.js");
|
||||
|
||||
const status = vi.fn();
|
||||
const metrics = vi.fn();
|
||||
pluginEvents.on(TOPICS.hostStatus, status);
|
||||
pluginEvents.on(TOPICS.hostMetrics, metrics);
|
||||
|
||||
notifyAutomationStatus(42, "user-1", true);
|
||||
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
online: true,
|
||||
});
|
||||
expect(metrics).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
metrics: { cpu: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user