feat(webkit): pause and configure provisional pages on creation (#200)

This commit is contained in:
Yury Semikhatsky 2019-12-10 15:34:36 -07:00 committed by GitHub
parent fe6addc71a
commit e8ec7e5118
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 189 additions and 122 deletions

View File

@ -78,7 +78,7 @@ export class Page<Browser, BrowserContext extends BrowserContextInterface<Browse
private _closedPromise: Promise<void>;
private _disconnected = false;
private _disconnectedCallback: (e: Error) => void;
private _disconnectedPromise: Promise<Error>;
readonly _disconnectedPromise: Promise<Error>;
private _browserContext: BrowserContext;
readonly keyboard: input.Keyboard;
readonly mouse: input.Mouse;

View File

@ -59,6 +59,12 @@ export class Browser extends EventEmitter {
helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)),
helper.addEventListener(this._connection, 'Target.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 userAgent(): Promise<string> {
@ -157,6 +163,11 @@ export class Browser extends EventEmitter {
context = this._defaultContext;
const target = new Target(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);
}
@ -185,7 +196,7 @@ export class Browser extends EventEmitter {
async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) {
const oldTarget = this._targets.get(oldTargetId);
const newTarget = this._targets.get(newTargetId);
newTarget._swappedIn(oldTarget);
newTarget._swapWith(oldTarget);
}
disconnect() {

View File

@ -15,26 +15,29 @@
* limitations under the License.
*/
import {assert} from '../helper';
import {assert, debugError} from '../helper';
import * as debug from 'debug';
import {EventEmitter} from 'events';
import { ConnectionTransport } from '../types';
import { Protocol } from './protocol';
import { throws } from 'assert';
const debugProtocol = debug('playwright:protocol');
const debugWrappedMessage = require('debug')('wrapped');
export const ConnectionEvents = {
Disconnected: Symbol('ConnectionEvents.Disconnected'),
TargetCreated: Symbol('ConnectionEvents.TargetCreated')
};
export class Connection extends EventEmitter {
_lastId = 0;
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
private _delay: number;
private _transport: ConnectionTransport;
private _sessions = new Map<string, TargetSession>();
private readonly _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
private readonly _delay: number;
private readonly _transport: ConnectionTransport;
private readonly _sessions = new Map<string, TargetSession>();
private _incomingMessageQueue: string[] = [];
private _dispatchTimerId?: NodeJS.Timer;
_closed = false;
constructor(transport: ConnectionTransport, delay: number | undefined = 0) {
@ -68,12 +71,48 @@ export class Connection extends EventEmitter {
return id;
}
async _onMessage(message: string) {
if (this._delay)
await new Promise(f => setTimeout(f, this._delay));
private _onMessage(message: string) {
if (this._incomingMessageQueue.length || this._delay)
this._enqueueMessage(message);
else
this._dispatchMessage(message);
}
private _enqueueMessage(message: string) {
this._incomingMessageQueue.push(message);
this._scheduleQueueDispatch();
}
private _enqueueMessages(messages: string[]) {
this._incomingMessageQueue = this._incomingMessageQueue.concat(messages);
this._scheduleQueueDispatch();
}
private _scheduleQueueDispatch() {
if (this._dispatchTimerId)
return;
if (!this._incomingMessageQueue.length)
return;
const delay = this._delay || 0;
this._dispatchTimerId = setTimeout(() => {
this._dispatchTimerId = undefined;
this._dispatchOneMessageFromQueue()
}, delay);
}
private _dispatchOneMessageFromQueue() {
const message = this._incomingMessageQueue.shift();
try {
this._dispatchMessage(message);
} finally {
this._scheduleQueueDispatch();
}
}
private _dispatchMessage(message: string) {
debugProtocol('◀ RECV ' + message);
const object = JSON.parse(message);
this._dispatchTargetMessageToSession(object);
this._dispatchTargetMessageToSession(object, message);
if (object.id) {
const callback = this._callbacks.get(object.id);
// Callbacks could be all rejected if someone has called `.dispose()`.
@ -91,12 +130,14 @@ export class Connection extends EventEmitter {
}
}
_dispatchTargetMessageToSession(object: {method: string, params: any}) {
_dispatchTargetMessageToSession(object: {method: string, params: any}, wrappedMessage: string) {
if (object.method === 'Target.targetCreated') {
const {targetId, type} = object.params.targetInfo;
const session = new TargetSession(this, type, targetId);
this._sessions.set(targetId, session);
const targetInfo = object.params.targetInfo as Protocol.Target.TargetInfo;
const session = new TargetSession(this, targetInfo);
this._sessions.set(session._sessionId, session);
this.emit(ConnectionEvents.TargetCreated, session, object.params.targetInfo);
if (targetInfo.isPaused)
this.send('Target.resume', { targetId: targetInfo.targetId }).catch(debugError);
} else if (object.method === 'Target.targetDestroyed') {
const session = this._sessions.get(object.params.targetId);
if (session) {
@ -104,12 +145,16 @@ export class Connection extends EventEmitter {
this._sessions.delete(object.params.targetId);
}
} else if (object.method === 'Target.dispatchMessageFromTarget') {
const session = this._sessions.get(object.params.targetId);
const {targetId, message} = object.params as Protocol.Target.dispatchMessageFromTargetPayload;
const session = this._sessions.get(targetId);
if (!session)
throw new Error('Unknown target: ' + object.params.targetId);
session._dispatchMessageFromTarget(object.params.message);
throw new Error('Unknown target: ' + targetId);
if (session.isProvisional())
session._addProvisionalMessage(wrappedMessage);
else
session._dispatchMessageFromTarget(message);
} else if (object.method === 'Target.didCommitProvisionalTarget') {
const {oldTargetId, newTargetId} = object.params;
const {oldTargetId, newTargetId} = object.params as Protocol.Target.didCommitProvisionalTargetPayload;
const newSession = this._sessions.get(newTargetId);
if (!newSession)
throw new Error('Unknown new target: ' + newTargetId);
@ -117,6 +162,7 @@ export class Connection extends EventEmitter {
if (!oldSession)
throw new Error('Unknown old target: ' + oldTargetId);
oldSession._swappedOut = true;
this._enqueueMessages(newSession._takeProvisionalMessagesAndCommit());
}
}
@ -132,7 +178,6 @@ export class Connection extends EventEmitter {
for (const session of this._sessions.values())
session._onClosed();
this._sessions.clear();
this.emit(ConnectionEvents.Disconnected);
}
dispose() {
@ -149,19 +194,27 @@ 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;
private _sessionId: 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, targetType: string, sessionId: string) {
constructor(connection: Connection, targetInfo: Protocol.Target.TargetInfo) {
super();
const {targetId, type, isProvisional} = targetInfo;
this._connection = connection;
this._targetType = targetType;
this._sessionId = sessionId;
this._targetType = type;
this._sessionId = targetId;
if (isProvisional)
this._provisionalMessages = [];
}
isProvisional() : boolean {
return !!this._provisionalMessages;
}
send<T extends keyof Protocol.CommandParameters>(
@ -194,7 +247,18 @@ export class TargetSession extends EventEmitter {
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)) {

View File

@ -40,7 +40,6 @@ const UTILITY_WORLD_NAME = '__playwright_utility_world__';
export const FrameManagerEvents = {
FrameNavigatedWithinDocument: Symbol('FrameNavigatedWithinDocument'),
TargetSwappedOnNavigation: Symbol('TargetSwappedOnNavigation'),
FrameAttached: Symbol('FrameAttached'),
FrameDetached: Symbol('FrameDetached'),
FrameNavigated: Symbol('FrameNavigated'),
@ -58,14 +57,14 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate,
readonly rawKeyboard: RawKeyboardImpl;
readonly screenshotterDelegate: WKScreenshotDelegate;
_session: TargetSession;
_page: Page<Browser, BrowserContext>;
_networkManager: NetworkManager;
_frames: Map<string, frames.Frame>;
_contextIdToContext: Map<number, js.ExecutionContext>;
_isolatedWorlds: Set<string>;
_sessionListeners: RegisteredListener[] = [];
_mainFrame: frames.Frame;
private _bootstrapScripts: string[] = [];
readonly _page: Page<Browser, BrowserContext>;
private readonly _networkManager: NetworkManager;
private readonly _frames: Map<string, frames.Frame>;
private readonly _contextIdToContext: Map<number, js.ExecutionContext>;
private _isolatedWorlds: Set<string>;
private _sessionListeners: RegisteredListener[] = [];
private _mainFrame: frames.Frame;
private readonly _bootstrapScripts: string[] = [];
constructor(browserContext: BrowserContext) {
super();
@ -83,34 +82,43 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate,
this._networkManager.on(NetworkManagerEvents.RequestFinished, event => this._page.emit(Events.Page.RequestFinished, event));
}
async initialize(session: TargetSession) {
setSession(session: TargetSession) {
helper.removeEventListeners(this._sessionListeners);
this.disconnectFromTarget();
this._session = session;
this.rawKeyboard.setSession(session);
this.rawMouse.setSession(session);
this.screenshotterDelegate.initialize(session);
this.screenshotterDelegate.setSession(session);
this._addSessionListeners();
this.emit(FrameManagerEvents.TargetSwappedOnNavigation);
const [,{frameTree}] = await Promise.all([
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.
this._session.send('Page.enable'),
this._session.send('Page.getResourceTree'),
]);
this._handleFrameTree(frameTree);
await Promise.all([
this._session.send('Runtime.enable').then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)),
this._session.send('Console.enable'),
this._session.send('Dialog.enable'),
this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }),
this._networkManager.initialize(session),
]);
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'));
}
if (this._page._state.userAgent !== null)
await this._session.send('Page.overrideUserAgent', { value: this._page._state.userAgent });
promises.push(session.send('Page.overrideUserAgent', { value: this._page._state.userAgent }));
if (this._page._state.mediaType !== null)
await this._session.send('Page.setEmulatedMedia', { media: this._page._state.mediaType || '' });
promises.push(session.send('Page.setEmulatedMedia', { media: this._page._state.mediaType || '' }));
if (this._page._state.javascriptEnabled !== null)
await this._session.send('Emulation.setJavaScriptEnabled', { enabled: this._page._state.javascriptEnabled });
promises.push(session.send('Emulation.setJavaScriptEnabled', { enabled: this._page._state.javascriptEnabled }));
await Promise.all(promises);
}
didClose() {
@ -511,24 +519,24 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate,
* @internal
*/
class NextNavigationWatchdog {
_frameManager: FrameManager;
_frame: frames.Frame;
_newDocumentNavigationPromise: Promise<Error | null>;
_newDocumentNavigationCallback: (value?: unknown) => void;
_sameDocumentNavigationPromise: Promise<Error | null>;
_sameDocumentNavigationCallback: (value?: unknown) => void;
private _lifecyclePromise: Promise<void>;
private readonly _frameManager: FrameManager;
private readonly _frame: frames.Frame;
private readonly _newDocumentNavigationPromise: Promise<Error | null>;
private _newDocumentNavigationCallback: (value?: unknown) => void;
private readonly _sameDocumentNavigationPromise: Promise<Error | null>;
private _sameDocumentNavigationCallback: (value?: unknown) => void;
private readonly _lifecyclePromise: Promise<void>;
private _lifecycleCallback: () => void;
private _terminationPromise: Promise<Error | null>;
private _terminationCallback: (err: Error | null) => void;
_navigationRequest: any;
_eventListeners: RegisteredListener[];
_timeoutPromise: Promise<Error | null>;
_timeoutId: NodeJS.Timer;
_hasSameDocumentNavigation = false;
_expectedLifecycle: frames.LifecycleEvent[];
_initialLoaderId: string;
_disconnectedListener: RegisteredListener;
private readonly _frameDetachPromise: Promise<Error | null>;
private _frameDetachCallback: (err: Error | null) => void;
private readonly _initialSession: TargetSession;
private _navigationRequest?: network.Request = null;
private readonly _eventListeners: RegisteredListener[];
private readonly _timeoutPromise: Promise<Error | null>;
private readonly _timeoutId: NodeJS.Timer;
private _hasSameDocumentNavigation = false;
private readonly _expectedLifecycle: frames.LifecycleEvent[];
private readonly _initialLoaderId: string;
constructor(frameManager: FrameManager, frame: frames.Frame, waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[], timeout) {
if (Array.isArray(waitUntil))
@ -539,6 +547,7 @@ class NextNavigationWatchdog {
this._frameManager = frameManager;
this._frame = frame;
this._initialLoaderId = frameManager._frameData(frame).loaderId;
this._initialSession = frameManager._session;
this._newDocumentNavigationPromise = new Promise(fulfill => {
this._newDocumentNavigationCallback = fulfill;
});
@ -548,23 +557,19 @@ class NextNavigationWatchdog {
this._lifecyclePromise = new Promise(fulfill => {
this._lifecycleCallback = fulfill;
});
/** @type {?Request} */
this._navigationRequest = null;
this._eventListeners = [
helper.addEventListener(frameManager, FrameManagerEvents.LifecycleEvent, frame => this._onLifecycleEvent(frame)),
helper.addEventListener(frameManager, FrameManagerEvents.FrameNavigated, frame => this._onLifecycleEvent(frame)),
helper.addEventListener(frameManager, FrameManagerEvents.FrameNavigatedWithinDocument, frame => this._onSameDocumentNavigation(frame)),
helper.addEventListener(frameManager, FrameManagerEvents.TargetSwappedOnNavigation, event => this._onTargetReconnected()),
helper.addEventListener(frameManager, FrameManagerEvents.FrameDetached, frame => this._onFrameDetached(frame)),
helper.addEventListener(frameManager.networkManager(), NetworkManagerEvents.Request, this._onRequest.bind(this)),
];
this._registerDisconnectedListener();
const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms');
let timeoutCallback;
this._timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
this._timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
this._terminationPromise = new Promise(fulfill => {
this._terminationCallback = fulfill;
this._frameDetachPromise = new Promise(fulfill => {
this._frameDetachCallback = fulfill;
});
}
@ -581,37 +586,11 @@ class NextNavigationWatchdog {
}
timeoutOrTerminationPromise(): Promise<Error | null> {
return Promise.race([this._timeoutPromise, this._terminationPromise]);
}
_registerDisconnectedListener() {
if (this._disconnectedListener)
helper.removeEventListeners([this._disconnectedListener]);
const session = this._frameManager._session;
this._disconnectedListener = helper.addEventListener(this._frameManager._session, TargetSessionEvents.Disconnected, () => {
// Session may change on swap out, check that it's current.
if (session === this._frameManager._session)
this._terminationCallback(new Error('Navigation failed because browser has disconnected!'));
});
}
async _onTargetReconnected() {
this._registerDisconnectedListener();
// In case web process change we migh have missed load event. Check current ready
// state to mitigate that.
try {
const context = await this._frame.executionContext();
const readyState = await context.evaluate(() => document.readyState);
switch (readyState) {
case 'loading':
case 'interactive':
case 'complete':
this._newDocumentNavigationCallback();
break;
}
} catch (e) {
debugError('_onTargetReconnected ' + e);
}
return Promise.race([
this._timeoutPromise,
this._frameDetachPromise,
this._frameManager._page._disconnectedPromise
]);
}
_onLifecycleEvent(frame: frames.Frame) {
@ -648,13 +627,14 @@ class NextNavigationWatchdog {
this._lifecycleCallback();
if (this._hasSameDocumentNavigation)
this._sameDocumentNavigationCallback();
if (this._frameManager._frameData(this._frame).loaderId !== this._initialLoaderId)
if (this._frameManager._frameData(this._frame).loaderId !== this._initialLoaderId ||
this._initialSession !== this._frameManager._session)
this._newDocumentNavigationCallback();
}
_onFrameDetached(frame: frames.Frame) {
if (this._frame === frame) {
this._terminationCallback.call(null, new Error('Navigating frame was detached'));
this._frameDetachCallback.call(null, new Error('Navigating frame was detached'));
return;
}
this._checkLifecycle();

View File

@ -44,7 +44,7 @@ export class NetworkManager extends EventEmitter {
this._frameManager = frameManager;
}
async initialize(session: TargetSession) {
setSession(session: TargetSession) {
helper.removeEventListeners(this._sessionListeners);
this._session = session;
this._sessionListeners = [
@ -53,8 +53,13 @@ export class NetworkManager extends EventEmitter {
helper.addEventListener(this._session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)),
helper.addEventListener(this._session, 'Network.loadingFailed', this._onLoadingFailed.bind(this)),
];
await this._session.send('Network.enable');
await this._session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders });
}
async initializeSession(session: TargetSession) {
await Promise.all([
session.send('Network.enable'),
session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }),
]);
}
async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) {

View File

@ -11,7 +11,7 @@ import { TargetSession } from './Connection';
export class WKScreenshotDelegate implements ScreenshotterDelegate {
private _session: TargetSession;
initialize(session: TargetSession) {
setSession(session: TargetSession) {
this._session = session;
}

View File

@ -50,7 +50,18 @@ export class Target {
this._page._didClose();
}
async _swappedIn(oldTarget: Target) {
async _initializeSession(session: TargetSession) {
if (!this._page)
return;
await (this._page._delegate as FrameManager)._initializeSession(session).catch(e => {
// Swallow initialization errors due to newer target swap in,
// since we will reinitialize again.
if (!isSwappedOutError(e))
throw e;
});
}
async _swapWith(oldTarget: Target) {
if (!oldTarget._pagePromise)
return;
this._pagePromise = oldTarget._pagePromise;
@ -59,22 +70,17 @@ export class Target {
// old target does not close the page on connection reset.
oldTarget._pagePromise = null;
oldTarget._page = null;
await this._adoptPage();
this._adoptPage();
}
private async _adoptPage() {
private _adoptPage() {
(this._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._page)
this._page._didDisconnect();
});
await (this._page._delegate as FrameManager).initialize(this._session).catch(e => {
// Swallow initialization errors due to newer target swap in,
// since we will reinitialize again.
if (!isSwappedOutError(e))
throw e;
});
(this._page._delegate as FrameManager).setSession(this._session);
}
async page(): Promise<Page<Browser, BrowserContext>> {
@ -86,7 +92,8 @@ export class Target {
const page = frameManager._page;
this._page = page;
this._pagePromise = new Promise(async f => {
await this._adoptPage();
this._adoptPage();
await this._initializeSession(this._session);
if (browser._defaultViewport)
await page.setViewport(browser._defaultViewport);
f(page);