diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 7645b8b362..72534c7934 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1395,3 +1395,31 @@ be flaky. Use signals such as network events, selectors becoming visible and oth - `timeout` <[float]> A timeout to wait for + +## async method: Frame.waitForURL + +Waits for the frame to navigate to the given URL. + +```js +await frame.click('a.delayed-navigation'); // Clicking the link will indirectly cause a navigation +await frame.waitForURL('**/target.html'); +``` + +```java +frame.click("a.delayed-navigation"); // Clicking the link will indirectly cause a navigation +frame.waitForURL("**/target.html"); +``` + +```python async +await frame.click("a.delayed-navigation") # clicking the link will indirectly cause a navigation +await frame.wait_for_url("**/target.html") +``` + +```python sync +frame.click("a.delayed-navigation") # clicking the link will indirectly cause a navigation +frame.wait_for_url("**/target.html") +``` + +### param: Frame.waitForURL.url = %%-wait-for-navigation-url-%% +### option: Frame.waitForURL.timeout = %%-navigation-timeout-%% +### option: Frame.waitForURL.waitUntil = %%-navigation-wait-until-%% diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 6c0c8d1d0e..83a6ca28f3 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2274,6 +2274,7 @@ This setting will change the default maximum navigation time for the following m * [`method: Page.reload`] * [`method: Page.setContent`] * [`method: Page.waitForNavigation`] +* [`method: Page.waitForURL`] :::note [`method: Page.setDefaultNavigationTimeout`] takes priority over [`method: Page.setDefaultTimeout`], @@ -3109,6 +3110,36 @@ Shortcut for main frame's [`method: Frame.waitForTimeout`]. A timeout to wait for +## async method: Page.waitForURL + +Waits for the main frame to navigate to the given URL. + +```js +await page.click('a.delayed-navigation'); // Clicking the link will indirectly cause a navigation +await page.waitForURL('**/target.html'); +``` + +```java +page.click("a.delayed-navigation"); // Clicking the link will indirectly cause a navigation +page.waitForURL("**/target.html"); +``` + +```python async +await page.click("a.delayed-navigation") # clicking the link will indirectly cause a navigation +await page.wait_for_url("**/target.html") +``` + +```python sync +page.click("a.delayed-navigation") # clicking the link will indirectly cause a navigation +page.wait_for_url("**/target.html") +``` + +Shortcut for main frame's [`method: Frame.waitForURL`]. + +### param: Page.waitForURL.url = %%-wait-for-navigation-url-%% +### option: Page.waitForURL.timeout = %%-navigation-timeout-%% +### option: Page.waitForURL.waitUntil = %%-navigation-wait-until-%% + ## async method: Page.waitForWebSocket * langs: csharp, java - returns: <[WebSocket]> diff --git a/src/client/frame.ts b/src/client/frame.ts index 9ca8275a1b..dec8c1140e 100644 --- a/src/client/frame.ts +++ b/src/client/frame.ts @@ -159,6 +159,12 @@ export class Frame extends ChannelOwner { + if (urlMatches(this.url(), url)) + return await this.waitForLoadState(options?.waitUntil, options); + await this.waitForNavigation({ url, ...options }); + } + async frameElement(): Promise { return this._wrapApiCall(this._apiName('frameElement'), async (channel: channels.FrameChannel) => { return ElementHandle.from((await channel.frameElement()).element); diff --git a/src/client/page.ts b/src/client/page.ts index 4cb2915e14..302bec6882 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -362,6 +362,10 @@ export class Page extends ChannelOwner this._mainFrame.waitForNavigation(options)); } + async waitForURL(url: URLMatch, options?: { waitUntil?: LifecycleEvent, timeout?: number }): Promise { + return this._attributeToPage(() => this._mainFrame.waitForURL(url, options)); + } + async waitForRequest(urlOrPredicate: string | RegExp | ((r: Request) => boolean), options: { timeout?: number } = {}): Promise { return this._wrapApiCall('page.waitForRequest', async (channel: channels.PageChannel) => { const predicate = (request: Request) => { diff --git a/test/page/page-wait-for-url.spec.ts b/test/page/page-wait-for-url.spec.ts new file mode 100644 index 0000000000..9894461e9e --- /dev/null +++ b/test/page/page-wait-for-url.spec.ts @@ -0,0 +1,133 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { it, expect } from '../fixtures'; + +it('should work', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(url => window.location.href = url, server.PREFIX + '/grid.html'); + await page.waitForURL('**/grid.html'); +}); + +it('should respect timeout', async ({page, server}) => { + const promise = page.waitForURL('**/frame.html', { timeout: 2500 }); + await page.goto(server.EMPTY_PAGE); + const error = await promise.catch(e => e); + expect(error.message).toContain('page.waitForNavigation: Timeout 2500ms exceeded.'); +}); + +it('should work with both domcontentloaded and load', async ({page, server}) => { + let response = null; + server.setRoute('/one-style.css', (req, res) => response = res); + const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); + const domContentLoadedPromise = page.waitForURL('**/one-style.html', { waitUntil: 'domcontentloaded' }); + let bothFired = false; + const bothFiredPromise = Promise.all([ + page.waitForURL('**/one-style.html', { waitUntil: 'load' }), + domContentLoadedPromise + ]).then(() => bothFired = true); + + await server.waitForRequest('/one-style.css'); + await domContentLoadedPromise; + expect(bothFired).toBe(false); + response.end(); + await bothFiredPromise; + await navigationPromise; +}); + +it('should work with clicking on anchor links', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(`foobar`); + await page.click('a'); + await page.waitForURL('**/*#foobar'); +}); + +it('should work with history.pushState()', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + SPA + + `); + await page.click('a'); + await page.waitForURL('**/wow.html'); + expect(page.url()).toBe(server.PREFIX + '/wow.html'); +}); + +it('should work with history.replaceState()', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + SPA + + `); + await page.click('a'); + await page.waitForURL('**/replaced.html'); + expect(page.url()).toBe(server.PREFIX + '/replaced.html'); +}); + +it('should work with DOM history.back()/history.forward()', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + back + forward + + `); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + + await page.click('a#back'); + await page.waitForURL('**/first.html'); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + + await page.click('a#forward'); + await page.waitForURL('**/second.html'); + expect(page.url()).toBe(server.PREFIX + '/second.html'); +}); + +it('should work with url match for same document navigations', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + let resolved = false; + const waitPromise = page.waitForURL(/third\.html/).then(() => resolved = true); + expect(resolved).toBe(false); + await page.evaluate(() => { + history.pushState({}, '', '/first.html'); + }); + expect(resolved).toBe(false); + await page.evaluate(() => { + history.pushState({}, '', '/second.html'); + }); + expect(resolved).toBe(false); + await page.evaluate(() => { + history.pushState({}, '', '/third.html'); + }); + await waitPromise; + expect(resolved).toBe(true); +}); + +it('should work on frame', async ({page, server}) => { + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]; + await frame.evaluate(url => window.location.href = url, server.PREFIX + '/grid.html'); + await frame.waitForURL('**/grid.html'); +}); diff --git a/types/types.d.ts b/types/types.d.ts index 5cfa4e79d7..db8af04356 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -2531,6 +2531,7 @@ export interface Page { * - [page.reload([options])](https://playwright.dev/docs/api/class-page#pagereloadoptions) * - [page.setContent(html[, options])](https://playwright.dev/docs/api/class-page#pagesetcontenthtml-options) * - [page.waitForNavigation([options])](https://playwright.dev/docs/api/class-page#pagewaitfornavigationoptions) + * - [page.waitForURL(url[, options])](https://playwright.dev/docs/api/class-page#pagewaitforurlurl-options) * * > NOTE: * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#pagesetdefaultnavigationtimeouttimeout) @@ -3174,6 +3175,39 @@ export interface Page { */ waitForTimeout(timeout: number): Promise; + /** + * Waits for the main frame to navigate to the given URL. + * + * ```js + * await page.click('a.delayed-navigation'); // Clicking the link will indirectly cause a navigation + * await page.waitForURL('**\/target.html'); + * ``` + * + * Shortcut for main frame's + * [frame.waitForURL(url[, options])](https://playwright.dev/docs/api/class-frame#framewaitforurlurl-options). + * @param url A glob pattern, regex pattern or predicate receiving [URL] to match while waiting for the navigation. + * @param options + */ + waitForURL(url: string|RegExp|((url: URL) => boolean), options?: { + /** + * Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be + * changed by using the + * [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browsercontextsetdefaultnavigationtimeouttimeout), + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browsercontextsetdefaulttimeouttimeout), + * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#pagesetdefaultnavigationtimeouttimeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#pagesetdefaulttimeouttimeout) methods. + */ + timeout?: number; + + /** + * When to consider operation succeeded, defaults to `load`. Events can be either: + * - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired. + * - `'load'` - consider operation to be finished when the `load` event is fired. + * - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms. + */ + waitUntil?: "load"|"domcontentloaded"|"networkidle"; + }): Promise; + /** * This method returns all of the dedicated [WebWorkers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) * associated with the page. @@ -4476,7 +4510,38 @@ export interface Frame { * be flaky. Use signals such as network events, selectors becoming visible and others instead. * @param timeout A timeout to wait for */ - waitForTimeout(timeout: number): Promise;} + waitForTimeout(timeout: number): Promise; + + /** + * Waits for the frame to navigate to the given URL. + * + * ```js + * await frame.click('a.delayed-navigation'); // Clicking the link will indirectly cause a navigation + * await frame.waitForURL('**\/target.html'); + * ``` + * + * @param url A glob pattern, regex pattern or predicate receiving [URL] to match while waiting for the navigation. + * @param options + */ + waitForURL(url: string|RegExp|((url: URL) => boolean), options?: { + /** + * Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be + * changed by using the + * [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browsercontextsetdefaultnavigationtimeouttimeout), + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browsercontextsetdefaulttimeouttimeout), + * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#pagesetdefaultnavigationtimeouttimeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#pagesetdefaulttimeouttimeout) methods. + */ + timeout?: number; + + /** + * When to consider operation succeeded, defaults to `load`. Events can be either: + * - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired. + * - `'load'` - consider operation to be finished when the `load` event is fired. + * - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms. + */ + waitUntil?: "load"|"domcontentloaded"|"networkidle"; + }): Promise;} /** * - extends: [EventEmitter]