Files
webmail/lib/__tests__/desktop-window-sync.test.ts
T
Linus Rath b79781cc94 fix: never lose a draft moving between windows and keep credentials off the window channel
Review of the pop-out work found ways to lose text, a wrong sign-out and
changes reaching ordinary browser tabs.

- Drafts: a closing window hands text over only to a main window holding
  its login, and asks instead when the last save failed, the browser is
  offline, a save is running or plugins could still have a say. A failed
  rescue goes to "Continue draft". The opening window lets go only once
  the composer is on screen, also for Edit draft in a message window.
  Send options travel with the draft. Closing during a send asks first.
- Sessions: answers are encrypted for the window that asked, and only a
  window holding the login answers. A renewal queued on the lock can be
  called off by a sign-out, signs nobody out once its account is gone, and
  takes the cached token after waiting (Rauthy `nbf`, #552).
- Ordinary tabs run none of the host. No lock is held for life.
- A message window hides after a refused session and waits for its
  folders; a composer stays on screen with its text. Another tab dropping a
  login it couldn't restore no longer closes pop-outs.
- Plain-text mail stays selectable in the desktop app. Folder save renames
  reserved and shell names and skips one refused name. All display-mode
  checks know the overlay. The attachment bar keeps to one row.

The earlier test that accepted text being gone after a failed rescue now
asserts that it reaches the "Continue draft" slot.
2026-11-03 08:37:10 +01:00

159 lines
6.2 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { desktopBootScript } from '@/lib/desktop/boot';
import { startWindowSync } from '@/lib/desktop/window-sync';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useThemeStore } from '@/stores/theme-store';
/** What another window of this browser writing the store looks like here. */
function writeFromAnotherWindow(key: string, state: Record<string, unknown>) {
const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
const value = JSON.stringify({ ...current, state: { ...current.state, ...state } });
localStorage.setItem(key, value);
window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('state across windows', () => {
let stop: () => void = () => {};
beforeEach(() => {
useThemeStore.setState({ theme: 'light' });
useLocaleStore.setState({ locale: 'en' });
});
afterEach(() => {
stop();
stop = () => {};
delete window.bulwarkDesktop;
window.history.replaceState(null, '', '/');
localStorage.clear();
document.documentElement.classList.remove('dark', 'light');
});
function asPopout() {
window.history.replaceState(null, '', '/en/popout/compose');
stop = startWindowSync();
}
it('follows a theme change without a reload, down to the page', async () => {
asPopout();
writeFromAnotherWindow('theme-storage', { theme: 'dark' });
await settle();
expect(useThemeStore.getState().theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => {
asPopout();
const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css);
expect(pack).toBeTruthy();
writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id });
await settle();
expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css);
});
it('follows a language change without a reload', async () => {
asPopout();
writeFromAnotherWindow('locale-storage', { locale: 'de' });
await settle();
expect(useLocaleStore.getState().locale).toBe('de');
});
it('follows a settings change without a reload', async () => {
asPopout();
const before = useSettingsStore.getState().plainTextMode;
writeFromAnotherWindow('settings-storage', { plainTextMode: !before });
await settle();
expect(useSettingsStore.getState().plainTextMode).toBe(!before);
});
it('also runs in the desktop app\'s main window', async () => {
window.bulwarkDesktop = { windows: {} };
stop = startWindowSync();
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
await settle();
expect(useLocaleStore.getState().locale).toBe('fr');
});
it('leaves a browser tab as it was', async () => {
stop = startWindowSync();
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
await settle();
expect(useLocaleStore.getState().locale).toBe('en');
});
});
describe('the app-like behaviour', () => {
const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8');
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? [];
it('hangs entirely on html[data-desktop], so browsers do not change', () => {
expect(rules.length).toBeGreaterThan(0);
for (const rule of rules) {
const selector = rule.slice(0, rule.indexOf('{'));
// Every selector of the list; commas inside :is() don't split it.
const parts = selector.split(/,(?![^(]*\))/);
for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/);
}
});
it('covers UI chrome and what the spec names', () => {
expect(css).toMatch(/user-select: none/);
expect(css).toMatch(/cursor: default/);
expect(css).toMatch(/overscroll-behavior: none/);
expect(css).toMatch(/-webkit-user-drag: none/);
});
it('keeps fields selectable and what the app drags draggable', () => {
expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/);
expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/);
});
it('does not switch selection off for the whole page, so message content stays selectable', () => {
expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/);
});
it('matches list rows only, not the plain-text body that carries the same data attribute', () => {
// email-viewer.tsx and thread-conversation-view.tsx put data-email-id on the body.
for (const file of ['components/email/email-viewer.tsx', 'components/email/thread-conversation-view.tsx']) {
const source = readFileSync(join(process.cwd(), file), 'utf8').replace(/\r\n/g, '\n');
expect(source, file).toMatch(/data-plugin-source="email-body"\n\s*data-email-id=/);
}
expect(css).not.toContain('data-email-id');
});
it('keeps a plain-text body selectable wherever it sits', () => {
expect(css).toMatch(/\[data-plugin-source="email-body"\],\s*html\[data-desktop\] \[data-plugin-source="email-body"\] \*\s*\{\s*-webkit-user-select: text;\s*user-select: text;/);
});
function runBoot() {
new Function(desktopBootScript())();
}
afterEach(() => {
delete window.bulwarkDesktop;
document.documentElement.removeAttribute('data-desktop');
});
it('marks the page before the first paint when the bridge is there', () => {
window.bulwarkDesktop = {};
runBoot();
expect(document.documentElement.hasAttribute('data-desktop')).toBe(true);
});
it('does not mark a browser', () => {
runBoot();
expect(document.documentElement.hasAttribute('data-desktop')).toBe(false);
});
it('is in the root layout\'s boot script', () => {
const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8');
expect(layout).toContain('${desktopBootScript()}');
expect(layout).toContain('../desktop.css');
});
});