Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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.urlRegExOrPredicate
* since: v1.18
- `urlOrRegExp` <[string]|[RegExp]>
- `urlRegExOrPredicate` <[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. The predicate parameter ignores this flag.

### option: PageAssertions.toHaveURL.timeout = %%-js-assertions-timeout-%%
* since: v1.18
Expand Down
104 changes: 104 additions & 0 deletions packages/playwright/src/matchers/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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 { ExpectMatcherState } from '../../types/test';
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import { colors } from 'playwright-core/lib/utilsBundle';
import type { Locator } from 'playwright-core';
import { EXPECTED_COLOR, printReceived } from '../common/expectBundle';
import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from './expect';
import { callLogText } from '../util';

export function toMatchExpectedStringOrPredicateVerification(
state: ExpectMatcherState,
matcherName: string,
receiver: Locator | undefined,
expression: string | Locator | undefined,
expected: string | RegExp | Function,
supportsPredicate: boolean = false
): void {
const matcherOptions = {
isNot: state.isNot,
promise: state.promise,
};

if (
!(typeof expected === 'string') &&
!(expected && 'test' in expected && typeof expected.test === 'function') &&
!(supportsPredicate && typeof expected === 'function')
) {
// Same format as jest's matcherErrorMessage
const message = supportsPredicate ? 'string, regular expression, or predicate' : 'string or regular expression';

throw new Error([
// Always display `expected` in expectation place
matcherHint(state, receiver, matcherName, expression, undefined, matcherOptions),
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a ${message}`,
state.utils.printWithType('Expected', expected, state.utils.printExpected)
].join('\n\n'));
}
}

export function textMatcherMessage(state: ExpectMatcherState, matcherName: string, receiver: Locator | undefined, expression: string, expected: string | RegExp | Function, received: string | undefined, callLog: string[] | undefined, stringName: string, pass: boolean, didTimeout: boolean, timeout: number): string {
const matcherOptions = {
isNot: state.isNot,
promise: state.promise,
};
const receivedString = received || '';
const messagePrefix = matcherHint(state, receiver, matcherName, expression, undefined, matcherOptions, didTimeout ? timeout : undefined);
const notFound = received === kNoElementsFoundError;

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') {
if (notFound) {
printedExpected = `Expected ${stringName}: not ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected ${stringName}: not ${state.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
printedReceived = `Received string: ${formattedReceived}`;
}
} else {
if (notFound) {
printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} 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' ? stringName : 'pattern'}`;
if (notFound) {
printedExpected = `${labelExpected}: ${state.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedDiff = state.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false);
}
}
}

const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived;
return messagePrefix + resultDetails + callLogText(callLog);
}
2 changes: 1 addition & 1 deletion packages/playwright/src/matchers/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
toHaveURL,
toHaveValue,
toHaveValues,
toPass
toPass,
} from './matchers';
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
import type { Expect, ExpectMatcherState } from '../../types/test';
Expand Down
89 changes: 78 additions & 11 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, urlMatches } 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 { textMatcherMessage, toMatchExpectedStringOrPredicateVerification } from './error';

export interface LocatorEx extends Locator {
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
Expand Down Expand Up @@ -386,19 +387,85 @@ export function toHaveTitle(
}, expected, options);
}

export function toHaveURL(
export async 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);
const matcherName = 'toHaveURL';
const expression = 'page';
toMatchExpectedStringOrPredicateVerification(
this,
matcherName,
undefined,
expression,
expected,
true,
);

const timeout = options?.timeout ?? this.timeout;
let conditionSucceeded = false;
let lastCheckedURLString: string | undefined = undefined;
try {
await page.mainFrame().waitForURL(
url => {
const baseURL: string | undefined = (page.context() as any)._options
.baseURL;
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 { pass: !this.isNot, message: () => '' };

return {
pass: this.isNot,
message: () =>
textMatcherMessage(
this,
matcherName,
undefined,
expression,
expected,
lastCheckedURLString,
undefined,
'string',
this.isNot,
true,
timeout,
),
actual: lastCheckedURLString,
timeout,
};
}

export async function toBeOK(
Expand Down
84 changes: 17 additions & 67 deletions packages/playwright/src/matchers/toMatchText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,11 @@
*/


import { expectTypes, callLogText } from '../util';
import {
printReceivedStringContainExpectedResult,
printReceivedStringContainExpectedSubstring
} from './expect';
import { EXPECTED_COLOR } from '../common/expectBundle';
import { expectTypes } from '../util';
import type { ExpectMatcherState } from '../../types/test';
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import type { MatcherResult } from './matcherHint';
import type { Locator } from 'playwright-core';
import { colors } from 'playwright-core/lib/utilsBundle';
import { textMatcherMessage, toMatchExpectedStringOrPredicateVerification } from './error';

export async function toMatchText(
this: ExpectMatcherState,
Expand All @@ -37,23 +31,7 @@ export async function toMatchText(
options: { timeout?: number, matchSubstring?: boolean } = {},
): Promise<MatcherResult<string | RegExp, string>> {
expectTypes(receiver, [receiverType], matcherName);

const matcherOptions = {
isNot: this.isNot,
promise: this.promise,
};

if (
!(typeof expected === 'string') &&
!(expected && typeof expected.test === 'function')
) {
// Same format as jest's matcherErrorMessage
throw new Error([
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions),
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string or regular expression`,
this.utils.printWithType('Expected', expected, this.utils.printExpected)
].join('\n\n'));
}
toMatchExpectedStringOrPredicateVerification(this, matcherName, receiver, receiver, expected);

const timeout = options.timeout ?? this.timeout;

Expand All @@ -68,52 +46,24 @@ export async function toMatchText(
}

const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const receivedString = received || '';
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;

let printedReceived: string | undefined;
let printedExpected: string | undefined;
let printedDiff: string | undefined;
if (pass) {
if (typeof expected === 'string') {
if (notFound) {
printedExpected = `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}`;
const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
printedReceived = `Received string: ${formattedReceived}`;
}
} else {
if (notFound) {
printedExpected = `Expected pattern: not ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedExpected = `Expected pattern: not ${this.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' ? stringSubstring : 'pattern'}`;
if (notFound) {
printedExpected = `${labelExpected}: ${this.utils.printExpected(expected)}`;
printedReceived = `Received: ${received}`;
} else {
printedDiff = this.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false);
}
}

const message = () => {
const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived;
return messagePrefix + resultDetails + callLogText(log);
};

return {
name: matcherName,
expected,
message,
message: () =>
textMatcherMessage(
this,
matcherName,
receiver,
'locator',
expected,
received,
log,
stringSubstring,
pass,
!!timedOut,
timeout,
),
pass,
actual: received,
log,
Expand Down
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 urlRegExOrPredicate 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(urlRegExOrPredicate: 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. The predicate parameter
* ignores this flag.
*/
ignoreCase?: boolean;

Expand Down
Loading
Loading