import React, { Suspense, lazy } from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mailbox } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks'; import { requestInternalMailto } from '@/lib/protocol-handlers/mailto'; // onBeforeCompose and onMailtoIntercept in the mail page: a plugin can stop // the composer from opening or prefill it, and stop a mailto: link. vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { const Component = lazy(async () => ({ default: await load() })); return function Dynamic(props: Record) { return ; }; } })); const composerProps = vi.hoisted(() => ({ last: null as Record | null })); vi.mock('@/components/email/email-composer', () => ({ EmailComposer: (props: Record) => { composerProps.last = props; return
stub composer
; }, })); vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), useMediaQuery: () => false, useIsDesktop: () => false, })); vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); const { MailApp } = await import('../mail-app'); const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; const sendEmail = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); const client = { getAccountId: () => 'acc', getUsername: () => 'me@example.com', getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, }; useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], getClientForAccount: () => client as never, getAllConnectedClients: () => new Map([['login', client]]) as never, }); useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); sendEmail.mockReset().mockResolvedValue({}); useEmailStore.setState({ emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, selectedEmailKeys: new Set(), fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), sendEmail, } as never); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); const openCompose = () => fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); describe('onBeforeCompose', () => { beforeEach(() => { clearAllHooks(); composerProps.last = null; }); it('opens the composer as before when no plugin listens', async () => { render(); openCompose(); await screen.findByText('stub composer', {}, { timeout: 5000 }); expect(composerProps.last?.prefill).toBeNull(); }); it('keeps the composer closed when a plugin returns false', async () => { const handler = vi.fn(() => false); emailHooks.onBeforeCompose.register('test', handler); render(); openCompose(); await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); await act(() => new Promise((r) => setTimeout(r, 50))); expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); expect(handler).toHaveBeenCalledWith({ mode: 'new', to: [], cc: [], subject: '', body: '' }); }); it('hands the fields a plugin returns to the composer', async () => { emailHooks.onBeforeCompose.register('test', (opts: Record) => ({ ...opts, to: ['carol@example.com'], subject: 'Weekly report', })); render(); openCompose(); await screen.findByText('stub composer', {}, { timeout: 5000 }); expect(composerProps.last?.prefill).toEqual({ to: ['carol@example.com'], subject: 'Weekly report' }); }); }); describe('onMailtoIntercept', () => { beforeEach(() => { clearAllHooks(); composerProps.last = null; }); it('asks plugins with the link and opens the composer when they agree', async () => { const intercept = vi.fn(() => true); const before = vi.fn(() => undefined); emailHooks.onMailtoIntercept.register('test', intercept); emailHooks.onBeforeCompose.register('test', before); render(); let taken = false; act(() => { taken = requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); expect(taken).toBe(true); await screen.findByText('stub composer', {}, { timeout: 5000 }); expect(intercept).toHaveBeenCalledWith({ href: 'mailto:bob@example.com?subject=Hi', to: ['bob@example.com'], subject: 'Hi' }); expect(before).toHaveBeenCalledWith(expect.objectContaining({ mode: 'new', to: ['bob@example.com'], subject: 'Hi' })); expect(composerProps.last?.initialData).toEqual(expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' })); }); it('keeps the composer closed when a plugin returns false', async () => { const intercept = vi.fn(() => false); emailHooks.onMailtoIntercept.register('test', intercept); render(); let taken = false; act(() => { taken = requestInternalMailto('mailto:bob@example.com'); }); // Still taken: the click must not fall through to the OS handler. expect(taken).toBe(true); await waitFor(() => expect(intercept).toHaveBeenCalledTimes(1)); await act(() => new Promise((r) => setTimeout(r, 50))); expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); });