mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 13:45:36 +03:00
chore: rename the world
This commit is contained in:
parent
b746733306
commit
91c309797d
@ -22,7 +22,7 @@ for (const className in api.Chromium) {
|
||||
helper.installAsyncStackHooks(api.Chromium[className]);
|
||||
}
|
||||
|
||||
const {Playwright} = require('./lib/chromium/Playwright');
|
||||
const { CRPlaywright } = require('./lib/chromium/crPlaywright');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
module.exports = new Playwright(__dirname, packageJson.playwright.chromium_revision);
|
||||
module.exports = new CRPlaywright(__dirname, packageJson.playwright.chromium_revision);
|
||||
|
@ -22,7 +22,7 @@ for (const className in api.Firefox) {
|
||||
helper.installAsyncStackHooks(api.Firefox[className]);
|
||||
}
|
||||
|
||||
const {Playwright} = require('./lib/firefox/Playwright');
|
||||
const { FFPlaywright } = require('./lib/firefox/ffPlaywright');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
module.exports = new Playwright(__dirname, packageJson.playwright.firefox_revision);
|
||||
module.exports = new FFPlaywright(__dirname, packageJson.playwright.firefox_revision);
|
||||
|
@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { Events } from './events';
|
||||
import { assert, helper } from '../helper';
|
||||
import { BrowserContext, BrowserContextOptions } from '../browserContext';
|
||||
import { Connection, ConnectionEvents, CDPSession } from './Connection';
|
||||
import { Page } from '../page';
|
||||
import { Target } from './Target';
|
||||
import { Protocol } from './protocol';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import * as browser from '../browser';
|
||||
import * as network from '../network';
|
||||
import { Permissions } from './features/permissions';
|
||||
import { Overrides } from './features/overrides';
|
||||
import { Worker } from './features/workers';
|
||||
import { ConnectionTransport } from '../transport';
|
||||
import { readProtocolStream } from './protocolHelper';
|
||||
|
||||
export class Browser extends EventEmitter implements browser.Browser {
|
||||
_connection: Connection;
|
||||
_client: CDPSession;
|
||||
private _defaultContext: BrowserContext;
|
||||
private _contexts = new Map<string, BrowserContext>();
|
||||
_targets = new Map<string, Target>();
|
||||
|
||||
private _tracingRecording = false;
|
||||
private _tracingPath = '';
|
||||
private _tracingClient: CDPSession | undefined;
|
||||
|
||||
static async create(
|
||||
transport: ConnectionTransport) {
|
||||
const connection = new Connection(transport);
|
||||
|
||||
const { browserContextIds } = await connection.rootSession.send('Target.getBrowserContexts');
|
||||
const browser = new Browser(connection, browserContextIds);
|
||||
await connection.rootSession.send('Target.setDiscoverTargets', { discover: true });
|
||||
await browser.waitForTarget(t => t.type() === 'page');
|
||||
return browser;
|
||||
}
|
||||
|
||||
constructor(connection: Connection, 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(Events.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: Overrides | null = null;
|
||||
const context = new BrowserContext({
|
||||
pages: async (): Promise<Page[]> => {
|
||||
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<Page> => {
|
||||
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 FrameManager)._client;
|
||||
const promises: Promise<any>[] = [ 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 FrameManager)._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<void> => {
|
||||
assert(contextId, 'Non-incognito profiles cannot be closed!');
|
||||
await this._client.send('Target.disposeBrowserContext', {browserContextId: contextId || undefined});
|
||||
this._contexts.delete(contextId);
|
||||
},
|
||||
|
||||
cookies: async (): Promise<network.NetworkCookie[]> => {
|
||||
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<void> => {
|
||||
await this._client.send('Storage.clearCookies', { browserContextId: contextId || undefined });
|
||||
},
|
||||
|
||||
setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise<void> => {
|
||||
await this._client.send('Storage.setCookies', { cookies, browserContextId: contextId || undefined });
|
||||
},
|
||||
}, options);
|
||||
overrides = new Overrides(context);
|
||||
(context as any).permissions = new Permissions(this._client, contextId);
|
||||
(context as any).overrides = overrides;
|
||||
return context;
|
||||
}
|
||||
|
||||
async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
|
||||
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 Target(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: Target.fromPage(page)._targetId });
|
||||
}
|
||||
|
||||
_allTargets(): Target[] {
|
||||
return Array.from(this._targets.values()).filter(target => target._isInitialized);
|
||||
}
|
||||
|
||||
async _activatePage(page: Page) {
|
||||
await (page._delegate as FrameManager)._client.send('Target.activateTarget', {targetId: Target.fromPage(page)._targetId});
|
||||
}
|
||||
|
||||
async waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise<Target> {
|
||||
const {
|
||||
timeout = 30000
|
||||
} = options;
|
||||
const existingTarget = this._allTargets().find(predicate);
|
||||
if (existingTarget)
|
||||
return existingTarget;
|
||||
let resolve: (target: Target) => void;
|
||||
const targetPromise = new Promise<Target>(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: Target) {
|
||||
if (predicate(target))
|
||||
resolve(target);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this._connection.rootSession.send('Browser.close');
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
browserTarget(): Target {
|
||||
return [...this._targets.values()].find(t => t.type() === 'browser');
|
||||
}
|
||||
|
||||
serviceWorker(target: Target): Promise<Worker | null> {
|
||||
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 FrameManager)._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<Buffer> {
|
||||
assert(this._tracingClient, 'Tracing was not started.');
|
||||
let fulfill: (buffer: Buffer) => void;
|
||||
const contentPromise = new Promise<Buffer>(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): Target[] {
|
||||
const targets = this._allTargets();
|
||||
return context ? targets.filter(t => t.browserContext() === context) : targets;
|
||||
}
|
||||
|
||||
pageTarget(page: Page): Target {
|
||||
return Target.fromPage(page);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._connection.dispose();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return !this._connection._closed;
|
||||
}
|
||||
}
|
||||
|
||||
async function emulateTimezone(session: CDPSession, 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;
|
||||
}
|
||||
}
|
@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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 Connection extends EventEmitter {
|
||||
private _lastId = 0;
|
||||
private _transport: ConnectionTransport;
|
||||
private _sessions = new Map<string, CDPSession>();
|
||||
readonly rootSession: CDPSession;
|
||||
_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 CDPSession(this, 'browser', '');
|
||||
this._sessions.set('', this.rootSession);
|
||||
}
|
||||
|
||||
static fromSession(session: CDPSession): Connection {
|
||||
return session._connection;
|
||||
}
|
||||
|
||||
session(sessionId: string): CDPSession | 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 CDPSession(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<CDPSession> {
|
||||
const { sessionId } = await this.rootSession.send('Target.attachToTarget', { targetId: targetInfo.targetId, flatten: true });
|
||||
return this._sessions.get(sessionId);
|
||||
}
|
||||
|
||||
async createBrowserSession(): Promise<CDPSession> {
|
||||
const { sessionId } = await this.rootSession.send('Target.attachToBrowserTarget');
|
||||
return this._sessions.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const CDPSessionEvents = {
|
||||
Disconnected: Symbol('Events.CDPSession.Disconnected')
|
||||
};
|
||||
|
||||
export class CDPSession extends EventEmitter {
|
||||
_connection: Connection;
|
||||
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||
private _targetType: string;
|
||||
private _sessionId: string;
|
||||
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
|
||||
constructor(connection: Connection, targetType: string, sessionId: string) {
|
||||
super();
|
||||
this._connection = connection;
|
||||
this._targetType = targetType;
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
send<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
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(CDPSessionEvents.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;
|
||||
}
|
@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
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 ExecutionContextDelegate implements js.ExecutionContextDelegate {
|
||||
_client: CDPSession;
|
||||
_contextId: number;
|
||||
|
||||
constructor(client: CDPSession, contextPayload: Protocol.Runtime.ExecutionContextDescription) {
|
||||
this._client = client;
|
||||
this._contextId = contextPayload.id;
|
||||
}
|
||||
|
||||
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
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<Map<string, js.JSHandle>> {
|
||||
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<void> {
|
||||
await releaseObject(this._client, toRemoteObject(handle));
|
||||
}
|
||||
|
||||
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
|
||||
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;
|
||||
}
|
@ -1,459 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as dom from '../dom';
|
||||
import * as frames from '../frames';
|
||||
import { debugError, helper, RegisteredListener } from '../helper';
|
||||
import * as network from '../network';
|
||||
import { CDPSession } from './Connection';
|
||||
import { EVALUATION_SCRIPT_URL, ExecutionContextDelegate } from './ExecutionContext';
|
||||
import { NetworkManager } from './NetworkManager';
|
||||
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 './Input';
|
||||
import { Accessibility } from './features/accessibility';
|
||||
import { Coverage } from './features/coverage';
|
||||
import { PDF } from './features/pdf';
|
||||
import { Workers } from './features/workers';
|
||||
import { Interception } from './features/interception';
|
||||
import { Browser } from './Browser';
|
||||
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 FrameManager implements PageDelegate {
|
||||
_client: CDPSession;
|
||||
private _page: Page;
|
||||
readonly _networkManager: NetworkManager;
|
||||
private _contextIdToContext = new Map<number, dom.FrameExecutionContext>();
|
||||
private _isolatedWorlds = new Set<string>();
|
||||
private _eventListeners: RegisteredListener[];
|
||||
rawMouse: RawMouseImpl;
|
||||
rawKeyboard: RawKeyboardImpl;
|
||||
private _browser: Browser;
|
||||
|
||||
constructor(client: CDPSession, browser: Browser, 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 NetworkManager(client, this._page);
|
||||
(this._page as any).accessibility = new Accessibility(client);
|
||||
(this._page as any).coverage = new Coverage(client);
|
||||
(this._page as any).pdf = new PDF(client);
|
||||
(this._page as any).workers = new Workers(client, this._page._addConsoleMessage.bind(this._page), error => this._page.emit(Events.Page.PageError, error));
|
||||
(this._page as any).interception = new Interception(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<frames.GotoResult> {
|
||||
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 ExecutionContextDelegate(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<void> {
|
||||
await this._client.send('Network.setExtraHTTPHeaders', { headers });
|
||||
}
|
||||
|
||||
async setViewport(viewport: types.Viewport): Promise<void> {
|
||||
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<void> {
|
||||
const features = mediaColorScheme ? [{ name: 'prefers-color-scheme', value: mediaColorScheme }] : [];
|
||||
await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features });
|
||||
}
|
||||
|
||||
setCacheEnabled(enabled: boolean): Promise<void> {
|
||||
return this._networkManager.setCacheEnabled(enabled);
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this._client.send('Page.reload');
|
||||
}
|
||||
|
||||
private async _go(delta: number): Promise<boolean> {
|
||||
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<boolean> {
|
||||
return this._go(-1);
|
||||
}
|
||||
|
||||
goForward(): Promise<boolean> {
|
||||
return this._go(+1);
|
||||
}
|
||||
|
||||
async evaluateOnNewDocument(source: string): Promise<void> {
|
||||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
}
|
||||
|
||||
async closePage(runBeforeUnload: boolean): Promise<void> {
|
||||
if (runBeforeUnload)
|
||||
await this._client.send('Page.close');
|
||||
else
|
||||
await this._browser._closePage(this._page);
|
||||
}
|
||||
|
||||
async getBoundingBoxForScreenshot(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
|
||||
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<void> {
|
||||
await this._client.send('Emulation.setDefaultBackgroundColorOverride', { color });
|
||||
}
|
||||
|
||||
async takeScreenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
|
||||
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<void> {
|
||||
await this._client.send('Emulation.setDeviceMetricsOverride', { mobile: false, width: 0, height: 0, deviceScaleFactor: 0 });
|
||||
}
|
||||
|
||||
async getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null> {
|
||||
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<frames.Frame | null> {
|
||||
// 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<types.Rect | null> {
|
||||
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<types.Quad[] | null> {
|
||||
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<void> {
|
||||
await handle.evaluate(input.setFileInputFunction, files);
|
||||
}
|
||||
|
||||
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
|
||||
const nodeInfo = await this._client.send('DOM.describeNode', {
|
||||
objectId: toRemoteObject(handle).objectId,
|
||||
});
|
||||
return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to) as Promise<dom.ElementHandle<T>>;
|
||||
}
|
||||
|
||||
async adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId, to: dom.FrameExecutionContext): Promise<dom.ElementHandle> {
|
||||
const result = await this._client.send('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
executionContextId: (to._delegate as ExecutionContextDelegate)._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;
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as input from '../input';
|
||||
import { CDPSession } from './Connection';
|
||||
|
||||
function toModifiersMask(modifiers: Set<input.Modifier>): 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: CDPSession;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async keydown(modifiers: Set<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
|
||||
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<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
||||
await this._client.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
modifiers: toModifiersMask(modifiers),
|
||||
key,
|
||||
windowsVirtualKeyCode: keyCodeWithoutLocation,
|
||||
code,
|
||||
location
|
||||
});
|
||||
}
|
||||
|
||||
async sendText(text: string): Promise<void> {
|
||||
await this._client.send('Input.insertText', { text });
|
||||
}
|
||||
}
|
||||
|
||||
export class RawMouseImpl implements input.RawMouse {
|
||||
private _client: CDPSession;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async move(x: number, y: number, button: input.Button | 'none', buttons: Set<input.Button>, modifiers: Set<input.Modifier>): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
await this._client.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
button,
|
||||
x,
|
||||
y,
|
||||
modifiers: toModifiersMask(modifiers),
|
||||
clickCount
|
||||
});
|
||||
}
|
||||
}
|
@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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 { Browser } from './Browser';
|
||||
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 Launcher {
|
||||
private _projectRoot: string;
|
||||
private _preferredRevision: string;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._preferredRevision = preferredRevision;
|
||||
}
|
||||
|
||||
async launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & ConnectionOptions) = {}): Promise<BrowserServer<Browser>> {
|
||||
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: Browser | 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 Browser.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
|
||||
};
|
||||
});
|
||||
}
|
@ -1,452 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
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 NetworkManager {
|
||||
private _client: CDPSession;
|
||||
private _page: Page;
|
||||
private _requestIdToRequest = new Map<string, InterceptableRequest>();
|
||||
private _requestIdToRequestWillBeSentEvent = new Map<string, Protocol.Network.requestWillBeSentPayload>();
|
||||
private _offline = false;
|
||||
private _credentials: {username: string, password: string} | null = null;
|
||||
private _attemptedAuthentications = new Set<string>();
|
||||
private _userRequestInterceptionEnabled = false;
|
||||
private _protocolRequestInterceptionEnabled = false;
|
||||
private _userCacheDisabled = false;
|
||||
private _requestIdToInterceptionId = new Map<string, string>();
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
||||
constructor(client: CDPSession, 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: CDPSession;
|
||||
private _allowInterception: boolean;
|
||||
private _interceptionHandled = false;
|
||||
|
||||
constructor(client: CDPSession, 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',
|
||||
};
|
@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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, Launcher, LauncherChromeArgOptions, LauncherLaunchOptions } from './Launcher';
|
||||
import { Browser } from './Browser';
|
||||
|
||||
type Devices = { [name: string]: DeviceDescriptor } & DeviceDescriptor[];
|
||||
|
||||
export class Playwright {
|
||||
private _projectRoot: string;
|
||||
private _launcher: Launcher;
|
||||
readonly _revision: string;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._launcher = new Launcher(projectRoot, preferredRevision);
|
||||
this._revision = preferredRevision;
|
||||
}
|
||||
|
||||
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
|
||||
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<browsers.Browser> {
|
||||
const server = await this._launcher.launch(options);
|
||||
return server.connect();
|
||||
}
|
||||
|
||||
async launchServer(options: (LauncherLaunchOptions & LauncherChromeArgOptions & ConnectionOptions) = {}): Promise<browsers.BrowserServer<Browser>> {
|
||||
return this._launcher.launch(options);
|
||||
}
|
||||
|
||||
async connect(options: (ConnectionOptions & {
|
||||
browserWSEndpoint?: string;
|
||||
browserURL?: string;
|
||||
transport?: ConnectionTransport; })): Promise<Browser> {
|
||||
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 Browser.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<string> {
|
||||
let resolve: (url: string) => void;
|
||||
let reject: (e: Error) => void;
|
||||
const promise = new Promise<string>((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;
|
||||
});
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
/**
|
||||
* 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 { Browser } from './Browser';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { CDPSession, CDPSessionEvents } from './Connection';
|
||||
import { Events } from '../events';
|
||||
import { Worker } from './features/workers';
|
||||
import { Page } from '../page';
|
||||
import { Protocol } from './protocol';
|
||||
import { debugError } from '../helper';
|
||||
import { FrameManager } from './FrameManager';
|
||||
|
||||
const targetSymbol = Symbol('target');
|
||||
|
||||
export class Target {
|
||||
private _targetInfo: Protocol.Target.TargetInfo;
|
||||
private _browser: Browser;
|
||||
private _browserContext: BrowserContext;
|
||||
_targetId: string;
|
||||
private _sessionFactory: () => Promise<CDPSession>;
|
||||
private _pagePromise: Promise<Page> | null = null;
|
||||
private _frameManager: FrameManager | null = null;
|
||||
private _workerPromise: Promise<Worker> | null = null;
|
||||
_initializedPromise: Promise<boolean>;
|
||||
_initializedCallback: (value?: unknown) => void;
|
||||
_isInitialized: boolean;
|
||||
|
||||
static fromPage(page: Page): Target {
|
||||
return (page as any)[targetSymbol];
|
||||
}
|
||||
|
||||
constructor(
|
||||
browser: Browser,
|
||||
targetInfo: Protocol.Target.TargetInfo,
|
||||
browserContext: BrowserContext,
|
||||
sessionFactory: () => Promise<CDPSession>) {
|
||||
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<Page | null> {
|
||||
if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) {
|
||||
this._pagePromise = this._sessionFactory().then(async client => {
|
||||
this._frameManager = new FrameManager(client, this._browser, this._browserContext);
|
||||
const page = this._frameManager.page();
|
||||
(page as any)[targetSymbol] = this;
|
||||
client.once(CDPSessionEvents.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<Worker | null> {
|
||||
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(): Target | null {
|
||||
const { openerId } = this._targetInfo;
|
||||
if (!openerId)
|
||||
return null;
|
||||
return this._browser._targets.get(openerId);
|
||||
}
|
||||
|
||||
createCDPSession(): Promise<CDPSession> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -10,13 +10,13 @@ export { Keyboard, Mouse } from '../input';
|
||||
export { JSHandle } from '../javascript';
|
||||
export { Request, Response } from '../network';
|
||||
export { BrowserContext } from '../browserContext';
|
||||
export { CDPSession } from './Connection';
|
||||
export { Accessibility } from './features/accessibility';
|
||||
export { Coverage } from './features/coverage';
|
||||
export { Interception } from './features/interception';
|
||||
export { Overrides } from './features/overrides';
|
||||
export { PDF } from './features/pdf';
|
||||
export { Permissions } from './features/permissions';
|
||||
export { Worker, Workers } from './features/workers';
|
||||
export { CRSession as CDPSession } from './crConnection';
|
||||
export { CRAccessibility as Accessibility } from './features/crAccessibility';
|
||||
export { CRCoverage as Coverage } from './features/crCoverage';
|
||||
export { CRInterception as Interception } from './features/crInterception';
|
||||
export { CROverrides as Overrides } from './features/crOverrides';
|
||||
export { CRPdf as PDF } from './features/crPdf';
|
||||
export { CRPermissions as Permissions } from './features/crPermissions';
|
||||
export { Worker, CRWorkers as Workers } from './features/crWorkers';
|
||||
export { Page } from '../page';
|
||||
export { Target } from './Target';
|
||||
export { CRTarget as Target } from './crTarget';
|
||||
|
@ -17,7 +17,6 @@
|
||||
|
||||
export const Events = {
|
||||
Browser: {
|
||||
Disconnected: 'disconnected',
|
||||
TargetCreated: 'targetcreated',
|
||||
TargetDestroyed: 'targetdestroyed',
|
||||
TargetChanged: 'targetchanged',
|
||||
|
@ -1,374 +0,0 @@
|
||||
/**
|
||||
* 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 { CDPSession } from '../Connection';
|
||||
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 Accessibility {
|
||||
private _client: CDPSession;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async snapshot(options: {
|
||||
interestingOnly?: boolean;
|
||||
root?: dom.ElementHandle | null;
|
||||
} = {}): Promise<SerializedAXNode> {
|
||||
const {
|
||||
interestingOnly = true,
|
||||
root = null,
|
||||
} = options;
|
||||
const {nodes} = await this._client.send('Accessibility.getFullAXTree');
|
||||
let backendNodeId = 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 = AXNode.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<AXNode> = new Set();
|
||||
collectInterestingNodes(interestingNodes, defaultRoot, false);
|
||||
if (!interestingNodes.has(needle))
|
||||
return null;
|
||||
return serializeTree(needle, interestingNodes)[0];
|
||||
}
|
||||
}
|
||||
|
||||
function collectInterestingNodes(collection: Set<AXNode>, 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<AXNode>): 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 AXNode {
|
||||
_payload: Protocol.Accessibility.AXNode;
|
||||
_children: AXNode[] = [];
|
||||
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: AXNode) => boolean): AXNode | 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<string, number | string | boolean> = 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<keyof SerializedAXNode> = [
|
||||
'name',
|
||||
'value',
|
||||
'description',
|
||||
'keyshortcuts',
|
||||
'roledescription',
|
||||
'valuetext',
|
||||
];
|
||||
for (const userStringProperty of userStringProperties) {
|
||||
if (!properties.has(userStringProperty))
|
||||
continue;
|
||||
node[userStringProperty] = properties.get(userStringProperty);
|
||||
}
|
||||
|
||||
const booleanProperties: Array<keyof SerializedAXNode> = [
|
||||
'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<keyof SerializedAXNode> = [
|
||||
'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<keyof SerializedAXNode> = [
|
||||
'level',
|
||||
'valuemax',
|
||||
'valuemin',
|
||||
];
|
||||
for (const numericalProperty of numericalProperties) {
|
||||
if (!properties.has(numericalProperty))
|
||||
continue;
|
||||
node[numericalProperty] = properties.get(numericalProperty);
|
||||
}
|
||||
const tokenProperties: Array<keyof SerializedAXNode> = [
|
||||
'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[]): AXNode {
|
||||
const nodeById: Map<string, AXNode> = new Map();
|
||||
for (const payload of payloads)
|
||||
nodeById.set(payload.nodeId, new AXNode(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;
|
||||
}
|
||||
}
|
@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from '../Connection';
|
||||
import { assert, debugError, helper, RegisteredListener } from '../../helper';
|
||||
import { Protocol } from '../protocol';
|
||||
|
||||
import { EVALUATION_SCRIPT_URL } from '../ExecutionContext';
|
||||
|
||||
type CoverageEntry = {
|
||||
url: string,
|
||||
text: string,
|
||||
ranges : {start: number, end: number}[]
|
||||
};
|
||||
|
||||
export class Coverage {
|
||||
private _jsCoverage: JSCoverage;
|
||||
private _cssCoverage: CSSCoverage;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
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<CoverageEntry[]> {
|
||||
return await this._jsCoverage.stop();
|
||||
}
|
||||
|
||||
async startCSSCoverage(options: { resetOnNavigation?: boolean; } = {}) {
|
||||
return await this._cssCoverage.start(options);
|
||||
}
|
||||
|
||||
async stopCSSCoverage(): Promise<CoverageEntry[]> {
|
||||
return await this._cssCoverage.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class JSCoverage {
|
||||
_client: CDPSession;
|
||||
_enabled: boolean;
|
||||
_scriptURLs: Map<string, string>;
|
||||
_scriptSources: Map<string, string>;
|
||||
_eventListeners: RegisteredListener[];
|
||||
_resetOnNavigation: boolean;
|
||||
_reportAnonymousScripts: boolean;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
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<CoverageEntry[]> {
|
||||
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: CDPSession;
|
||||
_enabled: boolean;
|
||||
_stylesheetURLs: Map<string, string>;
|
||||
_stylesheetSources: Map<string, string>;
|
||||
_eventListeners: RegisteredListener[];
|
||||
_resetOnNavigation: boolean;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
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<CoverageEntry[]> {
|
||||
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);
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { NetworkManager, toInterceptableRequest } from '../NetworkManager';
|
||||
import * as network from '../../network';
|
||||
|
||||
export class Interception {
|
||||
private _networkManager: NetworkManager;
|
||||
|
||||
constructor(networkManager: NetworkManager) {
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BrowserContext } from '../../browserContext';
|
||||
import { FrameManager } from '../FrameManager';
|
||||
import { Page } from '../api';
|
||||
|
||||
export class Overrides {
|
||||
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 FrameManager)._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 FrameManager)._client.send('Emulation.setGeolocationOverride', this._geolocation);
|
||||
}
|
||||
|
||||
async _applyOverrides(page: Page): Promise<void> {
|
||||
if (this._geolocation)
|
||||
await (page._delegate as FrameManager)._client.send('Emulation.setGeolocationOverride', this._geolocation);
|
||||
}
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { assert, helper } from '../../helper';
|
||||
import { CDPSession } from '../Connection';
|
||||
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 = {
|
||||
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 = {
|
||||
'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 PDF {
|
||||
private _client: CDPSession;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async generate(options: PDFOptions = {}): Promise<Buffer> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Protocol } from '../protocol';
|
||||
import { CDPSession } from './../Connection';
|
||||
|
||||
export class Permissions {
|
||||
private _client: CDPSession;
|
||||
private _browserContextId: string;
|
||||
|
||||
constructor(client: CDPSession, browserContextId: string | null) {
|
||||
this._client = client;
|
||||
this._browserContextId = browserContextId;
|
||||
}
|
||||
|
||||
async override(origin: string, permissions: string[]) {
|
||||
const webPermissionToProtocol = new Map<string, Protocol.Browser.PermissionType>([
|
||||
['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});
|
||||
}
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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 { CDPSession, Connection } from '../Connection';
|
||||
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 { ExecutionContextDelegate } from '../ExecutionContext';
|
||||
import { toConsoleMessageLocation, exceptionToError } from '../protocolHelper';
|
||||
|
||||
type AddToConsoleCallback = (type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) => void;
|
||||
type HandleExceptionCallback = (error: Error) => void;
|
||||
|
||||
export class Workers extends EventEmitter {
|
||||
private _workers = new Map<string, Worker>();
|
||||
|
||||
constructor(client: CDPSession, addToConsole: AddToConsoleCallback, handleException: HandleExceptionCallback) {
|
||||
super();
|
||||
|
||||
client.on('Target.attachedToTarget', event => {
|
||||
if (event.targetInfo.type !== 'worker')
|
||||
return;
|
||||
const session = Connection.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: CDPSession;
|
||||
private _url: string;
|
||||
private _executionContextPromise: Promise<js.ExecutionContext>;
|
||||
private _executionContextCallback: (value?: js.ExecutionContext) => void;
|
||||
|
||||
constructor(client: CDPSession, 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 ExecutionContextDelegate(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);
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import {helper, assert, debugError} from '../helper';
|
||||
import { CDPSession } from './Connection';
|
||||
import { CRSession } from './crConnection';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const openAsync = helper.promisify(fs.open);
|
||||
@ -59,7 +59,7 @@ export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObjec
|
||||
return remoteObject.value;
|
||||
}
|
||||
|
||||
export async function releaseObject(client: CDPSession, remoteObject: Protocol.Runtime.RemoteObject) {
|
||||
export async function releaseObject(client: CRSession, remoteObject: Protocol.Runtime.RemoteObject) {
|
||||
if (!remoteObject.objectId)
|
||||
return;
|
||||
await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => {
|
||||
@ -69,7 +69,7 @@ export async function releaseObject(client: CDPSession, remoteObject: Protocol.R
|
||||
});
|
||||
}
|
||||
|
||||
export async function readProtocolStream(client: CDPSession, handle: string, path: string | null): Promise<Buffer> {
|
||||
export async function readProtocolStream(client: CRSession, handle: string, path: string | null): Promise<Buffer> {
|
||||
let eof = false;
|
||||
let file;
|
||||
if (path)
|
||||
|
@ -16,6 +16,10 @@
|
||||
*/
|
||||
|
||||
export const Events = {
|
||||
Browser: {
|
||||
Disconnected: 'disconnected'
|
||||
},
|
||||
|
||||
Page: {
|
||||
Close: 'close',
|
||||
Console: 'console',
|
||||
|
@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 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 { Connection, ConnectionEvents, JugglerSessionEvents } from './Connection';
|
||||
import { Events } from './events';
|
||||
import { Events as CommonEvents } from '../events';
|
||||
import { Permissions } from './features/permissions';
|
||||
import { Page } from '../page';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import * as browser from '../browser';
|
||||
import * as network from '../network';
|
||||
import { BrowserContext, BrowserContextOptions } from '../browserContext';
|
||||
import { ConnectionTransport } from '../transport';
|
||||
|
||||
export class Browser extends EventEmitter implements browser.Browser {
|
||||
_connection: Connection;
|
||||
_targets: Map<string, Target>;
|
||||
private _defaultContext: BrowserContext;
|
||||
private _contexts: Map<string, BrowserContext>;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
||||
static async create(transport: ConnectionTransport) {
|
||||
const connection = new Connection(transport);
|
||||
const {browserContextIds} = await connection.send('Target.getBrowserContexts');
|
||||
const browser = new Browser(connection, browserContextIds);
|
||||
await connection.send('Target.enable');
|
||||
return browser;
|
||||
}
|
||||
|
||||
constructor(connection: Connection, browserContextIds: Array<string>) {
|
||||
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<BrowserContext> {
|
||||
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<BrowserContext> {
|
||||
return [this._defaultContext, ...Array.from(this._contexts.values())];
|
||||
}
|
||||
|
||||
defaultContext() {
|
||||
return this._defaultContext;
|
||||
}
|
||||
|
||||
async _waitForTarget(predicate: (target: Target) => boolean, options: { timeout?: number; } = {}): Promise<Target> {
|
||||
const {
|
||||
timeout = 30000
|
||||
} = options;
|
||||
const existingTarget = this._allTargets().find(predicate);
|
||||
if (existingTarget)
|
||||
return existingTarget;
|
||||
let resolve: (t: Target) => void;
|
||||
const targetPromise = new Promise<Target>(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(CommonEvents.Page.Popup)) {
|
||||
const popupPage = await target.page();
|
||||
openerPage.emit(CommonEvents.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<Page[]> => {
|
||||
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<Page> => {
|
||||
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 FrameManager)._session;
|
||||
const promises: Promise<any>[] = [];
|
||||
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<void> => {
|
||||
assert(browserContextId, 'Non-incognito profiles cannot be closed!');
|
||||
await this._connection.send('Target.removeBrowserContext', { browserContextId });
|
||||
this._contexts.delete(browserContextId);
|
||||
},
|
||||
|
||||
cookies: async (): Promise<network.NetworkCookie[]> => {
|
||||
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<void> => {
|
||||
await this._connection.send('Browser.clearCookies', { browserContextId: browserContextId || undefined });
|
||||
},
|
||||
|
||||
setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise<void> => {
|
||||
await this._connection.send('Browser.setCookies', { browserContextId: browserContextId || undefined, cookies });
|
||||
},
|
||||
}, options);
|
||||
(context as any).permissions = new Permissions(this._connection, browserContextId);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
export class Target {
|
||||
_pagePromise?: Promise<Page>;
|
||||
private _frameManager: FrameManager | null = null;
|
||||
private _browser: Browser;
|
||||
_context: BrowserContext;
|
||||
private _connection: Connection;
|
||||
private _targetId: string;
|
||||
private _type: 'page' | 'browser';
|
||||
_url: string;
|
||||
private _openerId: string;
|
||||
|
||||
constructor(connection: any, browser: Browser, 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<Page> {
|
||||
if (this._type === 'page' && !this._pagePromise) {
|
||||
this._pagePromise = new Promise(async f => {
|
||||
const session = await this._connection.createSession(this._targetId);
|
||||
this._frameManager = new FrameManager(session, this._context);
|
||||
const page = this._frameManager._page;
|
||||
session.once(JugglerSessionEvents.Disconnected, () => page._didDisconnect());
|
||||
await this._frameManager._initialize();
|
||||
f(page);
|
||||
});
|
||||
}
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
browser() {
|
||||
return this._browser;
|
||||
}
|
||||
}
|
@ -1,204 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {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 Connection extends EventEmitter {
|
||||
private _lastId: number;
|
||||
private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>;
|
||||
private _transport: ConnectionTransport;
|
||||
private _sessions: Map<string, JugglerSession>;
|
||||
_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: JugglerSession): Connection {
|
||||
return session._connection;
|
||||
}
|
||||
|
||||
session(sessionId: string): JugglerSession | null {
|
||||
return this._sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
send(method: string, params: object | undefined = {}): Promise<any> {
|
||||
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 JugglerSession(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<JugglerSession> {
|
||||
const {sessionId} = await this.send('Target.attachToTarget', {targetId});
|
||||
return this._sessions.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const JugglerSessionEvents = {
|
||||
Disconnected: Symbol('Disconnected')
|
||||
};
|
||||
|
||||
export class JugglerSession extends EventEmitter {
|
||||
_connection: Connection;
|
||||
private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>;
|
||||
private _targetType: string;
|
||||
private _sessionId: string;
|
||||
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
|
||||
constructor(connection: Connection, targetType: string, sessionId: string) {
|
||||
super();
|
||||
this._callbacks = new Map();
|
||||
this._connection = connection;
|
||||
this._targetType = targetType;
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
send<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
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(JugglerSessionEvents.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;
|
||||
}
|
@ -1,183 +0,0 @@
|
||||
/**
|
||||
* 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 { JugglerSession } from './Connection';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
|
||||
_session: JugglerSession;
|
||||
_executionContextId: string;
|
||||
|
||||
constructor(session: JugglerSession, executionContextId: string) {
|
||||
this._session = session;
|
||||
this._executionContextId = executionContextId;
|
||||
}
|
||||
|
||||
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
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<Map<string, js.JSHandle>> {
|
||||
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<void> {
|
||||
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<T>(handle: js.JSHandle<T>): Promise<T> {
|
||||
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;
|
||||
}
|
@ -1,342 +0,0 @@
|
||||
/**
|
||||
* 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 { JugglerSession } from './Connection';
|
||||
import { ExecutionContextDelegate } from './ExecutionContext';
|
||||
import { Page, PageDelegate } from '../page';
|
||||
import { NetworkManager } from './NetworkManager';
|
||||
import { Events } from '../events';
|
||||
import * as dialog from '../dialog';
|
||||
import { Protocol } from './protocol';
|
||||
import * as input from '../input';
|
||||
import { RawMouseImpl, RawKeyboardImpl } from './Input';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { Interception } from './features/interception';
|
||||
import { Accessibility } from './features/accessibility';
|
||||
import * as network from '../network';
|
||||
import * as types from '../types';
|
||||
|
||||
export class FrameManager implements PageDelegate {
|
||||
readonly rawMouse: RawMouseImpl;
|
||||
readonly rawKeyboard: RawKeyboardImpl;
|
||||
readonly _session: JugglerSession;
|
||||
readonly _page: Page;
|
||||
private readonly _networkManager: NetworkManager;
|
||||
private readonly _contextIdToContext: Map<string, dom.FrameExecutionContext>;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
||||
constructor(session: JugglerSession, 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 NetworkManager(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 Interception(this._networkManager);
|
||||
(this._page as any).accessibility = new Accessibility(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 ExecutionContextDelegate(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<void> {
|
||||
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<frames.GotoResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this._session.send('Page.setEmulatedMedia', {
|
||||
type: mediaType === null ? undefined : mediaType,
|
||||
colorScheme: mediaColorScheme === null ? undefined : mediaColorScheme
|
||||
});
|
||||
}
|
||||
|
||||
async setCacheEnabled(enabled: boolean): Promise<void> {
|
||||
await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled});
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this._session.send('Page.reload', { frameId: this._page.mainFrame()._id });
|
||||
}
|
||||
|
||||
async goBack(): Promise<boolean> {
|
||||
const { navigationId } = await this._session.send('Page.goBack', { frameId: this._page.mainFrame()._id });
|
||||
return navigationId !== null;
|
||||
}
|
||||
|
||||
async goForward(): Promise<boolean> {
|
||||
const { navigationId } = await this._session.send('Page.goForward', { frameId: this._page.mainFrame()._id });
|
||||
return navigationId !== null;
|
||||
}
|
||||
|
||||
async evaluateOnNewDocument(source: string): Promise<void> {
|
||||
await this._session.send('Page.addScriptToEvaluateOnNewDocument', { script: source });
|
||||
}
|
||||
|
||||
async closePage(runBeforeUnload: boolean): Promise<void> {
|
||||
await this._session.send('Page.close', { runBeforeUnload });
|
||||
}
|
||||
|
||||
getBoundingBoxForScreenshot(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
|
||||
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<void> {
|
||||
if (color)
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async takeScreenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
|
||||
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<void> {
|
||||
await this._session.send('Page.setViewport', { viewport: null });
|
||||
}
|
||||
|
||||
async getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null> {
|
||||
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<frames.Frame | null> {
|
||||
return handle._context.frame;
|
||||
}
|
||||
|
||||
isElementHandle(remoteObject: any): boolean {
|
||||
return remoteObject.subtype === 'node';
|
||||
}
|
||||
|
||||
async getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null> {
|
||||
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<types.Quad[] | null> {
|
||||
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<void> {
|
||||
await handle.evaluate(input.setFileInputFunction, files);
|
||||
}
|
||||
|
||||
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
|
||||
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;
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JugglerSession } from './Connection';
|
||||
import * as input from '../input';
|
||||
|
||||
function toModifiersMask(modifiers: Set<input.Modifier>): 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<input.Button>): 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: JugglerSession;
|
||||
|
||||
constructor(client: JugglerSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async keydown(modifiers: Set<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
|
||||
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<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
||||
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<void> {
|
||||
await this._client.send('Page.insertText', { text });
|
||||
}
|
||||
}
|
||||
|
||||
export class RawMouseImpl implements input.RawMouse {
|
||||
private _client: JugglerSession;
|
||||
|
||||
constructor(client: JugglerSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async move(x: number, y: number, button: input.Button | 'none', buttons: Set<input.Button>, modifiers: Set<input.Modifier>): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
await this._client.send('Page.dispatchMouseEvent', {
|
||||
type: 'mouseup',
|
||||
button: toButtonNumber(button),
|
||||
buttons: toButtonsMask(buttons),
|
||||
x,
|
||||
y,
|
||||
modifiers: toModifiersMask(modifiers),
|
||||
clickCount
|
||||
});
|
||||
}
|
||||
}
|
@ -1,400 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Browser } from './Browser';
|
||||
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 Launcher {
|
||||
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<BrowserServer<Browser>> {
|
||||
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: Browser | 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 Browser.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<string> {
|
||||
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;
|
||||
}
|
@ -1,198 +0,0 @@
|
||||
/**
|
||||
* 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 { JugglerSession } from './Connection';
|
||||
import { Page } from '../page';
|
||||
import * as network from '../network';
|
||||
import * as frames from '../frames';
|
||||
|
||||
export class NetworkManager {
|
||||
private _session: JugglerSession;
|
||||
private _requests: Map<string, InterceptableRequest>;
|
||||
private _page: Page;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
||||
constructor(session: JugglerSession, 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: JugglerSession;
|
||||
private _suspended: boolean;
|
||||
private _interceptionHandled: boolean;
|
||||
|
||||
constructor(session: JugglerSession, 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);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as browsers from '../browser';
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
|
||||
import { WebSocketTransport, SlowMoTransport } from '../transport';
|
||||
import { DeviceDescriptors, DeviceDescriptor } from '../deviceDescriptors';
|
||||
import * as Errors from '../errors';
|
||||
import { Launcher, createBrowserFetcher } from './Launcher';
|
||||
|
||||
type Devices = { [name: string]: DeviceDescriptor } & DeviceDescriptor[];
|
||||
|
||||
export class Playwright {
|
||||
private _projectRoot: string;
|
||||
private _launcher: Launcher;
|
||||
readonly _revision: string;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._launcher = new Launcher(projectRoot, preferredRevision);
|
||||
this._revision = preferredRevision;
|
||||
}
|
||||
|
||||
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
|
||||
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<Browser> {
|
||||
const server = await this._launcher.launch(options);
|
||||
return server.connect();
|
||||
}
|
||||
|
||||
async launchServer(options: any): Promise<browsers.BrowserServer<Browser>> {
|
||||
return this._launcher.launch(options);
|
||||
}
|
||||
|
||||
async connect(options: { slowMo?: number, browserWSEndpoint: string }): Promise<Browser> {
|
||||
const transport = await WebSocketTransport.create(options.browserWSEndpoint);
|
||||
return Browser.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);
|
||||
}
|
||||
}
|
@ -7,9 +7,9 @@ export { BrowserContext } from '../browserContext';
|
||||
export { Dialog } from '../dialog';
|
||||
export { JSHandle } from '../javascript';
|
||||
export { ElementHandle } from '../dom';
|
||||
export { Accessibility } from './features/accessibility';
|
||||
export { Interception } from './features/interception';
|
||||
export { Permissions } from './features/permissions';
|
||||
export { FFAccessibility as Accessibility } from './features/ffAccessibility';
|
||||
export { FFInterception as Interception } from './features/ffInterception';
|
||||
export { FFPermissions as Permissions } from './features/ffPermissions';
|
||||
export { Frame } from '../frames';
|
||||
export { Request, Response } from '../network';
|
||||
export { Page } from '../page';
|
||||
|
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const Events = {
|
||||
Browser: {
|
||||
Disconnected: 'disconnected'
|
||||
},
|
||||
};
|
@ -1,281 +0,0 @@
|
||||
/**
|
||||
* 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<SerializedAXNode>;
|
||||
}
|
||||
export class Accessibility {
|
||||
_session: any;
|
||||
constructor(session) {
|
||||
this._session = session;
|
||||
}
|
||||
async snapshot(options: { interestingOnly?: boolean; } | undefined = {}): Promise<SerializedAXNode> {
|
||||
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<AXNode> = new Set();
|
||||
collectInterestingNodes(interestingNodes, root, false);
|
||||
return serializeTree(root, interestingNodes)[0];
|
||||
}
|
||||
}
|
||||
function collectInterestingNodes(collection: Set<AXNode>, 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<AXNode>): Array<SerializedAXNode> {
|
||||
const children: Array<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 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<keyof SerializedAXNode> = [
|
||||
'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<keyof SerializedAXNode> = [
|
||||
'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<keyof SerializedAXNode> = [
|
||||
'checked',
|
||||
'pressed',
|
||||
];
|
||||
for (const tristateProperty of tristateProperties) {
|
||||
if (!(tristateProperty in this._payload))
|
||||
continue;
|
||||
const value = this._payload[tristateProperty];
|
||||
node[tristateProperty] = value;
|
||||
}
|
||||
const numericalProperties: Array<keyof SerializedAXNode> = [
|
||||
'level'
|
||||
];
|
||||
for (const numericalProperty of numericalProperties) {
|
||||
if (!(numericalProperty in this._payload))
|
||||
continue;
|
||||
node[numericalProperty] = this._payload[numericalProperty];
|
||||
}
|
||||
const tokenProperties: Array<keyof SerializedAXNode> = [
|
||||
'autocomplete',
|
||||
'haspopup',
|
||||
'invalid',
|
||||
'orientation',
|
||||
];
|
||||
for (const tokenProperty of tokenProperties) {
|
||||
const value = this._payload[tokenProperty];
|
||||
if (!value || value === 'false')
|
||||
continue;
|
||||
node[tokenProperty] = value;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { NetworkManager, toInterceptableRequest } from '../NetworkManager';
|
||||
import * as network from '../../network';
|
||||
|
||||
export class Interception {
|
||||
private _networkManager: NetworkManager;
|
||||
|
||||
constructor(networkManager: NetworkManager) {
|
||||
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();
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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 { Connection } from '../Connection';
|
||||
|
||||
export class Permissions {
|
||||
private _connection: Connection;
|
||||
private _browserContextId: string;
|
||||
|
||||
constructor(connection: Connection, browserContextId: string | null) {
|
||||
this._connection = connection;
|
||||
this._browserContextId = browserContextId;
|
||||
}
|
||||
|
||||
|
||||
async override(origin: string, permissions: Array<string>) {
|
||||
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});
|
||||
}
|
||||
}
|
@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { helper, RegisteredListener, debugError, assert } from '../helper';
|
||||
import * as browser from '../browser';
|
||||
import * as network from '../network';
|
||||
import { Connection, ConnectionEvents, TargetSession } from './Connection';
|
||||
import { Page } from '../page';
|
||||
import { Target } from './Target';
|
||||
import { Protocol } from './protocol';
|
||||
import { Events } from '../events';
|
||||
import { BrowserContext, BrowserContextOptions } from '../browserContext';
|
||||
import { ConnectionTransport } from '../transport';
|
||||
|
||||
export class Browser extends EventEmitter implements browser.Browser {
|
||||
readonly _connection: Connection;
|
||||
private _defaultContext: BrowserContext;
|
||||
private _contexts = new Map<string, BrowserContext>();
|
||||
_targets = new Map<string, Target>();
|
||||
private _eventListeners: RegisteredListener[];
|
||||
private _privateEvents = new EventEmitter();
|
||||
|
||||
constructor(transport: ConnectionTransport) {
|
||||
super();
|
||||
this._connection = new Connection(transport);
|
||||
|
||||
/** @type {!Map<string, !Target>} */
|
||||
this._targets = new Map();
|
||||
|
||||
this._defaultContext = this._createBrowserContext(undefined, {});
|
||||
/** @type {!Map<string, !BrowserContext>} */
|
||||
this._contexts = new Map();
|
||||
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(this._connection, ConnectionEvents.TargetCreated, this._onTargetCreated.bind(this)),
|
||||
helper.addEventListener(this._connection, ConnectionEvents.TargetDestroyed, this._onTargetDestroyed.bind(this)),
|
||||
helper.addEventListener(this._connection, ConnectionEvents.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<BrowserContext> {
|
||||
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: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise<Target> {
|
||||
const {
|
||||
timeout = 30000
|
||||
} = options;
|
||||
const existingTarget = Array.from(this._targets.values()).find(predicate);
|
||||
if (existingTarget)
|
||||
return existingTarget;
|
||||
let resolve : (a: Target) => void;
|
||||
const targetPromise = new Promise<Target>(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: Target) {
|
||||
if (predicate(target))
|
||||
resolve(target);
|
||||
}
|
||||
}
|
||||
|
||||
_onTargetCreated(session: TargetSession, 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 Target(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: Target.fromPage(page)._targetId,
|
||||
runBeforeUnload
|
||||
}).catch(debugError);
|
||||
}
|
||||
|
||||
async _activatePage(page: Page): Promise<void> {
|
||||
await this._connection.send('Target.activate', { targetId: Target.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<Page[]> => {
|
||||
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<Page> => {
|
||||
const { targetId } = await this._connection.send('Browser.createPage', { browserContextId });
|
||||
const target = this._targets.get(targetId);
|
||||
return await target.page();
|
||||
},
|
||||
|
||||
close: async (): Promise<void> => {
|
||||
assert(browserContextId, 'Non-incognito profiles cannot be closed!');
|
||||
await this._connection.send('Browser.deleteContext', { browserContextId });
|
||||
this._contexts.delete(browserContextId);
|
||||
},
|
||||
|
||||
cookies: async (): Promise<network.NetworkCookie[]> => {
|
||||
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<void> => {
|
||||
await this._connection.send('Browser.deleteAllCookies', { browserContextId });
|
||||
},
|
||||
|
||||
setCookies: async (cookies: network.SetNetworkCookieParam[]): Promise<void> => {
|
||||
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'),
|
||||
};
|
@ -1,264 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ConnectionEvents = {
|
||||
TargetCreated: Symbol('ConnectionEvents.TargetCreated'),
|
||||
TargetDestroyed: Symbol('Connection.TargetDestroyed'),
|
||||
DidCommitProvisionalTarget: Symbol('Connection.DidCommitProvisionalTarget')
|
||||
};
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
_lastId = 0;
|
||||
private readonly _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||
private readonly _transport: ConnectionTransport;
|
||||
private readonly _sessions = new Map<string, TargetSession>();
|
||||
|
||||
_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: TargetSession): Connection {
|
||||
return session._connection;
|
||||
}
|
||||
|
||||
send<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
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 TargetSession(this, targetInfo);
|
||||
this._sessions.set(session._sessionId, session);
|
||||
Promise.resolve().then(() => this.emit(ConnectionEvents.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(ConnectionEvents.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(ConnectionEvents.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 TargetSessionEvents = {
|
||||
Disconnected: Symbol('TargetSessionEvents.Disconnected')
|
||||
};
|
||||
|
||||
export class TargetSession extends EventEmitter {
|
||||
_connection: Connection;
|
||||
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||
private _targetType: string;
|
||||
_sessionId: string;
|
||||
_swappedOut = false;
|
||||
private _provisionalMessages?: string[];
|
||||
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
|
||||
constructor(connection: Connection, 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<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
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<Protocol.CommandReturnValues[T]>((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(TargetSessionEvents.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.');
|
||||
}
|
@ -1,324 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TargetSession, isSwappedOutError } from './Connection';
|
||||
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 ExecutionContextDelegate implements js.ExecutionContextDelegate {
|
||||
private _globalObjectId?: Promise<string>;
|
||||
_session: TargetSession;
|
||||
_contextId: number;
|
||||
private _contextDestroyedCallback: () => void;
|
||||
private _executionContextDestroyedPromise: Promise<unknown>;
|
||||
|
||||
constructor(client: TargetSession, 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<any> {
|
||||
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<Map<string, js.JSHandle>> {
|
||||
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<void> {
|
||||
await releaseObject(this._session, toRemoteObject(handle));
|
||||
}
|
||||
|
||||
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
|
||||
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;
|
||||
}
|
@ -1,440 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as frames from '../frames';
|
||||
import { debugError, helper, RegisteredListener } from '../helper';
|
||||
import * as dom from '../dom';
|
||||
import * as network from '../network';
|
||||
import { TargetSession } from './Connection';
|
||||
import { Events } from '../events';
|
||||
import { ExecutionContextDelegate, EVALUATION_SCRIPT_URL } from './ExecutionContext';
|
||||
import { NetworkManager } from './NetworkManager';
|
||||
import { Page, PageDelegate } from '../page';
|
||||
import { Protocol } from './protocol';
|
||||
import * as dialog from '../dialog';
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { RawMouseImpl, RawKeyboardImpl } from './Input';
|
||||
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 FrameManager implements PageDelegate {
|
||||
readonly rawMouse: RawMouseImpl;
|
||||
readonly rawKeyboard: RawKeyboardImpl;
|
||||
_session: TargetSession;
|
||||
readonly _page: Page;
|
||||
private _browser: Browser;
|
||||
private readonly _networkManager: NetworkManager;
|
||||
private readonly _contextIdToContext: Map<number, dom.FrameExecutionContext>;
|
||||
private _isolatedWorlds: Set<string>;
|
||||
private _sessionListeners: RegisteredListener[] = [];
|
||||
private readonly _bootstrapScripts: string[] = [];
|
||||
|
||||
constructor(browser: Browser, 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 NetworkManager(this._page);
|
||||
}
|
||||
|
||||
setSession(session: TargetSession) {
|
||||
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: TargetSession) {
|
||||
const promises : Promise<any>[] = [
|
||||
// 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(FrameManager._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 ExecutionContextDelegate)._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 ExecutionContextDelegate)._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 ExecutionContextDelegate(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<frames.GotoResult> {
|
||||
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: TargetSession, headers: network.Headers): Promise<void> {
|
||||
await session.send('Network.setExtraHTTPHeaders', { headers });
|
||||
}
|
||||
|
||||
private async _setEmulateMedia(session: TargetSession, mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise<void> {
|
||||
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<void> {
|
||||
await this._setExtraHTTPHeaders(this._session, headers);
|
||||
}
|
||||
|
||||
async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.ColorScheme | null): Promise<void> {
|
||||
await this._setEmulateMedia(this._session, mediaType, mediaColorScheme);
|
||||
}
|
||||
|
||||
async setViewport(viewport: types.Viewport): Promise<void> {
|
||||
return FrameManager._setViewport(this._session, viewport);
|
||||
}
|
||||
|
||||
private static async _setViewport(session: TargetSession, viewport: types.Viewport): Promise<void> {
|
||||
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<void> {
|
||||
return this._networkManager.setCacheEnabled(enabled);
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this._session.send('Page.reload');
|
||||
}
|
||||
|
||||
goBack(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this._browser._closePage(this._page, runBeforeUnload);
|
||||
}
|
||||
|
||||
getBoundingBoxForScreenshot(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
|
||||
return handle.boundingBox();
|
||||
}
|
||||
|
||||
canScreenshotOutsideViewport(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
|
||||
// TODO: line below crashes, sort it out.
|
||||
this._session.send('Page.setDefaultBackgroundColorOverride', { color });
|
||||
}
|
||||
|
||||
async takeScreenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer> {
|
||||
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<void> {
|
||||
await this._session.send('Emulation.setDeviceMetricsOverride', { ...oldSize, deviceScaleFactor: 0 });
|
||||
}
|
||||
|
||||
async getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null> {
|
||||
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<frames.Frame | null> {
|
||||
return handle._context.frame;
|
||||
}
|
||||
|
||||
isElementHandle(remoteObject: any): boolean {
|
||||
return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node';
|
||||
}
|
||||
|
||||
async getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null> {
|
||||
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<types.Quad[] | null> {
|
||||
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<void> {
|
||||
const objectId = toRemoteObject(handle).objectId;
|
||||
await this._session.send('DOM.setInputFiles', { objectId, files });
|
||||
}
|
||||
|
||||
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
|
||||
const result = await this._session.send('DOM.resolveNode', {
|
||||
objectId: toRemoteObject(handle).objectId,
|
||||
executionContextId: (to._delegate as ExecutionContextDelegate)._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<T>;
|
||||
}
|
||||
}
|
||||
|
||||
function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject {
|
||||
return handle._remoteObject as Protocol.Runtime.RemoteObject;
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as input from '../input';
|
||||
import { helper } from '../helper';
|
||||
import { macEditingCommands } from '../usKeyboardLayout';
|
||||
import { TargetSession } from './Connection';
|
||||
|
||||
function toModifiersMask(modifiers: Set<input.Modifier>): 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: TargetSession;
|
||||
|
||||
setSession(session: TargetSession) {
|
||||
this._session = session;
|
||||
}
|
||||
|
||||
async keydown(modifiers: Set<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
|
||||
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<input.Modifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
||||
await this._session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
modifiers: toModifiersMask(modifiers),
|
||||
key,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
code,
|
||||
isKeypad: location === input.keypadLocation
|
||||
});
|
||||
}
|
||||
|
||||
async sendText(text: string): Promise<void> {
|
||||
await this._session.send('Page.insertText', { text });
|
||||
}
|
||||
}
|
||||
|
||||
export class RawMouseImpl implements input.RawMouse {
|
||||
private _client: TargetSession;
|
||||
|
||||
setSession(client: TargetSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async move(x: number, y: number, button: input.Button | 'none', buttons: Set<input.Button>, modifiers: Set<input.Modifier>): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
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<input.Button>, modifiers: Set<input.Modifier>, clickCount: number): Promise<void> {
|
||||
await this._client.send('Input.dispatchMouseEvent', {
|
||||
type: 'up',
|
||||
button,
|
||||
x,
|
||||
y,
|
||||
modifiers: toModifiersMask(modifiers),
|
||||
clickCount
|
||||
});
|
||||
}
|
||||
}
|
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { assert } from '../helper';
|
||||
import { Browser } from './Browser';
|
||||
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 Launcher {
|
||||
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<BrowserServer<Browser>> {
|
||||
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: Browser | undefined;
|
||||
try {
|
||||
const transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream);
|
||||
browser = new Browser(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',
|
||||
};
|
||||
});
|
||||
}
|
@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TargetSession } from './Connection';
|
||||
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 NetworkManager {
|
||||
private _session: TargetSession;
|
||||
private _page: Page;
|
||||
private _requestIdToRequest = new Map<string, InterceptableRequest>();
|
||||
private _attemptedAuthentications = new Set<string>();
|
||||
private _userCacheDisabled = false;
|
||||
private _sessionListeners: RegisteredListener[] = [];
|
||||
|
||||
constructor(page: Page) {
|
||||
this._page = page;
|
||||
}
|
||||
|
||||
setSession(session: TargetSession) {
|
||||
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: TargetSession) {
|
||||
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;
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as browsers from '../browser';
|
||||
import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
|
||||
import { DeviceDescriptors } from '../deviceDescriptors';
|
||||
import * as Errors from '../errors';
|
||||
import { Launcher, LauncherLaunchOptions, createBrowserFetcher } from './Launcher';
|
||||
import { Browser } from './Browser';
|
||||
|
||||
export class Playwright {
|
||||
private _projectRoot: string;
|
||||
private _launcher: Launcher;
|
||||
readonly _revision: string;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._launcher = new Launcher(projectRoot, preferredRevision);
|
||||
this._revision = preferredRevision;
|
||||
}
|
||||
|
||||
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
|
||||
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<Browser> {
|
||||
const server = await this._launcher.launch(options);
|
||||
return server.connect();
|
||||
}
|
||||
|
||||
async launchServer(options: (LauncherLaunchOptions) | undefined): Promise<browsers.BrowserServer<Browser>> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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 { TargetSession, TargetSessionEvents } from './Connection';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import { Browser } from './Browser';
|
||||
|
||||
const targetSymbol = Symbol('target');
|
||||
|
||||
export class Target {
|
||||
readonly _browserContext: BrowserContext;
|
||||
readonly _targetId: string;
|
||||
readonly _type: 'page' | 'service-worker' | 'worker';
|
||||
private readonly _session: TargetSession;
|
||||
private _pagePromise: Promise<Page> | null = null;
|
||||
private _browser: Browser;
|
||||
_frameManager: FrameManager | null = null;
|
||||
|
||||
static fromPage(page: Page): Target {
|
||||
return (page as any)[targetSymbol];
|
||||
}
|
||||
|
||||
constructor(browser: Browser, session: TargetSession, 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<!Page>} */
|
||||
this._pagePromise = null;
|
||||
}
|
||||
|
||||
_didClose() {
|
||||
if (this._frameManager)
|
||||
this._frameManager.didClose();
|
||||
}
|
||||
|
||||
async _initializeSession(session: TargetSession) {
|
||||
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: Target) {
|
||||
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(TargetSessionEvents.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<Page> {
|
||||
if (this._type === 'page' && !this._pagePromise) {
|
||||
this._frameManager = new FrameManager(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;
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const Events = {
|
||||
Browser: {
|
||||
Disconnected: 'disconnected'
|
||||
},
|
||||
};
|
@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { assert, debugError } from '../helper';
|
||||
import { TargetSession } from './Connection';
|
||||
import { WKTargetSession } from './wkConnection';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObject): any {
|
||||
@ -43,7 +43,7 @@ export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObjec
|
||||
return remoteObject.value;
|
||||
}
|
||||
|
||||
export async function releaseObject(client: TargetSession, remoteObject: Protocol.Runtime.RemoteObject) {
|
||||
export async function releaseObject(client: WKTargetSession, remoteObject: Protocol.Runtime.RemoteObject) {
|
||||
if (!remoteObject.objectId)
|
||||
return;
|
||||
await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => {
|
||||
|
@ -22,7 +22,7 @@ for (const className in api.WebKit) {
|
||||
helper.installAsyncStackHooks(api.WebKit[className]);
|
||||
}
|
||||
|
||||
const {Playwright} = require('./lib/webkit/Playwright');
|
||||
const { WKPlaywright } = require('./lib/webkit/wkPlaywright');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
module.exports = new Playwright(__dirname, packageJson.playwright.webkit_revision);
|
||||
module.exports = new WKPlaywright(__dirname, packageJson.playwright.webkit_revision);
|
||||
|
Loading…
Reference in New Issue
Block a user