/** * @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. Also: a shown notification plays its type's sound, else the * default one, and "none" plays nothing. * @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("plays the type's own sound, else the default, and nothing for none", async () => { const played: string[] = []; vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function ( this: HTMLMediaElement ) { played.push(this.src.split("/").pop()!); return Promise.resolve(); }); // The hook's own lane list must read as a list, or a later event finds none. fetchMock.mockImplementation(async (url: string) => ({ ok: true, json: async () => url === "/api/push/send" ? { ok: true, native: true, pushed: 1 } : { lanes: [], counts: {} }, })); enable({ sound: "chime", soundByType: { onTurnComplete: "pop", onAwaitingInput: "none" } }); renderHook(() => useNotifications()); emit("Stop", "Done"); await vi.waitFor(() => expect(played).toEqual(["pop.wav"])); emit("Notification", "needs permission"); await vi.waitFor(() => expect(sentTitles()).toHaveLength(2)); await new Promise((r) => setTimeout(r, 20)); expect(played).toEqual(["pop.wav"]); localStorage.setItem( NOTIF_KEY, JSON.stringify({ ...defaultNotifPrefs, enabled: true, sound: "chime", onSessionComplete: true, }) ); emit("SessionEnd", "Closed"); await vi.waitFor(() => expect(played).toEqual(["pop.wav", "chime.wav"])); vi.restoreAllMocks(); }); it("links a notification to the pane that holds the session, in any slot", async () => { fetchMock.mockImplementation(async (url: string) => ({ ok: true, json: async () => url === "/api/push/send" ? { ok: true, native: true, pushed: 1 } : url === "/api/run" ? { items: [ { id: "agenthub-lane-4-2", laneId: 4, status: "running", sessionId: "s-7" }, ], } : { lanes: [], counts: {} }, })); enable(); renderHook(() => useNotifications()); eventBus.publish({ type: "new_event", timestamp: new Date().toISOString(), data: { event_type: "Stop", summary: "Done", session_id: "s-7" } as DashboardEvent, }); await vi.waitFor(() => expect(sentTitles()).toHaveLength(1)); const push = fetchMock.mock.calls.find((c) => c[0] === "/api/push/send")!; expect(JSON.parse((push[1] as RequestInit).body as string).url).toBe( "/workspace?lane=4&run=agenthub-lane-4-2" ); }); 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); }); });