chore: expose adb devices and actions (#4647)

This commit is contained in:
Pavel Feldman 2020-12-09 15:06:57 -08:00 committed by GitHub
parent ab44d682ca
commit aacd8e633c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 3089 additions and 500 deletions

128
android-types-internal.d.ts vendored Normal file
View file

@ -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<BrowserContextOptions, BrowserContext> {
input: AndroidInput;
serial(): string;
model(): string;
shell(command: string): Promise<string>;
launchBrowser(options?: BrowserContextOptions & { packageName?: string }): Promise<BrowserContext>;
close(): Promise<void>;
wait(selector: AndroidSelector, options?: { state?: 'gone' } & { timeout?: number }): Promise<void>;
fill(selector: AndroidSelector, text: string, options?: { timeout?: number }): Promise<void>;
tap(selector: AndroidSelector, options?: { duration?: number } & { timeout?: number }): Promise<void>;
drag(selector: AndroidSelector, dest: { x: number, y: number }, options?: { speed?: number } & { timeout?: number }): Promise<void>;
fling(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', options?: { speed?: number } & { timeout?: number }): Promise<void>;
longTap(selector: AndroidSelector, options?: { timeout?: number }): Promise<void>;
pinchClose(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
pinchOpen(selector: AndroidSelector, percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
scroll(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
swipe(selector: AndroidSelector, direction: 'down' | 'up' | 'left' | 'right', percent: number, options?: { speed?: number } & { timeout?: number }): Promise<void>;
info(selector: AndroidSelector): Promise<AndroidElementInfo>;
}
export interface AndroidInput {
type(text: string): Promise<void>;
press(key: AndroidKey): Promise<void>;
tap(point: { x: number, y: number }): Promise<void>;
swipe(from: { x: number, y: number }, segments: { x: number, y: number }[], steps: number): Promise<void>;
drag(from: { x: number, y: number }, to: { x: number, y: number }, steps: number): Promise<void>;
}
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';

21
android-types.d.ts vendored Normal file
View file

@ -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<BrowserContext, BrowserContextOptions>;

Binary file not shown.

BIN
bin/android-driver.apk Normal file

Binary file not shown.

View file

@ -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);

View file

@ -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

View file

@ -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();
})();
```

20
packages/playwright-android/index.d.ts vendored Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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. */

View file

@ -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;

237
src/client/android.ts Normal file
View file

@ -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<channels.AndroidChannel, channels.AndroidInitializer> {
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<AndroidDevice[]> {
return this._wrapApiCall('android.devices', async () => {
const { devices } = await this._channel.devices();
return devices.map(d => AndroidDevice.from(d));
});
}
}
export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, channels.AndroidDeviceInitializer> {
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<apiInternal.AndroidElementInfo> {
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<string> {
return this._wrapApiCall('androidDevice.shell', async () => {
const { result } = await this._channel.shell({ command });
return result;
});
}
async launchBrowser(options: types.BrowserContextOptions & { packageName?: string } = {}): Promise<BrowserContext> {
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,
};
}

View file

@ -82,7 +82,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
};
}
protected async _wrapApiCall<T>(apiName: string, func: () => Promise<T>, logger?: Logger): Promise<T> {
async _wrapApiCall<T>(apiName: string, func: () => Promise<T>, logger?: Logger): Promise<T> {
logger = logger || this._logger;
try {
logApiCall(logger, `=> ${apiName} started`);

View file

@ -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<channels.Channel, {}> {
constructor(connection: Connection) {
@ -147,6 +148,12 @@ export class Connection {
let result: ChannelOwner<any, any>;
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;

View file

@ -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<channels.PlaywrightChannel, channels.PlaywrightInitializer> {
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<channels.PlaywrightChannel, channel
this.chromium = BrowserType.from(initializer.chromium);
this.firefox = BrowserType.from(initializer.firefox);
this.webkit = BrowserType.from(initializer.webkit);
this._clank = BrowserType.from(initializer.clank);
if (initializer.electron)
(this as any).electron = Electron.from(initializer.electron);
this._android = Android.from(initializer.android);
this._electron = Electron.from(initializer.electron);
this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;

View file

@ -30,6 +30,7 @@ export type Headers = { [key: string]: string };
export type Env = { [key: string]: string | number | boolean | undefined };
export type URLMatch = string | RegExp | ((url: URL) => boolean);
export type TimeoutOptions = { timeout?: number };
export type WaitForEventOptions = Function | { predicate?: Function, timeout?: number };
export type WaitForFunctionOptions = { timeout?: number, polling?: 'raf' | number };

View file

@ -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<Android, channels.AndroidInitializer> implements channels.AndroidChannel {
constructor(scope: DispatcherScope, electron: Android) {
super(scope, electron, 'Android', {}, true);
}
async devices(params: channels.AndroidDevicesParams): Promise<channels.AndroidDevicesResult> {
const devices = await this._object.devices();
return {
devices: devices.map(d => new AndroidDeviceDispatcher(this._scope, d))
};
}
}
export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.AndroidDeviceInitializer> 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<channels.AndroidDeviceInfoResult> {
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<channels.AndroidDeviceLaunchBrowserResult> {
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<string, number>([
['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],
]);

View file

@ -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<Playwright, channels.PlaywrightInitializer> 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');

View file

@ -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);
}

View file

@ -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<AndroidDevicesResult>;
}
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<AndroidDeviceWaitResult>;
fill(params: AndroidDeviceFillParams, metadata?: Metadata): Promise<AndroidDeviceFillResult>;
tap(params: AndroidDeviceTapParams, metadata?: Metadata): Promise<AndroidDeviceTapResult>;
drag(params: AndroidDeviceDragParams, metadata?: Metadata): Promise<AndroidDeviceDragResult>;
fling(params: AndroidDeviceFlingParams, metadata?: Metadata): Promise<AndroidDeviceFlingResult>;
longTap(params: AndroidDeviceLongTapParams, metadata?: Metadata): Promise<AndroidDeviceLongTapResult>;
pinchClose(params: AndroidDevicePinchCloseParams, metadata?: Metadata): Promise<AndroidDevicePinchCloseResult>;
pinchOpen(params: AndroidDevicePinchOpenParams, metadata?: Metadata): Promise<AndroidDevicePinchOpenResult>;
scroll(params: AndroidDeviceScrollParams, metadata?: Metadata): Promise<AndroidDeviceScrollResult>;
swipe(params: AndroidDeviceSwipeParams, metadata?: Metadata): Promise<AndroidDeviceSwipeResult>;
info(params: AndroidDeviceInfoParams, metadata?: Metadata): Promise<AndroidDeviceInfoResult>;
inputType(params: AndroidDeviceInputTypeParams, metadata?: Metadata): Promise<AndroidDeviceInputTypeResult>;
inputPress(params: AndroidDeviceInputPressParams, metadata?: Metadata): Promise<AndroidDeviceInputPressResult>;
inputTap(params: AndroidDeviceInputTapParams, metadata?: Metadata): Promise<AndroidDeviceInputTapResult>;
inputSwipe(params: AndroidDeviceInputSwipeParams, metadata?: Metadata): Promise<AndroidDeviceInputSwipeResult>;
inputDrag(params: AndroidDeviceInputDragParams, metadata?: Metadata): Promise<AndroidDeviceInputDragResult>;
launchBrowser(params: AndroidDeviceLaunchBrowserParams, metadata?: Metadata): Promise<AndroidDeviceLaunchBrowserResult>;
shell(params: AndroidDeviceShellParams, metadata?: Metadata): Promise<AndroidDeviceShellResult>;
close(params?: AndroidDeviceCloseParams, metadata?: Metadata): Promise<AndroidDeviceCloseResult>;
}
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;

View file

@ -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:

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);
});
}

View file

@ -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<DeviceBackend[]>;
}
export interface DeviceBackend {
serial: string;
close(): Promise<void>;
init(): Promise<void>;
runCommand(command: string): Promise<string>;
open(command: string): Promise<SocketBackend>;
}
export interface SocketBackend extends EventEmitter {
write(data: Buffer): Promise<void>;
close(): Promise<void>;
}
export class Android {
private _backend: Backend;
constructor(backend: Backend) {
this._backend = backend;
}
async devices(): Promise<AndroidDevice[]> {
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<Transport> | undefined;
private _lastId = 0;
private _callbacks = new Map<number, { fulfill: (result: any) => 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<AndroidDevice> {
await backend.init();
const model = await backend.runCommand('shell:getprop ro.product.model');
return new AndroidDevice(backend, model);
}
async shell(command: string): Promise<string> {
return await this._backend.runCommand(`shell:${command}`);
}
private async _driver(): Promise<Transport> {
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<any> {
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<BrowserContext> {
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<void> {
}
async close(): Promise<void> {
await this._browser.close();
}
}

15
src/server/android/driver/.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1 @@
Playwright Android Driver

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="1.8" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
</component>
</project>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>

View file

@ -0,0 +1 @@
/build

View file

@ -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'
}

View file

@ -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

View file

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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();
}
}
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.microsoft.playwright.androiddriver">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PlaywrightAndroidDriver" />
</manifest>

View file

@ -0,0 +1,59 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M57.814,12.557C58.249,20.66 58.405,26.384 58.28,29.731C57.996,37.327 56.71,43.174 55.834,46.634C54.204,53.072 51.186,58.388 49.458,61.375C45.641,67.978 40.199,71.434 33.101,71.689C29.413,71.211 26.647,70.515 24.818,69.555C20.577,67.329 16.148,63.147 13.7,59.245C11.686,56.036 9.094,50.998 7.091,44.254C5.708,39.596 3.853,30.848 1.52,18.011C14.2,18.244 23.774,18.044 30.242,17.417C36.711,16.79 45.903,15.171 57.814,12.557ZM33.642,52.26C31.371,52.379 28.856,53.136 26.931,54.127C25.884,54.666 25.008,55.278 24.408,55.885C23.596,56.707 23.267,57.556 23.305,58.293C23.343,59.01 23.66,59.468 24.12,59.775C24.456,60 24.931,60.15 25.56,60.135C26.396,60.115 27.665,59.768 29.114,59.343C30.695,58.879 32.528,58.328 34.182,58.242C35.699,58.162 37.235,58.353 38.551,58.512C39.977,58.684 41.181,58.797 41.948,58.652C42.547,58.539 42.975,58.289 43.267,57.97C43.596,57.609 43.807,57.135 43.773,56.473C43.733,55.718 43.356,54.975 42.585,54.334C42.01,53.856 41.194,53.426 40.208,53.088C38.392,52.466 35.967,52.138 33.642,52.26ZM17.816,31.819C16.348,33.06 14.848,34.71 13.723,36.742C13.325,37.461 13.315,38.133 13.58,38.758C13.783,39.236 14.192,39.727 14.909,40.153C15.098,40.266 15.281,40.357 15.457,40.43C15.619,40.377 15.784,40.323 15.951,40.268C20.151,38.884 22.766,37.972 23.8,37.537C24.441,37.267 24.847,37.139 25.124,37.051C25.882,36.812 26.126,36.652 26.45,36.246C26.978,35.584 27.251,34.872 27.336,34.161C27.429,33.387 27.295,32.609 26.994,31.9C26.668,31.132 26.147,30.447 25.539,29.94C24.865,29.378 24.089,29.041 23.354,28.979C22.637,28.918 20.271,29.744 17.816,31.819ZM38.043,26.999C37.344,27.265 36.718,27.79 36.261,28.49C35.844,29.13 35.571,29.913 35.511,30.728C35.408,32.125 35.903,33.618 37.406,34.687C37.855,35.008 37.783,35.085 39.973,35.348C41.086,35.482 43.852,35.637 48.27,35.809C48.446,35.816 48.619,35.822 48.79,35.829C48.939,35.71 49.089,35.572 49.24,35.412C49.812,34.804 50.07,34.22 50.133,33.704C50.216,33.03 50.021,32.387 49.44,31.806C47.799,30.163 45.902,28.99 44.149,28.202C41.218,26.884 38.715,26.743 38.043,26.999Z"
android:strokeWidth="2.52930486"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:gradientRadius="54.024082"
android:centerX="16.362686"
android:centerY="46.999622"
android:type="radial">
<item android:offset="0" android:color="#FFDE6A6D"/>
<item android:offset="1" android:color="#FFB54444"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:gradientRadius="56.845688"
android:centerX="16.362646"
android:centerY="1.5783212"
android:type="radial">
<item android:offset="0" android:color="#FF913232"/>
<item android:offset="1" android:color="#FFD44545"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M50.308,36.282C62.666,39.148 72.074,40.945 78.533,41.678C84.993,42.411 94.323,42.74 106.521,42.66C105.262,50.683 104.225,56.32 103.407,59.57C101.55,66.946 99.076,72.402 97.5,75.607C94.567,81.569 90.508,86.144 88.197,88.709C83.088,94.378 77.045,96.628 70.047,95.399C66.539,94.164 63.978,92.907 62.388,91.587C58.701,88.525 55.237,83.509 53.653,79.18C52.35,75.619 50.86,70.148 50.303,63.13C49.918,58.283 49.922,49.333 50.308,36.282ZM61.204,76.319C60.878,76.8 60.693,77.577 60.955,78.607C61.236,79.714 62.063,81.206 63.579,82.63C65.384,84.326 68.185,85.959 72.147,86.658C76.065,87.349 79.401,86.616 81.883,85.435C83.902,84.473 85.362,83.205 86.156,82.139C86.837,81.224 87.119,80.401 86.26,78.529C85.963,78.312 85.549,78.146 84.974,78.146C84.033,78.146 82.874,78.173 81.66,78.202C79.139,78.262 76.368,78.335 74.904,78.155C72.971,77.917 65.751,76.194 63.535,75.578C63.104,75.459 62.73,75.309 61.204,76.319ZM84.054,55.732C82.55,56.463 81.57,57.452 81.085,58.428C80.771,59.06 80.649,59.702 80.699,60.311C80.751,60.945 80.988,61.552 81.428,62.092C82.461,63.358 84.902,64.397 89.351,63.692C91.166,63.405 92.422,62.879 93.753,62.208C95.085,61.538 96.628,60.43 98.372,58.87C98.899,58.398 99.383,57.94 99.824,57.496C99.236,57.24 98.621,56.989 97.978,56.745C91.863,54.423 87.239,54.185 84.054,55.732ZM55.216,49.342C55.318,49.945 55.454,50.565 55.624,51.201C56.482,54.408 57.877,56.826 59.757,58.491C60.96,59.557 63.658,61.195 65.467,61.54C68.672,62.151 71.141,61.71 72.202,60.673C72.586,60.298 72.841,59.85 72.959,59.332C73.06,58.889 73.061,58.386 72.915,57.821C72.715,57.044 72.206,56.091 71.255,54.985C70.506,54.112 69.562,53.334 68.417,52.657C66.651,51.614 62.875,50.577 57.049,49.626C56.415,49.523 55.804,49.428 55.216,49.342Z"
android:strokeWidth="2.52930486"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="39.30427"
android:startX="78.2195"
android:endY="92.342415"
android:endX="78.2195"
android:type="linear">
<item android:offset="0" android:color="#FF59DC6F"/>
<item android:offset="1" android:color="#FF48B656"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startY="89.28204"
android:startX="70.226204"
android:endY="66.455"
android:endX="78.2195"
android:type="linear">
<item android:offset="0" android:color="#FF58C361"/>
<item android:offset="1" android:color="#FF519F50"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#333DDC84" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.PlaywrightAndroidDriver" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Playwright Android Driver</string>
</resources>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.PlaywrightAndroidDriver" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -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
}

View file

@ -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

Binary file not shown.

View file

@ -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

172
src/server/android/driver/gradlew vendored Executable file
View file

@ -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" "$@"

84
src/server/android/driver/gradlew.bat vendored Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
include ':app'
rootProject.name = "Playwright Android Driver"

View file

@ -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<DeviceBackend[]>;
}
export interface DeviceBackend {
close(): Promise<void>;
init(): Promise<void>;
runCommand(command: string): Promise<string>;
open(command: string): Promise<SocketBackend>;
}
export interface SocketBackend extends EventEmitter {
write(data: Buffer): Promise<void>;
close(): Promise<void>;
}
export class AndroidClient {
backend: Backend;
constructor(backend: Backend) {
this.backend = backend;
}
async devices(): Promise<AndroidDevice[]> {
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<AndroidBrowser> {
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];
}

View file

@ -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<Browser> {
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<Browser> {
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<void> {
}
async close(): Promise<void> {
await this._browser.close();
await this._device.close();
}
}

View file

@ -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());
}
}

View file

@ -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: [] },
]
});

View file

@ -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 = [];

View file

@ -28,7 +28,7 @@ const fixtures = base.extend<TestState>();
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;
}

View file

@ -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.