mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
The notification plugin can return an object whose unregister method expects its original listener object as this. Calling the extracted method directly drops that context and produces an unhandled rejection when the browser shell cleans up listeners. Constraint: The desktop frontend also runs in browser-like dev shells where Tauri plugin APIs may be partially present Rejected: Ignore cleanup errors | it leaves noisy unhandled rejections during local UI smoke tests Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- desktopNotifications Not-tested: Native notification click behavior on every desktop platform
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|