diff --git a/src/webkit/Browser.ts b/src/webkit/Browser.ts index bc0d91fbb7..ad7db9f624 100644 --- a/src/webkit/Browser.ts +++ b/src/webkit/Browser.ts @@ -184,17 +184,8 @@ export class Browser extends EventEmitter { async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) { const oldTarget = this._targets.get(oldTargetId); - if (!oldTarget._pagePromise) - return; - const page = await oldTarget._pagePromise; const newTarget = this._targets.get(newTargetId); - const newSession = this._connection.session(newTargetId); - page._swapSessionOnNavigation(newSession); - newTarget._pagePromise = oldTarget._pagePromise; - newTarget._adoptPage(page); - // Old 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; + newTarget._swappedIn(oldTarget, this._connection.session(newTargetId)); } disconnect() { diff --git a/src/webkit/Connection.ts b/src/webkit/Connection.ts index 887f0a746b..046a6f95d9 100644 --- a/src/webkit/Connection.ts +++ b/src/webkit/Connection.ts @@ -104,7 +104,6 @@ export class Connection extends EventEmitter { } else if (object.method === 'Target.targetDestroyed') { const session = this._sessions.get(object.params.targetId); if (session) { - // FIXME: this is a workaround for cross-origin navigation in WebKit. session._onClosed(); this._sessions.delete(object.params.targetId); } @@ -121,6 +120,7 @@ export class Connection extends EventEmitter { const oldSession = this._sessions.get(oldTargetId); if (!oldSession) throw new Error('Unknown old target: ' + oldTargetId); + oldSession._swappedOut = true; } } @@ -158,6 +158,7 @@ export class TargetSession extends EventEmitter { private _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); private _targetType: string; private _sessionId: string; + _swappedOut = false; on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; @@ -194,7 +195,7 @@ export class TargetSession extends EventEmitter { }).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.g + // case it's safe to swallow the exception. const callback = this._callbacks.get(innerId); assert(!callback, 'Callback was not rejected when target was destroyed.'); }); @@ -218,11 +219,17 @@ export class TargetSession extends EventEmitter { } _onClosed() { - for (const callback of this._callbacks.values()) - callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + 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; - this.emit(TargetSessionEvents.Disconnected); + if (!this._swappedOut) + this.emit(TargetSessionEvents.Disconnected); } } @@ -237,3 +244,7 @@ function rewriteError(error: Error, message: string): Error { error.message = message; return error; } + +export function isSwappedOutError(e: Error) { + return e.message.includes('Target was swapped out.'); +} diff --git a/src/webkit/ExecutionContext.ts b/src/webkit/ExecutionContext.ts index d3b34a547f..9a15401dd1 100644 --- a/src/webkit/ExecutionContext.ts +++ b/src/webkit/ExecutionContext.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { TargetSession } from './Connection'; +import { TargetSession, isSwappedOutError } from './Connection'; import { helper } from '../helper'; import { valueFromRemoteObject, releaseObject } from './protocolHelper'; import { Protocol } from './protocol'; @@ -25,7 +25,7 @@ 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?: string; + private _globalObjectId?: Promise; _session: TargetSession; _contextId: number; private _contextDestroyedCallback: () => void; @@ -45,8 +45,6 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { } async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { - const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; - if (helper.isString(pageFunction)) { const contextId = this._contextId; const expression: string = pageFunction as string; @@ -58,18 +56,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { emulateUserGesture: true }).then(response => { if (response.result.type === 'object' && response.result.className === 'Promise') { - const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({ - wasThrown: true, - result: { - description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.' - } as Protocol.Runtime.RemoteObject - })); return Promise.race([ - contextDiscarded, - this._session.send('Runtime.awaitPromise', { - promiseObjectId: response.result.objectId, - returnByValue: false - }) + this._executionContextDestroyedPromise.then(() => contextDestroyedResult), + this._awaitPromise(response.result.objectId), ]); } return response; @@ -78,32 +67,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { throw new Error('Evaluation failed: ' + response.result.description); if (!returnByValue) return context._createHandle(response.result); - if (response.result.objectId) { - 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: response.result.objectId, - returnByValue - }).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); - }); - } + if (response.result.objectId) + return this._returnObjectByValue(response.result.objectId); return valueFromRemoteObject(response.result); }).catch(rewriteError); } @@ -164,18 +129,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { } return callFunctionOnPromise.then(response => { if (response.result.type === 'object' && response.result.className === 'Promise') { - const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({ - wasThrown: true, - result: { - description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.' - } as Protocol.Runtime.RemoteObject - })); return Promise.race([ - contextDiscarded, - this._session.send('Runtime.awaitPromise', { - promiseObjectId: response.result.objectId, - returnByValue: false - }) + this._executionContextDestroyedPromise.then(() => contextDestroyedResult), + this._awaitPromise(response.result.objectId), ]); } return response; @@ -184,32 +140,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { throw new Error('Evaluation failed: ' + response.result.description); if (!returnByValue) return context._createHandle(response.result); - if (response.result.objectId) { - 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: response.result.objectId, - returnByValue - }).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); - }); - } + if (response.result.objectId) + return this._returnObjectByValue(response.result.objectId); return valueFromRemoteObject(response.result); }).catch(rewriteError); @@ -263,14 +195,64 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { } } - async _contextGlobalObjectId() { + private _contextGlobalObjectId() { if (!this._globalObjectId) { - const globalObject = await this._session.send('Runtime.evaluate', { expression: 'this', contextId: this._contextId }); - this._globalObjectId = globalObject.result.objectId; + this._globalObjectId = this._session.send('Runtime.evaluate', { + expression: 'this', + contextId: this._contextId + }).catch(e => { + if (isSwappedOutError(e)) + throw new Error('Execution context was destroyed, most likely because of a navigation.'); + throw e; + }).then(response => { + return response.result.objectId; + }); } return this._globalObjectId; } + private _awaitPromise(objectId: Protocol.Runtime.RemoteObjectId) { + return this._session.send('Runtime.awaitPromise', { + promiseObjectId: objectId, + returnByValue: false + }).catch(e => { + if (isSwappedOutError(e)) + return contextDestroyedResult; + throw e; + }); + } + + private _returnObjectByValue(objectId: Protocol.Runtime.RemoteObjectId) { + const serializeFunction = function() { + try { + return JSON.stringify(this); + } catch (e) { + if (e instanceof TypeError) + return void 0; + throw e; + } + }; + return this._session.send('Runtime.callFunctionOn', { + // Serialize object using standard JSON implementation to correctly pass 'undefined'. + functionDeclaration: serializeFunction + '\n' + suffix + '\n', + objectId: objectId, + returnByValue: true + }).catch(e => { + if (isSwappedOutError(e)) + return contextDestroyedResult; + throw e; + }).then(serializeResponse => { + if (serializeResponse.wasThrown) + throw new Error('Serialization failed: ' + serializeResponse.result.description); + // This is the case of too long property chain, not serializable to json string. + if (serializeResponse.result.type === 'undefined') + return undefined; + if (serializeResponse.result.type !== 'string') + throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2)); + return JSON.parse(serializeResponse.result.value); + }); + } + async getProperties(handle: js.JSHandle): Promise> { const response = await this._session.send('Runtime.getProperties', { objectId: toRemoteObject(handle).objectId, @@ -328,9 +310,16 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate { } return { value: arg }; } - } +const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; +const contextDestroyedResult = { + wasThrown: true, + result: { + description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.' + } as Protocol.Runtime.RemoteObject +}; + function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject { return handle._remoteObject as Protocol.Runtime.RemoteObject; } diff --git a/src/webkit/FrameManager.ts b/src/webkit/FrameManager.ts index 535aa68d83..350e6d3dfc 100644 --- a/src/webkit/FrameManager.ts +++ b/src/webkit/FrameManager.ts @@ -91,7 +91,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { ]; } - async _swapSessionOnNavigation(newSession) { + _swapSessionOnNavigation(newSession) { helper.removeEventListeners(this._sessionListeners); this.disconnectFromTarget(); this._session = newSession; diff --git a/src/webkit/NetworkManager.ts b/src/webkit/NetworkManager.ts index 3e84115371..41c8ba9063 100644 --- a/src/webkit/NetworkManager.ts +++ b/src/webkit/NetworkManager.ts @@ -63,6 +63,7 @@ export class NetworkManager extends EventEmitter { async initialize() { await this._sesssion.send('Network.enable'); + await this._sesssion.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }); } async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) { diff --git a/src/webkit/Page.ts b/src/webkit/Page.ts index e12a5cb13b..662f487451 100644 --- a/src/webkit/Page.ts +++ b/src/webkit/Page.ts @@ -20,7 +20,7 @@ import * as console from '../console'; import * as dialog from '../dialog'; import * as dom from '../dom'; import * as frames from '../frames'; -import { assert, debugError, helper, RegisteredListener } from '../helper'; +import { assert, helper, RegisteredListener } from '../helper'; import * as input from '../input'; import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input'; import * as js from '../javascript'; @@ -49,6 +49,7 @@ export class Page extends EventEmitter { private _frameManager: FrameManager; private _bootstrapScripts: string[] = []; _javascriptEnabled = true; + private _userAgent: string | null = null; private _viewport: types.Viewport | null = null; _screenshotter: Screenshotter; private _workers = new Map(); @@ -57,14 +58,6 @@ export class Page extends EventEmitter { private _emulatedMediaType: string | undefined; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: types.Viewport | null): Promise { - const page = new Page(session, browserContext); - await page._initialize(); - if (defaultViewport) - await page.setViewport(defaultViewport); - return page; - } - constructor(session: TargetSession, browserContext: BrowserContext) { super(); this._closedPromise = new Promise(f => this._closedCallback = f); @@ -97,12 +90,16 @@ export class Page extends EventEmitter { } async _initialize() { - return Promise.all([ + await Promise.all([ this._frameManager.initialize(), this._session.send('Console.enable'), this._session.send('Dialog.enable'), this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }), ]); + if (this._userAgent !== null) + await this._session.send('Page.overrideUserAgent', { value: this._userAgent }); + if (this._emulatedMediaType !== undefined) + await this._session.send('Page.setEmulatedMedia', { media: this._emulatedMediaType || '' }); } _setSession(newSession: TargetSession) { @@ -130,8 +127,8 @@ export class Page extends EventEmitter { async _swapSessionOnNavigation(newSession: TargetSession) { this._setSession(newSession); - await this._frameManager._swapSessionOnNavigation(newSession); - await this._initialize().catch(e => debugError('failed to enable agents after swap: ' + e)); + this._frameManager._swapSessionOnNavigation(newSession); + await this._initialize(); } browser(): Browser { @@ -230,6 +227,7 @@ export class Page extends EventEmitter { } async setUserAgent(userAgent: string) { + this._userAgent = userAgent; await this._session.send('Page.overrideUserAgent', { value: userAgent }); } @@ -326,9 +324,8 @@ export class Page extends EventEmitter { assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); assert(!options.colorScheme, 'Media feature emulation is not supported'); - const media = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type; - await this._session.send('Page.setEmulatedMedia', { media: media || '' }); - this._emulatedMediaType = options.type; + this._emulatedMediaType = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type; + await this._session.send('Page.setEmulatedMedia', { media: this._emulatedMediaType || '' }); } async setViewport(viewport: types.Viewport) { diff --git a/src/webkit/Target.ts b/src/webkit/Target.ts index 72a40d3379..ddfcbb41b9 100644 --- a/src/webkit/Target.ts +++ b/src/webkit/Target.ts @@ -19,6 +19,7 @@ import { RegisteredListener } from '../helper'; import { Browser, BrowserContext } from './Browser'; import { Page } from './Page'; import { Protocol } from './protocol'; +import { isSwappedOutError, TargetSession } from './Connection'; const targetSymbol = Symbol('target'); @@ -26,7 +27,7 @@ export class Target { private _browserContext: BrowserContext; _targetId: string; private _type: 'page' | 'service-worker' | 'worker'; - _pagePromise: Promise | null = null; + private _pagePromise: Promise | null = null; private _url: string; _initializedPromise: Promise; _initializedCallback: (value?: unknown) => void; @@ -52,16 +53,28 @@ export class Target { this._pagePromise.then(page => page._didClose()); } - _adoptPage(page: Page) { + async _swappedIn(oldTarget: Target, session: TargetSession) { + this._pagePromise = oldTarget._pagePromise; + // 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; + if (!this._pagePromise) + return; + const page = await this._pagePromise; (page as any)[targetSymbol] = this; + page._swapSessionOnNavigation(session).catch(rethrowIfNotSwapped); } async page(): Promise { if (this._type === 'page' && !this._pagePromise) { const session = this.browser()._connection.session(this._targetId); - this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport).then(page => { - this._adoptPage(page); - return page; + this._pagePromise = new Promise(async f => { + const page = new Page(session, this._browserContext); + await page._initialize().catch(rethrowIfNotSwapped); + if (this.browser()._defaultViewport) + await page.setViewport(this.browser()._defaultViewport); + (page as any)[targetSymbol] = this; + f(page); }); } return this._pagePromise; @@ -83,3 +96,8 @@ export class Target { return this._browserContext; } } + +function rethrowIfNotSwapped(e: Error) { + if (!isSwappedOutError(e)) + throw e; +}