diff --git a/src/chromium/ExecutionContext.ts b/src/chromium/ExecutionContext.ts index d63fa6e74f..6165ed5e77 100644 --- a/src/chromium/ExecutionContext.ts +++ b/src/chromium/ExecutionContext.ts @@ -17,7 +17,7 @@ import { CDPSession } from './Connection'; import { Frame } from './Frame'; -import { assert, helper } from '../helper'; +import { helper } from '../helper'; import { valueFromRemoteObject, getExceptionMessage } from './protocolHelper'; import { createJSHandle, ElementHandle, JSHandle } from './JSHandle'; import { Protocol } from './protocol'; diff --git a/src/chromium/Frame.ts b/src/chromium/Frame.ts index 69d3a8c1b6..e6e64bccbd 100644 --- a/src/chromium/Frame.ts +++ b/src/chromium/Frame.ts @@ -19,14 +19,11 @@ import * as types from '../types'; import * as fs from 'fs'; import { helper, assert } from '../helper'; import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption } from '../input'; -import { CDPSession } from './Connection'; import { ExecutionContext } from './ExecutionContext'; -import { FrameManager } from './FrameManager'; -import { ElementHandle, JSHandle, createJSHandle } from './JSHandle'; +import { ElementHandle, JSHandle } from './JSHandle'; import { Response } from './NetworkManager'; -import { Protocol } from './protocol'; -import { LifecycleWatcher } from './LifecycleWatcher'; import { waitForSelectorOrXPath, WaitTaskParams, WaitTask } from '../waitTask'; +import { TimeoutSettings } from '../TimeoutSettings'; const readFileAsync = helper.promisify(fs.readFile); @@ -38,25 +35,35 @@ type World = { waitTasks: Set>; }; +export type NavigateOptions = { + timeout?: number, + waitUntil?: string | string[], +}; + +export type GotoOptions = NavigateOptions & { + referer?: string, +}; + +export interface FrameDelegate { + timeoutSettings(): TimeoutSettings; + navigateFrame(frame: Frame, url: string, options?: GotoOptions): Promise; + waitForFrameNavigation(frame: Frame, options?: NavigateOptions): Promise; + setFrameContent(frame: Frame, html: string, options?: NavigateOptions): Promise; + adoptElementHandle(elementHandle: ElementHandle, context: ExecutionContext): Promise; +} + export class Frame { - _id: string; - _frameManager: FrameManager; - private _client: CDPSession; + _delegate: FrameDelegate; private _parentFrame: Frame; private _url = ''; private _detached = false; - _loaderId = ''; - _lifecycleEvents = new Set(); - _worlds = new Map(); + private _worlds = new Map(); private _childFrames = new Set(); private _name: string; - private _navigationURL: string; - constructor(frameManager: FrameManager, client: CDPSession, parentFrame: Frame | null, frameId: string) { - this._frameManager = frameManager; - this._client = client; + constructor(delegate: FrameDelegate, parentFrame: Frame | null) { + this._delegate = delegate; this._parentFrame = parentFrame; - this._id = frameId; this._worlds.set('main', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, waitTasks: new Set() }); this._worlds.set('utility', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, waitTasks: new Set() }); @@ -67,15 +74,12 @@ export class Frame { this._parentFrame._childFrames.add(this); } - async goto( - url: string, - options: { referer?: string; timeout?: number; waitUntil?: string | string[]; } | undefined - ): Promise { - return await this._frameManager.navigateFrame(this, url, options); + goto(url: string, options?: GotoOptions): Promise { + return this._delegate.navigateFrame(this, url, options); } - async waitForNavigation(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise { - return await this._frameManager.waitForFrameNavigation(this, options); + waitForNavigation(options?: NavigateOptions): Promise { + return this._delegate.waitForFrameNavigation(this, options); } _mainContext(): Promise { @@ -146,30 +150,8 @@ export class Frame { }); } - async setContent(html: string, options: { - timeout?: number; - waitUntil?: string | string[]; - } = {}) { - const { - waitUntil = ['load'], - timeout = this._frameManager._timeoutSettings.navigationTimeout(), - } = options; - const context = await this._utilityContext(); - // We rely upon the fact that document.open() will reset frame lifecycle with "init" - // lifecycle event. @see https://crrev.com/608658 - await context.evaluate(html => { - document.open(); - document.write(html); - document.close(); - }, html); - const watcher = new LifecycleWatcher(this._frameManager, this, waitUntil, timeout); - const error = await Promise.race([ - watcher.timeoutOrTerminationPromise(), - watcher.lifecyclePromise(), - ]); - watcher.dispose(); - if (error) - throw error; + setContent(html: string, options?: NavigateOptions) { + return this._delegate.setFrameContent(this, html, options); } name(): string { @@ -404,7 +386,7 @@ export class Frame { visible?: boolean; hidden?: boolean; timeout?: number; } | undefined): Promise { - const params = waitForSelectorOrXPath(selector, false /* isXPath */, { timeout: this._frameManager._timeoutSettings.timeout(), ...options }); + const params = waitForSelectorOrXPath(selector, false /* isXPath */, { timeout: this._delegate.timeoutSettings().timeout(), ...options }); const handle = await this._scheduleWaitTask(params, this._worlds.get('utility')); if (!handle.asElement()) { await handle.dispose(); @@ -418,7 +400,7 @@ export class Frame { visible?: boolean; hidden?: boolean; timeout?: number; } | undefined): Promise { - const params = waitForSelectorOrXPath(xpath, true /* isXPath */, { timeout: this._frameManager._timeoutSettings.timeout(), ...options }); + const params = waitForSelectorOrXPath(xpath, true /* isXPath */, { timeout: this._delegate.timeoutSettings().timeout(), ...options }); const handle = await this._scheduleWaitTask(params, this._worlds.get('utility')); if (!handle.asElement()) { await handle.dispose(); @@ -434,7 +416,7 @@ export class Frame { ...args): Promise { const { polling = 'raf', - timeout = this._frameManager._timeoutSettings.timeout(), + timeout = this._delegate.timeoutSettings().timeout(), } = options; const params: WaitTaskParams = { predicateBody: pageFunction, @@ -451,28 +433,9 @@ export class Frame { return context.evaluate(() => document.title); } - _navigated(framePayload: Protocol.Page.Frame) { - this._name = framePayload.name; - // TODO(lushnikov): remove this once requestInterception has loaderId exposed. - this._navigationURL = framePayload.url; - this._url = framePayload.url; - } - - _navigatedWithinDocument(url: string) { + _navigated(url: string, name: string) { this._url = url; - } - - _onLifecycleEvent(loaderId: string, name: string) { - if (name === 'init') { - this._loaderId = loaderId; - this._lifecycleEvents.clear(); - } - this._lifecycleEvents.add(name); - } - - _onLoadingStopped() { - this._lifecycleEvents.add('DOMContentLoaded'); - this._lifecycleEvents.add('load'); + this._name = name; } _detach() { @@ -527,12 +490,9 @@ export class Frame { private async _adoptElementHandle(elementHandle: ElementHandle, context: ExecutionContext, dispose: boolean): Promise { if (elementHandle.executionContext() === context) return elementHandle; - const nodeInfo = await this._client.send('DOM.describeNode', { - objectId: elementHandle._remoteObject.objectId, - }); - const result = await context._adoptBackendNodeId(nodeInfo.node.backendNodeId); + const handle = this._delegate.adoptElementHandle(elementHandle, context); if (dispose) await elementHandle.dispose(); - return result; + return handle; } } diff --git a/src/chromium/FrameManager.ts b/src/chromium/FrameManager.ts index f296bd7b5f..68bd8e07c6 100644 --- a/src/chromium/FrameManager.ts +++ b/src/chromium/FrameManager.ts @@ -20,11 +20,12 @@ import { assert, debugError } from '../helper'; import { TimeoutSettings } from '../TimeoutSettings'; import { CDPSession } from './Connection'; import { EVALUATION_SCRIPT_URL, ExecutionContext } from './ExecutionContext'; -import { Frame } from './Frame'; +import { Frame, NavigateOptions, FrameDelegate } from './Frame'; import { LifecycleWatcher } from './LifecycleWatcher'; import { NetworkManager, Response } from './NetworkManager'; import { Page } from './Page'; import { Protocol } from './protocol'; +import { ElementHandle, createJSHandle } from './JSHandle'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -36,7 +37,14 @@ export const FrameManagerEvents = { FrameNavigatedWithinDocument: Symbol('Events.FrameManager.FrameNavigatedWithinDocument'), }; -export class FrameManager extends EventEmitter { +const frameDataSymbol = Symbol('frameData'); +type FrameData = { + id: string, + loaderId: string, + lifecycleEvents: Set, +}; + +export class FrameManager extends EventEmitter implements FrameDelegate { _client: CDPSession; private _page: Page; private _networkManager: NetworkManager; @@ -81,6 +89,10 @@ export class FrameManager extends EventEmitter { return this._networkManager; } + _frameData(frame: Frame): FrameData { + return (frame as any)[frameDataSymbol]; + } + async navigateFrame( frame: Frame, url: string, @@ -95,7 +107,7 @@ export class FrameManager extends EventEmitter { const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); let ensureNewDocumentNavigation = false; let error = await Promise.race([ - navigate(this._client, url, referer, frame._id), + navigate(this._client, url, referer, this._frameData(frame).id), watcher.timeoutOrTerminationPromise(), ]); if (!error) { @@ -141,11 +153,50 @@ export class FrameManager extends EventEmitter { return watcher.navigationResponse(); } + async setFrameContent(frame: Frame, html: string, options: NavigateOptions = {}) { + const { + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + const context = await frame._utilityContext(); + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + await context.evaluate(html => { + document.open(); + document.write(html); + document.close(); + }, html); + const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) + throw error; + } + + timeoutSettings(): TimeoutSettings { + return this._timeoutSettings; + } + + async adoptElementHandle(elementHandle: ElementHandle, context: ExecutionContext): Promise { + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: elementHandle._remoteObject.objectId, + }); + return context._adoptBackendNodeId(nodeInfo.node.backendNodeId); + } + _onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) { const frame = this._frames.get(event.frameId); if (!frame) return; - frame._onLifecycleEvent(event.loaderId, event.name); + const data = this._frameData(frame); + if (event.name === 'init') { + data.loaderId = event.loaderId; + data.lifecycleEvents.clear(); + } + data.lifecycleEvents.add(event.name); this.emit(FrameManagerEvents.LifecycleEvent, frame); } @@ -153,7 +204,9 @@ export class FrameManager extends EventEmitter { const frame = this._frames.get(frameId); if (!frame) return; - frame._onLoadingStopped(); + const data = this._frameData(frame); + data.lifecycleEvents.add('DOMContentLoaded'); + data.lifecycleEvents.add('load'); this.emit(FrameManagerEvents.LifecycleEvent, frame); } @@ -189,8 +242,14 @@ export class FrameManager extends EventEmitter { return; assert(parentFrameId); const parentFrame = this._frames.get(parentFrameId); - const frame = new Frame(this, this._client, parentFrame, frameId); - this._frames.set(frame._id, frame); + const frame = new Frame(this, parentFrame); + const data: FrameData = { + id: frameId, + loaderId: '', + lifecycleEvents: new Set(), + }; + frame[frameDataSymbol] = data; + this._frames.set(frameId, frame); this.emit(FrameManagerEvents.FrameAttached, frame); } @@ -209,18 +268,25 @@ export class FrameManager extends EventEmitter { if (isMainFrame) { if (frame) { // Update frame id to retain frame identity on cross-process navigation. - this._frames.delete(frame._id); - frame._id = framePayload.id; + const data = this._frameData(frame); + this._frames.delete(data.id); + data.id = framePayload.id; } else { // Initial main frame navigation. - frame = new Frame(this, this._client, null, framePayload.id); + frame = new Frame(this, null); + const data: FrameData = { + id: framePayload.id, + loaderId: '', + lifecycleEvents: new Set(), + }; + frame[frameDataSymbol] = data; } this._frames.set(framePayload.id, frame); this._mainFrame = frame; } // Update frame payload. - frame._navigated(framePayload); + frame._navigated(framePayload.url, framePayload.name); this.emit(FrameManagerEvents.FrameNavigated, frame); } @@ -234,7 +300,7 @@ export class FrameManager extends EventEmitter { worldName: name, }), await Promise.all(this.frames().map(frame => this._client.send('Page.createIsolatedWorld', { - frameId: frame._id, + frameId: this._frameData(frame).id, grantUniveralAccess: true, worldName: name, }).catch(debugError))); // frames might be removed before we send this @@ -244,7 +310,7 @@ export class FrameManager extends EventEmitter { const frame = this._frames.get(frameId); if (!frame) return; - frame._navigatedWithinDocument(url); + frame._navigated(url, frame.name()); this.emit(FrameManagerEvents.FrameNavigatedWithinDocument, frame); this.emit(FrameManagerEvents.FrameNavigated, frame); } @@ -294,7 +360,7 @@ export class FrameManager extends EventEmitter { for (const child of frame.childFrames()) this._removeFramesRecursively(child); frame._detach(); - this._frames.delete(frame._id); + this._frames.delete(this._frameData(frame).id); this.emit(FrameManagerEvents.FrameDetached, frame); } } diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index a587b52451..77c065d4de 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -37,7 +37,7 @@ type Point = { export function createJSHandle(context: ExecutionContext, remoteObject: Protocol.Runtime.RemoteObject) { const frame = context.frame(); if (remoteObject.subtype === 'node' && frame) { - const frameManager = frame._frameManager; + const frameManager = frame._delegate as FrameManager; return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager); } return new JSHandle(context, context._client, remoteObject); diff --git a/src/chromium/LifecycleWatcher.ts b/src/chromium/LifecycleWatcher.ts index 06c1977b49..5a6778475d 100644 --- a/src/chromium/LifecycleWatcher.ts +++ b/src/chromium/LifecycleWatcher.ts @@ -55,7 +55,7 @@ export class LifecycleWatcher { this._frameManager = frameManager; this._frame = frame; - this._initialLoaderId = frame._loaderId; + this._initialLoaderId = frameManager._frameData(frame).loaderId; this._timeout = timeout; this._eventListeners = [ helper.addEventListener(frameManager._client, CDPSessionEvents.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), @@ -138,20 +138,9 @@ export class LifecycleWatcher { } _checkLifecycleComplete() { - // We expect navigation to commit. - if (!checkLifecycle(this._frame, this._expectedLifecycle)) - return; - this._lifecycleCallback(); - if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) - return; - if (this._hasSameDocumentNavigation) - this._sameDocumentNavigationCompleteCallback(); - if (this._frame._loaderId !== this._initialLoaderId) - this._newDocumentNavigationCompleteCallback(); - - function checkLifecycle(frame: Frame, expectedLifecycle: string[]): boolean { + const checkLifecycle = (frame: Frame, expectedLifecycle: string[]): boolean => { for (const event of expectedLifecycle) { - if (!frame._lifecycleEvents.has(event)) + if (!this._frameManager._frameData(frame).lifecycleEvents.has(event)) return false; } for (const child of frame.childFrames()) { @@ -159,7 +148,18 @@ export class LifecycleWatcher { return false; } return true; - } + }; + + // We expect navigation to commit. + if (!checkLifecycle(this._frame, this._expectedLifecycle)) + return; + this._lifecycleCallback(); + if (this._frameManager._frameData(this._frame).loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) + return; + if (this._hasSameDocumentNavigation) + this._sameDocumentNavigationCompleteCallback(); + if (this._frameManager._frameData(this._frame).loaderId !== this._initialLoaderId) + this._newDocumentNavigationCompleteCallback(); } dispose() {