mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat: toHaveURL predicate matcher (#34413)
This commit is contained in:
parent
f11768436a
commit
f65dc0cee4
@ -323,17 +323,18 @@ expect(page).to_have_url(re.compile(".*checkout"))
|
|||||||
await Expect(Page).ToHaveURLAsync(new Regex(".*checkout"));
|
await Expect(Page).ToHaveURLAsync(new Regex(".*checkout"));
|
||||||
```
|
```
|
||||||
|
|
||||||
### param: PageAssertions.toHaveURL.urlOrRegExp
|
### param: PageAssertions.toHaveURL.url
|
||||||
* since: v1.18
|
* 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
|
### option: PageAssertions.toHaveURL.ignoreCase
|
||||||
* since: v1.44
|
* since: v1.44
|
||||||
- `ignoreCase` <[boolean]>
|
- `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-%%
|
### option: PageAssertions.toHaveURL.timeout = %%-js-assertions-timeout-%%
|
||||||
* since: v1.18
|
* since: v1.18
|
||||||
|
|||||||
@ -1404,8 +1404,6 @@ export class InjectedScript {
|
|||||||
received = getAriaRole(element) || '';
|
received = getAriaRole(element) || '';
|
||||||
} else if (expression === 'to.have.title') {
|
} else if (expression === 'to.have.title') {
|
||||||
received = this.document.title;
|
received = this.document.title;
|
||||||
} else if (expression === 'to.have.url') {
|
|
||||||
received = this.document.location.href;
|
|
||||||
} else if (expression === 'to.have.value') {
|
} else if (expression === 'to.have.value') {
|
||||||
element = this.retarget(element, 'follow-label')!;
|
element = this.retarget(element, 'follow-label')!;
|
||||||
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
|
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
|
||||||
|
|||||||
@ -21,11 +21,12 @@ import { expectTypes, callLogText } from '../util';
|
|||||||
import { toBeTruthy } from './toBeTruthy';
|
import { toBeTruthy } from './toBeTruthy';
|
||||||
import { toEqual } from './toEqual';
|
import { toEqual } from './toEqual';
|
||||||
import { toMatchText } from './toMatchText';
|
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 { currentTestInfo } from '../common/globals';
|
||||||
import { TestInfoImpl } from '../worker/testInfo';
|
import { TestInfoImpl } from '../worker/testInfo';
|
||||||
import type { ExpectMatcherState } from '../../types/test';
|
import type { ExpectMatcherState } from '../../types/test';
|
||||||
import { takeFirst } from '../common/config';
|
import { takeFirst } from '../common/config';
|
||||||
|
import { toHaveURL as toHaveURLExternal } from './toHaveURL';
|
||||||
|
|
||||||
export interface LocatorEx extends Locator {
|
export interface LocatorEx extends Locator {
|
||||||
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
|
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
|
||||||
@ -382,16 +383,10 @@ export function toHaveTitle(
|
|||||||
export function toHaveURL(
|
export function toHaveURL(
|
||||||
this: ExpectMatcherState,
|
this: ExpectMatcherState,
|
||||||
page: Page,
|
page: Page,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp | ((url: URL) => boolean),
|
||||||
options?: { ignoreCase?: boolean, timeout?: number },
|
options?: { ignoreCase?: boolean; timeout?: number },
|
||||||
) {
|
) {
|
||||||
const baseURL = (page.context() as any)._options.baseURL;
|
return toHaveURLExternal.call(this, page, expected, options);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toBeOK(
|
export async function toBeOK(
|
||||||
|
|||||||
153
packages/playwright/src/matchers/toHaveURL.ts
Normal file
153
packages/playwright/src/matchers/toHaveURL.ts
Normal file
@ -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
packages/playwright/types/test.d.ts
vendored
10
packages/playwright/types/test.d.ts
vendored
@ -8795,14 +8795,18 @@ interface PageAssertions {
|
|||||||
* await expect(page).toHaveURL(/.*checkout/);
|
* 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
|
* @param options
|
||||||
*/
|
*/
|
||||||
toHaveURL(urlOrRegExp: string|RegExp, options?: {
|
toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: {
|
||||||
/**
|
/**
|
||||||
* Whether to perform case-insensitive match.
|
* Whether to perform case-insensitive match.
|
||||||
* [`ignoreCase`](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url-option-ignore-case)
|
* [`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;
|
ignoreCase?: boolean;
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stripVTControlCharacters } from 'node:util';
|
||||||
import { stripAnsi } from '../config/utils';
|
import { stripAnsi } from '../config/utils';
|
||||||
import { test, expect } from './pageTest';
|
import { test, expect } from './pageTest';
|
||||||
|
|
||||||
@ -240,10 +241,45 @@ test.describe('toHaveURL', () => {
|
|||||||
await expect(page).toHaveURL('data:text/html,<div>A</div>');
|
await expect(page).toHaveURL('data:text/html,<div>A</div>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fail', async ({ page }) => {
|
test('fail string', async ({ page }) => {
|
||||||
await page.goto('data:text/html,<div>B</div>');
|
await page.goto('data:text/html,<div>A</div>');
|
||||||
const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e);
|
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 }) => {
|
test('support ignoreCase', async ({ page }) => {
|
||||||
|
|||||||
@ -543,11 +543,31 @@ test('should respect expect.timeout', async ({ runInlineTest }) => {
|
|||||||
'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`,
|
'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`,
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { stripVTControlCharacters } from 'node:util';
|
||||||
|
|
||||||
test('timeout', async ({ page }) => {
|
test('timeout', async ({ page }) => {
|
||||||
await page.goto('data:text/html,<div>A</div>');
|
await page.goto('data:text/html,<div>A</div>');
|
||||||
const error = await expect(page).toHaveURL('data:text/html,<div>B</div>').catch(e => e);
|
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>');
|
expect(error.message).toContain('data:text/html,<div>');
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user