diff --git a/docs/src/api/class-androiddevice.md b/docs/src/api/class-androiddevice.md index 861792243c..73f35a7d04 100644 --- a/docs/src/api/class-androiddevice.md +++ b/docs/src/api/class-androiddevice.md @@ -4,6 +4,12 @@ [AndroidDevice] represents a connected device, either real hardware or emulated. Devices can be obtained using [`method: Android.devices`]. +## event: AndroidDevice.close +* since: v1.28 +- argument: <[AndroidDevice]> + +Emitted when the device connection gets closed. + ## event: AndroidDevice.webView * since: v1.9 - argument: <[AndroidWebView]> diff --git a/packages/playwright-core/src/androidServerImpl.ts b/packages/playwright-core/src/androidServerImpl.ts index 65643ae53a..2ac01f25fa 100644 --- a/packages/playwright-core/src/androidServerImpl.ts +++ b/packages/playwright-core/src/androidServerImpl.ts @@ -57,6 +57,10 @@ export class AndroidServerLauncherImpl { browserServer.wsEndpoint = () => wsEndpoint; browserServer.close = () => device.close(); browserServer.kill = () => device.close(); + device.on('close', () => { + server.close(); + browserServer.emit('close'); + }); return browserServer; } } diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 3bb8430140..2cb4136fb5 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -131,6 +131,7 @@ export class AndroidDevice extends ChannelOwner i this._timeoutSettings = new TimeoutSettings((parent as Android)._timeoutSettings); this._channel.on('webViewAdded', ({ webView }) => this._onWebViewAdded(webView)); this._channel.on('webViewRemoved', ({ socketName }) => this._onWebViewRemoved(socketName)); + this._channel.on('close', () => this._didClose()); } private _onWebViewAdded(webView: channels.AndroidWebView) { @@ -239,7 +240,6 @@ export class AndroidDevice extends ChannelOwner i async close() { try { - this._didClose(); if (this._shouldCloseConnectionOnClose) this._connection.close(kBrowserClosedError); else @@ -252,7 +252,7 @@ export class AndroidDevice extends ChannelOwner i } _didClose() { - this.emit(Events.AndroidDevice.Close); + this.emit(Events.AndroidDevice.Close, this); } async shell(command: string): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 21f19a427a..25c3432a34 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2209,6 +2209,7 @@ scheme.AndroidDeviceInitializer = tObject({ model: tString, serial: tString, }); +scheme.AndroidDeviceCloseEvent = tOptional(tObject({})); scheme.AndroidDeviceWebViewAddedEvent = tObject({ webView: tType('AndroidWebView'), }); diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index e3220748b6..90ba632e9d 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -145,7 +145,7 @@ export class PlaywrightConnection { this._debugLog(`engaged pre-launched (Android) mode`); const playwright = this._preLaunched.playwright!; const androidDevice = this._preLaunched.androidDevice!; - androidDevice.on(AndroidDevice.Events.Closed, () => { + androidDevice.on(AndroidDevice.Events.Close, () => { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Android device disconnected' }); }); diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 10b8c5f68c..1065d8c273 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -112,7 +112,7 @@ export class AndroidDevice extends SdkObject { static Events = { WebViewAdded: 'webViewAdded', WebViewRemoved: 'webViewRemoved', - Closed: 'closed' + Close: 'close', }; private _browserConnections = new Set(); @@ -140,7 +140,11 @@ export class AndroidDevice extends SdkObject { async _init() { await this._refreshWebViews(); const poll = () => { - this._pollingWebViews = setTimeout(() => this._refreshWebViews().then(poll).catch(() => {}), 500); + this._pollingWebViews = setTimeout(() => this._refreshWebViews() + .then(poll) + .catch(() => { + this.close().catch(() => {}); + }), 500); }; poll(); } @@ -163,7 +167,9 @@ export class AndroidDevice extends SdkObject { return await this._backend.runCommand(`shell:screencap -p`); } - private async _driver(): Promise { + private async _driver(): Promise { + if (this._isClosed) + return; if (!this._driverPromise) this._driverPromise = this._installDriver(); return this._driverPromise; @@ -223,6 +229,8 @@ export class AndroidDevice extends SdkObject { // Patch the timeout in! params.timeout = this._timeoutSettings.timeout(params); const driver = await this._driver(); + if (!driver) + throw new Error('Device is closed'); const id = ++this._lastId; const result = new Promise((fulfill, reject) => this._callbacks.set(id, { fulfill, reject })); driver.send(JSON.stringify({ id, method, params })); @@ -230,6 +238,8 @@ export class AndroidDevice extends SdkObject { } async close() { + if (this._isClosed) + return; this._isClosed = true; if (this._pollingWebViews) clearTimeout(this._pollingWebViews); @@ -237,11 +247,11 @@ export class AndroidDevice extends SdkObject { await connection.close(); if (this._driverPromise) { const driver = await this._driver(); - driver.close(); + driver?.close(); } await this._backend.close(); this._android._deviceClosed(this); - this.emit(AndroidDevice.Events.Closed); + this.emit(AndroidDevice.Events.Close); } async launchBrowser(pkg: string = 'com.android.chrome', options: channels.BrowserNewContextParams): Promise { diff --git a/packages/playwright-core/src/server/android/backendAdb.ts b/packages/playwright-core/src/server/android/backendAdb.ts index 29e3834482..cd718b9d95 100644 --- a/packages/playwright-core/src/server/android/backendAdb.ts +++ b/packages/playwright-core/src/server/android/backendAdb.ts @@ -71,23 +71,26 @@ class AdbDevice implements DeviceBackend { async function runCommand(command: string, host: string = '127.0.0.1', port: number = 5037, serial?: string): Promise { debug('pw:adb:runCommand')(command, serial); const socket = new BufferedSocketWrapper(command, net.createConnection({ host, port })); - if (serial) { - await socket.write(encodeMessage(`host:transport:${serial}`)); + try { + if (serial) { + await socket.write(encodeMessage(`host:transport:${serial}`)); + const status = await socket.read(4); + assert(status.toString() === 'OKAY', status.toString()); + } + await socket.write(encodeMessage(command)); const status = await socket.read(4); assert(status.toString() === 'OKAY', status.toString()); + let commandOutput: Buffer; + if (!command.startsWith('shell:')) { + const remainingLength = parseInt((await socket.read(4)).toString(), 16); + commandOutput = await socket.read(remainingLength); + } else { + commandOutput = await socket.readAll(); + } + return commandOutput; + } finally { + socket.close(); } - await socket.write(encodeMessage(command)); - const status = await socket.read(4); - assert(status.toString() === 'OKAY', status.toString()); - let commandOutput: Buffer; - if (!command.startsWith('shell:')) { - const remainingLength = parseInt((await socket.read(4)).toString(), 16); - commandOutput = await socket.read(remainingLength); - } else { - commandOutput = await socket.readAll(); - } - socket.close(); - return commandOutput; } async function open(command: string, host: string = '127.0.0.1', port: number = 5037, serial?: string): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index 0e1cc5315c..970d2b5582 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -58,6 +58,7 @@ export class AndroidDeviceDispatcher extends Dispatcher this._dispatchEvent('webViewAdded', { webView })); this.addObjectListener(AndroidDevice.Events.WebViewRemoved, socketName => this._dispatchEvent('webViewRemoved', { socketName })); + this.addObjectListener(AndroidDevice.Events.Close, socketName => this._dispatchEvent('close')); } async wait(params: channels.AndroidDeviceWaitParams) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 6295cc0cf5..bfd3918c52 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12475,31 +12475,61 @@ export interface Android { * [android.devices([options])](https://playwright.dev/docs/api/class-android#android-devices). */ export interface AndroidDevice { + /** + * Emitted when the device connection gets closed. + */ + on(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Emitted when a new WebView instance is detected. */ on(event: 'webview', listener: (androidWebView: AndroidWebView) => void): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ once(event: 'webview', listener: (androidWebView: AndroidWebView) => void): this; + /** + * Emitted when the device connection gets closed. + */ + addListener(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Emitted when a new WebView instance is detected. */ addListener(event: 'webview', listener: (androidWebView: AndroidWebView) => void): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'webview', listener: (androidWebView: AndroidWebView) => void): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'webview', listener: (androidWebView: AndroidWebView) => void): this; + /** + * Emitted when the device connection gets closed. + */ + prependListener(event: 'close', listener: (androidDevice: AndroidDevice) => void): this; + /** * Emitted when a new WebView instance is detected. */ @@ -13105,6 +13135,11 @@ export interface AndroidDevice { timeout?: number; }): Promise; + /** + * Emitted when the device connection gets closed. + */ + waitForEvent(event: 'close', optionsOrPredicate?: { predicate?: (androidDevice: AndroidDevice) => boolean | Promise, timeout?: number } | ((androidDevice: AndroidDevice) => boolean | Promise)): Promise; + /** * Emitted when a new WebView instance is detected. */ diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index ef53f73a8b..a7d6c5881c 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -4045,6 +4045,7 @@ export type AndroidDeviceInitializer = { serial: string, }; export interface AndroidDeviceEventTarget { + on(event: 'close', callback: (params: AndroidDeviceCloseEvent) => void): this; on(event: 'webViewAdded', callback: (params: AndroidDeviceWebViewAddedEvent) => void): this; on(event: 'webViewRemoved', callback: (params: AndroidDeviceWebViewRemovedEvent) => void): this; } @@ -4076,6 +4077,7 @@ export interface AndroidDeviceChannel extends AndroidDeviceEventTarget, EventTar connectToWebView(params: AndroidDeviceConnectToWebViewParams, metadata?: Metadata): Promise; close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise; } +export type AndroidDeviceCloseEvent = {}; export type AndroidDeviceWebViewAddedEvent = { webView: AndroidWebView, }; @@ -4406,6 +4408,7 @@ export type AndroidDeviceCloseOptions = {}; export type AndroidDeviceCloseResult = void; export interface AndroidDeviceEvents { + 'close': AndroidDeviceCloseEvent; 'webViewAdded': AndroidDeviceWebViewAddedEvent; 'webViewRemoved': AndroidDeviceWebViewRemovedEvent; } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index cfccfdd3e7..78f94925f2 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -3267,6 +3267,8 @@ AndroidDevice: close: events: + close: + webViewAdded: parameters: webView: AndroidWebView diff --git a/tests/android/device.spec.ts b/tests/android/device.spec.ts index 6ae375a987..fc76fc1b11 100644 --- a/tests/android/device.spec.ts +++ b/tests/android/device.spec.ts @@ -92,3 +92,14 @@ test('androidDevice.options.omitDriverInstall', async function({ playwright }) { expect(fillStatus).toBe('success'); }); + +test('androidDevice.close', async function({ playwright }) { + const devices = await playwright._android.devices(); + expect(devices.length).toBe(1); + const device = devices[0]; + const events = []; + device.on('close', () => events.push('close')); + await device.close(); + await device.close(); + expect(events).toEqual(['close']); +}); diff --git a/tests/android/launch-server.spec.ts b/tests/android/launch-server.spec.ts index 6ebd6dbc0e..2a6d3baf12 100644 --- a/tests/android/launch-server.spec.ts +++ b/tests/android/launch-server.spec.ts @@ -26,7 +26,29 @@ test('android.launchServer should connect to a device', async ({ playwright }) = await browserServer.close(); }); -test('android.launchServer should be abe to reconnect to a device', async ({ playwright }) => { +test('android.launchServer should handle close event correctly', async ({ playwright }) => { + const receivedEvents = []; + const browserServer = await playwright._android.launchServer(); + const device = await playwright._android.connect(browserServer.wsEndpoint()); + device.on('close', () => receivedEvents.push('device')); + browserServer.on('close', () => receivedEvents.push('browserServer')); + { + const waitForDeviceClose = new Promise(f => device.on('close', f)); + await device.close(); + await waitForDeviceClose; + } + expect(receivedEvents).toEqual(['device']); + await device.close(); + expect(receivedEvents).toEqual(['device']); + await browserServer.close(); + expect(receivedEvents).toEqual(['device', 'browserServer']); + await browserServer.close(); + expect(receivedEvents).toEqual(['device', 'browserServer']); + await device.close(); + expect(receivedEvents).toEqual(['device', 'browserServer']); +}); + +test('android.launchServer should be able to reconnect to a device', async ({ playwright }) => { const browserServer = await playwright._android.launchServer(); try { { @@ -94,10 +116,12 @@ test('android.launchServer should terminate WS connection when device gets disco forwardingServer.on('connection', connection => { receivedConnection = connection; const actualConnection = new ws.WebSocket(browserServer.wsEndpoint()); - actualConnection.on('message', message => connection.send(message)); - connection.on('message', message => actualConnection.send(message)); - connection.on('close', () => actualConnection.close()); - actualConnection.on('close', () => connection.close()); + actualConnection.on('open', () => { + actualConnection.on('message', message => connection.send(message)); + connection.on('message', message => actualConnection.send(message)); + connection.on('close', () => actualConnection.close()); + actualConnection.on('close', () => connection.close()); + }); }); try { const device = await playwright._android.connect(`ws://localhost:${(forwardingServer.address() as ws.AddressInfo).port}/connect`);