import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types'; import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer'; import { requestPanel } from '@/lib/plugin-sandbox/panel-requests'; // Plugin surfaces of the open message: frames above the subject, under the // sender, between new text and quote, above a conversation; buttons in the // reader toolbar (More panel on phones); entries in an attachment's menu. const device = vi.hoisted(() => ({ isMobile: false })); vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), useMediaQuery: () => false, useIsMobile: () => device.isMobile, })); vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), })); vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({ PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot, })); class NoopResizeObserver { observe() {} unobserve() {} disconnect() {} } vi.stubGlobal('ResizeObserver', NoopResizeObserver); const { EmailViewer } = await import('../email-viewer'); const { ThreadConversationView } = await import('../thread-conversation-view'); const { ReaderAboveQuoteSlot, QUOTE_ANCHOR_ATTR } = await import('../reader-plugin-surfaces'); const { useSettingsStore } = await import('@/stores/settings-store'); const mailboxes = [ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, ] as unknown as Mailbox[]; function mail(overrides: Partial = {}): Email { return { id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, ...overrides, } as unknown as Email; } function textMail(text: string, overrides: Partial = {}): Email { return mail({ textBody: [{ partId: '1', type: 'text/plain' }], bodyValues: { '1': { value: text, isTruncated: false } }, ...overrides, } as Partial); } const REPLY = 'Sounds good, see you then.\n\nOn Monday Bob wrote:\n> Shall we meet at ten?\n> Bob'; function viewer(email: Email, props: Partial> = {}) { return render( , ); } const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); const dispatch = vi.fn().mockResolvedValue(undefined); const initialSettings = useSettingsStore.getState(); beforeEach(() => { device.isMobile = false; resetActions(); dispatch.mockClear(); setActionDispatcher(dispatch); useSettingsStore.setState(initialSettings, true); }); afterEach(() => { clearOffers(); setActionDispatcher(null); }); describe('email-detail-sidebar', () => { // Reports a fixed width for whatever it observes, like a 584 px reading pane // (three panes on a laptop). function stubWidth(width: number) { class FixedWidthObserver { constructor(private cb: ResizeObserverCallback) {} observe(el: Element) { this.cb([{ target: el, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); } unobserve() {} disconnect() {} } vi.stubGlobal('ResizeObserver', FixedWidthObserver); } afterEach(() => { vi.stubGlobal('ResizeObserver', NoopResizeObserver); try { localStorage.removeItem('emailDetailSidebarCollapsed'); } catch { /* ignore */ } }); it('starts collapsed when the message opens into a pane too narrow for it', () => { stubWidth(584); offerSlots('p1', ['email-detail-sidebar']); // The reader mounts empty first, then a message opens. const { rerender } = render( , ); rerender(); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument(); }); it('lays the panel over the message when opened anyway, instead of squeezing it', () => { stubWidth(584); offerSlots('p1', ['email-detail-sidebar']); viewer(textMail('Hi')); fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' })); const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!; expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay'); expect(panel.className).toContain('absolute'); }); it('opens, closes and toggles on a plugin\'s request, and tells the slot to focus', () => { stubWidth(584); offerSlots('p1', ['email-detail-sidebar']); viewer(textMail('Hi')); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); let open: boolean | null = false; act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); }); expect(open).toBe(true); const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!; expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay'); expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(1); act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); }); expect(open).toBe(false); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); }); expect(open).toBe(true); expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(2); act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'close', undefined); }); expect(open).toBe(false); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); }); it('answers no request on phones, where there is no panel', () => { device.isMobile = true; offerSlots('p1', ['email-detail-sidebar']); viewer(textMail('Hi')); let open: boolean | null = true; act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); }); expect(open).toBeNull(); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); }); it('starts folded when the plugin asks for it, and remembers the user opening it', () => { stubWidth(1000); offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] }); const { unmount } = viewer(textMail('Hi')); expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' })); expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked'); unmount(); viewer(textMail('Hi')); expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument(); }); it('docks next to the message when there is room', () => { stubWidth(1000); offerSlots('p1', ['email-detail-sidebar']); viewer(textMail('Hi')); const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!; expect(panel.getAttribute('data-plugin-detail-panel')).toBe('docked'); }); }); describe('reader-selection-menu and the side panel\'s selection', () => { const SURFACE = 'reader-selection-menu'; const plainBody = () => document.querySelector('[data-plugin-source="email-body"]')!; const selectBody = () => { const range = document.createRange(); range.selectNodeContents(plainBody()); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); }; const rightClick = () => { const event = createEvent.contextMenu(plainBody(), { clientX: 30, clientY: 40 }); fireEvent(plainBody(), event); return event; }; afterEach(() => { vi.useRealTimers(); window.getSelection()?.removeAllRanges(); }); it('leaves the browser menu alone without plugin entries, and without a selection', () => { viewer(textMail('Hi there')); selectBody(); expect(rightClick().defaultPrevented).toBe(false); expect(screen.queryByRole('menu')).toBeNull(); act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain' }]); }); window.getSelection()!.removeAllRanges(); expect(rightClick().defaultPrevented).toBe(false); expect(screen.queryByRole('menu')).toBeNull(); }); it('offers Copy and the plugin entries on selected text, and hands the click the message and the text', async () => { act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain', icon: 'sparkles' }]); }); viewer(textMail('Hi there')); selectBody(); expect(rightClick().defaultPrevented).toBe(true); expect(screen.getByRole('menu')).toHaveTextContent('copy'); fireEvent.click(screen.getByRole('menuitem', { name: 'Explain' })); await act(async () => {}); expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'explain', { emailId: 'e1', selection: { text: 'Hi there' } }); expect(screen.queryByRole('menu')).toBeNull(); }); it('sends the side panel the selected text a moment after it changes', () => { offerSlots('p1', ['email-detail-sidebar']); viewer(textMail('Hi there')); expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' }); vi.useFakeTimers(); selectBody(); act(() => { document.dispatchEvent(new Event('selectionchange')); }); expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' }); act(() => { vi.advanceTimersByTime(200); }); expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: 'Hi there' }); }); }); describe('reader-header and reader-below-subject', () => { it('tell the frames which layout they are in', () => { offerSlots('p1', ['email-banner']); const { unmount } = viewer(textMail('Hi')); expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('desktop'); unmount(); device.isMobile = true; viewer(textMail('Hi')); expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('mobile'); }); it('put the frames above the subject and under the sender, with the message', () => { offerSlots('p1', ['reader-header', 'reader-below-subject']); const { container } = viewer(textMail('Hi')); const header = screen.getByTestId('slot-reader-header'); const below = screen.getByTestId('slot-reader-below-subject'); const subject = screen.getByRole('heading', { name: 'Hello there' }); const body = container.querySelector('.email-content-wrapper')!; expect(follows(header, subject)).toBe(true); expect(follows(subject, below)).toBe(true); expect(follows(below, body)).toBe(true); expect((slotProps(header).email as { id: string }).id).toBe('e1'); expect((slotProps(below).email as { subject: string }).subject).toBe('Hello there'); }); it('draw nothing without a plugin', () => { const { container } = viewer(textMail('Hi')); expect(container.querySelector('[data-plugin-slot="reader-header"]')).toBeNull(); expect(container.querySelector('[data-plugin-slot="reader-below-subject"]')).toBeNull(); }); }); describe('email-footer', () => { it('hands the frame the id of the open message', () => { offerSlots('p1', ['email-footer']); viewer(textMail('Hi')); expect(slotProps(screen.getByTestId('slot-email-footer'))).toEqual({ emailId: 'e1' }); }); }); describe('reader-above-quote', () => { it('sits between the new text of a plain-text reply and its folded quote', () => { offerSlots('p1', ['reader-above-quote']); const { container } = viewer(textMail(REPLY)); const frame = screen.getByTestId('slot-reader-above-quote'); const quote = container.querySelector('.email-content-text details')!; expect(quote).not.toBeNull(); expect(follows(frame, quote)).toBe(true); expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true); expect((slotProps(frame).email as { id: string }).id).toBe('e1'); // The frame renders inside the anchor, so the anchor must grow with it // instead of letting the frame spill over the quote toggle and the footer. const anchor = container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!; expect(anchor.contains(frame)).toBe(true); expect(anchor.style.height).toBe(''); }); it('is not mounted for a message without a quote, or without a plugin', () => { offerSlots('p1', ['reader-above-quote']); const { unmount } = viewer(textMail('Just some text.')); expect(screen.queryByTestId('slot-reader-above-quote')).toBeNull(); unmount(); clearOffers(); const { container } = viewer(textMail(REPLY)); expect(container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull(); }); it('lays the frame over an HTML body and makes room for it before the quote toggle', () => { offerSlots('p1', ['reader-above-quote']); const host = document.createElement('div'); document.body.appendChild(host); const iframe = document.createElement('iframe'); host.appendChild(iframe); const doc = iframe.contentDocument!; doc.body.innerHTML = '

New text

Old
'; const { unmount } = render( , { container: host.appendChild(document.createElement('div')) }, ); const anchor = doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!; expect(anchor.nextElementSibling).toBe(doc.querySelector('[data-quote-toggle]')); const overlay = document.querySelector('[data-plugin-quote-overlay]')!; expect(within(overlay as HTMLElement).getByTestId('slot-reader-above-quote')).toBeInTheDocument(); unmount(); expect(doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull(); host.remove(); }); it('shows in a conversation message on phones too', () => { offerSlots('p1', ['reader-above-quote']); const email = textMail(REPLY, { keywords: {} } as Partial); const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup; render( {}} />); expect(screen.getByTestId('slot-reader-above-quote')).toBeInTheDocument(); }); }); describe('thread-summary', () => { const conversation = [ textMail('First', { id: 'm1', receivedAt: '2026-01-01T00:00:00Z' } as Partial), textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial), ]; it('sits above the messages of the phone conversation view', () => { device.isMobile = true; offerSlots('p1', ['thread-summary']); const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; render( {}} />); const frame = screen.getByTestId('slot-thread-summary'); expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'], deviceType: 'mobile' }); expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true); }); it('sits above a Mountain View conversation', () => { offerSlots('p1', ['thread-summary']); useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 }); viewer(conversation[1], { mvConversation: conversation }); expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'], deviceType: 'desktop' }); }); it('draws nothing without a plugin, and nothing for a single message', () => { const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; const { unmount } = render( {}} />); expect(screen.queryByTestId('slot-thread-summary')).toBeNull(); unmount(); offerSlots('p1', ['thread-summary']); viewer(conversation[1]); expect(screen.queryByTestId('slot-thread-summary')).toBeNull(); }); }); describe('reader surfaces in the phone conversation view', () => { const conversation = [ textMail('Second', { id: 'm2', subject: 'Re: Plan', receivedAt: '2026-01-02T00:00:00Z' } as Partial), textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial), ]; const thread = { threadKey: 't1', latestEmail: conversation[0], emails: conversation, emailCount: 2 } as unknown as ThreadGroup; it('mounts the single reader\'s frames on the unfolded message only', () => { offerSlots('p1', ['reader-header', 'reader-below-subject', 'email-banner', 'email-footer']); render( {}} />); // The latest message is unfolded, the older one (read) is folded. for (const slot of ['reader-header', 'reader-below-subject', 'email-banner'] as const) { const frames = screen.getAllByTestId(`slot-${slot}`); expect(frames).toHaveLength(1); expect((slotProps(frames[0]).email as { id: string }).id).toBe('m2'); } const footer = screen.getAllByTestId('slot-email-footer'); expect(footer).toHaveLength(1); expect(slotProps(footer[0])).toEqual({ emailId: 'm2' }); // Header on top, the body between banner and footer. expect(follows(screen.getByTestId('slot-reader-header'), screen.getByTestId('slot-reader-below-subject'))).toBe(true); expect(follows(screen.getByTestId('slot-email-banner'), screen.getByText('Second'))).toBe(true); expect(follows(screen.getByText('Second'), footer[0])).toBe(true); }); it('puts plugin reader actions behind a more button on the unfolded message', async () => { act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['inbox'] }])); const { unmount } = render( {}} mailboxRole="inbox" />); fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); const item = await screen.findByRole('menuitem', { name: 'Translate' }); await act(async () => { fireEvent.click(item); }); expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'm2' }); unmount(); // Kept to the folder roles the plugin asked for. render( {}} mailboxRole="junk" />); expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull(); }); }); describe('reader-actions', () => { it('adds the plugin buttons to the reader toolbar', async () => { act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }])); viewer(textMail('Hola')); const button = screen.getByRole('button', { name: 'Translate' }); expect(button.closest('[data-plugin-actions="reader-actions"]')).not.toBeNull(); await act(async () => { fireEvent.click(button); }); expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' }); }); it('keeps to the folder roles the plugin asked for', () => { act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['junk'] }])); viewer(textMail('Hola')); expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull(); }); it('puts them in the More panel on phones', async () => { device.isMobile = true; act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }])); viewer(textMail('Hola')); expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull(); const item = screen.getByRole('menuitem', { name: 'Translate', hidden: true }); await act(async () => { fireEvent.click(item); }); expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' }); }); it('draws nothing without a plugin', () => { const { container } = viewer(textMail('Hola')); expect(container.querySelector('[data-plugin-actions="reader-actions"]')).toBeNull(); }); }); describe('attachment-actions', () => { const withAttachment = () => textMail('See attached', { hasAttachment: true, attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }], } as Partial); it('puts the frames next to the chip, not in its hover overlay', () => { offerSlots('p1', ['attachment-actions']); viewer(withAttachment()); // Desktop chip and phone chip are both in the DOM. const frames = screen.getAllByTestId('slot-attachment-actions'); expect(frames.length).toBeGreaterThan(0); for (const frame of frames) { // Nothing between the frame and the page may hide it until hover. for (let el: HTMLElement | null = frame; el; el = el.parentElement) { expect(el.className).not.toMatch(/\bopacity-0\b/); } expect(frame.closest('[data-attachment-name]')).toBeNull(); expect(slotProps(frame)).toEqual({ attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' }, }); } }); it('keeps the invisible hover overlay from taking taps', () => { viewer(withAttachment()); const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')] .filter((d) => /\bopacity-0\b/.test(d.className)); expect(overlays.length).toBeGreaterThan(0); for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/); }); it("shows the overlay's buttons to keyboard users who tab into them, not only on hover", () => { viewer(withAttachment()); const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')] .filter((d) => /\bopacity-0\b/.test(d.className)); expect(overlays.length).toBeGreaterThan(0); for (const overlay of overlays) { expect(overlay.className).toMatch(/\bgroup-focus-within:opacity-100\b/); expect(overlay.className).toMatch(/\bgroup-focus-within:pointer-events-auto\b/); } }); }); describe('attachment-menu', () => { const withAttachments = () => textMail('See attached', { hasAttachment: true, attachments: [ { partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }, { partId: '3', blobId: 'blob-png', name: 'photo.png', type: 'image/png', size: 99, disposition: 'attachment' }, ], } as Partial); it('opens the plugin entries from a menu button on the attachment and sends the attachment', async () => { act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud', mimeTypes: ['application/pdf'] }])); viewer(withAttachments()); // Only the PDF gets the menu (desktop chip and phone chip are both in the DOM). const buttons = document.querySelectorAll('[data-attachment-plugin-menu]'); expect(buttons.length).toBeGreaterThan(0); for (const b of buttons) { expect(b.closest('[data-attachment-name]')?.getAttribute('data-attachment-name')).toBe('invoice.pdf'); } fireEvent.click(buttons[0]); const item = await screen.findByRole('menuitem', { name: 'Save to cloud' }); await act(async () => { fireEvent.click(item); }); expect(dispatch).toHaveBeenCalledWith('p', 'attachment-menu', 'save', { emailId: 'e1', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, }); }); it('adds the menu to attachments in a conversation message', () => { act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud' }])); const email = withAttachments(); const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup; render( {}} />); expect(document.querySelectorAll('[data-attachment-plugin-menu]')).toHaveLength(2); }); it('draws no menu button without a plugin entry', () => { viewer(withAttachments()); expect(document.querySelector('[data-attachment-plugin-menu]')).toBeNull(); }); });