1import * as Effect from "effect/Effect";
2import { beforeEach, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5
6const 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}));
16vi.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
29import * as ElectronApp from "../../electron/ElectronApp.ts";
30import * as DesktopIpc from "../DesktopIpc.ts";
31import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts";
32
33const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" };
34
35beforeEach(() => {
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
45it.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
52it("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
62it.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
73it("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
83it("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
90it.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);