Files
makelore/tests/unit/main-window-reopen-ipc.test.ts
2026-09-30 00:58:46 +08:00

213 lines
10 KiB
TypeScript

import { EventEmitter } from 'node:events';
import type { BrowserWindow, WebContents } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BackgroundLifecycleController } from '../../electron/main/background-lifecycle';
function createWindow(id: number) {
const contents = Object.assign(new EventEmitter(), {
id,
isDestroyed: vi.fn(() => false),
send: vi.fn(),
});
return Object.assign(new EventEmitter(), {
id,
webContents: contents,
isDestroyed: vi.fn(() => false),
minimize: vi.fn(),
maximize: vi.fn(),
unmaximize: vi.fn(),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
close: vi.fn(),
});
}
async function setup() {
type Handler = (event: { sender: WebContents }, input?: unknown) => unknown;
const handlers = new Map<string, Handler>();
const owners = new Map<unknown, BrowserWindow>();
const showSaveDialog = vi.fn().mockResolvedValue({ canceled: true });
const popup = vi.fn();
const handle = vi.fn((channel: string, handler: Handler) => {
if (handlers.has(channel)) throw new Error(`duplicate channel: ${channel}`);
handlers.set(channel, handler);
});
vi.doMock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp/test'), quit: vi.fn() },
ipcMain: { handle },
BrowserWindow: { fromWebContents: (contents: WebContents) => owners.get(contents) ?? null },
dialog: { showSaveDialog, showOpenDialog: vi.fn(), showMessageBox: vi.fn() },
shell: { openExternal: vi.fn(), openPath: vi.fn(), showItemInFolder: vi.fn() },
clipboard: { writeText: vi.fn() },
Menu: { buildFromTemplate: vi.fn(() => ({ popup })) },
}));
vi.doMock('../../electron/main/ipc/host-api-proxy', () => ({ registerHostApiProxyHandlers: vi.fn() }));
vi.doMock('../../electron/main/proxy', () => ({ applyProxySettings: vi.fn() }));
vi.doMock('../../electron/main/launch-at-startup', () => ({ syncLaunchAtStartupSettingFromStore: vi.fn() }));
vi.doMock('../../electron/utils/store', () => ({
getAllSettings: vi.fn(), getSetting: vi.fn(), resetSettings: vi.fn(), setSetting: vi.fn(),
}));
vi.doMock('../../electron/services/providers/provider-service', () => ({ getProviderService: vi.fn() }));
vi.doMock('../../electron/services/providers/provider-validation', () => ({ validateApiKeyWithProvider: vi.fn() }));
vi.doMock('../../electron/main/admin-access', () => ({
isAdminSessionUnlocked: vi.fn(), lockAdminSession: vi.fn(), verifyAdminPassword: vi.fn(),
}));
vi.doMock('../../electron/main/performance-diagnostics', () => ({ collectPerformanceSnapshot: vi.fn() }));
vi.doMock('../../electron/main/app-deep-link', () => ({ takeCloudAgentRoute: vi.fn() }));
const first = createWindow(1);
const second = createWindow(2);
owners.set(first.webContents, first as unknown as BrowserWindow);
owners.set(second.webContents, second as unknown as BrowserWindow);
let current: BrowserWindow | null = first as unknown as BrowserWindow;
const leases = new Set<string>();
const lifecycle = {
acquireLease: vi.fn(({ id }: { id: string }) => { leases.add(id); }),
releaseLease: vi.fn((id: string) => { leases.delete(id); }),
getLeaseCount: () => leases.size,
getActivity: vi.fn(() => ({ visible: true, module: 'programming' })),
setActivity: vi.fn(),
};
const { registerIpcHandlers } = await import('../../electron/main/ipc-handlers');
const bindings = registerIpcHandlers(() => current, lifecycle as unknown as BackgroundLifecycleController);
const invoke = (channel: string, window: ReturnType<typeof createWindow>, input?: unknown) => {
const handler = handlers.get(channel);
if (!handler) throw new Error(`missing channel: ${channel}`);
return handler({ sender: window.webContents as unknown as WebContents }, input);
};
const reopen = () => {
first.isDestroyed.mockReturnValue(true);
current = second as unknown as BrowserWindow;
bindings.bindWindow(current);
};
return { first, second, bindings, handle, lifecycle, leases, invoke, reopen, showSaveDialog, popup };
}
describe('IPC after closing and reopening the main window', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('controls the replacement window without registering any IPC channel again', async () => {
const ctx = await setup();
const registeredCount = ctx.handle.mock.calls.length;
ctx.reopen();
ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow);
ctx.invoke('window:minimize', ctx.second);
ctx.invoke('window:maximize', ctx.second);
ctx.invoke('window:close', ctx.second);
expect(ctx.second.minimize).toHaveBeenCalledOnce();
expect(ctx.second.maximize).toHaveBeenCalledOnce();
expect(ctx.second.close).toHaveBeenCalledOnce();
expect(ctx.first.close).not.toHaveBeenCalled();
expect(ctx.handle).toHaveBeenCalledTimes(registeredCount);
expect(ctx.second.webContents.listenerCount('destroyed')).toBe(1);
});
it('queries fullscreen for the requesting window before and after reopening', async () => {
const ctx = await setup();
ctx.first.isFullScreen.mockReturnValue(true);
expect(ctx.invoke('window:isFullScreen', ctx.first)).toBe(true);
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(false);
ctx.reopen();
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(false);
ctx.second.isFullScreen.mockReturnValue(true);
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(true);
expect(() => ctx.invoke('window:isFullScreen', ctx.first)).toThrow('Main window is unavailable');
});
it('notifies each bound renderer once when its window enters or leaves fullscreen', async () => {
const ctx = await setup();
ctx.bindings.bindWindow(ctx.first as unknown as BrowserWindow);
expect(ctx.first.listenerCount('enter-full-screen')).toBe(1);
expect(ctx.first.listenerCount('leave-full-screen')).toBe(1);
ctx.first.emit('enter-full-screen');
ctx.first.emit('leave-full-screen');
expect(ctx.first.webContents.send.mock.calls).toEqual([
['window:fullscreen-changed', true],
['window:fullscreen-changed', false],
]);
expect(ctx.second.webContents.send).not.toHaveBeenCalled();
const registeredCount = ctx.handle.mock.calls.length;
ctx.reopen();
ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow);
expect(ctx.second.listenerCount('enter-full-screen')).toBe(1);
expect(ctx.second.listenerCount('leave-full-screen')).toBe(1);
ctx.second.emit('enter-full-screen');
ctx.second.emit('leave-full-screen');
expect(ctx.second.webContents.send.mock.calls).toEqual([
['window:fullscreen-changed', true],
['window:fullscreen-changed', false],
]);
expect(ctx.first.webContents.send).toHaveBeenCalledTimes(2);
expect(ctx.handle).toHaveBeenCalledTimes(registeredCount);
});
it('does not send fullscreen events after a window or renderer is destroyed', async () => {
const ctx = await setup();
ctx.reopen();
ctx.first.emit('enter-full-screen');
ctx.first.emit('leave-full-screen');
expect(ctx.first.webContents.send).not.toHaveBeenCalled();
ctx.second.webContents.isDestroyed.mockReturnValue(true);
ctx.second.emit('enter-full-screen');
ctx.second.emit('leave-full-screen');
expect(ctx.second.webContents.send).not.toHaveBeenCalled();
});
it('does not bind fullscreen events to an already destroyed window or renderer', async () => {
const ctx = await setup();
const closed = createWindow(3);
closed.isDestroyed.mockReturnValue(true);
ctx.bindings.bindWindow(closed as unknown as BrowserWindow);
expect(closed.listenerCount('enter-full-screen')).toBe(0);
expect(closed.listenerCount('leave-full-screen')).toBe(0);
ctx.second.webContents.isDestroyed.mockReturnValue(true);
ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow);
expect(ctx.second.listenerCount('enter-full-screen')).toBe(0);
expect(ctx.second.listenerCount('leave-full-screen')).toBe(0);
});
it('parents dialogs and link menus to the reopened renderer', async () => {
const ctx = await setup();
ctx.reopen();
await ctx.invoke('dialog:save', ctx.second, { defaultPath: 'new.md' });
expect(ctx.showSaveDialog).toHaveBeenLastCalledWith(ctx.second, { defaultPath: 'new.md' });
await ctx.invoke('transcript:save', ctx.second, { markdown: '# Transcript', defaultPath: 'chat.md' });
expect(ctx.showSaveDialog).toHaveBeenLastCalledWith(ctx.second, expect.objectContaining({ defaultPath: 'chat.md' }));
ctx.invoke('shell:showLinkContextMenu', ctx.second, { kind: 'external', target: 'https://example.com' });
expect(ctx.popup).toHaveBeenCalledWith({ window: ctx.second });
});
it('releases each renderer lease without releasing main-owned or replacement leases', async () => {
const ctx = await setup();
ctx.lifecycle.acquireLease({ id: 'coding-run:background' });
ctx.invoke('lifecycle:lease', ctx.first, { id: 'paint', kind: 'painting', active: true });
ctx.reopen();
ctx.invoke('lifecycle:lease', ctx.second, { id: 'paint', kind: 'painting', active: true });
expect(ctx.leases.size).toBe(3);
ctx.first.webContents.emit('destroyed');
expect(ctx.leases).toEqual(new Set(['coding-run:background', 'renderer:2:paint']));
ctx.second.webContents.emit('render-process-gone');
ctx.second.webContents.emit('destroyed');
expect(ctx.leases).toEqual(new Set(['coding-run:background']));
expect(ctx.lifecycle.releaseLease).toHaveBeenCalledTimes(2);
});
it('ignores stale activity from the closed renderer and notifies the current renderer', async () => {
const ctx = await setup();
ctx.reopen();
ctx.invoke('lifecycle:activity', ctx.first, { visible: false, module: 'painting' });
expect(ctx.lifecycle.setActivity).not.toHaveBeenCalled();
ctx.invoke('lifecycle:activity', ctx.second, { visible: true, module: 'programming' });
expect(ctx.lifecycle.setActivity).toHaveBeenCalledWith({ visible: true, module: 'programming' });
expect(ctx.second.webContents.send).toHaveBeenCalledWith('lifecycle:pause');
expect(ctx.first.webContents.send).not.toHaveBeenCalled();
});
});