diff --git a/frontend/src/components/workspace/settings/notification-settings-page.tsx b/frontend/src/components/workspace/settings/notification-settings-page.tsx index 5fbdb5002..fa876c6ba 100644 --- a/frontend/src/components/workspace/settings/notification-settings-page.tsx +++ b/frontend/src/components/workspace/settings/notification-settings-page.tsx @@ -54,6 +54,7 @@ export function NotificationSettingsPage() {
{t.settings.notification.description}
("default"); const [isSupported, setIsSupported] = useState(false); - const lastNotificationTime = useRef(new Date()); + const lastNotificationTime = useRef(null); useEffect(() => { // Check if browser supports Notification API @@ -60,19 +60,25 @@ export function useNotification(): UseNotificationReturn { return; } + const currentPermission = Notification.permission; + if (currentPermission !== permission) { + setPermission(currentPermission); + } + + if (currentPermission !== "granted") { + console.warn("Notification permission not granted"); + return; + } + + const now = Date.now(); if ( - new Date().getTime() - lastNotificationTime.current.getTime() < - 1000 + lastNotificationTime.current !== null && + now - lastNotificationTime.current < 1000 ) { console.warn("Notification sent too soon"); return; } - lastNotificationTime.current = new Date(); - - if (permission !== "granted") { - console.warn("Notification permission not granted"); - return; - } + lastNotificationTime.current = now; const notification = new Notification(title, options); diff --git a/frontend/tests/e2e/settings-notification.spec.ts b/frontend/tests/e2e/settings-notification.spec.ts new file mode 100644 index 000000000..7248ccdb8 --- /dev/null +++ b/frontend/tests/e2e/settings-notification.spec.ts @@ -0,0 +1,115 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +declare global { + interface Window { + __deerflowNotifications?: Array<{ title: string; body?: string }>; + } +} + +async function installNotificationMock( + page: Page, + initialPermission: NotificationPermission = "default", +) { + await page.addInitScript((permission) => { + window.__deerflowNotifications = []; + + class MockNotification { + static permission: NotificationPermission = permission; + + static async requestPermission(): Promise { + MockNotification.permission = "granted"; + return "granted"; + } + + onclick: (() => void) | null = null; + onerror: ((error: Event) => void) | null = null; + closed = false; + + constructor(title: string, options?: NotificationOptions) { + window.__deerflowNotifications?.push({ + title, + body: options?.body, + }); + } + + close() { + this.closed = true; + } + } + + Object.defineProperty(window, "Notification", { + configurable: true, + value: MockNotification, + }); + }, initialPermission); +} + +async function openNotificationSettings(page: Page) { + await page.goto("/workspace/chats/new"); + const sidebar = page.locator("[data-sidebar='sidebar']"); + await sidebar.getByRole("button", { name: /Settings and more/ }).click(); + await page.getByRole("menuitem", { name: "Settings" }).click(); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Notification" }).click(); + return dialog; +} + +test.describe("Notification settings", () => { + test("can request permission and send the first test notification immediately", async ({ + page, + }) => { + mockLangGraphAPI(page); + await installNotificationMock(page); + + const dialog = await openNotificationSettings(page); + await dialog + .getByRole("button", { name: "Request notification permission" }) + .click(); + + await expect( + dialog.getByRole("switch", { name: "Notification" }), + ).toBeChecked(); + + await dialog + .getByRole("button", { name: "Send test notification" }) + .click(); + + await expect + .poll(() => page.evaluate(() => window.__deerflowNotifications ?? [])) + .toEqual([ + { + title: "DeerFlow", + body: "This is a test notification.", + }, + ]); + }); + + test("sends a completion notification when chat finishes while the page is unfocused", async ({ + page, + }) => { + mockLangGraphAPI(page); + await installNotificationMock(page, "granted"); + await page.addInitScript(() => { + Document.prototype.hasFocus = () => false; + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("Run a one minute task"); + await textarea.press("Enter"); + + await expect + .poll(() => page.evaluate(() => window.__deerflowNotifications ?? [])) + .toEqual([ + { + title: "New Chat", + body: "Hello from DeerFlow!", + }, + ]); + }); +}); diff --git a/frontend/tests/unit/core/notification/hooks.test.ts b/frontend/tests/unit/core/notification/hooks.test.ts new file mode 100644 index 000000000..4833755d1 --- /dev/null +++ b/frontend/tests/unit/core/notification/hooks.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test, rs } from "@rstest/core"; + +type NotificationInstance = { + onclick: (() => void) | null; + onerror: ((error: Event) => void) | null; + close: () => void; +}; + +async function loadNotificationHook({ + browserPermission = "granted", + hookPermission = "granted", +}: { + browserPermission?: NotificationPermission; + hookPermission?: NotificationPermission; +} = {}) { + const notifications: Array<{ + title: string; + options: NotificationOptions | undefined; + instance: NotificationInstance; + }> = []; + + class MockNotification implements NotificationInstance { + static permission: NotificationPermission = browserPermission; + static requestPermission = rs.fn(async () => { + MockNotification.permission = "granted"; + return "granted" as const; + }); + + onclick: (() => void) | null = null; + onerror: ((error: Event) => void) | null = null; + close = rs.fn(); + + constructor(title: string, options?: NotificationOptions) { + notifications.push({ title, options, instance: this }); + } + } + + rs.resetModules(); + rs.doMock("react", () => ({ + useCallback: unknown>(callback: T) => + callback, + useEffect: () => undefined, + useRef: (initialValue: T) => ({ current: initialValue }), + useState: (initialValue: T) => { + if (initialValue === "default") { + return [hookPermission, rs.fn()] as const; + } + if (initialValue === false) { + return [true, rs.fn()] as const; + } + return [initialValue, rs.fn()] as const; + }, + })); + rs.doMock("@/core/settings", () => ({ + useLocalSettings: () => [ + { + notification: { enabled: true }, + tokenUsage: { headerTotal: true, inlineMode: "per_turn" }, + context: { + model_name: undefined, + mode: undefined, + reasoning_effort: undefined, + }, + }, + rs.fn(), + ], + })); + rs.stubGlobal("window", { focus: rs.fn() }); + rs.stubGlobal("Notification", MockNotification); + + const { useNotification } = await import("@/core/notification/hooks"); + + return { + notifications, + useNotification, + }; +} + +afterEach(() => { + rs.doUnmock("react"); + rs.doUnmock("@/core/settings"); + rs.unstubAllGlobals(); + rs.resetModules(); +}); + +describe("useNotification", () => { + test("allows the first notification immediately after the hook is created", async () => { + const { notifications, useNotification } = await loadNotificationHook(); + const { showNotification } = useNotification(); + + showNotification("Finished", { body: "Conversation finished" }); + + expect(notifications).toHaveLength(1); + expect(notifications[0]?.title).toBe("Finished"); + expect(notifications[0]?.options?.body).toBe("Conversation finished"); + }); + + test("rate limits only after a notification has been sent", async () => { + const { notifications, useNotification } = await loadNotificationHook(); + const { showNotification } = useNotification(); + + showNotification("First"); + showNotification("Second"); + + expect(notifications.map((notification) => notification.title)).toEqual([ + "First", + ]); + }); + + test("uses the browser's current permission when another hook requested it", async () => { + const { notifications, useNotification } = await loadNotificationHook({ + browserPermission: "granted", + hookPermission: "default", + }); + const { showNotification } = useNotification(); + + showNotification("Finished elsewhere"); + + expect(notifications.map((notification) => notification.title)).toEqual([ + "Finished elsewhere", + ]); + }); +});