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);
|
||||
});
|
||||
Reference in New Issue
Block a user