diff --git a/docs/src/api/class-electronapplication.md b/docs/src/api/class-electronapplication.md index bc5b3136ef3b6..2079ba10c8eb4 100644 --- a/docs/src/api/class-electronapplication.md +++ b/docs/src/api/class-electronapplication.md @@ -45,6 +45,16 @@ This event is issued when the application closes. This event is issued for every window that is created **and loaded** in Electron. It contains a [Page] that can be used for Playwright automation. +## async method: ElectronApplication.browserWindow +- returns: <[JSHandle]> + +Returns the BrowserWindow object that corresponds to the given Playwright page. + +### param: ElectronApplication.browserWindow.page +- `page` <[Page]> + +Page to retrieve the window for. + ## async method: ElectronApplication.close Closes Electron application. diff --git a/src/client/electron.ts b/src/client/electron.ts index 157b54f593293..b1540e5a999d5 100644 --- a/src/client/electron.ts +++ b/src/client/electron.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { BrowserWindow } from 'electron'; import * as structs from '../../types/structs'; import * as api from '../../types/types'; import * as channels from '../protocol/channels'; @@ -55,7 +56,7 @@ export class Electron extends ChannelOwner implements api.ElectronApplication { - private _context?: BrowserContext; + private _context: BrowserContext; private _windows = new Set(); private _timeoutSettings = new TimeoutSettings(); @@ -65,13 +66,11 @@ export class ElectronApplication extends ChannelOwner this._context = BrowserContext.from(context)); - this._channel.on('window', ({ page, browserWindow }) => { - const window = Page.from(page); - (window as any).browserWindow = JSHandle.from(browserWindow); - this._windows.add(window); - this.emit(Events.ElectronApplication.Window, window); - window.once(Events.Page.Close, () => this._windows.delete(window)); + this._context = BrowserContext.from(initializer.context); + this._context.on(Events.BrowserContext.Page, page => { + this._windows.add(page); + this.emit(Events.ElectronApplication.Window, page); + page.once(Events.Page.Close, () => this._windows.delete(page)); }); this._channel.on('close', () => this.emit(Events.ElectronApplication.Close)); } @@ -109,6 +108,13 @@ export class ElectronApplication extends ChannelOwner> { + return this._wrapApiCall('electronApplication.browserWindow', async (channel: channels.ElectronApplicationChannel) => { + const result = await channel.browserWindow({ page: page._channel }); + return JSHandle.from(result.handle); + }); + } + async evaluate(pageFunction: structs.PageFunctionOn, arg: Arg): Promise { return this._wrapApiCall('electronApplication.evaluate', async (channel: channels.ElectronApplicationChannel) => { const result = await channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); diff --git a/src/dispatchers/electronDispatcher.ts b/src/dispatchers/electronDispatcher.ts index 72e8990b5ec5c..9ef87084dc74b 100644 --- a/src/dispatchers/electronDispatcher.ts +++ b/src/dispatchers/electronDispatcher.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { Dispatcher, DispatcherScope, lookupDispatcher } from './dispatcher'; -import { Electron, ElectronApplication, ElectronPage } from '../server/electron/electron'; +import { Dispatcher, DispatcherScope } from './dispatcher'; +import { Electron, ElectronApplication } from '../server/electron/electron'; import * as channels from '../protocol/channels'; import { BrowserContextDispatcher } from './browserContextDispatcher'; import { PageDispatcher } from './pageDispatcher'; @@ -35,27 +35,27 @@ export class ElectronDispatcher extends Dispatcher implements channels.ElectronApplicationChannel { constructor(scope: DispatcherScope, electronApplication: ElectronApplication) { - super(scope, electronApplication, 'ElectronApplication', {}, true); - this._dispatchEvent('context', { context: new BrowserContextDispatcher(this._scope, electronApplication.context()) }); + super(scope, electronApplication, 'ElectronApplication', { + context: new BrowserContextDispatcher(scope, electronApplication.context()) + }, true); electronApplication.on(ElectronApplication.Events.Close, () => { this._dispatchEvent('close'); this._dispose(); }); - electronApplication.on(ElectronApplication.Events.Window, (page: ElectronPage) => { - this._dispatchEvent('window', { - page: lookupDispatcher(page), - browserWindow: ElementHandleDispatcher.fromJSHandle(this._scope, page.browserWindow), - }); - }); + } + + async browserWindow(params: channels.ElectronApplicationBrowserWindowParams): Promise { + const handle = await this._object.browserWindow((params.page as PageDispatcher).page()); + return { handle: ElementHandleDispatcher.fromJSHandle(this._scope, handle) }; } async evaluateExpression(params: channels.ElectronApplicationEvaluateExpressionParams): Promise { - const handle = await this._object._nodeElectronHandlePromised; + const handle = await this._object._nodeElectronHandlePromise; return { value: serializeResult(await handle.evaluateExpressionAndWaitForSignals(params.expression, params.isFunction, true /* returnByValue */, parseArgument(params.arg))) }; } async evaluateExpressionHandle(params: channels.ElectronApplicationEvaluateExpressionHandleParams): Promise { - const handle = await this._object._nodeElectronHandlePromised; + const handle = await this._object._nodeElectronHandlePromise; const result = await handle.evaluateExpressionAndWaitForSignals(params.expression, params.isFunction, false /* returnByValue */, parseArgument(params.arg)); return { handle: ElementHandleDispatcher.fromJSHandle(this._scope, result) }; } diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index 883594612b9dd..bdc98e4e0516d 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -93,6 +93,10 @@ export class PageDispatcher extends Dispatcher i this._dispatchEvent('video', { artifact: existingDispatcher(page._video) }); } + page(): Page { + return this._page; + } + async setDefaultNavigationTimeoutNoReply(params: channels.PageSetDefaultNavigationTimeoutNoReplyParams, metadata: CallMetadata): Promise { this._page.setDefaultNavigationTimeout(params.timeout); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 2d6f702c363f9..0fcf0ef0cd62a 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -2564,22 +2564,25 @@ export type ElectronLaunchResult = { }; // ----------- ElectronApplication ----------- -export type ElectronApplicationInitializer = {}; +export type ElectronApplicationInitializer = { + context: BrowserContextChannel, +}; export interface ElectronApplicationChannel extends Channel { - on(event: 'context', callback: (params: ElectronApplicationContextEvent) => void): this; on(event: 'close', callback: (params: ElectronApplicationCloseEvent) => void): this; - on(event: 'window', callback: (params: ElectronApplicationWindowEvent) => void): this; + browserWindow(params: ElectronApplicationBrowserWindowParams, metadata?: Metadata): Promise; evaluateExpression(params: ElectronApplicationEvaluateExpressionParams, metadata?: Metadata): Promise; evaluateExpressionHandle(params: ElectronApplicationEvaluateExpressionHandleParams, metadata?: Metadata): Promise; close(params?: ElectronApplicationCloseParams, metadata?: Metadata): Promise; } -export type ElectronApplicationContextEvent = { - context: BrowserContextChannel, -}; export type ElectronApplicationCloseEvent = {}; -export type ElectronApplicationWindowEvent = { +export type ElectronApplicationBrowserWindowParams = { page: PageChannel, - browserWindow: JSHandleChannel, +}; +export type ElectronApplicationBrowserWindowOptions = { + +}; +export type ElectronApplicationBrowserWindowResult = { + handle: JSHandleChannel, }; export type ElectronApplicationEvaluateExpressionParams = { expression: string, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 8e1dd1c5ff824..80cf72375026c 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -2097,8 +2097,17 @@ Electron: ElectronApplication: type: interface + initializer: + context: BrowserContext + commands: + browserWindow: + parameters: + page: Page + returns: + handle: JSHandle + evaluateExpression: parameters: expression: string @@ -2118,20 +2127,8 @@ ElectronApplication: close: events: - - # This event happens once immediately after creation. - context: - parameters: - context: BrowserContext - close: - window: - parameters: - page: Page - browserWindow: JSHandle - - Android: type: interface diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 31b076937a42e..99819277a16b8 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -974,6 +974,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { env: tOptional(tArray(tType('NameValue'))), timeout: tOptional(tNumber), }); + scheme.ElectronApplicationBrowserWindowParams = tObject({ + page: tChannel('Page'), + }); scheme.ElectronApplicationEvaluateExpressionParams = tObject({ expression: tString, isFunction: tOptional(tBoolean), diff --git a/src/server/electron/electron.ts b/src/server/electron/electron.ts index 42cdbf41dbb6e..55ff02ee5af73 100644 --- a/src/server/electron/electron.ts +++ b/src/server/electron/electron.ts @@ -43,24 +43,16 @@ export type ElectronLaunchOptionsBase = { timeout?: number, }; -export interface ElectronPage extends Page { - browserWindow: js.JSHandle; - _browserWindowId: number; -} - export class ElectronApplication extends SdkObject { static Events = { Close: 'close', - Window: 'window', }; private _browserContext: CRBrowserContext; private _nodeConnection: CRConnection; private _nodeSession: CRSession; private _nodeExecutionContext: js.ExecutionContext | undefined; - _nodeElectronHandlePromised: Promise>; - private _resolveNodeElectronHandle!: (handle: js.JSHandle) => void; - private _windows = new Set(); + _nodeElectronHandlePromise: Promise>; private _lastWindowId = 0; readonly _timeoutSettings = new TimeoutSettings(); @@ -74,28 +66,21 @@ export class ElectronApplication extends SdkObject { this._browserContext.on(BrowserContext.Events.Page, event => this._onPage(event)); this._nodeConnection = nodeConnection; this._nodeSession = nodeConnection.rootSession; - this._nodeElectronHandlePromised = new Promise(resolve => this._resolveNodeElectronHandle = resolve); + this._nodeElectronHandlePromise = new Promise(f => { + this._nodeSession.on('Runtime.executionContextCreated', async (event: any) => { + if (event.context.auxData && event.context.auxData.isDefault) { + this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context)); + f(await js.evaluate(this._nodeExecutionContext, false /* returnByValue */, `process.mainModule.require('electron')`)); + } + }); + }); + this._nodeSession.send('Runtime.enable', {}).catch(e => {}); } - private async _onPage(page: ElectronPage) { + private async _onPage(page: Page) { // Needs to be sync. const windowId = ++this._lastWindowId; - page.on(Page.Events.Close, () => { - if (page.browserWindow) - page.browserWindow.dispose(); - this._windows.delete(page); - }); - page._browserWindowId = windowId; - this._windows.add(page); - - // Below is async. - const handle = await (await this._nodeElectronHandlePromised).evaluateHandle(({ BrowserWindow }, windowId) => BrowserWindow.fromId(windowId), windowId).catch(e => {}); - if (!handle) - return; - page.browserWindow = handle; - const controller = new ProgressController(internalCallMetadata(), this); - await controller.run(progress => page.mainFrame()._waitForLoadState(progress, 'domcontentloaded'), page._timeoutSettings.navigationTimeout({})).catch(e => {}); // can happen after detach - this.emit(ElectronApplication.Events.Window, page); + (page as any)._browserWindowId = windowId; } context(): BrowserContext { @@ -105,19 +90,15 @@ export class ElectronApplication extends SdkObject { async close() { const progressController = new ProgressController(internalCallMetadata(), this); const closed = progressController.run(progress => helper.waitForEvent(progress, this, ElectronApplication.Events.Close).promise, this._timeoutSettings.timeout({})); - await (await this._nodeElectronHandlePromised).evaluate(({ app }) => app.quit()); + const electronHandle = await this._nodeElectronHandlePromise; + await electronHandle.evaluate(({ app }) => app.quit()); this._nodeConnection.close(); await closed; } - async _init() { - this._nodeSession.on('Runtime.executionContextCreated', (event: any) => { - if (event.context.auxData && event.context.auxData.isDefault) - this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context)); - }); - await this._nodeSession.send('Runtime.enable', {}).catch(e => {}); - js.evaluate(this._nodeExecutionContext!, false /* returnByValue */, `process.mainModule.require('electron')`) - .then(this._resolveNodeElectronHandle); + async browserWindow(page: Page): Promise> { + const electronHandle = await this._nodeElectronHandlePromise; + return await electronHandle.evaluateHandle(({ BrowserWindow }, windowId) => BrowserWindow.fromId(windowId), (page as any)._browserWindowId); } } @@ -188,7 +169,6 @@ export class Electron extends SdkObject { }; const browser = await CRBrowser.connect(chromeTransport, browserOptions); app = new ElectronApplication(this, browser, nodeConnection); - await app._init(); return app; }, TimeoutSettings.timeout(options)); } diff --git a/tests/config/electron-app.js b/tests/config/electron-app.js index 7c7ffd8839e8e..26326546292f0 100644 --- a/tests/config/electron-app.js +++ b/tests/config/electron-app.js @@ -1,3 +1,3 @@ -const { app, BrowserWindow } = require('electron'); +const { app } = require('electron'); app.on('window-all-closed', e => e.preventDefault()); diff --git a/tests/config/electron-window-app.js b/tests/config/electron-window-app.js new file mode 100644 index 0000000000000..b2d8ebe758475 --- /dev/null +++ b/tests/config/electron-window-app.js @@ -0,0 +1,11 @@ +const { app, BrowserWindow } = require('electron'); + +app.whenReady().then(() => { + const win = new BrowserWindow({ + width: 800, + height: 600, + }); + win.loadURL('about:blank'); +}) + +app.on('window-all-closed', e => e.preventDefault()); diff --git a/tests/config/electron.config.ts b/tests/config/electron.config.ts index 5fd97090546cc..c232c28d2e535 100644 --- a/tests/config/electron.config.ts +++ b/tests/config/electron.config.ts @@ -16,7 +16,7 @@ import * as folio from 'folio'; import * as path from 'path'; -import { ElectronEnv, electronTest } from './electronTest'; +import { baseElectronTest, ElectronEnv, electronTest } from './electronTest'; import { test as pageTest } from './pageTest'; const config: folio.Config = { @@ -63,5 +63,6 @@ const envConfig = { } }; +baseElectronTest.runWith(envConfig); electronTest.runWith(envConfig); pageTest.runWith(envConfig, new ElectronPageEnv()); diff --git a/tests/config/electronTest.ts b/tests/config/electronTest.ts index c94b93a7a5177..933ee04712ced 100644 --- a/tests/config/electronTest.ts +++ b/tests/config/electronTest.ts @@ -79,4 +79,5 @@ export class ElectronEnv { } } +export const baseElectronTest = baseTest.extend({}); export const electronTest = baseTest.extend(new ElectronEnv()); diff --git a/tests/electron/electron-app.spec.ts b/tests/electron/electron-app.spec.ts index a37c84c2e4533..1c07c78d7af4f 100644 --- a/tests/electron/electron-app.spec.ts +++ b/tests/electron/electron-app.spec.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +import type { BrowserWindow } from 'electron'; import path from 'path'; -import { electronTest as test, expect } from '../config/electronTest'; +import { electronTest as test, baseElectronTest as baseTest, expect } from '../config/electronTest'; -test('should fire close event', async ({ playwright }) => { +baseTest('should fire close event', async ({ playwright }) => { const electronApp = await playwright._electron.launch({ args: [path.join(__dirname, '..', 'config', 'electron-app.js')], }); @@ -88,3 +89,20 @@ test('should have a clipboard instance', async ({ electronApp }) => { const clipboardContentRead = await electronApp.evaluate(async ({clipboard}) => clipboard.readText()); expect(clipboardContentRead).toEqual(clipboardContentToWrite); }); + +test('should test app that opens window fast', async ({ playwright }) => { + const electronApp = await playwright._electron.launch({ + args: [path.join(__dirname, '..', 'config', 'electron-window-app.js')], + }); + await electronApp.close(); +}); + +test('should return browser window', async ({ playwright }) => { + const electronApp = await playwright._electron.launch({ + args: [path.join(__dirname, '..', 'config', 'electron-window-app.js')], + }); + const page = await electronApp.waitForEvent('window'); + const bwHandle = await electronApp.browserWindow(page); + expect(await bwHandle.evaluate((bw: BrowserWindow) => bw.title)).toBe('Electron'); + await electronApp.close(); +}); diff --git a/types/types.d.ts b/types/types.d.ts index 23dfe075a63f6..809bfe7c0744e 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -7511,6 +7511,12 @@ export interface ElectronApplication { */ off(event: 'window', listener: (page: Page) => void): this; + /** + * Returns the BrowserWindow object that corresponds to the given Playwright page. + * @param page Page to retrieve the window for. + */ + browserWindow(page: Page): Promise; + /** * Closes Electron application. */