From b20e87d9d0298e46aaf6c8561a2762032fb69443 Mon Sep 17 00:00:00 2001 From: Pavel Date: Thu, 19 Dec 2019 16:53:24 -0800 Subject: [PATCH] chore: rename the world (2) --- src/chromium/crBrowser.ts | 302 +++++++++++++++ src/chromium/crConnection.ts | 185 +++++++++ src/chromium/crExecutionContext.ts | 178 +++++++++ src/chromium/crFrameManager.ts | 459 +++++++++++++++++++++++ src/chromium/crInput.ts | 110 ++++++ src/chromium/crLauncher.ts | 258 +++++++++++++ src/chromium/crNetworkManager.ts | 452 ++++++++++++++++++++++ src/chromium/crPlaywright.ts | 131 +++++++ src/chromium/crTarget.ts | 147 ++++++++ src/chromium/features/crAccessibility.ts | 374 ++++++++++++++++++ src/chromium/features/crCoverage.ts | 297 +++++++++++++++ src/chromium/features/crInterception.ts | 41 ++ src/chromium/features/crOverrides.ts | 54 +++ src/chromium/features/crPdf.ts | 144 +++++++ src/chromium/features/crPermissions.ts | 61 +++ src/chromium/features/crWorkers.ts | 95 +++++ src/firefox/features/ffAccessibility.ts | 281 ++++++++++++++ src/firefox/features/ffInterception.ts | 33 ++ src/firefox/features/ffPermissions.ts | 49 +++ src/firefox/ffBrowser.ts | 264 +++++++++++++ src/firefox/ffConnection.ts | 204 ++++++++++ src/firefox/ffExecutionContext.ts | 183 +++++++++ src/firefox/ffFrameManager.ts | 342 +++++++++++++++++ src/firefox/ffInput.ts | 137 +++++++ src/firefox/ffLauncher.ts | 400 ++++++++++++++++++++ src/firefox/ffNetworkManager.ts | 198 ++++++++++ src/firefox/ffPlaywright.ts | 82 ++++ src/webkit/wkBrowser.ts | 217 +++++++++++ src/webkit/wkConnection.ts | 264 +++++++++++++ src/webkit/wkExecutionContext.ts | 324 ++++++++++++++++ src/webkit/wkFrameManager.ts | 440 ++++++++++++++++++++++ src/webkit/wkInput.ts | 123 ++++++ src/webkit/wkLauncher.ts | 179 +++++++++ src/webkit/wkNetworkManager.ts | 173 +++++++++ src/webkit/wkPlaywright.ts | 73 ++++ src/webkit/wkTarget.ts | 103 +++++ 36 files changed, 7357 insertions(+) create mode 100644 src/chromium/crBrowser.ts create mode 100644 src/chromium/crConnection.ts create mode 100644 src/chromium/crExecutionContext.ts create mode 100644 src/chromium/crFrameManager.ts create mode 100644 src/chromium/crInput.ts create mode 100644 src/chromium/crLauncher.ts create mode 100644 src/chromium/crNetworkManager.ts create mode 100644 src/chromium/crPlaywright.ts create mode 100644 src/chromium/crTarget.ts create mode 100644 src/chromium/features/crAccessibility.ts create mode 100644 src/chromium/features/crCoverage.ts create mode 100644 src/chromium/features/crInterception.ts create mode 100644 src/chromium/features/crOverrides.ts create mode 100644 src/chromium/features/crPdf.ts create mode 100644 src/chromium/features/crPermissions.ts create mode 100644 src/chromium/features/crWorkers.ts create mode 100644 src/firefox/features/ffAccessibility.ts create mode 100644 src/firefox/features/ffInterception.ts create mode 100644 src/firefox/features/ffPermissions.ts create mode 100644 src/firefox/ffBrowser.ts create mode 100644 src/firefox/ffConnection.ts create mode 100644 src/firefox/ffExecutionContext.ts create mode 100644 src/firefox/ffFrameManager.ts create mode 100644 src/firefox/ffInput.ts create mode 100644 src/firefox/ffLauncher.ts create mode 100644 src/firefox/ffNetworkManager.ts create mode 100644 src/firefox/ffPlaywright.ts create mode 100644 src/webkit/wkBrowser.ts create mode 100644 src/webkit/wkConnection.ts create mode 100644 src/webkit/wkExecutionContext.ts create mode 100644 src/webkit/wkFrameManager.ts create mode 100644 src/webkit/wkInput.ts create mode 100644 src/webkit/wkLauncher.ts create mode 100644 src/webkit/wkNetworkManager.ts create mode 100644 src/webkit/wkPlaywright.ts create mode 100644 src/webkit/wkTarget.ts diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts new file mode 100644 index 0000000000..b893442113 --- /dev/null +++ b/src/chromium/crBrowser.ts @@ -0,0 +1,302 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Events } from './events'; +import { Events as CommonEvents } from '../events'; +import { assert, helper } from '../helper'; +import { BrowserContext, BrowserContextOptions } from '../browserContext'; +import { CRConnection, ConnectionEvents, CRSession } from './crConnection'; +import { Page } from '../page'; +import { CRTarget } from './crTarget'; +import { Protocol } from './protocol'; +import { CRFrameManager } from './crFrameManager'; +import * as browser from '../browser'; +import * as network from '../network'; +import { CRPermissions } from './features/crPermissions'; +import { CROverrides } from './features/crOverrides'; +import { Worker } from './features/crWorkers'; +import { ConnectionTransport } from '../transport'; +import { readProtocolStream } from './protocolHelper'; + +export class CRBrowser extends EventEmitter implements browser.Browser { + _connection: CRConnection; + _client: CRSession; + private _defaultContext: BrowserContext; + private _contexts = new Map(); + _targets = new Map(); + + private _tracingRecording = false; + private _tracingPath = ''; + private _tracingClient: CRSession | undefined; + + static async create( + transport: ConnectionTransport) { + const connection = new CRConnection(transport); + + const { browserContextIds } = await connection.rootSession.send('Target.getBrowserContexts'); + const browser = new CRBrowser(connection, browserContextIds); + await connection.rootSession.send('Target.setDiscoverTargets', { discover: true }); + await browser.waitForTarget(t => t.type() === 'page'); + return browser; + } + + constructor(connection: CRConnection, contextIds: string[]) { + super(); + this._connection = connection; + this._client = connection.rootSession; + + this._defaultContext = this._createBrowserContext(null, {}); + for (const contextId of contextIds) + this._contexts.set(contextId, this._createBrowserContext(contextId, {})); + + this._connection.on(ConnectionEvents.Disconnected, () => this.emit(CommonEvents.Browser.Disconnected)); + this._client.on('Target.targetCreated', this._targetCreated.bind(this)); + this._client.on('Target.targetDestroyed', this._targetDestroyed.bind(this)); + this._client.on('Target.targetInfoChanged', this._targetInfoChanged.bind(this)); + } + + _createBrowserContext(contextId: string | null, options: BrowserContextOptions): BrowserContext { + let overrides: CROverrides | null = null; + const context = new BrowserContext({ + pages: async (): Promise => { + const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + }, + + newPage: async (): Promise => { + const { targetId } = await this._client.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined }); + const target = this._targets.get(targetId); + assert(await target._initializedPromise, 'Failed to create target for page'); + const page = await target.page(); + const session = (page._delegate as CRFrameManager)._client; + const promises: Promise[] = [ overrides._applyOverrides(page) ]; + if (options.bypassCSP) + promises.push(session.send('Page.setBypassCSP', { enabled: true })); + if (options.ignoreHTTPSErrors) + promises.push(session.send('Security.setIgnoreCertificateErrors', { ignore: true })); + if (options.viewport) + promises.push(page._delegate.setViewport(options.viewport)); + if (options.javaScriptEnabled === false) + promises.push(session.send('Emulation.setScriptExecutionDisabled', { value: true })); + if (options.userAgent) + (page._delegate as CRFrameManager)._networkManager.setUserAgent(options.userAgent); + if (options.mediaType || options.colorScheme) { + const features = options.colorScheme ? [{ name: 'prefers-color-scheme', value: options.colorScheme }] : []; + promises.push(session.send('Emulation.setEmulatedMedia', { media: options.mediaType || '', features })); + } + if (options.timezoneId) + promises.push(emulateTimezone(session, options.timezoneId)); + await Promise.all(promises); + return page; + }, + + close: async (): Promise => { + assert(contextId, 'Non-incognito profiles cannot be closed!'); + await this._client.send('Target.disposeBrowserContext', {browserContextId: contextId || undefined}); + this._contexts.delete(contextId); + }, + + cookies: async (): Promise => { + const { cookies } = await this._client.send('Storage.getCookies', { browserContextId: contextId || undefined }); + return cookies.map(c => { + const copy: any = { sameSite: 'None', ...c }; + delete copy.size; + delete copy.priority; + return copy as network.NetworkCookie; + }); + }, + + clearCookies: async (): Promise => { + await this._client.send('Storage.clearCookies', { browserContextId: contextId || undefined }); + }, + + setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise => { + await this._client.send('Storage.setCookies', { cookies, browserContextId: contextId || undefined }); + }, + }, options); + overrides = new CROverrides(context); + (context as any).permissions = new CRPermissions(this._client, contextId); + (context as any).overrides = overrides; + return context; + } + + async newContext(options: BrowserContextOptions = {}): Promise { + const { browserContextId } = await this._client.send('Target.createBrowserContext'); + const context = this._createBrowserContext(browserContextId, options); + this._contexts.set(browserContextId, context); + return context; + } + + browserContexts(): BrowserContext[] { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + defaultContext(): BrowserContext { + return this._defaultContext; + } + + async _targetCreated(event: Protocol.Target.targetCreatedPayload) { + const targetInfo = event.targetInfo; + const {browserContextId} = targetInfo; + const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId) : this._defaultContext; + + const target = new CRTarget(this, targetInfo, context, () => this._connection.createSession(targetInfo)); + assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); + this._targets.set(event.targetInfo.targetId, target); + + if (target._isInitialized || await target._initializedPromise) + this.emit(Events.Browser.TargetCreated, target); + } + + async _targetDestroyed(event: { targetId: string; }) { + const target = this._targets.get(event.targetId); + target._initializedCallback(false); + this._targets.delete(event.targetId); + target._didClose(); + if (await target._initializedPromise) + this.emit(Events.Browser.TargetDestroyed, target); + } + + _targetInfoChanged(event: Protocol.Target.targetInfoChangedPayload) { + const target = this._targets.get(event.targetInfo.targetId); + assert(target, 'target should exist before targetInfoChanged'); + const previousURL = target.url(); + const wasInitialized = target._isInitialized; + target._targetInfoChanged(event.targetInfo); + if (wasInitialized && previousURL !== target.url()) + this.emit(Events.Browser.TargetChanged, target); + } + + async _closePage(page: Page) { + await this._client.send('Target.closeTarget', { targetId: CRTarget.fromPage(page)._targetId }); + } + + _allTargets(): CRTarget[] { + return Array.from(this._targets.values()).filter(target => target._isInitialized); + } + + async _activatePage(page: Page) { + await (page._delegate as CRFrameManager)._client.send('Target.activateTarget', {targetId: CRTarget.fromPage(page)._targetId}); + } + + async waitForTarget(predicate: (arg0: CRTarget) => boolean, options: { timeout?: number; } | undefined = {}): Promise { + const { + timeout = 30000 + } = options; + const existingTarget = this._allTargets().find(predicate); + if (existingTarget) + return existingTarget; + let resolve: (target: CRTarget) => void; + const targetPromise = new Promise(x => resolve = x); + this.on(Events.Browser.TargetCreated, check); + this.on(Events.Browser.TargetChanged, check); + try { + if (!timeout) + return await targetPromise; + return await helper.waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this.removeListener(Events.Browser.TargetCreated, check); + this.removeListener(Events.Browser.TargetChanged, check); + } + + function check(target: CRTarget) { + if (predicate(target)) + resolve(target); + } + } + + async close() { + await this._connection.rootSession.send('Browser.close'); + this.disconnect(); + } + + browserTarget(): CRTarget { + return [...this._targets.values()].find(t => t.type() === 'browser'); + } + + serviceWorker(target: CRTarget): Promise { + return target._worker(); + } + + async startTracing(page: Page | undefined, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) { + assert(!this._tracingRecording, 'Cannot start recording trace while already recording trace.'); + this._tracingClient = page ? (page._delegate as CRFrameManager)._client : this._client; + + const defaultCategories = [ + '-*', 'devtools.timeline', 'v8.execute', 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', 'toplevel', + 'blink.console', 'blink.user_timing', 'latencyInfo', 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', 'disabled-by-default-v8.cpu_profiler.hires' + ]; + const { + path = null, + screenshots = false, + categories = defaultCategories, + } = options; + + if (screenshots) + categories.push('disabled-by-default-devtools.screenshot'); + + this._tracingPath = path; + this._tracingRecording = true; + await this._tracingClient.send('Tracing.start', { + transferMode: 'ReturnAsStream', + categories: categories.join(',') + }); + } + + async stopTracing(): Promise { + assert(this._tracingClient, 'Tracing was not started.'); + let fulfill: (buffer: Buffer) => void; + const contentPromise = new Promise(x => fulfill = x); + this._tracingClient.once('Tracing.tracingComplete', event => { + readProtocolStream(this._tracingClient, event.stream, this._tracingPath).then(fulfill); + }); + await this._tracingClient.send('Tracing.end'); + this._tracingRecording = false; + return contentPromise; + } + + targets(context?: BrowserContext): CRTarget[] { + const targets = this._allTargets(); + return context ? targets.filter(t => t.browserContext() === context) : targets; + } + + pageTarget(page: Page): CRTarget { + return CRTarget.fromPage(page); + } + + disconnect() { + this._connection.dispose(); + } + + isConnected(): boolean { + return !this._connection._closed; + } +} + +async function emulateTimezone(session: CRSession, timezoneId: string) { + try { + await session.send('Emulation.setTimezoneOverride', { timezoneId: timezoneId }); + } catch (exception) { + if (exception.message.includes('Invalid timezone')) + throw new Error(`Invalid timezone ID: ${timezoneId}`); + throw exception; + } +} diff --git a/src/chromium/crConnection.ts b/src/chromium/crConnection.ts new file mode 100644 index 0000000000..26783d5f33 --- /dev/null +++ b/src/chromium/crConnection.ts @@ -0,0 +1,185 @@ +/** + * 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 * as debug from 'debug'; +import { EventEmitter } from 'events'; +import { ConnectionTransport } from '../transport'; +import { assert } from '../helper'; +import { Protocol } from './protocol'; + +const debugProtocol = debug('playwright:protocol'); + +export const ConnectionEvents = { + Disconnected: Symbol('ConnectionEvents.Disconnected') +}; + +export class CRConnection extends EventEmitter { + private _lastId = 0; + private _transport: ConnectionTransport; + private _sessions = new Map(); + readonly rootSession: CRSession; + _closed = false; + + constructor(transport: ConnectionTransport) { + super(); + this._transport = transport; + this._transport.onmessage = this._onMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + this.rootSession = new CRSession(this, 'browser', ''); + this._sessions.set('', this.rootSession); + } + + static fromSession(session: CRSession): CRConnection { + return session._connection; + } + + session(sessionId: string): CRSession | null { + return this._sessions.get(sessionId) || null; + } + + _rawSend(sessionId: string, message: any): number { + const id = ++this._lastId; + message.id = id; + if (sessionId) + message.sessionId = sessionId; + const data = JSON.stringify(message); + debugProtocol('SEND ► ' + data); + this._transport.send(data); + return id; + } + + async _onMessage(message: string) { + debugProtocol('◀ RECV ' + message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CRSession(this, object.params.targetInfo.type, sessionId); + this._sessions.set(sessionId, session); + } else if (object.method === 'Target.detachedFromTarget') { + const session = this._sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this._sessions.delete(object.params.sessionId); + } + } + const session = this._sessions.get(object.sessionId || ''); + if (session) + session._onMessage(object); + } + + _onClose() { + if (this._closed) + return; + this._closed = true; + this._transport.onmessage = null; + this._transport.onclose = null; + for (const session of this._sessions.values()) + session._onClosed(); + this._sessions.clear(); + Promise.resolve().then(() => this.emit(ConnectionEvents.Disconnected)); + } + + dispose() { + this._onClose(); + this._transport.close(); + } + + async createSession(targetInfo: Protocol.Target.TargetInfo): Promise { + const { sessionId } = await this.rootSession.send('Target.attachToTarget', { targetId: targetInfo.targetId, flatten: true }); + return this._sessions.get(sessionId); + } + + async createBrowserSession(): Promise { + const { sessionId } = await this.rootSession.send('Target.attachToBrowserTarget'); + return this._sessions.get(sessionId); + } +} + +export const CRSessionEvents = { + Disconnected: Symbol('Events.CDPSession.Disconnected') +}; + +export class CRSession extends EventEmitter { + _connection: CRConnection; + private _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); + private _targetType: string; + private _sessionId: string; + on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + + constructor(connection: CRConnection, targetType: string, sessionId: string) { + super(); + this._connection = connection; + this._targetType = targetType; + this._sessionId = sessionId; + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + if (!this._connection) + return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`)); + const id = this._connection._rawSend(this._sessionId, { method, params }); + return new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + } + + _onMessage(object: { id?: number; method: string; params: any; error: { message: string; data: any; }; result?: any; }) { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } else { + assert(!object.id); + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } + + async detach() { + if (!this._connection) + throw new Error(`Session already detached. Most likely the ${this._targetType} has been closed.`); + await this._connection.rootSession.send('Target.detachFromTarget', { sessionId: this._sessionId }); + } + + _onClosed() { + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + this._callbacks.clear(); + this._connection = null; + Promise.resolve().then(() => this.emit(CRSessionEvents.Disconnected)); + } +} + +function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) + message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} diff --git a/src/chromium/crExecutionContext.ts b/src/chromium/crExecutionContext.ts new file mode 100644 index 0000000000..7b329fdb07 --- /dev/null +++ b/src/chromium/crExecutionContext.ts @@ -0,0 +1,178 @@ +/** + * 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 { CRSession } from './crConnection'; +import { helper } from '../helper'; +import { valueFromRemoteObject, getExceptionMessage, releaseObject } from './protocolHelper'; +import { Protocol } from './protocol'; +import * as js from '../javascript'; + +export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__'; +const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; + +export class CRExecutionContext implements js.ExecutionContextDelegate { + _client: CRSession; + _contextId: number; + + constructor(client: CRSession, contextPayload: Protocol.Runtime.ExecutionContextDescription) { + this._client = client; + this._contextId = contextPayload.id; + } + + async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { + const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; + + if (helper.isString(pageFunction)) { + const contextId = this._contextId; + const expression: string = pageFunction as string; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix; + const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true + }).catch(rewriteError); + if (exceptionDetails) + throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); + return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject); + } + + if (typeof pageFunction !== 'function') + throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); + + let functionText = pageFunction.toString(); + try { + new Function('(' + functionText + ')'); + } catch (e1) { + // This means we might have a function shorthand. Try another + // time prefixing 'function '. + if (functionText.startsWith('async ')) + functionText = 'async function ' + functionText.substring('async '.length); + else + functionText = 'function ' + functionText; + try { + new Function('(' + functionText + ')'); + } catch (e2) { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function is not well-serializable!'); + } + } + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: functionText + '\n' + suffix + '\n', + executionContextId: this._contextId, + arguments: args.map(convertArgument.bind(this)), + returnByValue, + awaitPromise: true, + userGesture: true + }); + } catch (err) { + if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON')) + err.message += ' Are you passing a nested JSHandle?'; + throw err; + } + const { exceptionDetails, result: remoteObject } = await callFunctionOnPromise.catch(rewriteError); + if (exceptionDetails) + throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); + return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject); + + function convertArgument(arg: any): any { + if (typeof arg === 'bigint') // eslint-disable-line valid-typeof + return { unserializableValue: `${arg.toString()}n` }; + if (Object.is(arg, -0)) + return { unserializableValue: '-0' }; + if (Object.is(arg, Infinity)) + return { unserializableValue: 'Infinity' }; + if (Object.is(arg, -Infinity)) + return { unserializableValue: '-Infinity' }; + if (Object.is(arg, NaN)) + return { unserializableValue: 'NaN' }; + const objectHandle = arg && (arg instanceof js.JSHandle) ? arg : null; + if (objectHandle) { + if (objectHandle._context !== context) + throw new Error('JSHandles can be evaluated only in the context they were created!'); + if (objectHandle._disposed) + throw new Error('JSHandle is disposed!'); + const remoteObject = toRemoteObject(objectHandle); + if (remoteObject.unserializableValue) + return { unserializableValue: remoteObject.unserializableValue }; + if (!remoteObject.objectId) + return { value: remoteObject.value }; + return { objectId: remoteObject.objectId }; + } + return { value: arg }; + } + + function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { + if (error.message.includes('Object reference chain is too long')) + return {result: {type: 'undefined'}}; + if (error.message.includes('Object couldn\'t be returned by value')) + return {result: {type: 'undefined'}}; + + if (error.message.endsWith('Cannot find context with specified id') || error.message.endsWith('Inspected target navigated or closed') || error.message.endsWith('Execution context was destroyed.')) + throw new Error('Execution context was destroyed, most likely because of a navigation.'); + throw error; + } + } + + async getProperties(handle: js.JSHandle): Promise> { + const response = await this._client.send('Runtime.getProperties', { + objectId: toRemoteObject(handle).objectId, + ownProperties: true + }); + const result = new Map(); + for (const property of response.result) { + if (!property.enumerable) + continue; + result.set(property.name, handle._context._createHandle(property.value)); + } + return result; + } + + async releaseHandle(handle: js.JSHandle): Promise { + await releaseObject(this._client, toRemoteObject(handle)); + } + + async handleJSONValue(handle: js.JSHandle): Promise { + const remoteObject = toRemoteObject(handle); + if (remoteObject.objectId) { + const response = await this._client.send('Runtime.callFunctionOn', { + functionDeclaration: 'function() { return this; }', + objectId: remoteObject.objectId, + returnByValue: true, + awaitPromise: true, + }); + return valueFromRemoteObject(response.result); + } + return valueFromRemoteObject(remoteObject); + } + + handleToString(handle: js.JSHandle, includeType: boolean): string { + const object = toRemoteObject(handle); + if (object.objectId) { + const type = object.subtype || object.type; + return 'JSHandle@' + type; + } + return (includeType ? 'JSHandle:' : '') + valueFromRemoteObject(object); + } +} + +function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject { + return handle._remoteObject as Protocol.Runtime.RemoteObject; +} diff --git a/src/chromium/crFrameManager.ts b/src/chromium/crFrameManager.ts new file mode 100644 index 0000000000..366f67417d --- /dev/null +++ b/src/chromium/crFrameManager.ts @@ -0,0 +1,459 @@ +/** + * 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 * as dom from '../dom'; +import * as frames from '../frames'; +import { debugError, helper, RegisteredListener } from '../helper'; +import * as network from '../network'; +import { CRSession } from './crConnection'; +import { EVALUATION_SCRIPT_URL, CRExecutionContext } from './crExecutionContext'; +import { CRNetworkManager } from './crNetworkManager'; +import { Page } from '../page'; +import { Protocol } from './protocol'; +import { Events } from '../events'; +import { toConsoleMessageLocation, exceptionToError, releaseObject } from './protocolHelper'; +import * as dialog from '../dialog'; +import { PageDelegate } from '../page'; +import { RawMouseImpl, RawKeyboardImpl } from './crInput'; +import { CRAccessibility } from './features/crAccessibility'; +import { CRCoverage } from './features/crCoverage'; +import { CRPdf } from './features/crPdf'; +import { CRWorkers } from './features/crWorkers'; +import { CRInterception } from './features/crInterception'; +import { CRBrowser } from './crBrowser'; +import { BrowserContext } from '../browserContext'; +import * as types from '../types'; +import * as input from '../input'; +import { ConsoleMessage } from '../console'; + +const UTILITY_WORLD_NAME = '__playwright_utility_world__'; + +export class CRFrameManager implements PageDelegate { + _client: CRSession; + private _page: Page; + readonly _networkManager: CRNetworkManager; + private _contextIdToContext = new Map(); + private _isolatedWorlds = new Set(); + private _eventListeners: RegisteredListener[]; + rawMouse: RawMouseImpl; + rawKeyboard: RawKeyboardImpl; + private _browser: CRBrowser; + + constructor(client: CRSession, browser: CRBrowser, browserContext: BrowserContext) { + this._client = client; + this._browser = browser; + this.rawKeyboard = new RawKeyboardImpl(client); + this.rawMouse = new RawMouseImpl(client); + this._page = new Page(this, browserContext); + this._networkManager = new CRNetworkManager(client, this._page); + (this._page as any).accessibility = new CRAccessibility(client); + (this._page as any).coverage = new CRCoverage(client); + (this._page as any).pdf = new CRPdf(client); + (this._page as any).workers = new CRWorkers(client, this._page._addConsoleMessage.bind(this._page), error => this._page.emit(Events.Page.PageError, error)); + (this._page as any).interception = new CRInterception(this._networkManager); + + this._eventListeners = [ + helper.addEventListener(client, 'Inspector.targetCrashed', event => this._onTargetCrashed()), + helper.addEventListener(client, 'Log.entryAdded', event => this._onLogEntryAdded(event)), + helper.addEventListener(client, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), + helper.addEventListener(client, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), + helper.addEventListener(client, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)), + helper.addEventListener(client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), + helper.addEventListener(client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), + helper.addEventListener(client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), + helper.addEventListener(client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)), + helper.addEventListener(client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), + helper.addEventListener(client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), + helper.addEventListener(client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)), + helper.addEventListener(client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)), + helper.addEventListener(client, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), + helper.addEventListener(client, 'Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)), + helper.addEventListener(client, 'Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()), + ]; + } + + async initialize() { + const [,{frameTree}] = await Promise.all([ + this._client.send('Page.enable'), + this._client.send('Page.getFrameTree'), + ]); + this._handleFrameTree(frameTree); + await Promise.all([ + this._client.send('Log.enable', {}), + this._client.send('Page.setInterceptFileChooserDialog', {enabled: true}), + this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), + this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), + this._networkManager.initialize(), + ]); + } + + didClose() { + helper.removeEventListeners(this._eventListeners); + this._networkManager.dispose(); + this._page._didClose(); + } + + async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + const response = await this._client.send('Page.navigate', { url, referrer, frameId: frame._id }); + if (response.errorText) + throw new Error(`${response.errorText} at ${url}`); + return { newDocumentId: response.loaderId, isSameDocument: !response.loaderId }; + } + + needsLifecycleResetOnSetContent(): boolean { + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + return false; + } + + _onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) { + if (event.name === 'init') + this._page._frameManager.frameLifecycleEvent(event.frameId, 'clear'); + else if (event.name === 'load') + this._page._frameManager.frameLifecycleEvent(event.frameId, 'load'); + else if (event.name === 'DOMContentLoaded') + this._page._frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded'); + } + + _onFrameStoppedLoading(frameId: string) { + this._page._frameManager.frameStoppedLoading(frameId); + } + + _handleFrameTree(frameTree: Protocol.Page.FrameTree) { + this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); + this._onFrameNavigated(frameTree.frame, true); + if (!frameTree.childFrames) + return; + + for (const child of frameTree.childFrames) + this._handleFrameTree(child); + } + + page(): Page { + return this._page; + } + + _onFrameAttached(frameId: string, parentFrameId: string | null) { + this._page._frameManager.frameAttached(frameId, parentFrameId); + } + + _onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) { + this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url, framePayload.name || '', framePayload.loaderId, initial); + } + + async _ensureIsolatedWorld(name: string) { + if (this._isolatedWorlds.has(name)) + return; + this._isolatedWorlds.add(name); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, + worldName: name, + }); + await Promise.all(this._page.frames().map(frame => this._client.send('Page.createIsolatedWorld', { + frameId: frame._id, + grantUniveralAccess: true, + worldName: name, + }).catch(debugError))); // frames might be removed before we send this + } + + _onFrameNavigatedWithinDocument(frameId: string, url: string) { + this._page._frameManager.frameCommittedSameDocumentNavigation(frameId, url); + } + + _onFrameDetached(frameId: string) { + this._page._frameManager.frameDetached(frameId); + } + + _onExecutionContextCreated(contextPayload: Protocol.Runtime.ExecutionContextDescription) { + const frame = this._page._frameManager.frame(contextPayload.auxData ? contextPayload.auxData.frameId : null); + if (!frame) + return; + if (contextPayload.auxData && contextPayload.auxData.type === 'isolated') + this._isolatedWorlds.add(contextPayload.name); + const delegate = new CRExecutionContext(this._client, contextPayload); + const context = new dom.FrameExecutionContext(delegate, frame); + if (contextPayload.auxData && !!contextPayload.auxData.isDefault) + frame._contextCreated('main', context); + else if (contextPayload.name === UTILITY_WORLD_NAME) + frame._contextCreated('utility', context); + this._contextIdToContext.set(contextPayload.id, context); + } + + _onExecutionContextDestroyed(executionContextId: number) { + const context = this._contextIdToContext.get(executionContextId); + if (!context) + return; + this._contextIdToContext.delete(executionContextId); + context.frame._contextDestroyed(context); + } + + _onExecutionContextsCleared() { + for (const contextId of Array.from(this._contextIdToContext.keys())) + this._onExecutionContextDestroyed(contextId); + } + + async _onConsoleAPI(event: Protocol.Runtime.consoleAPICalledPayload) { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Playwright clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/GoogleChrome/puppeteer/issues/3865 + return; + } + const context = this._contextIdToContext.get(event.executionContextId); + const values = event.args.map(arg => context._createHandle(arg)); + this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); + } + + async exposeBinding(name: string, bindingFunction: string) { + await this._client.send('Runtime.addBinding', {name: name}); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: bindingFunction}); + await Promise.all(this._page.frames().map(frame => frame.evaluate(bindingFunction).catch(debugError))); + } + + _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { + const context = this._contextIdToContext.get(event.executionContextId); + this._page._onBindingCalled(event.payload, context); + } + + _onDialog(event : Protocol.Page.javascriptDialogOpeningPayload) { + this._page.emit(Events.Page.Dialog, new dialog.Dialog( + event.type as dialog.DialogType, + event.message, + async (accept: boolean, promptText?: string) => { + await this._client.send('Page.handleJavaScriptDialog', { accept, promptText }); + }, + event.defaultPrompt)); + } + + _handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails) { + this._page.emit(Events.Page.PageError, exceptionToError(exceptionDetails)); + } + + _onTargetCrashed() { + this._page.emit('error', new Error('Page crashed!')); + } + + _onLogEntryAdded(event: Protocol.Log.entryAddedPayload) { + const {level, text, args, source, url, lineNumber} = event.entry; + if (args) + args.map(arg => releaseObject(this._client, arg)); + if (source !== 'worker') + this._page.emit(Events.Page.Console, new ConsoleMessage(level, text, [], {url, lineNumber})); + } + + async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) { + const frame = this._page._frameManager.frame(event.frameId); + const utilityContext = await frame._utilityContext(); + const handle = await this.adoptBackendNodeId(event.backendNodeId, utilityContext); + this._page._onFileChooserOpened(handle); + } + + async setExtraHTTPHeaders(headers: network.Headers): Promise { + await this._client.send('Network.setExtraHTTPHeaders', { headers }); + } + + async setViewport(viewport: types.Viewport): Promise { + const { + width, + height, + isMobile = false, + deviceScaleFactor = 1, + hasTouch = false, + isLandscape = false, + } = viewport; + const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; + await Promise.all([ + this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }), + this._client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch + }) + ]); + } + + async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise { + const features = mediaColorScheme ? [{ name: 'prefers-color-scheme', value: mediaColorScheme }] : []; + await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features }); + } + + setCacheEnabled(enabled: boolean): Promise { + return this._networkManager.setCacheEnabled(enabled); + } + + async reload(): Promise { + await this._client.send('Page.reload'); + } + + private async _go(delta: number): Promise { + const history = await this._client.send('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) + return false; + await this._client.send('Page.navigateToHistoryEntry', { entryId: entry.id }); + return true; + } + + goBack(): Promise { + return this._go(-1); + } + + goForward(): Promise { + return this._go(+1); + } + + async evaluateOnNewDocument(source: string): Promise { + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); + } + + async closePage(runBeforeUnload: boolean): Promise { + if (runBeforeUnload) + await this._client.send('Page.close'); + else + await this._browser._closePage(this._page); + } + + async getBoundingBoxForScreenshot(handle: dom.ElementHandle): Promise { + const rect = await handle.boundingBox(); + if (!rect) + return rect; + const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics'); + rect.x += pageX; + rect.y += pageY; + return rect; + } + + canScreenshotOutsideViewport(): boolean { + return false; + } + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise { + await this._client.send('Emulation.setDefaultBackgroundColorOverride', { color }); + } + + async takeScreenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise { + await this._client.send('Page.bringToFront', {}); + const clip = options.clip ? { ...options.clip, scale: 1 } : undefined; + const result = await this._client.send('Page.captureScreenshot', { format, quality: options.quality, clip }); + return Buffer.from(result.data, 'base64'); + } + + async resetViewport(): Promise { + await this._client.send('Emulation.setDeviceMetricsOverride', { mobile: false, width: 0, height: 0, deviceScaleFactor: 0 }); + } + + async getContentFrame(handle: dom.ElementHandle): Promise { + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: toRemoteObject(handle).objectId + }); + if (!nodeInfo || typeof nodeInfo.node.frameId !== 'string') + return null; + return this._page._frameManager.frame(nodeInfo.node.frameId); + } + + async getOwnerFrame(handle: dom.ElementHandle): Promise { + // document.documentElement has frameId of the owner frame. + const documentElement = await handle.evaluateHandle(node => { + const doc = node as Document; + if (doc.documentElement && doc.documentElement.ownerDocument === doc) + return doc.documentElement; + return node.ownerDocument ? node.ownerDocument.documentElement : null; + }); + if (!documentElement) + return null; + const remoteObject = toRemoteObject(documentElement); + if (!remoteObject.objectId) + return null; + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: remoteObject.objectId + }); + const frame = nodeInfo && typeof nodeInfo.node.frameId === 'string' ? + this._page._frameManager.frame(nodeInfo.node.frameId) : null; + await documentElement.dispose(); + return frame; + } + + isElementHandle(remoteObject: any): boolean { + return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node'; + } + + async getBoundingBox(handle: dom.ElementHandle): Promise { + const result = await this._client.send('DOM.getBoxModel', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); + if (!result) + return null; + const quad = result.model.border; + const x = Math.min(quad[0], quad[2], quad[4], quad[6]); + const y = Math.min(quad[1], quad[3], quad[5], quad[7]); + const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; + const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; + return {x, y, width, height}; + } + + async getContentQuads(handle: dom.ElementHandle): Promise { + const result = await this._client.send('DOM.getContentQuads', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] } + ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + const layoutMetrics = await this._client.send('Page.getLayoutMetrics'); + return { width: layoutMetrics.layoutViewport.clientWidth, height: layoutMetrics.layoutViewport.clientHeight }; + } + + async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { + await handle.evaluate(input.setFileInputFunction, files); + } + + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: toRemoteObject(handle).objectId, + }); + return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to) as Promise>; + } + + async adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId, to: dom.FrameExecutionContext): Promise { + const result = await this._client.send('DOM.resolveNode', { + backendNodeId, + executionContextId: (to._delegate as CRExecutionContext)._contextId, + }).catch(debugError); + if (!result || result.object.subtype === 'null') + throw new Error('Unable to adopt element handle from a different document'); + return to._createHandle(result.object).asElement()!; + } +} + +function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject { + return handle._remoteObject as Protocol.Runtime.RemoteObject; +} diff --git a/src/chromium/crInput.ts b/src/chromium/crInput.ts new file mode 100644 index 0000000000..e48fb93b85 --- /dev/null +++ b/src/chromium/crInput.ts @@ -0,0 +1,110 @@ +/** + * 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 * as input from '../input'; +import { CRSession } from './crConnection'; + +function toModifiersMask(modifiers: Set): number { + let mask = 0; + if (modifiers.has('Alt')) + mask |= 1; + if (modifiers.has('Control')) + mask |= 2; + if (modifiers.has('Meta')) + mask |= 4; + if (modifiers.has('Shift')) + mask |= 8; + return mask; +} + +export class RawKeyboardImpl implements input.RawKeyboard { + private _client: CRSession; + + constructor(client: CRSession) { + this._client = client; + } + + async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { + await this._client.send('Input.dispatchKeyEvent', { + type: text ? 'keyDown' : 'rawKeyDown', + modifiers: toModifiersMask(modifiers), + windowsVirtualKeyCode: keyCodeWithoutLocation, + code, + key, + text, + unmodifiedText: text, + autoRepeat, + location, + isKeypad: location === input.keypadLocation + }); + } + + async keyup(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise { + await this._client.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: toModifiersMask(modifiers), + key, + windowsVirtualKeyCode: keyCodeWithoutLocation, + code, + location + }); + } + + async sendText(text: string): Promise { + await this._client.send('Input.insertText', { text }); + } +} + +export class RawMouseImpl implements input.RawMouse { + private _client: CRSession; + + constructor(client: CRSession) { + this._client = client; + } + + async move(x: number, y: number, button: input.Button | 'none', buttons: Set, modifiers: Set): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + button, + x, + y, + modifiers: toModifiersMask(modifiers) + }); + } + + async down(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + button, + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } + + async up(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + button, + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } +} diff --git a/src/chromium/crLauncher.ts b/src/chromium/crLauncher.ts new file mode 100644 index 0000000000..2822770438 --- /dev/null +++ b/src/chromium/crLauncher.ts @@ -0,0 +1,258 @@ +/** + * 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 * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; +import { TimeoutError } from '../errors'; +import { assert, helper } from '../helper'; +import { launchProcess, waitForLine } from '../processLauncher'; +import { ConnectionTransport, PipeTransport, SlowMoTransport, WebSocketTransport } from '../transport'; +import { CRBrowser } from './crBrowser'; +import { BrowserServer } from '../browser'; + +const mkdtempAsync = helper.promisify(fs.mkdtemp); + +const CHROME_PROFILE_PATH = path.join(os.tmpdir(), 'playwright_dev_profile-'); + +const DEFAULT_ARGS = [ + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + // BlinkGenPropertyTrees disabled due to crbug.com/937609 + '--disable-features=TranslateUI,BlinkGenPropertyTrees', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', +]; + +export class CRLauncher { + private _projectRoot: string; + private _preferredRevision: string; + + constructor(projectRoot: string, preferredRevision: string) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + } + + async launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & ConnectionOptions) = {}): Promise> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + slowMo = 0, + timeout = 30000 + } = options; + + const chromeArguments = []; + if (!ignoreDefaultArgs) + chromeArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + chromeArguments.push(...this.defaultArgs(options).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1)); + else + chromeArguments.push(...args); + + let temporaryUserDataDir: string | null = null; + + if (!chromeArguments.some(argument => argument.startsWith('--remote-debugging-'))) + chromeArguments.push(pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0'); + if (!chromeArguments.some(arg => arg.startsWith('--user-data-dir'))) { + temporaryUserDataDir = await mkdtempAsync(CHROME_PROFILE_PATH); + chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); + } + + let chromeExecutable = executablePath; + if (!executablePath) { + const {missingText, executablePath} = this._resolveExecutablePath(); + if (missingText) + throw new Error(missingText); + chromeExecutable = executablePath; + } + + const usePipe = chromeArguments.includes('--remote-debugging-pipe'); + + const launchedProcess = await launchProcess({ + executablePath: chromeExecutable, + args: chromeArguments, + env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: usePipe, + tempDir: temporaryUserDataDir + }, () => { + if (temporaryUserDataDir || !browser) + return Promise.reject(); + return browser.close(); + }); + + let browser: CRBrowser | undefined; + try { + let transport: ConnectionTransport | null = null; + let browserWSEndpoint: string = ''; + if (!usePipe) { + const timeoutError = new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${this._preferredRevision}`); + const match = await waitForLine(launchedProcess, launchedProcess.stderr, /^DevTools listening on (ws:\/\/.*)$/, timeout, timeoutError); + browserWSEndpoint = match[1]; + transport = await WebSocketTransport.create(browserWSEndpoint); + } else { + transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream); + } + + browser = await CRBrowser.create(SlowMoTransport.wrap(transport, slowMo)); + return new BrowserServer(browser, launchedProcess, browserWSEndpoint); + } catch (e) { + if (browser) + await browser.close(); + throw e; + } + } + + defaultArgs(options: LauncherChromeArgOptions = {}): string[] { + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null + } = options; + const chromeArguments = [...DEFAULT_ARGS]; + if (userDataDir) + chromeArguments.push(`--user-data-dir=${userDataDir}`); + if (devtools) + chromeArguments.push('--auto-open-devtools-for-tabs'); + if (headless) { + chromeArguments.push( + '--headless', + '--hide-scrollbars', + '--mute-audio' + ); + } + if (args.every(arg => arg.startsWith('-'))) + chromeArguments.push('about:blank'); + chromeArguments.push(...args); + return chromeArguments; + } + + executablePath(): string { + return this._resolveExecutablePath().executablePath; + } + + _resolveExecutablePath(): { executablePath: string; missingText: string | null; } { + const browserFetcher = createBrowserFetcher(this._projectRoot); + const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); + const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null; + return {executablePath: revisionInfo.executablePath, missingText}; + } + +} + +export type LauncherChromeArgOptions = { + headless?: boolean, + args?: string[], + userDataDir?: string, + devtools?: boolean, +}; + +export type LauncherLaunchOptions = { + executablePath?: string, + ignoreDefaultArgs?: boolean|string[], + handleSIGINT?: boolean, + handleSIGTERM?: boolean, + handleSIGHUP?: boolean, + timeout?: number, + dumpio?: boolean, + env?: {[key: string]: string} | undefined, + pipe?: boolean, +}; + +export type ConnectionOptions = { + slowMo?: number, +}; + +export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher { + const downloadURLs = { + linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', + mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', + win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', + win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', + }; + + const defaultOptions = { + path: path.join(projectRoot, '.local-chromium'), + host: 'https://storage.googleapis.com', + platform: (() => { + const platform = os.platform(); + if (platform === 'darwin') + return 'mac'; + if (platform === 'linux') + return 'linux'; + if (platform === 'win32') + return os.arch() === 'x64' ? 'win64' : 'win32'; + return platform; + })() + }; + options = { + ...defaultOptions, + ...options, + }; + assert(!!(downloadURLs as any)[options.platform], 'Unsupported platform: ' + options.platform); + + return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => { + let archiveName = ''; + let executablePath = ''; + if (platform === 'linux') { + archiveName = 'chrome-linux'; + executablePath = path.join(archiveName, 'chrome'); + } else if (platform === 'mac') { + archiveName = 'chrome-mac'; + executablePath = path.join(archiveName, 'Chromium.app', 'Contents', 'MacOS', 'Chromium'); + } else if (platform === 'win32' || platform === 'win64') { + // Windows archive name changed at r591479. + archiveName = parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; + executablePath = path.join(archiveName, 'chrome.exe'); + } + return { + downloadUrl: util.format((downloadURLs as any)[platform], options.host, revision, archiveName), + executablePath + }; + }); +} diff --git a/src/chromium/crNetworkManager.ts b/src/chromium/crNetworkManager.ts new file mode 100644 index 0000000000..19d970195c --- /dev/null +++ b/src/chromium/crNetworkManager.ts @@ -0,0 +1,452 @@ +/** + * 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 { CRSession } from './crConnection'; +import { Page } from '../page'; +import { assert, debugError, helper, RegisteredListener } from '../helper'; +import { Protocol } from './protocol'; +import * as network from '../network'; +import * as frames from '../frames'; + +export class CRNetworkManager { + private _client: CRSession; + private _page: Page; + private _requestIdToRequest = new Map(); + private _requestIdToRequestWillBeSentEvent = new Map(); + private _offline = false; + private _credentials: {username: string, password: string} | null = null; + private _attemptedAuthentications = new Set(); + private _userRequestInterceptionEnabled = false; + private _protocolRequestInterceptionEnabled = false; + private _userCacheDisabled = false; + private _requestIdToInterceptionId = new Map(); + private _eventListeners: RegisteredListener[]; + + constructor(client: CRSession, page: Page) { + this._client = client; + this._page = page; + + this._eventListeners = [ + helper.addEventListener(client, 'Fetch.requestPaused', this._onRequestPaused.bind(this)), + helper.addEventListener(client, 'Fetch.authRequired', this._onAuthRequired.bind(this)), + helper.addEventListener(client, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)), + helper.addEventListener(client, 'Network.responseReceived', this._onResponseReceived.bind(this)), + helper.addEventListener(client, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), + helper.addEventListener(client, 'Network.loadingFailed', this._onLoadingFailed.bind(this)), + ]; + } + + async initialize() { + await this._client.send('Network.enable'); + } + + dispose() { + helper.removeEventListeners(this._eventListeners); + } + + async authenticate(credentials: { username: string; password: string; } | null) { + this._credentials = credentials; + await this._updateProtocolRequestInterception(); + } + + async setOfflineMode(value: boolean) { + if (this._offline === value) + return; + this._offline = value; + await this._client.send('Network.emulateNetworkConditions', { + offline: this._offline, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1 + }); + } + + async setUserAgent(userAgent: string) { + await this._client.send('Network.setUserAgentOverride', { userAgent }); + } + + async setCacheEnabled(enabled: boolean) { + this._userCacheDisabled = !enabled; + await this._updateProtocolCacheDisabled(); + } + + async setRequestInterception(value: boolean) { + this._userRequestInterceptionEnabled = value; + await this._updateProtocolRequestInterception(); + } + + async _updateProtocolRequestInterception() { + const enabled = this._userRequestInterceptionEnabled || !!this._credentials; + if (enabled === this._protocolRequestInterceptionEnabled) + return; + this._protocolRequestInterceptionEnabled = enabled; + if (enabled) { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{urlPattern: '*'}], + }), + ]); + } else { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.disable') + ]); + } + } + + async _updateProtocolCacheDisabled() { + await this._client.send('Network.setCacheDisabled', { + cacheDisabled: this._userCacheDisabled || this._protocolRequestInterceptionEnabled + }); + } + + _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { + // Request interception doesn't happen for data URLs with Network Service. + if (this._protocolRequestInterceptionEnabled && !event.request.url.startsWith('data:')) { + const requestId = event.requestId; + const interceptionId = this._requestIdToInterceptionId.get(requestId); + if (interceptionId) { + this._onRequest(event, interceptionId); + this._requestIdToInterceptionId.delete(requestId); + } else { + this._requestIdToRequestWillBeSentEvent.set(event.requestId, event); + } + return; + } + this._onRequest(event, null); + } + + _onAuthRequired(event: Protocol.Fetch.authRequiredPayload) { + let response: 'Default' | 'CancelAuth' | 'ProvideCredentials' = 'Default'; + if (this._attemptedAuthentications.has(event.requestId)) { + response = 'CancelAuth'; + } else if (this._credentials) { + response = 'ProvideCredentials'; + this._attemptedAuthentications.add(event.requestId); + } + const {username, password} = this._credentials || {username: undefined, password: undefined}; + this._client.send('Fetch.continueWithAuth', { + requestId: event.requestId, + authChallengeResponse: { response, username, password }, + }).catch(debugError); + } + + _onRequestPaused(event: Protocol.Fetch.requestPausedPayload) { + if (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled) { + this._client.send('Fetch.continueRequest', { + requestId: event.requestId + }).catch(debugError); + } + + const requestId = event.networkId; + const interceptionId = event.requestId; + if (requestId && this._requestIdToRequestWillBeSentEvent.has(requestId)) { + const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId); + this._onRequest(requestWillBeSentEvent, interceptionId); + this._requestIdToRequestWillBeSentEvent.delete(requestId); + } else { + this._requestIdToInterceptionId.set(requestId, interceptionId); + } + } + + _onRequest(event: Protocol.Network.requestWillBeSentPayload, interceptionId: string | null) { + let redirectChain: network.Request[] = []; + if (event.redirectResponse) { + const request = this._requestIdToRequest.get(event.requestId); + // If we connect late to the target, we could have missed the requestWillBeSent event. + if (request) { + this._handleRequestRedirect(request, event.redirectResponse); + redirectChain = request.request._redirectChain; + } + } + // TODO: how can frame be null here? + const frame = event.frameId ? this._page._frameManager.frame(event.frameId) : null; + const isNavigationRequest = event.requestId === event.loaderId && event.type === 'Document'; + const documentId = isNavigationRequest ? event.loaderId : undefined; + const request = new InterceptableRequest(this._client, frame, interceptionId, documentId, this._userRequestInterceptionEnabled, event, redirectChain); + this._requestIdToRequest.set(event.requestId, request); + this._page._frameManager.requestStarted(request.request); + } + + _createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response): network.Response { + const remoteAddress: network.RemoteAddress = { ip: responsePayload.remoteIPAddress, port: responsePayload.remotePort }; + const getResponseBody = async () => { + const response = await this._client.send('Network.getResponseBody', { requestId: request._requestId }); + return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + }; + return new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObject(responsePayload.headers), remoteAddress, getResponseBody); + } + + _handleRequestRedirect(request: InterceptableRequest, responsePayload: Protocol.Network.Response) { + const response = this._createResponse(request, responsePayload); + request.request._redirectChain.push(request.request); + response._requestFinished(new Error('Response body is unavailable for redirect responses')); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this._page._frameManager.requestReceivedResponse(response); + this._page._frameManager.requestFinished(request.request); + } + + _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // FileUpload sends a response without a matching request. + if (!request) + return; + const response = this._createResponse(request, event.response); + this._page._frameManager.requestReceivedResponse(response); + } + + _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) + return; + + // Under certain conditions we never get the Network.responseReceived + // event from protocol. @see https://crbug.com/883475 + if (request.request.response()) + request.request.response()._requestFinished(); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this._page._frameManager.requestFinished(request.request); + } + + _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) + return; + const response = request.request.response(); + if (response) + response._requestFinished(); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + request.request._setFailureText(event.errorText); + this._page._frameManager.requestFailed(request.request, event.canceled); + } +} + +const interceptableRequestSymbol = Symbol('interceptableRequest'); + +export function toInterceptableRequest(request: network.Request): InterceptableRequest { + return (request as any)[interceptableRequestSymbol]; +} + +class InterceptableRequest { + readonly request: network.Request; + _requestId: string; + _interceptionId: string; + _documentId: string; + private _client: CRSession; + private _allowInterception: boolean; + private _interceptionHandled = false; + + constructor(client: CRSession, frame: frames.Frame | null, interceptionId: string, documentId: string | undefined, allowInterception: boolean, event: Protocol.Network.requestWillBeSentPayload, redirectChain: network.Request[]) { + this._client = client; + this._requestId = event.requestId; + this._interceptionId = interceptionId; + this._documentId = documentId; + this._allowInterception = allowInterception; + + this.request = new network.Request(frame, redirectChain, documentId, + event.request.url, event.type.toLowerCase(), event.request.method, event.request.postData, headersObject(event.request.headers)); + (this.request as any)[interceptableRequestSymbol] = this; + } + + async continue(overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { + // Request interception is not supported for data: urls. + if (this.request.url().startsWith('data:')) + return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + const { + url, + method, + postData, + headers + } = overrides; + this._interceptionHandled = true; + await this._client.send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData, + headers: headers ? headersArray(headers) : undefined, + }).catch(error => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } + + async fulfill(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { + // Mocking responses for dataURL requests is not currently supported. + if (this.request.url().startsWith('data:')) + return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + + const responseBody = response.body && helper.isString(response.body) ? Buffer.from(/** @type {string} */(response.body)) : /** @type {?Buffer} */(response.body || null); + + const responseHeaders: { [s: string]: string; } = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = response.headers[header]; + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) + responseHeaders['content-length'] = String(Buffer.byteLength(responseBody)); + + await this._client.send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: response.status || 200, + responsePhrase: STATUS_TEXTS[String(response.status || 200)], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }).catch(error => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } + + async abort(errorCode: string = 'failed') { + // Request interception is not supported for data: urls. + if (this.request.url().startsWith('data:')) + return; + const errorReason = errorReasons[errorCode]; + assert(errorReason, 'Unknown error code: ' + errorCode); + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._client.send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason + }).catch(error => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } +} + +const errorReasons: { [reason: string]: Protocol.Network.ErrorReason } = { + 'aborted': 'Aborted', + 'accessdenied': 'AccessDenied', + 'addressunreachable': 'AddressUnreachable', + 'blockedbyclient': 'BlockedByClient', + 'blockedbyresponse': 'BlockedByResponse', + 'connectionaborted': 'ConnectionAborted', + 'connectionclosed': 'ConnectionClosed', + 'connectionfailed': 'ConnectionFailed', + 'connectionrefused': 'ConnectionRefused', + 'connectionreset': 'ConnectionReset', + 'internetdisconnected': 'InternetDisconnected', + 'namenotresolved': 'NameNotResolved', + 'timedout': 'TimedOut', + 'failed': 'Failed', +}; + +function headersArray(headers: { [s: string]: string; }): { name: string; value: string; }[] { + const result = []; + for (const name in headers) { + if (!Object.is(headers[name], undefined)) + result.push({name, value: headers[name] + ''}); + } + return result; +} + +function headersObject(headers: Protocol.Network.Headers): network.Headers { + const result: network.Headers = {}; + for (const key of Object.keys(headers)) + result[key.toLowerCase()] = headers[key]; + return result; +} + +// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. +const STATUS_TEXTS: { [status: string]: string } = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': 'I\'m a teapot', + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +}; diff --git a/src/chromium/crPlaywright.ts b/src/chromium/crPlaywright.ts new file mode 100644 index 0000000000..d14f3bb256 --- /dev/null +++ b/src/chromium/crPlaywright.ts @@ -0,0 +1,131 @@ +/** + * 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 * as http from 'http'; +import * as https from 'https'; +import * as URL from 'url'; +import * as browsers from '../browser'; +import { BrowserFetcher, BrowserFetcherOptions, BrowserFetcherRevisionInfo, OnProgressCallback } from '../browserFetcher'; +import { DeviceDescriptor, DeviceDescriptors } from '../deviceDescriptors'; +import * as Errors from '../errors'; +import { assert } from '../helper'; +import { ConnectionTransport, WebSocketTransport, SlowMoTransport } from '../transport'; +import { ConnectionOptions, createBrowserFetcher, CRLauncher, LauncherChromeArgOptions, LauncherLaunchOptions } from './crLauncher'; +import { CRBrowser } from './crBrowser'; + +type Devices = { [name: string]: DeviceDescriptor } & DeviceDescriptor[]; + +export class CRPlaywright { + private _projectRoot: string; + private _launcher: CRLauncher; + readonly _revision: string; + + constructor(projectRoot: string, preferredRevision: string) { + this._projectRoot = projectRoot; + this._launcher = new CRLauncher(projectRoot, preferredRevision); + this._revision = preferredRevision; + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + const fetcher = this.createBrowserFetcher(options); + const revisionInfo = fetcher.revisionInfo(this._revision); + await fetcher.download(this._revision, options ? options.onProgress : undefined); + return revisionInfo; + } + + async launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & ConnectionOptions) | undefined): Promise { + const server = await this._launcher.launch(options); + return server.connect(); + } + + async launchServer(options: (LauncherLaunchOptions & LauncherChromeArgOptions & ConnectionOptions) = {}): Promise> { + return this._launcher.launch(options); + } + + async connect(options: (ConnectionOptions & { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; })): Promise { + assert(Number(!!options.browserWSEndpoint) + Number(!!options.browserURL) + Number(!!options.transport) === 1, 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to playwright.connect'); + + let transport: ConnectionTransport | undefined; + let connectionURL: string = ''; + if (options.transport) { + transport = options.transport; + } else if (options.browserWSEndpoint) { + connectionURL = options.browserWSEndpoint; + transport = await WebSocketTransport.create(options.browserWSEndpoint); + } else if (options.browserURL) { + connectionURL = await getWSEndpoint(options.browserURL); + transport = await WebSocketTransport.create(connectionURL); + } + return CRBrowser.create(SlowMoTransport.wrap(transport, options.slowMo)); + } + + executablePath(): string { + return this._launcher.executablePath(); + } + + get devices(): Devices { + const result = DeviceDescriptors.slice() as Devices; + for (const device of DeviceDescriptors) + result[device.name] = device; + return result; + } + + get errors(): any { + return Errors; + } + + defaultArgs(options: LauncherChromeArgOptions | undefined): string[] { + return this._launcher.defaultArgs(options); + } + + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); + } +} + +function getWSEndpoint(browserURL: string): Promise { + let resolve: (url: string) => void; + let reject: (e: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + + const endpointURL = URL.resolve(browserURL, '/json/version'); + const protocol = endpointURL.startsWith('https') ? https : http; + const requestOptions = Object.assign(URL.parse(endpointURL), { method: 'GET' }); + const request = protocol.request(requestOptions, res => { + let data = ''; + if (res.statusCode !== 200) { + // Consume response data to free up memory. + res.resume(); + reject(new Error('HTTP ' + res.statusCode)); + return; + } + res.setEncoding('utf8'); + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data).webSocketDebuggerUrl)); + }); + + request.on('error', reject); + request.end(); + + return promise.catch(e => { + e.message = `Failed to fetch browser webSocket url from ${endpointURL}: ` + e.message; + throw e; + }); +} \ No newline at end of file diff --git a/src/chromium/crTarget.ts b/src/chromium/crTarget.ts new file mode 100644 index 0000000000..f49b4ece52 --- /dev/null +++ b/src/chromium/crTarget.ts @@ -0,0 +1,147 @@ +/** + * Copyright 2019 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 { CRBrowser } from './crBrowser'; +import { BrowserContext } from '../browserContext'; +import { CRSession, CRSessionEvents } from './crConnection'; +import { Events } from '../events'; +import { Worker } from './features/crWorkers'; +import { Page } from '../page'; +import { Protocol } from './protocol'; +import { debugError } from '../helper'; +import { CRFrameManager } from './crFrameManager'; + +const targetSymbol = Symbol('target'); + +export class CRTarget { + private _targetInfo: Protocol.Target.TargetInfo; + private _browser: CRBrowser; + private _browserContext: BrowserContext; + _targetId: string; + private _sessionFactory: () => Promise; + private _pagePromise: Promise | null = null; + private _frameManager: CRFrameManager | null = null; + private _workerPromise: Promise | null = null; + _initializedPromise: Promise; + _initializedCallback: (value?: unknown) => void; + _isInitialized: boolean; + + static fromPage(page: Page): CRTarget { + return (page as any)[targetSymbol]; + } + + constructor( + browser: CRBrowser, + targetInfo: Protocol.Target.TargetInfo, + browserContext: BrowserContext, + sessionFactory: () => Promise) { + this._targetInfo = targetInfo; + this._browser = browser; + this._browserContext = browserContext; + this._targetId = targetInfo.targetId; + this._sessionFactory = sessionFactory; + this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => { + if (!success) + return false; + const opener = this.opener(); + if (!opener || !opener._pagePromise || this.type() !== 'page') + return true; + const openerPage = await opener._pagePromise; + if (!openerPage.listenerCount(Events.Page.Popup)) + return true; + const popupPage = await this.page(); + openerPage.emit(Events.Page.Popup, popupPage); + return true; + }); + this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; + if (this._isInitialized) + this._initializedCallback(true); + } + + _didClose() { + if (this._frameManager) + this._frameManager.didClose(); + } + + async page(): Promise { + if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { + this._pagePromise = this._sessionFactory().then(async client => { + this._frameManager = new CRFrameManager(client, this._browser, this._browserContext); + const page = this._frameManager.page(); + (page as any)[targetSymbol] = this; + client.once(CRSessionEvents.Disconnected, () => page._didDisconnect()); + client.on('Target.attachedToTarget', event => { + if (event.targetInfo.type !== 'worker') { + // If we don't detach from service workers, they will never die. + client.send('Target.detachFromTarget', { sessionId: event.sessionId }).catch(debugError); + } + }); + await this._frameManager.initialize(); + await client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}); + return page; + }); + } + return this._pagePromise; + } + + async _worker(): Promise { + if (this._targetInfo.type !== 'service_worker' && this._targetInfo.type !== 'shared_worker') + return null; + if (!this._workerPromise) { + // TODO(einbinder): Make workers send their console logs. + this._workerPromise = this._sessionFactory() + .then(client => new Worker(client, this._targetInfo.url, () => { } /* consoleAPICalled */, () => { } /* exceptionThrown */)); + } + return this._workerPromise; + } + + url(): string { + return this._targetInfo.url; + } + + type(): 'page' | 'background_page' | 'service_worker' | 'shared_worker' | 'other' | 'browser' { + const type = this._targetInfo.type; + if (type === 'page' || type === 'background_page' || type === 'service_worker' || type === 'shared_worker' || type === 'browser') + return type; + return 'other'; + } + + browserContext(): BrowserContext { + return this._browserContext; + } + + opener(): CRTarget | null { + const { openerId } = this._targetInfo; + if (!openerId) + return null; + return this._browser._targets.get(openerId); + } + + createCDPSession(): Promise { + return this._sessionFactory(); + } + + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo) { + this._targetInfo = targetInfo; + + if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) { + this._isInitialized = true; + this._initializedCallback(true); + return; + } + } +} diff --git a/src/chromium/features/crAccessibility.ts b/src/chromium/features/crAccessibility.ts new file mode 100644 index 0000000000..1ba9cab7d4 --- /dev/null +++ b/src/chromium/features/crAccessibility.ts @@ -0,0 +1,374 @@ +/** + * Copyright 2018 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 { CRSession } from '../crConnection'; +import { Protocol } from '../protocol'; +import * as dom from '../../dom'; + +type SerializedAXNode = { + role: string, + name?: string, + value?: string|number, + description?: string, + + keyshortcuts?: string, + roledescription?: string, + valuetext?: string, + + disabled?: boolean, + expanded?: boolean, + focused?: boolean, + modal?: boolean, + multiline?: boolean, + multiselectable?: boolean, + readonly?: boolean, + required?: boolean, + selected?: boolean, + + checked?: boolean|'mixed', + pressed?: boolean|'mixed', + + level?: number, + valuemin?: number, + valuemax?: number, + + autocomplete?: string, + haspopup?: string, + invalid?: string, + orientation?: string, + + children?: SerializedAXNode[] +}; + +export class CRAccessibility { + private _client: CRSession; + + constructor(client: CRSession) { + this._client = client; + } + + async snapshot(options: { + interestingOnly?: boolean; + root?: dom.ElementHandle | null; + } = {}): Promise { + const { + interestingOnly = true, + root = null, + } = options; + const {nodes} = await this._client.send('Accessibility.getFullAXTree'); + let backendNodeId: number | null = null; + if (root) { + const remoteObject = root._remoteObject as Protocol.Runtime.RemoteObject; + const {node} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId}); + backendNodeId = node.backendNodeId; + } + const defaultRoot = CRAXNode.createTree(nodes); + let needle = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId); + if (!needle) + return null; + } + if (!interestingOnly) + return serializeTree(needle)[0]; + + const interestingNodes: Set = new Set(); + collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) + return null; + return serializeTree(needle, interestingNodes)[0]; + } +} + +function collectInterestingNodes(collection: Set, node: CRAXNode, insideControl: boolean) { + if (node.isInteresting(insideControl)) + collection.add(node); + if (node.isLeafNode()) + return; + insideControl = insideControl || node.isControl(); + for (const child of node._children) + collectInterestingNodes(collection, child, insideControl); +} + +function serializeTree(node: CRAXNode, whitelistedNodes?: Set): SerializedAXNode[] { + const children: SerializedAXNode[] = []; + for (const child of node._children) + children.push(...serializeTree(child, whitelistedNodes)); + + if (whitelistedNodes && !whitelistedNodes.has(node)) + return children; + + const serializedNode = node.serialize(); + if (children.length) + serializedNode.children = children; + return [serializedNode]; +} + + +class CRAXNode { + _payload: Protocol.Accessibility.AXNode; + _children: CRAXNode[] = []; + private _richlyEditable = false; + private _editable = false; + private _focusable = false; + private _expanded = false; + private _hidden = false; + private _name: string; + private _role: string; + private _cachedHasFocusableChild: boolean | undefined; + + constructor(payload: Protocol.Accessibility.AXNode) { + this._payload = payload; + + this._name = this._payload.name ? this._payload.name.value : ''; + this._role = this._payload.role ? this._payload.role.value : 'Unknown'; + + for (const property of this._payload.properties || []) { + if (property.name === 'editable') { + this._richlyEditable = property.value.value === 'richtext'; + this._editable = true; + } + if (property.name === 'focusable') + this._focusable = property.value.value; + if (property.name === 'expanded') + this._expanded = property.value.value; + if (property.name === 'hidden') + this._hidden = property.value.value; + } + } + + private _isPlainTextField(): boolean { + if (this._richlyEditable) + return false; + if (this._editable) + return true; + return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox'; + } + + private _isTextOnlyObject(): boolean { + const role = this._role; + return (role === 'LineBreak' || role === 'text' || + role === 'InlineTextBox'); + } + + private _hasFocusableChild(): boolean { + if (this._cachedHasFocusableChild === undefined) { + this._cachedHasFocusableChild = false; + for (const child of this._children) { + if (child._focusable || child._hasFocusableChild()) { + this._cachedHasFocusableChild = true; + break; + } + } + } + return this._cachedHasFocusableChild; + } + + find(predicate: (arg0: CRAXNode) => boolean): CRAXNode | null { + if (predicate(this)) + return this; + for (const child of this._children) { + const result = child.find(predicate); + if (result) + return result; + } + return null; + } + + isLeafNode(): boolean { + if (!this._children.length) + return true; + + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (this._isPlainTextField() || this._isTextOnlyObject()) + return true; + + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (this._role) { + case 'doc-cover': + case 'graphics-symbol': + case 'img': + case 'Meter': + case 'scrollbar': + case 'slider': + case 'separator': + case 'progressbar': + return true; + default: + break; + } + + // Here and below: Android heuristics + if (this._hasFocusableChild()) + return false; + if (this._focusable && this._name) + return true; + if (this._role === 'heading' && this._name) + return true; + return false; + } + + isControl(): boolean { + switch (this._role) { + case 'button': + case 'checkbox': + case 'ColorWell': + case 'combobox': + case 'DisclosureTriangle': + case 'listbox': + case 'menu': + case 'menubar': + case 'menuitem': + case 'menuitemcheckbox': + case 'menuitemradio': + case 'radio': + case 'scrollbar': + case 'searchbox': + case 'slider': + case 'spinbutton': + case 'switch': + case 'tab': + case 'textbox': + case 'tree': + return true; + default: + return false; + } + } + + isInteresting(insideControl: boolean): boolean { + const role = this._role; + if (role === 'Ignored' || this._hidden) + return false; + + if (this._focusable || this._richlyEditable) + return true; + + // If it's not focusable but has a control role, then it's interesting. + if (this.isControl()) + return true; + + // A non focusable child of a control is not interesting + if (insideControl) + return false; + + return this.isLeafNode() && !!this._name; + } + + serialize(): SerializedAXNode { + const properties: Map = new Map(); + for (const property of this._payload.properties || []) + properties.set(property.name.toLowerCase(), property.value.value); + if (this._payload.name) + properties.set('name', this._payload.name.value); + if (this._payload.value) + properties.set('value', this._payload.value.value); + if (this._payload.description) + properties.set('description', this._payload.description.value); + + const node: {[x in keyof SerializedAXNode]: any} = { + role: this._role + }; + + const userStringProperties: Array = [ + 'name', + 'value', + 'description', + 'keyshortcuts', + 'roledescription', + 'valuetext', + ]; + for (const userStringProperty of userStringProperties) { + if (!properties.has(userStringProperty)) + continue; + node[userStringProperty] = properties.get(userStringProperty); + } + + const booleanProperties: Array = [ + 'disabled', + 'expanded', + 'focused', + 'modal', + 'multiline', + 'multiselectable', + 'readonly', + 'required', + 'selected', + ]; + for (const booleanProperty of booleanProperties) { + // WebArea's treat focus differently than other nodes. They report whether their frame has focus, + // not whether focus is specifically on the root node. + if (booleanProperty === 'focused' && this._role === 'WebArea') + continue; + const value = properties.get(booleanProperty); + if (!value) + continue; + node[booleanProperty] = value; + } + + const tristateProperties: Array = [ + 'checked', + 'pressed', + ]; + for (const tristateProperty of tristateProperties) { + if (!properties.has(tristateProperty)) + continue; + const value = properties.get(tristateProperty); + node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false; + } + const numericalProperties: Array = [ + 'level', + 'valuemax', + 'valuemin', + ]; + for (const numericalProperty of numericalProperties) { + if (!properties.has(numericalProperty)) + continue; + node[numericalProperty] = properties.get(numericalProperty); + } + const tokenProperties: Array = [ + 'autocomplete', + 'haspopup', + 'invalid', + 'orientation', + ]; + for (const tokenProperty of tokenProperties) { + const value = properties.get(tokenProperty); + if (!value || value === 'false') + continue; + node[tokenProperty] = value; + } + return node as SerializedAXNode; + } + + static createTree(payloads: Protocol.Accessibility.AXNode[]): CRAXNode { + const nodeById: Map = new Map(); + for (const payload of payloads) + nodeById.set(payload.nodeId, new CRAXNode(payload)); + for (const node of nodeById.values()) { + for (const childId of node._payload.childIds || []) + node._children.push(nodeById.get(childId)); + } + return nodeById.values().next().value; + } +} diff --git a/src/chromium/features/crCoverage.ts b/src/chromium/features/crCoverage.ts new file mode 100644 index 0000000000..2e85d49cd3 --- /dev/null +++ b/src/chromium/features/crCoverage.ts @@ -0,0 +1,297 @@ +/** + * 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 { CRSession } from '../crConnection'; +import { assert, debugError, helper, RegisteredListener } from '../../helper'; +import { Protocol } from '../protocol'; + +import { EVALUATION_SCRIPT_URL } from '../crExecutionContext'; + +type CoverageEntry = { + url: string, + text: string, + ranges : {start: number, end: number}[] +}; + +export class CRCoverage { + private _jsCoverage: JSCoverage; + private _cssCoverage: CSSCoverage; + + constructor(client: CRSession) { + this._jsCoverage = new JSCoverage(client); + this._cssCoverage = new CSSCoverage(client); + } + + async startJSCoverage(options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + }) { + return await this._jsCoverage.start(options); + } + + async stopJSCoverage(): Promise { + return await this._jsCoverage.stop(); + } + + async startCSSCoverage(options: { resetOnNavigation?: boolean; } = {}) { + return await this._cssCoverage.start(options); + } + + async stopCSSCoverage(): Promise { + return await this._cssCoverage.stop(); + } +} + +class JSCoverage { + _client: CRSession; + _enabled: boolean; + _scriptURLs: Map; + _scriptSources: Map; + _eventListeners: RegisteredListener[]; + _resetOnNavigation: boolean; + _reportAnonymousScripts: boolean; + + constructor(client: CRSession) { + this._client = client; + this._enabled = false; + this._scriptURLs = new Map(); + this._scriptSources = new Map(); + this._eventListeners = []; + this._resetOnNavigation = false; + } + + async start(options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + } = {}) { + assert(!this._enabled, 'JSCoverage is already enabled'); + const { + resetOnNavigation = true, + reportAnonymousScripts = false + } = options; + this._resetOnNavigation = resetOnNavigation; + this._reportAnonymousScripts = reportAnonymousScripts; + this._enabled = true; + this._scriptURLs.clear(); + this._scriptSources.clear(); + this._eventListeners = [ + helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)), + helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), + ]; + this._client.on('Debugger.paused', () => this._client.send('Debugger.resume')); + await Promise.all([ + this._client.send('Profiler.enable'), + this._client.send('Profiler.startPreciseCoverage', {callCount: false, detailed: true}), + this._client.send('Debugger.enable'), + this._client.send('Debugger.setSkipAllPauses', {skip: true}) + ]); + } + + _onExecutionContextsCleared() { + if (!this._resetOnNavigation) + return; + this._scriptURLs.clear(); + this._scriptSources.clear(); + } + + async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) { + // Ignore playwright-injected scripts + if (event.url === EVALUATION_SCRIPT_URL) + return; + // Ignore other anonymous scripts unless the reportAnonymousScripts option is true. + if (!event.url && !this._reportAnonymousScripts) + return; + try { + const response = await this._client.send('Debugger.getScriptSource', {scriptId: event.scriptId}); + this._scriptURLs.set(event.scriptId, event.url); + this._scriptSources.set(event.scriptId, response.scriptSource); + } catch (e) { + // This might happen if the page has already navigated away. + debugError(e); + } + } + + async stop(): Promise { + assert(this._enabled, 'JSCoverage is not enabled'); + this._enabled = false; + const [profileResponse] = await Promise.all([ + this._client.send('Profiler.takePreciseCoverage'), + this._client.send('Profiler.stopPreciseCoverage'), + this._client.send('Profiler.disable'), + this._client.send('Debugger.disable'), + ]); + helper.removeEventListeners(this._eventListeners); + + const coverage = []; + for (const entry of profileResponse.result) { + let url = this._scriptURLs.get(entry.scriptId); + if (!url && this._reportAnonymousScripts) + url = 'debugger://VM' + entry.scriptId; + const text = this._scriptSources.get(entry.scriptId); + if (text === undefined || url === undefined) + continue; + const flattenRanges = []; + for (const func of entry.functions) + flattenRanges.push(...func.ranges); + const ranges = convertToDisjointRanges(flattenRanges); + coverage.push({url, ranges, text}); + } + return coverage; + } +} + +class CSSCoverage { + _client: CRSession; + _enabled: boolean; + _stylesheetURLs: Map; + _stylesheetSources: Map; + _eventListeners: RegisteredListener[]; + _resetOnNavigation: boolean; + + constructor(client: CRSession) { + this._client = client; + this._enabled = false; + this._stylesheetURLs = new Map(); + this._stylesheetSources = new Map(); + this._eventListeners = []; + this._resetOnNavigation = false; + } + + async start(options: { resetOnNavigation?: boolean; } = {}) { + assert(!this._enabled, 'CSSCoverage is already enabled'); + const {resetOnNavigation = true} = options; + this._resetOnNavigation = resetOnNavigation; + this._enabled = true; + this._stylesheetURLs.clear(); + this._stylesheetSources.clear(); + this._eventListeners = [ + helper.addEventListener(this._client, 'CSS.styleSheetAdded', this._onStyleSheet.bind(this)), + helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), + ]; + await Promise.all([ + this._client.send('DOM.enable'), + this._client.send('CSS.enable'), + this._client.send('CSS.startRuleUsageTracking'), + ]); + } + + _onExecutionContextsCleared() { + if (!this._resetOnNavigation) + return; + this._stylesheetURLs.clear(); + this._stylesheetSources.clear(); + } + + async _onStyleSheet(event: Protocol.CSS.styleSheetAddedPayload) { + const header = event.header; + // Ignore anonymous scripts + if (!header.sourceURL) + return; + try { + const response = await this._client.send('CSS.getStyleSheetText', {styleSheetId: header.styleSheetId}); + this._stylesheetURLs.set(header.styleSheetId, header.sourceURL); + this._stylesheetSources.set(header.styleSheetId, response.text); + } catch (e) { + // This might happen if the page has already navigated away. + debugError(e); + } + } + + async stop(): Promise { + assert(this._enabled, 'CSSCoverage is not enabled'); + this._enabled = false; + const ruleTrackingResponse = await this._client.send('CSS.stopRuleUsageTracking'); + await Promise.all([ + this._client.send('CSS.disable'), + this._client.send('DOM.disable'), + ]); + helper.removeEventListeners(this._eventListeners); + + // aggregate by styleSheetId + const styleSheetIdToCoverage = new Map(); + for (const entry of ruleTrackingResponse.ruleUsage) { + let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); + if (!ranges) { + ranges = []; + styleSheetIdToCoverage.set(entry.styleSheetId, ranges); + } + ranges.push({ + startOffset: entry.startOffset, + endOffset: entry.endOffset, + count: entry.used ? 1 : 0, + }); + } + + const coverage = []; + for (const styleSheetId of this._stylesheetURLs.keys()) { + const url = this._stylesheetURLs.get(styleSheetId); + const text = this._stylesheetSources.get(styleSheetId); + const ranges = convertToDisjointRanges(styleSheetIdToCoverage.get(styleSheetId) || []); + coverage.push({url, ranges, text}); + } + + return coverage; + } +} + +function convertToDisjointRanges(nestedRanges: { + startOffset: number; + endOffset: number; + count: number; }[]): { start: number; end: number; }[] { + const points = []; + for (const range of nestedRanges) { + points.push({ offset: range.startOffset, type: 0, range }); + points.push({ offset: range.endOffset, type: 1, range }); + } + // Sort points to form a valid parenthesis sequence. + points.sort((a, b) => { + // Sort with increasing offsets. + if (a.offset !== b.offset) + return a.offset - b.offset; + // All "end" points should go before "start" points. + if (a.type !== b.type) + return b.type - a.type; + const aLength = a.range.endOffset - a.range.startOffset; + const bLength = b.range.endOffset - b.range.startOffset; + // For two "start" points, the one with longer range goes first. + if (a.type === 0) + return bLength - aLength; + // For two "end" points, the one with shorter range goes first. + return aLength - bLength; + }); + + const hitCountStack = []; + const results = []; + let lastOffset = 0; + // Run scanning line to intersect all ranges. + for (const point of points) { + if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) { + const lastResult = results.length ? results[results.length - 1] : null; + if (lastResult && lastResult.end === lastOffset) + lastResult.end = point.offset; + else + results.push({start: lastOffset, end: point.offset}); + } + lastOffset = point.offset; + if (point.type === 0) + hitCountStack.push(point.range.count); + else + hitCountStack.pop(); + } + // Filter out empty ranges. + return results.filter(range => range.end - range.start > 1); +} diff --git a/src/chromium/features/crInterception.ts b/src/chromium/features/crInterception.ts new file mode 100644 index 0000000000..b54f7a3c7c --- /dev/null +++ b/src/chromium/features/crInterception.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { CRNetworkManager, toInterceptableRequest } from '../crNetworkManager'; +import * as network from '../../network'; + +export class CRInterception { + private _networkManager: CRNetworkManager; + + constructor(networkManager: CRNetworkManager) { + this._networkManager = networkManager; + } + + async enable() { + await this._networkManager.setRequestInterception(true); + } + + async disable() { + await this._networkManager.setRequestInterception(false); + } + + async continue(request: network.Request, overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { + return toInterceptableRequest(request).continue(overrides); + } + + async fulfill(request: network.Request, response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { + return toInterceptableRequest(request).fulfill(response); + } + + async abort(request: network.Request, errorCode: string = 'failed') { + return toInterceptableRequest(request).abort(errorCode); + } + + setOfflineMode(enabled: boolean) { + return this._networkManager.setOfflineMode(enabled); + } + + async authenticate(credentials: { username: string; password: string; } | null) { + return this._networkManager.authenticate(credentials); + } +} diff --git a/src/chromium/features/crOverrides.ts b/src/chromium/features/crOverrides.ts new file mode 100644 index 0000000000..3cd4a8bb13 --- /dev/null +++ b/src/chromium/features/crOverrides.ts @@ -0,0 +1,54 @@ +/** + * 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 { BrowserContext } from '../../browserContext'; +import { CRFrameManager } from '../crFrameManager'; +import { Page } from '../api'; + +export class CROverrides { + private _context: BrowserContext; + private _geolocation: { longitude?: number; latitude?: number; accuracy?: number; } | null = null; + + constructor(context: BrowserContext) { + this._context = context; + } + + async setGeolocation(options: { longitude?: number; latitude?: number; accuracy?: (number | undefined); } | null) { + if (!options) { + for (const page of await this._context.pages()) + await (page._delegate as CRFrameManager)._client.send('Emulation.clearGeolocationOverride', {}); + this._geolocation = null; + return; + } + + const { longitude, latitude, accuracy = 0} = options; + if (longitude < -180 || longitude > 180) + throw new Error(`Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`); + if (latitude < -90 || latitude > 90) + throw new Error(`Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`); + if (accuracy < 0) + throw new Error(`Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`); + this._geolocation = { longitude, latitude, accuracy }; + for (const page of await this._context.pages()) + await (page._delegate as CRFrameManager)._client.send('Emulation.setGeolocationOverride', this._geolocation); + } + + async _applyOverrides(page: Page): Promise { + if (this._geolocation) + await (page._delegate as CRFrameManager)._client.send('Emulation.setGeolocationOverride', this._geolocation); + } +} diff --git a/src/chromium/features/crPdf.ts b/src/chromium/features/crPdf.ts new file mode 100644 index 0000000000..59607cf9d3 --- /dev/null +++ b/src/chromium/features/crPdf.ts @@ -0,0 +1,144 @@ +/** + * 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 { assert, helper } from '../../helper'; +import { CRSession } from '../crConnection'; +import { readProtocolStream } from '../protocolHelper'; + +type PDFOptions = { + scale?: number, + displayHeaderFooter?: boolean, + headerTemplate?: string, + footerTemplate?: string, + printBackground?: boolean, + landscape?: boolean, + pageRanges?: string, + format?: string, + width?: string|number, + height?: string|number, + preferCSSPageSize?: boolean, + margin?: {top?: string|number, bottom?: string|number, left?: string|number, right?: string|number}, + path?: string, +} + +const PagePaperFormats: { [key: string]: { width: number, height: number }} = { + letter: {width: 8.5, height: 11}, + legal: {width: 8.5, height: 14}, + tabloid: {width: 11, height: 17}, + ledger: {width: 17, height: 11}, + a0: {width: 33.1, height: 46.8 }, + a1: {width: 23.4, height: 33.1 }, + a2: {width: 16.54, height: 23.4 }, + a3: {width: 11.7, height: 16.54 }, + a4: {width: 8.27, height: 11.7 }, + a5: {width: 5.83, height: 8.27 }, + a6: {width: 4.13, height: 5.83 }, +}; + +const unitToPixels: { [key: string]: number } = { + 'px': 1, + 'in': 96, + 'cm': 37.8, + 'mm': 3.78 +}; + +function convertPrintParameterToInches(parameter: (string | number | undefined)): (number | undefined) { + if (typeof parameter === 'undefined') + return undefined; + let pixels: number; + if (helper.isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = parameter as number; + } else if (helper.isString(parameter)) { + const text: string = parameter as string; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unitToPixels.hasOwnProperty(unit)) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + pixels = value * unitToPixels[unit]; + } else { + throw new Error('page.pdf() Cannot handle parameter type: ' + (typeof parameter)); + } + return pixels / 96; +} + +export class CRPdf { + private _client: CRSession; + + constructor(client: CRSession) { + this._client = client; + } + + async generate(options: PDFOptions = {}): Promise { + const { + scale = 1, + displayHeaderFooter = false, + headerTemplate = '', + footerTemplate = '', + printBackground = false, + landscape = false, + pageRanges = '', + preferCSSPageSize = false, + margin = {}, + path = null + } = options; + + let paperWidth = 8.5; + let paperHeight = 11; + if (options.format) { + const format = PagePaperFormats[options.format.toLowerCase()]; + assert(format, 'Unknown paper format: ' + options.format); + paperWidth = format.width; + paperHeight = format.height; + } else { + paperWidth = convertPrintParameterToInches(options.width) || paperWidth; + paperHeight = convertPrintParameterToInches(options.height) || paperHeight; + } + + const marginTop = convertPrintParameterToInches(margin.top) || 0; + const marginLeft = convertPrintParameterToInches(margin.left) || 0; + const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; + const marginRight = convertPrintParameterToInches(margin.right) || 0; + + const result = await this._client.send('Page.printToPDF', { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop, + marginBottom, + marginLeft, + marginRight, + pageRanges, + preferCSSPageSize + }); + return await readProtocolStream(this._client, result.stream, path); + } +} diff --git a/src/chromium/features/crPermissions.ts b/src/chromium/features/crPermissions.ts new file mode 100644 index 0000000000..3f17430b07 --- /dev/null +++ b/src/chromium/features/crPermissions.ts @@ -0,0 +1,61 @@ +/** + * 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 { Protocol } from '../protocol'; +import { CRSession } from '../crConnection'; + +export class CRPermissions { + private _client: CRSession; + private _browserContextId: string; + + constructor(client: CRSession, browserContextId: string | null) { + this._client = client; + this._browserContextId = browserContextId; + } + + async override(origin: string, permissions: string[]) { + const webPermissionToProtocol = new Map([ + ['geolocation', 'geolocation'], + ['midi', 'midi'], + ['notifications', 'notifications'], + ['camera', 'videoCapture'], + ['microphone', 'audioCapture'], + ['background-sync', 'backgroundSync'], + ['ambient-light-sensor', 'sensors'], + ['accelerometer', 'sensors'], + ['gyroscope', 'sensors'], + ['magnetometer', 'sensors'], + ['accessibility-events', 'accessibilityEvents'], + ['clipboard-read', 'clipboardReadWrite'], + ['clipboard-write', 'clipboardSanitizedWrite'], + ['payment-handler', 'paymentHandler'], + // chrome-specific permissions we have. + ['midi-sysex', 'midiSysex'], + ]); + const filtered = permissions.map(permission => { + const protocolPermission = webPermissionToProtocol.get(permission); + if (!protocolPermission) + throw new Error('Unknown permission: ' + permission); + return protocolPermission; + }); + await this._client.send('Browser.grantPermissions', {origin, browserContextId: this._browserContextId || undefined, permissions: filtered}); + } + + async clearOverrides() { + await this._client.send('Browser.resetPermissions', {browserContextId: this._browserContextId || undefined}); + } +} diff --git a/src/chromium/features/crWorkers.ts b/src/chromium/features/crWorkers.ts new file mode 100644 index 0000000000..f7650509db --- /dev/null +++ b/src/chromium/features/crWorkers.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2018 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 { EventEmitter } from 'events'; +import { CRSession, CRConnection } from '../crConnection'; +import { debugError } from '../../helper'; +import { Protocol } from '../protocol'; +import { Events } from '../events'; +import * as types from '../../types'; +import * as js from '../../javascript'; +import * as console from '../../console'; +import { CRExecutionContext } from '../crExecutionContext'; +import { toConsoleMessageLocation, exceptionToError } from '../protocolHelper'; + +type AddToConsoleCallback = (type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) => void; +type HandleExceptionCallback = (error: Error) => void; + +export class CRWorkers extends EventEmitter { + private _workers = new Map(); + + constructor(client: CRSession, addToConsole: AddToConsoleCallback, handleException: HandleExceptionCallback) { + super(); + + client.on('Target.attachedToTarget', event => { + if (event.targetInfo.type !== 'worker') + return; + const session = CRConnection.fromSession(client).session(event.sessionId); + const worker = new Worker(session, event.targetInfo.url, addToConsole, handleException); + this._workers.set(event.sessionId, worker); + this.emit(Events.Workers.WorkerCreated, worker); + }); + client.on('Target.detachedFromTarget', event => { + const worker = this._workers.get(event.sessionId); + if (!worker) + return; + this.emit(Events.Workers.WorkerDestroyed, worker); + this._workers.delete(event.sessionId); + }); + } + + list(): Worker[] { + return Array.from(this._workers.values()); + } +} + +export class Worker extends EventEmitter { + private _client: CRSession; + private _url: string; + private _executionContextPromise: Promise; + private _executionContextCallback: (value?: js.ExecutionContext) => void; + + constructor(client: CRSession, url: string, addToConsole: AddToConsoleCallback, handleException: HandleExceptionCallback) { + super(); + this._client = client; + this._url = url; + this._executionContextPromise = new Promise(x => this._executionContextCallback = x); + let jsHandleFactory: (o: Protocol.Runtime.RemoteObject) => js.JSHandle; + this._client.once('Runtime.executionContextCreated', async event => { + jsHandleFactory = remoteObject => executionContext._createHandle(remoteObject); + const executionContext = new js.ExecutionContext(new CRExecutionContext(client, event.context)); + this._executionContextCallback(executionContext); + }); + // This might fail if the target is closed before we recieve all execution contexts. + this._client.send('Runtime.enable', {}).catch(debugError); + + this._client.on('Runtime.consoleAPICalled', event => addToConsole(event.type, event.args.map(jsHandleFactory), toConsoleMessageLocation(event.stackTrace))); + this._client.on('Runtime.exceptionThrown', exception => handleException(exceptionToError(exception.exceptionDetails))); + } + + url(): string { + return this._url; + } + + evaluate: types.Evaluate = async (pageFunction, ...args) => { + return (await this._executionContextPromise).evaluate(pageFunction, ...args as any); + } + + evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => { + return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args as any); + } +} diff --git a/src/firefox/features/ffAccessibility.ts b/src/firefox/features/ffAccessibility.ts new file mode 100644 index 0000000000..d36bcce1be --- /dev/null +++ b/src/firefox/features/ffAccessibility.ts @@ -0,0 +1,281 @@ +/** + * Copyright 2018 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. + */ + +interface SerializedAXNode { + role: string; + + name?: string; + value?: string|number; + description?: string; + + keyshortcuts?: string; + roledescription?: string; + valuetext?: string; + + disabled?: boolean; + expanded?: boolean; + focused?: boolean; + modal?: boolean; + multiline?: boolean; + multiselectable?: boolean; + readonly?: boolean; + required?: boolean; + selected?: boolean; + + checked?: boolean|'mixed'; + pressed?: boolean|'mixed'; + + level?: number; + + autocomplete?: string; + haspopup?: string; + invalid?: string; + orientation?: string; + + children?: Array; +} +export class FFAccessibility { + _session: any; + constructor(session) { + this._session = session; + } + async snapshot(options: { interestingOnly?: boolean; } | undefined = {}): Promise { + const { interestingOnly = true } = options; + const { tree } = await this._session.send('Accessibility.getFullAXTree'); + const root = new AXNode(tree); + if (!interestingOnly) + return serializeTree(root)[0]; + const interestingNodes: Set = new Set(); + collectInterestingNodes(interestingNodes, root, false); + return serializeTree(root, interestingNodes)[0]; + } +} +function collectInterestingNodes(collection: Set, node: AXNode, insideControl: boolean) { + if (node.isInteresting(insideControl)) + collection.add(node); + if (node.isLeafNode()) + return; + insideControl = insideControl || node.isControl(); + for (const child of node._children) + collectInterestingNodes(collection, child, insideControl); +} +function serializeTree(node: AXNode, whitelistedNodes?: Set): Array { + const children: Array = []; + for (const child of node._children) + children.push(...serializeTree(child, whitelistedNodes)); + if (whitelistedNodes && !whitelistedNodes.has(node)) + return children; + const serializedNode = node.serialize(); + if (children.length) + serializedNode.children = children; + return [serializedNode]; +} +class AXNode { + _children: AXNode[]; + private _payload: any; + private _editable: boolean; + private _richlyEditable: boolean; + private _focusable: boolean; + private _expanded: boolean; + private _name: string; + private _role: string; + private _cachedHasFocusableChild: boolean|undefined; + + constructor(payload) { + this._payload = payload; + this._children = (payload.children || []).map(x => new AXNode(x)); + this._editable = payload.editable; + this._richlyEditable = this._editable && (payload.tag !== 'textarea' && payload.tag !== 'input'); + this._focusable = payload.focusable; + this._expanded = payload.expanded; + this._name = this._payload.name; + this._role = this._payload.role; + this._cachedHasFocusableChild; + } + + _isPlainTextField(): boolean { + if (this._richlyEditable) + return false; + if (this._editable) + return true; + return this._role === 'entry'; + } + + _isTextOnlyObject(): boolean { + const role = this._role; + return (role === 'text leaf' || role === 'text' || role === 'statictext'); + } + + _hasFocusableChild(): boolean { + if (this._cachedHasFocusableChild === undefined) { + this._cachedHasFocusableChild = false; + for (const child of this._children) { + if (child._focusable || child._hasFocusableChild()) { + this._cachedHasFocusableChild = true; + break; + } + } + } + return this._cachedHasFocusableChild; + } + + isLeafNode(): boolean { + if (!this._children.length) + return true; + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (this._isPlainTextField() || this._isTextOnlyObject()) + return true; + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (this._role) { + case 'graphic': + case 'scrollbar': + case 'slider': + case 'separator': + case 'progressbar': + return true; + default: + break; + } + // Here and below: Android heuristics + if (this._hasFocusableChild()) + return false; + if (this._focusable && this._name) + return true; + if (this._role === 'heading' && this._name) + return true; + return false; + } + + isControl(): boolean { + switch (this._role) { + case 'checkbutton': + case 'check menu item': + case 'check rich option': + case 'combobox': + case 'combobox option': + case 'color chooser': + case 'listbox': + case 'listbox option': + case 'listbox rich option': + case 'popup menu': + case 'menupopup': + case 'menuitem': + case 'menubar': + case 'button': + case 'pushbutton': + case 'radiobutton': + case 'radio menuitem': + case 'scrollbar': + case 'slider': + case 'spinbutton': + case 'switch': + case 'pagetab': + case 'entry': + case 'tree table': + return true; + default: + return false; + } + } + + isInteresting(insideControl: boolean): boolean { + if (this._focusable || this._richlyEditable) + return true; + // If it's not focusable but has a control role, then it's interesting. + if (this.isControl()) + return true; + // A non focusable child of a control is not interesting + if (insideControl) + return false; + return this.isLeafNode() && !!this._name.trim(); + } + + serialize(): SerializedAXNode { + const node: {[x in keyof SerializedAXNode]: any} = { + role: this._role + }; + const userStringProperties: Array = [ + 'name', + 'value', + 'description', + 'roledescription', + 'valuetext', + 'keyshortcuts', + ]; + for (const userStringProperty of userStringProperties) { + if (!(userStringProperty in this._payload)) + continue; + node[userStringProperty] = this._payload[userStringProperty]; + } + const booleanProperties: Array = [ + 'disabled', + 'expanded', + 'focused', + 'modal', + 'multiline', + 'multiselectable', + 'readonly', + 'required', + 'selected', + ]; + for (const booleanProperty of booleanProperties) { + if (this._role === 'document' && booleanProperty === 'focused') + continue; // document focusing is strange + const value = this._payload[booleanProperty]; + if (!value) + continue; + node[booleanProperty] = value; + } + const tristateProperties: Array = [ + 'checked', + 'pressed', + ]; + for (const tristateProperty of tristateProperties) { + if (!(tristateProperty in this._payload)) + continue; + const value = this._payload[tristateProperty]; + node[tristateProperty] = value; + } + const numericalProperties: Array = [ + 'level' + ]; + for (const numericalProperty of numericalProperties) { + if (!(numericalProperty in this._payload)) + continue; + node[numericalProperty] = this._payload[numericalProperty]; + } + const tokenProperties: Array = [ + 'autocomplete', + 'haspopup', + 'invalid', + 'orientation', + ]; + for (const tokenProperty of tokenProperties) { + const value = this._payload[tokenProperty]; + if (!value || value === 'false') + continue; + node[tokenProperty] = value; + } + return node; + } +} diff --git a/src/firefox/features/ffInterception.ts b/src/firefox/features/ffInterception.ts new file mode 100644 index 0000000000..7c54b5b4db --- /dev/null +++ b/src/firefox/features/ffInterception.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { FFNetworkManager, toInterceptableRequest } from '../ffNetworkManager'; +import * as network from '../../network'; + +export class FFInterception { + private _networkManager: FFNetworkManager; + + constructor(networkManager: FFNetworkManager) { + this._networkManager = networkManager; + } + + async enable() { + await this._networkManager.setRequestInterception(true); + } + + async disable() { + await this._networkManager.setRequestInterception(false); + } + + async continue(request: network.Request, overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) { + return toInterceptableRequest(request).continue(overrides); + } + + async fulfill(request: network.Request, response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) { + throw new Error('Not implemented'); + } + + async abort(request: network.Request, errorCode: string = 'failed') { + return toInterceptableRequest(request).abort(); + } +} diff --git a/src/firefox/features/ffPermissions.ts b/src/firefox/features/ffPermissions.ts new file mode 100644 index 0000000000..0a671fed8d --- /dev/null +++ b/src/firefox/features/ffPermissions.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2018 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 { FFConnection } from '../ffConnection'; + +export class FFPermissions { + private _connection: FFConnection; + private _browserContextId: string; + + constructor(connection: FFConnection, browserContextId: string | null) { + this._connection = connection; + this._browserContextId = browserContextId; + } + + + async override(origin: string, permissions: Array) { + const webPermissionToProtocol = new Map([ + ['geolocation', 'geo'], + ['microphone', 'microphone'], + ['camera', 'camera'], + ['notifications', 'desktop-notifications'], + ]); + permissions = permissions.map(permission => { + const protocolPermission = webPermissionToProtocol.get(permission); + if (!protocolPermission) + throw new Error('Unknown permission: ' + permission); + return protocolPermission; + }); + await this._connection.send('Browser.grantPermissions', {origin, browserContextId: this._browserContextId || undefined, permissions}); + } + + async clearOverrides() { + await this._connection.send('Browser.resetPermissions', {browserContextId: this._browserContextId || undefined}); + } +} diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts new file mode 100644 index 0000000000..1023d75f37 --- /dev/null +++ b/src/firefox/ffBrowser.ts @@ -0,0 +1,264 @@ +/** + * Copyright 2018 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 { EventEmitter } from 'events'; +import { helper, RegisteredListener, assert } from '../helper'; +import { FFConnection, ConnectionEvents, FFSessionEvents } from './ffConnection'; +import { Events } from '../events'; +import { FFPermissions } from './features/ffPermissions'; +import { Page } from '../page'; +import { FFFrameManager } from './ffFrameManager'; +import * as browser from '../browser'; +import * as network from '../network'; +import { BrowserContext, BrowserContextOptions } from '../browserContext'; +import { ConnectionTransport } from '../transport'; + +export class FFBrowser extends EventEmitter implements browser.Browser { + _connection: FFConnection; + _targets: Map; + private _defaultContext: BrowserContext; + private _contexts: Map; + private _eventListeners: RegisteredListener[]; + + static async create(transport: ConnectionTransport) { + const connection = new FFConnection(transport); + const {browserContextIds} = await connection.send('Target.getBrowserContexts'); + const browser = new FFBrowser(connection, browserContextIds); + await connection.send('Target.enable'); + return browser; + } + + constructor(connection: FFConnection, browserContextIds: Array) { + super(); + this._connection = connection; + this._targets = new Map(); + + this._defaultContext = this._createBrowserContext(null, {}); + this._contexts = new Map(); + for (const browserContextId of browserContextIds) + this._contexts.set(browserContextId, this._createBrowserContext(browserContextId, {})); + + this._connection.on(ConnectionEvents.Disconnected, () => this.emit(Events.Browser.Disconnected)); + + this._eventListeners = [ + helper.addEventListener(this._connection, 'Target.targetCreated', this._onTargetCreated.bind(this)), + helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)), + helper.addEventListener(this._connection, 'Target.targetInfoChanged', this._onTargetInfoChanged.bind(this)), + ]; + } + + disconnect() { + this._connection.dispose(); + } + + isConnected(): boolean { + return !this._connection._closed; + } + + async newContext(options: BrowserContextOptions = {}): Promise { + const {browserContextId} = await this._connection.send('Target.createBrowserContext'); + // TODO: move ignoreHTTPSErrors to browser context level. + if (options.ignoreHTTPSErrors) + await this._connection.send('Browser.setIgnoreHTTPSErrors', { enabled: true }); + const context = this._createBrowserContext(browserContextId, options); + this._contexts.set(browserContextId, context); + return context; + } + + browserContexts(): Array { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + defaultContext() { + return this._defaultContext; + } + + async _waitForTarget(predicate: (target: Target) => boolean, options: { timeout?: number; } = {}): Promise { + const { + timeout = 30000 + } = options; + const existingTarget = this._allTargets().find(predicate); + if (existingTarget) + return existingTarget; + let resolve: (t: Target) => void; + const targetPromise = new Promise(x => resolve = x); + this.on('targetchanged', check); + try { + if (!timeout) + return await targetPromise; + return await helper.waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this.removeListener('targetchanged', check); + } + + function check(target: Target) { + if (predicate(target)) + resolve(target); + } + } + + _allTargets() { + return Array.from(this._targets.values()); + } + + async _onTargetCreated({targetId, url, browserContextId, openerId, type}) { + const context = browserContextId ? this._contexts.get(browserContextId) : this._defaultContext; + const target = new Target(this._connection, this, context, targetId, type, url, openerId); + this._targets.set(targetId, target); + if (target.opener() && target.opener()._pagePromise) { + const openerPage = await target.opener()._pagePromise; + if (openerPage.listenerCount(Events.Page.Popup)) { + const popupPage = await target.page(); + openerPage.emit(Events.Page.Popup, popupPage); + } + } + } + + _onTargetDestroyed({targetId}) { + const target = this._targets.get(targetId); + this._targets.delete(targetId); + target._didClose(); + } + + _onTargetInfoChanged({targetId, url}) { + const target = this._targets.get(targetId); + target._url = url; + } + + async close() { + helper.removeEventListeners(this._eventListeners); + await this._connection.send('Browser.close'); + } + + _createBrowserContext(browserContextId: string | null, options: BrowserContextOptions): BrowserContext { + const context = new BrowserContext({ + pages: async (): Promise => { + const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + }, + + newPage: async (): Promise => { + const {targetId} = await this._connection.send('Target.newPage', { + browserContextId: browserContextId || undefined + }); + const target = this._targets.get(targetId); + const page = await target.page(); + const session = (page._delegate as FFFrameManager)._session; + const promises: Promise[] = []; + if (options.viewport) + promises.push(page._delegate.setViewport(options.viewport)); + if (options.bypassCSP) + promises.push(session.send('Page.setBypassCSP', { enabled: true })); + if (options.javaScriptEnabled === false) + promises.push(session.send('Page.setJavascriptEnabled', { enabled: false })); + if (options.userAgent) + promises.push(session.send('Page.setUserAgent', { userAgent: options.userAgent })); + if (options.mediaType || options.colorScheme) + promises.push(session.send('Page.setEmulatedMedia', { type: options.mediaType, colorScheme: options.colorScheme })); + await Promise.all(promises); + return page; + }, + + close: async (): Promise => { + assert(browserContextId, 'Non-incognito profiles cannot be closed!'); + await this._connection.send('Target.removeBrowserContext', { browserContextId }); + this._contexts.delete(browserContextId); + }, + + cookies: async (): Promise => { + const { cookies } = await this._connection.send('Browser.getCookies', { browserContextId: browserContextId || undefined }); + return cookies.map(c => { + const copy: any = { ... c }; + delete copy.size; + return copy as network.NetworkCookie; + }); + }, + + clearCookies: async (): Promise => { + await this._connection.send('Browser.clearCookies', { browserContextId: browserContextId || undefined }); + }, + + setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise => { + await this._connection.send('Browser.setCookies', { browserContextId: browserContextId || undefined, cookies }); + }, + }, options); + (context as any).permissions = new FFPermissions(this._connection, browserContextId); + return context; + } +} + +export class Target { + _pagePromise?: Promise; + private _frameManager: FFFrameManager | null = null; + private _browser: FFBrowser; + _context: BrowserContext; + private _connection: FFConnection; + private _targetId: string; + private _type: 'page' | 'browser'; + _url: string; + private _openerId: string; + + constructor(connection: any, browser: FFBrowser, context: BrowserContext, targetId: string, type: 'page' | 'browser', url: string, openerId: string | undefined) { + this._browser = browser; + this._context = context; + this._connection = connection; + this._targetId = targetId; + this._type = type; + this._url = url; + this._openerId = openerId; + } + + _didClose() { + if (this._frameManager) + this._frameManager.didClose(); + } + + opener(): Target | null { + return this._openerId ? this._browser._targets.get(this._openerId) : null; + } + + type(): 'page' | 'browser' { + return this._type; + } + + url() { + return this._url; + } + + browserContext(): BrowserContext { + return this._context; + } + + page(): Promise { + if (this._type === 'page' && !this._pagePromise) { + this._pagePromise = new Promise(async f => { + const session = await this._connection.createSession(this._targetId); + this._frameManager = new FFFrameManager(session, this._context); + const page = this._frameManager._page; + session.once(FFSessionEvents.Disconnected, () => page._didDisconnect()); + await this._frameManager._initialize(); + f(page); + }); + } + return this._pagePromise; + } + + browser() { + return this._browser; + } +} diff --git a/src/firefox/ffConnection.ts b/src/firefox/ffConnection.ts new file mode 100644 index 0000000000..35723080c0 --- /dev/null +++ b/src/firefox/ffConnection.ts @@ -0,0 +1,204 @@ +/** + * 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 {assert} from '../helper'; +import {EventEmitter} from 'events'; +import * as debug from 'debug'; +import { ConnectionTransport } from '../transport'; +import { Protocol } from './protocol'; +const debugProtocol = debug('playwright:protocol'); + +export const ConnectionEvents = { + Disconnected: Symbol('Disconnected'), +}; + +export class FFConnection extends EventEmitter { + private _lastId: number; + private _callbacks: Map; + private _transport: ConnectionTransport; + private _sessions: Map; + _closed: boolean; + + constructor(transport: ConnectionTransport) { + super(); + this._transport = transport; + this._lastId = 0; + this._callbacks = new Map(); + + this._transport.onmessage = this._onMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + this._sessions = new Map(); + this._closed = false; + } + + static fromSession(session: FFSession): FFConnection { + return session._connection; + } + + session(sessionId: string): FFSession | null { + return this._sessions.get(sessionId) || null; + } + + send(method: string, params: object | undefined = {}): Promise { + const id = this._rawSend({method, params}); + return new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + } + + _rawSend(message: any): number { + const id = ++this._lastId; + message = JSON.stringify(Object.assign({}, message, {id})); + debugProtocol('SEND ► ' + message); + this._transport.send(message); + return id; + } + + async _onMessage(message: string) { + debugProtocol('◀ RECV ' + message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new FFSession(this, object.params.targetInfo.type, sessionId); + this._sessions.set(sessionId, session); + } else if (object.method === 'Browser.detachedFromTarget') { + const session = this._sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this._sessions.delete(object.params.sessionId); + } + } + if (object.sessionId) { + const session = this._sessions.get(object.sessionId); + if (session) + session._onMessage(object); + } else if (object.id) { + const callback = this._callbacks.get(object.id); + // Callbacks could be all rejected if someone has called `.dispose()`. + if (callback) { + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } + } else { + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } + + _onClose() { + if (this._closed) + return; + this._closed = true; + this._transport.onmessage = null; + this._transport.onclose = null; + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + this._callbacks.clear(); + for (const session of this._sessions.values()) + session._onClosed(); + this._sessions.clear(); + Promise.resolve().then(() => this.emit(ConnectionEvents.Disconnected)); + } + + dispose() { + this._onClose(); + this._transport.close(); + } + + async createSession(targetId: string): Promise { + const {sessionId} = await this.send('Target.attachToTarget', {targetId}); + return this._sessions.get(sessionId); + } +} + +export const FFSessionEvents = { + Disconnected: Symbol('Disconnected') +}; + +export class FFSession extends EventEmitter { + _connection: FFConnection; + private _callbacks: Map; + private _targetType: string; + private _sessionId: string; + on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + + constructor(connection: FFConnection, targetType: string, sessionId: string) { + super(); + this._callbacks = new Map(); + this._connection = connection; + this._targetType = targetType; + this._sessionId = sessionId; + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + if (!this._connection) + return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`)); + const id = this._connection._rawSend({sessionId: this._sessionId, method, params}); + return new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + } + + _onMessage(object: { id?: number; method: string; params: object; error: { message: string; data: any; }; result?: any; }) { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } else { + assert(!object.id); + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } + + async detach() { + if (!this._connection) + throw new Error(`Session already detached. Most likely the ${this._targetType} has been closed.`); + await this._connection.send('Target.detachFromTarget', {sessionId: this._sessionId}); + } + + _onClosed() { + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + this._callbacks.clear(); + this._connection = null; + Promise.resolve().then(() => this.emit(FFSessionEvents.Disconnected)); + } +} + +function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) + message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} diff --git a/src/firefox/ffExecutionContext.ts b/src/firefox/ffExecutionContext.ts new file mode 100644 index 0000000000..44ec084beb --- /dev/null +++ b/src/firefox/ffExecutionContext.ts @@ -0,0 +1,183 @@ +/** + * Copyright 2019 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 {helper, debugError} from '../helper'; +import * as js from '../javascript'; +import { FFSession } from './ffConnection'; +import { Protocol } from './protocol'; + +export class FFExecutionContext implements js.ExecutionContextDelegate { + _session: FFSession; + _executionContextId: string; + + constructor(session: FFSession, executionContextId: string) { + this._session = session; + this._executionContextId = executionContextId; + } + + async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { + if (returnByValue) { + try { + const handle = await this.evaluate(context, false /* returnByValue */, pageFunction, ...args as any); + const result = await handle.jsonValue(); + await handle.dispose(); + return result; + } catch (e) { + if (e.message.includes('cyclic object value') || e.message.includes('Object is not serializable')) + return undefined; + throw e; + } + } + + if (helper.isString(pageFunction)) { + const payload = await this._session.send('Runtime.evaluate', { + expression: pageFunction.trim(), + executionContextId: this._executionContextId, + }).catch(rewriteError); + checkException(payload.exceptionDetails); + return context._createHandle(payload.result); + } + if (typeof pageFunction !== 'function') + throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); + + let functionText = pageFunction.toString(); + try { + new Function('(' + functionText + ')'); + } catch (e1) { + // This means we might have a function shorthand. Try another + // time prefixing 'function '. + if (functionText.startsWith('async ')) + functionText = 'async function ' + functionText.substring('async '.length); + else + functionText = 'function ' + functionText; + try { + new Function('(' + functionText + ')'); + } catch (e2) { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function is not well-serializable!'); + } + } + const protocolArgs = args.map(arg => { + if (arg instanceof js.JSHandle) { + if (arg._context !== context) + throw new Error('JSHandles can be evaluated only in the context they were created!'); + if (arg._disposed) + throw new Error('JSHandle is disposed!'); + return this._toCallArgument(arg._remoteObject); + } + if (Object.is(arg, Infinity)) + return {unserializableValue: 'Infinity'}; + if (Object.is(arg, -Infinity)) + return {unserializableValue: '-Infinity'}; + if (Object.is(arg, -0)) + return {unserializableValue: '-0'}; + if (Object.is(arg, NaN)) + return {unserializableValue: 'NaN'}; + return {value: arg}; + }); + let callFunctionPromise; + try { + callFunctionPromise = this._session.send('Runtime.callFunction', { + functionDeclaration: functionText, + args: protocolArgs, + executionContextId: this._executionContextId + }); + } catch (err) { + if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON')) + err.message += ' Are you passing a nested JSHandle?'; + throw err; + } + const payload = await callFunctionPromise.catch(rewriteError); + checkException(payload.exceptionDetails); + return context._createHandle(payload.result); + + function rewriteError(error) : never { + if (error.message.includes('Failed to find execution context with id') || error.message.includes('Execution context was destroyed!')) + throw new Error('Execution context was destroyed, most likely because of a navigation.'); + throw error; + } + } + + async getProperties(handle: js.JSHandle): Promise> { + const response = await this._session.send('Runtime.getObjectProperties', { + executionContextId: this._executionContextId, + objectId: handle._remoteObject.objectId, + }); + const result = new Map(); + for (const property of response.properties) + result.set(property.name, handle._context._createHandle(property.value)); + return result; + } + + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._remoteObject.objectId) + return; + await this._session.send('Runtime.disposeObject', { + executionContextId: this._executionContextId, + objectId: handle._remoteObject.objectId, + }).catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); + } + + async handleJSONValue(handle: js.JSHandle): Promise { + const payload = handle._remoteObject; + if (!payload.objectId) + return deserializeValue(payload); + const simpleValue = await this._session.send('Runtime.callFunction', { + executionContextId: this._executionContextId, + returnByValue: true, + functionDeclaration: (e => e).toString(), + args: [this._toCallArgument(payload)], + }); + return deserializeValue(simpleValue.result); + } + + handleToString(handle: js.JSHandle, includeType: boolean): string { + const payload = handle._remoteObject; + if (payload.objectId) + return 'JSHandle@' + (payload.subtype || payload.type); + return (includeType ? 'JSHandle:' : '') + deserializeValue(payload); + } + + private _toCallArgument(payload: any): any { + return { value: payload.value, unserializableValue: payload.unserializableValue, objectId: payload.objectId }; + } +} + +function checkException(exceptionDetails?: any) { + if (exceptionDetails) { + if (exceptionDetails.value) + throw new Error('Evaluation failed: ' + JSON.stringify(exceptionDetails.value)); + else + throw new Error('Evaluation failed: ' + exceptionDetails.text + '\n' + exceptionDetails.stack); + } +} + +export function deserializeValue({unserializableValue, value}: Protocol.RemoteObject) { + if (unserializableValue === 'Infinity') + return Infinity; + if (unserializableValue === '-Infinity') + return -Infinity; + if (unserializableValue === '-0') + return -0; + if (unserializableValue === 'NaN') + return NaN; + return value; +} diff --git a/src/firefox/ffFrameManager.ts b/src/firefox/ffFrameManager.ts new file mode 100644 index 0000000000..2dda906d5e --- /dev/null +++ b/src/firefox/ffFrameManager.ts @@ -0,0 +1,342 @@ +/** + * Copyright 2019 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 * as frames from '../frames'; +import { assert, helper, RegisteredListener, debugError } from '../helper'; +import * as dom from '../dom'; +import { FFSession } from './ffConnection'; +import { FFExecutionContext } from './ffExecutionContext'; +import { Page, PageDelegate } from '../page'; +import { FFNetworkManager } from './ffNetworkManager'; +import { Events } from '../events'; +import * as dialog from '../dialog'; +import { Protocol } from './protocol'; +import * as input from '../input'; +import { RawMouseImpl, RawKeyboardImpl } from './ffInput'; +import { BrowserContext } from '../browserContext'; +import { FFInterception } from './features/ffInterception'; +import { FFAccessibility } from './features/ffAccessibility'; +import * as network from '../network'; +import * as types from '../types'; + +export class FFFrameManager implements PageDelegate { + readonly rawMouse: RawMouseImpl; + readonly rawKeyboard: RawKeyboardImpl; + readonly _session: FFSession; + readonly _page: Page; + private readonly _networkManager: FFNetworkManager; + private readonly _contextIdToContext: Map; + private _eventListeners: RegisteredListener[]; + + constructor(session: FFSession, browserContext: BrowserContext) { + this._session = session; + this.rawKeyboard = new RawKeyboardImpl(session); + this.rawMouse = new RawMouseImpl(session); + this._contextIdToContext = new Map(); + this._page = new Page(this, browserContext); + this._networkManager = new FFNetworkManager(session, this._page); + this._eventListeners = [ + helper.addEventListener(this._session, 'Page.eventFired', this._onEventFired.bind(this)), + helper.addEventListener(this._session, 'Page.frameAttached', this._onFrameAttached.bind(this)), + helper.addEventListener(this._session, 'Page.frameDetached', this._onFrameDetached.bind(this)), + helper.addEventListener(this._session, 'Page.navigationAborted', this._onNavigationAborted.bind(this)), + helper.addEventListener(this._session, 'Page.navigationCommitted', this._onNavigationCommitted.bind(this)), + helper.addEventListener(this._session, 'Page.navigationStarted', this._onNavigationStarted.bind(this)), + helper.addEventListener(this._session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)), + helper.addEventListener(this._session, 'Runtime.executionContextCreated', this._onExecutionContextCreated.bind(this)), + helper.addEventListener(this._session, 'Runtime.executionContextDestroyed', this._onExecutionContextDestroyed.bind(this)), + helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)), + helper.addEventListener(this._session, 'Runtime.console', this._onConsole.bind(this)), + helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), + helper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)), + helper.addEventListener(this._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)), + ]; + (this._page as any).interception = new FFInterception(this._networkManager); + (this._page as any).accessibility = new FFAccessibility(session); + } + + async _initialize() { + await Promise.all([ + this._session.send('Runtime.enable'), + this._session.send('Network.enable'), + this._session.send('Page.enable'), + this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }) + ]); + } + + _onExecutionContextCreated({executionContextId, auxData}) { + const frame = this._page._frameManager.frame(auxData ? auxData.frameId : null); + if (!frame) + return; + const delegate = new FFExecutionContext(this._session, executionContextId); + const context = new dom.FrameExecutionContext(delegate, frame); + frame._contextCreated('main', context); + frame._contextCreated('utility', context); + this._contextIdToContext.set(executionContextId, context); + } + + _onExecutionContextDestroyed({executionContextId}) { + const context = this._contextIdToContext.get(executionContextId); + if (!context) + return; + this._contextIdToContext.delete(executionContextId); + context.frame._contextDestroyed(context as dom.FrameExecutionContext); + } + + _onNavigationStarted() { + } + + _onNavigationAborted(params: Protocol.Page.navigationAbortedPayload) { + const frame = this._page._frameManager.frame(params.frameId); + for (const watcher of this._page._frameManager._lifecycleWatchers) + watcher._onAbortedNewDocumentNavigation(frame, params.navigationId, params.errorText); + } + + _onNavigationCommitted(params: Protocol.Page.navigationCommittedPayload) { + this._page._frameManager.frameCommittedNewDocumentNavigation(params.frameId, params.url, params.name || '', params.navigationId || '', false); + } + + _onSameDocumentNavigation(params: Protocol.Page.sameDocumentNavigationPayload) { + this._page._frameManager.frameCommittedSameDocumentNavigation(params.frameId, params.url); + } + + _onFrameAttached(params: Protocol.Page.frameAttachedPayload) { + this._page._frameManager.frameAttached(params.frameId, params.parentFrameId); + } + + _onFrameDetached(params: Protocol.Page.frameDetachedPayload) { + this._page._frameManager.frameDetached(params.frameId); + } + + _onEventFired({frameId, name}) { + if (name === 'load') + this._page._frameManager.frameLifecycleEvent(frameId, 'load'); + if (name === 'DOMContentLoaded') + this._page._frameManager.frameLifecycleEvent(frameId, 'domcontentloaded'); + } + + _onUncaughtError(params: Protocol.Page.uncaughtErrorPayload) { + const error = new Error(params.message); + error.stack = params.stack; + this._page.emit(Events.Page.PageError, error); + } + + _onConsole({type, args, executionContextId, location}) { + const context = this._contextIdToContext.get(executionContextId); + this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location); + } + + _onDialogOpened(params: Protocol.Page.dialogOpenedPayload) { + this._page.emit(Events.Page.Dialog, new dialog.Dialog( + params.type as dialog.DialogType, + params.message, + async (accept: boolean, promptText?: string) => { + await this._session.send('Page.handleDialog', { dialogId: params.dialogId, accept, promptText }).catch(debugError); + }, + params.defaultValue)); + } + + _onBindingCalled(event: Protocol.Page.bindingCalledPayload) { + const context = this._contextIdToContext.get(event.executionContextId); + this._page._onBindingCalled(event.payload, context); + } + + async _onFileChooserOpened({executionContextId, element}) { + const context = this._contextIdToContext.get(executionContextId); + const handle = context._createHandle(element).asElement()!; + this._page._onFileChooserOpened(handle); + } + + async exposeBinding(name: string, bindingFunction: string): Promise { + await this._session.send('Page.addBinding', {name: name}); + await this._session.send('Page.addScriptToEvaluateOnNewDocument', {script: bindingFunction}); + await Promise.all(this._page.frames().map(frame => frame.evaluate(bindingFunction).catch(debugError))); + } + + didClose() { + helper.removeEventListeners(this._eventListeners); + this._networkManager.dispose(); + this._page._didClose(); + } + + async navigateFrame(frame: frames.Frame, url: string, referer: string | undefined): Promise { + const response = await this._session.send('Page.navigate', { url, referer, frameId: frame._id }); + return { newDocumentId: response.navigationId, isSameDocument: !response.navigationId }; + } + + needsLifecycleResetOnSetContent(): boolean { + return true; + } + + async setExtraHTTPHeaders(headers: network.Headers): Promise { + const array = []; + for (const [name, value] of Object.entries(headers)) + array.push({ name, value }); + await this._session.send('Network.setExtraHTTPHeaders', { headers: array }); + } + + async setViewport(viewport: types.Viewport): Promise { + const { + width, + height, + isMobile = false, + deviceScaleFactor = 1, + hasTouch = false, + isLandscape = false, + } = viewport; + await this._session.send('Page.setViewport', { + viewport: { width, height, isMobile, deviceScaleFactor, hasTouch, isLandscape }, + }); + } + + async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise { + await this._session.send('Page.setEmulatedMedia', { + type: mediaType === null ? undefined : mediaType, + colorScheme: mediaColorScheme === null ? undefined : mediaColorScheme + }); + } + + async setCacheEnabled(enabled: boolean): Promise { + await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled}); + } + + async reload(): Promise { + await this._session.send('Page.reload', { frameId: this._page.mainFrame()._id }); + } + + async goBack(): Promise { + const { navigationId } = await this._session.send('Page.goBack', { frameId: this._page.mainFrame()._id }); + return navigationId !== null; + } + + async goForward(): Promise { + const { navigationId } = await this._session.send('Page.goForward', { frameId: this._page.mainFrame()._id }); + return navigationId !== null; + } + + async evaluateOnNewDocument(source: string): Promise { + await this._session.send('Page.addScriptToEvaluateOnNewDocument', { script: source }); + } + + async closePage(runBeforeUnload: boolean): Promise { + await this._session.send('Page.close', { runBeforeUnload }); + } + + getBoundingBoxForScreenshot(handle: dom.ElementHandle): Promise { + const frameId = handle._context.frame._id; + return this._session.send('Page.getBoundingBox', { + frameId, + objectId: handle._remoteObject.objectId, + }); + } + + canScreenshotOutsideViewport(): boolean { + return true; + } + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise { + if (color) + throw new Error('Not implemented'); + } + + async takeScreenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise { + const { data } = await this._session.send('Page.screenshot', { + mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), + fullPage: options.fullPage, + clip: options.clip, + }); + return Buffer.from(data, 'base64'); + } + + async resetViewport(): Promise { + await this._session.send('Page.setViewport', { viewport: null }); + } + + async getContentFrame(handle: dom.ElementHandle): Promise { + const { frameId } = await this._session.send('Page.contentFrame', { + frameId: handle._context.frame._id, + objectId: toRemoteObject(handle).objectId, + }); + if (!frameId) + return null; + return this._page._frameManager.frame(frameId); + } + + async getOwnerFrame(handle: dom.ElementHandle): Promise { + return handle._context.frame; + } + + isElementHandle(remoteObject: any): boolean { + return remoteObject.subtype === 'node'; + } + + async getBoundingBox(handle: dom.ElementHandle): Promise { + const quads = await this.getContentQuads(handle); + if (!quads || !quads.length) + return null; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const quad of quads) { + for (const point of quad) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + async getContentQuads(handle: dom.ElementHandle): Promise { + const result = await this._session.send('Page.getContentQuads', { + frameId: handle._context.frame._id, + objectId: toRemoteObject(handle).objectId, + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ quad.p1, quad.p2, quad.p3, quad.p4 ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + return this._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); + } + + async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { + await handle.evaluate(input.setFileInputFunction, files); + } + + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { + assert(false, 'Multiple isolated worlds are not implemented'); + return handle; + } +} + +export function normalizeWaitUntil(waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[]): frames.LifecycleEvent[] { + if (!Array.isArray(waitUntil)) + waitUntil = [waitUntil]; + for (const condition of waitUntil) { + if (condition !== 'load' && condition !== 'domcontentloaded') + throw new Error('Unknown waitUntil condition: ' + condition); + } + return waitUntil; +} + +function toRemoteObject(handle: dom.ElementHandle): Protocol.RemoteObject { + return handle._remoteObject; +} diff --git a/src/firefox/ffInput.ts b/src/firefox/ffInput.ts new file mode 100644 index 0000000000..d0f9f96f96 --- /dev/null +++ b/src/firefox/ffInput.ts @@ -0,0 +1,137 @@ +/** + * 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 { FFSession } from './ffConnection'; +import * as input from '../input'; + +function toModifiersMask(modifiers: Set): number { + let mask = 0; + if (modifiers.has('Alt')) + mask |= 1; + if (modifiers.has('Control')) + mask |= 2; + if (modifiers.has('Shift')) + mask |= 4; + if (modifiers.has('Meta')) + mask |= 8; + return mask; +} + +function toButtonNumber(button: input.Button): number { + if (button === 'left') + return 0; + if (button === 'middle') + return 1; + if (button === 'right') + return 2; +} + +function toButtonsMask(buttons: Set): number { + let mask = 0; + if (buttons.has('left')) + mask |= 1; + if (buttons.has('right')) + mask |= 2; + if (buttons.has('middle')) + mask |= 4; + return mask; +} + +export class RawKeyboardImpl implements input.RawKeyboard { + private _client: FFSession; + + constructor(client: FFSession) { + this._client = client; + } + + async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { + if (code === 'MetaLeft') + code = 'OSLeft'; + if (code === 'MetaRight') + code = 'OSRight'; + await this._client.send('Page.dispatchKeyEvent', { + type: 'keydown', + keyCode: keyCodeWithoutLocation, + code, + key, + repeat: autoRepeat, + location + }); + } + + async keyup(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise { + if (code === 'MetaLeft') + code = 'OSLeft'; + if (code === 'MetaRight') + code = 'OSRight'; + await this._client.send('Page.dispatchKeyEvent', { + type: 'keyup', + key, + keyCode: keyCodeWithoutLocation, + code, + location, + repeat: false + }); + } + + async sendText(text: string): Promise { + await this._client.send('Page.insertText', { text }); + } +} + +export class RawMouseImpl implements input.RawMouse { + private _client: FFSession; + + constructor(client: FFSession) { + this._client = client; + } + + async move(x: number, y: number, button: input.Button | 'none', buttons: Set, modifiers: Set): Promise { + await this._client.send('Page.dispatchMouseEvent', { + type: 'mousemove', + button: 0, + buttons: toButtonsMask(buttons), + x, + y, + modifiers: toModifiersMask(modifiers) + }); + } + + async down(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Page.dispatchMouseEvent', { + type: 'mousedown', + button: toButtonNumber(button), + buttons: toButtonsMask(buttons), + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } + + async up(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Page.dispatchMouseEvent', { + type: 'mouseup', + button: toButtonNumber(button), + buttons: toButtonsMask(buttons), + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } +} diff --git a/src/firefox/ffLauncher.ts b/src/firefox/ffLauncher.ts new file mode 100644 index 0000000000..3caf2d3ab6 --- /dev/null +++ b/src/firefox/ffLauncher.ts @@ -0,0 +1,400 @@ +/** + * 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 * as os from 'os'; +import * as path from 'path'; +import { FFBrowser } from './ffBrowser'; +import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; +import * as fs from 'fs'; +import * as util from 'util'; +import { assert } from '../helper'; +import { TimeoutError } from '../errors'; +import { WebSocketTransport, SlowMoTransport } from '../transport'; +import { launchProcess, waitForLine } from '../processLauncher'; +import { BrowserServer } from '../browser'; + +const mkdtempAsync = util.promisify(fs.mkdtemp); +const writeFileAsync = util.promisify(fs.writeFile); + +const DEFAULT_ARGS = [ + '-no-remote', +]; + +export class FFLauncher { + private _projectRoot: string; + private _preferredRevision: string; + constructor(projectRoot, preferredRevision) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + } + + defaultArgs(options: any = {}) { + const { + headless = true, + args = [], + userDataDir = null, + } = options; + const firefoxArguments = [...DEFAULT_ARGS]; + if (userDataDir) + firefoxArguments.push('-profile', userDataDir); + if (headless) + firefoxArguments.push('-headless'); + firefoxArguments.push(...args); + if (args.every(arg => arg.startsWith('-'))) + firefoxArguments.push('about:blank'); + return firefoxArguments; + } + + async launch(options: any = {}): Promise> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + env = process.env, + handleSIGHUP = true, + handleSIGINT = true, + handleSIGTERM = true, + slowMo = 0, + timeout = 30000, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) + firefoxArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + firefoxArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); + else + firefoxArguments.push(...args); + + if (!firefoxArguments.includes('-juggler')) + firefoxArguments.unshift('-juggler', '0'); + + let temporaryProfileDir = null; + if (!firefoxArguments.includes('-profile') && !firefoxArguments.includes('--profile')) { + temporaryProfileDir = await createProfile(); + firefoxArguments.unshift(`-profile`, temporaryProfileDir); + } + + let firefoxExecutable = executablePath; + if (!firefoxExecutable) { + const {missingText, executablePath} = this._resolveExecutablePath(); + if (missingText) + throw new Error(missingText); + firefoxExecutable = executablePath; + } + const launchedProcess = await launchProcess({ + executablePath: firefoxExecutable, + args: firefoxArguments, + env: os.platform() === 'linux' ? { + ...env, + // On linux Juggler ships the libstdc++ it was linked against. + LD_LIBRARY_PATH: `${path.dirname(firefoxExecutable)}:${process.env.LD_LIBRARY_PATH}`, + } : env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: false, + tempDir: temporaryProfileDir + }, () => { + if (temporaryProfileDir || !browser) + return Promise.reject(); + browser.close(); + }); + + let browser: FFBrowser | undefined; + try { + const timeoutError = new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Firefox!`); + const match = await waitForLine(launchedProcess, launchedProcess.stdout, /^Juggler listening on (ws:\/\/.*)$/, timeout, timeoutError); + const url = match[1]; + const transport = await WebSocketTransport.create(url); + browser = await FFBrowser.create(SlowMoTransport.wrap(transport, slowMo)); + await browser._waitForTarget(t => t.type() === 'page'); + return new BrowserServer(browser, launchedProcess, url); + } catch (e) { + if (browser) + await browser.close(); + throw e; + } + } + + executablePath(): string { + return this._resolveExecutablePath().executablePath; + } + + _resolveExecutablePath() { + const browserFetcher = createBrowserFetcher(this._projectRoot); + const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); + const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null; + return {executablePath: revisionInfo.executablePath, missingText}; + } +} + +export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher { + const downloadURLs = { + linux: '%s/builds/firefox/%s/firefox-linux.zip', + mac: '%s/builds/firefox/%s/firefox-mac.zip', + win32: '%s/builds/firefox/%s/firefox-win32.zip', + win64: '%s/builds/firefox/%s/firefox-win64.zip', + }; + + const defaultOptions = { + path: path.join(projectRoot, '.local-firefox'), + host: 'https://playwrightaccount.blob.core.windows.net', + platform: (() => { + const platform = os.platform(); + if (platform === 'darwin') + return 'mac'; + if (platform === 'linux') + return 'linux'; + if (platform === 'win32') + return os.arch() === 'x64' ? 'win64' : 'win32'; + return platform; + })() + }; + options = { + ...defaultOptions, + ...options, + }; + assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform); + + return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => { + let executablePath = ''; + if (platform === 'linux') + executablePath = path.join('firefox', 'firefox'); + else if (platform === 'mac') + executablePath = path.join('firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'); + else if (platform === 'win32' || platform === 'win64') + executablePath = path.join('firefox', 'firefox.exe'); + return { + downloadUrl: util.format(downloadURLs[platform], options.host, revision), + executablePath + }; + }); +} + +const DUMMY_UMA_SERVER = 'dummy.test'; +const DEFAULT_PREFERENCES = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.section.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.passwords.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // Do not allow background tabs to be zombified on Android: otherwise for + // tests that open additional tabs: the test harness tab itself might get + // unloaded + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Do not show datareporting policy notifications which can + // interfere with tests + 'datareporting.healthreport.about.reportUrl': `http://${DUMMY_UMA_SERVER}/dummy/abouthealthreport/`, + 'datareporting.healthreport.documentServerURI': `http://${DUMMY_UMA_SERVER}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyAccepted': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${DUMMY_UMA_SERVER}/dummy/discoveryURL`, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + // Disable useragent updates + 'general.useragent.updates.enabled': false, + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + // Do not scan Wifi + 'geo.wifi.scan': false, + // No hang monitor + 'hangmonitor.timeout': 0, + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': DUMMY_UMA_SERVER, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Enable Remote Agent + // https://bugzilla.mozilla.org/show_bug.cgi?id=1544393 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${DUMMY_UMA_SERVER}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // We want to collect telemetry, but we don't want to send in the results + 'toolkit.telemetry.server': `https://${DUMMY_UMA_SERVER}/dummy/telemetry/`, + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, +}; + +async function createProfile(extraPrefs?: object): Promise { + const profilePath = await mkdtempAsync(path.join(os.tmpdir(), 'playwright_dev_firefox_profile-')); + const prefsJS = []; + const userJS = []; + + const prefs = { ...DEFAULT_PREFERENCES, ...extraPrefs }; + for (const [key, value] of Object.entries(prefs)) + userJS.push(`user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`); + + await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); + await writeFileAsync(path.join(profilePath, 'prefs.js'), prefsJS.join('\n')); + return profilePath; +} diff --git a/src/firefox/ffNetworkManager.ts b/src/firefox/ffNetworkManager.ts new file mode 100644 index 0000000000..843f4aa88c --- /dev/null +++ b/src/firefox/ffNetworkManager.ts @@ -0,0 +1,198 @@ +/** + * Copyright 2019 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 { assert, debugError, helper, RegisteredListener } from '../helper'; +import { FFSession } from './ffConnection'; +import { Page } from '../page'; +import * as network from '../network'; +import * as frames from '../frames'; + +export class FFNetworkManager { + private _session: FFSession; + private _requests: Map; + private _page: Page; + private _eventListeners: RegisteredListener[]; + + constructor(session: FFSession, page: Page) { + this._session = session; + + this._requests = new Map(); + this._page = page; + + this._eventListeners = [ + helper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)), + helper.addEventListener(session, 'Network.responseReceived', this._onResponseReceived.bind(this)), + helper.addEventListener(session, 'Network.requestFinished', this._onRequestFinished.bind(this)), + helper.addEventListener(session, 'Network.requestFailed', this._onRequestFailed.bind(this)), + ]; + } + + dispose() { + helper.removeEventListeners(this._eventListeners); + } + + async setRequestInterception(enabled) { + await this._session.send('Network.setRequestInterception', {enabled}); + } + + _onRequestWillBeSent(event) { + const redirected = event.redirectedFrom ? this._requests.get(event.redirectedFrom) : null; + const frame = redirected ? redirected.request.frame() : (event.frameId ? this._page._frameManager.frame(event.frameId) : null); + if (!frame) + return; + let redirectChain: network.Request[] = []; + if (redirected) { + redirectChain = redirected.request._redirectChain; + redirectChain.push(redirected.request); + this._requests.delete(redirected._id); + } + const request = new InterceptableRequest(this._session, frame, redirectChain, event); + this._requests.set(request._id, request); + this._page._frameManager.requestStarted(request.request); + } + + _onResponseReceived(event) { + const request = this._requests.get(event.requestId); + if (!request) + return; + const remoteAddress: network.RemoteAddress = { ip: event.remoteIPAddress, port: event.remotePort }; + const getResponseBody = async () => { + const response = await this._session.send('Network.getResponseBody', { + requestId: request._id + }); + if (response.evicted) + throw new Error(`Response body for ${request.request.method()} ${request.request.url()} was evicted!`); + return Buffer.from(response.base64body, 'base64'); + }; + const headers: network.Headers = {}; + for (const {name, value} of event.headers) + headers[name.toLowerCase()] = value; + const response = new network.Response(request.request, event.status, event.statusText, headers, remoteAddress, getResponseBody); + this._page._frameManager.requestReceivedResponse(response); + } + + _onRequestFinished(event) { + const request = this._requests.get(event.requestId); + if (!request) + return; + const response = request.request.response(); + // Keep redirected requests in the map for future reference in redirectChain. + const isRedirected = response.status() >= 300 && response.status() <= 399; + if (isRedirected) { + response._requestFinished(new Error('Response body is unavailable for redirect responses')); + } else { + this._requests.delete(request._id); + response._requestFinished(); + } + this._page._frameManager.requestFinished(request.request); + } + + _onRequestFailed(event) { + const request = this._requests.get(event.requestId); + if (!request) + return; + this._requests.delete(request._id); + if (request.request.response()) + request.request.response()._requestFinished(); + request.request._setFailureText(event.errorCode); + this._page._frameManager.requestFailed(request.request, event.errorCode === 'NS_BINDING_ABORTED'); + } +} + +const causeToResourceType = { + TYPE_INVALID: 'other', + TYPE_OTHER: 'other', + TYPE_SCRIPT: 'script', + TYPE_IMAGE: 'image', + TYPE_STYLESHEET: 'stylesheet', + TYPE_OBJECT: 'other', + TYPE_DOCUMENT: 'document', + TYPE_SUBDOCUMENT: 'document', + TYPE_REFRESH: 'document', + TYPE_XBL: 'other', + TYPE_PING: 'other', + TYPE_XMLHTTPREQUEST: 'xhr', + TYPE_OBJECT_SUBREQUEST: 'other', + TYPE_DTD: 'other', + TYPE_FONT: 'font', + TYPE_MEDIA: 'media', + TYPE_WEBSOCKET: 'websocket', + TYPE_CSP_REPORT: 'other', + TYPE_XSLT: 'other', + TYPE_BEACON: 'other', + TYPE_FETCH: 'fetch', + TYPE_IMAGESET: 'images', + TYPE_WEB_MANIFEST: 'manifest', +}; + +const interceptableRequestSymbol = Symbol('interceptableRequest'); + +export function toInterceptableRequest(request: network.Request): InterceptableRequest { + return (request as any)[interceptableRequestSymbol]; +} + +class InterceptableRequest { + readonly request: network.Request; + _id: string; + private _session: FFSession; + private _suspended: boolean; + private _interceptionHandled: boolean; + + constructor(session: FFSession, frame: frames.Frame, redirectChain: network.Request[], payload: any) { + this._id = payload.requestId; + this._session = session; + this._suspended = payload.suspended; + this._interceptionHandled = false; + + const headers: network.Headers = {}; + for (const {name, value} of payload.headers) + headers[name.toLowerCase()] = value; + + this.request = new network.Request(frame, redirectChain, payload.navigationId, + payload.url, causeToResourceType[payload.cause] || 'other', payload.method, payload.postData, headers); + (this.request as any)[interceptableRequestSymbol] = this; + } + + async continue(overrides: {url?: string, method?: string, postData?: string, headers?: {[key: string]: string}} = {}) { + assert(!overrides.url, 'Playwright-Firefox does not support overriding URL'); + assert(!overrides.method, 'Playwright-Firefox does not support overriding method'); + assert(!overrides.postData, 'Playwright-Firefox does not support overriding postData'); + assert(this._suspended, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + const { + headers, + } = overrides; + await this._session.send('Network.resumeSuspendedRequest', { + requestId: this._id, + headers: headers ? Object.entries(headers).filter(([, value]) => !Object.is(value, undefined)).map(([name, value]) => ({name, value})) : undefined, + }).catch(error => { + debugError(error); + }); + } + + async abort() { + assert(this._suspended, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._session.send('Network.abortSuspendedRequest', { + requestId: this._id, + }).catch(error => { + debugError(error); + }); + } +} diff --git a/src/firefox/ffPlaywright.ts b/src/firefox/ffPlaywright.ts new file mode 100644 index 0000000000..fdf716fe0a --- /dev/null +++ b/src/firefox/ffPlaywright.ts @@ -0,0 +1,82 @@ +/** + * 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 * as browsers from '../browser'; +import { FFBrowser } from './ffBrowser'; +import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher'; +import { WebSocketTransport, SlowMoTransport } from '../transport'; +import { DeviceDescriptors, DeviceDescriptor } from '../deviceDescriptors'; +import * as Errors from '../errors'; +import { FFLauncher, createBrowserFetcher } from './ffLauncher'; + +type Devices = { [name: string]: DeviceDescriptor } & DeviceDescriptor[]; + +export class FFPlaywright { + private _projectRoot: string; + private _launcher: FFLauncher; + readonly _revision: string; + + constructor(projectRoot: string, preferredRevision: string) { + this._projectRoot = projectRoot; + this._launcher = new FFLauncher(projectRoot, preferredRevision); + this._revision = preferredRevision; + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + const fetcher = this.createBrowserFetcher(options); + const revisionInfo = fetcher.revisionInfo(this._revision); + await fetcher.download(this._revision, options ? options.onProgress : undefined); + return revisionInfo; + } + + async launch(options: any): Promise { + const server = await this._launcher.launch(options); + return server.connect(); + } + + async launchServer(options: any): Promise> { + return this._launcher.launch(options); + } + + async connect(options: { slowMo?: number, browserWSEndpoint: string }): Promise { + const transport = await WebSocketTransport.create(options.browserWSEndpoint); + return FFBrowser.create(SlowMoTransport.wrap(transport, options.slowMo || 0)); + } + + executablePath(): string { + return this._launcher.executablePath(); + } + + get devices(): Devices { + const result = DeviceDescriptors.slice() as Devices; + for (const device of DeviceDescriptors) + result[device.name] = device; + return result; + } + + get errors(): any { + return Errors; + } + + defaultArgs(options: any | undefined): string[] { + return this._launcher.defaultArgs(options); + } + + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); + } +} diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts new file mode 100644 index 0000000000..15b768dbd1 --- /dev/null +++ b/src/webkit/wkBrowser.ts @@ -0,0 +1,217 @@ +/** + * 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 { EventEmitter } from 'events'; +import { helper, RegisteredListener, debugError, assert } from '../helper'; +import * as browser from '../browser'; +import * as network from '../network'; +import { WKConnection, WKConnectionEvents, WKTargetSession } from './wkConnection'; +import { Page } from '../page'; +import { WKTarget } from './wkTarget'; +import { Protocol } from './protocol'; +import { Events } from '../events'; +import { BrowserContext, BrowserContextOptions } from '../browserContext'; +import { ConnectionTransport } from '../transport'; + +export class WKBrowser extends EventEmitter implements browser.Browser { + readonly _connection: WKConnection; + private _defaultContext: BrowserContext; + private _contexts = new Map(); + _targets = new Map(); + private _eventListeners: RegisteredListener[]; + private _privateEvents = new EventEmitter(); + + constructor(transport: ConnectionTransport) { + super(); + this._connection = new WKConnection(transport); + + /** @type {!Map} */ + this._targets = new Map(); + + this._defaultContext = this._createBrowserContext(undefined, {}); + /** @type {!Map} */ + this._contexts = new Map(); + + this._eventListeners = [ + helper.addEventListener(this._connection, WKConnectionEvents.TargetCreated, this._onTargetCreated.bind(this)), + helper.addEventListener(this._connection, WKConnectionEvents.TargetDestroyed, this._onTargetDestroyed.bind(this)), + helper.addEventListener(this._connection, WKConnectionEvents.DidCommitProvisionalTarget, this._onProvisionalTargetCommitted.bind(this)), + ]; + + // Intercept provisional targets during cross-process navigation. + this._connection.send('Target.setPauseOnStart', { pauseOnStart: true }).catch(e => { + debugError(e); + throw e; + }); + } + + async newContext(options: BrowserContextOptions = {}): Promise { + const { browserContextId } = await this._connection.send('Browser.createContext'); + const context = this._createBrowserContext(browserContextId, options); + if (options.ignoreHTTPSErrors) + await this._connection.send('Browser.setIgnoreCertificateErrors', { browserContextId, ignore: true }); + this._contexts.set(browserContextId, context); + return context; + } + + browserContexts(): BrowserContext[] { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + defaultContext(): BrowserContext { + return this._defaultContext; + } + + async _waitForTarget(predicate: (arg0: WKTarget) => boolean, options: { timeout?: number; } | undefined = {}): Promise { + const { + timeout = 30000 + } = options; + const existingTarget = Array.from(this._targets.values()).find(predicate); + if (existingTarget) + return existingTarget; + let resolve : (a: WKTarget) => void; + const targetPromise = new Promise(x => resolve = x); + this._privateEvents.on(BrowserEvents.TargetCreated, check); + try { + if (!timeout) + return await targetPromise; + return await helper.waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this._privateEvents.removeListener(BrowserEvents.TargetCreated, check); + } + + function check(target: WKTarget) { + if (predicate(target)) + resolve(target); + } + } + + _onTargetCreated(session: WKTargetSession, targetInfo: Protocol.Target.TargetInfo) { + let context = null; + if (targetInfo.browserContextId) { + // FIXME: we don't know about the default context id, so assume that all targets from + // unknown contexts are created in the 'default' context which can in practice be represented + // by multiple actual contexts in WebKit. Solving this properly will require adding context + // lifecycle events. + context = this._contexts.get(targetInfo.browserContextId); + // if (!context) + // throw new Error(`Target ${targetId} created in unknown browser context ${browserContextId}.`); + } + if (!context) + context = this._defaultContext; + const target = new WKTarget(this, session, targetInfo, context); + this._targets.set(targetInfo.targetId, target); + if (targetInfo.isProvisional) { + const oldTarget = this._targets.get(targetInfo.oldTargetId); + if (oldTarget) + oldTarget._initializeSession(session); + } + this._privateEvents.emit(BrowserEvents.TargetCreated, target); + if (!targetInfo.oldTargetId && targetInfo.openerId) { + const opener = this._targets.get(targetInfo.openerId); + if (!opener) + return; + const openerPage = opener._frameManager ? opener._frameManager._page : null; + if (!openerPage || !openerPage.listenerCount(Events.Page.Popup)) + return; + target.page().then(page => openerPage.emit(Events.Page.Popup, page)); + } + if (targetInfo.isPaused) + this._connection.send('Target.resume', { targetId: targetInfo.targetId }).catch(debugError); + } + + _onTargetDestroyed({targetId}) { + const target = this._targets.get(targetId); + this._targets.delete(targetId); + target._didClose(); + } + + _closePage(page: Page, runBeforeUnload: boolean) { + this._connection.send('Target.close', { + targetId: WKTarget.fromPage(page)._targetId, + runBeforeUnload + }).catch(debugError); + } + + async _activatePage(page: Page): Promise { + await this._connection.send('Target.activate', { targetId: WKTarget.fromPage(page)._targetId }); + } + + async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) { + const oldTarget = this._targets.get(oldTargetId); + const newTarget = this._targets.get(newTargetId); + newTarget._swapWith(oldTarget); + } + + disconnect() { + throw new Error('Unsupported operation'); + } + + isConnected(): boolean { + return true; + } + + async close() { + helper.removeEventListeners(this._eventListeners); + await this._connection.send('Browser.close'); + } + + _createBrowserContext(browserContextId: string | undefined, options: BrowserContextOptions): BrowserContext { + const context = new BrowserContext({ + pages: async (): Promise => { + const targets = Array.from(this._targets.values()).filter(target => target._browserContext === context && target._type === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + }, + + newPage: async (): Promise => { + const { targetId } = await this._connection.send('Browser.createPage', { browserContextId }); + const target = this._targets.get(targetId); + return await target.page(); + }, + + close: async (): Promise => { + assert(browserContextId, 'Non-incognito profiles cannot be closed!'); + await this._connection.send('Browser.deleteContext', { browserContextId }); + this._contexts.delete(browserContextId); + }, + + cookies: async (): Promise => { + const { cookies } = await this._connection.send('Browser.getAllCookies', { browserContextId }); + return cookies.map((c: network.NetworkCookie) => ({ + ...c, + expires: c.expires === 0 ? -1 : c.expires + })); + }, + + clearCookies: async (): Promise => { + await this._connection.send('Browser.deleteAllCookies', { browserContextId }); + }, + + setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise => { + const cc = cookies.map(c => ({ ...c, session: c.expires === -1 || c.expires === undefined })) as Protocol.Browser.SetCookieParam[]; + await this._connection.send('Browser.setCookies', { cookies: cc, browserContextId }); + }, + }, options); + return context; + } +} + +const BrowserEvents = { + TargetCreated: Symbol('BrowserEvents.TargetCreated'), + TargetDestroyed: Symbol('BrowserEvents.TargetDestroyed'), +}; diff --git a/src/webkit/wkConnection.ts b/src/webkit/wkConnection.ts new file mode 100644 index 0000000000..6092d1dc03 --- /dev/null +++ b/src/webkit/wkConnection.ts @@ -0,0 +1,264 @@ +/** + * 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 { assert } from '../helper'; +import * as debug from 'debug'; +import { EventEmitter } from 'events'; +import { ConnectionTransport } from '../transport'; +import { Protocol } from './protocol'; + +const debugProtocol = debug('playwright:protocol'); +const debugWrappedMessage = require('debug')('wrapped'); + +export const WKConnectionEvents = { + TargetCreated: Symbol('ConnectionEvents.TargetCreated'), + TargetDestroyed: Symbol('Connection.TargetDestroyed'), + DidCommitProvisionalTarget: Symbol('Connection.DidCommitProvisionalTarget') +}; + +export class WKConnection extends EventEmitter { + _lastId = 0; + private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); + private readonly _transport: ConnectionTransport; + private readonly _sessions = new Map(); + + _closed = false; + + constructor(transport: ConnectionTransport) { + super(); + this._transport = transport; + this._transport.onmessage = this._dispatchMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + } + + static fromSession(session: WKTargetSession): WKConnection { + return session._connection; + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + const id = this._rawSend({method, params}); + return new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + } + + _rawSend(message: any): number { + const id = ++this._lastId; + message = JSON.stringify(Object.assign({}, message, {id})); + debugProtocol('SEND ► ' + message); + this._transport.send(message); + return id; + } + + private _dispatchMessage(message: string) { + debugProtocol('◀ RECV ' + message); + const object = JSON.parse(message); + this._dispatchTargetMessageToSession(object, message); + if (object.id) { + const callback = this._callbacks.get(object.id); + // Callbacks could be all rejected if someone has called `.dispose()`. + if (callback) { + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } else { + assert(this._closed, 'Received response for unknown callback: ' + object.id); + } + } else { + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } + + _dispatchTargetMessageToSession(object: {method: string, params: any}, wrappedMessage: string) { + if (object.method === 'Target.targetCreated') { + const targetInfo = object.params.targetInfo as Protocol.Target.TargetInfo; + const session = new WKTargetSession(this, targetInfo); + this._sessions.set(session._sessionId, session); + Promise.resolve().then(() => this.emit(WKConnectionEvents.TargetCreated, session, object.params.targetInfo)); + } else if (object.method === 'Target.targetDestroyed') { + const session = this._sessions.get(object.params.targetId); + if (session) { + session._onClosed(); + this._sessions.delete(object.params.targetId); + } + Promise.resolve().then(() => this.emit(WKConnectionEvents.TargetDestroyed, { targetId: object.params.targetId })); + } else if (object.method === 'Target.dispatchMessageFromTarget') { + const {targetId, message} = object.params as Protocol.Target.dispatchMessageFromTargetPayload; + const session = this._sessions.get(targetId); + if (!session) + throw new Error('Unknown target: ' + targetId); + if (session.isProvisional()) + session._addProvisionalMessage(message); + else + session._dispatchMessageFromTarget(message); + } else if (object.method === 'Target.didCommitProvisionalTarget') { + const {oldTargetId, newTargetId} = object.params as Protocol.Target.didCommitProvisionalTargetPayload; + Promise.resolve().then(() => this.emit(WKConnectionEvents.DidCommitProvisionalTarget, { oldTargetId, newTargetId })); + const newSession = this._sessions.get(newTargetId); + if (!newSession) + throw new Error('Unknown new target: ' + newTargetId); + const oldSession = this._sessions.get(oldTargetId); + if (!oldSession) + throw new Error('Unknown old target: ' + oldTargetId); + oldSession._swappedOut = true; + for (const message of newSession._takeProvisionalMessagesAndCommit()) + newSession._dispatchMessageFromTarget(message); + } + } + + _onClose() { + if (this._closed) + return; + this._closed = true; + this._transport.onmessage = null; + this._transport.onclose = null; + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + this._callbacks.clear(); + for (const session of this._sessions.values()) + session._onClosed(); + this._sessions.clear(); + } + + dispose() { + this._onClose(); + this._transport.close(); + } +} + +export const WKTargetSessionEvents = { + Disconnected: Symbol('TargetSessionEvents.Disconnected') +}; + +export class WKTargetSession extends EventEmitter { + _connection: WKConnection; + private _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); + private _targetType: string; + _sessionId: string; + _swappedOut = false; + private _provisionalMessages?: string[]; + on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + + constructor(connection: WKConnection, targetInfo: Protocol.Target.TargetInfo) { + super(); + const {targetId, type, isProvisional} = targetInfo; + this._connection = connection; + this._targetType = type; + this._sessionId = targetId; + if (isProvisional) + this._provisionalMessages = []; + } + + isProvisional() : boolean { + return !!this._provisionalMessages; + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + if (!this._connection) + return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`)); + const innerId = ++this._connection._lastId; + const messageObj = { + id: innerId, + method, + params + }; + debugWrappedMessage('SEND ► ' + JSON.stringify(messageObj, null, 2)); + // Serialize message before adding callback in case JSON throws. + const message = JSON.stringify(messageObj); + const result = new Promise((resolve, reject) => { + this._callbacks.set(innerId, {resolve, reject, error: new Error(), method}); + }); + this._connection.send('Target.sendMessageToTarget', { + message: message, targetId: this._sessionId + }).catch(e => { + // There is a possible race of the connection closure. We may have received + // targetDestroyed notification before response for the command, in that + // case it's safe to swallow the exception. + const callback = this._callbacks.get(innerId); + assert(!callback, 'Callback was not rejected when target was destroyed.'); + }); + return result; + } + + _addProvisionalMessage(message: string) { + this._provisionalMessages.push(message); + } + + _takeProvisionalMessagesAndCommit() : string[] { + const messages = this._provisionalMessages; + this._provisionalMessages = undefined; + return messages; + } + + _dispatchMessageFromTarget(message: string) { + console.assert(!this.isProvisional()); + const object = JSON.parse(message); + debugWrappedMessage('◀ RECV ' + JSON.stringify(object, null, 2)); + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } else { + assert(!object.id); + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } + + _onClosed() { + for (const callback of this._callbacks.values()) { + // TODO: make some calls like screenshot catch swapped out error and retry. + if (this._swappedOut) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target was swapped out.`)); + else + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + } + this._callbacks.clear(); + this._connection = null; + Promise.resolve().then(() => this.emit(WKTargetSessionEvents.Disconnected)); + } +} + +function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) + message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} + +export function isSwappedOutError(e: Error) { + return e.message.includes('Target was swapped out.'); +} diff --git a/src/webkit/wkExecutionContext.ts b/src/webkit/wkExecutionContext.ts new file mode 100644 index 0000000000..74462359b4 --- /dev/null +++ b/src/webkit/wkExecutionContext.ts @@ -0,0 +1,324 @@ +/** + * 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 { WKTargetSession, isSwappedOutError } from './wkConnection'; +import { helper } from '../helper'; +import { valueFromRemoteObject, releaseObject } from './protocolHelper'; +import { Protocol } from './protocol'; +import * as js from '../javascript'; + +export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__'; +const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; + +export class WKExecutionContext implements js.ExecutionContextDelegate { + private _globalObjectId?: Promise; + _session: WKTargetSession; + _contextId: number; + private _contextDestroyedCallback: () => void; + private _executionContextDestroyedPromise: Promise; + + constructor(client: WKTargetSession, contextPayload: Protocol.Runtime.ExecutionContextDescription) { + this._session = client; + this._contextId = contextPayload.id; + this._contextDestroyedCallback = null; + this._executionContextDestroyedPromise = new Promise((resolve, reject) => { + this._contextDestroyedCallback = resolve; + }); + } + + _dispose() { + this._contextDestroyedCallback(); + } + + async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { + if (helper.isString(pageFunction)) { + const contextId = this._contextId; + const expression: string = pageFunction as string; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix; + return this._session.send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue: false, + emulateUserGesture: true + }).then(response => { + if (response.result.type === 'object' && response.result.className === 'Promise') { + return Promise.race([ + this._executionContextDestroyedPromise.then(() => contextDestroyedResult), + this._awaitPromise(response.result.objectId), + ]); + } + return response; + }).then(response => { + if (response.wasThrown) + throw new Error('Evaluation failed: ' + response.result.description); + if (!returnByValue) + return context._createHandle(response.result); + if (response.result.objectId) + return this._returnObjectByValue(response.result.objectId); + return valueFromRemoteObject(response.result); + }).catch(rewriteError); + } + + if (typeof pageFunction !== 'function') + throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); + + let functionText = pageFunction.toString(); + try { + new Function('(' + functionText + ')'); + } catch (e1) { + // This means we might have a function shorthand. Try another + // time prefixing 'function '. + if (functionText.startsWith('async ')) + functionText = 'async function ' + functionText.substring('async '.length); + else + functionText = 'function ' + functionText; + try { + new Function('(' + functionText + ')'); + } catch (e2) { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function is not well-serializable!'); + } + } + + let serializableArgs; + if (args.some(isUnserializable)) { + serializableArgs = []; + const paramStrings = []; + for (const arg of args) { + if (isUnserializable(arg)) { + paramStrings.push(unserializableToString(arg)); + } else { + paramStrings.push('arguments[' + serializableArgs.length + ']'); + serializableArgs.push(arg); + } + } + functionText = `() => (${functionText})(${paramStrings.join(',')})`; + } else { + serializableArgs = args; + } + + const thisObjectId = await this._contextGlobalObjectId(); + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._session.send('Runtime.callFunctionOn', { + functionDeclaration: functionText + '\n' + suffix + '\n', + objectId: thisObjectId, + arguments: serializableArgs.map((arg: any) => this._convertArgument(arg)), + returnByValue: false, + emulateUserGesture: true + }); + } catch (err) { + if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON')) + err.message += ' Are you passing a nested JSHandle?'; + throw err; + } + return callFunctionOnPromise.then(response => { + if (response.result.type === 'object' && response.result.className === 'Promise') { + return Promise.race([ + this._executionContextDestroyedPromise.then(() => contextDestroyedResult), + this._awaitPromise(response.result.objectId), + ]); + } + return response; + }).then(response => { + if (response.wasThrown) + throw new Error('Evaluation failed: ' + response.result.description); + if (!returnByValue) + return context._createHandle(response.result); + if (response.result.objectId) + return this._returnObjectByValue(response.result.objectId); + return valueFromRemoteObject(response.result); + }).catch(rewriteError); + + function unserializableToString(arg) { + if (Object.is(arg, -0)) + return '-0'; + if (Object.is(arg, Infinity)) + return 'Infinity'; + if (Object.is(arg, -Infinity)) + return '-Infinity'; + if (Object.is(arg, NaN)) + return 'NaN'; + if (arg instanceof js.JSHandle) { + const remoteObj = toRemoteObject(arg); + if (!remoteObj.objectId) + return valueFromRemoteObject(remoteObj); + } + throw new Error('Unsupported value: ' + arg + ' (' + (typeof arg) + ')'); + } + + function isUnserializable(arg) { + if (typeof arg === 'bigint') + return true; + if (Object.is(arg, -0)) + return true; + if (Object.is(arg, Infinity)) + return true; + if (Object.is(arg, -Infinity)) + return true; + if (Object.is(arg, NaN)) + return true; + if (arg instanceof js.JSHandle) { + const remoteObj = toRemoteObject(arg); + if (!remoteObj.objectId) + return !Object.is(valueFromRemoteObject(remoteObj), remoteObj.value); + } + return false; + } + + /** + * @param {!Error} error + * @return {!Protocol.Runtime.evaluateReturnValue} + */ + function rewriteError(error) { + if (error.message.includes('Object couldn\'t be returned by value')) + return {result: {type: 'undefined'}}; + + if (error.message.includes('Missing injected script for given')) + throw new Error('Execution context was destroyed, most likely because of a navigation.'); + throw error; + } + } + + private _contextGlobalObjectId() { + if (!this._globalObjectId) { + this._globalObjectId = this._session.send('Runtime.evaluate', { + expression: 'this', + contextId: this._contextId + }).catch(e => { + if (isSwappedOutError(e)) + throw new Error('Execution context was destroyed, most likely because of a navigation.'); + throw e; + }).then(response => { + return response.result.objectId; + }); + } + return this._globalObjectId; + } + + private _awaitPromise(objectId: Protocol.Runtime.RemoteObjectId) { + return this._session.send('Runtime.awaitPromise', { + promiseObjectId: objectId, + returnByValue: false + }).catch(e => { + if (isSwappedOutError(e)) + return contextDestroyedResult; + throw e; + }); + } + + private _returnObjectByValue(objectId: Protocol.Runtime.RemoteObjectId) { + const serializeFunction = function() { + try { + return JSON.stringify(this); + } catch (e) { + if (e instanceof TypeError) + return void 0; + throw e; + } + }; + return this._session.send('Runtime.callFunctionOn', { + // Serialize object using standard JSON implementation to correctly pass 'undefined'. + functionDeclaration: serializeFunction + '\n' + suffix + '\n', + objectId: objectId, + returnByValue: true + }).catch(e => { + if (isSwappedOutError(e)) + return contextDestroyedResult; + throw e; + }).then(serializeResponse => { + if (serializeResponse.wasThrown) + throw new Error('Serialization failed: ' + serializeResponse.result.description); + // This is the case of too long property chain, not serializable to json string. + if (serializeResponse.result.type === 'undefined') + return undefined; + if (serializeResponse.result.type !== 'string') + throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2)); + return JSON.parse(serializeResponse.result.value); + }); + } + + async getProperties(handle: js.JSHandle): Promise> { + const response = await this._session.send('Runtime.getProperties', { + objectId: toRemoteObject(handle).objectId, + ownProperties: true + }); + const result = new Map(); + for (const property of response.properties) { + if (!property.enumerable) + continue; + result.set(property.name, handle._context._createHandle(property.value)); + } + return result; + } + + async releaseHandle(handle: js.JSHandle): Promise { + await releaseObject(this._session, toRemoteObject(handle)); + } + + async handleJSONValue(handle: js.JSHandle): Promise { + const remoteObject = toRemoteObject(handle); + if (remoteObject.objectId) { + const response = await this._session.send('Runtime.callFunctionOn', { + functionDeclaration: 'function() { return this; }', + objectId: remoteObject.objectId, + returnByValue: true + }); + return valueFromRemoteObject(response.result); + } + return valueFromRemoteObject(remoteObject); + } + + handleToString(handle: js.JSHandle, includeType: boolean): string { + const object = toRemoteObject(handle); + if (object.objectId) { + let type: string = object.subtype || object.type; + // FIXME: promise doesn't have special subtype in WebKit. + if (object.className === 'Promise') + type = 'promise'; + return 'JSHandle@' + type; + } + return (includeType ? 'JSHandle:' : '') + valueFromRemoteObject(object); + } + + private _convertArgument(arg: js.JSHandle | any) : Protocol.Runtime.CallArgument { + const objectHandle = arg && (arg instanceof js.JSHandle) ? arg : null; + if (objectHandle) { + if (objectHandle._context._delegate !== this) + throw new Error('JSHandles can be evaluated only in the context they were created!'); + if (objectHandle._disposed) + throw new Error('JSHandle is disposed!'); + const remoteObject = toRemoteObject(arg); + if (!remoteObject.objectId) + return { value: valueFromRemoteObject(remoteObject) }; + return { objectId: remoteObject.objectId }; + } + return { value: arg }; + } +} + +const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; +const contextDestroyedResult = { + wasThrown: true, + result: { + description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.' + } as Protocol.Runtime.RemoteObject +}; + +function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject { + return handle._remoteObject as Protocol.Runtime.RemoteObject; +} diff --git a/src/webkit/wkFrameManager.ts b/src/webkit/wkFrameManager.ts new file mode 100644 index 0000000000..67ea2a7ffb --- /dev/null +++ b/src/webkit/wkFrameManager.ts @@ -0,0 +1,440 @@ +/** + * 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 * as frames from '../frames'; +import { debugError, helper, RegisteredListener } from '../helper'; +import * as dom from '../dom'; +import * as network from '../network'; +import { WKTargetSession } from './wkConnection'; +import { Events } from '../events'; +import { WKExecutionContext, EVALUATION_SCRIPT_URL } from './wkExecutionContext'; +import { WKNetworkManager } from './wkNetworkManager'; +import { Page, PageDelegate } from '../page'; +import { Protocol } from './protocol'; +import * as dialog from '../dialog'; +import { WKBrowser } from './wkBrowser'; +import { BrowserContext } from '../browserContext'; +import { RawMouseImpl, RawKeyboardImpl } from './wkInput'; +import * as input from '../input'; +import * as types from '../types'; +import * as jpeg from 'jpeg-js'; +import { PNG } from 'pngjs'; + +const UTILITY_WORLD_NAME = '__playwright_utility_world__'; +const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; + +export class WKFrameManager implements PageDelegate { + readonly rawMouse: RawMouseImpl; + readonly rawKeyboard: RawKeyboardImpl; + _session: WKTargetSession; + readonly _page: Page; + private _browser: WKBrowser; + private readonly _networkManager: WKNetworkManager; + private readonly _contextIdToContext: Map; + private _isolatedWorlds: Set; + private _sessionListeners: RegisteredListener[] = []; + private readonly _bootstrapScripts: string[] = []; + + constructor(browser: WKBrowser, browserContext: BrowserContext) { + this._browser = browser; + this.rawKeyboard = new RawKeyboardImpl(); + this.rawMouse = new RawMouseImpl(); + this._contextIdToContext = new Map(); + this._isolatedWorlds = new Set(); + this._page = new Page(this, browserContext); + this._networkManager = new WKNetworkManager(this._page); + } + + setSession(session: WKTargetSession) { + helper.removeEventListeners(this._sessionListeners); + this.disconnectFromTarget(); + this._session = session; + this.rawKeyboard.setSession(session); + this.rawMouse.setSession(session); + this._addSessionListeners(); + this._networkManager.setSession(session); + this._isolatedWorlds = new Set(); + } + + // This method is called for provisional targets as well. The session passed as the parameter + // may be different from the current session and may be destroyed without becoming current. + async _initializeSession(session: WKTargetSession) { + const promises : Promise[] = [ + // Page agent must be enabled before Runtime. + session.send('Page.enable'), + session.send('Page.getResourceTree').then(({frameTree}) => this._handleFrameTree(frameTree)), + // Resource tree should be received before first execution context. + session.send('Runtime.enable').then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), + session.send('Console.enable'), + session.send('Page.setInterceptFileChooserDialog', { enabled: true }), + this._networkManager.initializeSession(session), + ]; + if (!session.isProvisional()) { + // FIXME: move dialog agent to web process. + // Dialog agent resides in the UI process and should not be re-enabled on navigation. + promises.push(session.send('Dialog.enable')); + } + const contextOptions = this._page.browserContext()._options; + if (contextOptions.userAgent) + promises.push(session.send('Page.overrideUserAgent', { value: contextOptions.userAgent })); + if (this._page._state.mediaType || this._page._state.colorScheme) + promises.push(this._setEmulateMedia(session, this._page._state.mediaType, this._page._state.colorScheme)); + if (contextOptions.javaScriptEnabled === false) + promises.push(session.send('Emulation.setJavaScriptEnabled', { enabled: false })); + if (contextOptions.bypassCSP) + promises.push(session.send('Page.setBypassCSP', { enabled: true })); + if (this._page._state.extraHTTPHeaders !== null) + promises.push(this._setExtraHTTPHeaders(session, this._page._state.extraHTTPHeaders)); + if (this._page._state.viewport) + promises.push(WKFrameManager._setViewport(session, this._page._state.viewport)); + await Promise.all(promises); + } + + didClose() { + helper.removeEventListeners(this._sessionListeners); + this._networkManager.dispose(); + this.disconnectFromTarget(); + this._page._didClose(); + } + + _addSessionListeners() { + this._sessionListeners = [ + helper.addEventListener(this._session, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), + helper.addEventListener(this._session, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), + helper.addEventListener(this._session, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), + helper.addEventListener(this._session, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)), + helper.addEventListener(this._session, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), + helper.addEventListener(this._session, 'Page.loadEventFired', event => this._onLifecycleEvent(event.frameId, 'load')), + helper.addEventListener(this._session, 'Page.domContentEventFired', event => this._onLifecycleEvent(event.frameId, 'domcontentloaded')), + helper.addEventListener(this._session, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), + helper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)), + helper.addEventListener(this._session, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)), + helper.addEventListener(this._session, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)) + ]; + } + + disconnectFromTarget() { + for (const context of this._contextIdToContext.values()) { + (context._delegate as WKExecutionContext)._dispose(); + context.frame._contextDestroyed(context); + } + this._contextIdToContext.clear(); + } + + _onFrameStoppedLoading(frameId: string) { + this._page._frameManager.frameStoppedLoading(frameId); + } + + _onLifecycleEvent(frameId: string, event: frames.LifecycleEvent) { + this._page._frameManager.frameLifecycleEvent(frameId, event); + } + + _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) { + this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); + this._onFrameNavigated(frameTree.frame, true); + if (!frameTree.childFrames) + return; + + for (const child of frameTree.childFrames) + this._handleFrameTree(child); + } + + _onFrameAttached(frameId: string, parentFrameId: string | null) { + this._page._frameManager.frameAttached(frameId, parentFrameId); + } + + _onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) { + const frame = this._page._frameManager.frame(framePayload.id); + for (const [contextId, context] of this._contextIdToContext) { + if (context.frame === frame) { + (context._delegate as WKExecutionContext)._dispose(); + this._contextIdToContext.delete(contextId); + frame._contextDestroyed(context); + } + } + // Append session id to avoid cross-process loaderId clash. + const documentId = this._session._sessionId + '::' + framePayload.loaderId; + this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url, framePayload.name || '', documentId, initial); + } + + _onFrameNavigatedWithinDocument(frameId: string, url: string) { + this._page._frameManager.frameCommittedSameDocumentNavigation(frameId, url); + } + + _onFrameDetached(frameId: string) { + this._page._frameManager.frameDetached(frameId); + } + + _onExecutionContextCreated(contextPayload : Protocol.Runtime.ExecutionContextDescription) { + if (this._contextIdToContext.has(contextPayload.id)) + return; + const frame = this._page._frameManager.frame(contextPayload.frameId); + if (!frame) + return; + const delegate = new WKExecutionContext(this._session, contextPayload); + const context = new dom.FrameExecutionContext(delegate, frame); + if (contextPayload.isPageContext) + frame._contextCreated('main', context); + else if (contextPayload.name === UTILITY_WORLD_NAME) + frame._contextCreated('utility', context); + this._contextIdToContext.set(contextPayload.id, context); + } + + async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + await this._session.send('Page.navigate', { url, frameId: frame._id, referrer }); + return {}; // We cannot get loaderId of cross-process navigation in advance. + } + + needsLifecycleResetOnSetContent(): boolean { + return true; + } + + async _onConsoleMessage(event: Protocol.Console.messageAddedPayload) { + const { type, level, text, parameters, url, line: lineNumber, column: columnNumber } = event.message; + if (level === 'debug' && parameters && parameters[0].value === BINDING_CALL_MESSAGE) { + const parsedObjectId = JSON.parse(parameters[1].objectId); + const context = this._contextIdToContext.get(parsedObjectId.injectedScriptId); + this._page._onBindingCalled(parameters[2].value, context); + return; + } + let derivedType: string = type; + if (type === 'log') + derivedType = level; + else if (type === 'timing') + derivedType = 'timeEnd'; + + const mainFrameContext = await this._page.mainFrame()._mainContext(); + const handles = (parameters || []).map(p => { + let context: dom.FrameExecutionContext | null = null; + if (p.objectId) { + const objectId = JSON.parse(p.objectId); + context = this._contextIdToContext.get(objectId.injectedScriptId); + } else { + context = mainFrameContext; + } + return context._createHandle(p); + }); + this._page._addConsoleMessage(derivedType, handles, { url, lineNumber: lineNumber - 1, columnNumber: columnNumber - 1 }, handles.length ? undefined : text); + } + + _onDialog(event: Protocol.Dialog.javascriptDialogOpeningPayload) { + this._page.emit(Events.Page.Dialog, new dialog.Dialog( + event.type as dialog.DialogType, + event.message, + async (accept: boolean, promptText?: string) => { + await this._session.send('Dialog.handleJavaScriptDialog', { accept, promptText }); + }, + event.defaultPrompt)); + } + + async _onFileChooserOpened(event: {frameId: Protocol.Network.FrameId, element: Protocol.Runtime.RemoteObject}) { + const context = await this._page._frameManager.frame(event.frameId)._mainContext(); + const handle = context._createHandle(event.element).asElement()!; + this._page._onFileChooserOpened(handle); + } + + async _ensureIsolatedWorld(name: string) { + if (this._isolatedWorlds.has(name)) + return; + this._isolatedWorlds.add(name); + await this._session.send('Page.createIsolatedWorld', { + name, + source: `//# sourceURL=${EVALUATION_SCRIPT_URL}` + }); + } + + private async _setExtraHTTPHeaders(session: WKTargetSession, headers: network.Headers): Promise { + await session.send('Network.setExtraHTTPHeaders', { headers }); + } + + private async _setEmulateMedia(session: WKTargetSession, mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise { + const promises = []; + promises.push(session.send('Page.setEmulatedMedia', { media: mediaType || '' })); + if (mediaColorScheme !== null) { + let appearance: any = ''; + switch (mediaColorScheme) { + case 'light': appearance = 'Light'; break; + case 'dark': appearance = 'Dark'; break; + } + promises.push(session.send('Page.setForcedAppearance', { appearance })); + } + await Promise.all(promises); + } + + async setExtraHTTPHeaders(headers: network.Headers): Promise { + await this._setExtraHTTPHeaders(this._session, headers); + } + + async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise { + await this._setEmulateMedia(this._session, mediaType, mediaColorScheme); + } + + async setViewport(viewport: types.Viewport): Promise { + return WKFrameManager._setViewport(this._session, viewport); + } + + private static async _setViewport(session: WKTargetSession, viewport: types.Viewport): Promise { + if (viewport.isMobile || viewport.isLandscape || viewport.hasTouch) + throw new Error('Not implemented'); + const width = viewport.width; + const height = viewport.height; + await session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: viewport.deviceScaleFactor || 1 }); + } + + setCacheEnabled(enabled: boolean): Promise { + return this._networkManager.setCacheEnabled(enabled); + } + + async reload(): Promise { + await this._session.send('Page.reload'); + } + + goBack(): Promise { + return this._session.send('Page.goBack').then(() => true).catch(error => { + if (error instanceof Error && error.message.includes(`Protocol error (Page.goBack): Failed to go`)) + return false; + throw error; + }); + } + + goForward(): Promise { + return this._session.send('Page.goForward').then(() => true).catch(error => { + if (error instanceof Error && error.message.includes(`Protocol error (Page.goForward): Failed to go`)) + return false; + throw error; + }); + } + + async exposeBinding(name: string, bindingFunction: string): Promise { + const script = `self.${name} = (param) => console.debug('${BINDING_CALL_MESSAGE}', {}, param); ${bindingFunction}`; + this._bootstrapScripts.unshift(script); + const source = this._bootstrapScripts.join(';'); + await this._session.send('Page.setBootstrapScript', { source }); + await Promise.all(this._page.frames().map(frame => frame.evaluate(script).catch(debugError))); + } + + async evaluateOnNewDocument(script: string): Promise { + this._bootstrapScripts.push(script); + const source = this._bootstrapScripts.join(';'); + // TODO(yurys): support process swap on navigation. + await this._session.send('Page.setBootstrapScript', { source }); + } + + async closePage(runBeforeUnload: boolean): Promise { + this._browser._closePage(this._page, runBeforeUnload); + } + + getBoundingBoxForScreenshot(handle: dom.ElementHandle): Promise { + return handle.boundingBox(); + } + + canScreenshotOutsideViewport(): boolean { + return false; + } + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise { + // TODO: line below crashes, sort it out. + this._session.send('Page.setDefaultBackgroundColorOverride', { color }); + } + + async takeScreenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise { + const rect = options.clip || { x: 0, y: 0, width: viewport.width, height: viewport.height }; + const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: options.fullPage ? 'Page' : 'Viewport' }); + const prefix = 'data:image/png;base64,'; + let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); + if (format === 'jpeg') + buffer = jpeg.encode(PNG.sync.read(buffer)).data; + return buffer; + } + + async resetViewport(oldSize: types.Size): Promise { + await this._session.send('Emulation.setDeviceMetricsOverride', { ...oldSize, deviceScaleFactor: 0 }); + } + + async getContentFrame(handle: dom.ElementHandle): Promise { + const nodeInfo = await this._session.send('DOM.describeNode', { + objectId: toRemoteObject(handle).objectId + }); + if (!nodeInfo.contentFrameId) + return null; + return this._page._frameManager.frame(nodeInfo.contentFrameId); + } + + async getOwnerFrame(handle: dom.ElementHandle): Promise { + return handle._context.frame; + } + + isElementHandle(remoteObject: any): boolean { + return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node'; + } + + async getBoundingBox(handle: dom.ElementHandle): Promise { + const quads = await this.getContentQuads(handle); + if (!quads || !quads.length) + return null; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const quad of quads) { + for (const point of quad) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + async getContentQuads(handle: dom.ElementHandle): Promise { + const result = await this._session.send('DOM.getContentQuads', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] } + ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + return this._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); + } + + async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { + const objectId = toRemoteObject(handle).objectId; + await this._session.send('DOM.setInputFiles', { objectId, files }); + } + + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { + const result = await this._session.send('DOM.resolveNode', { + objectId: toRemoteObject(handle).objectId, + executionContextId: (to._delegate as WKExecutionContext)._contextId + }).catch(debugError); + if (!result || result.object.subtype === 'null') + throw new Error('Unable to adopt element handle from a different document'); + return to._createHandle(result.object) as dom.ElementHandle; + } +} + +function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject { + return handle._remoteObject as Protocol.Runtime.RemoteObject; +} diff --git a/src/webkit/wkInput.ts b/src/webkit/wkInput.ts new file mode 100644 index 0000000000..abaf4058f3 --- /dev/null +++ b/src/webkit/wkInput.ts @@ -0,0 +1,123 @@ +/** + * 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 * as input from '../input'; +import { helper } from '../helper'; +import { macEditingCommands } from '../usKeyboardLayout'; +import { WKTargetSession } from './wkConnection'; + +function toModifiersMask(modifiers: Set): number { + // From Source/WebKit/Shared/WebEvent.h + let mask = 0; + if (modifiers.has('Shift')) + mask |= 1; + if (modifiers.has('Control')) + mask |= 2; + if (modifiers.has('Alt')) + mask |= 4; + if (modifiers.has('Meta')) + mask |= 8; + return mask; +} + +export class RawKeyboardImpl implements input.RawKeyboard { + private _session: WKTargetSession; + + setSession(session: WKTargetSession) { + this._session = session; + } + + async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { + const parts = []; + for (const modifier of (['Shift', 'Control', 'Alt', 'Meta']) as input.Modifier[]) { + if (modifiers.has(modifier)) + parts.push(modifier); + } + parts.push(code); + const shortcut = parts.join('+'); + let commands = macEditingCommands[shortcut]; + if (helper.isString(commands)) + commands = [commands]; + await this._session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + modifiers: toModifiersMask(modifiers), + windowsVirtualKeyCode: keyCode, + code, + key, + text, + unmodifiedText: text, + autoRepeat, + macCommands: commands, + isKeypad: location === input.keypadLocation + }); + } + + async keyup(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise { + await this._session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: toModifiersMask(modifiers), + key, + windowsVirtualKeyCode: keyCode, + code, + isKeypad: location === input.keypadLocation + }); + } + + async sendText(text: string): Promise { + await this._session.send('Page.insertText', { text }); + } +} + +export class RawMouseImpl implements input.RawMouse { + private _client: WKTargetSession; + + setSession(client: WKTargetSession) { + this._client = client; + } + + async move(x: number, y: number, button: input.Button | 'none', buttons: Set, modifiers: Set): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'move', + button, + x, + y, + modifiers: toModifiersMask(modifiers) + }); + } + + async down(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'down', + button, + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } + + async up(x: number, y: number, button: input.Button, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'up', + button, + x, + y, + modifiers: toModifiersMask(modifiers), + clickCount + }); + } +} diff --git a/src/webkit/wkLauncher.ts b/src/webkit/wkLauncher.ts new file mode 100644 index 0000000000..20dfdf2799 --- /dev/null +++ b/src/webkit/wkLauncher.ts @@ -0,0 +1,179 @@ +/** + * 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 { assert } from '../helper'; +import { WKBrowser } from './wkBrowser'; +import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; +import { PipeTransport, SlowMoTransport } from '../transport'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import * as util from 'util'; +import * as os from 'os'; +import { launchProcess } from '../processLauncher'; +import { BrowserServer } from '../browser'; + +const DEFAULT_ARGS = [ +]; + +export class WKLauncher { + private _projectRoot: string; + private _preferredRevision: string; + + constructor(projectRoot: string, preferredRevision: string) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + } + + defaultArgs(options: any = {}) { + const { + args = [], + } = options; + const webkitArguments = [...DEFAULT_ARGS]; + webkitArguments.push(...args); + return webkitArguments; + } + + async launch(options: LauncherLaunchOptions = {}): Promise> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + slowMo = 0, + } = options; + + const webkitArguments = []; + if (!ignoreDefaultArgs) + webkitArguments.push(...this.defaultArgs(options)); + else + webkitArguments.push(...args); + + let webkitExecutable = executablePath; + if (!executablePath) { + const {missingText, executablePath} = this._resolveExecutablePath(); + if (missingText) + throw new Error(missingText); + webkitExecutable = executablePath; + } + webkitArguments.push('--inspector-pipe'); + // Headless options is only implemented on Mac at the moment. + if (process.platform === 'darwin' && options.headless !== false) + webkitArguments.push('--headless'); + + const launchedProcess = await launchProcess({ + executablePath: webkitExecutable, + args: webkitArguments, + env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: true, + tempDir: null + }, () => { + if (!browser) + return Promise.reject(); + browser.close(); + }); + + let browser: WKBrowser | undefined; + try { + const transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream); + browser = new WKBrowser(SlowMoTransport.wrap(transport, slowMo)); + await browser._waitForTarget(t => t._type === 'page'); + return new BrowserServer(browser, launchedProcess, ''); + } catch (e) { + if (browser) + await browser.close(); + throw e; + } + } + + executablePath(): string { + return this._resolveExecutablePath().executablePath; + } + + _resolveExecutablePath(): { executablePath: string; missingText: string | null; } { + const browserFetcher = createBrowserFetcher(this._projectRoot); + const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); + const missingText = !revisionInfo.local ? `WebKit revision is not downloaded. Run "npm install" or "yarn install"` : null; + return {executablePath: revisionInfo.executablePath, missingText}; + } + +} + +export type LauncherLaunchOptions = { + ignoreDefaultArgs?: boolean, + args?: string[], + executablePath?: string, + handleSIGINT?: boolean, + handleSIGTERM?: boolean, + handleSIGHUP?: boolean, + headless?: boolean, + dumpio?: boolean, + env?: {[key: string]: string} | undefined, + slowMo?: number, +}; + +let cachedMacVersion = undefined; +function getMacVersion() { + if (!cachedMacVersion) { + const [major, minor] = execSync('sw_vers -productVersion').toString('utf8').trim().split('.'); + cachedMacVersion = major + '.' + minor; + } + return cachedMacVersion; +} + +export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher { + const downloadURLs = { + linux: '%s/builds/webkit/%s/minibrowser-linux.zip', + mac: '%s/builds/webkit/%s/minibrowser-mac-%s.zip', + }; + + const defaultOptions = { + path: path.join(projectRoot, '.local-webkit'), + host: 'https://playwrightaccount.blob.core.windows.net', + platform: (() => { + const platform = os.platform(); + if (platform === 'darwin') + return 'mac'; + if (platform === 'linux') + return 'linux'; + if (platform === 'win32') + return 'linux'; // Windows gets linux binaries and uses WSL + return platform; + })() + }; + options = { + ...defaultOptions, + ...options, + }; + assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform); + + return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => { + return { + downloadUrl: (platform === 'mac') ? + util.format(downloadURLs[platform], options.host, revision, getMacVersion()) : + util.format(downloadURLs[platform], options.host, revision), + executablePath: 'pw_run.sh', + }; + }); +} diff --git a/src/webkit/wkNetworkManager.ts b/src/webkit/wkNetworkManager.ts new file mode 100644 index 0000000000..04bcefa9b4 --- /dev/null +++ b/src/webkit/wkNetworkManager.ts @@ -0,0 +1,173 @@ +/** + * 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 { WKTargetSession } from './wkConnection'; +import { Page } from '../page'; +import { helper, RegisteredListener } from '../helper'; +import { Protocol } from './protocol'; +import * as network from '../network'; +import * as frames from '../frames'; + +export class WKNetworkManager { + private _session: WKTargetSession; + private _page: Page; + private _requestIdToRequest = new Map(); + private _attemptedAuthentications = new Set(); + private _userCacheDisabled = false; + private _sessionListeners: RegisteredListener[] = []; + + constructor(page: Page) { + this._page = page; + } + + setSession(session: WKTargetSession) { + helper.removeEventListeners(this._sessionListeners); + this._session = session; + this._sessionListeners = [ + helper.addEventListener(this._session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)), + helper.addEventListener(this._session, 'Network.responseReceived', this._onResponseReceived.bind(this)), + helper.addEventListener(this._session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), + helper.addEventListener(this._session, 'Network.loadingFailed', this._onLoadingFailed.bind(this)), + ]; + } + + async initializeSession(session: WKTargetSession) { + await session.send('Network.enable'); + } + + dispose() { + helper.removeEventListeners(this._sessionListeners); + } + + async setCacheEnabled(enabled: boolean) { + this._userCacheDisabled = !enabled; + await this._updateProtocolCacheDisabled(); + } + + async _updateProtocolCacheDisabled() { + await this._session.send('Network.setResourceCachingDisabled', { + disabled: this._userCacheDisabled + }); + } + + _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { + let redirectChain: network.Request[] = []; + if (event.redirectResponse) { + const request = this._requestIdToRequest.get(event.requestId); + // If we connect late to the target, we could have missed the requestWillBeSent event. + if (request) { + this._handleRequestRedirect(request, event.redirectResponse); + redirectChain = request.request._redirectChain; + } + } + const frame = this._page._frameManager.frame(event.frameId); + // TODO(einbinder) this will fail if we are an XHR document request + const isNavigationRequest = event.type === 'Document'; + const documentId = isNavigationRequest ? this._session._sessionId + '::' + event.loaderId : undefined; + const request = new InterceptableRequest(frame, undefined, event, redirectChain, documentId); + this._requestIdToRequest.set(event.requestId, request); + this._page._frameManager.requestStarted(request.request); + } + + _createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response): network.Response { + const remoteAddress: network.RemoteAddress = { ip: '', port: 0 }; + const getResponseBody = async () => { + const response = await this._session.send('Network.getResponseBody', { requestId: request._requestId }); + return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + }; + return new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObject(responsePayload.headers), remoteAddress, getResponseBody); + } + + _handleRequestRedirect(request: InterceptableRequest, responsePayload: Protocol.Network.Response) { + const response = this._createResponse(request, responsePayload); + request.request._redirectChain.push(request.request); + response._requestFinished(new Error('Response body is unavailable for redirect responses')); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this._page._frameManager.requestReceivedResponse(response); + this._page._frameManager.requestFinished(request.request); + } + + _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // FileUpload sends a response without a matching request. + if (!request) + return; + const response = this._createResponse(request, event.response); + this._page._frameManager.requestReceivedResponse(response); + } + + _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) + return; + + // Under certain conditions we never get the Network.responseReceived + // event from protocol. @see https://crbug.com/883475 + if (request.request.response()) + request.request.response()._requestFinished(); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this._page._frameManager.requestFinished(request.request); + } + + _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) + return; + const response = request.request.response(); + if (response) + response._requestFinished(); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + request.request._setFailureText(event.errorText); + this._page._frameManager.requestFailed(request.request, event.errorText.includes('cancelled')); + } +} + +const interceptableRequestSymbol = Symbol('interceptableRequest'); + +export function toInterceptableRequest(request: network.Request): InterceptableRequest { + return (request as any)[interceptableRequestSymbol]; +} + +class InterceptableRequest { + readonly request: network.Request; + _requestId: string; + _interceptionId: string; + _documentId: string | undefined; + + constructor(frame: frames.Frame | null, interceptionId: string, event: Protocol.Network.requestWillBeSentPayload, redirectChain: network.Request[], documentId: string | undefined) { + this._requestId = event.requestId; + this._interceptionId = interceptionId; + this._documentId = documentId; + this.request = new network.Request(frame, redirectChain, documentId, event.request.url, + event.type ? event.type.toLowerCase() : 'Unknown', event.request.method, event.request.postData, headersObject(event.request.headers)); + (this.request as any)[interceptableRequestSymbol] = this; + } +} + +function headersObject(headers: Protocol.Network.Headers): network.Headers { + const result: network.Headers = {}; + for (const key of Object.keys(headers)) + result[key.toLowerCase()] = headers[key]; + return result; +} diff --git a/src/webkit/wkPlaywright.ts b/src/webkit/wkPlaywright.ts new file mode 100644 index 0000000000..f71c3d30eb --- /dev/null +++ b/src/webkit/wkPlaywright.ts @@ -0,0 +1,73 @@ +/** + * 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 * as browsers from '../browser'; +import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher'; +import { DeviceDescriptors } from '../deviceDescriptors'; +import * as Errors from '../errors'; +import { WKLauncher, LauncherLaunchOptions, createBrowserFetcher } from './wkLauncher'; +import { WKBrowser } from './wkBrowser'; + +export class WKPlaywright { + private _projectRoot: string; + private _launcher: WKLauncher; + readonly _revision: string; + + constructor(projectRoot: string, preferredRevision: string) { + this._projectRoot = projectRoot; + this._launcher = new WKLauncher(projectRoot, preferredRevision); + this._revision = preferredRevision; + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + const fetcher = this.createBrowserFetcher(options); + const revisionInfo = fetcher.revisionInfo(this._revision); + await fetcher.download(this._revision, options ? options.onProgress : undefined); + return revisionInfo; + } + + async launch(options: (LauncherLaunchOptions) | undefined): Promise { + const server = await this._launcher.launch(options); + return server.connect(); + } + + async launchServer(options: (LauncherLaunchOptions) | undefined): Promise> { + return this._launcher.launch(options); + } + + executablePath(): string { + return this._launcher.executablePath(); + } + + get devices(): any { + const result = DeviceDescriptors.slice(); + for (const device of DeviceDescriptors) + result[device.name] = device; + return result; + } + + get errors(): any { + return Errors; + } + + defaultArgs(options: any | undefined): string[] { + return this._launcher.defaultArgs(options); + } + + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); + } +} diff --git a/src/webkit/wkTarget.ts b/src/webkit/wkTarget.ts new file mode 100644 index 0000000000..776540e3b5 --- /dev/null +++ b/src/webkit/wkTarget.ts @@ -0,0 +1,103 @@ +/** + * Copyright 2019 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 { BrowserContext } from '../browserContext'; +import { Page } from '../page'; +import { Protocol } from './protocol'; +import { WKTargetSession, WKTargetSessionEvents } from './wkConnection'; +import { WKFrameManager } from './wkFrameManager'; +import { WKBrowser } from './wkBrowser'; + +const targetSymbol = Symbol('target'); + +export class WKTarget { + readonly _browserContext: BrowserContext; + readonly _targetId: string; + readonly _type: 'page' | 'service-worker' | 'worker'; + private readonly _session: WKTargetSession; + private _pagePromise: Promise | null = null; + private _browser: WKBrowser; + _frameManager: WKFrameManager | null = null; + + static fromPage(page: Page): WKTarget { + return (page as any)[targetSymbol]; + } + + constructor(browser: WKBrowser, session: WKTargetSession, targetInfo: Protocol.Target.TargetInfo, browserContext: BrowserContext) { + const {targetId, type} = targetInfo; + this._browser = browser; + this._session = session; + this._browserContext = browserContext; + this._targetId = targetId; + this._type = type; + /** @type {?Promise} */ + this._pagePromise = null; + } + + _didClose() { + if (this._frameManager) + this._frameManager.didClose(); + } + + async _initializeSession(session: WKTargetSession) { + if (!this._frameManager) + return; + await this._frameManager._initializeSession(session).catch(e => { + // Swallow initialization errors due to newer target swap in, + // since we will reinitialize again. + if (this._frameManager) + throw e; + }); + } + + async _swapWith(oldTarget: WKTarget) { + if (!oldTarget._pagePromise) + return; + this._pagePromise = oldTarget._pagePromise; + this._frameManager = oldTarget._frameManager; + // Swapped out target should not be accessed by anyone. Reset page promise so that + // old target does not close the page on connection reset. + oldTarget._pagePromise = null; + oldTarget._frameManager = null; + this._adoptPage(); + } + + private _adoptPage() { + (this._frameManager._page as any)[targetSymbol] = this; + this._session.once(WKTargetSessionEvents.Disconnected, () => { + // Once swapped out, we reset _page and won't call _didDisconnect for old session. + if (this._frameManager) + this._frameManager._page._didDisconnect(); + }); + this._frameManager.setSession(this._session); + } + + async page(): Promise { + if (this._type === 'page' && !this._pagePromise) { + this._frameManager = new WKFrameManager(this._browser, this._browserContext); + // Reference local page variable as |this._frameManager| may be + // cleared on swap. + const page = this._frameManager._page; + this._pagePromise = new Promise(async f => { + this._adoptPage(); + await this._initializeSession(this._session); + f(page); + }); + } + return this._pagePromise; + } +}