diff --git a/android-types-internal.d.ts b/android-types-internal.d.ts new file mode 100644 index 0000000000..8f4ee655fc --- /dev/null +++ b/android-types-internal.d.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type AndroidElementInfo = { + clazz: string; + desc: string; + res: string; + pkg: string; + text: string; + bounds: { x: number, y: number, width: number, height: number }; + checkable: boolean; + checked: boolean; + clickable: boolean; + enabled: boolean; + focusable: boolean; + focused: boolean; + longClickable: boolean; + scrollable: boolean; + selected: boolean; +}; + +export type AndroidSelector = { + checkable?: boolean, + checked?: boolean, + clazz?: string | RegExp, + clickable?: boolean, + depth?: number, + desc?: string | RegExp, + enabled?: boolean, + focusable?: boolean, + focused?: boolean, + hasChild?: { selector: AndroidSelector }, + hasDescendant?: { selector: AndroidSelector, maxDepth?: number }, + longClickable?: boolean, + pkg?: string | RegExp, + res?: string | RegExp, + scrollable?: boolean, + selected?: boolean, + text?: string | RegExp, +}; + +export interface AndroidDevice { + input: AndroidInput; + + serial(): string; + model(): string; + shell(command: string): Promise; + launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise; + close(): Promise; + + wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise; + fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise; + tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise; + drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise; + fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise; + longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise; + pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise; + + info(selector: AndroidSelector): Promise; +} + +export interface AndroidInput { + type(text: string): Promise; + press(key: AndroidKey): Promise; + tap(point: { x: number, y: number }): Promise; + swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise; + drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise; +} + +export type AndroidKey = + 'Unknown' | + 'SoftLeft' | 'SoftRight' | + 'Home' | + 'Back' | + 'Call' | 'EndCall' | + '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | + 'Star' | 'Pound' | '*' | '#' | + 'DialUp' | 'DialDown' | 'DialLeft' | 'DialRight' | 'DialCenter' | + 'VolumeUp' | 'VolumeDown' | + 'Power' | + 'Camera' | + 'Clear' | + 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | + 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | + 'Comma' | ',' | + 'Period' | '.' | + 'AltLeft' | 'AltRight' | + 'ShiftLeft' | 'ShiftRight' | + 'Tab' | '\t' | + 'Space' | ' ' | + 'Sym' | + 'Explorer' | + 'Envelop' | + 'Enter' | '\n' | + 'Del' | + 'Grave' | + 'Minus' | '-' | + 'Equals' | '=' | + 'LeftBracket' | '(' | + 'RightBracket' | ')' | + 'Backslash' | '\\' | + 'Semicolon' | ';' | + 'Apostrophe' | '`' | + 'Slash' | '/' | + 'At' | + 'Num' | + 'HeadsetHook' | + 'Focus' | + 'Plus' | '+' | + 'Menu' | + 'Notification' | + 'Search'; diff --git a/android-types.d.ts b/android-types.d.ts new file mode 100644 index 0000000000..f13b3eb565 --- /dev/null +++ b/android-types.d.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowserContext, BrowserContextOptions } from './types/types'; +import * as apiInternal from './android-types-internal'; + +export * from './android-types-internal'; +export type AndroidDevice = apiInternal.AndroidDevice; diff --git a/bin/android-driver-target.apk b/bin/android-driver-target.apk new file mode 100644 index 0000000000..1e1e0d188f Binary files /dev/null and b/bin/android-driver-target.apk differ diff --git a/bin/android-driver.apk b/bin/android-driver.apk new file mode 100644 index 0000000000..90c9184ea8 Binary files /dev/null and b/bin/android-driver.apk differ diff --git a/index.js b/index.js index 814b04276e..b60cba9d41 100644 --- a/index.js +++ b/index.js @@ -18,10 +18,8 @@ const { setUnderTest } = require('./lib/utils/utils'); setUnderTest(); // Note: we must call setUnderTest before initializing. const { Playwright } = require('./lib/server/playwright'); -const { Electron } = require('./lib/server/electron/electron'); const { setupInProcess } = require('./lib/inprocess'); const path = require('path'); const playwright = new Playwright(__dirname, require(path.join(__dirname, 'browsers.json'))['browsers']); -playwright.electron = new Electron(); module.exports = setupInProcess(playwright); diff --git a/packages/build_package.js b/packages/build_package.js index c45edc77ad..d8bc1fe0c5 100755 --- a/packages/build_package.js +++ b/packages/build_package.js @@ -64,6 +64,12 @@ const PACKAGES = { browsers: [], files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'electron-types.d.ts'], }, + 'playwright-android': { + version: '0.0.2', // Manually manage playwright-android version. + description: 'A high-level API to automate Chrome for Android', + browsers: [], + files: [...PLAYWRIGHT_CORE_FILES, ...FFMPEG_FILES, 'android-types.d.ts', 'bin/android-driver.apk', 'bin/android-driver-target.apk'], + }, }; // 1. Parse CLI arguments diff --git a/packages/playwright-android/README.md b/packages/playwright-android/README.md new file mode 100644 index 0000000000..4d13f93202 --- /dev/null +++ b/packages/playwright-android/README.md @@ -0,0 +1,43 @@ +# playwright-android +This package contains the [Android](https://www.android.com/) flavor of [Playwright](http://github.com/microsoft/playwright). + +## Requirements + +- Android device or AVD Emulator. +- [ADB daemon](https://developer.android.com/studio/command-line/adb) running and authenticated with your device. Typically running `adb devices` is all you need to do. +- [Chrome 87](https://play.google.com/store/apps/details?id=com.android.chrome) or newer installed on the device +- "Enable command line on non-rooted devices" enabled in `chrome://flags`. + +## How to demo + +```js +const { android } = require('playwright-android'); + +(async () => { + const [device] = await android.devices(); + + // Android automation. + console.log(`Model: ${device.model()}`); + console.log(`Serial: ${device.serial()}`); + + await device.tap({ desc: 'Home' }); + console.log(await device.info({ text: 'Chrome' })); + await device.tap({ text: 'Chrome' }); + await device.fill({ res: 'com.android.chrome:id/url_bar' }, 'www.chromium.org'); + await device.input.press('Enter'); + await new Promise(f => setTimeout(f, 1000)); + + await device.tap({ res: 'com.android.chrome:id/tab_switcher_button' }); + await device.tap({ desc: 'More options' }); + await device.tap({ desc: 'Close all tabs' }); + + // Browser automation. + const context = await device.launchBrowser(); + const [page] = context.pages(); + await page.goto('https://webkit.org/'); + console.log(await page.evaluate(() => window.location.href)); + await context.close(); + + await device.close(); +})(); +``` diff --git a/packages/playwright-android/index.d.ts b/packages/playwright-android/index.d.ts new file mode 100644 index 0000000000..7fb6a6c144 --- /dev/null +++ b/packages/playwright-android/index.d.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Android } from './android-types'; +export * from './types/types'; +export * from './android-types'; +export const android: Android; diff --git a/packages/playwright-android/index.js b/packages/playwright-android/index.js new file mode 100644 index 0000000000..1a06625778 --- /dev/null +++ b/packages/playwright-android/index.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { Playwright } = require('./lib/server/playwright'); +const { setupInProcess } = require('./lib/inprocess'); + +const playwrightServer = new Playwright(__dirname, require('./browsers.json')['browsers']); +const playwright = setupInProcess(playwrightServer); +playwright.android = playwright._android; +module.exports = playwright; diff --git a/packages/playwright-android/index.mjs b/packages/playwright-android/index.mjs new file mode 100644 index 0000000000..b750bd3dc4 --- /dev/null +++ b/packages/playwright-android/index.mjs @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import playwright from './index.js'; + +export const android = playwright.android; +export const selectors = playwright.selectors; +export const devices = playwright.devices; +export const errors = playwright.errors; +export default playwright; diff --git a/packages/playwright-android/install.js b/packages/playwright-android/install.js new file mode 100644 index 0000000000..b8037e25b3 --- /dev/null +++ b/packages/playwright-android/install.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* NOTE: playwright-android does not install browsers by design. */ diff --git a/packages/playwright-electron/index.js b/packages/playwright-electron/index.js index 92c8d8fb98..f998e5aa2d 100644 --- a/packages/playwright-electron/index.js +++ b/packages/playwright-electron/index.js @@ -15,9 +15,9 @@ */ const { Playwright } = require('./lib/server/playwright'); -const { Electron } = require('./lib/server/electron/electron'); const { setupInProcess } = require('./lib/inprocess'); -const playwright = new Playwright(__dirname, require('./browsers.json')['browsers']); -playwright.electron = new Electron(); -module.exports = setupInProcess(playwright); +const playwrightServer = new Playwright(__dirname, require('./browsers.json')['browsers']); +const playwright = setupInProcess(playwrightServer); +playwright.electron = playwright._electron; +module.exports = playwright; diff --git a/src/client/android.ts b/src/client/android.ts new file mode 100644 index 0000000000..c3600cf18e --- /dev/null +++ b/src/client/android.ts @@ -0,0 +1,237 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as channels from '../protocol/channels'; +import { BrowserContext, validateBrowserContextOptions } from './browserContext'; +import { ChannelOwner } from './channelOwner'; +import * as apiInternal from '../../android-types-internal'; +import * as types from './types'; + +type Direction = 'down' | 'up' | 'left' | 'right'; +type SpeedOptions = { speed?: number }; + +export class Android extends ChannelOwner { + static from(android: channels.AndroidChannel): Android { + return (android as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidInitializer) { + super(parent, type, guid, initializer); + } + + async devices(): Promise { + return this._wrapApiCall('android.devices', async () => { + const { devices } = await this._channel.devices(); + return devices.map(d => AndroidDevice.from(d)); + }); + } +} + +export class AndroidDevice extends ChannelOwner { + static from(androidDevice: channels.AndroidDeviceChannel): AndroidDevice { + return (androidDevice as any)._object; + } + + input: Input; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.AndroidDeviceInitializer) { + super(parent, type, guid, initializer); + this.input = new Input(this); + } + + serial(): string { + return this._initializer.serial; + } + + model(): string { + return this._initializer.model; + } + + async wait(selector: apiInternal.AndroidSelector, options?: { state?: 'gone' } & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.wait', async () => { + await this._channel.wait({ selector: toSelectorChannel(selector), ...options }); + }); + } + + async fill(selector: apiInternal.AndroidSelector, text: string, options?: types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.fill', async () => { + await this._channel.fill({ selector: toSelectorChannel(selector), text, ...options }); + }); + } + + async tap(selector: apiInternal.AndroidSelector, options?: { duration?: number } & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.tap', async () => { + await this._channel.tap({ selector: toSelectorChannel(selector), ...options }); + }); + } + + async drag(selector: apiInternal.AndroidSelector, dest: types.Point, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.drag', async () => { + await this._channel.drag({ selector: toSelectorChannel(selector), dest, ...options }); + }); + } + + async fling(selector: apiInternal.AndroidSelector, direction: Direction, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.fling', async () => { + await this._channel.fling({ selector: toSelectorChannel(selector), direction, ...options }); + }); + } + + async longTap(selector: apiInternal.AndroidSelector, options?: types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.longTap', async () => { + await this._channel.longTap({ selector: toSelectorChannel(selector), ...options }); + }); + } + + async pinchClose(selector: apiInternal.AndroidSelector, percent: number, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.pinchClose', async () => { + await this._channel.pinchClose({ selector: toSelectorChannel(selector), percent, ...options }); + }); + } + + async pinchOpen(selector: apiInternal.AndroidSelector, percent: number, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.pinchOpen', async () => { + await this._channel.pinchOpen({ selector: toSelectorChannel(selector), percent, ...options }); + }); + } + + async scroll(selector: apiInternal.AndroidSelector, direction: Direction, percent: number, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.scroll', async () => { + await this._channel.scroll({ selector: toSelectorChannel(selector), direction, percent, ...options }); + }); + } + + async swipe(selector: apiInternal.AndroidSelector, direction: Direction, percent: number, options?: SpeedOptions & types.TimeoutOptions) { + await this._wrapApiCall('androidDevice.swipe', async () => { + await this._channel.swipe({ selector: toSelectorChannel(selector), direction, percent, ...options }); + }); + } + + async info(selector: apiInternal.AndroidSelector): Promise { + return await this._wrapApiCall('androidDevice.info', async () => { + return (await this._channel.info({ selector: toSelectorChannel(selector) })).info; + }); + } + + async close() { + return this._wrapApiCall('androidDevice.close', async () => { + await this._channel.close(); + }); + } + + async shell(command: string): Promise { + return this._wrapApiCall('androidDevice.shell', async () => { + const { result } = await this._channel.shell({ command }); + return result; + }); + } + + async launchBrowser(options: types.BrowserContextOptions & { packageName?: string } = {}): Promise { + return this._wrapApiCall('androidDevice.launchBrowser', async () => { + const contextOptions = validateBrowserContextOptions(options); + const { context } = await this._channel.launchBrowser(contextOptions); + return BrowserContext.from(context); + }); + } +} + +class Input implements apiInternal.AndroidInput { + private _device: AndroidDevice; + + constructor(device: AndroidDevice) { + this._device = device; + } + + async type(text: string) { + return this._device._wrapApiCall('androidDevice.inputType', async () => { + await this._device._channel.inputType({ text }); + }); + } + + async press(key: apiInternal.AndroidKey) { + return this._device._wrapApiCall('androidDevice.inputPress', async () => { + await this._device._channel.inputPress({ key }); + }); + } + + async tap(point: types.Point) { + return this._device._wrapApiCall('androidDevice.inputTap', async () => { + await this._device._channel.inputTap({ point }); + }); + } + + async swipe(from: types.Point, segments: types.Point[], steps: number) { + return this._device._wrapApiCall('androidDevice.inputSwipe', async () => { + await this._device._channel.inputSwipe({ segments, steps }); + }); + } + + async drag(from: types.Point, to: types.Point, steps: number) { + return this._device._wrapApiCall('androidDevice.inputDragAndDrop', async () => { + await this._device._channel.inputDrag({ from, to, steps }); + }); + } +} + +function toSelectorChannel(selector: apiInternal.AndroidSelector): channels.AndroidSelector { + const { + checkable, + checked, + clazz, + clickable, + depth, + desc, + enabled, + focusable, + focused, + hasChild, + hasDescendant, + longClickable, + pkg, + res, + scrollable, + selected, + text, + } = selector; + + const toRegex = (value: RegExp | string | undefined): string | undefined => { + if (value === undefined) + return undefined; + if (value instanceof RegExp) + return value.source; + return '^' + value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d') + '$'; + }; + + return { + checkable, + checked, + clazz: toRegex(clazz), + pkg: toRegex(pkg), + desc: toRegex(desc), + res: toRegex(res), + text: toRegex(text), + clickable, + depth, + enabled, + focusable, + focused, + hasChild: hasChild ? { selector: toSelectorChannel(hasChild.selector) } : undefined, + hasDescendant: hasDescendant ? { selector: toSelectorChannel(hasDescendant.selector), maxDepth: hasDescendant.maxDepth} : undefined, + longClickable, + scrollable, + selected, + }; +} diff --git a/src/client/channelOwner.ts b/src/client/channelOwner.ts index 91e59deabf..d776b4a131 100644 --- a/src/client/channelOwner.ts +++ b/src/client/channelOwner.ts @@ -82,7 +82,7 @@ export abstract class ChannelOwner(apiName: string, func: () => Promise, logger?: Logger): Promise { + async _wrapApiCall(apiName: string, func: () => Promise, logger?: Logger): Promise { logger = logger || this._logger; try { logApiCall(logger, `=> ${apiName} started`); diff --git a/src/client/connection.ts b/src/client/connection.ts index 3efc9bf7a7..0aed67b8db 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -40,6 +40,7 @@ import { FirefoxBrowser } from './firefoxBrowser'; import { debugLogger } from '../utils/debugLogger'; import { SelectorsOwner } from './selectors'; import { isUnderTest } from '../utils/utils'; +import { Android, AndroidDevice } from './android'; class Root extends ChannelOwner { constructor(connection: Connection) { @@ -147,6 +148,12 @@ export class Connection { let result: ChannelOwner; initializer = this._replaceGuidsWithChannels(initializer); switch (type) { + case 'Android': + result = new Android(parent, type, guid, initializer); + break; + case 'AndroidDevice': + result = new AndroidDevice(parent, type, guid, initializer); + break; case 'BindingCall': result = new BindingCall(parent, type, guid, initializer); break; diff --git a/src/client/playwright.ts b/src/client/playwright.ts index b99801ce26..2c4f2e3fa4 100644 --- a/src/client/playwright.ts +++ b/src/client/playwright.ts @@ -21,6 +21,7 @@ import { Selectors, SelectorsOwner, sharedSelectors } from './selectors'; import { Electron } from './electron'; import { TimeoutError } from '../utils/errors'; import { Size } from './types'; +import { Android } from './android'; type DeviceDescriptor = { userAgent: string, @@ -33,10 +34,11 @@ type DeviceDescriptor = { type Devices = { [name: string]: DeviceDescriptor }; export class Playwright extends ChannelOwner { + readonly _android: Android; + readonly _electron: Electron; readonly chromium: BrowserType; readonly firefox: BrowserType; readonly webkit: BrowserType; - readonly _clank: BrowserType; readonly devices: Devices; readonly selectors: Selectors; readonly errors: { TimeoutError: typeof TimeoutError }; @@ -46,9 +48,8 @@ export class Playwright extends ChannelOwner boolean); +export type TimeoutOptions = { timeout?: number }; export type WaitForEventOptions = Function | { predicate?: Function, timeout?: number }; export type WaitForFunctionOptions = { timeout?: number, polling?: 'raf' | number }; diff --git a/src/dispatchers/androidDispatcher.ts b/src/dispatchers/androidDispatcher.ts new file mode 100644 index 0000000000..aed6eb819c --- /dev/null +++ b/src/dispatchers/androidDispatcher.ts @@ -0,0 +1,234 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Dispatcher, DispatcherScope } from './dispatcher'; +import { Android, AndroidDevice } from '../server/android/android'; +import * as channels from '../protocol/channels'; +import { BrowserContextDispatcher } from './browserContextDispatcher'; + +export class AndroidDispatcher extends Dispatcher implements channels.AndroidChannel { + constructor(scope: DispatcherScope, electron: Android) { + super(scope, electron, 'Android', {}, true); + } + + async devices(params: channels.AndroidDevicesParams): Promise { + const devices = await this._object.devices(); + return { + devices: devices.map(d => new AndroidDeviceDispatcher(this._scope, d)) + }; + } +} + +export class AndroidDeviceDispatcher extends Dispatcher implements channels.AndroidDeviceChannel { + constructor(scope: DispatcherScope, device: AndroidDevice) { + super(scope, device, 'AndroidDevice', { + model: device.model, + serial: device.serial + }, true); + } + + async wait(params: channels.AndroidDeviceWaitParams) { + await this._object.send('wait', params); + } + + async fill(params: channels.AndroidDeviceFillParams) { + await Promise.all([ + this._object.send('click', { selector: params.selector }), + this._object.send('fill', params) + ]); + } + + async tap(params: channels.AndroidDeviceTapParams) { + await this._object.send('click', params); + } + + async drag(params: channels.AndroidDeviceDragParams) { + await this._object.send('drag', params); + } + + async fling(params: channels.AndroidDeviceFlingParams) { + await this._object.send('fling', params); + } + + async longTap(params: channels.AndroidDeviceLongTapParams) { + await this._object.send('longClick', params); + } + + async pinchClose(params: channels.AndroidDevicePinchCloseParams) { + await this._object.send('pinchClose', params); + } + + async pinchOpen(params: channels.AndroidDevicePinchOpenParams) { + await this._object.send('pinchOpen', params); + } + + async scroll(params: channels.AndroidDeviceScrollParams) { + await this._object.send('scroll', params); + } + + async swipe(params: channels.AndroidDeviceSwipeParams) { + await this._object.send('swipe', params); + } + + async info(params: channels.AndroidDeviceTapParams): Promise { + return { info: await this._object.send('info', params) }; + } + + async inputType(params: channels.AndroidDeviceInputTypeParams) { + const text = params.text; + const keyCodes: number[] = []; + for (let i = 0; i < text.length; ++i) { + const code = keyMap.get(text[i].toUpperCase()); + if (code === undefined) + throw new Error('No mapping for ' + text[i] + ' found'); + keyCodes.push(code); + } + await Promise.all(keyCodes.map(keyCode => this._object.send('inputPress', { keyCode }))); + } + + async inputPress(params: channels.AndroidDeviceInputPressParams) { + if (!keyMap.has(params.key)) + throw new Error('Unknown key: ' + params.key); + await this._object.send('inputPress', { keyCode: keyMap.get(params.key) }); + } + + async inputTap(params: channels.AndroidDeviceInputTapParams) { + await this._object.send('inputClick', params); + } + + async inputSwipe(params: channels.AndroidDeviceInputSwipeParams) { + await this._object.send('inputSwipe', params); + } + + async inputDrag(params: channels.AndroidDeviceInputDragParams) { + await this._object.send('inputDrag', params); + } + + async shell(params: channels.AndroidDeviceShellParams) { + return { result: await this._object.shell(params.command) }; + } + + async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams): Promise { + const context = await this._object.launchBrowser(params.packageName, params); + return { context: new BrowserContextDispatcher(this._scope, context) }; + } + + async close(params: channels.AndroidDeviceCloseParams) { + await this._object.close(); + } +} + +const keyMap = new Map([ + ['Unknown', 0], + ['SoftLeft', 1], + ['SoftRight', 2], + ['Home', 3], + ['Back', 4], + ['Call', 5], + ['EndCall', 6], + ['0', 7], + ['1', 8], + ['2', 9], + ['3', 10], + ['4', 11], + ['5', 12], + ['6', 13], + ['7', 14], + ['8', 15], + ['9', 16], + ['Star', 17], + ['*', 17], + ['Pound', 18], + ['#', 18], + ['DialUp', 19], + ['DialDown', 20], + ['DialLeft', 21], + ['DialRight', 22], + ['DialCenter', 23], + ['VolumeUp', 24], + ['VolumeDown', 25], + ['Power', 26], + ['Camera', 27], + ['Clear', 28], + ['A', 29], + ['B', 30], + ['C', 31], + ['D', 32], + ['E', 33], + ['F', 34], + ['G', 35], + ['H', 36], + ['I', 37], + ['J', 38], + ['K', 39], + ['L', 40], + ['M', 41], + ['N', 42], + ['O', 43], + ['P', 44], + ['Q', 45], + ['R', 46], + ['S', 47], + ['T', 48], + ['U', 49], + ['V', 50], + ['W', 51], + ['X', 52], + ['Y', 53], + ['Z', 54], + ['Comma', 55], + [',', 55], + ['Period', 56], + ['.', 56], + ['AltLeft', 57], + ['AltRight', 58], + ['ShiftLeft', 59], + ['ShiftRight', 60], + ['Tab', 61], + ['\t', 61], + ['Space', 62], + [' ', 62], + ['Sym', 63], + ['Explorer', 64], + ['Envelop', 65], + ['Enter', 66], + ['Del', 67], + ['Grave', 68], + ['Minus', 69], + ['-', 69], + ['Equals', 70], + ['=', 70], + ['LeftBracket', 71], + ['(', 71], + ['RightBracket', 72], + [')', 72], + ['Backslash', 73], + ['\\', 73], + ['Semicolon', 74], + [';', 74], + ['Apostrophe', 75], + ['`', 75], + ['Slash', 76], + ['/', 76], + ['At', 77], + ['Num', 78], + ['HeadsetHook', 79], + ['Focus', 80], + ['Plus', 81], + ['Menu', 82], + ['Notification', 83], + ['Search', 84], +]); diff --git a/src/dispatchers/playwrightDispatcher.ts b/src/dispatchers/playwrightDispatcher.ts index 6ec3b604e4..92fa3716af 100644 --- a/src/dispatchers/playwrightDispatcher.ts +++ b/src/dispatchers/playwrightDispatcher.ts @@ -14,26 +14,25 @@ * limitations under the License. */ -import { Playwright } from '../server/playwright'; import * as channels from '../protocol/channels'; +import { DeviceDescriptors } from '../server/deviceDescriptors'; +import { Playwright } from '../server/playwright'; +import { AndroidDispatcher } from './androidDispatcher'; import { BrowserTypeDispatcher } from './browserTypeDispatcher'; import { Dispatcher, DispatcherScope } from './dispatcher'; -import { Electron } from '../server/electron/electron'; import { ElectronDispatcher } from './electronDispatcher'; -import { DeviceDescriptors } from '../server/deviceDescriptors'; import { SelectorsDispatcher } from './selectorsDispatcher'; export class PlaywrightDispatcher extends Dispatcher implements channels.PlaywrightChannel { constructor(scope: DispatcherScope, playwright: Playwright) { - const electron = (playwright as any).electron as (Electron | undefined); const deviceDescriptors = Object.entries(DeviceDescriptors) .map(([name, descriptor]) => ({ name, descriptor })); super(scope, playwright, 'Playwright', { chromium: new BrowserTypeDispatcher(scope, playwright.chromium), - clank: new BrowserTypeDispatcher(scope, playwright.clank), firefox: new BrowserTypeDispatcher(scope, playwright.firefox), webkit: new BrowserTypeDispatcher(scope, playwright.webkit), - electron: electron ? new ElectronDispatcher(scope, electron) : undefined, + android: new AndroidDispatcher(scope, playwright.android), + electron: new ElectronDispatcher(scope, playwright.electron), deviceDescriptors, selectors: new SelectorsDispatcher(scope, playwright.selectors), }, false, 'Playwright'); diff --git a/src/driver.ts b/src/driver.ts index 5f36fe0d37..69d97edeb0 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -21,7 +21,6 @@ import { DispatcherConnection } from './dispatchers/dispatcher'; import { PlaywrightDispatcher } from './dispatchers/playwrightDispatcher'; import { installBrowsersWithProgressBar } from './install/installer'; import { Transport } from './protocol/transport'; -import { Electron } from './server/electron/electron'; import { Playwright } from './server/playwright'; import { gracefullyCloseAll } from './server/processLauncher'; import { installHarTracer } from './trace/harTracer'; @@ -62,7 +61,6 @@ export function runServer() { dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message)); const playwright = new Playwright(__dirname, require('../browsers.json')['browsers']); - (playwright as any).electron = new Electron(); new PlaywrightDispatcher(dispatcherConnection.rootDispatcher(), playwright); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index a3f5d95acf..f2de8b6f17 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -27,6 +27,18 @@ export type Metadata = { stack?: string, }; +export type Point = { + x: number, + y: number, +}; + +export type Rect = { + x: number, + y: number, + width: number, + height: number, +}; + export type SerializedValue = { n?: number, b?: boolean, @@ -125,10 +137,10 @@ export type SerializedError = { // ----------- Playwright ----------- export type PlaywrightInitializer = { chromium: BrowserTypeChannel, - clank: BrowserTypeChannel, firefox: BrowserTypeChannel, webkit: BrowserTypeChannel, - electron?: ElectronChannel, + android: AndroidChannel, + electron: ElectronChannel, deviceDescriptors: { name: string, descriptor: { @@ -916,12 +928,7 @@ export type PageScreenshotParams = { quality?: number, omitBackground?: boolean, fullPage?: boolean, - clip?: { - width: number, - height: number, - x: number, - y: number, - }, + clip?: Rect, }; export type PageScreenshotOptions = { timeout?: number, @@ -929,12 +936,7 @@ export type PageScreenshotOptions = { quality?: number, omitBackground?: boolean, fullPage?: boolean, - clip?: { - width: number, - height: number, - x: number, - y: number, - }, + clip?: Rect, }; export type PageScreenshotResult = { binary: Binary, @@ -1271,10 +1273,7 @@ export type FrameClickParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', clickCount?: number, @@ -1284,10 +1283,7 @@ export type FrameClickOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', clickCount?: number, @@ -1304,10 +1300,7 @@ export type FrameDblclickParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', timeout?: number, @@ -1316,10 +1309,7 @@ export type FrameDblclickOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', timeout?: number, @@ -1412,19 +1402,13 @@ export type FrameHoverParams = { selector: string, force?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type FrameHoverOptions = { force?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type FrameHoverResult = void; @@ -1533,20 +1517,14 @@ export type FrameTapParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type FrameTapOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type FrameTapResult = void; @@ -1784,12 +1762,7 @@ export type ElementHandleEvalOnSelectorAllResult = { export type ElementHandleBoundingBoxParams = {}; export type ElementHandleBoundingBoxOptions = {}; export type ElementHandleBoundingBoxResult = { - value?: { - width: number, - height: number, - x: number, - y: number, - }, + value?: Rect, }; export type ElementHandleCheckParams = { force?: boolean, @@ -1806,10 +1779,7 @@ export type ElementHandleClickParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', clickCount?: number, @@ -1819,10 +1789,7 @@ export type ElementHandleClickOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', clickCount?: number, @@ -1838,10 +1805,7 @@ export type ElementHandleDblclickParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', timeout?: number, @@ -1850,10 +1814,7 @@ export type ElementHandleDblclickOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, delay?: number, button?: 'left' | 'right' | 'middle', timeout?: number, @@ -1892,19 +1853,13 @@ export type ElementHandleGetAttributeResult = { export type ElementHandleHoverParams = { force?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type ElementHandleHoverOptions = { force?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type ElementHandleHoverResult = void; @@ -2023,20 +1978,14 @@ export type ElementHandleTapParams = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type ElementHandleTapOptions = { force?: boolean, noWaitAfter?: boolean, modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], - position?: { - x: number, - y: number, - }, + position?: Point, timeout?: number, }; export type ElementHandleTapResult = void; @@ -2452,3 +2401,341 @@ export type ElectronApplicationEvaluateExpressionHandleResult = { export type ElectronApplicationCloseParams = {}; export type ElectronApplicationCloseOptions = {}; export type ElectronApplicationCloseResult = void; + +// ----------- Android ----------- +export type AndroidInitializer = {}; +export interface AndroidChannel extends Channel { + devices(params?: AndroidDevicesParams, metadata?: Metadata): Promise; +} +export type AndroidDevicesParams = {}; +export type AndroidDevicesOptions = {}; +export type AndroidDevicesResult = { + devices: AndroidDeviceChannel[], +}; + +export type AndroidSelector = { + checkable?: boolean, + checked?: boolean, + clazz?: string, + clickable?: boolean, + depth?: number, + desc?: string, + enabled?: boolean, + focusable?: boolean, + focused?: boolean, + hasChild?: { + selector: AndroidSelector, + }, + hasDescendant?: { + selector: AndroidSelector, + maxDepth?: number, + }, + longClickable?: boolean, + pkg?: string, + res?: string, + scrollable?: boolean, + selected?: boolean, + text?: string, +}; + +export type AndroidElementInfo = { + clazz: string, + desc: string, + res: string, + pkg: string, + text: string, + bounds: Rect, + checkable: boolean, + checked: boolean, + clickable: boolean, + enabled: boolean, + focusable: boolean, + focused: boolean, + longClickable: boolean, + scrollable: boolean, + selected: boolean, +}; + +// ----------- AndroidDevice ----------- +export type AndroidDeviceInitializer = { + model: string, + serial: string, +}; +export interface AndroidDeviceChannel extends Channel { + wait(params: AndroidDeviceWaitParams, metadata?: Metadata): Promise; + fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise; + tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise; + drag(params: AndroidDeviceDragParams, metadata?: Metadata): Promise; + fling(params: AndroidDeviceFlingParams, metadata?: Metadata): Promise; + longTap(params: AndroidDeviceLongTapParams, metadata?: Metadata): Promise; + pinchClose(params: AndroidDevicePinchCloseParams, metadata?: Metadata): Promise; + pinchOpen(params: AndroidDevicePinchOpenParams, metadata?: Metadata): Promise; + scroll(params: AndroidDeviceScrollParams, metadata?: Metadata): Promise; + swipe(params: AndroidDeviceSwipeParams, metadata?: Metadata): Promise; + info(params: AndroidDeviceInfoParams, metadata?: Metadata): Promise; + inputType(params: AndroidDeviceInputTypeParams, metadata?: Metadata): Promise; + inputPress(params: AndroidDeviceInputPressParams, metadata?: Metadata): Promise; + inputTap(params: AndroidDeviceInputTapParams, metadata?: Metadata): Promise; + inputSwipe(params: AndroidDeviceInputSwipeParams, metadata?: Metadata): Promise; + inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise; + launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise; + shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise; + close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise; +} +export type AndroidDeviceWaitParams = { + selector: AndroidSelector, + state?: 'gone', + timeout?: number, +}; +export type AndroidDeviceWaitOptions = { + state?: 'gone', + timeout?: number, +}; +export type AndroidDeviceWaitResult = void; +export type AndroidDeviceFillParams = { + selector: AndroidSelector, + text: string, + timeout?: number, +}; +export type AndroidDeviceFillOptions = { + timeout?: number, +}; +export type AndroidDeviceFillResult = void; +export type AndroidDeviceTapParams = { + selector: AndroidSelector, + duration?: number, + timeout?: number, +}; +export type AndroidDeviceTapOptions = { + duration?: number, + timeout?: number, +}; +export type AndroidDeviceTapResult = void; +export type AndroidDeviceDragParams = { + selector: AndroidSelector, + dest: Point, + speed?: number, + timeout?: number, +}; +export type AndroidDeviceDragOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDeviceDragResult = void; +export type AndroidDeviceFlingParams = { + selector: AndroidSelector, + direction: 'up' | 'down' | 'left' | 'right', + speed?: number, + timeout?: number, +}; +export type AndroidDeviceFlingOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDeviceFlingResult = void; +export type AndroidDeviceLongTapParams = { + selector: AndroidSelector, + timeout?: number, +}; +export type AndroidDeviceLongTapOptions = { + timeout?: number, +}; +export type AndroidDeviceLongTapResult = void; +export type AndroidDevicePinchCloseParams = { + selector: AndroidSelector, + percent: number, + speed?: number, + timeout?: number, +}; +export type AndroidDevicePinchCloseOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDevicePinchCloseResult = void; +export type AndroidDevicePinchOpenParams = { + selector: AndroidSelector, + percent: number, + speed?: number, + timeout?: number, +}; +export type AndroidDevicePinchOpenOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDevicePinchOpenResult = void; +export type AndroidDeviceScrollParams = { + selector: AndroidSelector, + direction: 'up' | 'down' | 'left' | 'right', + percent: number, + speed?: number, + timeout?: number, +}; +export type AndroidDeviceScrollOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDeviceScrollResult = void; +export type AndroidDeviceSwipeParams = { + selector: AndroidSelector, + direction: 'up' | 'down' | 'left' | 'right', + percent: number, + speed?: number, + timeout?: number, +}; +export type AndroidDeviceSwipeOptions = { + speed?: number, + timeout?: number, +}; +export type AndroidDeviceSwipeResult = void; +export type AndroidDeviceInfoParams = { + selector: AndroidSelector, +}; +export type AndroidDeviceInfoOptions = { + +}; +export type AndroidDeviceInfoResult = { + info: AndroidElementInfo, +}; +export type AndroidDeviceInputTypeParams = { + text: string, +}; +export type AndroidDeviceInputTypeOptions = { + +}; +export type AndroidDeviceInputTypeResult = void; +export type AndroidDeviceInputPressParams = { + key: string, +}; +export type AndroidDeviceInputPressOptions = { + +}; +export type AndroidDeviceInputPressResult = void; +export type AndroidDeviceInputTapParams = { + point: Point, +}; +export type AndroidDeviceInputTapOptions = { + +}; +export type AndroidDeviceInputTapResult = void; +export type AndroidDeviceInputSwipeParams = { + segments: Point[], + steps: number, +}; +export type AndroidDeviceInputSwipeOptions = { + +}; +export type AndroidDeviceInputSwipeResult = void; +export type AndroidDeviceInputDragParams = { + from: Point, + to: Point, + steps: number, +}; +export type AndroidDeviceInputDragOptions = { + +}; +export type AndroidDeviceInputDragResult = void; +export type AndroidDeviceLaunchBrowserParams = { + packageName?: string, + ignoreHTTPSErrors?: boolean, + javaScriptEnabled?: boolean, + bypassCSP?: boolean, + userAgent?: string, + locale?: string, + timezoneId?: string, + geolocation?: { + longitude: number, + latitude: number, + accuracy?: number, + }, + permissions?: string[], + extraHTTPHeaders?: NameValue[], + offline?: boolean, + httpCredentials?: { + username: string, + password: string, + }, + deviceScaleFactor?: number, + isMobile?: boolean, + hasTouch?: boolean, + colorScheme?: 'dark' | 'light' | 'no-preference', + acceptDownloads?: boolean, + _traceResourcesPath?: string, + _tracePath?: string, + recordVideo?: { + dir: string, + size?: { + width: number, + height: number, + }, + }, + recordHar?: { + omitContent?: boolean, + path: string, + }, + proxy?: { + server: string, + bypass?: string, + username?: string, + password?: string, + }, +}; +export type AndroidDeviceLaunchBrowserOptions = { + packageName?: string, + ignoreHTTPSErrors?: boolean, + javaScriptEnabled?: boolean, + bypassCSP?: boolean, + userAgent?: string, + locale?: string, + timezoneId?: string, + geolocation?: { + longitude: number, + latitude: number, + accuracy?: number, + }, + permissions?: string[], + extraHTTPHeaders?: NameValue[], + offline?: boolean, + httpCredentials?: { + username: string, + password: string, + }, + deviceScaleFactor?: number, + isMobile?: boolean, + hasTouch?: boolean, + colorScheme?: 'dark' | 'light' | 'no-preference', + acceptDownloads?: boolean, + _traceResourcesPath?: string, + _tracePath?: string, + recordVideo?: { + dir: string, + size?: { + width: number, + height: number, + }, + }, + recordHar?: { + omitContent?: boolean, + path: string, + }, + proxy?: { + server: string, + bypass?: string, + username?: string, + password?: string, + }, +}; +export type AndroidDeviceLaunchBrowserResult = { + context: BrowserContextChannel, +}; +export type AndroidDeviceShellParams = { + command: string, +}; +export type AndroidDeviceShellOptions = { + +}; +export type AndroidDeviceShellResult = { + result: string, +}; +export type AndroidDeviceCloseParams = {}; +export type AndroidDeviceCloseOptions = {}; +export type AndroidDeviceCloseResult = void; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 9170cd0803..67e07755e3 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -20,6 +20,22 @@ Metadata: stack: string? +Point: + type: object + properties: + x: number + y: number + + +Rect: + type: object + properties: + x: number + y: number + width: number + height: number + + SerializedValue: type: object # Exactly one of the properties must be present. @@ -183,10 +199,10 @@ Playwright: initializer: chromium: BrowserType - clank: BrowserType firefox: BrowserType webkit: BrowserType - electron: Electron? + android: Android + electron: Electron deviceDescriptors: type: array items: @@ -727,13 +743,7 @@ Page: quality: number? omitBackground: boolean? fullPage: boolean? - clip: - type: object? - properties: - width: number - height: number - x: number - y: number + clip: Rect? returns: binary: binary @@ -1062,11 +1072,7 @@ Frame: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? delay: number? button: type: enum? @@ -1095,11 +1101,7 @@ Frame: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? delay: number? button: type: enum? @@ -1193,11 +1195,7 @@ Frame: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? timeout: number? innerHTML: @@ -1296,11 +1294,7 @@ Frame: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? timeout: number? textContent: @@ -1501,13 +1495,7 @@ ElementHandle: boundingBox: returns: - value: - type: object? - properties: - width: number - height: number - x: number - y: number + value: Rect? check: parameters: @@ -1528,11 +1516,7 @@ ElementHandle: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? delay: number? button: type: enum? @@ -1560,11 +1544,7 @@ ElementHandle: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? delay: number? button: type: enum? @@ -1605,11 +1585,7 @@ ElementHandle: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? timeout: number? innerHTML: @@ -1712,11 +1688,7 @@ ElementHandle: - Control - Meta - Shift - position: - type: object? - properties: - x: number - y: number + position: Point? timeout: number? textContent: @@ -2095,3 +2067,263 @@ ElectronApplication: browserWindow: JSHandle + +Android: + type: interface + + commands: + + devices: + returns: + devices: + type: array + items: AndroidDevice + + +AndroidSelector: + type: object + properties: + checkable: boolean? + checked: boolean? + clazz: string? + clickable: boolean? + depth: number? + desc: string? + enabled: boolean? + focusable: boolean? + focused: boolean? + hasChild: + type: object? + properties: + selector: AndroidSelector + hasDescendant: + type: object? + properties: + selector: AndroidSelector + maxDepth: number? + longClickable: boolean? + pkg: string? + res: string? + scrollable: boolean? + selected: boolean? + text: string? + + +AndroidElementInfo: + type: object + properties: + clazz: string + desc: string + res: string + pkg: string + text: string + bounds: Rect + checkable: boolean + checked: boolean + clickable: boolean + enabled: boolean + focusable: boolean + focused: boolean + longClickable: boolean + scrollable: boolean + selected: boolean + + +AndroidDevice: + type: interface + + initializer: + model: string + serial: string + + commands: + wait: + parameters: + selector: AndroidSelector + state: + type: enum? + literals: + - gone + timeout: number? + + fill: + parameters: + selector: AndroidSelector + text: string + timeout: number? + + tap: + parameters: + selector: AndroidSelector + duration: number? + timeout: number? + + drag: + parameters: + selector: AndroidSelector + dest: Point + speed: number? + timeout: number? + + fling: + parameters: + selector: AndroidSelector + direction: + type: enum + literals: + - up + - down + - left + - right + speed: number? + timeout: number? + + longTap: + parameters: + selector: AndroidSelector + timeout: number? + + pinchClose: + parameters: + selector: AndroidSelector + percent: number + speed: number? + timeout: number? + + pinchOpen: + parameters: + selector: AndroidSelector + percent: number + speed: number? + timeout: number? + + scroll: + parameters: + selector: AndroidSelector + direction: + type: enum + literals: + - up + - down + - left + - right + percent: number + speed: number? + timeout: number? + + swipe: + parameters: + selector: AndroidSelector + direction: + type: enum + literals: + - up + - down + - left + - right + percent: number + speed: number? + timeout: number? + + info: + parameters: + selector: AndroidSelector + returns: + info: AndroidElementInfo + + inputType: + parameters: + text: string + + inputPress: + parameters: + key: string + + inputTap: + parameters: + point: Point + + inputSwipe: + parameters: + segments: + type: array + items: Point + steps: number + + inputDrag: + parameters: + from: Point + to: Point + steps: number + + launchBrowser: + parameters: + packageName: string? + ignoreHTTPSErrors: boolean? + javaScriptEnabled: boolean? + bypassCSP: boolean? + userAgent: string? + locale: string? + timezoneId: string? + geolocation: + type: object? + properties: + longitude: number + latitude: number + accuracy: number? + permissions: + type: array? + items: string + extraHTTPHeaders: + type: array? + items: NameValue + offline: boolean? + httpCredentials: + type: object? + properties: + username: string + password: string + deviceScaleFactor: number? + isMobile: boolean? + hasTouch: boolean? + colorScheme: + type: enum? + literals: + - dark + - light + - no-preference + acceptDownloads: boolean? + _traceResourcesPath: string? + _tracePath: string? + recordVideo: + type: object? + properties: + dir: string + size: + type: object? + properties: + width: number + height: number + recordHar: + type: object? + properties: + omitContent: boolean? + path: string + proxy: + type: object? + properties: + server: string + bypass: string? + username: string? + password: string? + + returns: + context: BrowserContext + + shell: + parameters: + command: string + returns: + result: string + + close: diff --git a/src/protocol/transport.ts b/src/protocol/transport.ts index fbd2fabcc5..c23329a082 100644 --- a/src/protocol/transport.ts +++ b/src/protocol/transport.ts @@ -16,8 +16,21 @@ import { makeWaitForNextTask } from '../utils/utils'; +export interface WritableStream { + write(data: Buffer): void; +} + +export interface ReadableStream { + on(event: 'data', callback: (b: Buffer) => void): void; + on(event: 'close', callback: () => void): void; +} + +export interface ClosableStream { + close(): void; +} + export class Transport { - private _pipeWrite: NodeJS.WritableStream; + private _pipeWrite: WritableStream; private _data = Buffer.from([]); private _waitForNextTask = makeWaitForNextTask(); private _closed = false; @@ -26,8 +39,13 @@ export class Transport { onmessage?: (message: string) => void; onclose?: () => void; - constructor(pipeWrite: NodeJS.WritableStream, pipeRead: NodeJS.ReadableStream) { + private _endian: 'be' | 'le'; + private _closeableStream: ClosableStream | undefined; + + constructor(pipeWrite: WritableStream, pipeRead: ReadableStream, closeable?: ClosableStream, endian: 'be' | 'le' = 'le') { this._pipeWrite = pipeWrite; + this._endian = endian; + this._closeableStream = closeable; pipeRead.on('data', buffer => this._dispatch(buffer)); pipeRead.on('close', () => this.onclose && this.onclose()); this.onmessage = undefined; @@ -39,13 +57,17 @@ export class Transport { throw new Error('Pipe has been closed'); const data = Buffer.from(message, 'utf-8'); const dataLength = Buffer.alloc(4); - dataLength.writeUInt32LE(data.length, 0); + if (this._endian === 'be') + dataLength.writeUInt32BE(data.length, 0); + else + dataLength.writeUInt32LE(data.length, 0); this._pipeWrite.write(dataLength); this._pipeWrite.write(data); } close() { - throw new Error('unimplemented'); + // Let it throw. + this._closeableStream!.close(); } _dispatch(buffer: Buffer) { @@ -57,7 +79,7 @@ export class Transport { } if (!this._bytesLeft) { - this._bytesLeft = this._data.readUInt32LE(0); + this._bytesLeft = this._endian === 'be' ? this._data.readUInt32BE(0) : this._data.readUInt32LE(0); this._data = this._data.slice(4); } diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index b5907a8ca8..c995a70163 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -36,6 +36,16 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.Metadata = tObject({ stack: tOptional(tString), }); + scheme.Point = tObject({ + x: tNumber, + y: tNumber, + }); + scheme.Rect = tObject({ + x: tNumber, + y: tNumber, + width: tNumber, + height: tNumber, + }); scheme.SerializedValue = tObject({ n: tOptional(tNumber), b: tOptional(tBoolean), @@ -372,12 +382,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { quality: tOptional(tNumber), omitBackground: tOptional(tBoolean), fullPage: tOptional(tBoolean), - clip: tOptional(tObject({ - width: tNumber, - height: tNumber, - x: tNumber, - y: tNumber, - })), + clip: tOptional(tType('Rect')), }); scheme.PageSetExtraHTTPHeadersParams = tObject({ headers: tArray(tType('NameValue')), @@ -497,10 +502,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), delay: tOptional(tNumber), button: tOptional(tEnum(['left', 'right', 'middle'])), clickCount: tOptional(tNumber), @@ -512,10 +514,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), delay: tOptional(tNumber), button: tOptional(tEnum(['left', 'right', 'middle'])), timeout: tOptional(tNumber), @@ -564,10 +563,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { selector: tString, force: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), timeout: tOptional(tNumber), }); scheme.FrameInnerHTMLParams = tObject({ @@ -622,10 +618,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), timeout: tOptional(tNumber), }); scheme.FrameTextContentParams = tObject({ @@ -716,10 +709,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), delay: tOptional(tNumber), button: tOptional(tEnum(['left', 'right', 'middle'])), clickCount: tOptional(tNumber), @@ -730,10 +720,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), delay: tOptional(tNumber), button: tOptional(tEnum(['left', 'right', 'middle'])), timeout: tOptional(tNumber), @@ -754,10 +741,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.ElementHandleHoverParams = tObject({ force: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), timeout: tOptional(tNumber), }); scheme.ElementHandleInnerHTMLParams = tOptional(tObject({})); @@ -810,10 +794,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { force: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean), modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), - position: tOptional(tObject({ - x: tNumber, - y: tNumber, - })), + position: tOptional(tType('Point')), timeout: tOptional(tNumber), }); scheme.ElementHandleTextContentParams = tOptional(tObject({})); @@ -916,6 +897,175 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { arg: tType('SerializedArgument'), }); scheme.ElectronApplicationCloseParams = tOptional(tObject({})); + scheme.AndroidDevicesParams = tOptional(tObject({})); + scheme.AndroidSelector = tObject({ + checkable: tOptional(tBoolean), + checked: tOptional(tBoolean), + clazz: tOptional(tString), + clickable: tOptional(tBoolean), + depth: tOptional(tNumber), + desc: tOptional(tString), + enabled: tOptional(tBoolean), + focusable: tOptional(tBoolean), + focused: tOptional(tBoolean), + hasChild: tOptional(tObject({ + selector: tType('AndroidSelector'), + })), + hasDescendant: tOptional(tObject({ + selector: tType('AndroidSelector'), + maxDepth: tOptional(tNumber), + })), + longClickable: tOptional(tBoolean), + pkg: tOptional(tString), + res: tOptional(tString), + scrollable: tOptional(tBoolean), + selected: tOptional(tBoolean), + text: tOptional(tString), + }); + scheme.AndroidElementInfo = tObject({ + clazz: tString, + desc: tString, + res: tString, + pkg: tString, + text: tString, + bounds: tType('Rect'), + checkable: tBoolean, + checked: tBoolean, + clickable: tBoolean, + enabled: tBoolean, + focusable: tBoolean, + focused: tBoolean, + longClickable: tBoolean, + scrollable: tBoolean, + selected: tBoolean, + }); + scheme.AndroidDeviceWaitParams = tObject({ + selector: tType('AndroidSelector'), + state: tOptional(tEnum(['gone'])), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceFillParams = tObject({ + selector: tType('AndroidSelector'), + text: tString, + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceTapParams = tObject({ + selector: tType('AndroidSelector'), + duration: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceDragParams = tObject({ + selector: tType('AndroidSelector'), + dest: tType('Point'), + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceFlingParams = tObject({ + selector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceLongTapParams = tObject({ + selector: tType('AndroidSelector'), + timeout: tOptional(tNumber), + }); + scheme.AndroidDevicePinchCloseParams = tObject({ + selector: tType('AndroidSelector'), + percent: tNumber, + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDevicePinchOpenParams = tObject({ + selector: tType('AndroidSelector'), + percent: tNumber, + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceScrollParams = tObject({ + selector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + percent: tNumber, + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceSwipeParams = tObject({ + selector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + percent: tNumber, + speed: tOptional(tNumber), + timeout: tOptional(tNumber), + }); + scheme.AndroidDeviceInfoParams = tObject({ + selector: tType('AndroidSelector'), + }); + scheme.AndroidDeviceInputTypeParams = tObject({ + text: tString, + }); + scheme.AndroidDeviceInputPressParams = tObject({ + key: tString, + }); + scheme.AndroidDeviceInputTapParams = tObject({ + point: tType('Point'), + }); + scheme.AndroidDeviceInputSwipeParams = tObject({ + segments: tArray(tType('Point')), + steps: tNumber, + }); + scheme.AndroidDeviceInputDragParams = tObject({ + from: tType('Point'), + to: tType('Point'), + steps: tNumber, + }); + scheme.AndroidDeviceLaunchBrowserParams = tObject({ + packageName: tOptional(tString), + ignoreHTTPSErrors: tOptional(tBoolean), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tNumber, + latitude: tNumber, + accuracy: tOptional(tNumber), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + })), + deviceScaleFactor: tOptional(tNumber), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), + acceptDownloads: tOptional(tBoolean), + _traceResourcesPath: tOptional(tString), + _tracePath: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tNumber, + height: tNumber, + })), + })), + recordHar: tOptional(tObject({ + omitContent: tOptional(tBoolean), + path: tString, + })), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + }); + scheme.AndroidDeviceShellParams = tObject({ + command: tString, + }); + scheme.AndroidDeviceCloseParams = tOptional(tObject({})); return scheme; } diff --git a/src/remote/playwrightServer.ts b/src/remote/playwrightServer.ts index 991b0ce640..66820a9740 100644 --- a/src/remote/playwrightServer.ts +++ b/src/remote/playwrightServer.ts @@ -20,7 +20,6 @@ import * as WebSocket from 'ws'; import { installDebugController } from '../debug/debugController'; import { DispatcherConnection } from '../dispatchers/dispatcher'; import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher'; -import { Electron } from '../server/electron/electron'; import { Playwright } from '../server/playwright'; import { gracefullyCloseAll } from '../server/processLauncher'; import { installTracer } from '../trace/tracer'; @@ -64,7 +63,6 @@ export class PlaywrightServer { }); dispatcherConnection.onmessage = message => ws.send(JSON.stringify(message)); const playwright = new Playwright(__dirname, require('../../browsers.json')['browsers']); - (playwright as any).electron = new Electron(); new PlaywrightDispatcher(dispatcherConnection.rootDispatcher(), playwright); }); } diff --git a/src/server/android/android.ts b/src/server/android/android.ts new file mode 100644 index 0000000000..6218bd5dcb --- /dev/null +++ b/src/server/android/android.ts @@ -0,0 +1,280 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as debug from 'debug'; +import * as types from '../types'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as stream from 'stream'; +import * as util from 'util'; +import * as ws from 'ws'; +import { createGuid, makeWaitForNextTask } from '../../utils/utils'; +import { BrowserOptions, BrowserProcess } from '../browser'; +import { BrowserContext, validateBrowserContextOptions } from '../browserContext'; +import { ProgressController } from '../progress'; +import { CRBrowser } from '../chromium/crBrowser'; +import { helper } from '../helper'; +import { Transport } from '../../protocol/transport'; +import { RecentLogsCollector } from '../../utils/debugLogger'; + +const readFileAsync = util.promisify(fs.readFile); + +export interface Backend { + devices(): Promise; +} + +export interface DeviceBackend { + serial: string; + close(): Promise; + init(): Promise; + runCommand(command: string): Promise; + open(command: string): Promise; +} + +export interface SocketBackend extends EventEmitter { + write(data: Buffer): Promise; + close(): Promise; +} + +export class Android { + private _backend: Backend; + + constructor(backend: Backend) { + this._backend = backend; + } + + async devices(): Promise { + const devices = await this._backend.devices(); + return await Promise.all(devices.map(d => AndroidDevice.create(d))); + } +} + +export class AndroidDevice { + readonly _backend: DeviceBackend; + readonly model: string; + readonly serial: string; + private _driverPromise: Promise | undefined; + private _lastId = 0; + private _callbacks = new Map void, reject: (error: Error) => void }>(); + + constructor(backend: DeviceBackend, model: string) { + this._backend = backend; + this.model = model; + this.serial = backend.serial; + } + + static async create(backend: DeviceBackend): Promise { + await backend.init(); + const model = await backend.runCommand('shell:getprop ro.product.model'); + return new AndroidDevice(backend, model); + } + + async shell(command: string): Promise { + return await this._backend.runCommand(`shell:${command}`); + } + + private async _driver(): Promise { + if (this._driverPromise) + return this._driverPromise; + let callback: any; + this._driverPromise = new Promise(f => callback = f); + + debug('pw:android')('Stopping the old driver'); + await this.shell(`am force-stop com.microsoft.playwright.androiddriver`); + + debug('pw:android')('Uninstalling the old driver'); + await this.shell(`cmd package uninstall com.microsoft.playwright.androiddriver`); + await this.shell(`cmd package uninstall com.microsoft.playwright.androiddriver.test`); + + debug('pw:android')('Installing the new driver'); + for (const file of ['android-driver.apk', 'android-driver-target.apk']) { + const driverFile = await readFileAsync(require.resolve(`../../../bin/${file}`)); + const installSocket = await this._backend.open(`shell:cmd package install -r -t -S ${driverFile.length}`); + debug('pw:android')('Writing driver bytes: ' + driverFile.length); + await installSocket.write(driverFile); + const success = await new Promise(f => installSocket.on('data', f)); + debug('pw:android')('Written driver bytes: ' + success); + } + + debug('pw:android')('Starting the new driver'); + this.shell(`am instrument -w com.microsoft.playwright.androiddriver.test/androidx.test.runner.AndroidJUnitRunner`); + + debug('pw:android')('Polling the socket'); + let socket; + while (!socket) { + try { + socket = await this._backend.open(`localabstract:playwright_android_driver_socket`); + } catch (e) { + await new Promise(f => setTimeout(f, 100)); + } + } + + debug('pw:android')('Connected to driver'); + const transport = new Transport(socket, socket, socket, 'be'); + transport.onmessage = message => { + const response = JSON.parse(message); + const { id, result, error } = response; + const callback = this._callbacks.get(id); + if (!callback) + return; + if (error) + callback.reject(new Error(error)); + else + callback.fulfill(result); + this._callbacks.delete(id); + }; + + callback(transport); + return this._driverPromise; + } + + async send(method: string, params: any): Promise { + const driver = await this._driver(); + const id = ++this._lastId; + const result = new Promise((fulfill, reject) => this._callbacks.set(id, { fulfill, reject })); + driver.send(JSON.stringify({ id, method, params })); + return result; + } + + async close() { + const driver = await this._driver(); + driver.close(); + await this._backend.close(); + } + + async launchBrowser(packageName: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise { + debug('pw:android')('Force-stopping', packageName); + await this._backend.runCommand(`shell:am force-stop ${packageName}`); + + const socketName = createGuid(); + const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`; + debug('pw:android')('Starting', packageName, commandLine); + await this._backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`); + await this._backend.runCommand(`shell:am start -n ${packageName}/com.google.android.apps.chrome.Main about:blank`); + + debug('pw:android')('Polling for socket', socketName); + while (true) { + const net = await this._backend.runCommand(`shell:cat /proc/net/unix | grep ${socketName}$`); + if (net) + break; + await new Promise(f => setTimeout(f, 100)); + } + debug('pw:android')('Got the socket, connecting'); + const androidBrowser = new AndroidBrowser(this, packageName, socketName); + await androidBrowser._open(); + + const browserOptions: BrowserOptions = { + name: 'clank', + slowMo: 0, + persistent: { ...options, noDefaultViewport: true }, + downloadsPath: undefined, + browserProcess: new ClankBrowserProcess(androidBrowser), + proxy: options.proxy, + protocolLogger: helper.debugProtocolLogger(), + browserLogsCollector: new RecentLogsCollector() + }; + validateBrowserContextOptions(options, browserOptions); + + const browser = await CRBrowser.connect(androidBrowser, browserOptions); + const controller = new ProgressController(); + await controller.run(async progress => { + await browser._defaultContext!._loadDefaultContext(progress); + }); + return browser._defaultContext!; + } +} + +class AndroidBrowser extends EventEmitter { + readonly device: AndroidDevice; + readonly socketName: string; + private _socket: SocketBackend | undefined; + private _receiver: stream.Writable; + private _waitForNextTask = makeWaitForNextTask(); + onmessage?: (message: any) => void; + onclose?: () => void; + private _packageName: string; + + constructor(device: AndroidDevice, packageName: string, socketName: string) { + super(); + this._packageName = packageName; + this.device = device; + this.socketName = socketName; + this._receiver = new (ws as any).Receiver() as stream.Writable; + this._receiver.on('message', message => { + this._waitForNextTask(() => { + if (this.onmessage) + this.onmessage(JSON.parse(message)); + }); + }); + } + + async _open() { + this._socket = await this.device._backend.open(`localabstract:${this.socketName}`); + this._socket.on('close', () => { + this._waitForNextTask(() => { + if (this.onclose) + this.onclose(); + }); + }); + await this._socket.write(Buffer.from(`GET /devtools/browser HTTP/1.1\r +Upgrade: WebSocket\r +Connection: Upgrade\r +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r +Sec-WebSocket-Version: 13\r +\r +`)); + // HTTP Upgrade response. + await new Promise(f => this._socket!.once('data', f)); + + // Start sending web frame to receiver. + this._socket.on('data', data => this._receiver._write(data, 'binary', () => {})); + } + + async send(s: any) { + await this._socket!.write(encodeWebFrame(JSON.stringify(s))); + } + + async close() { + await this._socket!.close(); + await this.device._backend.runCommand(`shell:am force-stop ${this._packageName}`); + } +} + +function encodeWebFrame(data: string): Buffer { + return (ws as any).Sender.frame(Buffer.from(data), { + opcode: 1, + mask: true, + fin: true, + readOnly: true + })[0]; +} + +class ClankBrowserProcess implements BrowserProcess { + private _browser: AndroidBrowser; + + constructor(browser: AndroidBrowser) { + this._browser = browser; + } + + onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; + + async kill(): Promise { + } + + async close(): Promise { + await this._browser.close(); + } +} diff --git a/src/server/clank/backendAdb.ts b/src/server/android/backendAdb.ts similarity index 100% rename from src/server/clank/backendAdb.ts rename to src/server/android/backendAdb.ts diff --git a/src/server/android/driver/.gitignore b/src/server/android/driver/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/src/server/android/driver/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/src/server/android/driver/.idea/.gitignore b/src/server/android/driver/.idea/.gitignore new file mode 100644 index 0000000000..26d33521af --- /dev/null +++ b/src/server/android/driver/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/src/server/android/driver/.idea/.name b/src/server/android/driver/.idea/.name new file mode 100644 index 0000000000..d1edd25ea0 --- /dev/null +++ b/src/server/android/driver/.idea/.name @@ -0,0 +1 @@ +Playwright Android Driver \ No newline at end of file diff --git a/src/server/android/driver/.idea/compiler.xml b/src/server/android/driver/.idea/compiler.xml new file mode 100644 index 0000000000..61a9130cd9 --- /dev/null +++ b/src/server/android/driver/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/.idea/encodings.xml b/src/server/android/driver/.idea/encodings.xml new file mode 100644 index 0000000000..15a15b218a --- /dev/null +++ b/src/server/android/driver/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/server/android/driver/.idea/gradle.xml b/src/server/android/driver/.idea/gradle.xml new file mode 100644 index 0000000000..23a89bbb6c --- /dev/null +++ b/src/server/android/driver/.idea/gradle.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/.idea/jarRepositories.xml b/src/server/android/driver/.idea/jarRepositories.xml new file mode 100644 index 0000000000..a5f05cd8c8 --- /dev/null +++ b/src/server/android/driver/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/.idea/misc.xml b/src/server/android/driver/.idea/misc.xml new file mode 100644 index 0000000000..d5d35ec44f --- /dev/null +++ b/src/server/android/driver/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/.idea/vcs.xml b/src/server/android/driver/.idea/vcs.xml new file mode 100644 index 0000000000..423963aabf --- /dev/null +++ b/src/server/android/driver/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/app/.gitignore b/src/server/android/driver/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/src/server/android/driver/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/src/server/android/driver/app/build.gradle b/src/server/android/driver/app/build.gradle new file mode 100644 index 0000000000..48823ef079 --- /dev/null +++ b/src/server/android/driver/app/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'com.android.application' +} + +android { + compileSdkVersion 29 + + defaultConfig { + applicationId "com.microsoft.playwright.androiddriver" + minSdkVersion 21 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' +} diff --git a/src/server/android/driver/app/proguard-rules.pro b/src/server/android/driver/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/src/server/android/driver/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/src/server/android/driver/app/src/androidTest/java/com/microsoft/playwright/androiddriver/InstrumentedTest.java b/src/server/android/driver/app/src/androidTest/java/com/microsoft/playwright/androiddriver/InstrumentedTest.java new file mode 100644 index 0000000000..9483a72610 --- /dev/null +++ b/src/server/android/driver/app/src/androidTest/java/com/microsoft/playwright/androiddriver/InstrumentedTest.java @@ -0,0 +1,367 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright.androiddriver; + +import android.graphics.Point; +import android.graphics.Rect; +import android.net.LocalServerSocket; +import android.net.LocalSocket; +import android.os.Bundle; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.BySelector; +import androidx.test.uiautomator.Direction; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.Until; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +@SdkSuppress(minSdkVersion = 21) +public class InstrumentedTest { + + private static BySelector parseSelector(JSONObject param) throws JSONException{ + JSONObject selector = param.getJSONObject("selector"); + BySelector result = null; + if (selector.has("checkable")) { + boolean value = selector.getBoolean("checkable"); + result = result != null ? result.checkable(value) : By.checkable(value); + } + if (selector.has("checked")) { + boolean value = selector.getBoolean("checked"); + result = result != null ? result.checked(value) : By.checked(value); + } + if (selector.has("clazz")) { + Pattern value = Pattern.compile(selector.getString("clazz")); + result = result != null ? result.clazz(value) : By.clazz(value); + } + if (selector.has("pkg")) { + Pattern value = Pattern.compile(selector.getString("pkg")); + result = result != null ? result.pkg(value) : By.pkg(value); + } + if (selector.has("desc")) { + Pattern value = Pattern.compile(selector.getString("desc")); + result = result != null ? result.desc(value) : By.desc(value); + } + if (selector.has("text")) { + Pattern value = Pattern.compile(selector.getString("text")); + result = result != null ? result.text(value) : By.text(value); + } + if (selector.has("clickable")) { + boolean value = selector.getBoolean("clickable"); + result = result != null ? result.clickable(value) : By.clickable(value); + } + if (selector.has("depth")) { + int value = selector.getInt("depth"); + result = result != null ? result.depth(value) : By.depth(value); + } + if (selector.has("enabled")) { + boolean value = selector.getBoolean("enabled"); + result = result != null ? result.enabled(value) : By.enabled(value); + } + if (selector.has("focusable")) { + boolean value = selector.getBoolean("focusable"); + result = result != null ? result.focusable(value) : By.focusable(value); + } + if (selector.has("focused")) { + boolean value = selector.getBoolean("focused"); + result = result != null ? result.focused(value) : By.focused(value); + } + if (selector.has("hasChild")) { + BySelector value = parseSelector(selector.getJSONObject("hasChild")); + result = result != null ? result.hasChild(value) : By.hasChild(value); + } + if (selector.has("hasDescendant")) { + JSONObject object = selector.getJSONObject("hasDescendant"); + BySelector value = parseSelector(object); + int maxDepth = 10000; + if (selector.has("maxDepth")) + maxDepth = selector.getInt("maxDepth"); + result = result != null ? result.hasDescendant(value, maxDepth) : By.hasDescendant(value, maxDepth); + } + if (selector.has("longClickable")) { + boolean value = selector.getBoolean("longClickable"); + result = result != null ? result.longClickable(value) : By.longClickable(value); + } + if (selector.has("res")) { + Pattern value = Pattern.compile(selector.getString("res")); + result = result != null ? result.res(value) : By.res(value); + } + if (selector.has("scrollable")) { + boolean value = selector.getBoolean("scrollable"); + result = result != null ? result.scrollable(value) : By.scrollable(value); + } + if (selector.has("selected")) { + boolean value = selector.getBoolean("selected"); + result = result != null ? result.selected(value) : By.selected(value); + } + return result; + } + + private static int parseTimeout(JSONObject params) throws JSONException { + if (params.has("timeout")) + return params.getInt("timeout"); + return 30000; + } + + private static Point parsePoint(JSONObject params, String propertyName) throws JSONException { + JSONObject point = params.getJSONObject(propertyName); + return new Point(params.getInt("x"), params.getInt("y")); + } + + private static Direction parseDirection(JSONObject params) throws JSONException { + switch (params.getString("direction")) { + case "up": return Direction.UP; + case "down": return Direction.DOWN; + case "left": return Direction.LEFT; + case "right": return Direction.RIGHT; + } + throw new JSONException("Unsupported direction: " + params.getString("direction")); + } + + private static UiObject2 wait(UiDevice device, JSONObject params) throws JSONException { + return device.wait(Until.findObject(parseSelector(params)), parseTimeout(params)); + } + + private static void fill(UiDevice device, JSONObject params) throws JSONException { + device.wait(Until.findObject(parseSelector(params)), parseTimeout(params)).setText(params.getString("text")); + } + + private static void click(UiDevice device, JSONObject params) throws JSONException { + int duration = params.has("duration") ? params.getInt("duration") : 0; + wait(device, params).click(duration); + } + + private static void drag(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).drag(parsePoint(params, "dest"), speed); + else + wait(device, params).drag(parsePoint(params, "dest")); + } + + private static void fling(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).fling(parseDirection(params), speed); + else + wait(device, params).fling(parseDirection(params)); + } + + private static void longClick(UiDevice device, JSONObject params) throws JSONException { + wait(device, params).longClick(); + } + + private static void pinchClose(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).pinchClose(params.getInt("percent"), speed); + else + wait(device, params).pinchClose(params.getInt("percent")); + } + + private static void pinchOpen(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).pinchOpen(params.getInt("percent"), speed); + else + wait(device, params).pinchOpen(params.getInt("percent")); + } + + private static void scroll(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).scroll(parseDirection(params), params.getInt("percent"), speed); + else + wait(device, params).scroll(parseDirection(params), params.getInt("percent")); + } + + private static void swipe(UiDevice device, JSONObject params) throws JSONException { + int speed = params.has("speed") ? params.getInt("speed") : -1; + if (speed >= 0) + wait(device, params).swipe(parseDirection(params), params.getInt("percent"), speed); + else + wait(device, params).swipe(parseDirection(params), params.getInt("percent")); + } + + private static JSONObject info(UiDevice device, JSONObject params) throws JSONException { + JSONObject info = new JSONObject(); + UiObject2 object = device.findObject(parseSelector(params)); + Rect bounds = object.getVisibleBounds(); + JSONObject boundsObject = new JSONObject(); + boundsObject.put("x", bounds.left); + boundsObject.put("y", bounds.top); + boundsObject.put("width", bounds.width()); + boundsObject.put("height", bounds.height()); + info.put("clazz", object.getClassName()); + info.put("pkg", object.getApplicationPackage()); + info.put("desc", object.getContentDescription()); + info.put("res", object.getResourceName()); + info.put("text", object.getText()); + info.put("bounds", boundsObject); + info.put("checkable", object.isCheckable()); + info.put("checked", object.isChecked()); + info.put("clickable", object.isClickable()); + info.put("enabled", object.isEnabled()); + info.put("focusable", object.isFocusable()); + info.put("focused", object.isFocused()); + info.put("longClickable", object.isLongClickable()); + info.put("scrollable", object.isScrollable()); + info.put("selected", object.isSelected()); + return info; + } + + private static void inputPress(UiDevice device, JSONObject params) throws JSONException { + device.pressKeyCode(params.getInt("keyCode")); + } + + private static void inputClick(UiDevice device, JSONObject params) throws JSONException { + Point point = parsePoint(params, "point"); + device.click(point.x, point.y); + } + + private static void inputSwipe(UiDevice device, JSONObject params) throws JSONException { + JSONArray items = params.getJSONArray("segments"); + Point[] segments = new Point[items.length()]; + for (int i = 0; i < items.length(); ++i) { + JSONObject p = items.getJSONObject(i); + segments[i] = new Point(p.getInt("x"), p.getInt("y")); + } + device.swipe(segments, params.getInt("steps")); + } + + private static void inputDrag(UiDevice device, JSONObject params) throws JSONException { + Point from = parsePoint(params, "from"); + Point to = parsePoint(params, "to"); + device.drag(from.x, from.y, to.x, to.y, params.getInt("steps")); + } + + @Test + public void main() { + UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + + try { + LocalServerSocket serverSocket = new LocalServerSocket("playwright_android_driver_socket"); + LocalSocket socket = serverSocket.accept(); + InputStream is = socket.getInputStream(); + DataInputStream dis = new DataInputStream(is); + DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); + + while (true) { + int id = 0; + String method = null; + JSONObject params = null; + try { + int size = dis.readInt(); + byte[] buffer = new byte[size]; + dis.readFully(buffer); + String s = new String(buffer, StandardCharsets.UTF_8); + JSONObject message = new JSONObject(s); + id = message.getInt("id"); + method = message.getString("method"); + params = message.getJSONObject("params"); + } catch (JSONException e) { + } + if (method == null) + continue; + + JSONObject response = new JSONObject(); + response.put("id", id); + response.put("result", params); + try { + switch (method) { + case "wait": + wait(device, params); + break; + case "fill": + fill(device, params); + break; + case "click": + click(device, params); + break; + case "drag": + drag(device, params); + break; + case "fling": + fling(device, params); + break; + case "longClick": + longClick(device, params); + break; + case "pinchClose": + pinchClose(device, params); + break; + case "pinchOpen": + pinchOpen(device, params); + break; + case "scroll": + scroll(device, params); + break; + case "swipe": + swipe(device, params); + break; + case "info": + response.put("result", info(device, params)); + break; + case "inputPress": + inputPress(device, params); + break; + case "inputClick": + inputClick(device, params); + break; + case "inputSwipe": + inputSwipe(device, params); + break; + case "inputDrag": + inputDrag(device, params); + break; + default: + + } + } catch (RuntimeException e) { + response.put("error", e.toString()); + } + byte[] responseBytes = response.toString().getBytes(StandardCharsets.UTF_8); + dos.writeInt(responseBytes.length); + dos.write(responseBytes); + dos.flush(); + } + } catch (JSONException | IOException e) { + e.printStackTrace(); + } + } +} diff --git a/src/server/android/driver/app/src/main/AndroidManifest.xml b/src/server/android/driver/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c9df74d240 --- /dev/null +++ b/src/server/android/driver/app/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/src/server/android/driver/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/server/android/driver/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..7ae2abf094 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/server/android/driver/app/src/main/res/drawable/ic_launcher_background.xml b/src/server/android/driver/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..c6fd8cadd6 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..6b78462d61 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..eca70cfe52 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/server/android/driver/app/src/main/res/values-night/themes.xml b/src/server/android/driver/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..335c89b667 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/src/server/android/driver/app/src/main/res/values/colors.xml b/src/server/android/driver/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src/server/android/driver/app/src/main/res/values/strings.xml b/src/server/android/driver/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..4feaf3acd9 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Playwright Android Driver + \ No newline at end of file diff --git a/src/server/android/driver/app/src/main/res/values/themes.xml b/src/server/android/driver/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..a6f83506b1 --- /dev/null +++ b/src/server/android/driver/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/src/server/android/driver/build.gradle b/src/server/android/driver/build.gradle new file mode 100644 index 0000000000..c8d7712154 --- /dev/null +++ b/src/server/android/driver/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.0" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/src/server/android/driver/gradle.properties b/src/server/android/driver/gradle.properties new file mode 100644 index 0000000000..52f5917cb0 --- /dev/null +++ b/src/server/android/driver/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true \ No newline at end of file diff --git a/src/server/android/driver/gradle/wrapper/gradle-wrapper.jar b/src/server/android/driver/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..f6b961fd5a Binary files /dev/null and b/src/server/android/driver/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/server/android/driver/gradle/wrapper/gradle-wrapper.properties b/src/server/android/driver/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..3ea48480c4 --- /dev/null +++ b/src/server/android/driver/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Dec 08 09:38:33 PST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip diff --git a/src/server/android/driver/gradlew b/src/server/android/driver/gradlew new file mode 100755 index 0000000000..cccdd3d517 --- /dev/null +++ b/src/server/android/driver/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/src/server/android/driver/gradlew.bat b/src/server/android/driver/gradlew.bat new file mode 100644 index 0000000000..e95643d6a2 --- /dev/null +++ b/src/server/android/driver/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/server/android/driver/settings.gradle b/src/server/android/driver/settings.gradle new file mode 100644 index 0000000000..fb9b74ffc3 --- /dev/null +++ b/src/server/android/driver/settings.gradle @@ -0,0 +1,2 @@ +include ':app' +rootProject.name = "Playwright Android Driver" \ No newline at end of file diff --git a/src/server/clank/android.ts b/src/server/clank/android.ts deleted file mode 100644 index a8cadc6d7d..0000000000 --- a/src/server/clank/android.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Copyright Microsoft Corporation. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as debug from 'debug'; -import { EventEmitter } from 'events'; -import * as stream from 'stream'; -import * as ws from 'ws'; -import { createGuid, makeWaitForNextTask } from '../../utils/utils'; - -export interface Backend { - devices(): Promise; -} - -export interface DeviceBackend { - close(): Promise; - init(): Promise; - runCommand(command: string): Promise; - open(command: string): Promise; -} - -export interface SocketBackend extends EventEmitter { - write(data: Buffer): Promise; - close(): Promise; -} - -export class AndroidClient { - backend: Backend; - - constructor(backend: Backend) { - this.backend = backend; - } - - async devices(): Promise { - const devices = await this.backend.devices(); - return devices.map(b => new AndroidDevice(b)); - } -} - -export class AndroidDevice { - readonly backend: DeviceBackend; - private _model: string | undefined; - - constructor(backend: DeviceBackend) { - this.backend = backend; - } - - async init() { - await this.backend.init(); - this._model = await this.backend.runCommand('shell:getprop ro.product.model'); - } - - async close() { - await this.backend.close(); - } - - async launchBrowser(packageName: string): Promise { - debug('pw:android')('Force-stopping', packageName); - await this.backend.runCommand(`shell:am force-stop ${packageName}`); - - const socketName = createGuid(); - const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`; - debug('pw:android')('Starting', packageName, commandLine); - await this.backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`); - await this.backend.runCommand(`shell:am start -n ${packageName}/com.google.android.apps.chrome.Main about:blank`); - - debug('pw:android')('Polling for socket', socketName); - while (true) { - const net = await this.backend.runCommand(`shell:cat /proc/net/unix | grep ${socketName}$`); - if (net) - break; - await new Promise(f => setTimeout(f, 100)); - } - debug('pw:android')('Got the socket, connecting'); - const browser = new AndroidBrowser(this, packageName, socketName); - await browser._open(); - return browser; - } - - model(): string | undefined { - return this._model; - } -} - -export class AndroidBrowser extends EventEmitter { - readonly device: AndroidDevice; - readonly socketName: string; - private _socket: SocketBackend | undefined; - private _receiver: stream.Writable; - private _waitForNextTask = makeWaitForNextTask(); - onmessage?: (message: any) => void; - onclose?: () => void; - private _packageName: string; - - constructor(device: AndroidDevice, packageName: string, socketName: string) { - super(); - this._packageName = packageName; - this.device = device; - this.socketName = socketName; - this._receiver = new (ws as any).Receiver() as stream.Writable; - this._receiver.on('message', message => { - this._waitForNextTask(() => { - if (this.onmessage) - this.onmessage(JSON.parse(message)); - }); - }); - } - - async _open() { - this._socket = await this.device.backend.open(`localabstract:${this.socketName}`); - this._socket.on('close', () => { - this._waitForNextTask(() => { - if (this.onclose) - this.onclose(); - }); - }); - await this._socket.write(Buffer.from(`GET /devtools/browser HTTP/1.1\r -Upgrade: WebSocket\r -Connection: Upgrade\r -Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r -Sec-WebSocket-Version: 13\r -\r -`)); - // HTTP Upgrade response. - await new Promise(f => this._socket!.once('data', f)); - - // Start sending web frame to receiver. - this._socket.on('data', data => this._receiver._write(data, 'binary', () => {})); - } - - async send(s: any) { - await this._socket!.write(encodeWebFrame(JSON.stringify(s))); - } - - async close() { - await this._socket!.close(); - await this.device.backend.runCommand(`shell:am force-stop ${this._packageName}`); - } -} - -function encodeWebFrame(data: string): Buffer { - return (ws as any).Sender.frame(Buffer.from(data), { - opcode: 1, - mask: true, - fin: true, - readOnly: true - })[0]; -} diff --git a/src/server/clank/clank.ts b/src/server/clank/clank.ts deleted file mode 100644 index 5d81c75490..0000000000 --- a/src/server/clank/clank.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright 2017 Google Inc. All rights reserved. - * Modifications copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { BrowserType } from '../browserType'; -import { Browser, BrowserOptions, BrowserProcess } from '../browser'; -import * as types from '../types'; -import { normalizeProxySettings, validateBrowserContextOptions } from '../browserContext'; -import { Progress } from '../progress'; -import { ConnectionTransport } from '../transport'; -import { Env } from '../processLauncher'; -import { CRBrowser } from '../chromium/crBrowser'; -import { AndroidBrowser, AndroidClient, AndroidDevice } from './android'; -import { AdbBackend } from './backendAdb'; -import { RecentLogsCollector } from '../../utils/debugLogger'; - -export class Clank extends BrowserType { - async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise { - options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; - if ((options as any).__testHookBeforeCreateBrowser) - await (options as any).__testHookBeforeCreateBrowser(); - - // const client = new AndroidClient(new UsbBackend()); - const client = new AndroidClient(new AdbBackend()); - const device = (await client.devices())[0]; - await device.init(); - const adbBrowser = await device.launchBrowser(options.executablePath || 'com.android.chrome'); // com.chrome.canary - const transport = adbBrowser; - - const browserOptions: BrowserOptions = { - name: 'clank', - slowMo: options.slowMo, - persistent, - headful: !options.headless, - downloadsPath: undefined, - browserProcess: new ClankBrowserProcess(device, adbBrowser), - proxy: options.proxy, - protocolLogger, - browserLogsCollector: new RecentLogsCollector(), - }; - if (persistent) - validateBrowserContextOptions(persistent, browserOptions); - - const browser = await this._connectToTransport(transport, browserOptions); - // We assume no control when using custom arguments, and do not prepare the default context in that case. - if (persistent && !options.ignoreAllDefaultArgs) - await browser._defaultContext!._loadDefaultContext(progress); - return browser; - } - - _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { - return []; - } - - _connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise { - return CRBrowser.connect(transport, options); - } - - _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env { - return env; - } - - _rewriteStartupError(error: Error): Error { - return error; - } - - _attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void { - } -} - -class ClankBrowserProcess implements BrowserProcess { - private _device: AndroidDevice; - private _browser: AndroidBrowser; - - constructor(device: AndroidDevice, browser: AndroidBrowser) { - this._device = device; - this._browser = browser; - } - - onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; - - async kill(): Promise { - } - - async close(): Promise { - await this._browser.close(); - await this._device.close(); - } -} diff --git a/src/server/playwright.ts b/src/server/playwright.ts index cc35478c7c..4d5979d1da 100644 --- a/src/server/playwright.ts +++ b/src/server/playwright.ts @@ -14,17 +14,20 @@ * limitations under the License. */ -import { Chromium } from './chromium/chromium'; -import { Clank } from './clank/clank'; -import { WebKit } from './webkit/webkit'; -import { Firefox } from './firefox/firefox'; import * as browserPaths from '../utils/browserPaths'; +import { Android } from './android/android'; +import { AdbBackend } from './android/backendAdb'; +import { Chromium } from './chromium/chromium'; +import { Electron } from './electron/electron'; +import { Firefox } from './firefox/firefox'; import { serverSelectors } from './selectors'; +import { WebKit } from './webkit/webkit'; export class Playwright { readonly selectors = serverSelectors; readonly chromium: Chromium; - readonly clank: Clank; + readonly android: Android; + readonly electron: Electron; readonly firefox: Firefox; readonly webkit: WebKit; @@ -38,10 +41,7 @@ export class Playwright { const webkit = browsers.find(browser => browser.name === 'webkit'); this.webkit = new WebKit(packagePath, webkit!); - this.clank = new Clank(packagePath, { - name: 'clank', - revision: '0', - download: false - }); + this.electron = new Electron(); + this.android = new Android(new AdbBackend()); } } diff --git a/test/channels.spec.ts b/test/channels.spec.ts index 7d0d043f2c..3f457d47c2 100644 --- a/test/channels.spec.ts +++ b/test/channels.spec.ts @@ -39,15 +39,15 @@ it('should scope context handles', async ({browser, server}) => { const GOLDEN_PRECONDITION = { _guid: '', objects: [ + { _guid: 'Android', objects: [] }, + { _guid: 'BrowserType', objects: [] }, + { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ { _guid: 'Browser', objects: [] } ] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }; await expectScopeState(browser, GOLDEN_PRECONDITION); @@ -58,7 +58,7 @@ it('should scope context handles', async ({browser, server}) => { await expectScopeState(browser, { _guid: '', objects: [ - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Android', objects: [] }, { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ @@ -72,9 +72,9 @@ it('should scope context handles', async ({browser, server}) => { ]}, ] }, ] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }); @@ -88,15 +88,15 @@ it('should scope CDPSession handles', (test, { browserName }) => { const GOLDEN_PRECONDITION = { _guid: '', objects: [ + { _guid: 'Android', objects: [] }, + { _guid: 'BrowserType', objects: [] }, + { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ { _guid: 'Browser', objects: [] } ] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }; await expectScopeState(browserType, GOLDEN_PRECONDITION); @@ -105,17 +105,17 @@ it('should scope CDPSession handles', (test, { browserName }) => { await expectScopeState(browserType, { _guid: '', objects: [ + { _guid: 'Android', objects: [] }, + { _guid: 'BrowserType', objects: [] }, + { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ { _guid: 'Browser', objects: [ { _guid: 'CDPSession', objects: [] }, ] }, ] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }); @@ -127,16 +127,16 @@ it('should scope browser handles', async ({browserType, browserOptions}) => { const GOLDEN_PRECONDITION = { _guid: '', objects: [ - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Android', objects: [] }, { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ { _guid: 'Browser', objects: [] }, ] }, { _guid: 'BrowserType', objects: [] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }; await expectScopeState(browserType, GOLDEN_PRECONDITION); @@ -146,6 +146,9 @@ it('should scope browser handles', async ({browserType, browserOptions}) => { await expectScopeState(browserType, { _guid: '', objects: [ + { _guid: 'Android', objects: [] }, + { _guid: 'BrowserType', objects: [] }, + { _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [ { _guid: 'Browser', objects: [] }, { @@ -155,12 +158,9 @@ it('should scope browser handles', async ({browserType, browserOptions}) => { }, ] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, - { _guid: 'BrowserType', objects: [] }, + { _guid: 'Electron', objects: [] }, { _guid: 'Playwright', objects: [] }, { _guid: 'Selectors', objects: [] }, - { _guid: 'Electron', objects: [] }, ] }); diff --git a/test/electron/electron-app.spec.ts b/test/electron/electron-app.spec.ts index f770116b9d..f8852ab4ce 100644 --- a/test/electron/electron-app.spec.ts +++ b/test/electron/electron-app.spec.ts @@ -25,7 +25,7 @@ describe('electron app', (suite, { browserName }) => { }, () => { it('should fire close event', async ({ playwright }) => { const electronPath = path.join(__dirname, '..', '..', 'node_modules', '.bin', electronName); - const application = await playwright.electron.launch(electronPath, { + const application = await playwright._electron.launch(electronPath, { args: [path.join(__dirname, 'testApp.js')], }); const events = []; diff --git a/test/electron/electron.fixture.ts b/test/electron/electron.fixture.ts index b4b5c49d28..6b23c22ee7 100644 --- a/test/electron/electron.fixture.ts +++ b/test/electron/electron.fixture.ts @@ -28,7 +28,7 @@ const fixtures = base.extend(); fixtures.application.init(async ({ playwright }, run) => { const electronPath = path.join(__dirname, '..', '..', 'node_modules', '.bin', electronName); - const application = await playwright.electron.launch(electronPath, { + const application = await playwright._electron.launch(electronPath, { args: [path.join(__dirname, 'testApp.js')], }); await run(application); @@ -44,5 +44,5 @@ fixtures.window.init(async ({ application }, run) => { export const folio = fixtures.build(); declare module '../../index' { - const electron: ElectronLauncher; + const _electron: ElectronLauncher; } diff --git a/utils/check_deps.js b/utils/check_deps.js index 01599a2bb6..f76cc4eaca 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -56,6 +56,8 @@ async function checkDeps() { } function allowImport(from, to) { + if (!to.startsWith('src' + path.sep)) + return true; from = from.substring(from.indexOf('src' + path.sep)).replace(/\\/g, '/'); to = to.substring(to.indexOf('src' + path.sep)).replace(/\\/g, '/'); const fromDirectory = from.substring(0, from.lastIndexOf('/') + 1); @@ -111,10 +113,11 @@ DEPS['src/server/common/'] = []; DEPS['src/server/injected/'] = ['src/server/common/']; // Electron and Clank use chromium internally. +DEPS['src/server/android/'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/protocol/transport.ts']; DEPS['src/server/electron/'] = [...DEPS['src/server/'], 'src/server/chromium/']; DEPS['src/server/clank/'] = [...DEPS['src/server/'], 'src/server/chromium/']; -DEPS['src/server/playwright.ts'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/server/webkit/', 'src/server/firefox/', 'src/server/clank/']; +DEPS['src/server/playwright.ts'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/server/webkit/', 'src/server/firefox/', 'src/server/android/', 'src/server/electron/']; DEPS['src/driver.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerImpl.ts'] = ['src/**']; // Tracing is a client/server plugin, nothing should depend on it.