diff --git a/docs/src/api/class-pageassertions.md b/docs/src/api/class-pageassertions.md index 8eefd41f023aa..38a05b9df8c5f 100644 --- a/docs/src/api/class-pageassertions.md +++ b/docs/src/api/class-pageassertions.md @@ -323,17 +323,18 @@ expect(page).to_have_url(re.compile(".*checkout")) await Expect(Page).ToHaveURLAsync(new Regex(".*checkout")); ``` -### param: PageAssertions.toHaveURL.urlOrRegExp +### param: PageAssertions.toHaveURL.url * since: v1.18 -- `urlOrRegExp` <[string]|[RegExp]> +- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> -Expected URL string or RegExp. +Expected URL string, RegExp, or predicate receiving [URL] to match. +When a [`option: Browser.newContext.baseURL`] via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. ### option: PageAssertions.toHaveURL.ignoreCase * since: v1.44 - `ignoreCase` <[boolean]> -Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified. +Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression parameter if specified. A provided predicate ignores this flag. ### option: PageAssertions.toHaveURL.timeout = %%-js-assertions-timeout-%% * since: v1.18 diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 4ffb8ba2d86c2..208a2e8cad1de 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1404,8 +1404,6 @@ export class InjectedScript { received = getAriaRole(element) || ''; } else if (expression === 'to.have.title') { received = this.document.title; - } else if (expression === 'to.have.url') { - received = this.document.location.href; } else if (expression === 'to.have.value') { element = this.retarget(element, 'follow-label')!; if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT') diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index c942fef2461d8..92b074dda3989 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -21,11 +21,12 @@ import { expectTypes, callLogText } from '../util'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toMatchText } from './toMatchText'; -import { constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils'; +import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils'; import { currentTestInfo } from '../common/globals'; import { TestInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherState } from '../../types/test'; import { takeFirst } from '../common/config'; +import { toHaveURL as toHaveURLExternal } from './toHaveURL'; export interface LocatorEx extends Locator { _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; @@ -389,16 +390,10 @@ export function toHaveTitle( export function toHaveURL( this: ExpectMatcherState, page: Page, - expected: string | RegExp, - options?: { ignoreCase?: boolean, timeout?: number }, + expected: string | RegExp | ((url: URL) => boolean), + options?: { ignoreCase?: boolean; timeout?: number }, ) { - const baseURL = (page.context() as any)._options.baseURL; - expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; - const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => { - const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase }); - return await locator._expect('to.have.url', { expectedText, isNot, timeout }); - }, expected, options); + return toHaveURLExternal.call(this, page, expected, options); } export async function toBeOK( diff --git a/packages/playwright/src/matchers/toHaveURL.ts b/packages/playwright/src/matchers/toHaveURL.ts new file mode 100644 index 0000000000000..df09833f84dab --- /dev/null +++ b/packages/playwright/src/matchers/toHaveURL.ts @@ -0,0 +1,153 @@ +/** + * 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 type { Page } from 'playwright-core'; +import type { ExpectMatcherState } from '../../types/test'; +import { EXPECTED_COLOR, printReceived } from '../common/expectBundle'; +import { matcherHint, type MatcherResult } from './matcherHint'; +import { constructURLBasedOnBaseURL, urlMatches } from 'playwright-core/lib/utils'; +import { colors } from 'playwright-core/lib/utilsBundle'; +import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from './expect'; + +export async function toHaveURL( + this: ExpectMatcherState, + page: Page, + expected: string | RegExp | ((url: URL) => boolean), + options?: { ignoreCase?: boolean; timeout?: number }, +): Promise> { + const matcherName = 'toHaveURL'; + const expression = 'page'; + const matcherOptions = { + isNot: this.isNot, + promise: this.promise, + }; + + if ( + !(typeof expected === 'string') && + !(expected && 'test' in expected && typeof expected.test === 'function') && + !(typeof expected === 'function') + ) { + throw new Error( + [ + // Always display `expected` in expectation place + matcherHint(this, undefined, matcherName, expression, undefined, matcherOptions), + `${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected')} value must be a string, regular expression, or predicate`, + this.utils.printWithType('Expected', expected, this.utils.printExpected,), + ].join('\n\n'), + ); + } + + const timeout = options?.timeout ?? this.timeout; + const baseURL: string | undefined = (page.context() as any)._options.baseURL; + let conditionSucceeded = false; + let lastCheckedURLString: string | undefined = undefined; + try { + await page.mainFrame().waitForURL( + url => { + lastCheckedURLString = url.toString(); + + if (options?.ignoreCase) { + return ( + !this.isNot === + urlMatches( + baseURL?.toLocaleLowerCase(), + lastCheckedURLString.toLocaleLowerCase(), + typeof expected === 'string' + ? expected.toLocaleLowerCase() + : expected, + ) + ); + } + + return ( + !this.isNot === urlMatches(baseURL, lastCheckedURLString, expected) + ); + }, + { timeout }, + ); + + conditionSucceeded = true; + } catch (e) { + conditionSucceeded = false; + } + + if (conditionSucceeded) + return { name: matcherName, pass: !this.isNot, message: () => '' }; + + return { + name: matcherName, + pass: this.isNot, + message: () => + toHaveURLMessage( + this, + matcherName, + expression, + typeof expected === 'string' + ? constructURLBasedOnBaseURL(baseURL, expected) + : expected, + lastCheckedURLString, + this.isNot, + true, + timeout, + ), + actual: lastCheckedURLString, + timeout, + }; +} + +function toHaveURLMessage( + state: ExpectMatcherState, + matcherName: string, + expression: string, + expected: string | RegExp | Function, + received: string | undefined, + pass: boolean, + didTimeout: boolean, + timeout: number, +): string { + const matcherOptions = { + isNot: state.isNot, + promise: state.promise, + }; + const receivedString = received || ''; + const messagePrefix = matcherHint(state, undefined, matcherName, expression, undefined, matcherOptions, didTimeout ? timeout : undefined); + + let printedReceived: string | undefined; + let printedExpected: string | undefined; + let printedDiff: string | undefined; + if (typeof expected === 'function') { + printedExpected = `Expected predicate to ${!state.isNot ? 'succeed' : 'fail'}`; + printedReceived = `Received string: ${printReceived(receivedString)}`; + } else { + if (pass) { + if (typeof expected === 'string') { + printedExpected = `Expected string: not ${state.utils.printExpected(expected)}`; + const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length); + printedReceived = `Received string: ${formattedReceived}`; + } else { + printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`; + const formattedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null); + printedReceived = `Received string: ${formattedReceived}`; + } + } else { + const labelExpected = `Expected ${typeof expected === 'string' ? 'string' : 'pattern'}`; + printedDiff = state.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false); + } + } + + const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived; + return messagePrefix + resultDetails; +} diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 83306e932b79a..29f3d85e754b5 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8800,14 +8800,18 @@ interface PageAssertions { * await expect(page).toHaveURL(/.*checkout/); * ``` * - * @param urlOrRegExp Expected URL string or RegExp. + * @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When a + * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context + * options was provided and the passed URL is a path, it gets merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. * @param options */ - toHaveURL(urlOrRegExp: string|RegExp, options?: { + toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: { /** * Whether to perform case-insensitive match. * [`ignoreCase`](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url-option-ignore-case) - * option takes precedence over the corresponding regular expression flag if specified. + * option takes precedence over the corresponding regular expression parameter if specified. A provided predicate + * ignores this flag. */ ignoreCase?: boolean; diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index a1fb6637b13df..3c51975920125 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { stripVTControlCharacters } from 'node:util'; import { stripAnsi } from '../config/utils'; import { test, expect } from './pageTest'; @@ -240,10 +241,45 @@ test.describe('toHaveURL', () => { await expect(page).toHaveURL('data:text/html,
A
'); }); - test('fail', async ({ page }) => { - await page.goto('data:text/html,
B
'); + test('fail string', async ({ page }) => { + await page.goto('data:text/html,
A
'); const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e); - expect(error.message).toContain('expect.toHaveURL with timeout 1000ms'); + expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); + expect(stripVTControlCharacters(error.message)).toContain('Expected string: "wrong"\nReceived string: "data:text/html,
A
"'); + }); + + test('fail with invalid argument', async ({ page }) => { + await page.goto('data:text/html,
A
'); + // @ts-expect-error + const error = await expect(page).toHaveURL({}).catch(e => e); + expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)\n\n\n\nMatcher error: expected value must be a string, regular expression, or predicate'); + expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}'); + }); + + test('fail with positive predicate', async ({ page }) => { + await page.goto('data:text/html,
A
'); + const error = await expect(page).toHaveURL(_url => false).catch(e => e); + expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)'); + expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to succeed\nReceived string: "data:text/html,
A
"'); + }); + + test('fail with negative predicate', async ({ page }) => { + await page.goto('data:text/html,
A
'); + const error = await expect(page).not.toHaveURL(_url => true).catch(e => e); + expect(stripVTControlCharacters(error.message)).toContain('expect(page).not.toHaveURL(expected)'); + expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to fail\nReceived string: "data:text/html,
A
"'); + }); + + test('resolve predicate on initial call', async ({ page }) => { + await page.goto('data:text/html,
A
'); + await expect(page).toHaveURL(url => url.href === 'data:text/html,
A
', { timeout: 1000 }); + }); + + test('resolve predicate after retries', async ({ page }) => { + await page.goto('data:text/html,
A
'); + const expectPromise = expect(page).toHaveURL(url => url.href === 'data:text/html,
B
', { timeout: 1000 }); + setTimeout(() => page.goto('data:text/html,
B
'), 500); + await expectPromise; }); test('support ignoreCase', async ({ page }) => { diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index c35cdd7c09f1a..9e47f5282f27b 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -543,11 +543,31 @@ test('should respect expect.timeout', async ({ runInlineTest }) => { 'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`, 'a.test.ts': ` import { test, expect } from '@playwright/test'; + import { stripVTControlCharacters } from 'node:util'; test('timeout', async ({ page }) => { await page.goto('data:text/html,
A
'); const error = await expect(page).toHaveURL('data:text/html,
B
').catch(e => e); - expect(error.message).toContain('expect.toHaveURL with timeout 1000ms'); + expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); + expect(error.message).toContain('data:text/html,
'); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should support toHaveURL predicate', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { stripVTControlCharacters } from 'node:util'; + + test('predicate', async ({ page }) => { + await page.goto('data:text/html,
A
'); + const error = await expect(page).toHaveURL('data:text/html,
B
').catch(e => e); + expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); expect(error.message).toContain('data:text/html,
'); }); `,