fix: close and recreate macOS windows safely
This commit is contained in:
188
tests/e2e/main-window-lifecycle.spec.ts
Normal file
188
tests/e2e/main-window-lifecycle.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
|
||||
import { expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
async function hostRequest<T>(page: Page, path: string, body?: unknown): Promise<T> {
|
||||
const response = await page.evaluate(async (request) => {
|
||||
return await window.electron.ipcRenderer.invoke('hostapi:fetch', request) as {
|
||||
ok: boolean;
|
||||
data?: { ok: boolean; status: number; json?: unknown };
|
||||
error?: unknown;
|
||||
};
|
||||
}, { path, method: body === undefined ? 'GET' : 'POST', body });
|
||||
expect(response, path).toMatchObject({ ok: true, data: { ok: true } });
|
||||
return response.data!.json as T;
|
||||
}
|
||||
|
||||
async function mainWindowState(app: ElectronApplication) {
|
||||
return await app.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return win ? {
|
||||
id: win.id,
|
||||
minimized: win.isMinimized(),
|
||||
maximized: win.isMaximized(),
|
||||
fullScreen: win.isFullScreen(),
|
||||
visible: win.isVisible(),
|
||||
} : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function reopenMainWindow(app: ElectronApplication): Promise<Page> {
|
||||
const opened = app.waitForEvent('window');
|
||||
await app.evaluate(({ app: electronApp }) => { electronApp.emit('activate'); });
|
||||
await opened;
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
await expect.poll(async () => (await mainWindowState(app))?.visible).toBe(true);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function quitApplication(app: ElectronApplication): Promise<void> {
|
||||
const child = app.process();
|
||||
const closed = app.waitForEvent('close', { timeout: 15_000 });
|
||||
// The native Cmd+Q menu role calls this same Electron quit lifecycle.
|
||||
await app.evaluate(({ app: electronApp }) => { electronApp.quit(); });
|
||||
await closed;
|
||||
await expect.poll(() => child.exitCode).not.toBeNull();
|
||||
expect(child.exitCode).toBe(0);
|
||||
}
|
||||
|
||||
test('macOS closes and reopens the real window with working controls and a newly attached preview', async ({
|
||||
launchElectronApp,
|
||||
electronTestResources,
|
||||
}) => {
|
||||
test.skip(process.platform !== 'darwin', 'macOS keeps the application running after its last window closes');
|
||||
const projectPath = join(electronTestResources.rootDir, 'window-lifecycle-project');
|
||||
await mkdir(projectPath);
|
||||
const server = createServer((_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.end('<!doctype html><title>Window lifecycle preview</title><h1>Isolated local preview</h1>');
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing local preview port');
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
|
||||
try {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
const pid = app.process().pid;
|
||||
expect(await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')))
|
||||
.toBe(electronTestResources.userDataDir);
|
||||
const { project } = await hostRequest<{ project: { id: string } }>(page, '/api/coding/projects/open', { projectPath });
|
||||
const windowIds = new Set<number>();
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
const state = await mainWindowState(app);
|
||||
expect(state).not.toBeNull();
|
||||
expect(windowIds.has(state!.id)).toBe(false);
|
||||
windowIds.add(state!.id);
|
||||
expect(app.process().pid).toBe(pid);
|
||||
expect(app.process().exitCode).toBeNull();
|
||||
expect(await page.evaluate(() => window.electron.ipcRenderer.invoke('app:platform'))).toBe('darwin');
|
||||
expect(await page.evaluate(() => window.electron.ipcRenderer.invoke('window:isMaximized'))).toBe(false);
|
||||
|
||||
await page.evaluate(() => window.electron.ipcRenderer.invoke('window:minimize'));
|
||||
await expect.poll(async () => (await mainWindowState(app))?.minimized).toBe(true);
|
||||
await app.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows()[0].restore(); });
|
||||
await expect.poll(async () => (await mainWindowState(app))?.minimized).toBe(false);
|
||||
await page.evaluate(() => window.electron.ipcRenderer.invoke('window:maximize'));
|
||||
await expect.poll(async () => (await mainWindowState(app))?.maximized).toBe(true);
|
||||
await page.evaluate(() => window.electron.ipcRenderer.invoke('window:maximize'));
|
||||
await expect.poll(async () => (await mainWindowState(app))?.maximized).toBe(false);
|
||||
|
||||
// Hold only this renderer's test lease while inspecting a preview outside
|
||||
// the Code screen; closing the renderer must release it automatically.
|
||||
await page.evaluate(async () => {
|
||||
await window.electron.ipcRenderer.invoke('lifecycle:lease', {
|
||||
id: 'window-lifecycle-preview', kind: 'test-preview', active: true,
|
||||
});
|
||||
const state = window as typeof window & { __windowLifecycleBrowserEvents?: unknown[] };
|
||||
state.__windowLifecycleBrowserEvents = [];
|
||||
window.electron.ipcRenderer.on('agent-browser:show', (...args) => {
|
||||
state.__windowLifecycleBrowserEvents!.push(args.at(-1));
|
||||
});
|
||||
});
|
||||
const result = await hostRequest<{ browser: AgentBrowserSnapshot }>(page, '/api/agent-browser/open', {
|
||||
project_id: project.id,
|
||||
url,
|
||||
visible: true,
|
||||
bounds: { x: 20, y: 70, width: 420, height: 300 },
|
||||
});
|
||||
expect(result.browser).toMatchObject({ state: 'attached', visible: true, url });
|
||||
await expect.poll(async () => await page.evaluate(() => (
|
||||
window as typeof window & { __windowLifecycleBrowserEvents?: unknown[] }
|
||||
).__windowLifecycleBrowserEvents?.length)).toBeGreaterThan(0);
|
||||
const previewId = await app.evaluate(({ BrowserWindow, WebContentsView }, previewUrl) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const view = win.contentView.children.find((child) => (
|
||||
child instanceof WebContentsView && child.webContents.getURL() === previewUrl
|
||||
));
|
||||
if (!(view instanceof WebContentsView)) throw new Error('Preview was not attached to the current window');
|
||||
if (!view.getVisible()) throw new Error('Attached preview was not visible');
|
||||
return view.webContents.id;
|
||||
}, url);
|
||||
|
||||
if (cycle === 2) break;
|
||||
const closed = page.waitForEvent('close');
|
||||
if (cycle === 0) {
|
||||
// BrowserWindow.close follows the native red traffic-light close path.
|
||||
await app.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows()[0].close(); });
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
void window.electron.ipcRenderer.invoke('window:close').catch(() => undefined);
|
||||
});
|
||||
}
|
||||
await closed;
|
||||
await expect.poll(async () => await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(0);
|
||||
await expect.poll(async () => await app.evaluate(({ webContents }, id) => Boolean(webContents.fromId(id)), previewId)).toBe(false);
|
||||
expect(app.process().exitCode).toBeNull();
|
||||
page = await reopenMainWindow(app);
|
||||
}
|
||||
|
||||
await quitApplication(app);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS leaves native fullscreen before closing and can reopen and quit normally', async ({ launchElectronApp }) => {
|
||||
test.skip(process.platform !== 'darwin', 'Native macOS fullscreen transition');
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
|
||||
await app.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const entered = new Promise<void>((resolve) => { win.once('enter-full-screen', () => resolve()); });
|
||||
win.setFullScreen(true);
|
||||
await entered;
|
||||
});
|
||||
expect((await mainWindowState(app))?.fullScreen).toBe(true);
|
||||
|
||||
const events = await app.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const sequence: string[] = [];
|
||||
return await new Promise<string[]>((resolve) => {
|
||||
// Record the native event before the production listener is allowed to
|
||||
// synchronously finish closing the window in response to it.
|
||||
win.prependOnceListener('leave-full-screen', () => { sequence.push('leave-full-screen'); });
|
||||
win.once('closed', () => {
|
||||
sequence.push('closed');
|
||||
resolve(sequence);
|
||||
});
|
||||
win.close();
|
||||
});
|
||||
});
|
||||
expect(events).toEqual(['leave-full-screen', 'closed']);
|
||||
await expect.poll(async () => await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(0);
|
||||
expect(app.process().exitCode).toBeNull();
|
||||
await reopenMainWindow(app);
|
||||
expect(await mainWindowState(app)).toMatchObject({ fullScreen: false, minimized: false, visible: true });
|
||||
await quitApplication(app);
|
||||
});
|
||||
@@ -1742,6 +1742,67 @@ describe('AgentBrowserModule', () => {
|
||||
expect(adapter.resetPartitions).toEqual([agentBrowserPartition(projectPath)]);
|
||||
});
|
||||
|
||||
it.each(['close', 'replace-preview', 'switch-project'] as const)(
|
||||
'waits for old preview cleanup and cancels stale opens when reopening after %s',
|
||||
async (scenario) => {
|
||||
const adapter = new FakeAdapter();
|
||||
const preview = new FakePreviewDataSession();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({
|
||||
identifier: 'script-root',
|
||||
}));
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter, { previewDataSession: preview });
|
||||
await module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true,
|
||||
});
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>((resolve) => { finishCleanup = resolve; });
|
||||
const cleanupStarted = vi.fn(async () => await cleanup);
|
||||
adapter.views[0].webContents.debugger.responders.set(
|
||||
'Page.removeScriptToEvaluateOnNewDocument', cleanupStarted,
|
||||
);
|
||||
const superseded = scenario === 'close' ? undefined : module.open({
|
||||
projectId: 'clock',
|
||||
projectPath: scenario === 'switch-project' ? `${projectPath}-other` : projectPath,
|
||||
url: 'http://localhost:3001/',
|
||||
});
|
||||
const supersededResult = superseded?.catch((error: unknown) => error);
|
||||
if (superseded) await vi.waitFor(() => expect(cleanupStarted).toHaveBeenCalledOnce());
|
||||
|
||||
const closing = module.close();
|
||||
await vi.waitFor(() => expect(cleanupStarted).toHaveBeenCalledOnce());
|
||||
const reopening = module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://localhost:3002/',
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(adapter.views).toHaveLength(1);
|
||||
|
||||
finishCleanup();
|
||||
await expect(closing).resolves.toMatchObject({ state: 'closed' });
|
||||
if (supersededResult) await expect(supersededResult).resolves.toMatchObject({ code: 'CLOSED' });
|
||||
await expect(reopening).resolves.toMatchObject({
|
||||
state: 'attached', generation: 2, url: 'http://localhost:3002/',
|
||||
});
|
||||
expect(adapter.views).toHaveLength(2);
|
||||
expect(adapter.destroyed).toBe(1);
|
||||
expect(adapter.views[1].webContents.destroyed).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('cleans up a new view if its host window has already closed', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
vi.spyOn(adapter, 'mount').mockImplementation(() => { throw new Error('window-closed'); });
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
|
||||
await expect(module.open({
|
||||
projectId: 'clock', projectPath, url: 'http://localhost:3001/',
|
||||
})).rejects.toThrow('window-closed');
|
||||
expect(adapter.destroyed).toBe(1);
|
||||
expect(adapter.views[0].webContents.destroyed).toBe(true);
|
||||
await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' });
|
||||
});
|
||||
|
||||
it('does not reopen a project partition until profile reset has finished', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
let finishReset: (() => void) | undefined;
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
});
|
||||
|
||||
it.each([false, true])('captures a page without presenting a hidden native view (visible=%s)', async (visible) => {
|
||||
const adapter = new ElectronAgentBrowserAdapter({} as never);
|
||||
const adapter = new ElectronAgentBrowserAdapter(() => null);
|
||||
const view = adapter.createView('browser-screenshot');
|
||||
const native = electronMocks.nativeViews[0];
|
||||
native.getVisible.mockReturnValue(visible);
|
||||
@@ -184,6 +184,46 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('binds new views to the reopened window and never detaches them from an old owner', () => {
|
||||
const makeWindow = () => ({
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
webContents: { getZoomFactor: vi.fn().mockReturnValue(1) },
|
||||
getContentBounds: vi.fn().mockReturnValue({ width: 900, height: 650 }),
|
||||
});
|
||||
const first = makeWindow();
|
||||
const second = makeWindow();
|
||||
let current: ReturnType<typeof makeWindow> | null = first;
|
||||
const adapter = new ElectronAgentBrowserAdapter(() => current as never);
|
||||
const oldView = adapter.createView('first');
|
||||
adapter.mount(oldView);
|
||||
first.isDestroyed.mockReturnValue(true);
|
||||
current = null;
|
||||
adapter.unmount(oldView);
|
||||
adapter.destroy(oldView);
|
||||
current = second;
|
||||
const newView = adapter.createView('second');
|
||||
adapter.mount(newView);
|
||||
newView.setBounds({ x: 10, y: 10, width: 800, height: 600 });
|
||||
// A late old-view cleanup cannot remove a view from the replacement window.
|
||||
adapter.unmount(oldView);
|
||||
expect(second.contentView.addChildView).toHaveBeenCalledWith(electronMocks.nativeViews[1]);
|
||||
expect(second.contentView.removeChildView).not.toHaveBeenCalled();
|
||||
expect(first.contentView.addChildView).toHaveBeenCalledTimes(1);
|
||||
expect(electronMocks.nativeViews[1].setBounds).toHaveBeenCalledWith({ x: 10, y: 10, width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('allows detached preflight views but refuses visible mounting without a window', () => {
|
||||
const adapter = new ElectronAgentBrowserAdapter(() => null);
|
||||
const view = adapter.createView('detached');
|
||||
view.setBounds({ x: 0, y: 0, width: 390, height: 844 });
|
||||
expect(electronMocks.nativeViews[0].setBounds).toHaveBeenCalledWith({ x: 0, y: 0, width: 390, height: 844 });
|
||||
expect(() => adapter.mount(view)).toThrow('请先打开麦洛窗口');
|
||||
expect(() => adapter.unmount(view)).not.toThrow();
|
||||
adapter.destroy(view);
|
||||
expect((electronMocks.nativeViews[0].webContents as { close: unknown }).close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('denies downloads and clears only the requested project partition', async () => {
|
||||
const adapter = new ElectronAgentBrowserAdapter({
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
|
||||
94
tests/unit/main-window-close.test.ts
Normal file
94
tests/unit/main-window-close.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { registerMainWindowClose } from '@electron/main/main-window-close';
|
||||
|
||||
class WindowStub extends EventEmitter {
|
||||
fullscreen = false;
|
||||
destroyed = false;
|
||||
hide = vi.fn();
|
||||
setFullScreen = vi.fn();
|
||||
isFullScreen = () => this.fullscreen;
|
||||
isDestroyed = () => this.destroyed;
|
||||
close = vi.fn(() => {
|
||||
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
this.emit('close', event);
|
||||
if (!event.defaultPrevented) {
|
||||
this.destroyed = true;
|
||||
this.emit('closed');
|
||||
}
|
||||
});
|
||||
leaveFullscreen(): void {
|
||||
this.fullscreen = false;
|
||||
this.emit('leave-full-screen');
|
||||
}
|
||||
}
|
||||
|
||||
function setup(fullscreen = false) {
|
||||
const window = new WindowStub();
|
||||
window.fullscreen = fullscreen;
|
||||
let quitting = false;
|
||||
registerMainWindowClose(window as never, {
|
||||
platform: 'darwin', isQuitting: () => quitting, hideOnClose: false,
|
||||
});
|
||||
return { window, quit: () => { quitting = true; window.close(); } };
|
||||
}
|
||||
|
||||
describe('native main window close', () => {
|
||||
it('really destroys a normal macOS window instead of hiding it', () => {
|
||||
const { window } = setup();
|
||||
window.close();
|
||||
expect(window.destroyed).toBe(true);
|
||||
expect(window.hide).not.toHaveBeenCalled();
|
||||
expect(window.setFullScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the fullscreen exit before closing, including repeated clicks', () => {
|
||||
const { window } = setup(true);
|
||||
const order: string[] = [];
|
||||
window.prependListener('leave-full-screen', () => order.push('left fullscreen'));
|
||||
window.on('closed', () => order.push('closed'));
|
||||
window.close();
|
||||
window.close();
|
||||
expect(window.destroyed).toBe(false);
|
||||
expect(window.hide).not.toHaveBeenCalled();
|
||||
expect(window.setFullScreen).toHaveBeenCalledExactlyOnceWith(false);
|
||||
window.leaveFullscreen();
|
||||
expect(window.destroyed).toBe(true);
|
||||
expect(order).toEqual(['left fullscreen', 'closed']);
|
||||
});
|
||||
|
||||
it('does not delay a real app quit while fullscreen exit is pending', () => {
|
||||
const { window, quit } = setup(true);
|
||||
window.close();
|
||||
quit();
|
||||
expect(window.destroyed).toBe(true);
|
||||
window.leaveFullscreen();
|
||||
expect(window.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lets Cmd+Q close a fullscreen window without starting another transition', () => {
|
||||
const { window, quit } = setup(true);
|
||||
quit();
|
||||
expect(window.destroyed).toBe(true);
|
||||
expect(window.setFullScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels deferred closing if the window was destroyed externally', () => {
|
||||
const { window } = setup(true);
|
||||
window.close();
|
||||
window.destroyed = true;
|
||||
window.emit('closed');
|
||||
window.leaveFullscreen();
|
||||
expect(window.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves existing close-to-tray behavior on other platforms', () => {
|
||||
const window = new WindowStub();
|
||||
registerMainWindowClose(window as never, {
|
||||
platform: 'win32', isQuitting: () => false, hideOnClose: true,
|
||||
});
|
||||
window.close();
|
||||
expect(window.destroyed).toBe(false);
|
||||
expect(window.hide).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
143
tests/unit/main-window-reopen-ipc.test.ts
Normal file
143
tests/unit/main-window-reopen-ipc.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
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