cc-haha/desktop/src/lib/desktopNotifications.test.ts
程序员阿江(Relakkes) 386a41e606 feat(desktop): enable Electron migration path
Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.

The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.

Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron📦dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
2026-06-01 22:43:16 +08:00

454 lines
17 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',
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Reflect.deleteProperty(window, 'desktopHost')
})
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('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 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)
})
})