Files
webmail/components/settings/__tests__/desktop-quick-compose-setting.test.tsx
T
Linus Rath 71aaadef50 fix: run plugins' compose check in quick compose, keep a composer with one owner, and bring it back after a crash
The first version of quick compose skipped `onBeforeCompose`, a composer
moved back could end up open in both windows, a reply moved back under
Pro flagged the selected message, and a crashed pop-out came back blank.

- A new message in a pop-out runs `onBeforeCompose` with the same result
  as the main window: a plugin that stops it closes the window.
- The way back has two steps, so a pop-out that gave up can't leave a
  second owner. A handoff behind a plugin's gate makes it say busy.
- A Pro tab made from a composer moved back answers its own message.
- Each save writes `draft`, `account` and `owner` into the pop-out's
  address, which a reload after a crash starts from. What was typed since
  the last save, up to the 2 s autosave delay, is still lost.
- The recorder refuses keys that type a character on this keyboard, and
  what the shell refuses from a page, and says why.
2026-11-04 01:31:43 +01:00

178 lines
8.2 KiB
TypeScript

import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DesktopOsStatus, DesktopQuickComposeStatus } from '@/lib/desktop/types';
import { DesktopOsSettings } from '../desktop-os-settings';
import { DesktopQuickComposeSetting } from '../desktop-quick-compose-setting';
// The shell decides what's held. This page shows what it says, and that includes keys another
// app has: a shortcut that does nothing must not look as if it works.
vi.mock('next-intl', async (importOriginal) => ({
...(await importOriginal<typeof import('next-intl')>()),
useTranslations: () => (key: string, values?: Record<string, string>) => (values?.keys ? `${key}:${values.keys}` : key),
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const KEYS = 'CommandOrControl+Alt+N';
const HELD: DesktopQuickComposeStatus = { shortcut: KEYS, active: true, locked: false, builtIn: KEYS };
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('DesktopQuickComposeSetting', () => {
const show = (status: Partial<DesktopQuickComposeStatus>, platform = 'win32') => {
const onChange = vi.fn();
render(<DesktopQuickComposeSetting status={{ ...HELD, ...status }} platform={platform} onChange={onChange} />);
return onChange;
};
it('names the keys while they work', () => {
show({});
expect(screen.getByText('description:Ctrl+Alt+N')).toBeTruthy();
expect(screen.getByTestId('quick-compose-keys').textContent).toBe('Ctrl+Alt+N');
expect(screen.queryByText('taken:Ctrl+Alt+N')).toBeNull();
});
it('says so when another app holds the keys', () => {
show({ active: false });
expect(screen.getByText('taken:Ctrl+Alt+N')).toBeTruthy();
expect(screen.queryByText('description:Ctrl+Alt+N')).toBeNull();
});
it('turned off, it names no keys and no conflict', () => {
show({ shortcut: null, active: false });
expect(screen.getByText('description_off')).toBeTruthy();
expect(screen.queryByTestId('quick-compose-keys')).toBeNull();
expect(screen.queryByText(/^taken/)).toBeNull();
});
it('the switch turns it off, and on again with the usual keys', () => {
const onChange = show({});
fireEvent.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenLastCalledWith(null);
const again = vi.fn();
render(<DesktopQuickComposeSetting status={{ shortcut: null, active: false, locked: false, builtIn: 'CommandOrControl+Alt+M' }} platform="win32" onChange={again} />);
fireEvent.click(screen.getAllByRole('switch')[1]);
expect(again).toHaveBeenLastCalledWith('CommandOrControl+Alt+M');
});
it('takes new keys from a press, and ignores a press that can\'t be a shortcut', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
const recorder = screen.getByTestId('quick-compose-recording');
fireEvent.keyDown(recorder, { key: 'c', code: 'KeyC', ctrlKey: true });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-compose-recording')).toBeTruthy();
fireEvent.keyDown(recorder, { key: 'm', code: 'KeyM', ctrlKey: true, altKey: true });
expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+M');
expect(screen.queryByTestId('quick-compose-recording')).toBeNull();
});
it('refuses keys this keyboard types a character with, and says so', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
const recorder = screen.getByTestId('quick-compose-recording');
fireEvent.keyDown(recorder, { key: '€', code: 'KeyE', ctrlKey: true, altKey: true });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('types_character')).toBeTruthy();
// Still recording: the next try can be better.
fireEvent.keyDown(recorder, { key: 'Enter', code: 'Enter', ctrlKey: true, altKey: true });
expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+Enter');
});
it('refuses keys the system or every app uses, and says so', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('reserved')).toBeTruthy();
});
it('refuses keys this keyboard types a character with, and says so', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
const recorder = screen.getByTestId('quick-compose-recording');
fireEvent.keyDown(recorder, { key: '€', code: 'KeyE', ctrlKey: true, altKey: true });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('types_character')).toBeTruthy();
// Still recording: the next try can be better.
fireEvent.keyDown(recorder, { key: 'Enter', code: 'Enter', ctrlKey: true, altKey: true });
expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+Enter');
});
it('refuses keys the system or every app uses, and says so', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('reserved')).toBeTruthy();
});
it('Esc leaves things as they were', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'Escape', code: 'Escape' });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-compose-keys')).toBeTruthy();
});
it('a press while recording goes no further, or the app\'s own shortcuts would run', () => {
const heard = vi.fn();
document.addEventListener('keydown', heard);
show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'k', code: 'KeyK', ctrlKey: true, altKey: true });
document.removeEventListener('keydown', heard);
expect(heard).not.toHaveBeenCalled();
});
it('set by a policy, the row is locked and says who set it', () => {
show({ locked: true });
expect(screen.getByRole('switch').closest('[inert]')).not.toBeNull();
});
});
describe('on the desktop settings page', () => {
function bridge(status: Partial<DesktopOsStatus>) {
const full = {
settings: { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null },
pausedUntil: null,
shortcutActive: false,
...status,
};
const setSettings = vi.fn(async (patch: object) => ({ ...full, settings: { ...full.settings, ...patch }, quickCompose: { shortcut: null, active: false, locked: false, builtIn: KEYS } }));
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
mode: 'standalone',
os: {
getStatus: async () => full,
setSettings,
defaultAppStatus: async () => ({ registered: false, mailto: false, webcal: false }),
onStatusChange: () => () => {},
},
};
return setSettings;
}
it('sends the choice to the shell, and shows what comes back', async () => {
const setSettings = bridge({ quickCompose: HELD });
render(<DesktopOsSettings />);
const keys = await screen.findByTestId('quick-compose-keys');
fireEvent.click(keys.parentElement!.querySelector('[role="switch"]')!);
expect(setSettings).toHaveBeenCalledWith({ quickComposeShortcut: null });
await screen.findByText('description_off');
});
it('shows nothing about it under a shell that doesn\'t know it', async () => {
bridge({});
render(<DesktopOsSettings />);
await screen.findByText('default_app_button');
expect(screen.queryByText('label')).toBeNull();
});
});