This merge delivers five features: a session switcher for the chat header with mobile-aware dropdown styling and proper ARIA state, mailbox notification events with deep-link highlighting to the unread task, a fix for org chart connector endpoints in wide subtrees, a correction to merge finalize so Fusion-Task-Id: FN-3800
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
sendNtfyNotification: vi.fn(async () => undefined),
|
|
buildNtfyClickUrl: vi.fn(() => "http://dash/?project=p1&task=FN-1"),
|
|
}));
|
|
|
|
vi.mock("../notifier.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../notifier.js")>();
|
|
return {
|
|
...actual,
|
|
sendNtfyNotification: mocks.sendNtfyNotification,
|
|
buildNtfyClickUrl: mocks.buildNtfyClickUrl,
|
|
};
|
|
});
|
|
|
|
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
|
|
|
describe("NtfyNotificationProvider", () => {
|
|
let provider: NtfyNotificationProvider;
|
|
|
|
beforeEach(async () => {
|
|
mocks.sendNtfyNotification.mockClear();
|
|
mocks.buildNtfyClickUrl.mockClear();
|
|
provider = new NtfyNotificationProvider();
|
|
await provider.initialize({
|
|
topic: "topic-a",
|
|
ntfyBaseUrl: "https://ntfy.local",
|
|
dashboardHost: "http://dash",
|
|
projectId: "p1",
|
|
});
|
|
});
|
|
|
|
it("returns provider id", () => {
|
|
expect(provider.getProviderId()).toBe("ntfy");
|
|
});
|
|
|
|
it.each([
|
|
["in-review", "Task FN-1 completed", "is ready for review", "default"],
|
|
["merged", "Task FN-1 merged", "has been merged to main", "default"],
|
|
["failed", "Task FN-1 failed", "has failed and needs attention", "high"],
|
|
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
|
|
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
|
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
|
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
|
["message:agent-to-user", "New message from agent-1", "agent-1 → you: preview text", "high"],
|
|
["message:agent-to-agent", "agent-1 → agent-2", "agent-1 messaged agent-2: preview text", "default"],
|
|
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
|
await provider.sendNotification(event as any, {
|
|
taskId: "FN-1",
|
|
taskTitle: "T",
|
|
event: event as any,
|
|
metadata: { fromId: "agent-1", toId: "agent-2", preview: "preview text", messageId: "msg-1" },
|
|
});
|
|
|
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
topic: "topic-a",
|
|
title: expectedTitle,
|
|
priority,
|
|
message: expect.stringContaining(messagePart),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("supports known events and rejects unknown", () => {
|
|
expect(provider.isEventSupported("in-review" as any)).toBe(true);
|
|
expect(provider.isEventSupported("merged" as any)).toBe(true);
|
|
expect(provider.isEventSupported("failed" as any)).toBe(true);
|
|
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
|
|
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
|
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
|
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
|
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
|
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
|
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
|
});
|
|
|
|
it("shutdown aborts internal AbortController", async () => {
|
|
await provider.shutdown();
|
|
await provider.sendNotification("in-review" as any, { taskId: "FN-1", taskTitle: "T", event: "in-review" as any });
|
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(expect.objectContaining({ signal: undefined }));
|
|
});
|
|
|
|
it("uses fallback identifier from id+description when no title", async () => {
|
|
await provider.sendNotification("failed" as any, {
|
|
taskId: "FN-1",
|
|
taskDescription: "desc",
|
|
event: "failed" as any,
|
|
});
|
|
|
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: expect.stringContaining('Task "FN-1: desc"') }),
|
|
);
|
|
});
|
|
|
|
it("builds click URL from config for task message events", async () => {
|
|
await provider.sendNotification("message:agent-to-user" as any, {
|
|
taskId: "FN-1",
|
|
taskTitle: "T",
|
|
event: "message:agent-to-user" as any,
|
|
metadata: { messageId: "msg-1", fromId: "agent-1", preview: "hello" },
|
|
});
|
|
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith({
|
|
dashboardHost: "http://dash",
|
|
projectId: "p1",
|
|
taskId: "FN-1",
|
|
messageId: "msg-1",
|
|
view: "mailbox",
|
|
});
|
|
});
|
|
|
|
it("uses mailbox deep link when message is not task-bound", async () => {
|
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
|
event: "message:agent-to-agent" as any,
|
|
metadata: { messageId: "msg-2", fromId: "agent-1", toId: "agent-2", preview: "hello" },
|
|
});
|
|
|
|
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
taskId: undefined,
|
|
messageId: "msg-2",
|
|
view: "mailbox",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses Re: title for agent-to-agent replies", async () => {
|
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
|
event: "message:agent-to-agent" as any,
|
|
metadata: {
|
|
messageId: "msg-3",
|
|
fromId: "agent-1",
|
|
toId: "agent-2",
|
|
preview: "reply preview",
|
|
replyToMessageId: "msg-1",
|
|
},
|
|
});
|
|
|
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "Re: reply preview",
|
|
}),
|
|
);
|
|
});
|
|
});
|