fix: close and recreate macOS windows safely
This commit is contained in:
138
tests/unit/main-window-reopen-navigation.test.ts
Normal file
138
tests/unit/main-window-reopen-navigation.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function createWindow(loading = false) {
|
||||
const contents = Object.assign(new EventEmitter(), {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isLoadingMainFrame: vi.fn(() => loading),
|
||||
getURL: vi.fn(() => loading ? '' : 'file:///index.html'),
|
||||
send: vi.fn(),
|
||||
});
|
||||
return {
|
||||
webContents: contents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isVisible: vi.fn(() => false),
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const trays: FakeTray[] = [];
|
||||
const menus: Electron.MenuItemConstructorOptions[][] = [];
|
||||
class FakeTray extends EventEmitter {
|
||||
destroyed = false;
|
||||
setToolTip = vi.fn();
|
||||
setContextMenu = vi.fn();
|
||||
isDestroyed = () => this.destroyed;
|
||||
destroy = () => { this.destroyed = true; };
|
||||
constructor() {
|
||||
super();
|
||||
trays.push(this);
|
||||
}
|
||||
}
|
||||
const getFocusedWindow = vi.fn(() => null);
|
||||
vi.doMock('electron', () => ({
|
||||
app: { isPackaged: false, name: 'Makelore', quit: vi.fn() },
|
||||
Tray: FakeTray,
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn((template: Electron.MenuItemConstructorOptions[]) => {
|
||||
menus.push(template);
|
||||
return template;
|
||||
}),
|
||||
setApplicationMenu: vi.fn(),
|
||||
},
|
||||
BrowserWindow: { getFocusedWindow },
|
||||
nativeImage: { createFromPath: vi.fn(() => ({ isEmpty: () => false, setTemplateImage: vi.fn() })) },
|
||||
}));
|
||||
const { createTray } = await import('../../electron/main/tray');
|
||||
const { createMenu } = await import('../../electron/main/menu');
|
||||
const { sendWhenWindowReady } = await import('../../electron/main/window-provider');
|
||||
return { trays, menus, createTray, createMenu, sendWhenWindowReady };
|
||||
}
|
||||
|
||||
function findItem(items: Electron.MenuItemConstructorOptions[], label: string): Electron.MenuItemConstructorOptions {
|
||||
for (const item of items) {
|
||||
if (item.label === label) return item;
|
||||
if (Array.isArray(item.submenu)) {
|
||||
try { return findItem(item.submenu, label); } catch { /* Search the next submenu. */ }
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing menu item ${label}`);
|
||||
}
|
||||
|
||||
function click(items: Electron.MenuItemConstructorOptions[], label: string): void {
|
||||
const handler = findItem(items, label).click;
|
||||
expect(handler).toBeTypeOf('function');
|
||||
(handler as () => void)();
|
||||
}
|
||||
|
||||
describe('menu and tray after closing the main window', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reopens a window from New Chat and waits for the renderer before navigating', async () => {
|
||||
const ctx = await setup();
|
||||
const window = createWindow(true);
|
||||
const ensure = vi.fn(() => window as unknown as BrowserWindow);
|
||||
ctx.createMenu(ensure);
|
||||
click(ctx.menus[0], 'New Chat');
|
||||
expect(ensure).toHaveBeenCalledOnce();
|
||||
expect(window.show).toHaveBeenCalledOnce();
|
||||
expect(window.webContents.send).not.toHaveBeenCalled();
|
||||
window.webContents.emit('did-finish-load');
|
||||
expect(window.webContents.send).toHaveBeenCalledExactlyOnceWith('navigate', '/');
|
||||
expect(window.webContents.listenerCount('destroyed')).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses the tray while resolving a replacement window and restoring a minimized window', async () => {
|
||||
const ctx = await setup();
|
||||
const oldWindow = createWindow();
|
||||
let current: BrowserWindow | null = oldWindow as unknown as BrowserWindow;
|
||||
const replacement = createWindow(true);
|
||||
replacement.isMinimized.mockReturnValue(true);
|
||||
const ensure = vi.fn(() => {
|
||||
current = replacement as unknown as BrowserWindow;
|
||||
return current;
|
||||
});
|
||||
const tray = ctx.createTray(() => current, ensure);
|
||||
oldWindow.isDestroyed.mockReturnValue(true);
|
||||
expect(ctx.createTray(() => current, ensure)).toBe(tray);
|
||||
expect(ctx.trays).toHaveLength(1);
|
||||
click(ctx.menus[0], 'Open Settings');
|
||||
expect(ensure).toHaveBeenCalledOnce();
|
||||
expect(replacement.restore).toHaveBeenCalledOnce();
|
||||
expect(oldWindow.show).not.toHaveBeenCalled();
|
||||
expect(replacement.webContents.send).not.toHaveBeenCalled();
|
||||
replacement.webContents.emit('did-finish-load');
|
||||
expect(replacement.webContents.send).toHaveBeenCalledWith('navigate', '/settings');
|
||||
});
|
||||
|
||||
it('opens a fresh window from the tray click when no window exists', async () => {
|
||||
const ctx = await setup();
|
||||
const replacement = createWindow();
|
||||
const ensure = vi.fn(() => replacement as unknown as BrowserWindow);
|
||||
ctx.createTray(() => null, ensure);
|
||||
ctx.trays[0].emit('click');
|
||||
expect(ensure).toHaveBeenCalledOnce();
|
||||
expect(replacement.show).toHaveBeenCalledOnce();
|
||||
expect(replacement.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cancels a pending navigation if the replacement window closes before loading', async () => {
|
||||
const ctx = await setup();
|
||||
const window = createWindow(true);
|
||||
ctx.sendWhenWindowReady(window as unknown as BrowserWindow, 'navigate', '/settings');
|
||||
window.isDestroyed.mockReturnValue(true);
|
||||
window.webContents.emit('destroyed');
|
||||
window.webContents.emit('did-finish-load');
|
||||
expect(window.webContents.send).not.toHaveBeenCalled();
|
||||
expect(window.webContents.listenerCount('did-finish-load')).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user