diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 0014db703a1850..cc48bb8d843a38 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -80,6 +80,7 @@ export class CRPage implements PageDelegate { helper.addEventListener(this._client, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), helper.addEventListener(this._client, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)), helper.addEventListener(this._client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), + helper.addEventListener(this._client, 'Page.frameRequestedNavigation', event => this._onFrameRequestedNavigation(event)), helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), helper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)), @@ -172,6 +173,10 @@ export class CRPage implements PageDelegate { this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url, framePayload.name || '', framePayload.loaderId, initial); } + _onFrameRequestedNavigation(payload: Protocol.Page.frameRequestedNavigationPayload) { + this._page._frameManager.frameRequestedNavigation(payload.frameId); + } + async _ensureIsolatedWorld(name: string) { if (this._isolatedWorlds.has(name)) return; diff --git a/src/dom.ts b/src/dom.ts index 3e415a8d4ec26e..d41dfd52bc390c 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -65,7 +65,9 @@ export class FrameExecutionContext extends js.ExecutionContext { })); let result; try { - result = await this._delegate.evaluate(this, returnByValue, pageFunction, ...adopted); + result = await this.frame._page._frameManager.waitForNavigationsCreatedBy(async () => { + return this._delegate.evaluate(this, returnByValue, pageFunction, ...adopted); + }); } finally { await Promise.all(toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose()))); } @@ -248,12 +250,15 @@ export class ElementHandle extends js.JSHandle { const point = offset ? await this._offsetPoint(offset) : await this._clickablePoint(); if (waitFor) await this._waitForHitTargetAt(point, options); - let restoreModifiers: input.Modifier[] | undefined; - if (options && options.modifiers) - restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); - await action(point); - if (restoreModifiers) - await this._page.keyboard._ensureModifiers(restoreModifiers); + + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + let restoreModifiers: input.Modifier[] | undefined; + if (options && options.modifiers) + restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); + await action(point); + if (restoreModifiers) + await this._page.keyboard._ensureModifiers(restoreModifiers); + }); } hover(options?: PointerActionOptions & types.WaitForOptions): Promise { @@ -284,18 +289,22 @@ export class ElementHandle extends js.JSHandle { if (option.index !== undefined) assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"'); } - return this._evaluateInUtility((injected, node, ...optionsToSelect) => injected.selectOptions(node, optionsToSelect), ...options); + return await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + return this._evaluateInUtility((injected, node, ...optionsToSelect) => injected.selectOptions(node, optionsToSelect), ...options); + }); } async fill(value: string): Promise { assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); - const error = await this._evaluateInUtility((injected, node, value) => injected.fill(node, value), value); - if (error) - throw new Error(error); - if (value) - await this._page.keyboard.sendCharacters(value); - else - await this._page.keyboard.press('Delete'); + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + const error = await this._evaluateInUtility((injected, node, value) => injected.fill(node, value), value); + if (error) + throw new Error(error); + if (value) + await this._page.keyboard.sendCharacters(value); + else + await this._page.keyboard.press('Delete'); + }); } async setInputFiles(...files: (string | types.FilePayload)[]) { @@ -317,7 +326,9 @@ export class ElementHandle extends js.JSHandle { } return item; })); - await this._page._delegate.setInputFiles(this as any as ElementHandle, filePayloads); + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + await this._page._delegate.setInputFiles(this as any as ElementHandle, filePayloads); + }); } async focus() { @@ -332,13 +343,17 @@ export class ElementHandle extends js.JSHandle { } async type(text: string, options?: { delay?: number }) { - await this.focus(); - await this._page.keyboard.type(text, options); + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + await this.focus(); + await this._page.keyboard.type(text, options); + }); } async press(key: string, options?: { delay?: number, text?: string }) { - await this.focus(); - await this._page.keyboard.press(key, options); + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + await this.focus(); + await this._page.keyboard.press(key, options); + }); } async check(options?: types.WaitForOptions) { @@ -352,9 +367,11 @@ export class ElementHandle extends js.JSHandle { private async _setChecked(state: boolean, options?: types.WaitForOptions) { if (await this._evaluateInUtility((injected, node) => injected.isCheckboxChecked(node)) === state) return; - await this.click(options); - if (await this._evaluateInUtility((injected, node) => injected.isCheckboxChecked(node)) !== state) - throw new Error('Unable to click checkbox'); + await this._page._frameManager.waitForNavigationsCreatedBy(async () => { + await this.click(options); + if (await this._evaluateInUtility((injected, node) => injected.isCheckboxChecked(node)) !== state) + throw new Error('Unable to click checkbox'); + }); } async boundingBox(): Promise { diff --git a/src/frames.ts b/src/frames.ts index 99918b28123ae0..7eab95697026d4 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -59,6 +59,7 @@ export class FrameManager { private _mainFrame: Frame; readonly _lifecycleWatchers = new Set<() => void>(); readonly _consoleMessageTags = new Map(); + private _navigationRequestCollectors = new Set>(); constructor(page: Page) { this._page = page; @@ -107,7 +108,33 @@ export class FrameManager { } } + async waitForNavigationsCreatedBy(action: () => Promise): Promise { + const frameIds = new Set(); + this._navigationRequestCollectors.add(frameIds); + try { + const result = await action(); + if (!frameIds.size) + return result; + const frames = Array.from(frameIds.values()).map(frameId => this._frames.get(frameId)); + await Promise.all(frames.map(frame => frame!.waitForNavigation({ waitUntil: []}))).catch(e => {}); + return result; + } finally { + this._navigationRequestCollectors.delete(frameIds); + } + } + + frameRequestedNavigation(frameId: string) { + for (const frameIds of this._navigationRequestCollectors) + frameIds.add(frameId); + } + + _cancelFrameRequestedNavigation(frameId: string) { + for (const frameIds of this._navigationRequestCollectors) + frameIds.delete(frameId); + } + frameCommittedNewDocumentNavigation(frameId: string, url: string, name: string, documentId: string, initial: boolean) { + this._cancelFrameRequestedNavigation(frameId); const frame = this._frames.get(frameId)!; for (const child of frame.childFrames()) this._removeFramesRecursively(child); @@ -122,9 +149,11 @@ export class FrameManager { } frameCommittedSameDocumentNavigation(frameId: string, url: string) { + this._cancelFrameRequestedNavigation(frameId); const frame = this._frames.get(frameId); if (!frame) return; + this._cancelFrameRequestedNavigation(frameId); frame._url = url; for (const watcher of frame._sameDocumentNavigationWatchers) watcher(); @@ -138,6 +167,7 @@ export class FrameManager { } frameStoppedLoading(frameId: string) { + this._cancelFrameRequestedNavigation(frameId); const frame = this._frames.get(frameId); if (!frame) return; @@ -223,6 +253,7 @@ export class FrameManager { } private _removeFramesRecursively(frame: Frame) { + this._cancelFrameRequestedNavigation(frame._id); for (const child of frame.childFrames()) this._removeFramesRecursively(child); frame._onDetached(); diff --git a/test/navigation.spec.js b/test/navigation.spec.js index 50a67b8255d94f..f786a0c0a62dd1 100644 --- a/test/navigation.spec.js +++ b/test/navigation.spec.js @@ -800,6 +800,74 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF }); }); + describe.fail(true)('Page.automaticWaiting', () => { + it.fail(FFOX || WEBKIT)('clicking anchor should await navigation', async({page, server}) => { + const messages = []; + server.setRoute('/empty.html', async (req, res) => { + messages.push('route'); + res.end('done'); + }); + + await page.setContent(`empty.html`); + + await Promise.all([ + page.click('a').then(() => messages.push('click')), + page.waitForNavigation({ waitUntil: [] }).then(() => messages.push('waitForNavigation')) + ]); + expect(messages.join('|')).toBe('route|waitForNavigation|click'); + }); + it.fail(FFOX || WEBKIT)('clicking anchor should await cross-process navigation', async({page, server}) => { + const messages = []; + server.setRoute('/empty.html', async (req, res) => { + messages.push('route'); + res.end('done'); + }); + + await page.setContent(`empty.html`); + + await Promise.all([ + page.click('a').then(() => messages.push('click')), + page.waitForNavigation({ waitUntil: [] }).then(() => messages.push('waitForNavigation')) + ]); + expect(messages.join('|')).toBe('route|waitForNavigation|click'); + }); + it.fail(true)('should submit form that causes navigation', async({page, server}) => { + const messages = []; + server.setRoute('/empty.html', async (req, res) => { + messages.push('route'); + res.end('done'); + }); + + await page.setContent(` +
+ + +
`); + + await Promise.all([ + page.click('input[type=submit]').then(() => messages.push('click')), + page.waitForNavigation({ waitUntil: [] }).then(() => messages.push('waitForNavigation')) + ]); + expect(messages.join('|')).toBe('route|waitForNavigation|click'); + }); + it('should not throw when clicking on links which do not commit navigation', async({page, server, httpsServer}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(`foobar`); + await page.click('a'); + }); + it('should not throw when clicking on download link', async({page, server, httpsServer}) => { + await page.setContent(`table2.wasm`); + await page.click('a'); + }); + it.fail(true)('should not hang on window.stop', async({page, server, httpsServer}) => { + server.setRoute('/empty.html', async (req, res) => {}); + await page.setContent(`example.html`); + const clickIt = page.click('a'); + await page.evaluate('window.stop()'); + await clickIt; + }); + }); + describe('Page.waitForLoadState', () => { it('should pick up ongoing navigation', async({page, server}) => { let response = null; @@ -950,13 +1018,13 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF server.setRoute('/empty.html', () => {}); let error = null; - const navigationPromise = frame.waitForNavigation().catch(e => error = e); await Promise.all([ - server.waitForRequest('/empty.html'), - frame.evaluate(() => window.location = '/empty.html') - ]); - await page.$eval('iframe', frame => frame.remove()); - await navigationPromise; + frame.waitForNavigation().catch(e => error = e), + server.waitForRequest('/empty.html').then(() => { + page.$eval('iframe', frame => frame.remove()); + }), + frame.evaluate(() => window.location = '/empty.html'), + ]).catch(e => error = e); expect(error.message).toContain('frame was detached'); }); });