213 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|