import { beforeEach, describe, expect, it, vi } from 'vitest' const notificationPluginMock = vi.hoisted(() => ({ isPermissionGranted: vi.fn(), requestPermission: vi.fn(), sendNotification: vi.fn(), onAction: vi.fn(), })) const coreApiMock = vi.hoisted(() => ({ invoke: vi.fn(), })) const eventApiMock = vi.hoisted(() => ({ listen: vi.fn(), })) const shellApiMock = vi.hoisted(() => ({ open: vi.fn(), })) const requestUserAttentionMock = vi.hoisted(() => vi.fn()) const windowApiMock = vi.hoisted(() => ({ requestUserAttention: requestUserAttentionMock, getCurrentWindow: vi.fn(() => ({ requestUserAttention: requestUserAttentionMock, })), UserAttentionType: { Critical: 1, Informational: 2, }, })) vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) vi.mock('@tauri-apps/api/core', () => coreApiMock) vi.mock('@tauri-apps/api/event', () => eventApiMock) vi.mock('@tauri-apps/api/window', () => windowApiMock) vi.mock('@tauri-apps/plugin-shell', () => shellApiMock) import { getDesktopNotificationPermission, installDesktopNotificationClickListener, notifyDesktop, openDesktopNotificationSettings, requestDesktopNotificationPermission, resetDesktopNotificationsForTests, setNativeNotificationSenderForTests, } from './desktopNotifications' import { browserHost } from './desktopHost/browserHost' import { useSettingsStore } from '../stores/settingsStore' describe('desktopNotifications', () => { const installElectronNotificationHost = () => { window.desktopHost = { ...browserHost, kind: 'electron', isDesktop: true, capabilities: { ...browserHost.capabilities, notifications: true, shell: true, }, commands: { ...browserHost.commands, invoke: (command, args) => args === undefined ? coreApiMock.invoke(command) : coreApiMock.invoke(command, args), }, events: { ...browserHost.events, listen: (eventName, handler) => eventApiMock.listen(eventName, handler), }, shell: { ...browserHost.shell, open: shellApiMock.open, }, notifications: { ...browserHost.notifications, permissionState: async () => { const granted = await notificationPluginMock.isPermissionGranted() return granted ? 'granted' : 'denied' }, requestPermission: notificationPluginMock.requestPermission, send: notificationPluginMock.sendNotification, onAction: async (handler) => { const unlisten = await notificationPluginMock.onAction(handler) if (typeof unlisten === 'function') return unlisten if (unlisten && typeof unlisten === 'object' && 'unregister' in unlisten) { return () => { void (unlisten as { unregister: () => unknown }).unregister() } } return () => {} }, }, window: { ...browserHost.window, requestAttention: requestUserAttentionMock, focus: vi.fn().mockResolvedValue(undefined), }, } } beforeEach(() => { vi.useRealTimers() resetDesktopNotificationsForTests() coreApiMock.invoke.mockReset() eventApiMock.listen.mockReset() shellApiMock.open.mockReset() notificationPluginMock.isPermissionGranted.mockReset() notificationPluginMock.requestPermission.mockReset() notificationPluginMock.sendNotification.mockReset() notificationPluginMock.onAction.mockReset() windowApiMock.getCurrentWindow.mockClear() windowApiMock.requestUserAttention.mockReset() useSettingsStore.setState({ desktopNotificationsEnabled: true }) Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Linux x86_64', }) installElectronNotificationHost() Reflect.deleteProperty(window, '__TAURI_INTERNALS__') Reflect.deleteProperty(window, '__TAURI__') }) it('sends through the desktop notification host when permission is already granted', async () => { notificationPluginMock.isPermissionGranted.mockResolvedValue(true) notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required', body: 'Approve command execution', }) await vi.waitFor(() => expect(notificationPluginMock.sendNotification).toHaveBeenCalledTimes(1)) expect(notificationPluginMock.isPermissionGranted).toHaveBeenCalledTimes(1) expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({ title: 'Permission required', body: 'Approve command execution', }) }) it('passes notification targets through the desktop notification payload', async () => { notificationPluginMock.isPermissionGranted.mockResolvedValue(true) const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } await expect(notifyDesktop({ dedupeKey: 'permission:targeted', title: 'Permission required', body: 'Approve command execution', target, })).resolves.toBe(true) expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith(expect.objectContaining({ title: 'Permission required', body: 'Approve command execution', id: expect.any(Number), extra: { ccHahaTarget: JSON.stringify(target), }, })) }) it('does not request notification permission from a blocking permission prompt', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) notificationPluginMock.isPermissionGranted.mockResolvedValue(false) notifyDesktop({ title: 'Permission required' }) await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled()) expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith( '[desktopNotifications] native notification permission was not granted', ) expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() warnSpy.mockRestore() }) it('uses the macOS native bridge for foreground-visible notifications', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockResolvedValueOnce(true) await expect(notifyDesktop({ title: 'Permission required', body: 'Approve command execution', })).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { title: 'Permission required', body: 'Approve command execution', }) expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() }) it('passes notification targets through the macOS native bridge', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockResolvedValueOnce(true) const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } await expect(notifyDesktop({ title: 'Permission required', body: 'Approve command execution', target, })).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { title: 'Permission required', body: 'Approve command execution', target: JSON.stringify(target), }) }) it('does not request macOS permission from a blocking permission prompt', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockResolvedValueOnce(false) await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false) expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { title: 'Permission required', body: undefined, }) expect(coreApiMock.invoke).not.toHaveBeenCalledWith('macos_request_notification_permission') expect(warnSpy).toHaveBeenCalledWith( '[desktopNotifications] native notification permission was not granted', ) warnSpy.mockRestore() }) it('does not fall back to the generic notification bridge when the macOS bridge fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable')) notificationPluginMock.isPermissionGranted.mockResolvedValue(true) await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false) expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() warnSpy.mockRestore() }) it('does not send or consume dedupe keys when desktop notifications are disabled', async () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) useSettingsStore.setState({ desktopNotificationsEnabled: false }) notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(sender).not.toHaveBeenCalled() useSettingsStore.setState({ desktopNotificationsEnabled: true }) notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' }) await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) }) it('does not consume dedupe keys when native notification delivery fails', async () => { const sender = vi.fn() .mockResolvedValueOnce(false) .mockResolvedValueOnce(true) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) setNativeNotificationSenderForTests(sender) await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(false) await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(true) expect(sender).toHaveBeenCalledTimes(2) warnSpy.mockRestore() }) it('reports and requests native notification permission', async () => { notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false) notificationPluginMock.requestPermission.mockResolvedValue('granted') Object.defineProperty(window, 'Notification', { configurable: true, value: { permission: 'default' }, }) await expect(getDesktopNotificationPermission()).resolves.toBe('default') await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) }) it('reads and requests Windows notification permission through the native plugin command', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Win32', }) coreApiMock.invoke .mockResolvedValueOnce(true) .mockResolvedValueOnce('granted') await expect(getDesktopNotificationPermission()).resolves.toBe('granted') await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'plugin:notification|is_permission_granted') expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'plugin:notification|request_permission') expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() }) it('sends Windows notifications when the native plugin reports permission granted', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Win32', }) coreApiMock.invoke.mockResolvedValueOnce(true) await expect(notifyDesktop({ title: 'Permission required', body: 'Approve command execution', })).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('plugin:notification|is_permission_granted') expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({ title: 'Permission required', body: 'Approve command execution', }) }) it('opens Windows notification settings through the native command', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Win32', }) coreApiMock.invoke.mockResolvedValueOnce(true) await expect(openDesktopNotificationSettings()).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('open_windows_notification_settings') expect(shellApiMock.open).not.toHaveBeenCalled() }) it('opens macOS notification settings through the native command before shell fallback', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockResolvedValueOnce(true) await expect(openDesktopNotificationSettings()).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings') expect(shellApiMock.open).not.toHaveBeenCalled() }) it('falls back to shell.open for macOS notification settings when native command is unavailable', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockRejectedValueOnce(new Error('unavailable')) await expect(openDesktopNotificationSettings()).resolves.toBe(true) expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings') expect(shellApiMock.open).toHaveBeenCalledWith('x-apple.systempreferences:com.apple.preference.notifications') }) it('reports and requests macOS notification permission through the native bridge', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke .mockResolvedValueOnce('default') .mockResolvedValueOnce('granted') await expect(getDesktopNotificationPermission()).resolves.toBe('default') await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'macos_notification_permission_state') expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'macos_request_notification_permission') expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() }) it('does not use the generic notification permission fallback on macOS bridge errors', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel', }) coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable')) notificationPluginMock.isPermissionGranted.mockResolvedValue(true) await expect(getDesktopNotificationPermission()).resolves.toBe('unsupported') expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() warnSpy.mockRestore() }) it('sends a native notification once for a dedupe key', async () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) void notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required', body: 'Approve command execution', }) void notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required', body: 'Approve command execution', }) await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) expect(sender).toHaveBeenCalledWith({ title: 'Permission required', body: 'Approve command execution', }) }) it('forwards notification click targets from native and plugin listeners', async () => { const unlistenNative = vi.fn() const pluginListener = { unregister: vi.fn(function (this: unknown) { expect(this).toBe(pluginListener) }), } type NativeClickCallback = (event: { payload: unknown }) => void type PluginClickCallback = (notification: unknown) => void let nativeCallback: NativeClickCallback = () => { throw new Error('native listener was not registered') } let pluginCallback: PluginClickCallback = () => { throw new Error('plugin listener was not registered') } let nativeRegistered = false let pluginRegistered = false const sessionTarget = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } const scheduledTarget = { type: 'scheduled' as const } eventApiMock.listen.mockImplementation(async (_eventName: string, callback: (event: { payload: unknown }) => void) => { nativeCallback = callback nativeRegistered = true return unlistenNative }) notificationPluginMock.onAction.mockImplementation(async (callback: (notification: unknown) => void) => { pluginCallback = callback pluginRegistered = true return pluginListener }) const onTarget = vi.fn() const cleanup = await installDesktopNotificationClickListener(onTarget) expect(nativeRegistered).toBe(true) expect(pluginRegistered).toBe(true) nativeCallback({ payload: { target: JSON.stringify(sessionTarget) } }) pluginCallback({ extra: { ccHahaTarget: JSON.stringify(scheduledTarget) } }) expect(onTarget).toHaveBeenCalledWith(sessionTarget) expect(onTarget).toHaveBeenCalledWith(scheduledTarget) cleanup() expect(unlistenNative).toHaveBeenCalledTimes(1) expect(pluginListener.unregister).toHaveBeenCalledTimes(1) }) it('requests OS-level window attention for blocking prompts', async () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) notifyDesktop({ requestAttention: true, title: 'Permission required', body: 'Approve command execution', }) await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) await vi.waitFor(() => expect(windowApiMock.requestUserAttention).toHaveBeenCalledTimes(1)) expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith() }) it('throttles bursts within the same cooldown scope', async () => { vi.useFakeTimers() const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) notifyDesktop({ dedupeKey: 'permission:1', cooldownScope: 'permission', title: 'One' }) notifyDesktop({ dedupeKey: 'permission:2', cooldownScope: 'permission', title: 'Two' }) await vi.runAllTimersAsync() expect(sender).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(751) notifyDesktop({ dedupeKey: 'permission:3', cooldownScope: 'permission', title: 'Three' }) await vi.runAllTimersAsync() expect(sender).toHaveBeenCalledTimes(2) }) })