Files
orca/src/main/ipc/notifications.test.ts
T

1694 lines
54 KiB
TypeScript

/* eslint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const {
removeHandlerMock,
handleMock,
notificationShowMock,
notificationCloseMock,
notificationOnMock,
notificationOnceMock,
notificationRemoveListenerMock,
notificationCtorMock,
notificationIsSupportedMock,
getAllWindowsMock,
shellOpenExternalMock
} = vi.hoisted(() => {
const removeHandlerMock = vi.fn()
const handleMock = vi.fn()
const notificationShowMock = vi.fn()
const notificationCloseMock = vi.fn()
const notificationOnMock = vi.fn()
const notificationOnceMock = vi.fn()
const notificationRemoveListenerMock = vi.fn()
const notificationCtorMock = vi.fn(function () {
return {
show: notificationShowMock,
close: notificationCloseMock,
on: notificationOnMock,
once: notificationOnceMock,
removeListener: notificationRemoveListenerMock
}
})
const notificationIsSupportedMock = vi.fn(() => true)
const getAllWindowsMock = vi.fn(() => [])
const shellOpenExternalMock = vi.fn()
return {
removeHandlerMock,
handleMock,
notificationShowMock,
notificationCloseMock,
notificationOnMock,
notificationOnceMock,
notificationRemoveListenerMock,
notificationCtorMock,
notificationIsSupportedMock,
getAllWindowsMock,
shellOpenExternalMock
}
})
vi.mock('electron', () => ({
ipcMain: {
removeHandler: removeHandlerMock,
handle: handleMock
},
Notification: Object.assign(notificationCtorMock, {
isSupported: notificationIsSupportedMock
}),
BrowserWindow: {
getAllWindows: getAllWindowsMock
},
app: {
focus: vi.fn()
},
shell: {
openExternal: shellOpenExternalMock
}
}))
const { readAuthorizationStatusMock } = vi.hoisted(() => ({
readAuthorizationStatusMock: vi.fn(
(): Promise<'authorized' | 'denied' | 'not-determined' | 'unknown' | null> =>
Promise.resolve(null)
)
}))
vi.mock('./notification-authorization-status', () => ({
readNotificationAuthorizationStatus: readAuthorizationStatusMock
}))
// Why: notifications.ts pulls in the tray module (for the minimized attention
// dot), which transitively loads app-icon/electron-toolkit; stub it so this
// suite stays focused on notification dispatch and avoids that import chain.
const setTrayAttentionMock = vi.hoisted(() => vi.fn())
vi.mock('../tray/system-tray', () => ({
setTrayAttention: setTrayAttentionMock
}))
import {
registerNotificationHandlers,
triggerStartupNotificationRegistration
} from './notifications'
describe('registerNotificationHandlers', () => {
let tempDir: string
function expectedNativeNotificationOptions<T extends Record<string, unknown>>(
options: T
): T & { sound?: string } {
return process.platform === 'darwin' ? { ...options, sound: 'default' } : options
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-28T16:00:00Z'))
tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-'))
removeHandlerMock.mockReset()
handleMock.mockReset()
notificationCtorMock.mockClear()
notificationShowMock.mockClear()
notificationCloseMock.mockClear()
notificationOnMock.mockClear()
notificationOnceMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
getAllWindowsMock.mockReset()
getAllWindowsMock.mockReturnValue([])
shellOpenExternalMock.mockClear()
setTrayAttentionMock.mockClear()
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})
function getDispatchHandler(): (event: unknown, args: unknown) => unknown {
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dispatch')
if (!call) {
throw new Error('notifications:dispatch handler not registered')
}
return call[1] as (event: unknown, args: unknown) => unknown
}
function getDismissHandler(): (event: unknown, args: unknown) => unknown {
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dismiss')
if (!call) {
throw new Error('notifications:dismiss handler not registered')
}
return call[1] as (event: unknown, args: unknown) => unknown
}
function getOpenSystemSettingsHandler(): (event: unknown) => unknown {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'notifications:openSystemSettings'
)
if (!call) {
throw new Error('notifications:openSystemSettings handler not registered')
}
return call[1] as (event: unknown) => unknown
}
function getLoadSoundHandler(): (event: unknown) => Promise<unknown> {
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:loadSound')
if (!call) {
throw new Error('notifications:loadSound handler not registered')
}
return call[1] as (event: unknown) => Promise<unknown>
}
function getResolveSoundPathHandler(): (event: unknown) => unknown {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'notifications:resolveSoundPath'
)
if (!call) {
throw new Error('notifications:resolveSoundPath handler not registered')
}
return call[1] as (event: unknown) => unknown
}
function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void {
const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Notification ${eventName} handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
function getNotificationOnceEventHandler(eventName: string): () => void {
const call = notificationOnceMock.mock.calls.find((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Notification ${eventName} once handler not registered`)
}
return call[1] as () => void
}
it('registers the IPC handler', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
expect(removeHandlerMock).toHaveBeenCalledWith('notifications:dispatch')
expect(handleMock).toHaveBeenCalledWith('notifications:dispatch', expect.any(Function))
})
it('opens the current macOS app notification settings entry', async () => {
const originalPlatform = process.platform
const originalBundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
process.env.ORCA_DEV_MACOS_BUNDLE_ID = 'com.stablyai.orca.dev.fb5a47066f08'
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getOpenSystemSettingsHandler()
handler({})
expect(shellOpenExternalMock).toHaveBeenCalledWith(
'x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=com.stablyai.orca.dev.fb5a47066f08'
)
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
if (originalBundleId === undefined) {
delete process.env.ORCA_DEV_MACOS_BUNDLE_ID
} else {
process.env.ORCA_DEV_MACOS_BUNDLE_ID = originalBundleId
}
}
})
it('opens Windows notification settings', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getOpenSystemSettingsHandler()
handler({})
expect(shellOpenExternalMock).toHaveBeenCalledWith('ms-settings:notifications')
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('suppresses notifications when disabled in settings', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: false,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'disabled'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('suppresses active-worktree notifications while Orca is focused', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
isFocused: () => true
} as never
])
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
describe('minimized tray attention dot', () => {
function registerEnabledNotifications(): void {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
}
it('lights the tray dot for an agent completion while the window is hidden', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'agent-task-complete' })
expect(setTrayAttentionMock).toHaveBeenCalledWith(true)
})
it('lights the tray dot for a terminal bell while the window is minimized', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => true, isMinimized: () => true } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'terminal-bell' })
expect(setTrayAttentionMock).toHaveBeenCalledWith(true)
})
it('does not light the tray dot while the window is visible', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => true, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'agent-task-complete' })
expect(setTrayAttentionMock).not.toHaveBeenCalled()
})
it('does not light the tray dot for non-bell/completion sources', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'test' })
expect(setTrayAttentionMock).not.toHaveBeenCalled()
})
})
it('delivers a notification when the event is allowed', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{ source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' }
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: 'Task complete in feat/notis',
body: 'orca'
})
)
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('uses the macOS default notification sound when no custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
sound: 'default'
})
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('does not request a native macOS sound when a custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
silent: true
})
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => {
const webContentsSend = vi.fn()
const restore = vi.fn()
const focus = vi.fn()
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
isFocused: () => false,
isMinimized: () => true,
restore,
focus,
webContents: { send: webContentsSend }
} as never
])
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const handler = getDispatchHandler()
expect(
await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey })
).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
getNotificationEventHandler('click')()
expect(restore).toHaveBeenCalledTimes(1)
expect(focus).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function))
expect(webContentsSend).toHaveBeenCalledWith('ui:activateWorktree', {
repoId: 'repo',
worktreeId: 'repo::wt1'
})
expect(webContentsSend).toHaveBeenCalledWith('ui:focusTerminal', {
tabId: 'tab-1',
worktreeId: 'repo::wt1',
leafId: '11111111-1111-4111-8111-111111111111',
ackPaneKeyOnSuccess: paneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
})
it('clears the retained notification fallback timer when the native notification closes', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const closeHandler = getNotificationEventHandler('close')
closeHandler()
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler)
})
it('releases retained notifications when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const failedHandler = getNotificationEventHandler('failed')
failedHandler({}, 'Application is not code signed')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('agent-task-complete notification failed to show')
)
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler)
} finally {
warn.mockRestore()
}
})
it('formats agent-task-complete with the agent response when a status snapshot is present', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
repoLabel: 'orca',
terminalTitle: '* Claude done',
agentType: 'codex',
agentState: 'done',
agentPrompt: 'Fix rich notification text',
agentLastAssistantMessage: 'Updated the notification body.'
}
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: 'feat/notis - Codex finished',
body: 'Updated the notification body.'
})
)
})
it('includes the repo name when multiple repos are active', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
repoLabel: 'orca',
hasMultipleActiveRepos: true,
agentType: 'codex',
agentState: 'done',
agentLastAssistantMessage: 'Updated the notification body.'
}
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: 'orca / feat/notis - Codex finished',
body: 'Updated the notification body.'
})
)
})
it('keeps a readable body when no assistant response was captured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'main',
repoLabel: 'jinjing-work',
hasMultipleActiveRepos: true,
agentType: 'claude',
agentState: 'done',
agentPrompt: 'Do not show this request text'
}
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: 'jinjing-work / main - Claude finished',
body: 'Claude finished.'
})
)
})
it('formats blocked and interrupted agent snapshots distinctly', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType: 'claude',
agentState: 'blocked',
agentLastAssistantMessage: 'Please approve the command.'
}
)
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType: 'claude',
agentState: 'done',
agentInterrupted: true,
agentLastAssistantMessage: 'Stopped by user.'
}
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenNthCalledWith(
1,
expectedNativeNotificationOptions({
title: 'feat/notis - Claude needs input',
body: 'Please approve the command.'
})
)
expect(notificationCtorMock).toHaveBeenNthCalledWith(
2,
expectedNativeNotificationOptions({
title: 'feat/notis - Claude stopped',
body: 'Stopped by user.'
})
)
})
it('normalizes custom agent labels and re-bounds multiline assistant previews', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: false
}
})
} as never)
const longAssistantMessage = `Line one\n\n${'x'.repeat(400)}`
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType: 'builder\nagent',
agentState: 'done',
agentLastAssistantMessage: longAssistantMessage
}
)
).toEqual({ delivered: true })
const options = (
notificationCtorMock.mock.calls as unknown as [{ title: string; body: string }][]
)[0]?.[0]
if (!options) {
throw new Error('Expected notification options')
}
expect(options).toMatchObject({
title: 'feat/notis - builder agent finished'
})
expect(options.body).toMatch(/^Line one x+/)
expect(options.body).not.toContain('\n')
expect(options.body.length).toBeLessThanOrEqual(180)
})
it('uses tool context before falling back when no prompt or assistant preview exists', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType: 'unknown',
agentState: 'working',
agentToolName: 'Bash',
agentToolInput: 'pnpm test'
}
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: 'feat/notis - Agent finished',
body: 'Using Bash: pnpm test'
})
)
})
it('uses rich formatter output for mobile notifications before the native support guard', async () => {
notificationIsSupportedMock.mockReturnValue(false)
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
expect(
await handler(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType: 'hermes',
agentState: 'done',
agentPrompt: 'Summarize the diff',
agentLastAssistantMessage: 'The diff updates notification formatting.'
}
)
).toEqual({ delivered: false, reason: 'not-supported' })
expect(dispatchMobileNotification).toHaveBeenCalledWith({
type: 'notification',
source: 'agent-task-complete',
title: 'feat/notis - Hermes finished',
body: 'The diff updates notification formatting.',
worktreeId: 'repo::wt1'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when notifications are disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: false,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'disabled'
})
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when the source is disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('dispatches one mobile notification when the active worktree is focused on desktop', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
isFocused: () => true
} as never
])
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
const focusedNotification = {
source: 'agent-task-complete' as const,
worktreeId: 'repo::wt1',
isActiveWorktree: true
}
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
expect(dispatchMobileNotification).toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
)
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
expect(dispatchMobileNotification).toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
)
})
it('does not forward explicit desktop test notifications to mobile clients', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dispatchMobileNotification } as never
)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('dismisses active native notifications and fans out mobile dismissal once per id', async () => {
const dispatchMobileNotification = vi.fn()
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dispatchMobileNotification, dismissMobileNotification } as never
)
const dispatchHandler = getDispatchHandler()
expect(
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' })
).toEqual({ delivered: true })
const dismissHandler = getDismissHandler()
expect(dismissHandler({}, ['agent:one', 'agent:one', ''])).toEqual({ dismissed: 1 })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', expect.any(Function))
expect(dismissMobileNotification).toHaveBeenCalledTimes(1)
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:one')
})
it('fans out mobile dismissal even when there is no active native notification', async () => {
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never,
{ dismissMobileNotification } as never
)
const dismissHandler = getDismissHandler()
expect(dismissHandler({}, ['agent:missing'])).toEqual({ dismissed: 0 })
expect(notificationCloseMock).not.toHaveBeenCalled()
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:missing')
})
it('closes the previous native notification when replacing the same id', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const dispatchHandler = getDispatchHandler()
expect(
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('silences the native notification when a custom sound is configured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true,
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
silent: true
})
})
it('returns source-disabled when the specific source toggle is off', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
})
it('deduplicates repeated notifications for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
vi.advanceTimersByTime(5001)
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('bounds notification cooldown keys during unique worktree bursts', async () => {
notificationIsSupportedMock.mockReturnValue(false)
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
for (let i = 0; i < 75; i++) {
expect(await handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({
delivered: false,
reason: 'not-supported'
})
}
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({
delivered: false,
reason: 'not-supported'
})
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('deduplicates agent-task-complete and terminal-bell for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('skips native delivery and reports blocked-by-system when macOS would swallow it', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
readAuthorizationStatusMock.mockResolvedValue('denied')
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'blocked-by-system'
})
// Why: a swallowed native notification would still pile up in the
// Notification Center delivered list — skip creating it entirely.
expect(notificationCtorMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('does not cooldown explicit test notifications', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('confirms explicit test notifications after the native show event', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
// Why: the darwin authorization gate resolves before the notification is
// created, so flush microtasks before grabbing its event listeners.
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
showHandler()
await expect(result).resolves.toEqual({ delivered: true })
expect(notificationShowMock).toHaveBeenCalledTimes(1)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', showHandler)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler)
})
it('reports not-displayed when explicit test notifications never show', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
await vi.advanceTimersByTimeAsync(2501)
await expect(result).resolves.toEqual({ delivered: false, reason: 'not-displayed' })
expect(notificationShowMock).toHaveBeenCalledTimes(1)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', showHandler)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler)
})
it('loads allowed custom sound files for preload playback', async () => {
const soundPath = join(tempDir, 'sound.ogg')
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: soundPath
}
})
} as never)
const handler = getLoadSoundHandler()
await expect(handler({})).resolves.toMatchObject({
ok: true,
data: new Uint8Array([1, 2, 3]),
mimeType: 'audio/ogg'
})
})
it('rejects unsupported custom sound file types', async () => {
const soundPath = join(tempDir, 'sound.txt')
writeFileSync(soundPath, 'not audio')
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: soundPath
}
})
} as never)
const handler = getLoadSoundHandler()
expect(await handler({})).toEqual({
ok: false,
reason: 'unsupported-type'
})
})
it('resolves the sound path without reading the file', async () => {
const soundPath = join(tempDir, 'sound.ogg')
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: soundPath
}
})
} as never)
const handler = getResolveSoundPathHandler()
expect(await handler({})).toEqual({ ok: true, path: soundPath })
})
it('rejects unsupported types from resolveSoundPath without touching the disk', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: '/some/where/sound.txt'
}
})
} as never)
const handler = getResolveSoundPathHandler()
expect(await handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
})
})
describe('notifications:probeDelivery', () => {
const originalPlatform = process.platform
function getProbeDeliveryHandler(): (event: unknown, args?: { force?: boolean }) => unknown {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'notifications:probeDelivery'
)
if (!call) {
throw new Error('notifications:probeDelivery handler not registered')
}
return call[1] as (event: unknown, args?: { force?: boolean }) => unknown
}
function getProbeOnceEventHandler(eventName: string): (...args: unknown[]) => void {
// Why: findLast — a test may run several probes, and only the newest
// probe's listeners can settle the pending promise.
const call = notificationOnceMock.mock.calls.findLast((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Probe notification ${eventName} once handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
function createStore(ui: Record<string, unknown> = {}): {
getSettings: () => unknown
getUI: () => Record<string, unknown>
updateUI: ReturnType<typeof vi.fn>
} {
const state = { ...ui }
return {
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
}),
getUI: () => state,
updateUI: vi.fn((updates: Record<string, unknown>) => {
Object.assign(state, updates)
})
}
}
beforeEach(() => {
vi.useFakeTimers()
handleMock.mockReset()
removeHandlerMock.mockReset()
notificationCtorMock.mockClear()
notificationShowMock.mockClear()
notificationCloseMock.mockClear()
notificationOnMock.mockClear()
notificationOnceMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('reports unsupported on non-darwin platforms without probing', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = createStore()
registerNotificationHandlers(store as never)
await expect(getProbeDeliveryHandler()({})).resolves.toEqual({
state: 'unsupported',
authoritative: false
})
expect(notificationCtorMock).not.toHaveBeenCalled()
expect(store.updateUI).not.toHaveBeenCalled()
})
it('reports authoritative states straight from the authorization readout', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('authorized')
expect(await handler({})).toEqual({ state: 'delivered', authoritative: true })
readAuthorizationStatusMock.mockResolvedValue('denied')
expect(await handler({})).toEqual({ state: 'blocked', authoritative: true })
// No probe notifications were needed for either readout.
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('fires one dialog-trigger probe per session while the decision is pending', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('not-determined')
expect(await handler({})).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Polling again while pending must not spam more probe notifications.
expect(await handler({}, { force: true })).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('marks the one-shot permission registration as done so startup cannot re-prompt', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(store.updateUI).toHaveBeenCalledWith({ notificationPermissionRequested: true })
getProbeOnceEventHandler('failed')({}, 'not allowed')
await expect(result).resolves.toEqual({ state: 'blocked', authoritative: false })
})
it('falls back to delivery probes when the readout is unavailable', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationShowMock).toHaveBeenCalledTimes(1)
getProbeOnceEventHandler('show')()
await expect(result).resolves.toEqual({ state: 'delivered', authoritative: false })
// No persisted confirmation on purpose: OS permission changes between runs.
expect(store.updateUI).not.toHaveBeenCalledWith({ notificationDeliveryConfirmed: true })
})
it('serves session evidence without probing again until forced', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('show')()
await expect(probeResult).resolves.toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Cached session evidence answers non-force calls with no new probe.
expect(await handler({})).toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Force bypasses the cache and schedules a fresh probe.
const forced = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(forced).resolves.toEqual({ state: 'delivered', authoritative: false })
})
it('serves cached failure evidence after a rejected probe', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('failed')({}, 'Notifications are not allowed for this application')
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(await handler({})).toEqual({ state: 'blocked', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('resolves blocked on timeout without recording a definitive failure', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(3001)
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
// A timeout is ambiguous evidence, so the next non-force call probes again.
const secondResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(secondResult).resolves.toEqual({ state: 'delivered', authoritative: false })
})
})
describe('triggerStartupNotificationRegistration', () => {
const originalPlatform = process.platform
function getStartupNotificationEventHandler(eventName: string): (...args: unknown[]) => void {
const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Startup notification ${eventName} handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllTimers()
notificationCtorMock.mockClear()
notificationShowMock.mockClear()
notificationCloseMock.mockClear()
notificationOnMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('shows welcome notification when not yet requested', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
}
triggerStartupNotificationRegistration(store as never)
expect(store.updateUI).toHaveBeenCalledWith({ notificationPermissionRequested: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca is ready to notify you',
body: 'Allow notifications so Orca can alert you when agents finish or terminals need attention.'
})
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('does not fire when notificationPermissionRequested flag is set', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: true }),
updateUI: vi.fn()
}
triggerStartupNotificationRegistration(store as never)
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does nothing on non-darwin platforms', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
}
triggerStartupNotificationRegistration(store as never)
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('clears startup notification timers when the notification is clicked', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
}
triggerStartupNotificationRegistration(store as never)
expect(vi.getTimerCount()).toBe(1)
getStartupNotificationEventHandler('click')()
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(shellOpenExternalMock).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function))
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function))
})
it('cleans up startup notification registration when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
}
triggerStartupNotificationRegistration(store as never)
expect(vi.getTimerCount()).toBe(1)
const failedHandler = getStartupNotificationEventHandler('failed')
failedHandler({}, 'Application is not code signed')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('startup registration notification failed to show')
)
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler)
} finally {
warn.mockRestore()
}
})
})