mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 08:15:45 +00:00
496 lines
23 KiB
TypeScript
496 lines
23 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { WindowControlsHost } from '@/components/layout/window-controls-host';
|
|
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
|
|
import { menuKeyText } from '@/lib/desktop/menu-bar';
|
|
import { desktopBootScript } from '@/lib/desktop/boot';
|
|
import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay';
|
|
|
|
class FakeOverlay extends EventTarget {
|
|
visible = true;
|
|
rect = { x: 0, y: 0, width: 867, height: 40 };
|
|
getTitlebarAreaRect() {
|
|
return this.rect;
|
|
}
|
|
change(next: Partial<FakeOverlay>) {
|
|
Object.assign(this, next);
|
|
this.dispatchEvent(new Event('geometrychange'));
|
|
}
|
|
}
|
|
|
|
function installOverlay(overlay: FakeOverlay | undefined) {
|
|
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
const rightInset = () => root.style.getPropertyValue(TITLEBAR_RIGHT_VAR);
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
installOverlay(undefined);
|
|
root.style.removeProperty(TITLEBAR_RIGHT_VAR);
|
|
root.style.removeProperty(TITLEBAR_ROW_VAR);
|
|
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
|
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
|
});
|
|
|
|
describe('the menu bar in the desktop app', () => {
|
|
type BarEvent = { visible: boolean; open?: string; pinned?: boolean };
|
|
type Line = { key: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] };
|
|
|
|
const line = (key: string, label: string, extra: Partial<Line> = {}): Line => ({ key, label, type: 'normal', enabled: true, mnemonic: 0, ...extra });
|
|
const MENUS = [
|
|
{ id: 'file', label: 'File', mnemonic: 'f', items: [
|
|
line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N' }),
|
|
line('0/1', 'Print…', { enabled: false, mnemonic: 0 }),
|
|
{ key: '0/2', label: '', type: 'separator', enabled: false },
|
|
line('0/3', 'Exit', { mnemonic: 1, accelerator: 'Ctrl+Q' }),
|
|
] },
|
|
{ id: 'view', label: 'View', mnemonic: 'v', items: [
|
|
line('1/0', 'Reading pane', { type: 'submenu', items: [line('1/0/0', 'On the right', { type: 'radio', checked: true }), line('1/0/1', 'Off', { type: 'radio', mnemonic: 1 })] }),
|
|
line('1/1', 'Always show the menu bar', { type: 'checkbox', checked: false }),
|
|
line('1/2', 'Zoom in', { mnemonic: 0, accelerator: 'CmdOrCtrl+Plus' }),
|
|
] },
|
|
{ id: 'help', label: 'Help', mnemonic: 'h', items: [line('2/0', 'Keyboard shortcuts', { mnemonic: 0, accelerator: '?' })] },
|
|
];
|
|
|
|
function withBar(options: { legacy?: boolean; platform?: string } = {}) {
|
|
let send: (event: BarEvent) => void = () => {};
|
|
const open = vi.fn();
|
|
const run = vi.fn();
|
|
const report = vi.fn();
|
|
const setPinned = vi.fn();
|
|
window.bulwarkDesktop = {
|
|
version: 1,
|
|
platform: options.platform ?? 'win32',
|
|
menu: {
|
|
setState: () => {},
|
|
bar: {
|
|
entries: async () => MENUS.map(({ id, label, mnemonic }) => ({ id, label, mnemonic })),
|
|
open,
|
|
onEvent: (listener: (event: BarEvent) => void) => {
|
|
send = listener;
|
|
return () => {};
|
|
},
|
|
...(options.legacy ? {} : { menus: async () => MENUS, run, report, setPinned }),
|
|
},
|
|
},
|
|
} as never;
|
|
return { open, run, report, setPinned, send: (event: BarEvent) => act(async () => send(event)) };
|
|
}
|
|
|
|
afterEach(() => {
|
|
window.bulwarkDesktop = undefined;
|
|
document.documentElement.removeAttribute('data-menu-bar');
|
|
document.documentElement.removeAttribute('data-desktop');
|
|
localStorage.removeItem('bulwark-menu-bar');
|
|
});
|
|
|
|
const overlayOn = () => {
|
|
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
|
configurable: true,
|
|
value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }),
|
|
});
|
|
};
|
|
|
|
afterEach(() => {
|
|
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
|
|
});
|
|
|
|
const typed = () => {
|
|
const before = document.createElement('input');
|
|
document.body.appendChild(before);
|
|
before.focus();
|
|
return before;
|
|
};
|
|
|
|
it('Alt shows a row that moves the app down, with the keys on the first name, and Escape gives them back', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
const before = typed();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
const file = await screen.findByRole('menuitem', { name: 'File' });
|
|
await waitFor(() => expect(document.activeElement).toBe(file));
|
|
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
|
|
expect(screen.getByRole('menubar').hasAttribute('data-window-top-row')).toBe(true);
|
|
expect(bar.report).toHaveBeenLastCalledWith(expect.objectContaining({ active: true }));
|
|
// The letter that opens it is underlined while the keys are on the bar.
|
|
expect(file.querySelector('.underline')?.textContent).toBe('F');
|
|
fireEvent.keyDown(file, { key: 'ArrowRight' });
|
|
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'View' }));
|
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(false);
|
|
expect(bar.report).toHaveBeenLastCalledWith({ height: 0, active: false });
|
|
expect(document.activeElement).toBe(before);
|
|
before.remove();
|
|
});
|
|
|
|
it('draws the menu with its keys apart from the names, skips what is off, and runs a line where the keys were', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
const before = typed();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
const file = await screen.findByRole('menuitem', { name: 'File' });
|
|
await waitFor(() => expect(document.activeElement).toBe(file));
|
|
fireEvent.keyDown(file, { key: 'ArrowDown' });
|
|
const menu = await screen.findByRole('menu', { name: 'File' });
|
|
const newMessage = within(menu).getByRole('menuitem', { name: /New message/ });
|
|
await waitFor(() => expect(document.activeElement).toBe(newMessage));
|
|
expect(newMessage.querySelector('[data-menu-label]')?.textContent).toBe('New message');
|
|
expect(newMessage.querySelector('[data-menu-keys]')?.textContent).toBe('Ctrl+N');
|
|
expect(within(menu).getByRole('menuitem', { name: /Print/ }).getAttribute('aria-disabled')).toBe('true');
|
|
// Down skips the line that's off.
|
|
fireEvent.keyDown(newMessage, { key: 'ArrowDown' });
|
|
expect(document.activeElement?.textContent).toContain('Exit');
|
|
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
|
|
expect(bar.run).toHaveBeenCalledWith('0/3', 'Exit');
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
expect(document.activeElement).toBe(before);
|
|
before.remove();
|
|
});
|
|
|
|
it('the arrows go across to the next menu and into a submenu', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true, open: 'file' });
|
|
const menu = await screen.findByRole('menu', { name: 'File' });
|
|
await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true));
|
|
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
|
|
const view = await screen.findByRole('menu', { name: 'View' });
|
|
await waitFor(() => expect(document.activeElement?.textContent).toContain('Reading pane'));
|
|
expect(screen.queryByRole('menu', { name: 'File' })).toBeNull();
|
|
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
|
|
const sub = await screen.findByRole('menu', { name: 'Reading pane' });
|
|
await waitFor(() => expect(sub.contains(document.activeElement)).toBe(true));
|
|
expect(within(sub).getByRole('menuitemradio', { name: /On the right/ }).getAttribute('aria-checked')).toBe('true');
|
|
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
|
|
expect(screen.queryByRole('menu', { name: 'Reading pane' })).toBeNull();
|
|
expect(document.activeElement?.textContent).toContain('Reading pane');
|
|
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
|
|
await screen.findByRole('menu', { name: 'File' });
|
|
expect(view.isConnected).toBe(false);
|
|
// Escape closes the menu and leaves the keys on its name.
|
|
await waitFor(() => expect(screen.getByRole('menu', { name: 'File' }).contains(document.activeElement)).toBe(true));
|
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'File' }));
|
|
});
|
|
|
|
it('Alt and a letter opens that menu, and a line\'s letter runs it', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true, open: 'view' });
|
|
const view = await screen.findByRole('menu', { name: 'View' });
|
|
await waitFor(() => expect(view.contains(document.activeElement)).toBe(true));
|
|
fireEvent.keyDown(document.activeElement!, { key: 'z' });
|
|
expect(bar.run).toHaveBeenCalledWith('1/2', 'Zoom in');
|
|
});
|
|
|
|
it('a click elsewhere puts it away', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'Help' }), { button: 0 });
|
|
await screen.findByRole('menu', { name: 'Help' });
|
|
fireEvent.pointerDown(document.body);
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
});
|
|
|
|
it('always shown, it is there without Alt and takes no keys, and a click on a name opens that menu', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
const before = typed();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: false, pinned: true });
|
|
const help = await screen.findByRole('menuitem', { name: 'Help' });
|
|
expect(document.activeElement).toBe(before);
|
|
expect(help.querySelector('.underline')).toBeNull();
|
|
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
|
|
expect(localStorage.getItem('bulwark-menu-bar')).toBe('pinned');
|
|
fireEvent.pointerDown(help, { button: 0 });
|
|
const menu = await screen.findByRole('menu', { name: 'Help' });
|
|
fireEvent.click(within(menu).getByRole('menuitem', { name: /Keyboard shortcuts/ }));
|
|
expect(bar.run).toHaveBeenCalledWith('2/0', 'Keyboard shortcuts');
|
|
// Still there: it's always shown.
|
|
expect(screen.getByRole('menubar')).toBeTruthy();
|
|
expect(document.activeElement).toBe(before);
|
|
await bar.send({ visible: false, pinned: false });
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
expect(localStorage.getItem('bulwark-menu-bar')).toBeNull();
|
|
before.remove();
|
|
});
|
|
|
|
it('an older shell opens the menu natively', async () => {
|
|
overlayOn();
|
|
const bar = withBar({ legacy: true });
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'View' }), { button: 0 });
|
|
expect(bar.open).toHaveBeenCalledWith('view', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
|
|
});
|
|
|
|
it('the shell can take it away again', async () => {
|
|
overlayOn();
|
|
const bar = withBar();
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
await screen.findByRole('menubar');
|
|
await bar.send({ visible: false });
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
});
|
|
|
|
it('with no bridge there is none, and none on a Mac, which has the system bar', async () => {
|
|
overlayOn();
|
|
const { unmount } = render(<WindowMenuBar />);
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
unmount();
|
|
const bar = withBar({ platform: 'darwin' });
|
|
render(<WindowMenuBar />);
|
|
await bar.send({ visible: true });
|
|
expect(screen.queryByRole('menubar')).toBeNull();
|
|
});
|
|
|
|
it('an always-shown bar has its row from the first frame, before any script of the app', () => {
|
|
overlayOn();
|
|
localStorage.setItem('bulwark-menu-bar', 'pinned');
|
|
window.bulwarkDesktop = { version: 1, platform: 'darwin' } as never;
|
|
new Function(desktopBootScript())();
|
|
expect(document.documentElement.hasAttribute('data-menu-bar'), 'a Mac has the system bar').toBe(false);
|
|
window.bulwarkDesktop = { version: 1, platform: 'win32' } as never;
|
|
new Function(desktopBootScript())();
|
|
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
|
|
});
|
|
|
|
it('menu keys read the way Windows writes them', () => {
|
|
expect(menuKeyText('CmdOrCtrl+N', 'win32')).toBe('Ctrl+N');
|
|
expect(menuKeyText('CmdOrCtrl+Plus', 'win32')).toBe('Ctrl++');
|
|
expect(menuKeyText('Alt+Left', 'win32')).toBe('Alt+←');
|
|
expect(menuKeyText('Shift+R', 'linux')).toBe('Shift+R');
|
|
expect(menuKeyText('Delete', 'win32')).toBe('Del');
|
|
expect(menuKeyText('?', 'win32')).toBe('?');
|
|
expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z');
|
|
});
|
|
});
|
|
|
|
describe('the window controls over the app', () => {
|
|
const overlayAt = (x: number, width: number, height = 56) => {
|
|
const fake = Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x, y: 0, width, height }) });
|
|
installOverlay(fake as never);
|
|
return fake;
|
|
};
|
|
|
|
it('the root layout has no messages: the host there reads none, and the bar sits below', () => {
|
|
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
|
|
expect(read('components/layout/window-controls-host.tsx')).not.toContain('next-intl');
|
|
expect(read('app/(main)/layout.tsx')).toContain('<WindowControlsHost />');
|
|
expect(read('app/(main)/layout.tsx')).not.toContain('<WindowMenuBar');
|
|
for (const layout of ['app/(main)/[locale]/layout.tsx', 'app/(main)/admin/layout.tsx']) expect(read(layout)).toContain('<WindowMenuBar />');
|
|
});
|
|
|
|
it('does nothing without the API', () => {
|
|
installOverlay(undefined);
|
|
render(<WindowControlsHost />);
|
|
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
|
expect(rightInset()).toBe('');
|
|
expect(readTitlebarArea()).toBeNull();
|
|
});
|
|
|
|
it('does nothing when the overlay is there but hidden', () => {
|
|
installOverlay(Object.assign(new EventTarget(), { visible: false, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 56 }) }) as never);
|
|
render(<WindowControlsHost />);
|
|
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('draws no row of its own: no title, no strip, nothing on the page at all', () => {
|
|
overlayAt(0, window.innerWidth - 138);
|
|
document.title = 'A subject - Bulwark';
|
|
const { container } = render(<WindowControlsHost />);
|
|
expect(container.innerHTML).toBe('');
|
|
expect(document.body.textContent).not.toContain('A subject');
|
|
});
|
|
|
|
it('says how much room the controls take, on the right', () => {
|
|
overlayAt(0, window.innerWidth - 138);
|
|
render(<WindowControlsHost />);
|
|
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(true);
|
|
expect(rightInset()).toBe('138px');
|
|
expect(root.style.getPropertyValue(TITLEBAR_ROW_VAR)).toBe('56px');
|
|
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('and on the left, on macOS', () => {
|
|
overlayAt(78, window.innerWidth - 78);
|
|
render(<WindowControlsHost />);
|
|
expect(rightInset()).toBe('0px');
|
|
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(true);
|
|
});
|
|
|
|
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
|
const fake = overlayAt(0, window.innerWidth - 138);
|
|
render(<WindowControlsHost />);
|
|
act(() => {
|
|
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 100, height: 56 });
|
|
fake.dispatchEvent(new Event('geometrychange'));
|
|
});
|
|
expect(rightInset()).toBe('100px');
|
|
act(() => {
|
|
(fake as unknown as { visible: boolean }).visible = false;
|
|
fake.dispatchEvent(new Event('geometrychange'));
|
|
});
|
|
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
|
expect(rightInset()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('which rows hold the controls', () => {
|
|
const W = window.innerWidth;
|
|
const AREA = { x: 0, y: 0, width: W - 138, height: 56 };
|
|
const row = (className: string, rect: { left: number; right: number; top?: number; height?: number }, parent: HTMLElement = document.body, attrs: Record<string, string> = {}) => {
|
|
const el = document.createElement('div');
|
|
el.className = className;
|
|
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
|
|
const top = rect.top ?? 0;
|
|
const height = rect.height ?? 56;
|
|
el.getBoundingClientRect = () => ({ left: rect.left, right: rect.right, top, bottom: top + height, width: rect.right - rect.left, height, x: rect.left, y: top, toJSON: () => ({}) });
|
|
parent.appendChild(el);
|
|
return el;
|
|
};
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('three panes: all are rows to drag by, and only the reader\'s toolbar makes room', () => {
|
|
const sidebar = row('h-14', { left: 56, right: 300 });
|
|
const list = row('h-14', { left: 300, right: 700 });
|
|
const reader = row('h-14', { left: 700, right: W });
|
|
markWindowRows(AREA);
|
|
for (const el of [sidebar, list, reader]) expect(el.hasAttribute(ROW_ATTRIBUTE)).toBe(true);
|
|
expect(sidebar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
});
|
|
|
|
it('with no reader, the list\'s header is the one', () => {
|
|
const list = row('h-14', { left: 300, right: W });
|
|
markWindowRows(AREA);
|
|
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
});
|
|
|
|
it('a row further down is no row of the window\'s', () => {
|
|
const lower = row('h-14', { left: 0, right: W, top: 100 });
|
|
markWindowRows(AREA);
|
|
expect(lower.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
|
expect(lower.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('a tall box at the top is a pane, unless it says otherwise', () => {
|
|
const pane = row('h-14', { left: 0, right: W, height: 600 });
|
|
const header = row('', { left: 0, right: W, height: 72 }, document.body, { 'data-window-top-row': '' });
|
|
markWindowRows(AREA);
|
|
expect(pane.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
|
expect(header.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
});
|
|
|
|
it('the outermost box of a row takes it, not one inside it too', () => {
|
|
const outer = row('h-14', { left: 0, right: W });
|
|
const inner = row('h-14', { left: 0, right: W }, outer);
|
|
markWindowRows(AREA);
|
|
expect(outer.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
expect(inner.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('macOS: the row under the traffic lights makes room on the left', () => {
|
|
const sidebar = row('h-14', { left: 56, right: 300 });
|
|
const list = row('h-14', { left: 300, right: W });
|
|
markWindowRows({ x: 78, y: 0, width: W - 78, height: 56 });
|
|
expect(sidebar.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(true);
|
|
expect(list.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(false);
|
|
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('a mark goes when the row no longer lies there', () => {
|
|
const reader = row('h-14', { left: 700, right: W });
|
|
markWindowRows(AREA);
|
|
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
reader.getBoundingClientRect = () => ({ left: 500, right: 800, top: 0, bottom: 56, width: 300, height: 56, x: 700, y: 0, toJSON: () => ({}) });
|
|
markWindowRows(AREA);
|
|
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it('a pane that finishes changing width, with nothing else changing, is noticed', async () => {
|
|
const list = row('h-14', { left: 300, right: 700 });
|
|
const stop = watchWindowRows(AREA);
|
|
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
|
list.getBoundingClientRect = () => ({ left: 300, right: W, top: 0, bottom: 56, width: W - 300, height: 56, x: 300, y: 0, toJSON: () => ({}) });
|
|
document.body.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
|
await Promise.resolve();
|
|
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
stop();
|
|
});
|
|
|
|
it('a row that appears is marked before the next paint', async () => {
|
|
const stop = watchWindowRows(AREA);
|
|
const dialogRow = row('h-12', { left: 0, right: W });
|
|
// A mutation callback, then a microtask: both ahead of any paint.
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(dialogRow.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
|
stop();
|
|
});
|
|
});
|
|
|
|
describe('the page roots and the stylesheet', () => {
|
|
const root = process.cwd();
|
|
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
|
|
|
it('keeps no row: the inset is 0, and the page roots are on the safe area alone', () => {
|
|
expect(css).toMatch(/--titlebar-inset:\s*0px/);
|
|
expect(css).toMatch(/--app-top-inset:\s*env\(safe-area-inset-top, 0px\)/);
|
|
expect(css).not.toMatch(/--titlebar-inset:\s*env/);
|
|
});
|
|
|
|
it('takes the room from env(), so it holds from the first paint', () => {
|
|
expect(css).toContain('--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))');
|
|
expect(css).toContain('--titlebar-left-inset: env(titlebar-area-x, 0px)');
|
|
});
|
|
|
|
it('does not key on the display-mode media query, which is false in Electron', () => {
|
|
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
|
});
|
|
|
|
it('drags by the app\'s own rows, and leaves what\'s clickable alone', () => {
|
|
expect(css).toMatch(/:is\(\[data-window-row\], \[data-window-rail\]\) \{\s*app-region: drag;/);
|
|
expect(css).toMatch(/\[contenteditable="true"\][\s\S]*?app-region: no-drag;/);
|
|
expect(css).not.toContain('.window-title-bar');
|
|
});
|
|
|
|
function tsxFiles(dir: string): string[] {
|
|
return readdirSync(dir).flatMap((name) => {
|
|
const path = join(dir, name);
|
|
if (name === '__tests__' || name === 'node_modules') return [];
|
|
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
|
});
|
|
}
|
|
|
|
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
|
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
|
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
|
expect(left).toEqual([]);
|
|
});
|
|
|
|
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
|
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
|
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
|
}
|
|
});
|
|
});
|