diff --git a/src/infra/heartbeat-events.test.ts b/src/infra/heartbeat-events.test.ts new file mode 100644 index 00000000000..d1583f8080a --- /dev/null +++ b/src/infra/heartbeat-events.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + emitHeartbeatEvent, + getLastHeartbeatEvent, + onHeartbeatEvent, + resolveIndicatorType, +} from "./heartbeat-events.js"; + +describe("resolveIndicatorType", () => { + it("maps heartbeat statuses to indicator types", () => { + expect(resolveIndicatorType("ok-empty")).toBe("ok"); + expect(resolveIndicatorType("ok-token")).toBe("ok"); + expect(resolveIndicatorType("sent")).toBe("alert"); + expect(resolveIndicatorType("failed")).toBe("error"); + expect(resolveIndicatorType("skipped")).toBeUndefined(); + }); +}); + +describe("heartbeat events", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-09T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stores the last event and timestamps emitted payloads", () => { + emitHeartbeatEvent({ status: "sent", to: "+123", preview: "ping" }); + + expect(getLastHeartbeatEvent()).toEqual({ + ts: 1767960000000, + status: "sent", + to: "+123", + preview: "ping", + }); + }); + + it("delivers events to listeners, isolates listener failures, and supports unsubscribe", () => { + const seen: string[] = []; + const unsubscribeFirst = onHeartbeatEvent((evt) => { + seen.push(`first:${evt.status}`); + }); + onHeartbeatEvent(() => { + throw new Error("boom"); + }); + const unsubscribeThird = onHeartbeatEvent((evt) => { + seen.push(`third:${evt.status}`); + }); + + emitHeartbeatEvent({ status: "ok-empty" }); + unsubscribeFirst(); + unsubscribeThird(); + emitHeartbeatEvent({ status: "failed" }); + + expect(seen).toEqual(["first:ok-empty", "third:ok-empty"]); + }); +}); diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts new file mode 100644 index 00000000000..c8e6ea7e124 --- /dev/null +++ b/src/infra/outbound/target-normalization.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const normalizeChannelIdMock = vi.hoisted(() => vi.fn()); +const getChannelPluginMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), +})); + +import { + buildTargetResolverSignature, + normalizeChannelTargetInput, + normalizeTargetForProvider, +} from "./target-normalization.js"; + +describe("normalizeChannelTargetInput", () => { + it("trims raw target input", () => { + expect(normalizeChannelTargetInput(" channel:C1 ")).toBe("channel:C1"); + }); +}); + +describe("normalizeTargetForProvider", () => { + beforeEach(() => { + normalizeChannelIdMock.mockReset(); + getChannelPluginMock.mockReset(); + getActivePluginRegistryVersionMock.mockReset(); + }); + + it("returns undefined for missing or blank raw input", () => { + expect(normalizeTargetForProvider("telegram")).toBeUndefined(); + expect(normalizeTargetForProvider("telegram", " ")).toBeUndefined(); + }); + + it("falls back to trimmed input when the provider is unknown or has no normalizer", () => { + normalizeChannelIdMock.mockReturnValueOnce(null); + expect(normalizeTargetForProvider("unknown", " raw-id ")).toBe("raw-id"); + + normalizeChannelIdMock.mockReturnValueOnce("telegram"); + getActivePluginRegistryVersionMock.mockReturnValueOnce(1); + getChannelPluginMock.mockReturnValueOnce(undefined); + expect(normalizeTargetForProvider("telegram", " raw-id ")).toBe("raw-id"); + }); + + it("uses the cached target normalizer until the plugin registry version changes", () => { + const firstNormalizer = vi.fn((raw: string) => raw.trim().toUpperCase()); + const secondNormalizer = vi.fn((raw: string) => `next:${raw.trim()}`); + normalizeChannelIdMock.mockReturnValue("telegram"); + getActivePluginRegistryVersionMock + .mockReturnValueOnce(10) + .mockReturnValueOnce(10) + .mockReturnValueOnce(11); + getChannelPluginMock + .mockReturnValueOnce({ + messaging: { normalizeTarget: firstNormalizer }, + }) + .mockReturnValueOnce({ + messaging: { normalizeTarget: secondNormalizer }, + }); + + expect(normalizeTargetForProvider("telegram", " abc ")).toBe("ABC"); + expect(normalizeTargetForProvider("telegram", " def ")).toBe("DEF"); + expect(normalizeTargetForProvider("telegram", " ghi ")).toBe("next:ghi"); + + expect(getChannelPluginMock).toHaveBeenCalledTimes(2); + expect(firstNormalizer).toHaveBeenCalledTimes(2); + expect(secondNormalizer).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when the provider normalizer resolves to an empty value", () => { + normalizeChannelIdMock.mockReturnValueOnce("telegram"); + getActivePluginRegistryVersionMock.mockReturnValueOnce(20); + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + normalizeTarget: () => "", + }, + }); + + expect(normalizeTargetForProvider("telegram", " raw-id ")).toBeUndefined(); + }); +}); + +describe("buildTargetResolverSignature", () => { + beforeEach(() => { + getChannelPluginMock.mockReset(); + }); + + it("builds stable signatures from resolver hint and looksLikeId source", () => { + const looksLikeId = (value: string) => value.startsWith("C"); + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + targetResolver: { + hint: "Use channel id", + looksLikeId, + }, + }, + }); + + const first = buildTargetResolverSignature("slack"); + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + targetResolver: { + hint: "Use channel id", + looksLikeId, + }, + }, + }); + const second = buildTargetResolverSignature("slack"); + + expect(first).toBe(second); + }); + + it("changes when resolver metadata changes", () => { + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + targetResolver: { + hint: "Use channel id", + looksLikeId: (value: string) => value.startsWith("C"), + }, + }, + }); + const first = buildTargetResolverSignature("slack"); + + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + targetResolver: { + hint: "Use user id", + looksLikeId: (value: string) => value.startsWith("U"), + }, + }, + }); + const second = buildTargetResolverSignature("slack"); + + expect(first).not.toBe(second); + }); +}); diff --git a/src/infra/outbound/tool-payload.test.ts b/src/infra/outbound/tool-payload.test.ts new file mode 100644 index 00000000000..08629089618 --- /dev/null +++ b/src/infra/outbound/tool-payload.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { extractToolPayload } from "./tool-payload.js"; + +describe("extractToolPayload", () => { + it("prefers explicit details payloads", () => { + expect( + extractToolPayload({ + details: { ok: true }, + content: [{ type: "text", text: '{"ignored":true}' }], + } as never), + ).toEqual({ ok: true }); + }); + + it("parses JSON text blocks from tool content", () => { + expect( + extractToolPayload({ + content: [ + { type: "image", url: "https://example.com/a.png" }, + { type: "text", text: '{"ok":true,"count":2}' }, + ], + } as never), + ).toEqual({ ok: true, count: 2 }); + }); + + it("falls back to raw text, then content, then the whole result", () => { + expect( + extractToolPayload({ + content: [{ type: "text", text: "not json" }], + } as never), + ).toBe("not json"); + + const content = [{ type: "image", url: "https://example.com/a.png" }]; + expect( + extractToolPayload({ + content, + } as never), + ).toBe(content); + + const result = { status: "ok" }; + expect(extractToolPayload(result as never)).toBe(result); + }); +});