diff --git a/src/frames.ts b/src/frames.ts index e3b3bf215c..7d6996072a 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -323,7 +323,7 @@ export class Frame { _inflightRequests = new Set(); readonly _networkIdleTimers = new Map(); private _setContentCounter = 0; - private _detachedPromise: Promise; + readonly _detachedPromise: Promise; private _detachedCallback = () => {}; constructor(page: Page, id: string, parentFrame: Frame | null) { @@ -352,186 +352,44 @@ export class Frame { referer = options.referer; } url = helper.completeUserURL(url); - const { timeout = this._page._timeoutSettings.navigationTimeout() } = options; - const disposer = new Disposer(); - const timeoutPromise = disposer.add(createTimeoutPromise(timeout)); - const frameDestroyedPromise = this._createFrameDestroyedPromise(); - const sameDocumentPromise = disposer.add(this._waitForSameDocumentNavigation()); - const requestWatcher = disposer.add(this._trackDocumentRequests()); - let navigateResult: GotoResult; - const navigate = async () => { - try { - navigateResult = await this._page._delegate.navigateFrame(this, url, referer); - } catch (error) { - return error; - } - }; - - throwIfError(await Promise.race([ - navigate(), - timeoutPromise, - frameDestroyedPromise, - ])); - - const promises: Promise[] = [timeoutPromise, frameDestroyedPromise]; - if (navigateResult!.newDocumentId) - promises.push(disposer.add(this._waitForSpecificDocument(navigateResult!.newDocumentId))); - else - promises.push(sameDocumentPromise); - throwIfError(await Promise.race(promises)); - - const request = (navigateResult! && navigateResult!.newDocumentId) ? requestWatcher.get(navigateResult!.newDocumentId) : null; - const waitForLifecyclePromise = disposer.add(this._waitForLifecycle(options.waitUntil)); - throwIfError(await Promise.race([timeoutPromise, frameDestroyedPromise, waitForLifecyclePromise])); - - disposer.dispose(); - - return request ? request._finalRequest().response() : null; - - function throwIfError(error: Error|void): asserts error is void { - if (!error) - return; - disposer.dispose(); - const message = `While navigating to ${url}: ${error.message}`; - if (error instanceof TimeoutError) - throw new TimeoutError(message); - throw new Error(message); + const frameTask = new FrameTask(this, options, url); + const sameDocumentPromise = frameTask.waitForSameDocumentNavigation(); + const navigateResult = await frameTask.raceAgainstFailures(this._page._delegate.navigateFrame(this, url, referer)).catch(e => { + // Do not leave sameDocumentPromise unhandled. + sameDocumentPromise.catch(e => {}); + throw e; + }); + if (navigateResult.newDocumentId) { + // Do not leave sameDocumentPromise unhandled. + sameDocumentPromise.catch(e => {}); + await frameTask.waitForSpecificDocument(navigateResult.newDocumentId); + } else { + await sameDocumentPromise; } + const request = (navigateResult && navigateResult.newDocumentId) ? frameTask.request(navigateResult.newDocumentId) : null; + await frameTask.waitForLifecycle(options.waitUntil); + frameTask.done(); + return request ? request._finalRequest().response() : null; } async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise { - const disposer = new Disposer(); - const requestWatcher = disposer.add(this._trackDocumentRequests()); - const {timeout = this._page._timeoutSettings.navigationTimeout()} = options; - - const failurePromise = Promise.race([ - this._createFrameDestroyedPromise(), - disposer.add(createTimeoutPromise(timeout)), + const frameTask = new FrameTask(this, options); + let documentId: string | undefined; + await Promise.race([ + frameTask.waitForNewDocument(options.url).then(id => documentId = id), + frameTask.waitForSameDocumentNavigation(options.url), ]); - let documentId: string|null = null; - let error: void|Error = await Promise.race([ - failurePromise, - disposer.add(this._waitForNewDocument(options.url)).then(result => { - if (result.error) - return result.error; - documentId = result.documentId; - }), - disposer.add(this._waitForSameDocumentNavigation(options.url)), - ]); - const request = requestWatcher.get(documentId!); - if (!error) { - error = await Promise.race([ - failurePromise, - disposer.add(this._waitForLifecycle(options.waitUntil)), - ]); - } - disposer.dispose(); - if (error) - throw error; - + const request = documentId ? frameTask.request(documentId) : null; + await frameTask.waitForLifecycle(options.waitUntil); + frameTask.done(); return request ? request._finalRequest().response() : null; } async waitForLoadState(options: types.NavigateOptions = {}): Promise { - const { timeout = this._page._timeoutSettings.navigationTimeout() } = options; - const disposer = new Disposer(); - const error = await Promise.race([ - this._createFrameDestroyedPromise(), - disposer.add(createTimeoutPromise(timeout)), - disposer.add(this._waitForLifecycle(options.waitUntil)), - ]); - disposer.dispose(); - if (error) - throw error; - } - - _waitForSpecificDocument(expectedDocumentId: string): Disposable> { - let resolve: (error: Error|void) => void; - const promise = new Promise(x => resolve = x); - const watch = (documentId: string, error?: Error) => { - if (documentId === expectedDocumentId) - resolve(error); - else if (!error) - resolve(new Error('Navigation interrupted by another one')); - }; - const dispose = () => this._documentWatchers.delete(watch); - this._documentWatchers.add(watch); - return {value: promise, dispose}; - } - - _waitForNewDocument(url?: types.URLMatch): Disposable> { - let resolve: (error: {error?: Error, documentId: string}) => void; - const promise = new Promise<{error?: Error, documentId: string}>(x => resolve = x); - const watch = (documentId: string, error?: Error) => { - if (!error && !helper.urlMatches(this.url(), url)) - return; - resolve({error, documentId}); - }; - const dispose = () => this._documentWatchers.delete(watch); - this._documentWatchers.add(watch); - return {value: promise, dispose}; - } - - _waitForSameDocumentNavigation(url?: types.URLMatch): Disposable> { - let resolve: () => void; - const promise = new Promise(x => resolve = x); - const watch = () => { - if (helper.urlMatches(this.url(), url)) - resolve(); - }; - const dispose = () => this._sameDocumentNavigationWatchers.delete(watch); - this._sameDocumentNavigationWatchers.add(watch); - return {value: promise, dispose}; - } - - _waitForLifecycle(waitUntil: types.LifecycleEvent = 'load'): Disposable> { - let resolve: () => void; - if (!types.kLifecycleEvents.has(waitUntil)) - throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`); - - const checkLifecycleComplete = () => { - if (!checkLifecycleRecursively(this)) - return; - resolve(); - }; - - const promise = new Promise(x => resolve = x); - const dispose = () => this._page._frameManager._lifecycleWatchers.delete(checkLifecycleComplete); - this._page._frameManager._lifecycleWatchers.add(checkLifecycleComplete); - checkLifecycleComplete(); - return {value: promise, dispose}; - - function checkLifecycleRecursively(frame: Frame): boolean { - if (!frame._firedLifecycleEvents.has(waitUntil)) - return false; - for (const child of frame.childFrames()) { - if (!checkLifecycleRecursively(child)) - return false; - } - return true; - } - } - - _trackDocumentRequests(): Disposable> { - const requestMap = new Map(); - const dispose = () => { - this._requestWatchers.delete(onRequest); - }; - const onRequest = (request: network.Request) => { - if (!request._documentId || request.redirectedFrom()) - return; - requestMap.set(request._documentId, request); - }; - this._requestWatchers.add(onRequest); - return {dispose, value: requestMap}; - } - - _createFrameDestroyedPromise(): Promise { - return Promise.race([ - this._page._disconnectedPromise.then(() => new Error('Navigation failed because browser has disconnected!')), - this._detachedPromise.then(() => new Error('Navigating frame was detached!')), - ]); + const frameTask = new FrameTask(this, options); + await frameTask.waitForLifecycle(options.waitUntil); + frameTask.done(); } async frameElement(): Promise { @@ -1028,37 +886,6 @@ class RerunnableTask { } } -type Disposable = {value: T, dispose: () => void}; -class Disposer { - private _disposes: (() => void)[] = []; - add({value, dispose}: Disposable) { - this._disposes.push(dispose); - return value; - } - dispose() { - for (const dispose of this._disposes) - dispose(); - this._disposes = []; - } -} - -function createTimeoutPromise(timeout: number): Disposable> { - if (!timeout) - return { value: new Promise(() => {}), dispose: () => void 0 }; - - let timer: NodeJS.Timer; - const errorMessage = 'Navigation timeout of ' + timeout + ' ms exceeded'; - const promise = new Promise(fulfill => timer = setTimeout(fulfill, timeout)) - .then(() => new TimeoutError(errorMessage)); - const dispose = () => { - clearTimeout(timer); - }; - return { - value: promise, - dispose - }; -} - function selectorToString(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden'): string { let label; switch (waitFor) { @@ -1108,3 +935,146 @@ class PendingNavigationBarrier { this._promiseCallback(); } } + +export class FrameTask { + private _frame: Frame; + private _failurePromise: Promise; + private _requestMap = new Map(); + private _disposables: (() => void)[] = []; + private _url: string | undefined; + + constructor(frame: Frame, options: types.TimeoutOptions, url?: string) { + this._frame = frame; + this._url = url; + + // Process timeouts + let timeoutPromise = new Promise(() => {}); + const { timeout = frame._page._timeoutSettings.navigationTimeout() } = options; + if (timeout) { + const errorMessage = 'Navigation timeout of ' + timeout + ' ms exceeded'; + let timer: NodeJS.Timer; + timeoutPromise = new Promise(fulfill => timer = setTimeout(fulfill, timeout)) + .then(() => { throw new TimeoutError(errorMessage); }); + this._disposables.push(() => clearTimeout(timer)); + } + + // Process detached frames + this._failurePromise = Promise.race([ + timeoutPromise, + this._frame._page._disconnectedPromise.then(() => { throw new Error('Navigation failed because browser has disconnected!'); }), + this._frame._detachedPromise.then(() => { throw new Error('Navigating frame was detached!'); }), + ]); + + // Collect requests during the task. + const watcher = (request: network.Request) => { + if (!request._documentId || request.redirectedFrom()) + return; + this._requestMap.set(request._documentId, request); + }; + this._disposables.push(() => this._frame._requestWatchers.delete(watcher)); + this._frame._requestWatchers.add(watcher); + } + + async raceAgainstFailures(promise: Promise): Promise { + let result: T; + let error: Error | undefined; + await Promise.race([ + this._failurePromise.catch(e => error = e), + promise.then(r => result = r).catch(e => error = e) + ]); + + if (!error) + return result!; + this.done(); + if (this._url) + error.message = error.message + ` while navigating to ${this._url}`; + throw error; + } + + request(id: string): network.Request | undefined { + return this._requestMap.get(id); + } + + async waitForSameDocumentNavigation(url?: types.URLMatch): Promise { + let resolve: () => void; + const promise = new Promise(x => resolve = x); + const watch = () => { + if (helper.urlMatches(this._frame.url(), url)) + resolve(); + }; + this._disposables.push(() => this._frame._sameDocumentNavigationWatchers.delete(watch)); + this._frame._sameDocumentNavigationWatchers.add(watch); + return this.raceAgainstFailures(promise); + } + + async waitForSpecificDocument(expectedDocumentId: string): Promise { + let resolve: () => void; + let reject: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const watch = (documentId: string, error?: Error) => { + if (documentId === expectedDocumentId) { + if (!error) + resolve(); + else + reject(error); + } else if (!error) { + reject(new Error('Navigation interrupted by another one')); + } + }; + this._disposables.push(() => this._frame._documentWatchers.delete(watch)); + this._frame._documentWatchers.add(watch); + await this.raceAgainstFailures(promise); + } + + waitForNewDocument(url?: types.URLMatch): Promise { + let resolve: (documentId: string) => void; + let reject: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const watch = (documentId: string, error?: Error) => { + if (!error && !helper.urlMatches(this._frame.url(), url)) + return; + if (error) + reject(error); + else + resolve(documentId); + }; + this._disposables.push(() => this._frame._documentWatchers.delete(watch)); + this._frame._documentWatchers.add(watch); + return this.raceAgainstFailures(promise); + } + + waitForLifecycle(waitUntil: types.LifecycleEvent = 'load'): Promise { + let resolve: () => void; + if (!types.kLifecycleEvents.has(waitUntil)) + throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`); + + const checkLifecycleComplete = () => { + if (!checkLifecycleRecursively(this._frame)) + return; + resolve(); + }; + + const promise = new Promise(x => resolve = x); + this._disposables.push(() => this._frame._page._frameManager._lifecycleWatchers.delete(checkLifecycleComplete)); + this._frame._page._frameManager._lifecycleWatchers.add(checkLifecycleComplete); + checkLifecycleComplete(); + return this.raceAgainstFailures(promise); + + function checkLifecycleRecursively(frame: Frame): boolean { + if (!frame._firedLifecycleEvents.has(waitUntil)) + return false; + for (const child of frame.childFrames()) { + if (!checkLifecycleRecursively(child)) + return false; + } + return true; + } + } + + done() { + this._failurePromise.catch(e => {}); + for (const disposable of this._disposables) + disposable(); + this._disposables = []; + } +} diff --git a/src/page.ts b/src/page.ts index e8bc66952e..35adc19b37 100644 --- a/src/page.ts +++ b/src/page.ts @@ -104,14 +104,13 @@ export class PageEvent { if (!(page instanceof Page)) return page; - const { dispose, value: waitForLifecycle } = page.mainFrame()._waitForLifecycle(lifecycle); - const error = await Promise.race([ - page.mainFrame()._createFrameDestroyedPromise(), - waitForLifecycle, - ]); - dispose(); - if (error) + try { + const frameTask = new frames.FrameTask(page.mainFrame(), { timeout: 0 }); + await frameTask.waitForLifecycle(lifecycle); + frameTask.done(); + } catch (error) { return error; + } return page; } diff --git a/test/navigation.spec.js b/test/navigation.spec.js index 6aa8e53bfc..8e8dc4664b 100644 --- a/test/navigation.spec.js +++ b/test/navigation.spec.js @@ -897,7 +897,6 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF await page.$eval('iframe', frame => frame.remove()); const error = await navigationPromise; expect(error.message).toContain('frame was detached'); - expect(error.stack).toContain('Frame.goto') }); it('should return matching responses', async({page, server}) => { await page.goto(server.EMPTY_PAGE);