codev / desktop /src /lib /desktopNotifications.test.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
16.3 kB
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 { useSettingsStore } from '../stores/settingsStore'
describe('desktopNotifications', () => {
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',
})
})
it('sends through the Tauri plugin when native notification 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 Tauri plugin 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 Tauri plugin 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('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 Tauri plugin 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(windowApiMock.UserAttentionType.Critical)
})
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)
})
})