Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: toHaveURL predicate matcher #34413

Merged
merged 16 commits into from
Jan 24, 2025
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
agg23 marked this conversation as resolved.
Show resolved Hide resolved
* 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 @@
/**
agg23 marked this conversation as resolved.
Show resolved Hide resolved
* 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 {
agg23 marked this conversation as resolved.
Show resolved Hide resolved
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,
agg23 marked this conversation as resolved.
Show resolved Hide resolved
} 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