Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/src/api/class-pageassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Side note, ports are now on their own with this)

} else if (expression === 'to.have.value') {
element = this.retarget(element, 'follow-label')!;
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
Expand Down
15 changes: 5 additions & 10 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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(
Expand Down
153 changes: 153 additions & 0 deletions packages/playwright/src/matchers/toHaveURL.ts
Original file line number Diff line number Diff line change
@@ -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<MatcherResult<string | RegExp, string>> {
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;
}
10 changes: 7 additions & 3 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
42 changes: 39 additions & 3 deletions tests/page/expect-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { stripVTControlCharacters } from 'node:util';
import { stripAnsi } from '../config/utils';
import { test, expect } from './pageTest';

Expand Down Expand Up @@ -240,10 +241,45 @@ test.describe('toHaveURL', () => {
await expect(page).toHaveURL('data:text/html,<div>A</div>');
});

test('fail', async ({ page }) => {
await page.goto('data:text/html,<div>B</div>');
test('fail string', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
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,<div>A</div>"');
});

test('fail with invalid argument', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
// @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,<div>A</div>');
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,<div>A</div>"');
});

test('fail with negative predicate', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
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,<div>A</div>"');
});

test('resolve predicate on initial call', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
await expect(page).toHaveURL(url => url.href === 'data:text/html,<div>A</div>', { timeout: 1000 });
});

test('resolve predicate after retries', async ({ page }) => {
await page.goto('data:text/html,<div>A</div>');
const expectPromise = expect(page).toHaveURL(url => url.href === 'data:text/html,<div>B</div>', { timeout: 1000 });
setTimeout(() => page.goto('data:text/html,<div>B</div>'), 500);
await expectPromise;
});

test('support ignoreCase', async ({ page }) => {
Expand Down
22 changes: 21 additions & 1 deletion tests/playwright-test/expect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<div>A</div>');
const error = await expect(page).toHaveURL('data:text/html,<div>B</div>').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,<div>');
});
`,
}, { 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,<div>A</div>');
const error = await expect(page).toHaveURL('data:text/html,<div>B</div>').catch(e => e);
expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)');
expect(error.message).toContain('data:text/html,<div>');
});
`,
Expand Down
Loading