| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { beforeEach, expect, vi } from "vite-plus/test"; |
| 3 | import { it } from "@effect/vitest"; |
| 4 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 5 | |
| 6 | const native = vi.hoisted(() => ({ |
| 7 | setBadgeCount: vi.fn(), |
| 8 | setOverlayIcon: vi.fn(), |
| 9 | isDestroyed: vi.fn(() => false), |
| 10 | getFocusedWindow: vi.fn(() => null as object | null), |
| 11 | image: { isEmpty: vi.fn(() => false) }, |
| 12 | createFromDataURL: vi.fn(), |
| 13 | webContents: { send: vi.fn() }, |
| 14 | listeners: new Map<string, () => void>(), |
| 15 | })); |
| 16 | vi.mock("electron", () => ({ |
| 17 | app: { |
| 18 | setBadgeCount: native.setBadgeCount, |
| 19 | on: (event: string, listener: () => void) => native.listeners.set(event, listener), |
| 20 | removeListener: (event: string) => native.listeners.delete(event), |
| 21 | }, |
| 22 | BrowserWindow: { |
| 23 | getFocusedWindow: native.getFocusedWindow, |
| 24 | getAllWindows: () => [native], |
| 25 | }, |
| 26 | nativeImage: { createFromDataURL: native.createFromDataURL }, |
| 27 | })); |
| 28 | |
| 29 | import * as ElectronApp from "../../electron/ElectronApp.ts"; |
| 30 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 31 | import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; |
| 32 | |
| 33 | const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; |
| 34 | |
| 35 | beforeEach(() => { |
| 36 | vi.clearAllMocks(); |
| 37 | native.getFocusedWindow.mockReturnValue(null); |
| 38 | native.isDestroyed.mockReturnValue(false); |
| 39 | native.image.isEmpty.mockReturnValue(false); |
| 40 | native.createFromDataURL.mockReturnValue(native.image); |
| 41 | native.setBadgeCount.mockImplementation(() => true); |
| 42 | native.listeners.clear(); |
| 43 | }); |
| 44 | |
| 45 | it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { |
| 46 | applyNotificationBadge(platform, badge); |
| 47 | applyNotificationBadge(platform, { count: 0, image: null }); |
| 48 | expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); |
| 49 | expect(native.createFromDataURL).not.toHaveBeenCalled(); |
| 50 | }); |
| 51 | |
| 52 | it("sets and clears the Windows taskbar overlay", () => { |
| 53 | applyNotificationBadge("win32", badge); |
| 54 | expect(native.setOverlayIcon).toHaveBeenLastCalledWith( |
| 55 | native.image, |
| 56 | "2 threads with new notifications", |
| 57 | ); |
| 58 | applyNotificationBadge("win32", { count: 0, image: null }); |
| 59 | expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); |
| 60 | }); |
| 61 | |
| 62 | it.each(["win32", "darwin", "linux"] as const)( |
| 63 | "rejects a late positive count while %s is focused", |
| 64 | (platform) => { |
| 65 | native.getFocusedWindow.mockReturnValue({}); |
| 66 | applyNotificationBadge(platform, badge); |
| 67 | if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); |
| 68 | else expect(native.setBadgeCount).toHaveBeenCalledWith(0); |
| 69 | expect(native.createFromDataURL).not.toHaveBeenCalled(); |
| 70 | }, |
| 71 | ); |
| 72 | |
| 73 | it("ignores destroyed windows and clears invalid images", () => { |
| 74 | native.isDestroyed.mockReturnValue(true); |
| 75 | applyNotificationBadge("win32", badge); |
| 76 | expect(native.setOverlayIcon).not.toHaveBeenCalled(); |
| 77 | native.isDestroyed.mockReturnValue(false); |
| 78 | native.image.isEmpty.mockReturnValue(true); |
| 79 | applyNotificationBadge("win32", badge); |
| 80 | expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); |
| 81 | }); |
| 82 | |
| 83 | it("keeps notifications working when the native badge API fails", () => { |
| 84 | native.setBadgeCount.mockImplementation(() => { |
| 85 | throw new Error("Unavailable"); |
| 86 | }); |
| 87 | expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); |
| 88 | }); |
| 89 | |
| 90 | it.effect("validates IPC and clears on native focus, quit, and disposal", () => |
| 91 | Effect.gen(function* () { |
| 92 | const handlers = new Map<string, DesktopIpc.DesktopIpcHandleListener>(); |
| 93 | yield* Effect.scoped( |
| 94 | Effect.gen(function* () { |
| 95 | yield* installNotificationBadge(); |
| 96 | const handler = handlers.get("desktop:set-notification-badge")!; |
| 97 | const event = { sender: { id: 1 } }; |
| 98 | for (const invalid of [ |
| 99 | { ...badge, count: -1 }, |
| 100 | { ...badge, count: 0.5 }, |
| 101 | { ...badge, count: Infinity }, |
| 102 | { ...badge, image: "https://example.com/icon.png" }, |
| 103 | { ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` }, |
| 104 | ]) { |
| 105 | yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined()); |
| 106 | } |
| 107 | expect(native.setBadgeCount).not.toHaveBeenCalled(); |
| 108 | yield* Effect.promise(() => Promise.resolve(handler(event, badge))); |
| 109 | expect(native.setBadgeCount).toHaveBeenLastCalledWith(2); |
| 110 | native.listeners.get("browser-window-focus")!(); |
| 111 | expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); |
| 112 | expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge"); |
| 113 | native.getFocusedWindow.mockReturnValue({}); |
| 114 | yield* Effect.promise(() => Promise.resolve(handler(event, badge))); |
| 115 | expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); |
| 116 | expect(native.webContents.send).toHaveBeenCalledTimes(2); |
| 117 | yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null }))); |
| 118 | expect(native.webContents.send).toHaveBeenCalledTimes(2); |
| 119 | native.getFocusedWindow.mockReturnValue(null); |
| 120 | yield* Effect.promise(() => Promise.resolve(handler(event, badge))); |
| 121 | native.listeners.get("before-quit")!(); |
| 122 | expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); |
| 123 | }), |
| 124 | ).pipe( |
| 125 | Effect.provideService(HostProcess.Platform, "linux"), |
| 126 | Effect.provide([ |
| 127 | ElectronApp.layer, |
| 128 | DesktopIpc.layer({ |
| 129 | handle: (channel, handler) => { |
| 130 | handlers.set(channel, handler); |
| 131 | }, |
| 132 | removeHandler: (channel) => { |
| 133 | handlers.delete(channel); |
| 134 | }, |
| 135 | on: vi.fn(), |
| 136 | removeAllListeners: vi.fn(), |
| 137 | }), |
| 138 | ]), |
| 139 | ); |
| 140 | expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); |
| 141 | expect(native.listeners.size).toBe(0); |
| 142 | expect(handlers.size).toBe(0); |
| 143 | }), |
| 144 | ); |