Skip to content

Commit f65dc0c

Browse files
authored
feat: toHaveURL predicate matcher (#34413)
1 parent f117684 commit f65dc0c

File tree

7 files changed

+230
-23
lines changed

7 files changed

+230
-23
lines changed

docs/src/api/class-pageassertions.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,17 +323,18 @@ expect(page).to_have_url(re.compile(".*checkout"))
323323
await Expect(Page).ToHaveURLAsync(new Regex(".*checkout"));
324324
```
325325

326-
### param: PageAssertions.toHaveURL.urlOrRegExp
326+
### param: PageAssertions.toHaveURL.url
327327
* since: v1.18
328-
- `urlOrRegExp` <[string]|[RegExp]>
328+
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
329329

330-
Expected URL string or RegExp.
330+
Expected URL string, RegExp, or predicate receiving [URL] to match.
331+
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.
331332

332333
### option: PageAssertions.toHaveURL.ignoreCase
333334
* since: v1.44
334335
- `ignoreCase` <[boolean]>
335336

336-
Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified.
337+
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.
337338

338339
### option: PageAssertions.toHaveURL.timeout = %%-js-assertions-timeout-%%
339340
* since: v1.18

packages/playwright-core/src/server/injected/injectedScript.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,8 +1404,6 @@ export class InjectedScript {
14041404
received = getAriaRole(element) || '';
14051405
} else if (expression === 'to.have.title') {
14061406
received = this.document.title;
1407-
} else if (expression === 'to.have.url') {
1408-
received = this.document.location.href;
14091407
} else if (expression === 'to.have.value') {
14101408
element = this.retarget(element, 'follow-label')!;
14111409
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')

packages/playwright/src/matchers/matchers.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import { expectTypes, callLogText } from '../util';
2121
import { toBeTruthy } from './toBeTruthy';
2222
import { toEqual } from './toEqual';
2323
import { toMatchText } from './toMatchText';
24-
import { constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
24+
import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
2525
import { currentTestInfo } from '../common/globals';
2626
import { TestInfoImpl } from '../worker/testInfo';
2727
import type { ExpectMatcherState } from '../../types/test';
2828
import { takeFirst } from '../common/config';
29+
import { toHaveURL as toHaveURLExternal } from './toHaveURL';
2930

3031
export interface LocatorEx extends Locator {
3132
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
@@ -382,16 +383,10 @@ export function toHaveTitle(
382383
export function toHaveURL(
383384
this: ExpectMatcherState,
384385
page: Page,
385-
expected: string | RegExp,
386-
options?: { ignoreCase?: boolean, timeout?: number },
386+
expected: string | RegExp | ((url: URL) => boolean),
387+
options?: { ignoreCase?: boolean; timeout?: number },
387388
) {
388-
const baseURL = (page.context() as any)._options.baseURL;
389-
expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected;
390-
const locator = page.locator(':root') as LocatorEx;
391-
return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => {
392-
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase });
393-
return await locator._expect('to.have.url', { expectedText, isNot, timeout });
394-
}, expected, options);
389+
return toHaveURLExternal.call(this, page, expected, options);
395390
}
396391

397392
export async function toBeOK(
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { Page } from 'playwright-core';
18+
import type { ExpectMatcherState } from '../../types/test';
19+
import { EXPECTED_COLOR, printReceived } from '../common/expectBundle';
20+
import { matcherHint, type MatcherResult } from './matcherHint';
21+
import { constructURLBasedOnBaseURL, urlMatches } from 'playwright-core/lib/utils';
22+
import { colors } from 'playwright-core/lib/utilsBundle';
23+
import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from './expect';
24+
25+
export async function toHaveURL(
26+
this: ExpectMatcherState,
27+
page: Page,
28+
expected: string | RegExp | ((url: URL) => boolean),
29+
options?: { ignoreCase?: boolean; timeout?: number },
30+
): Promise<MatcherResult<string | RegExp, string>> {
31+
const matcherName = 'toHaveURL';
32+
const expression = 'page';
33+
const matcherOptions = {
34+
isNot: this.isNot,
35+
promise: this.promise,
36+
};
37+
38+
if (
39+
!(typeof expected === 'string') &&
40+
!(expected && 'test' in expected && typeof expected.test === 'function') &&
41+
!(typeof expected === 'function')
42+
) {
43+
throw new Error(
44+
[
45+
// Always display `expected` in expectation place
46+
matcherHint(this, undefined, matcherName, expression, undefined, matcherOptions),
47+
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected')} value must be a string, regular expression, or predicate`,
48+
this.utils.printWithType('Expected', expected, this.utils.printExpected,),
49+
].join('\n\n'),
50+
);
51+
}
52+
53+
const timeout = options?.timeout ?? this.timeout;
54+
const baseURL: string | undefined = (page.context() as any)._options.baseURL;
55+
let conditionSucceeded = false;
56+
let lastCheckedURLString: string | undefined = undefined;
57+
try {
58+
await page.mainFrame().waitForURL(
59+
url => {
60+
lastCheckedURLString = url.toString();
61+
62+
if (options?.ignoreCase) {
63+
return (
64+
!this.isNot ===
65+
urlMatches(
66+
baseURL?.toLocaleLowerCase(),
67+
lastCheckedURLString.toLocaleLowerCase(),
68+
typeof expected === 'string'
69+
? expected.toLocaleLowerCase()
70+
: expected,
71+
)
72+
);
73+
}
74+
75+
return (
76+
!this.isNot === urlMatches(baseURL, lastCheckedURLString, expected)
77+
);
78+
},
79+
{ timeout },
80+
);
81+
82+
conditionSucceeded = true;
83+
} catch (e) {
84+
conditionSucceeded = false;
85+
}
86+
87+
if (conditionSucceeded)
88+
return { name: matcherName, pass: !this.isNot, message: () => '' };
89+
90+
return {
91+
name: matcherName,
92+
pass: this.isNot,
93+
message: () =>
94+
toHaveURLMessage(
95+
this,
96+
matcherName,
97+
expression,
98+
typeof expected === 'string'
99+
? constructURLBasedOnBaseURL(baseURL, expected)
100+
: expected,
101+
lastCheckedURLString,
102+
this.isNot,
103+
true,
104+
timeout,
105+
),
106+
actual: lastCheckedURLString,
107+
timeout,
108+
};
109+
}
110+
111+
function toHaveURLMessage(
112+
state: ExpectMatcherState,
113+
matcherName: string,
114+
expression: string,
115+
expected: string | RegExp | Function,
116+
received: string | undefined,
117+
pass: boolean,
118+
didTimeout: boolean,
119+
timeout: number,
120+
): string {
121+
const matcherOptions = {
122+
isNot: state.isNot,
123+
promise: state.promise,
124+
};
125+
const receivedString = received || '';
126+
const messagePrefix = matcherHint(state, undefined, matcherName, expression, undefined, matcherOptions, didTimeout ? timeout : undefined);
127+
128+
let printedReceived: string | undefined;
129+
let printedExpected: string | undefined;
130+
let printedDiff: string | undefined;
131+
if (typeof expected === 'function') {
132+
printedExpected = `Expected predicate to ${!state.isNot ? 'succeed' : 'fail'}`;
133+
printedReceived = `Received string: ${printReceived(receivedString)}`;
134+
} else {
135+
if (pass) {
136+
if (typeof expected === 'string') {
137+
printedExpected = `Expected string: not ${state.utils.printExpected(expected)}`;
138+
const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
139+
printedReceived = `Received string: ${formattedReceived}`;
140+
} else {
141+
printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`;
142+
const formattedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
143+
printedReceived = `Received string: ${formattedReceived}`;
144+
}
145+
} else {
146+
const labelExpected = `Expected ${typeof expected === 'string' ? 'string' : 'pattern'}`;
147+
printedDiff = state.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false);
148+
}
149+
}
150+
151+
const resultDetails = printedDiff ? printedDiff : printedExpected + '\n' + printedReceived;
152+
return messagePrefix + resultDetails;
153+
}

packages/playwright/types/test.d.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8795,14 +8795,18 @@ interface PageAssertions {
87958795
* await expect(page).toHaveURL(/.*checkout/);
87968796
* ```
87978797
*
8798-
* @param urlOrRegExp Expected URL string or RegExp.
8798+
* @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When a
8799+
* [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context
8800+
* options was provided and the passed URL is a path, it gets merged via the
8801+
* [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
87998802
* @param options
88008803
*/
8801-
toHaveURL(urlOrRegExp: string|RegExp, options?: {
8804+
toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: {
88028805
/**
88038806
* Whether to perform case-insensitive match.
88048807
* [`ignoreCase`](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url-option-ignore-case)
8805-
* option takes precedence over the corresponding regular expression flag if specified.
8808+
* option takes precedence over the corresponding regular expression parameter if specified. A provided predicate
8809+
* ignores this flag.
88068810
*/
88078811
ignoreCase?: boolean;
88088812

tests/page/expect-misc.spec.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { stripVTControlCharacters } from 'node:util';
1718
import { stripAnsi } from '../config/utils';
1819
import { test, expect } from './pageTest';
1920

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

243-
test('fail', async ({ page }) => {
244-
await page.goto('data:text/html,<div>B</div>');
244+
test('fail string', async ({ page }) => {
245+
await page.goto('data:text/html,<div>A</div>');
245246
const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e);
246-
expect(error.message).toContain('expect.toHaveURL with timeout 1000ms');
247+
expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)');
248+
expect(stripVTControlCharacters(error.message)).toContain('Expected string: "wrong"\nReceived string: "data:text/html,<div>A</div>"');
249+
});
250+
251+
test('fail with invalid argument', async ({ page }) => {
252+
await page.goto('data:text/html,<div>A</div>');
253+
// @ts-expect-error
254+
const error = await expect(page).toHaveURL({}).catch(e => e);
255+
expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)\n\n\n\nMatcher error: expected value must be a string, regular expression, or predicate');
256+
expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}');
257+
});
258+
259+
test('fail with positive predicate', async ({ page }) => {
260+
await page.goto('data:text/html,<div>A</div>');
261+
const error = await expect(page).toHaveURL(_url => false).catch(e => e);
262+
expect(stripVTControlCharacters(error.message)).toContain('expect(page).toHaveURL(expected)');
263+
expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to succeed\nReceived string: "data:text/html,<div>A</div>"');
264+
});
265+
266+
test('fail with negative predicate', async ({ page }) => {
267+
await page.goto('data:text/html,<div>A</div>');
268+
const error = await expect(page).not.toHaveURL(_url => true).catch(e => e);
269+
expect(stripVTControlCharacters(error.message)).toContain('expect(page).not.toHaveURL(expected)');
270+
expect(stripVTControlCharacters(error.message)).toContain('Expected predicate to fail\nReceived string: "data:text/html,<div>A</div>"');
271+
});
272+
273+
test('resolve predicate on initial call', async ({ page }) => {
274+
await page.goto('data:text/html,<div>A</div>');
275+
await expect(page).toHaveURL(url => url.href === 'data:text/html,<div>A</div>', { timeout: 1000 });
276+
});
277+
278+
test('resolve predicate after retries', async ({ page }) => {
279+
await page.goto('data:text/html,<div>A</div>');
280+
const expectPromise = expect(page).toHaveURL(url => url.href === 'data:text/html,<div>B</div>', { timeout: 1000 });
281+
setTimeout(() => page.goto('data:text/html,<div>B</div>'), 500);
282+
await expectPromise;
247283
});
248284

249285
test('support ignoreCase', async ({ page }) => {

tests/playwright-test/expect.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,11 +543,31 @@ test('should respect expect.timeout', async ({ runInlineTest }) => {
543543
'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`,
544544
'a.test.ts': `
545545
import { test, expect } from '@playwright/test';
546+
import { stripVTControlCharacters } from 'node:util';
546547
547548
test('timeout', async ({ page }) => {
548549
await page.goto('data:text/html,<div>A</div>');
549550
const error = await expect(page).toHaveURL('data:text/html,<div>B</div>').catch(e => e);
550-
expect(error.message).toContain('expect.toHaveURL with timeout 1000ms');
551+
expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)');
552+
expect(error.message).toContain('data:text/html,<div>');
553+
});
554+
`,
555+
}, { workers: 1 });
556+
expect(result.exitCode).toBe(0);
557+
expect(result.passed).toBe(1);
558+
});
559+
560+
test('should support toHaveURL predicate', async ({ runInlineTest }) => {
561+
const result = await runInlineTest({
562+
'playwright.config.js': `module.exports = { expect: { timeout: 1000 } }`,
563+
'a.test.ts': `
564+
import { test, expect } from '@playwright/test';
565+
import { stripVTControlCharacters } from 'node:util';
566+
567+
test('predicate', async ({ page }) => {
568+
await page.goto('data:text/html,<div>A</div>');
569+
const error = await expect(page).toHaveURL('data:text/html,<div>B</div>').catch(e => e);
570+
expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)');
551571
expect(error.message).toContain('data:text/html,<div>');
552572
});
553573
`,

0 commit comments

Comments
 (0)