import { fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import App from './App'; import { PREFERENCES_KEY } from './preferences'; import { catalogue, catalogueFetch } from './test/fixtures'; afterEach(() => vi.unstubAllGlobals()); function toolLink(name: string) { return screen.getByRole('link', { name: `Open ${name}` }); } function queryToolLink(name: string) { return screen.queryByRole('link', { name: `Open ${name}` }); } function findToolLink(name = 'PDF Workbench') { return screen.findByRole('link', { name: `Open ${name}` }); } describe('portal UI', () => { it('renders compact actionable tiles and persists pin/hide controls', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); const launch = await findToolLink(); const card = screen.getByTestId('app-card-de.add-ideas.pdf-tools'); expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent( 'PDF' ); expect(within(card).getByText('Arrange PDF pages locally.')).toBeVisible(); expect( screen.getByTestId('app-card-de.add-ideas.xslt-tools') ).toHaveTextContent('XSLT'); expect( screen.getByTestId('app-card-de.add-ideas.onenote-tools') ).toHaveTextContent('OneNote'); expect( screen.queryByText( 'Page-level PDF operations performed locally in the browser.' ) ).not.toBeInTheDocument(); expect( screen.queryByText( 'Local processing · no file uploads declared · no telemetry declared' ) ).not.toBeInTheDocument(); expect( screen.getAllByRole('link', { name: 'Source and license' }) ).toHaveLength(1); expect( screen.queryByRole('region', { name: 'Pinned' }) ).not.toBeInTheDocument(); const showHidden = screen.getByRole('checkbox', { name: 'Show hidden (0)', }); expect(showHidden).toBeDisabled(); expect(showHidden).not.toBeChecked(); expect(launch).toHaveClass('app-card__launch-link'); expect( new URL(launch.getAttribute('href')!).searchParams.get('toolbox') ).toContain('toolbox.catalog.json'); const actions = within(card).getByRole('group', { name: 'Actions for PDF Workbench', }); const info = within(actions).getByRole('button', { name: 'More information about PDF Workbench', }); expect(info).toHaveAttribute('aria-haspopup', 'dialog'); for (const control of within(actions).getAllByRole('button')) expect(control.closest('a')).toBeNull(); expect(launch).not.toContainElement(info); await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' })); await waitFor(() => expect(localStorage.getItem(PREFERENCES_KEY)).toContain( 'de.add-ideas.pdf-tools' ) ); expect(screen.getByRole('region', { name: 'Pinned' })).toContainElement( screen.getByTestId('app-card-de.add-ideas.pdf-tools') ); expect( screen.queryByRole('button', { name: /Move PDF Workbench/i }) ).not.toBeInTheDocument(); const unpin = screen.getByRole('button', { name: 'Unpin PDF Workbench', }); expect(unpin).toHaveAttribute('aria-pressed', 'true'); expect(unpin).toHaveClass('is-active'); await waitFor(() => expect(unpin).toHaveFocus()); expect( screen.getByRole('button', { name: 'Reorder PDF Workbench' }) ).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ); expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument(); expect(showHidden).toBeEnabled(); expect(showHidden).toHaveAccessibleName('Show hidden (1)'); await waitFor(() => expect(showHidden).toHaveFocus()); fireEvent.click(showHidden); expect(toolLink('PDF Workbench')).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Show PDF Workbench' }) ); expect(showHidden).toBeDisabled(); expect(showHidden).not.toBeChecked(); await waitFor(() => expect( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ).toHaveFocus() ); }); it('uses the compact Regex presentation for the released tool', async () => { vi.stubGlobal( 'fetch', vi.fn( catalogueFetch({ '/toolbox.catalog.json': Response.json({ ...catalogue, apps: [ ...catalogue.apps, { manifest: './apps/regex/toolbox-app.json', enabled: true }, ], }), }) ) ); render(); await findToolLink(); const card = screen.getByTestId('app-card-de.add-ideas.regex-tools'); expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent( 'Regex' ); expect( within(card).getByText('Explain and test regex locally.') ).toBeVisible(); }); it('moves the full disclosure into an accessible modal', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); const { container } = render(); await findToolLink(); const info = screen.getByRole('button', { name: 'More information about PDF Workbench', }); await user.click(info); const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' }); expect(dialog).toHaveAttribute('aria-modal', 'true'); expect(dialog).toHaveTextContent( 'Page-level PDF operations performed locally in the browser.' ); expect(dialog).toHaveTextContent( 'Local processing · no file uploads declared · no telemetry declared' ); expect(dialog).toHaveTextContent( 'Needs Secure context · Web workers · IndexedDB' ); expect(dialog).toHaveTextContent('v0.4.3'); expect( within(dialog).getByRole('button', { name: 'documents' }) ).toBeInTheDocument(); expect( within(dialog).getByRole('button', { name: '#merge' }) ).toBeInTheDocument(); const sourceLink = within(dialog).getByRole('link', { name: 'Source and license', }); expect(sourceLink).toHaveAttribute( 'href', 'https://git.add-ideas.de/zemion/pdf-tools' ); expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer'); expect( within(dialog).getByRole('link', { name: 'Open PDF' }) ).toBeInTheDocument(); expect(container.querySelector('.site-shell')).toHaveAttribute('inert'); expect(container.querySelector('.site-shell')).toHaveAttribute( 'aria-hidden', 'true' ); expect(document.documentElement).toHaveStyle({ overflow: 'hidden' }); const close = within(dialog).getByRole('button', { name: 'Close information for PDF Workbench', }); expect(close).toHaveFocus(); within(dialog).getByRole('link', { name: 'Open PDF' }).focus(); await user.tab(); expect(close).toHaveFocus(); await user.keyboard('{Escape}'); expect( screen.queryByRole('dialog', { name: 'PDF Workbench' }) ).not.toBeInTheDocument(); expect(info).toHaveFocus(); expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' }); }); it('keeps search and category controls together and filters with each', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); const search = screen.getByRole('searchbox', { name: 'Search tools' }); const category = screen.getByRole('combobox', { name: 'Filter by category', }); expect(search.closest('.tool-controls')).toBe( category.closest('.tool-controls') ); await user.type(search, 'xslt'); expect(toolLink('XSLT Workbench')).toBeInTheDocument(); expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument(); await user.clear(search); await user.selectOptions(category, 'notes'); expect(toolLink('OneNote Reader')).toBeInTheDocument(); expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument(); await user.selectOptions(category, ''); await user.click( screen.getByRole('button', { name: 'More information about PDF Workbench', }) ); await user.click( within(screen.getByRole('dialog', { name: 'PDF Workbench' })).getByRole( 'button', { name: 'pdf' } ) ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(category).toHaveValue('pdf'); expect(toolLink('PDF Workbench')).toBeInTheDocument(); expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument(); }); it('keeps remaining hidden tools visible while editing the hidden set', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ); const showHidden = screen.getByRole('checkbox', { name: 'Show hidden (1)', }); await user.click(showHidden); await user.click( screen.getByRole('button', { name: 'Hide XSLT Workbench' }) ); expect(showHidden).toHaveAccessibleName('Show hidden (2)'); await user.click( screen.getByRole('button', { name: 'Show PDF Workbench' }) ); expect(showHidden).toBeChecked(); expect(showHidden).toHaveAccessibleName('Show hidden (1)'); expect(toolLink('XSLT Workbench')).toBeInTheDocument(); }); it('clears search filters without revealing hidden tools', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ); await user.type( screen.getByRole('searchbox', { name: 'Search tools' }), 'no tool has this phrase' ); await user.click(screen.getByRole('button', { name: 'Clear filters' })); expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument(); expect(toolLink('XSLT Workbench')).toBeInTheDocument(); expect( screen.getByRole('checkbox', { name: 'Show hidden (1)' }) ).not.toBeChecked(); }); it('lists contextual destinations in the Apps menu', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); await user.click(screen.getByRole('button', { name: 'Apps' })); const navigation = screen.getByRole('navigation', { name: 'Toolbox applications', }); const destination = within(navigation).getByRole('link', { name: 'XSLT Workbench', }); expect( new URL(destination.getAttribute('href')!).searchParams.get('toolbox') ).toContain('toolbox.catalog.json'); }); it('uses the shared accessible header identity and control order', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); render(); await findToolLink(); const header = document.querySelector('.portal-header')!; expect( within(header).getByRole('heading', { level: 1, name: 'Toolbox' }) ).toBeInTheDocument(); expect( within(header).getByText('Local-first browser tools') ).toBeInTheDocument(); expect( screen.getByRole('heading', { level: 2, name: 'Your document tools, in one calm place.', }) ).toBeInTheDocument(); expect( screen.getAllByRole('heading', { name: 'Your document tools, in one calm place.', }) ).toHaveLength(1); const personalize = within(header).getByRole('button', { name: 'Personalize', }); const apps = within(header).getByRole('button', { name: 'Apps' }); const source = within(header).getByRole('link', { name: 'Toolbox source on Gitea', }); const help = within(header).getByRole('button', { name: 'Help' }); const brand = within(header).getByRole('link', { name: 'add·ideas Toolbox', }); const controls = within(header).getByRole('group', { name: 'Toolbox controls', }); expect( Array.from(controls.children, (element) => element.getAttribute('aria-label') ) ).toEqual(['Help', 'Toolbox source on Gitea', 'Apps', 'Personalize']); expect(help.compareDocumentPosition(source)).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); expect(source.compareDocumentPosition(apps)).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); expect(apps.compareDocumentPosition(personalize)).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); expect( Array.from( header.querySelector('.toolbox-shell__bar')?.children ?? [], (element) => element.className ) ).toEqual([ 'toolbox-shell__brand', 'toolbox-shell__identity', 'toolbox-shell__controls', ]); expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg'); }); it('persists an explicit appearance and accepts cross-tab updates', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); const personalize = screen.getByRole('button', { name: 'Personalize' }); await user.click(personalize); expect( screen.getByRole('heading', { name: 'Personalize your toolbox' }) ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Dark' })); expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); await waitFor(() => expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"') ); await user.click(personalize); fireEvent( window, new StorageEvent('storage', { key: PREFERENCES_KEY, newValue: JSON.stringify({ version: 1, pinned: [], order: [ 'de.add-ideas.pdf-tools', 'de.add-ideas.xslt-tools', 'de.add-ideas.onenote-tools', ], hidden: [], theme: 'light', }), }) ); expect(document.documentElement).toHaveAttribute('data-theme', 'light'); }); it('supports keyboard drag ordering from each tile grip', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); render(); await findToolLink(); const cards = [ screen.getByTestId('app-card-de.add-ideas.pdf-tools'), screen.getByTestId('app-card-de.add-ideas.xslt-tools'), screen.getByTestId('app-card-de.add-ideas.onenote-tools'), ]; cards.forEach((card, index) => { const rect = { x: index * 220, y: 0, width: 188, height: 188, top: 0, right: index * 220 + 188, bottom: 188, left: index * 220, toJSON: () => ({}), } as DOMRect; card.getBoundingClientRect = () => rect; card.getClientRects = () => [rect] as unknown as DOMRectList; }); const grip = screen.getByRole('button', { name: 'Reorder PDF Workbench' }); grip.focus(); fireEvent.keyDown(grip, { key: ' ', code: 'Space' }); await waitFor(() => expect(grip).toHaveAttribute('aria-pressed', 'true')); await new Promise((resolve) => setTimeout(resolve, 0)); fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' }); fireEvent.keyDown(document, { key: ' ', code: 'Space' }); await waitFor(() => { const tools = screen.getByRole('region', { name: 'Tools' }); expect( within(tools) .getAllByRole('heading', { level: 3 }) .map((heading) => heading.textContent) ).toEqual(['XSLT', 'PDF', 'OneNote']); }); expect( JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order ).toEqual([ 'de.add-ideas.xslt-tools', 'de.add-ideas.pdf-tools', 'de.add-ideas.onenote-tools', ]); }); it('filters by clickable tags and clears an active tag', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); await findToolLink(); await user.click( screen.getByRole('button', { name: 'More information about PDF Workbench', }) ); const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' }); await user.click(within(dialog).getByRole('button', { name: '#merge' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(toolLink('PDF Workbench')).toBeInTheDocument(); expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument(); expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument(); const activeTag = screen.getByRole('button', { name: /#merge.*Clear tag filter/i, }); await user.click(activeTag); expect(await findToolLink('XSLT Workbench')).toBeInTheDocument(); expect(toolLink('OneNote Reader')).toBeInTheDocument(); }); it('opens the standard help control and explains tile interaction', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); const { container } = render(); await findToolLink(); const help = screen.getByRole('button', { name: 'Help' }); await user.click(help); const dialog = screen.getByRole('dialog', { name: 'Choose and arrange your tools', }); expect(dialog).toHaveTextContent('click anywhere on a tool tile'); expect(dialog).toHaveTextContent('use the light bulb'); expect(container.querySelector('.site-shell')).toHaveAttribute('inert'); expect(container.querySelector('.site-shell')).toHaveAttribute( 'aria-hidden', 'true' ); const close = within(dialog).getByRole('button', { name: 'Close help' }); expect(close).toHaveFocus(); await user.tab(); expect(close).toHaveFocus(); await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); expect(help).toHaveFocus(); }); it('shows a recoverable catalogue error', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(new Response('no', { status: 503 })) .mockImplementation(catalogueFetch()); vi.stubGlobal('fetch', fetchMock); render(); expect( await screen.findByRole('heading', { name: 'Toolbox unavailable' }) ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); expect(await findToolLink()).toBeInTheDocument(); }); it('shows a dedicated empty state for a valid catalogue', async () => { vi.stubGlobal( 'fetch', vi.fn( catalogueFetch({ '/toolbox.catalog.json': Response.json({ ...catalogue, apps: [] }), }) ) ); render(); expect( await screen.findByRole('heading', { name: 'No tools yet' }) ).toBeInTheDocument(); }); it('hosts compact preferences in the shared Personalize popover', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); const { container } = render(); const personalize = screen.getByRole('button', { name: 'Personalize' }); await user.click(personalize); const heading = screen.getByRole('heading', { name: 'Personalize your toolbox', }); const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' }); expect(popover).toContainElement(heading); expect(popover).toContainElement(screen.getByText('On this device')); expect( within(popover).getByRole('group', { name: 'Appearance' }) ).toBeInTheDocument(); expect( popover.querySelector('.toolbox-shell__preferences-content') ).toBeInTheDocument(); expect(popover).not.toHaveAttribute('aria-modal'); expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); expect(screen.getByText(/does not transmit them/i)).toHaveTextContent( /Trusted code in another Toolbox app on this site can access same-origin storage/i ); expect( screen.getByRole('button', { name: 'Export JSON' }) ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Import JSON' }) ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument(); await user.keyboard('{Escape}'); expect( screen.queryByRole('dialog', { name: 'Personalize Toolbox' }) ).not.toBeInTheDocument(); expect(personalize).toHaveFocus(); }); });