diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 20aa8c3d6b..49ea276fbc 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -128,7 +128,7 @@ export class CRPage implements PageDelegate { const response = await this._client.send('Page.navigate', { url, referrer, frameId: frame._id }); if (response.errorText) throw new Error(`${response.errorText} at ${url}`); - return { newDocumentId: response.loaderId, isSameDocument: !response.loaderId }; + return { newDocumentId: response.loaderId }; } _onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) { diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 568e166961..674146e1de 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -134,8 +134,8 @@ export class FFPage implements PageDelegate { _onNavigationAborted(params: Protocol.Page.navigationAbortedPayload) { const frame = this._page._frameManager.frame(params.frameId)!; - for (const watcher of this._page._frameManager._lifecycleWatchers) - watcher._onAbortedNewDocumentNavigation(frame, params.navigationId, params.errorText); + for (const watcher of frame._documentWatchers) + watcher(params.navigationId, new Error(params.errorText)); } _onNavigationCommitted(params: Protocol.Page.navigationCommittedPayload) { @@ -256,7 +256,7 @@ export class FFPage implements PageDelegate { async navigateFrame(frame: frames.Frame, url: string, referer: string | undefined): Promise { const response = await this._session.send('Page.navigate', { url, referer, frameId: frame._id }); - return { newDocumentId: response.navigationId || undefined, isSameDocument: !response.navigationId }; + return { newDocumentId: response.navigationId || undefined }; } async setExtraHTTPHeaders(headers: network.Headers): Promise { diff --git a/src/frames.ts b/src/frames.ts index a257fa23c1..39959e2162 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -47,7 +47,6 @@ export type GotoOptions = NavigateOptions & { }; export type GotoResult = { newDocumentId?: string, - isSameDocument?: boolean, }; export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; @@ -61,7 +60,7 @@ export class FrameManager { private _frames = new Map(); private _webSockets = new Map(); private _mainFrame: Frame; - readonly _lifecycleWatchers = new Set(); + readonly _lifecycleWatchers = new Set<() => void>(); readonly _consoleMessageTags = new Map(); constructor(page: Page) { @@ -118,13 +117,12 @@ export class FrameManager { frame._url = url; frame._name = name; frame._lastDocumentId = documentId; + for (const watcher of frame._documentWatchers) + watcher(documentId); this.clearFrameLifecycle(frame); this.clearWebSockets(frame); - if (!initial) { - for (const watcher of this._lifecycleWatchers) - watcher._onCommittedNewDocumentNavigation(frame); + if (!initial) this._page.emit(Events.Page.FrameNavigated, frame); - } } frameCommittedSameDocumentNavigation(frameId: string, url: string) { @@ -132,8 +130,8 @@ export class FrameManager { if (!frame) return; frame._url = url; - for (const watcher of this._lifecycleWatchers) - watcher._onNavigatedWithinDocument(frame); + for (const watcher of frame._sameDocumentNavigationWatchers) + watcher(); this._page.emit(Events.Page.FrameNavigated, frame); } @@ -152,7 +150,7 @@ export class FrameManager { frame._firedLifecycleEvents.add('domcontentloaded'); frame._firedLifecycleEvents.add('load'); for (const watcher of this._lifecycleWatchers) - watcher._onLifecycleEvent(frame); + watcher(); if (frame === this.mainFrame() && !hasDOMContentLoaded) this._page.emit(Events.Page.DOMContentLoaded); if (frame === this.mainFrame() && !hasLoad) @@ -165,7 +163,7 @@ export class FrameManager { return; frame._firedLifecycleEvents.add(event); for (const watcher of this._lifecycleWatchers) - watcher._onLifecycleEvent(frame); + watcher(); if (frame === this._mainFrame && event === 'load') this._page.emit(Events.Page.Load); if (frame === this._mainFrame && event === 'domcontentloaded') @@ -194,9 +192,9 @@ export class FrameManager { requestStarted(request: network.Request) { this._inflightRequestStarted(request); const frame = request.frame(); - if (request._documentId && frame && !request.redirectChain().length) { - for (const watcher of this._lifecycleWatchers) - watcher._onNavigationRequest(frame, request); + if (frame) { + for (const watcher of frame._requestWatchers) + watcher(request); } if (!request._isFavicon) this._page._requestStarted(request); @@ -222,8 +220,8 @@ export class FrameManager { let errorText = request.failure()!.errorText; if (canceled) errorText += '; maybe frame was detached?'; - for (const watcher of this._lifecycleWatchers) - watcher._onAbortedNewDocumentNavigation(frame, request._documentId, errorText); + for (const watcher of frame._documentWatchers) + watcher(request._documentId, new Error(errorText)); } } if (!request._isFavicon) @@ -274,9 +272,9 @@ export class FrameManager { ws._error(errorMessage); } - provisionalLoadFailed(documentId: string, error: string) { - for (const watcher of this._lifecycleWatchers) - watcher._onProvisionalLoadFailed(documentId, error); + provisionalLoadFailed(frame: Frame, documentId: string, error: string) { + for (const watcher of frame._documentWatchers) + watcher(documentId, new Error(error)); } private _removeFramesRecursively(frame: Frame) { @@ -284,8 +282,6 @@ export class FrameManager { this._removeFramesRecursively(child); frame._onDetached(); this._frames.delete(frame._id); - for (const watcher of this._lifecycleWatchers) - watcher._onFrameDetached(frame); this._page.emit(Events.Page.FrameDetached, frame); } @@ -345,7 +341,10 @@ export class FrameManager { export class Frame { _id: string; readonly _firedLifecycleEvents: Set; - _lastDocumentId: string; + _lastDocumentId = ''; + _requestWatchers = new Set<(request: network.Request) => void>(); + _documentWatchers = new Set<(documentId: string, error?: Error) => void>(); + _sameDocumentNavigationWatchers = new Set<() => void>(); readonly _page: Page; private _parentFrame: Frame | null; _url = ''; @@ -356,14 +355,17 @@ export class Frame { _inflightRequests = new Set(); readonly _networkIdleTimers = new Map(); private _setContentCounter = 0; + private _detachedPromise: Promise; + private _detachedCallback = () => {}; constructor(page: Page, id: string, parentFrame: Frame | null) { this._id = id; this._firedLifecycleEvents = new Set(); - this._lastDocumentId = ''; this._page = page; this._parentFrame = parentFrame; + this._detachedPromise = new Promise(x => this._detachedCallback = x); + this._contextData.set('main', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, rerunnableTasks: new Set() }); this._contextData.set('utility', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, rerunnableTasks: new Set() }); this._setContext('main', null); @@ -373,15 +375,21 @@ export class Frame { this._parentFrame._childFrames.add(this); } - async goto(url: string, options?: GotoOptions): Promise { + async goto(url: string, options: GotoOptions = {}): Promise { let referer = (this._page._state.extraHTTPHeaders || {})['referer']; - if (options && options.referer !== undefined) { + if (options.referer !== undefined) { if (referer !== undefined && referer !== options.referer) throw new Error('"referer" is already specified as extra HTTP header'); referer = options.referer; } - const watcher = new LifecycleWatcher(this, options, false /* supportUrlMatch */); 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 { @@ -391,52 +399,175 @@ export class Frame { } }; - let error = await Promise.race([ + throwIfError(await Promise.race([ navigate(), - watcher.timeoutOrTerminationPromise, - ]); - if (!error) { - const promises = [watcher.timeoutOrTerminationPromise]; - if (navigateResult!.newDocumentId) { - watcher.setExpectedDocumentId(navigateResult!.newDocumentId, url); - promises.push(watcher.newDocumentNavigationPromise); - } else if (navigateResult!.isSameDocument) { - promises.push(watcher.sameDocumentNavigationPromise); - } else { - promises.push(watcher.sameDocumentNavigationPromise, watcher.newDocumentNavigationPromise); - } - error = await Promise.race(promises); + 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._waitForResponse() : 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); } - watcher.dispose(); - if (error) - throw error; - return watcher.navigationResponse(); } - async waitForNavigation(options?: WaitForNavigationOptions): Promise { - const watcher = new LifecycleWatcher(this, options, true /* supportUrlMatch */); - const error = await Promise.race([ - watcher.timeoutOrTerminationPromise, - watcher.sameDocumentNavigationPromise, - watcher.newDocumentNavigationPromise, + async waitForNavigation(options: 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)), ]); - watcher.dispose(); + 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; - return watcher.navigationResponse(); + return request ? request._finalRequest._waitForResponse() : null; } - async waitForLoadState(options?: NavigateOptions): Promise { - const watcher = new LifecycleWatcher(this, options, false /* supportUrlMatch */); + async waitForLoadState(options: NavigateOptions = {}): Promise { + const {timeout = this._page._timeoutSettings.navigationTimeout()} = options; + const disposer = new Disposer(); const error = await Promise.race([ - watcher.timeoutOrTerminationPromise, - watcher.lifecyclePromise + this._createFrameDestroyedPromise(), + disposer.add(createTimeoutPromise(timeout)), + disposer.add(this._waitForLifecycle(options.waitUntil)), ]); - watcher.dispose(); + 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) + return resolve(new Error('Navigation interrupted by another one')); + resolve(error); + }; + 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 && !platform.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 (platform.urlMatches(this.url(), url)) + resolve(); + }; + const dispose = () => this._sameDocumentNavigationWatchers.delete(watch); + this._sameDocumentNavigationWatchers.add(watch); + return {value: promise, dispose}; + } + + _waitForLifecycle(waitUntil: LifecycleEvent|LifecycleEvent[] = 'load'): Disposable> { + let resolve: () => void; + const expectedLifecycle = typeof waitUntil === 'string' ? [waitUntil] : waitUntil; + for (const event of expectedLifecycle) { + if (!kLifecycleEvents.has(event)) + throw new Error(`Unsupported waitUntil option ${String(event)}`); + } + + 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 { + for (const event of expectedLifecycle) { + if (!frame._firedLifecycleEvents.has(event)) + 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.redirectChain().length) + 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!')), + ]); + } + async frameElement(): Promise { return this._page._delegate.getFrameElement(this); } @@ -527,27 +658,21 @@ export class Frame { async setContent(html: string, options?: NavigateOptions): Promise { const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; const context = await this._utilityContext(); - let watcher: LifecycleWatcher; - this._page._frameManager._consoleMessageTags.set(tag, () => { - // Clear lifecycle right after document.open() - see 'tag' below. - this._page._frameManager.clearFrameLifecycle(this); - watcher = new LifecycleWatcher(this, options, false /* supportUrlMatch */); + const lifecyclePromise = new Promise(resolve => { + this._page._frameManager._consoleMessageTags.set(tag, () => { + // Clear lifecycle right after document.open() - see 'tag' below. + this._page._frameManager.clearFrameLifecycle(this); + resolve(this.waitForLoadState(options)); + }); }); - await context.evaluate((html, tag) => { + const contentPromise = context.evaluate((html, tag) => { window.stop(); document.open(); console.debug(tag); // eslint-disable-line no-console document.write(html); document.close(); }, html, tag); - assert(watcher!, 'Was not able to clear lifecycle in setContent'); - const error = await Promise.race([ - watcher!.timeoutOrTerminationPromise, - watcher!.lifecyclePromise, - ]); - watcher!.dispose(); - if (error) - throw error; + await Promise.all([contentPromise, lifecyclePromise]); } name(): string { @@ -826,6 +951,7 @@ export class Frame { _onDetached() { this._detached = true; + this._detachedCallback(); for (const data of this._contextData.values()) { for (const rerunnableTask of data.rerunnableTasks) rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.')); @@ -957,162 +1083,37 @@ class RerunnableTask { } } -class LifecycleWatcher { - readonly sameDocumentNavigationPromise: Promise; - readonly lifecyclePromise: Promise; - readonly newDocumentNavigationPromise: Promise; - readonly timeoutOrTerminationPromise: Promise; - private _expectedLifecycle: LifecycleEvent[]; - private _frame: Frame; - private _navigationRequest: network.Request | null = null; - private _sameDocumentNavigationCompleteCallback: () => void = () => {}; - private _lifecycleCallback: () => void = () => {}; - private _newDocumentNavigationCompleteCallback: () => void = () => {}; - private _frameDetachedCallback: (err: Error) => void = () => {}; - private _navigationAbortedCallback: (err: Error) => void = () => {}; - private _maximumTimer?: NodeJS.Timer; - private _hasSameDocumentNavigation = false; - private _targetUrl: string | undefined; - private _expectedDocumentId: string | undefined; - private _urlMatch: types.URLMatch | undefined; - - constructor(frame: Frame, options: WaitForNavigationOptions | undefined, supportUrlMatch: boolean) { - options = options || {}; - let { waitUntil = 'load' as LifecycleEvent } = options; - const { timeout = frame._page._timeoutSettings.navigationTimeout() } = options; - if (!Array.isArray(waitUntil)) - waitUntil = [waitUntil]; - for (const event of waitUntil) { - if (!kLifecycleEvents.has(event)) - throw new Error(`Unsupported waitUntil option ${String(event)}`); - } - if (supportUrlMatch) - this._urlMatch = options.url; - this._expectedLifecycle = waitUntil.slice(); - this._frame = frame; - this.sameDocumentNavigationPromise = new Promise(f => this._sameDocumentNavigationCompleteCallback = f); - this.lifecyclePromise = new Promise(f => this._lifecycleCallback = f); - this.newDocumentNavigationPromise = new Promise(f => this._newDocumentNavigationCompleteCallback = f); - this.timeoutOrTerminationPromise = Promise.race([ - this._createTimeoutPromise(timeout), - new Promise(f => this._frameDetachedCallback = f), - new Promise(f => this._navigationAbortedCallback = f), - this._frame._page._disconnectedPromise.then(() => new Error('Navigation failed because browser has disconnected!')), - ]); - frame._page._frameManager._lifecycleWatchers.add(this); - this._checkLifecycleComplete(); +type Disposable = {value: T, dispose: () => void}; +class Disposer { + private _disposes: (() => void)[] = []; + add({value, dispose}: Disposable) { + this._disposes.push(dispose); + return value; } - - _urlMatches(urlString: string): boolean { - return !this._urlMatch || platform.urlMatches(urlString, this._urlMatch); - } - - setExpectedDocumentId(documentId: string, url: string) { - assert(!this._urlMatch, 'Should not have url match when expecting a particular navigation'); - this._expectedDocumentId = documentId; - this._targetUrl = url; - if (this._navigationRequest && this._navigationRequest._documentId !== documentId) - this._navigationRequest = null; - } - - _onFrameDetached(frame: Frame) { - if (this._frame === frame) { - this._frameDetachedCallback.call(null, new Error('Navigating frame was detached')); - return; - } - this._checkLifecycleComplete(); - } - - _onNavigatedWithinDocument(frame: Frame) { - if (frame !== this._frame) - return; - this._hasSameDocumentNavigation = true; - this._checkLifecycleComplete(); - } - - _onNavigationRequest(frame: Frame, request: network.Request) { - assert(request._documentId); - if (frame !== this._frame || !this._urlMatches(request.url())) - return; - if (this._expectedDocumentId === undefined || this._expectedDocumentId === request._documentId) { - this._navigationRequest = request; - this._expectedDocumentId = request._documentId; - this._targetUrl = request.url(); - } - } - - _onCommittedNewDocumentNavigation(frame: Frame) { - if (frame === this._frame && this._expectedDocumentId !== undefined && this._navigationRequest && - frame._lastDocumentId !== this._expectedDocumentId) { - this._navigationAbortedCallback(new Error('Navigation to ' + this._targetUrl + ' was canceled by another one')); - return; - } - if (frame === this._frame && this._expectedDocumentId === undefined && this._urlMatches(frame.url())) { - this._expectedDocumentId = frame._lastDocumentId; - this._targetUrl = frame.url(); - } - } - - _onAbortedNewDocumentNavigation(frame: Frame, documentId: string, errorText: string) { - if (frame === this._frame && documentId === this._expectedDocumentId) { - if (this._targetUrl) - this._navigationAbortedCallback(new Error('Navigation to ' + this._targetUrl + ' failed: ' + errorText)); - else - this._navigationAbortedCallback(new Error('Navigation failed: ' + errorText)); - } - } - - _onProvisionalLoadFailed(documentId: string, error: string) { - this._onAbortedNewDocumentNavigation(this._frame, documentId, error); - } - - _onLifecycleEvent(frame: Frame) { - this._checkLifecycleComplete(); - } - - async navigationResponse(): Promise { - return this._navigationRequest ? this._navigationRequest._finalRequest._waitForFinished() : null; - } - - private _createTimeoutPromise(timeout: number): Promise { - if (!timeout) - return new Promise(() => {}); - const errorMessage = 'Navigation timeout of ' + timeout + ' ms exceeded'; - return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, timeout)) - .then(() => new TimeoutError(errorMessage)); - } - - private _checkLifecycleRecursively(frame: Frame, expectedLifecycle: LifecycleEvent[]): boolean { - for (const event of expectedLifecycle) { - if (!frame._firedLifecycleEvents.has(event)) - return false; - } - for (const child of frame.childFrames()) { - if (!this._checkLifecycleRecursively(child, expectedLifecycle)) - return false; - } - return true; - } - - private _checkLifecycleComplete() { - if (!this._checkLifecycleRecursively(this._frame, this._expectedLifecycle)) - return; - if (this._urlMatches(this._frame.url())) { - this._lifecycleCallback(); - if (this._hasSameDocumentNavigation) - this._sameDocumentNavigationCompleteCallback(); - } - if (this._frame._lastDocumentId === this._expectedDocumentId) - this._newDocumentNavigationCompleteCallback(); - } - dispose() { - this._frame._page._frameManager._lifecycleWatchers.delete(this); - if (this._maximumTimer) - clearTimeout(this._maximumTimer); + 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, visibility: types.Visibility): string { let label; switch (visibility) { diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 9849efc0ac..a75e7f5ba6 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -307,7 +307,7 @@ export class WKPage implements PageDelegate { throw new Error('Target closed'); const pageProxyId = this._pageProxySession.sessionId; const result = await this._pageProxySession.connection.browserSession.send('Browser.navigate', { url, pageProxyId, frameId: frame._id, referrer }); - return { newDocumentId: result.loaderId, isSameDocument: !result.loaderId }; + return { newDocumentId: result.loaderId }; } private _onConsoleMessage(event: Protocol.Console.messageAddedPayload) { diff --git a/src/webkit/wkPageProxy.ts b/src/webkit/wkPageProxy.ts index a28a3dc068..78c572b87f 100644 --- a/src/webkit/wkPageProxy.ts +++ b/src/webkit/wkPageProxy.ts @@ -85,7 +85,7 @@ export class WKPageProxy { let errorText = event.error; if (errorText.includes('cancelled')) errorText += '; maybe frame was detached?'; - this._wkPage._page._frameManager.provisionalLoadFailed(event.loaderId, errorText); + this._wkPage._page._frameManager.provisionalLoadFailed(this._wkPage._page.mainFrame(), event.loaderId, errorText); } async page(): Promise { diff --git a/test/interception.spec.js b/test/interception.spec.js index 003996ef8d..79a1ba89da 100644 --- a/test/interception.spec.js +++ b/test/interception.spec.js @@ -352,22 +352,20 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p it('should not throw when continued after navigation', async({page, server}) => { await page.route(server.PREFIX + '/one-style.css', () => {}); // For some reason, Firefox issues load event with one outstanding request. - const failed = page.goto(server.PREFIX + '/one-style.html', { waitUntil: FFOX ? 'networkidle0' : 'load' }).catch(e => e); + const firstNavigation = page.goto(server.PREFIX + '/one-style.html', { waitUntil: FFOX ? 'networkidle0' : 'load' }).catch(e => e); const request = await page.waitForRequest(server.PREFIX + '/one-style.css'); await page.goto(server.PREFIX + '/empty.html'); - const error = await failed; - expect(error.message).toBe('Navigation to ' + server.PREFIX + '/one-style.html was canceled by another one'); + await firstNavigation; const notAnError = await request.continue().then(() => null).catch(e => e); expect(notAnError).toBe(null); }); it('should not throw when continued after cross-process navigation', async({page, server}) => { await page.route(server.PREFIX + '/one-style.css', () => {}); // For some reason, Firefox issues load event with one outstanding request. - const failed = page.goto(server.PREFIX + '/one-style.html', { waitUntil: FFOX ? 'networkidle0' : 'load' }).catch(e => e); + const firstNavigation = page.goto(server.PREFIX + '/one-style.html', { waitUntil: FFOX ? 'networkidle0' : 'load' }).catch(e => e); const request = await page.waitForRequest(server.PREFIX + '/one-style.css'); await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); - const error = await failed; - expect(error.message).toBe('Navigation to ' + server.PREFIX + '/one-style.html was canceled by another one'); + await firstNavigation; const notAnError = await request.continue().then(() => null).catch(e => e); expect(notAnError).toBe(null); }); diff --git a/test/launcher.spec.js b/test/launcher.spec.js index f9e513133b..63f4b9539f 100644 --- a/test/launcher.spec.js +++ b/test/launcher.spec.js @@ -145,7 +145,7 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, p await server.waitForRequest('/one-style.css'); await remote.close(); const error = await navigationPromise; - expect(error.message).toBe('Navigation failed because browser has disconnected!'); + expect(error.message).toContain('Navigation failed because browser has disconnected!'); await browserServer.close(); }); it('should reject waitForSelector when browser closes', async({server}) => { diff --git a/test/navigation.spec.js b/test/navigation.spec.js index 5e0d30c4f2..1a3c449d92 100644 --- a/test/navigation.spec.js +++ b/test/navigation.spec.js @@ -355,14 +355,13 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF expect(request2.headers['referer']).toBe(undefined); expect(page.url()).toBe(server.PREFIX + '/grid.html'); }); - it.skip(FFOX)('should fail when canceled by another navigation', async({page, server}) => { - server.setRoute('/one-style.css', (req, res) => {}); - // For some reason, Firefox issues load event with one outstanding request. - const failed = page.goto(server.PREFIX + '/one-style.html', { waitUntil: FFOX ? 'networkidle0' : 'load' }).catch(e => e); - await server.waitForRequest('/one-style.css'); + it('should fail when canceled by another navigation', async({page, server}) => { + server.setRoute('/one-style.html', (req, res) => {}); + const failed = page.goto(server.PREFIX + '/one-style.html').catch(e => e); + await server.waitForRequest('/one-style.html'); await page.goto(server.PREFIX + '/empty.html'); const error = await failed; - expect(error.message).toBe('Navigation to ' + server.PREFIX + '/one-style.html was canceled by another one'); + expect(error.message).toBeTruthy(); }); describe('network idle', function() { @@ -502,7 +501,7 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF }); it.skip(FFOX)('should wait for networkidle0 in setContent with request from previous navigation', async({page, server}) => { // TODO: in Firefox window.stop() does not cancel outstanding requests, and we also lack 'init' lifecycle, - // therefore we don't clear inglight requests at the right time. + // therefore we don't clear inflight requests at the right time. await page.goto(server.EMPTY_PAGE); server.setRoute('/foo.js', () => {}); await page.setContent(``); @@ -512,7 +511,7 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF }); it.skip(FFOX)('should wait for networkidle2 in setContent with request from previous navigation', async({page, server}) => { // TODO: in Firefox window.stop() does not cancel outstanding requests, and we also lack 'init' lifecycle, - // therefore we don't clear inglight requests at the right time. + // therefore we don't clear inflight requests at the right time. await page.goto(server.EMPTY_PAGE); server.setRoute('/foo.js', () => {}); await page.setContent(``); @@ -660,7 +659,7 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF expect(forwardResponse).toBe(null); expect(page.url()).toBe(server.PREFIX + '/second.html'); }); - it.skip(FFOX)('should work when subframe issues window.stop()', async({page, server}) => { + it('should work when subframe issues window.stop()', async({page, server}) => { server.setRoute('/frames/style.css', (req, res) => {}); const navigationPromise = page.goto(server.PREFIX + '/frames/one-frame.html'); const frame = await new Promise(f => page.once('frameattached', f)); @@ -726,30 +725,37 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF await waitPromise; expect(resolved).toBe(true); }); + it('should work for cross-process navigations', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const waitPromise = page.waitForNavigation({waitUntil: []}); + const url = server.CROSS_PROCESS_PREFIX + '/empty.html'; + const gotoPromise = page.goto(url); + const response = await waitPromise; + expect(response.url()).toBe(url); + expect(page.url()).toBe(url); + expect(await page.evaluate('document.location.href')).toBe(url); + await gotoPromise; + }); }); describe('Page.waitForLoadState', () => { it('should pick up ongoing navigation', async({page, server}) => { let response = null; server.setRoute('/one-style.css', (req, res) => response = res); - const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); - await server.waitForRequest('/one-style.css'); + await Promise.all([ + server.waitForRequest('/one-style.css'), + page.goto(server.PREFIX + '/one-style.html', {waitUntil: []}), + ]); const waitPromise = page.waitForLoadState(); response.statusCode = 404; response.end('Not found'); await waitPromise; - await navigationPromise; }); it('should respect timeout', async({page, server}) => { - let response = null; server.setRoute('/one-style.css', (req, res) => response = res); - const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); - await server.waitForRequest('/one-style.css'); + await page.goto(server.PREFIX + '/one-style.html', {waitUntil: []}); const error = await page.waitForLoadState({ timeout: 1 }).catch(e => e); expect(error.message).toBe('Navigation timeout of 1 ms exceeded'); - response.statusCode = 404; - response.end('Not found'); - await navigationPromise; }); it('should resolve immediately if loaded', async({page, server}) => { await page.goto(server.PREFIX + '/one-style.html'); @@ -757,14 +763,9 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF }); it('should resolve immediately if load state matches', async({page, server}) => { await page.goto(server.EMPTY_PAGE); - let response = null; server.setRoute('/one-style.css', (req, res) => response = res); - const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); - await server.waitForRequest('/one-style.css'); + await page.goto(server.PREFIX + '/one-style.html', {waitUntil: []}); await page.waitForLoadState({ waitUntil: 'domcontentloaded' }); - response.statusCode = 404; - response.end('Not found'); - await navigationPromise; }); it.skip(FFOX)('should work with pages that have loaded before being connected to', async({page, context, server}) => { await page.goto(server.EMPTY_PAGE); @@ -837,6 +838,7 @@ 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}) => { // Disable cache: otherwise, chromium will cache similar requests. @@ -897,6 +899,24 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF }); }); + describe('Frame.waitForLodState', function() { + it('should work', async({page, server}) => { + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]; + + const requestPromise = new Promise(resolve => page.route(server.PREFIX + '/one-style.css',resolve)); + await frame.goto(server.PREFIX + '/one-style.html', {waitUntil: 'domcontentloaded'}); + const request = await requestPromise; + let resolved = false; + const loadPromise = frame.waitForLoadState().then(() => resolved = true); + // give the promise a chance to resolve, even though it shouldn't + await page.evaluate('1'); + expect(resolved).toBe(false); + request.continue(); + await loadPromise; + }); + }); + describe('Page.reload', function() { it('should work', async({page, server}) => { await page.goto(server.EMPTY_PAGE); diff --git a/utils/testserver/index.js b/utils/testserver/index.js index 13f2ea77be..567bfe04dd 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -72,8 +72,7 @@ class TestServer { /** @type {!Set} */ this._sockets = new Set(); - - /** @type {!Map} */ + /** @type {!Map} */ this._routes = new Map(); /** @type {!Map} */ this._auths = new Map(); @@ -134,7 +133,7 @@ class TestServer { /** * @param {string} path - * @param {function(!IncomingMessage, !ServerResponse)} handler + * @param {function(!http.IncomingMessage,http.ServerResponse)} handler */ setRoute(path, handler) { this._routes.set(path, handler); @@ -153,7 +152,7 @@ class TestServer { /** * @param {string} path - * @return {!Promise} + * @return {!Promise} */ waitForRequest(path) { let promise = this._requestSubscribers.get(path); @@ -181,6 +180,10 @@ class TestServer { this._requestSubscribers.clear(); } + /** + * @param {http.IncomingMessage} request + * @param {http.ServerResponse} response + */ _onRequest(request, response) { request.on('error', error => { if (error.code === 'ECONNRESET') @@ -218,8 +221,8 @@ class TestServer { } /** - * @param {!IncomingMessage} request - * @param {!ServerResponse} response + * @param {!http.IncomingMessage} request + * @param {!http.ServerResponse} response * @param {string} pathName */ serveFile(request, response, pathName) {