diff --git a/docs/src/electron-api/class-electronapplication.md b/docs/src/electron-api/class-electronapplication.md index 3006801d262f9..6536a5ebe784f 100644 --- a/docs/src/electron-api/class-electronapplication.md +++ b/docs/src/electron-api/class-electronapplication.md @@ -85,6 +85,14 @@ Page to retrieve the window for. Closes Electron application. +### option: ElectronApplication.close.timeout +* since: v1.52 +- `timeout` ?<[float]> + +Maximum time in milliseconds to wait for the application to close gracefully. If the timeout is exceeded, the +application process is forcefully terminated. Pass `0` to disable timeout (default). When no timeout is specified, +`close()` waits indefinitely for the application to exit. + ## method: ElectronApplication.context * since: v1.9 - returns: <[BrowserContext]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 69ce47e5b6c6c..0a48be53bdbe5 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -17076,8 +17076,16 @@ export interface ElectronApplication { /** * Closes Electron application. + * @param options */ - close(): Promise; + close(options?: { + /** + * Maximum time in milliseconds to wait for the application to close gracefully. If the timeout is exceeded, the + * application process is forcefully terminated. Pass `0` to disable timeout (default). When no timeout is specified, + * `close()` waits indefinitely for the application to exit. + */ + timeout?: number; + }): Promise; /** * This method returns browser context that can be used for setting up context-wide routing, etc. diff --git a/packages/playwright-core/src/electron/electron.ts b/packages/playwright-core/src/electron/electron.ts index cc493b7ed1222..fd9e647a5fa67 100644 --- a/packages/playwright-core/src/electron/electron.ts +++ b/packages/playwright-core/src/electron/electron.ts @@ -180,7 +180,7 @@ export class Electron implements api.Electron { const chromeMatch = await Promise.race([chromeMatchPromise, waitForXserverError]); const browser = await chromium.connectOverCDP(chromeMatch[1], { timeout: progress.timeUntilDeadline(), isLocal: true }); - app = new ElectronApplication(worker, browser, launchedProcess); + app = new ElectronApplication(worker, browser, launchedProcess, kill); await progress.race(app._initialize()); return app; } catch (error) { @@ -198,8 +198,9 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp private _windows = new Map | undefined>(); private _appHandlePromise = new ManualPromise>(); private _closedPromise: Promise | undefined; + private _kill: () => Promise; - constructor(worker: Worker, browser: Browser, process: childProcess.ChildProcess) { + constructor(worker: Worker, browser: Browser, process: childProcess.ChildProcess, kill: () => Promise) { super(); this._worker = worker; @@ -214,6 +215,7 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp this._context.close = () => this.close(); this._process = process; + this._kill = kill; } _onClose() { @@ -249,7 +251,7 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp await this.close(); } - async close() { + async close(options?: { timeout?: number }) { if (!this._closedPromise) { this._closedPromise = new Promise(f => this.once(Events.ElectronApplication.Close, f)); await this._browser.close(); @@ -257,7 +259,17 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp await appHandle.evaluate(({ app }) => app.quit()).catch(() => {}); await this._worker._disconnect(); } - await this._closedPromise; + + if (options?.timeout) { + const timer = setTimeout(() => this._kill().catch(() => {}), options.timeout); + try { + await this._closedPromise; + } finally { + clearTimeout(timer); + } + } else { + await this._closedPromise; + } } async waitForEvent(event: string, optionsOrPredicate: Function | { timeout?: number, predicate?: Function } = {}): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 69ce47e5b6c6c..0a48be53bdbe5 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -17076,8 +17076,16 @@ export interface ElectronApplication { /** * Closes Electron application. + * @param options */ - close(): Promise; + close(options?: { + /** + * Maximum time in milliseconds to wait for the application to close gracefully. If the timeout is exceeded, the + * application process is forcefully terminated. Pass `0` to disable timeout (default). When no timeout is specified, + * `close()` waits indefinitely for the application to exit. + */ + timeout?: number; + }): Promise; /** * This method returns browser context that can be used for setting up context-wide routing, etc. diff --git a/tests/electron/electron-app-hang-on-close.js b/tests/electron/electron-app-hang-on-close.js new file mode 100644 index 0000000000000..60a2d1082add1 --- /dev/null +++ b/tests/electron/electron-app-hang-on-close.js @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); +const { app } = require('electron'); + +assert(process.env.PWTEST_ELECTRON_USER_DATA_DIR, 'PWTEST_ELECTRON_USER_DATA_DIR env var is not set'); +app.setPath('appData', process.env.PWTEST_ELECTRON_USER_DATA_DIR); + +app.on('window-all-closed', e => e.preventDefault()); + +// Prevent quit — simulates an app that hangs on close +// (e.g. due to IPC handlers, child processes, or beforeunload). +app.on('before-quit', e => { + e.preventDefault(); +}); diff --git a/tests/electron/electron-app.spec.ts b/tests/electron/electron-app.spec.ts index 77dc7e0b9aeef..6dbc3c87c383c 100644 --- a/tests/electron/electron-app.spec.ts +++ b/tests/electron/electron-app.spec.ts @@ -343,6 +343,33 @@ test('should detach debugger on app-initiated exit', async ({ launchElectronApp await closePromise; }); +test('should force-kill app when close timeout is exceeded', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp('electron-app-hang-on-close.js'); + const events: string[] = []; + electronApp.on('close', () => events.push('application(close)')); + electronApp.process().on('exit', () => events.push('process(exit)')); + + // This app prevents quit via before-quit handler, so close() without + // timeout would hang indefinitely. With a timeout, it should force-kill. + await electronApp.close({ timeout: 3000 }); + + events.sort(); + expect(events).toEqual(['application(close)', 'process(exit)']); +}); + +test('should not force-kill when app closes within timeout', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp('electron-app.js'); + const events: string[] = []; + electronApp.on('close', () => events.push('application(close)')); + electronApp.process().on('exit', () => events.push('process(exit)')); + + // Normal app should close well within the timeout. + await electronApp.close({ timeout: 30000 }); + + events.sort(); + expect(events).toEqual(['application(close)', 'process(exit)']); +}); + test('should run pre-ready apis', async ({ launchElectronApp }) => { await launchElectronApp('electron-app-pre-ready.js'); });