chore: add adb-based connectivity (#4375)

This commit is contained in:
Pavel Feldman 2020-11-06 16:31:11 -08:00 committed by GitHub
parent 06c8881dad
commit 28f6547d67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 463 additions and 10 deletions

View file

@ -47,28 +47,26 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? envObjectToArray(options.env) : undefined,
});
return new BrowserServerImpl(this._browserType, browser, options.port);
return new BrowserServerImpl(browser, options.port);
}
}
export class BrowserServerImpl extends EventEmitter implements BrowserServer {
private _server: ws.Server;
private _browserType: BrowserType;
private _browser: Browser;
private _wsEndpoint: string;
private _process: ChildProcess;
constructor(browserType: BrowserType, browser: Browser, port: number = 0) {
constructor(browser: Browser, port: number = 0) {
super();
this._browserType = browserType;
this._browser = browser;
const token = createGuid();
this._server = new ws.Server({ port });
const address = this._server.address();
this._wsEndpoint = typeof address === 'string' ? `${address}/${token}` : `ws://127.0.0.1:${address.port}/${token}`;
this._process = browser._options.browserProcess.process;
this._process = browser._options.browserProcess.process!;
this._server.on('connection', (socket: ws, req) => {
if (req.url !== '/' + token) {

View file

@ -36,6 +36,7 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel, channel
readonly chromium: BrowserType;
readonly firefox: BrowserType;
readonly webkit: BrowserType;
readonly _clank: BrowserType;
readonly devices: Devices;
readonly selectors: Selectors;
readonly errors: { TimeoutError: typeof TimeoutError };
@ -45,6 +46,7 @@ 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.devices = {};

View file

@ -30,6 +30,7 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
.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,

View file

@ -92,6 +92,7 @@ export type SerializedError = {
// ----------- Playwright -----------
export type PlaywrightInitializer = {
chromium: BrowserTypeChannel,
clank: BrowserTypeChannel,
firefox: BrowserTypeChannel,
webkit: BrowserTypeChannel,
electron?: ElectronChannel,

View file

@ -130,6 +130,7 @@ Playwright:
initializer:
chromium: BrowserType
clank: BrowserType
firefox: BrowserType
webkit: BrowserType
electron: Electron?

View file

@ -24,7 +24,7 @@ import { ChildProcess } from 'child_process';
export interface BrowserProcess {
onclose: ((exitCode: number | null, signal: string | null) => void) | undefined;
process: ChildProcess;
process?: ChildProcess;
kill(): Promise<void>;
close(): Promise<void>;
}

View file

@ -118,6 +118,10 @@ export class CRBrowser extends Browser {
return this._version;
}
isClank(): boolean {
return this._options.name === 'clank';
}
_onAttachedToTarget({targetInfo, sessionId, waitingForDebugger}: Protocol.Target.attachedToTargetPayload) {
if (targetInfo.type === 'browser')
return;

View file

@ -380,7 +380,9 @@ class FrameSession {
}
async _initialize(hasUIWindow: boolean) {
if (hasUIWindow && !this._crPage._browserContext._options.noDefaultViewport) {
if (hasUIWindow &&
!this._crPage._browserContext._browser.isClank() &&
!this._crPage._browserContext._options.noDefaultViewport) {
const { windowId } = await this._client.send('Browser.getWindowForTarget');
this._windowId = windowId;
}
@ -825,6 +827,8 @@ class FrameSession {
}
async _updateViewport(): Promise<void> {
if (this._crPage._browserContext._browser.isClank())
return;
assert(this._isMainFrame());
const options = this._crPage._browserContext._options;
const viewportSize = this._page._state.viewportSize;
@ -863,6 +867,8 @@ class FrameSession {
}
async _updateEmulateMedia(initial: boolean): Promise<void> {
if (this._crPage._browserContext._browser.isClank())
return;
const colorScheme = this._page._state.colorScheme || this._crPage._browserContext._options.colorScheme || 'light';
const features = colorScheme ? [{ name: 'prefers-color-scheme', value: colorScheme }] : [];
await this._client.send('Emulation.setEmulatedMedia', { media: this._page._state.mediaType || '', features });

169
src/server/clank/android.ts Normal file
View file

@ -0,0 +1,169 @@
/**
* 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 { 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 hasDefaultSocket = !!(await this.backend.runCommand(`shell:cat /proc/net/unix | grep chrome_devtools_remote$`));
debug('pw:android')('Starting', packageName);
await this.backend.runCommand(`shell:am start -n ${packageName}/com.google.android.apps.chrome.Main about:blank`);
let pid = 0;
debug('pw:android')('Polling pid for', packageName);
while (!pid) {
const ps = (await this.backend.runCommand(`shell:ps -A | grep ${packageName}`)).split('\n');
const proc = ps.find(line => line.endsWith(packageName));
if (proc)
pid = +proc.replace(/\s+/g, ' ').split(' ')[1];
await new Promise(f => setTimeout(f, 100));
}
debug('pw:android')('PID=' + pid);
const socketName = hasDefaultSocket ? `chrome_devtools_remote_${pid}` : 'chrome_devtools_remote';
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, pid);
await browser._open();
return browser;
}
model(): string | undefined {
return this._model;
}
}
export class AndroidBrowser extends EventEmitter {
readonly device: AndroidDevice;
readonly socketName: string;
readonly pid: number;
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, pid: number) {
super();
this._packageName = packageName;
this.device = device;
this.socketName = socketName;
this.pid = pid;
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

@ -0,0 +1,156 @@
/**
* 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 assert from 'assert';
import * as debug from 'debug';
import * as net from 'net';
import { EventEmitter } from 'ws';
import { Backend, DeviceBackend, SocketBackend } from './android';
export class AdbBackend implements Backend {
async devices(): Promise<DeviceBackend[]> {
const result = await runCommand('host:devices');
const lines = result.toString().trim().split('\n');
const serials = lines.map(line => line.split('\t')[0]);
return serials.map(serial => new AdbDevice(serial));
}
}
class AdbDevice implements DeviceBackend {
readonly serial: string;
constructor(serial: string) {
this.serial = serial;
}
async init() {
}
async close() {
}
runCommand(command: string): Promise<string> {
return runCommand(command, this.serial);
}
async open(command: string): Promise<SocketBackend> {
const result = await open(command, this.serial);
result.becomeSocket();
return result;
}
}
async function runCommand(command: string, serial?: string): Promise<string> {
debug('pw:adb:runCommand')(command, serial);
const socket = new BufferedSocketWrapper(net.createConnection({ port: 5037 }));
if (serial) {
await socket.write(encodeMessage(`host:transport:${serial}`));
const status = await socket.read(4);
assert(status.toString() === 'OKAY', status.toString());
}
await socket.write(encodeMessage(command));
const status = await socket.read(4);
assert(status.toString() === 'OKAY', status.toString());
if (!command.startsWith('shell:')) {
const remainingLength = parseInt((await socket.read(4)).toString(), 16);
return (await socket.read(remainingLength)).toString();
}
return (await socket.readAll()).toString();
}
async function open(command: string, serial?: string): Promise<BufferedSocketWrapper> {
const socket = new BufferedSocketWrapper(net.createConnection({ port: 5037 }));
if (serial) {
await socket.write(encodeMessage(`host:transport:${serial}`));
const status = await socket.read(4);
assert(status.toString() === 'OKAY', status.toString());
}
await socket.write(encodeMessage(command));
const status = await socket.read(4);
assert(status.toString() === 'OKAY', status.toString());
return socket;
}
function encodeMessage(message: string): Buffer {
let lenHex = (message.length).toString(16);
lenHex = '0'.repeat(4 - lenHex.length) + lenHex;
return Buffer.from(lenHex + message);
}
class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
private _socket: net.Socket;
private _buffer = Buffer.from([]);
private _isSocket = false;
private _notifyReader: (() => void) | undefined;
private _connectPromise: Promise<void>;
private _isClosed = false;
constructor(socket: net.Socket) {
super();
this._socket = socket;
this._connectPromise = new Promise(f => this._socket.on('connect', f));
this._socket.on('data', data => {
debug('pw:android:adb:data')(data.toString());
if (this._isSocket) {
this.emit('data', data);
return;
}
this._buffer = Buffer.concat([this._buffer, data]);
if (this._notifyReader)
this._notifyReader();
});
this._socket.on('close', () => {
this._isClosed = true;
if (this._notifyReader)
this._notifyReader();
this.emit('close');
});
this._socket.on('error', error => this.emit('error', error));
}
async write(data: Buffer) {
debug('pw:android:adb:send')(data.toString());
await this._connectPromise;
await new Promise(f => this._socket.write(data, f));
}
async close() {
debug('pw:android:adb')('Close');
this._socket.destroy();
}
async read(length: number): Promise<Buffer> {
await this._connectPromise;
assert(!this._isSocket, 'Can not read by length in socket mode');
while (this._buffer.length < length)
await new Promise(f => this._notifyReader = f);
const result = this._buffer.slice(0, length);
this._buffer = this._buffer.slice(length);
debug('pw:android:adb:recv')(result.toString());
return result;
}
async readAll(): Promise<Buffer> {
while (!this._isClosed)
await new Promise(f => this._notifyReader = f);
return this._buffer;
}
becomeSocket() {
assert(!this._buffer.length);
this._isSocket = true;
}
}

99
src/server/clank/clank.ts Normal file
View file

@ -0,0 +1,99 @@
/**
* 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';
export class Clank extends BrowserType {
async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, 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,
};
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

@ -15,6 +15,7 @@
*/
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';
@ -23,6 +24,7 @@ import { serverSelectors } from './selectors';
export class Playwright {
readonly selectors = serverSelectors;
readonly chromium: Chromium;
readonly clank: Clank;
readonly firefox: Firefox;
readonly webkit: WebKit;
@ -35,5 +37,11 @@ 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
});
}
}

View file

@ -38,6 +38,7 @@ const DL_OPEN_LIBRARIES = {
chromium: [],
webkit: ['libGLESv2.so.2', 'libx264.so'],
firefox: [],
clank: [],
};
async function validateDependencies(browserPath: string, browser: BrowserDescriptor) {

View file

@ -21,7 +21,7 @@ import * as path from 'path';
import { getUbuntuVersionSync } from './ubuntuVersion';
import { getFromENV } from './utils';
export type BrowserName = 'chromium'|'webkit'|'firefox';
export type BrowserName = 'chromium'|'webkit'|'firefox'|'clank';
export type BrowserPlatform = 'win32'|'win64'|'mac10.13'|'mac10.14'|'mac10.15'|'mac11.0'|'ubuntu18.04'|'ubuntu20.04';
export type BrowserDescriptor = {
name: BrowserName,

View file

@ -44,6 +44,7 @@ it('should scope context handles', async ({browser, server}) => {
] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] },
@ -57,6 +58,7 @@ it('should scope context handles', async ({browser, server}) => {
await expectScopeState(browser, {
_guid: '',
objects: [
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [
@ -91,6 +93,7 @@ it('should scope CDPSession handles', (test, { browserName }) => {
] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] },
@ -109,6 +112,7 @@ it('should scope CDPSession handles', (test, { browserName }) => {
] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] },
@ -129,6 +133,7 @@ it('should scope browser handles', async ({browserType, browserOptions}) => {
{ _guid: 'Browser', objects: [] },
]
},
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] },
@ -152,6 +157,7 @@ it('should scope browser handles', async ({browserType, browserOptions}) => {
},
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] },

View file

@ -110,10 +110,11 @@ DEPS['src/server/common/'] = [];
// Strict dependencies for injected code.
DEPS['src/server/injected/'] = ['src/server/common/'];
// Electron uses chromium internally.
// Electron and Clank use chromium internally.
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/'];
DEPS['src/server/playwright.ts'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/server/webkit/', 'src/server/firefox/', 'src/server/clank/'];
DEPS['src/driver.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerImpl.ts'] = ['src/**'];
// Tracing is a client/server plugin, nothing should depend on it.