import React from 'react'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; // The plugin UI kit (`@plugin-host/ui`): the app's controls for plugin // iframes. These tests hold it to the app's components, so a restyle of the // app shows up as a failing test here instead of plugins drifting off. vi.mock('next-intl', () => ({ useTranslations: () => (key: string, params?: Record) => params ? `${key}(${Object.values(params).join(',')})` : key, })); const kit = await import('../plugin-sandbox/ui-kit'); const { Button: AppButton } = await import('@/components/ui/button'); const { Input: AppInput } = await import('@/components/ui/input'); const ROOT = path.resolve(__dirname, '../..'); const source = (file: string) => readFileSync(path.join(ROOT, file), 'utf8'); const classesOf = (el: Element) => el.getAttribute('class')!.split(/\s+/).filter((c) => c !== 'bk').sort(); describe('plugin UI kit components', () => { it('renders the app\'s own Button and Input', () => { render(<> Cancel Cancel app ); expect(classesOf(screen.getByText('Cancel'))).toEqual(classesOf(screen.getByText('Cancel app'))); expect(classesOf(screen.getByPlaceholderText('kit'))).toEqual(classesOf(screen.getByPlaceholderText('app'))); }); it('maps primary, secondary, ghost and destructive onto the app\'s variants', () => { render(<> Primary Ghost Delete ); expect(screen.getByText('Primary').className).toContain('bg-primary'); expect(screen.getByText('Ghost').className).toContain('hover:bg-accent'); expect(screen.getByText('Delete').className).toContain('bg-destructive'); }); it('copies its classes from the app components it stands for', () => { // Each kit component that can't reuse an app component copies one; the // copy must still be there, word for word. const copies: [string, string][] = [ ['components/settings/settings-section.tsx', 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150'], ['components/settings/settings-section.tsx', 'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150'], ['components/settings/settings-section.tsx', 'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150'], ['components/settings/settings-section.tsx', 'flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0'], ['components/settings/settings-section.tsx', 'px-3 py-1.5 text-xs rounded-md transition-colors duration-150'], ['components/email/composer-toolbar-menu.tsx', 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border'], ['components/calendar/ical-import-modal.tsx', 'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors'], ['components/files/file-empty-state.tsx', 'w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4'], ['components/files/file-views.tsx', 'border-b border-border/60'], ['components/files/files-header.tsx', 'px-1.5 py-1 rounded-md transition-colors truncate max-w-[16rem]'], ['components/email/email-viewer.tsx', 'w-10 h-10 rounded-full'], ['components/email/email-viewer.tsx', 'text-[10px] font-semibold uppercase tracking-wider text-muted-foreground'], ['components/email/email-viewer.tsx', 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]'], ['components/identity/identity-form.tsx', 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200'], ]; const kitSource = source('lib/plugin-sandbox/ui-kit/components.tsx'); for (const [file, classes] of copies) { expect(source(file), file).toContain(classes); for (const word of classes.split(' ')) expect(kitSource, `${word} (from ${file})`).toContain(word); } }); it('puts bk on each root, which scopes the reset to kit elements', () => { const { container } = render(<> Sent ); for (const child of Array.from(container.children)) expect(child.classList.contains('bk'), child.outerHTML.slice(0, 60)).toBe(true); }); it('marks removed and added words in a change preview', () => { const { container } = render(); expect(container.querySelector('del')).toHaveTextContent('Bob'); expect(container.querySelector('del')!.className).toContain('line-through'); expect(container.querySelector('ins')).toHaveTextContent('Robert'); expect(container.textContent).toBe('Hi BobRobert'); }); it('takes only #rgb and #rrggbb colours for a colour dot', () => { const { container } = render(<> ); const dots = container.querySelectorAll('span'); expect((dots[0] as HTMLElement).style.backgroundColor).toBe('rgb(59, 130, 246)'); expect(dots[0]).toHaveAttribute('aria-label', 'Work'); expect((dots[1] as HTMLElement).getAttribute('style')).toBeNull(); expect(dots[1].className).toContain('bg-muted-foreground'); expect((dots[2] as HTMLElement).getAttribute('style')).toBeNull(); }); it('names the select box of a list row and a checkbox without a visible label', () => { render(<> {}} /> {}} /> ); expect(screen.getByRole('checkbox', { name: 'report.pdf' })).toBeTruthy(); expect(screen.getByRole('checkbox', { name: 'Select invoice' })).toBeTruthy(); expect(screen.getByRole('checkbox', { name: 'Use for all' })).toBeTruthy(); }); it('lets fields share a row and shows details as label and value', () => { const { container } = render(<> 2 MB ); expect(container.firstElementChild!.className).toContain('[&>*]:flex-1'); expect(container.querySelector('dt')).toHaveTextContent('Size'); expect(container.querySelector('dd')).toHaveTextContent('2 MB'); }); it('makes sidebar rows as tall as folder rows on touch screens', () => { // The page sets --touch-row-min-h to 44px below lg, where folder rows // are max-lg:min-h-[44px]; the frame can't see the page's width itself. expect(source('components/layout/sidebar.tsx')).toContain('max-lg:min-h-[44px]'); expect(source('app/globals.css')).toMatch(/@media \(width < 64rem\) \{\s*:root \{\s*--touch-row-min-h: 44px;/); render( {}} />); expect(screen.getByRole('button', { name: 'Anna' }).style.minHeight).toBe('var(--touch-row-min-h, 0px)'); }); it('draws a checkbox the user can toggle by its label', () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByText('Send as link')); expect(onChange).toHaveBeenCalledWith(true); expect(screen.getByRole('checkbox', { name: 'Send as link' })).toHaveAttribute('aria-checked', 'false'); }); it('gives a Field\'s control the label', () => { render(); expect(screen.getByLabelText('Title')).toBeInstanceOf(HTMLInputElement); expect(screen.getByText('Shown in Paperless')).toBeTruthy(); }); it('is what the sandbox hands a plugin for require("@plugin-host/ui")', async () => { const { makePluginRequire, loadRenderer } = await import('../plugin-sandbox/runtime'); const req = makePluginRequire(null, await loadRenderer()); expect(req('@plugin-host/ui')).toBe(kit.pluginUiKit); expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Combobox', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip'])); expect(() => req('left-pad')).toThrow(/not available/); }); it('has stand-ins with the same names in a background frame, which draws nothing', async () => { // Loading the kit and React DOM there cost every background frame ~350 kB of parsing. const { makePluginRequire } = await import('../plugin-sandbox/runtime'); const { KIT_NAMES } = await import('../plugin-sandbox/ui-kit/names'); const req = makePluginRequire(null); const ui = req('@plugin-host/ui') as Record; expect([...KIT_NAMES].sort()).toEqual(kit.KIT_EXPORTS); expect(Object.keys(ui).sort()).toEqual(kit.KIT_EXPORTS); expect(ui.KIT_VERSION).toBe(kit.KIT_VERSION); expect(() => (ui.Button as () => void)()).toThrow(/only works in a slot frame/); const { createPortal } = req('react-dom') as { createPortal: () => void }; expect(() => createPortal()).toThrow(/only works in a slot frame/); expect((req('react-dom') as { then?: unknown }).then).toBeUndefined(); }); }); describe('DialogButtons', () => { it('hands its buttons to the host and runs onClick when the host reports a click', () => { const setDialogButtons = vi.fn(); const onClick = vi.fn(); const ui = (n: number) => ( ); const { container, rerender } = render(ui(0)); expect(container.innerHTML).toBe(''); expect(setDialogButtons).toHaveBeenLastCalledWith([{ id: 'cancel', label: 'Cancel', variant: 'secondary' }, { id: 'send', label: 'Send', busy: true }]); expect(onClick).not.toHaveBeenCalled(); rerender(ui(1)); expect(onClick).toHaveBeenCalledWith('send'); rerender(ui(1)); expect(onClick).toHaveBeenCalledTimes(1); }); it('draws the buttons itself outside a host dialog', () => { const onClick = vi.fn(); render(); fireEvent.click(screen.getByRole('button', { name: 'Save' })); expect(onClick).toHaveBeenCalledWith('ok'); }); }); describe('plugin dialog footer', () => { it('draws the plugin\'s buttons in the app\'s footer and passes clicks back', async () => { let slotProps: Record = {}; vi.doMock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: ({ extraProps }: { extraProps: Record }) => { slotProps = extraProps; return null; }, })); const dialogs = await import('../plugin-sandbox/host-dialog'); const { PluginDialogHost } = await import('@/components/plugins/plugin-dialog-host'); render(); let result: unknown; act(() => { void dialogs.awaitCustomDialog({ pluginId: 'paperless', title: 'Send to Paperless', slot: 'plugin-dialog' } as never).then((r: unknown) => { result = r; }); }); expect(screen.getByRole('dialog', { name: 'Send to Paperless' })).toBeTruthy(); expect(screen.getByText('from_plugin(paperless)')).toBeTruthy(); act(() => (slotProps.setDialogButtons as (b: unknown) => void)([ { id: 'cancel', label: 'Cancel', variant: 'secondary' }, { id: 'send', label: 'Send', disabled: true, icon: 'send' }, { id: 'bad id!', label: 'Dropped' }, { id: 'x', label: '' }, ])); const footer = document.querySelector('[data-plugin-dialog-buttons]')!; const buttons = Array.from(footer.querySelectorAll('button')); expect(buttons.map((b) => b.textContent)).toEqual(['Cancel', 'Send']); expect(buttons[0].className).toContain('border-input'); expect(buttons[1]).toBeDisabled(); expect(buttons[1].querySelector('svg')).not.toBeNull(); expect(buttons[0].querySelector('svg')).toBeNull(); fireEvent.click(buttons[0]); expect(slotProps.dialogButtonClick).toEqual({ id: 'cancel', n: 1 }); fireEvent.click(buttons[0]); expect(slotProps.dialogButtonClick).toEqual({ id: 'cancel', n: 2 }); act(() => (slotProps.onResult as (v: unknown) => void)(null)); await act(async () => {}); expect(result).toBeNull(); vi.doUnmock('@/components/plugins/plugin-iframe-slot'); // The dialog host is the first import here that loads the app's stores // (the kit no longer does), and transforming them takes a while. }, 30_000); }); describe('Combobox', () => { const options = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, ]; it('renders with placeholder and closed dropdown', () => { render(); const input = screen.getByRole('combobox'); expect(input).toHaveAttribute('placeholder', 'Choose fruit'); expect(input).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('listbox')).toBeNull(); }); it('opens and filters options on input', () => { render(); const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.change(input, { target: { value: 'ban' } }); expect(input).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByRole('listbox')).toBeTruthy(); expect(screen.getByText('Banana')).toBeTruthy(); expect(screen.queryByText('Apple')).toBeNull(); }); it('selects option on click', () => { const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.click(input); fireEvent.click(screen.getByText('Cherry')); expect(onChange).toHaveBeenCalledWith('cherry'); expect(screen.queryByRole('listbox')).toBeNull(); }); it('navigates with keyboard and selects on Enter', () => { const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.keyDown(input, { key: 'ArrowDown' }); // opens dropdown expect(screen.getByRole('listbox')).toBeTruthy(); fireEvent.keyDown(input, { key: 'ArrowDown' }); // highlight next fireEvent.keyDown(input, { key: 'Enter' }); expect(onChange).toHaveBeenCalled(); }); it('clears selection when clear button is clicked without opening dropdown', () => { const onChange = vi.fn(); render(); const clearBtn = screen.getByLabelText('Clear'); fireEvent.click(clearBtn); expect(onChange).toHaveBeenCalledWith(''); expect(screen.queryByRole('listbox')).toBeNull(); }); it('closes dropdown and clears selection if clear button is clicked while open', () => { const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.focus(input); expect(screen.getByRole('listbox')).toBeTruthy(); const clearBtn = screen.getByLabelText('Clear'); fireEvent.click(clearBtn); expect(onChange).toHaveBeenCalledWith(''); expect(screen.queryByRole('listbox')).toBeNull(); }); it('supports multiselect mode with chip rendering, toggling and backspace removal', () => { const onChange = vi.fn(); const { rerender } = render( ); expect(screen.getByRole('checkbox', { name: 'Apple' })).toBeTruthy(); expect(screen.getByLabelText('Remove Apple')).toBeTruthy(); // Clicking remove chip does not open dropdown fireEvent.click(screen.getByLabelText('Remove Apple')); expect(onChange).toHaveBeenCalledWith([]); expect(screen.queryByRole('listbox')).toBeNull(); // Open dropdown and toggle cherry const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.click(screen.getByText('Cherry')); expect(onChange).toHaveBeenCalledWith(['apple', 'cherry']); // Backspace on empty input removes last value rerender(); fireEvent.keyDown(input, { key: 'Backspace' }); expect(onChange).toHaveBeenCalledWith(['apple']); }); it('associates label when wrapped in Field', () => { render(); expect(screen.getByLabelText('Fruit')).toBeInstanceOf(HTMLInputElement); }); }); describe('Combobox floating dropdown bridge', () => { const options = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, ]; it('delegates dropdown rendering to the host when supported and handles host selection', async () => { const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge'); const sentMessages: any[] = []; bridge.initFloatingDropdownBridge( (msg) => { sentMessages.push(msg); }, () => true, ); const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.focus(input); // Dropdown listbox is NOT rendered inside the component root expect(screen.queryByRole('listbox')).toBeNull(); // Verify open message was sent to host expect(sentMessages).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'floating-dropdown-open', id: 'fruit-picker-listbox', options, }), ]), ); // Host selects banana act(() => { bridge.dispatchFloatingDropdownEvent('fruit-picker-listbox', { type: 'select', option: options[1], }); }); expect(onChange).toHaveBeenCalledWith('banana'); // Tear down bridge for following tests bridge.initFloatingDropdownBridge(null as any, () => false); }); it('supports multiselect toggling and updates host with selected values', async () => { const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge'); const sentMessages: any[] = []; bridge.initFloatingDropdownBridge( (msg) => { sentMessages.push(msg); }, () => true, ); const onChange = vi.fn(); render(); const input = screen.getByRole('combobox'); fireEvent.focus(input); expect(screen.queryByRole('listbox')).toBeNull(); // Host selects banana act(() => { bridge.dispatchFloatingDropdownEvent('tag-picker-listbox', { type: 'select', option: options[1], }); }); expect(onChange).toHaveBeenCalledWith(['apple', 'banana']); // Tear down bridge bridge.initFloatingDropdownBridge(null as any, () => false); }); }); describe('plugin-kit.css', () => { it('builds the kit\'s utilities without the global Tailwind reset', async () => { const postcss = (await import('postcss')).default; const tailwind = (await import('@tailwindcss/postcss')).default; const file = path.join(ROOT, 'app/(sandbox)/plugin-kit.css'); const out = (await postcss([tailwind({ base: ROOT })]).process(readFileSync(file, 'utf8'), { from: file })).css; expect(out).toContain('.bg-primary'); expect(out).toContain('.h-10'); expect(out).toContain('--color-primary: var(--color-primary)'); // Preflight would restyle every plugin written before the kit. expect(out).not.toMatch(/(^|[\s,}])\*,\s*::after,\s*::before/); expect(out).not.toMatch(/(^|\n)\s*html,\s*:host\s*\{/); // Only the kit's sources are scanned: an app-only class stays out. expect(out).not.toContain('.email-content-wrapper'); expect(out.length).toBeLessThan(80_000); // Kit components whose root is the button itself (SidebarRow, RowAction) // get the reset too, not only buttons inside a kit element. const reset = out.split('}').find((rule) => /font:\s*inherit/.test(rule)) ?? ''; expect(reset).toMatch(/button\.bk/); expect(reset).toMatch(/\.bk button/); }, 60_000); });