diff --git a/src/chromium/Browser.ts b/src/chromium/Browser.ts index 2c65fdb9c3..dd7585b0a0 100644 --- a/src/chromium/Browser.ts +++ b/src/chromium/Browser.ts @@ -119,7 +119,7 @@ export class Browser extends EventEmitter { const target = this._targets.get(event.targetId); target._initializedCallback(false); this._targets.delete(event.targetId); - target._closedCallback(); + target._didClose(); if (await target._initializedPromise) this.chromium.emit(Events.Chromium.TargetDestroyed, target); } @@ -146,14 +146,24 @@ export class Browser extends EventEmitter { return page; } - async _closeTarget(target: Target) { - await this._client.send('Target.closeTarget', { targetId: target._targetId }); + 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 _pages(context: BrowserContext): Promise { + const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + } + + async _activatePage(page: Page) { + await page._client.send('Target.activateTarget', {targetId: Target.fromPage(page)._targetId}); + } + async _waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise { const { timeout = 30000 diff --git a/src/chromium/BrowserContext.ts b/src/chromium/BrowserContext.ts index 7ae966dba2..ec6314ff73 100644 --- a/src/chromium/BrowserContext.ts +++ b/src/chromium/BrowserContext.ts @@ -21,7 +21,6 @@ import { Browser } from './Browser'; import { CDPSession } from './Connection'; import { Permissions } from './features/permissions'; import { Page } from './Page'; -import { Target } from './Target'; export class BrowserContext { readonly permissions: Permissions; @@ -35,17 +34,8 @@ export class BrowserContext { this.permissions = new Permissions(client, contextId); } - _targets(): Target[] { - return this._browser._allTargets().filter(target => target.browserContext() === this); - } - - async pages(): Promise { - const pages = await Promise.all( - this._targets() - .filter(target => target.type() === 'page') - .map(target => target.page()) - ); - return pages.filter(page => !!page); + pages(): Promise { + return this._browser._pages(this); } isIncognito(): boolean { diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index dcd20a7b84..ae93268ab5 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -35,7 +35,6 @@ import { RawMouseImpl, RawKeyboardImpl } from './Input'; import { NetworkManagerEvents } from './NetworkManager'; import { Protocol } from './protocol'; import { getExceptionMessage, releaseObject } from './protocolHelper'; -import { Target } from './Target'; import * as input from '../input'; import * as types from '../types'; import * as frames from '../frames'; @@ -58,8 +57,10 @@ export type Viewport = { export class Page extends EventEmitter { private _closed = false; + private _closedCallback: () => void; + private _closedPromise: Promise; _client: CDPSession; - _target: Target; + private _browserContext: BrowserContext; private _keyboard: input.Keyboard; private _mouse: input.Mouse; private _timeoutSettings: TimeoutSettings; @@ -79,18 +80,19 @@ export class Page extends EventEmitter { private _disconnectPromise: Promise | undefined; private _emulatedMediaType: string | undefined; - static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise { - const page = new Page(client, target, ignoreHTTPSErrors, screenshotter); + static async create(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise { + const page = new Page(client, browserContext, ignoreHTTPSErrors, screenshotter); await page._initialize(); if (defaultViewport) await page.setViewport(defaultViewport); return page; } - constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, screenshotter: Screenshotter) { + constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, screenshotter: Screenshotter) { super(); this._client = client; - this._target = target; + this._closedPromise = new Promise(f => this._closedCallback = f); + this._browserContext = browserContext; this._keyboard = new input.Keyboard(new RawKeyboardImpl(client)); this._mouse = new input.Mouse(new RawMouseImpl(client), this._keyboard); this._timeoutSettings = new TimeoutSettings(); @@ -134,10 +136,13 @@ export class Page extends EventEmitter { client.on('Inspector.targetCrashed', event => this._onTargetCrashed()); client.on('Log.entryAdded', event => this._onLogEntryAdded(event)); client.on('Page.fileChooserOpened', event => this._onFileChooserOpened(event)); - this._target._isClosedPromise.then(() => { - this.emit(Events.Page.Close); - this._closed = true; - }); + } + + _didClose() { + assert(!this._closed, 'Page closed twice'); + this._closed = true; + this.emit(Events.Page.Close); + this._closedCallback(); } async _initialize() { @@ -179,11 +184,11 @@ export class Page extends EventEmitter { } browser(): Browser { - return this._target.browser(); + return this._browserContext.browser(); } browserContext(): BrowserContext { - return this._target.browserContext(); + return this._browserContext; } _onTargetCrashed() { @@ -518,8 +523,8 @@ export class Page extends EventEmitter { if (runBeforeUnload) { await this._client.send('Page.close'); } else { - await this.browser()._closeTarget(this._target); - await this._target._isClosedPromise; + await this.browser()._closePage(this); + await this._closedPromise; } } diff --git a/src/chromium/Screenshotter.ts b/src/chromium/Screenshotter.ts index fe5666b184..2740233480 100644 --- a/src/chromium/Screenshotter.ts +++ b/src/chromium/Screenshotter.ts @@ -85,7 +85,7 @@ export class Screenshotter { } private async _screenshot(page: Page, format: 'png' | 'jpeg', options: ScreenshotOptions): Promise { - await page._client.send('Target.activateTarget', {targetId: page._target._targetId}); + await page.browser()._activatePage(page); let clip = options.clip ? processClip(options.clip) : undefined; const viewport = page.viewport(); diff --git a/src/chromium/Target.ts b/src/chromium/Target.ts index 922eacb1b2..4e33301837 100644 --- a/src/chromium/Target.ts +++ b/src/chromium/Target.ts @@ -24,6 +24,8 @@ import { Page, Viewport } from './Page'; import { Protocol } from './protocol'; import { Screenshotter } from './Screenshotter'; +const targetSymbol = Symbol('target'); + export class Target { private _targetInfo: Protocol.Target.TargetInfo; private _browserContext: BrowserContext; @@ -36,10 +38,12 @@ export class Target { private _workerPromise: Promise | null = null; _initializedPromise: Promise; _initializedCallback: (value?: unknown) => void; - _isClosedPromise: Promise; - _closedCallback: (value?: unknown) => void; _isInitialized: boolean; + static fromPage(page: Page): Target { + return (page as any)[targetSymbol]; + } + constructor( targetInfo: Protocol.Target.TargetInfo, browserContext: BrowserContext, @@ -67,16 +71,23 @@ export class Target { openerPage.emit(Events.Page.Popup, popupPage); return true; }); - this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill); this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; if (this._isInitialized) this._initializedCallback(true); } + _didClose() { + if (this._pagePromise) + this._pagePromise.then(page => page._didClose()); + } + async page(): Promise { if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { - this._pagePromise = this._sessionFactory() - .then(client => Page.create(client, this, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotter)); + this._pagePromise = this._sessionFactory().then(async client => { + const page = await Page.create(client, this._browserContext, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotter); + page[targetSymbol] = this; + return page; + }); } return this._pagePromise; } diff --git a/src/chromium/features/chromium.ts b/src/chromium/features/chromium.ts index 613552b3b4..8aa84f5ab3 100644 --- a/src/chromium/features/chromium.ts +++ b/src/chromium/features/chromium.ts @@ -92,7 +92,7 @@ export class Chromium extends EventEmitter { } pageTarget(page: Page): Target { - return page._target; + return Target.fromPage(page); } waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise { diff --git a/src/firefox/Browser.ts b/src/firefox/Browser.ts index 852e604dbf..7318474fd3 100644 --- a/src/firefox/Browser.ts +++ b/src/firefox/Browser.ts @@ -150,6 +150,12 @@ export class Browser extends EventEmitter { return Array.from(this._targets.values()); } + async _pages(context: BrowserContext): Promise { + const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + } + 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); @@ -166,7 +172,7 @@ export class Browser extends EventEmitter { _onTargetDestroyed({targetId}) { const target = this._targets.get(targetId); this._targets.delete(targetId); - target._closedCallback(); + target._didClose(); } _onTargetInfoChanged({targetId, url}) { @@ -189,8 +195,6 @@ export class Target { private _type: 'page' | 'browser'; _url: string; private _openerId: string; - _isClosedPromise: Promise; - _closedCallback: (value?: unknown) => void; constructor(connection: any, browser: Browser, context: BrowserContext, targetId: string, type: 'page' | 'browser', url: string, openerId: string | undefined) { this._browser = browser; @@ -200,9 +204,12 @@ export class Target { this._type = type; this._url = url; this._openerId = openerId; - this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill); } + _didClose() { + if (this._pagePromise) + this._pagePromise.then(page => page._didClose()); + } opener(): Target | null { return this._openerId ? this._browser._targets.get(this._openerId) : null; @@ -225,7 +232,7 @@ export class Target { async page() { if (this._type === 'page' && !this._pagePromise) { const session = await this._connection.createSession(this._targetId); - this._pagePromise = Page.create(session, this, this._browser._defaultViewport); + this._pagePromise = Page.create(session, this._context, this._browser._defaultViewport); } return this._pagePromise; } @@ -248,17 +255,8 @@ export class BrowserContext { this.permissions = new Permissions(connection, browserContextId); } - _targets(): Array { - return this._browser._allTargets().filter(target => target.browserContext() === this); - } - - async pages(): Promise> { - const pages = await Promise.all( - this._targets() - .filter(target => target.type() === 'page') - .map(target => target.page()) - ); - return pages.filter(page => !!page); + pages(): Promise { + return this._browser._pages(this); } isIncognito(): boolean { diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index f0de1717f3..7ffd457c38 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -21,7 +21,7 @@ import * as mime from 'mime'; import { TimeoutError } from '../Errors'; import { assert, debugError, helper, RegisteredListener } from '../helper'; import { TimeoutSettings } from '../TimeoutSettings'; -import { BrowserContext, Target } from './Browser'; +import { BrowserContext } from './Browser'; import { JugglerSession, JugglerSessionEvents } from './Connection'; import { Events } from './events'; import { Accessibility } from './features/accessibility'; @@ -44,12 +44,14 @@ const writeFileAsync = helper.promisify(fs.writeFile); export class Page extends EventEmitter { private _timeoutSettings: TimeoutSettings; private _session: JugglerSession; - private _target: Target; + private _browserContext: BrowserContext; private _keyboard: input.Keyboard; private _mouse: input.Mouse; readonly accessibility: Accessibility; readonly interception: Interception; private _closed: boolean; + private _closedCallback: () => void; + private _closedPromise: Promise; private _pageBindings: Map; private _networkManager: NetworkManager; _frameManager: FrameManager; @@ -59,8 +61,8 @@ export class Page extends EventEmitter { private _disconnectPromise: Promise; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - static async create(session: JugglerSession, target: Target, defaultViewport: Viewport | null) { - const page = new Page(session, target); + static async create(session: JugglerSession, browserContext: BrowserContext, defaultViewport: Viewport | null) { + const page = new Page(session, browserContext); await Promise.all([ session.send('Runtime.enable'), session.send('Network.enable'), @@ -73,15 +75,16 @@ export class Page extends EventEmitter { return page; } - constructor(session: JugglerSession, target: Target) { + constructor(session: JugglerSession, browserContext: BrowserContext) { super(); this._timeoutSettings = new TimeoutSettings(); this._session = session; - this._target = target; + this._browserContext = browserContext; this._keyboard = new input.Keyboard(new RawKeyboardImpl(session)); this._mouse = new input.Mouse(new RawMouseImpl(session), this._keyboard); this.accessibility = new Accessibility(session); this._closed = false; + this._closedPromise = new Promise(f => this._closedCallback = f); this._pageBindings = new Map(); this._networkManager = new NetworkManager(session); this._frameManager = new FrameManager(session, this, this._networkManager, this._timeoutSettings); @@ -104,13 +107,16 @@ export class Page extends EventEmitter { helper.addEventListener(this._networkManager, NetworkManagerEvents.RequestFailed, request => this.emit(Events.Page.RequestFailed, request)), ]; this._viewport = null; - this._target._isClosedPromise.then(() => { - this._closed = true; - this._frameManager.dispose(); - this._networkManager.dispose(); - helper.removeEventListeners(this._eventListeners); - this.emit(Events.Page.Close); - }); + } + + _didClose() { + assert(!this._closed, 'Page closed twice'); + this._closed = true; + this._frameManager.dispose(); + this._networkManager.dispose(); + helper.removeEventListeners(this._eventListeners); + this.emit(Events.Page.Close); + this._closedCallback(); } async setExtraHTTPHeaders(headers) { @@ -250,7 +256,7 @@ export class Page extends EventEmitter { } browserContext(): BrowserContext { - return this._target.browserContext(); + return this._browserContext; } _onUncaughtError(params) { @@ -288,7 +294,7 @@ export class Page extends EventEmitter { } browser() { - return this._target.browser(); + return this._browserContext.browser(); } url() { @@ -535,7 +541,7 @@ export class Page extends EventEmitter { } = options; await this._session.send('Page.close', { runBeforeUnload }); if (!runBeforeUnload) - await this._target._isClosedPromise; + await this._closedPromise; } async content() { diff --git a/src/webkit/Browser.ts b/src/webkit/Browser.ts index f37b976238..e54010b9aa 100644 --- a/src/webkit/Browser.ts +++ b/src/webkit/Browser.ts @@ -17,7 +17,7 @@ import * as childProcess from 'child_process'; import { EventEmitter } from 'events'; -import { assert, helper, RegisteredListener } from '../helper'; +import { assert, helper, RegisteredListener, debugError } from '../helper'; import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } from '../network'; import { Connection } from './Connection'; import { Page, Viewport } from './Page'; @@ -167,7 +167,23 @@ export class Browser extends EventEmitter { _onTargetDestroyed({targetId}) { const target = this._targets.get(targetId); this._targets.delete(targetId); - target._closedCallback(); + target._didClose(); + } + + _closePage(page: Page) { + this._connection.send('Target.close', { + targetId: Target.fromPage(page)._targetId + }).catch(debugError); + } + + async _pages(context: BrowserContext): Promise { + const targets = this.targets().filter(target => target.browserContext() === context && target.type() === 'page'); + const pages = await Promise.all(targets.map(target => target.page())); + return pages.filter(page => !!page); + } + + async _activatePage(page: Page): Promise { + await this._connection.send('Target.activate', { targetId: Target.fromPage(page)._targetId }); } async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) { @@ -177,8 +193,12 @@ export class Browser extends EventEmitter { const page = await oldTarget._pagePromise; const newTarget = this._targets.get(newTargetId); const newSession = this._connection.session(newTargetId); - page._swapTargetOnNavigation(newSession, newTarget); + 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; } disconnect() { @@ -204,17 +224,8 @@ export class BrowserContext { this._id = contextId; } - _targets(): Target[] { - return this._browser.targets().filter(target => target.browserContext() === this); - } - - async pages(): Promise { - const pages = await Promise.all( - this._targets() - .filter(target => target.type() === 'page') - .map(target => target.page()) - ); - return pages.filter(page => !!page); + pages(): Promise { + return this._browser._pages(this); } isIncognito(): boolean { diff --git a/src/webkit/FrameManager.ts b/src/webkit/FrameManager.ts index 09425e5b1d..535aa68d83 100644 --- a/src/webkit/FrameManager.ts +++ b/src/webkit/FrameManager.ts @@ -91,7 +91,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { ]; } - async _swapTargetOnNavigation(newSession) { + async _swapSessionOnNavigation(newSession) { helper.removeEventListeners(this._sessionListeners); this.disconnectFromTarget(); this._session = newSession; diff --git a/src/webkit/Page.ts b/src/webkit/Page.ts index 2b0d142b08..266baaebf5 100644 --- a/src/webkit/Page.ts +++ b/src/webkit/Page.ts @@ -28,7 +28,6 @@ import { FrameManager, FrameManagerEvents } from './FrameManager'; import { RawKeyboardImpl, RawMouseImpl } from './Input'; import { NetworkManagerEvents } from './NetworkManager'; import { Protocol } from './protocol'; -import { Target } from './Target'; import { TaskQueue } from './TaskQueue'; import * as input from '../input'; import * as types from '../types'; @@ -48,8 +47,10 @@ export type Viewport = { export class Page extends EventEmitter { private _closed = false; + private _closedCallback: () => void; + private _closedPromise: Promise; _session: TargetSession; - private _target: Target; + private _browserContext: BrowserContext; private _keyboard: input.Keyboard; private _mouse: input.Mouse; private _timeoutSettings: TimeoutSettings; @@ -64,16 +65,17 @@ export class Page extends EventEmitter { private _emulatedMediaType: string | undefined; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - static async create(session: TargetSession, target: Target, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise { - const page = new Page(session, target, screenshotTaskQueue); + static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise { + const page = new Page(session, browserContext, screenshotTaskQueue); await page._initialize(); if (defaultViewport) await page.setViewport(defaultViewport); return page; } - constructor(session: TargetSession, target: Target, screenshotTaskQueue: TaskQueue) { + constructor(session: TargetSession, browserContext: BrowserContext, screenshotTaskQueue: TaskQueue) { super(); + this._closedPromise = new Promise(f => this._closedCallback = f); this._keyboard = new input.Keyboard(new RawKeyboardImpl(session)); this._mouse = new input.Mouse(new RawMouseImpl(session), this._keyboard); this._timeoutSettings = new TimeoutSettings(); @@ -82,7 +84,7 @@ export class Page extends EventEmitter { this._screenshotTaskQueue = screenshotTaskQueue; this._setSession(session); - this._setTarget(target); + this._browserContext = browserContext; this._frameManager.on(FrameManagerEvents.FrameAttached, event => this.emit(Events.Page.FrameAttached, event)); this._frameManager.on(FrameManagerEvents.FrameDetached, event => this.emit(Events.Page.FrameDetached, event)); @@ -95,6 +97,13 @@ export class Page extends EventEmitter { networkManager.on(NetworkManagerEvents.RequestFinished, event => this.emit(Events.Page.RequestFinished, event)); } + _didClose() { + assert(!this._closed, 'Page closed twice'); + this._closed = true; + this.emit(Events.Page.Close); + this._closedCallback(); + } + async _initialize() { return Promise.all([ this._frameManager.initialize(), @@ -127,29 +136,18 @@ export class Page extends EventEmitter { event.defaultPrompt)); } - _setTarget(newTarget: Target) { - this._target = newTarget; - this._target._isClosedPromise.then(() => { - if (this._target !== newTarget) - return; - this.emit(Events.Page.Close); - this._closed = true; - }); - } - - async _swapTargetOnNavigation(newSession : TargetSession, newTarget : Target) { + async _swapSessionOnNavigation(newSession: TargetSession) { this._setSession(newSession); - this._setTarget(newTarget); - await this._frameManager._swapTargetOnNavigation(newSession); + await this._frameManager._swapSessionOnNavigation(newSession); await this._initialize().catch(e => debugError('failed to enable agents after swap: ' + e)); } browser(): Browser { - return this._target.browser(); + return this._browserContext.browser(); } browserContext(): BrowserContext { - return this._target.browserContext(); + return this._browserContext; } _onTargetCrashed() { @@ -419,7 +417,7 @@ export class Page extends EventEmitter { Object.assign(params, this._viewport); } const [, result] = await Promise.all([ - this._session._connection.send('Target.activate', { targetId: this._target._targetId }), + this.browser()._activatePage(this), this._session.send('Page.snapshotRect', params), ]).catch(e => { debugError('Failed to take screenshot: ' + e); @@ -437,12 +435,8 @@ export class Page extends EventEmitter { } async close() { - this.browser()._connection.send('Target.close', { - targetId: this._target._targetId - }).catch(e => { - debugError(e); - }); - await this._target._isClosedPromise; + this.browser()._closePage(this); + await this._closedPromise; } isClosed(): boolean { diff --git a/src/webkit/Target.ts b/src/webkit/Target.ts index 66c379b37b..785fe6119c 100644 --- a/src/webkit/Target.ts +++ b/src/webkit/Target.ts @@ -20,6 +20,8 @@ import { Browser, BrowserContext } from './Browser'; import { Page } from './Page'; import { Protocol } from './protocol'; +const targetSymbol = Symbol('target'); + export class Target { private _browserContext: BrowserContext; _targetId: string; @@ -28,11 +30,13 @@ export class Target { private _url: string; _initializedPromise: Promise; _initializedCallback: (value?: unknown) => void; - _isClosedPromise: Promise; - _closedCallback: (value?: unknown) => void; _isInitialized: boolean; _eventListeners: RegisteredListener[]; + static fromPage(page: Page): Target { + return (page as any)[targetSymbol]; + } + constructor(targetInfo: Protocol.Target.TargetInfo, browserContext: BrowserContext) { const {targetId, url, type} = targetInfo; this._browserContext = browserContext; @@ -41,13 +45,24 @@ export class Target { /** @type {?Promise} */ this._pagePromise = null; this._url = url; - this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill); + } + + _didClose() { + if (this._pagePromise) + this._pagePromise.then(page => page._didClose()); + } + + _adoptPage(page: Page) { + (page as any)[targetSymbol] = this; } async page(): Promise { if (this._type === 'page' && !this._pagePromise) { const session = this.browser()._connection.session(this._targetId); - this._pagePromise = Page.create(session, this, this.browser()._defaultViewport, this.browser()._screenshotTaskQueue); + this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport, this.browser()._screenshotTaskQueue).then(page => { + this._adoptPage(page); + return page; + }); } return this._pagePromise; }