Files
webmail/components/settings/__tests__/settings-plugin-page.test.tsx
T
Linus Rath 8008393e59 feat: add an Accessibility page to Settings
Settings get a page of their own for accessibility, with presets on top
(screen reader, low vision, dyslexia, focus, keyboard and motor, motion
and seizure safe, deaf and hard of hearing) that change several options
at once and can be undone, then the options by topic, and a reset.

Text size used to be a fixed 14/16/18px on the root, which threw away
the size people set in their browser. It is now a percentage of the
browser's size, from 87.5% to 200%. Motion, contrast and transparency
follow the system by default and can be overridden either way; the
old Animations switch becomes Reduce motion. The visual options are
applied before the first paint, so the page no longer flashes at the
wrong size.

- `lib/accessibility-settings.ts`: the new settings, their allowed
  values, and the code that writes them onto `<html>`
- `app/accessibility.css`: bundled fonts (Atkinson Hyperlegible Next,
  Lexend, OpenDyslexic), text spacing, a focus outline that survives
  Windows contrast themes, more-contrast colours on any theme, reduced
  transparency, a large pointer
- admins can preset every new option but lock only shortcuts, send
  confirmation and the notification duration; a lock on text size or
  another reading aid works as a default
- always-light mail and toolbar labels now sync, and imports drop
  values the page doesn't know
- font size and animations move off Appearance, the plain text font
  off Reading; everyday settings such as conversation view and undo
  send show on both their own page and Accessibility
2026-10-29 17:53:32 +01:00

141 lines
7.0 KiB
TypeScript

import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
// settings-page: a page of its own in Settings for each plugin that offers
// one, nested under Add-ons with the plugin's name.
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
}));
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useMessages: () => ({}),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => <div>accessibility pane</div> }));
const { SettingsApp } = await import('../settings-app');
const registry = await import('@/lib/plugin-sandbox/registry');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
function activate(id: string, name: string, slots: string[]) {
const plugin: InstalledPlugin = {
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:settings-section'], grantedPermissions: ['ui:settings-section'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin, code: '', tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: slots.map((slot) => ({ name: slot as never, order: 100, hasShouldShow: false })),
hookDisposables: [],
});
}
beforeEach(() => {
createSlotInstance.mockClear();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
// Every capability check answers "no"; ids are plain strings.
const client = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
});
afterEach(() => {
for (const p of registry.all()) registry.deregister(p.plugin.id);
vi.unstubAllGlobals();
});
describe('Settings plugin pages', () => {
it('nests a plugin with only a section under Add-ons', () => {
activate('smime', 'S/MIME', ['settings-section']);
render(<SettingsApp linkSegments={[]} />);
const addons = screen.getByTestId('settings-tab-addons');
const entry = screen.getByTestId('settings-tab-plugin:smime');
expect(addons.compareDocumentPosition(entry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(entry.className).toContain('ps-9');
});
it('lists the plugin by name under Add-ons and opens its page full width', async () => {
activate('kanban', 'Kanban', ['settings-page']);
const { container } = render(<SettingsApp linkSegments={[]} />);
const addons = screen.getByTestId('settings-tab-addons');
const entry = screen.getByRole('button', { name: /Kanban/ });
expect(addons.compareDocumentPosition(entry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(entry.className).toContain('ps-9');
expect(createSlotInstance).not.toHaveBeenCalled();
act(() => { fireEvent.click(entry); });
const pane = container.querySelector('[data-plugin-slot="settings-page"]')!;
expect(pane).not.toBeNull();
expect(pane.parentElement?.className).not.toContain('max-w-3xl');
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; plugin: { id: string } };
expect(opts.slot).toBe('settings-page');
expect(opts.plugin.id).toBe('kanban');
});
it('gives a plugin with both a page and a section one entry, with the section on its page', async () => {
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
const { container } = render(<SettingsApp linkSegments={[]} />);
const entries = screen.getAllByRole('button', { name: /Showcase/ });
expect(entries).toHaveLength(1);
act(() => { fireEvent.click(entries[0]); });
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2));
const slots = createSlotInstance.mock.calls.map((call) => (call[0] as { slot: string }).slot);
expect(slots.sort()).toEqual(['settings-page', 'settings-section']);
const section = container.querySelector('[data-plugin-slot="settings-section"]')!;
const page = container.querySelector('[data-plugin-slot="settings-page"]')!;
expect(section.compareDocumentPosition(page) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('opens the page of such a plugin from an old link to its section', async () => {
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
const { container } = render(<SettingsApp linkSegments={['plugin:showcase']} />);
await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull());
});
it('opens the page from a /settings/plugin-page:<id> link', async () => {
activate('kanban', 'Kanban', ['settings-page']);
render(<SettingsApp linkSegments={['plugin-page:kanban']} />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect((createSlotInstance.mock.calls[0][0] as { slot: string }).slot).toBe('settings-page');
});
});
// SET-21: every settings page has an h1 with its name on the desktop too.
describe('Settings page heading', () => {
it('names the open page in an h1', () => {
render(<SettingsApp linkSegments={[]} />);
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('settings.tabs.appearance');
});
});