- Add ntfy settings (ntfyEnabled, ntfyTopic) to core types and config storage - Create NtfyNotifier service with event-based notifications for task completion/failure - Add Notifications section to SettingsModal for configuring ntfy topic - Wire up NtfyNotifier in engine to send notifications on task state changes - Include ntfy topic in task templates for per-task notification override - Add comprehensive tests for NtfyNotifier service
468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import type { Task, Column, MergeResult, Settings } from "@kb/core";
|
|
import { NtfyNotifier } from "./notifier.js";
|
|
|
|
// Mock the logger
|
|
vi.mock("./logger.js", () => ({
|
|
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
interface MockTaskStoreEvents {
|
|
"task:moved": [{ task: Task; from: Column; to: Column }];
|
|
"task:updated": [Task];
|
|
"task:merged": [MergeResult];
|
|
"settings:updated": [{ settings: Settings; previous: Settings }];
|
|
}
|
|
|
|
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
|
private settings: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
ntfyEnabled: false,
|
|
ntfyTopic: undefined,
|
|
};
|
|
|
|
getSettings(): Settings {
|
|
return { ...this.settings };
|
|
}
|
|
|
|
setSettings(settings: Partial<Settings>): void {
|
|
const previous = { ...this.settings };
|
|
this.settings = { ...this.settings, ...settings };
|
|
this.emit("settings:updated", { settings: this.settings, previous });
|
|
}
|
|
|
|
// Helper to trigger events
|
|
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
|
this.emit("task:moved", { task, from, to });
|
|
}
|
|
|
|
triggerTaskUpdated(task: Task): void {
|
|
this.emit("task:updated", task);
|
|
}
|
|
|
|
triggerTaskMerged(result: MergeResult): void {
|
|
this.emit("task:merged", result);
|
|
}
|
|
}
|
|
|
|
describe("NtfyNotifier", () => {
|
|
let store: MockTaskStore;
|
|
let notifier: NtfyNotifier;
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
store = new MockTaskStore();
|
|
fetchMock = vi.fn();
|
|
global.fetch = fetchMock;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (notifier) {
|
|
notifier.stop();
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const createTask = (id: string, title?: string, status?: string): Task => ({
|
|
id,
|
|
title,
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
status,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
log: [],
|
|
});
|
|
|
|
describe("when disabled", () => {
|
|
it("does not send any notifications when ntfyEnabled is false", async () => {
|
|
store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
|
|
// Wait for any async operations
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send notifications when ntfyTopic is not set", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: undefined });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when enabled", () => {
|
|
beforeEach(() => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
});
|
|
|
|
it("sends notification when task moves to in-review", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://ntfy.sh/test-topic",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
"Title": "Task KB-001 completed",
|
|
"Priority": "default",
|
|
}),
|
|
body: 'Task "Test Task" is ready for review',
|
|
})
|
|
);
|
|
});
|
|
|
|
it("sends notification when task moves to done", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-review", "done");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://ntfy.sh/test-topic",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
"Title": "Task KB-001 merged",
|
|
"Priority": "default",
|
|
}),
|
|
body: 'Task "Test Task" has been merged to main',
|
|
})
|
|
);
|
|
});
|
|
|
|
it("sends high priority notification when task fails", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const failedTask = createTask("KB-001", "Test Task", "failed");
|
|
store.triggerTaskUpdated(failedTask);
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://ntfy.sh/test-topic",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
"Title": "Task KB-001 failed",
|
|
"Priority": "high",
|
|
}),
|
|
body: 'Task "Test Task" has failed and needs attention',
|
|
})
|
|
);
|
|
});
|
|
|
|
it("sends notification when task is merged", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const mergeResult: MergeResult = {
|
|
task: createTask("KB-001", "Test Task"),
|
|
branch: "kb/kb-001",
|
|
merged: true,
|
|
worktreeRemoved: true,
|
|
branchDeleted: true,
|
|
};
|
|
store.triggerTaskMerged(mergeResult);
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://ntfy.sh/test-topic",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
"Title": "Task KB-001 merged",
|
|
"Priority": "default",
|
|
}),
|
|
body: 'Task "Test Task" has been merged to main',
|
|
})
|
|
);
|
|
});
|
|
|
|
it("does not send notification for failed merges", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const mergeResult: MergeResult = {
|
|
task: createTask("KB-001", "Test Task"),
|
|
branch: "kb/kb-001",
|
|
merged: false,
|
|
worktreeRemoved: false,
|
|
branchDeleted: false,
|
|
error: "Merge conflict",
|
|
};
|
|
store.triggerTaskMerged(mergeResult);
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses task ID when title is not available", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001"), "in-progress", "in-review");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://ntfy.sh/test-topic",
|
|
expect.objectContaining({
|
|
body: 'Task "KB-001" is ready for review',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("runtime reconfiguration", () => {
|
|
it("starts sending notifications when enabled at runtime", async () => {
|
|
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
// Initially disabled
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
// Enable at runtime
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
store.setSettings({ ntfyEnabled: true });
|
|
|
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("stops sending notifications when disabled at runtime", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
// Initially enabled
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Disable at runtime
|
|
store.setSettings({ ntfyEnabled: false });
|
|
|
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
|
});
|
|
|
|
it("uses updated topic when changed at runtime", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
|
|
|
// Change topic
|
|
store.setSettings({ ntfyTopic: "new-topic" });
|
|
|
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("catches and logs fetch errors without throwing", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockRejectedValue(new Error("Network error"));
|
|
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
// Should not throw
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles HTTP error responses without throwing", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
|
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
// Should not throw
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("debouncing", () => {
|
|
beforeEach(() => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
});
|
|
|
|
it("prevents duplicate notifications within debounce window", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const task = createTask("KB-001", "Test Task");
|
|
|
|
// Rapid transitions
|
|
store.triggerTaskMoved(task, "in-progress", "in-review");
|
|
store.triggerTaskMoved(task, "in-review", "done");
|
|
store.triggerTaskMoved(task, "done", "in-review");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Should only send one notification due to debouncing
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows notifications after debounce window", async () => {
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const task = createTask("KB-001", "Test Task");
|
|
|
|
store.triggerTaskMoved(task, "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Wait for debounce window (5 seconds) - use fake timers or access internal state
|
|
// For this test, we'll create a new task to verify separate tasks aren't debounced together
|
|
const task2 = createTask("KB-002", "Test Task 2");
|
|
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Different task ID should get its own notification
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("custom base URL", () => {
|
|
it("uses custom ntfy base URL when provided", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
|
|
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://my-ntfy.example.com/test-topic",
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("stop()", () => {
|
|
it("stops listening to events after stop() is called", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
notifier.stop();
|
|
|
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Should not increase after stop
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("does not notify on task:moved to columns other than in-review or done", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
// Move to todo - should not notify
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "triage", "todo");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Move to in-progress - should not notify
|
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "todo", "in-progress");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not notify on task:updated when status is not failed", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
const task = createTask("KB-001", "Test Task", "in-progress");
|
|
store.triggerTaskUpdated(task);
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles empty topic gracefully", async () => {
|
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
|
fetchMock.mockResolvedValue({ ok: true });
|
|
notifier = new NtfyNotifier(store);
|
|
await notifier.start();
|
|
|
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Empty topic should be treated as no topic
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|