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(); const owners = new Map(); 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(); 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, 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(); }); });