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();
});
});