/** * @file Unit tests for useNotifications — which websocket events raise a * browser notification, and which preference gates each one. Covers issue #29: * "Claude needs confirmation" and "Claude finished a turn" must fire out of the * box once notifications are enabled, since those are the two moments the user * is the blocker. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { useNotifications } from "../useNotifications"; import { eventBus } from "../../lib/eventBus"; import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs"; import type { DashboardEvent } from "../../lib/types"; function enable(patch: Partial = {}) { localStorage.setItem( NOTIF_KEY, JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch }) ); } function emit(event_type: string, summary: string) { eventBus.publish({ type: "new_event", timestamp: new Date().toISOString(), data: { event_type, summary } as DashboardEvent, }); } let fetchMock: ReturnType; describe("useNotifications", () => { beforeEach(() => { localStorage.clear(); // notify() posts to /api/push/send first and only falls back to a local // Notification when that throws, so the fetch spy is what we assert on. fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: true, pushed: 1 }) }); vi.stubGlobal("fetch", fetchMock); vi.stubGlobal( "Notification", Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification ); }); afterEach(() => { vi.unstubAllGlobals(); }); function sentTitles() { return fetchMock.mock.calls .filter((c) => c[0] === "/api/push/send") .map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string); } it("notifies on a confirmation request and on the end of a turn by default", async () => { enable(); renderHook(() => useNotifications()); emit("Notification", "Claude needs your permission to use Bash"); emit("Stop", "Done"); await vi.waitFor(() => expect(sentTitles()).toHaveLength(2)); expect(sentTitles()[0]).toBe("Claude needs your confirmation"); expect(sentTitles()[1]).toBe("Claude Finished Responding"); }); it("respects each gate independently", async () => { enable({ onAwaitingInput: false }); renderHook(() => useNotifications()); emit("Notification", "needs permission"); emit("Stop", "Done"); await vi.waitFor(() => expect(sentTitles()).toHaveLength(1)); expect(sentTitles()[0]).toBe("Claude Finished Responding"); }); it("shows a local notification when the push landed nowhere", async () => { // What a browser with no push subscription gets back: HTTP 200, zero // deliveries. Before this, the 200 alone counted as success and the user // saw nothing at all. fetchMock.mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: false, pushed: 0, failed: 0 }), }); enable(); renderHook(() => useNotifications()); emit("Stop", "Done"); await vi.waitFor(() => expect(Notification).toHaveBeenCalledTimes(1)); expect(vi.mocked(Notification).mock.calls[0]?.[0]).toBe("Claude Finished Responding"); }); it("does not double up when the push was delivered", async () => { enable(); renderHook(() => useNotifications()); emit("Stop", "Done"); await vi.waitFor(() => expect(sentTitles()).toHaveLength(1)); await new Promise((r) => setTimeout(r, 20)); expect(Notification).not.toHaveBeenCalled(); }); it("stays silent while notifications are disabled", async () => { renderHook(() => useNotifications()); emit("Notification", "needs permission"); emit("Stop", "Done"); await new Promise((r) => setTimeout(r, 20)); expect(sentTitles()).toHaveLength(0); }); });