fix: close and recreate macOS windows safely
This commit is contained in:
1 parent
f443f3bc66
commit
b5e6c663ef
19 files changed
+1079
-172
No files matched your search
@@ -0,0 +1,143 @@
|
||||
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 {
|
||||
id,
|
||||
webContents: contents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
minimize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
isMaximized: 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('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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user