Skip to content

chore: hide locator(':root') in Steps for toHaveTitle/URL #36213

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

Merged
merged 2 commits into from
Jun 16, 2025
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
14 changes: 10 additions & 4 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,16 @@ export class InjectedScript {
// expect(locator).not.toBeInViewport() passes when there is no element.
if (options.isNot && options.expression === 'to.be.in.viewport')
return { matches: false };
if (options.expression === 'to.have.title' && options?.expectedText?.[0]) {
const matcher = new ExpectedTextMatcher(options.expectedText[0]);
const received = this.document.title;
return { received, matches: matcher.matches(received) };
}
if (options.expression === 'to.have.url' && options?.expectedText?.[0]) {
const matcher = new ExpectedTextMatcher(options.expectedText[0]);
const received = this.document.location.href;
return { received, matches: matcher.matches(received) };
}
// When none of the above applies, expect does not match.
return { matches: options.isNot, missingReceived: true };
}
Expand Down Expand Up @@ -1498,10 +1508,6 @@ export class InjectedScript {
received = getElementAccessibleErrorMessage(element);
} else if (expression === 'to.have.role') {
received = getAriaRole(element) || '';
} else if (expression === 'to.have.title') {
received = this.document.title;
} else if (expression === 'to.have.url') {
received = this.document.location.href;
} else if (expression === 'to.have.value') {
element = this.retarget(element, 'follow-label')!;
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright-core/src/client/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
async title(): Promise<string> {
return (await this._channel.title()).value;
}

async _expect(expression: string, options: Omit<channels.FrameExpectParams, 'expression'>): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
const params: channels.FrameExpectParams = { expression, ...options, isNot: !!options.isNot };
params.expectedValue = serializeArgument(options.expectedValue);
const result = (await this._channel.expect(params));
if (result.received !== undefined)
result.received = parseResult(result.received);
return result;
}
}

export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent {
Expand Down
11 changes: 4 additions & 7 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/

import { ElementHandle } from './elementHandle';
import { parseResult, serializeArgument } from './jsHandle';
import { asLocator } from '../utils/isomorphic/locatorGenerators';
import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils';
import { escapeForTextSelector } from '../utils/isomorphic/stringUtils';
Expand Down Expand Up @@ -374,12 +373,10 @@ export class Locator implements api.Locator {
}

async _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot };
params.expectedValue = serializeArgument(options.expectedValue);
const result = (await this._frame._channel.expect(params));
if (result.received !== undefined)
result.received = parseResult(result.received);
return result;
return this._frame._expect(expression, {
...options,
selector: this._selector,
});
}

private _inspect() {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,7 +1878,7 @@ scheme.FrameWaitForSelectorResult = tObject({
element: tOptional(tChannel(['ElementHandle'])),
});
scheme.FrameExpectParams = tObject({
selector: tString,
selector: tOptional(tString),
expression: tString,
expressionArg: tOptional(tAny),
expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
Expand Down
11 changes: 6 additions & 5 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1382,15 +1382,15 @@ export class Frame extends SdkObject {
}, options.timeout);
}

async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
async expect(metadata: CallMetadata, selector: string | undefined, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
const result = await this._expectImpl(metadata, selector, options);
// Library mode special case for the expect errors which are return values, not exceptions.
if (result.matches === options.isNot)
metadata.error = { error: { name: 'Expect', message: 'Expect failed' } };
return result;
}

private async _expectImpl(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
private async _expectImpl(metadata: CallMetadata, selector: string | undefined, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false };
try {
let timeout = options.timeout;
Expand All @@ -1399,7 +1399,8 @@ export class Frame extends SdkObject {
// Step 1: perform locator handlers checkpoint with a specified timeout.
await (new ProgressController(metadata, this)).run(async progress => {
progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`);
progress.log(`waiting for ${this._asLocator(selector)}`);
if (selector)
progress.log(`waiting for ${this._asLocator(selector)}`);
await this._page.performActionPreChecks(progress);
}, timeout);

Expand Down Expand Up @@ -1452,8 +1453,8 @@ export class Frame extends SdkObject {
}
}

private async _expectInternal(progress: Progress, selector: string, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) {
const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true });
private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) {
const selectorInFrame = selector ? await this.selectors.resolveFrameForSelector(selector, { strict: true }) : undefined;
progress.throwIfAborted();

const { frame, info } = selectorInFrame || { frame: this, info: undefined };
Expand Down
18 changes: 10 additions & 8 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { TestInfoImpl } from '../worker/testInfo';

import type { ExpectMatcherState } from '../../types/test';
import type { TestStepInfoImpl } from '../worker/testInfo';
import type { APIResponse, Locator, Page } from 'playwright-core';
import type { APIResponse, Locator, Frame, Page } from 'playwright-core';
import type { FrameExpectParams } from 'playwright-core/lib/client/types';

export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl };
Expand All @@ -37,6 +37,10 @@ export interface LocatorEx extends Locator {
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
}

export interface FrameEx extends Frame {
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
}

interface APIResponseEx extends APIResponse {
_fetchLog(): Promise<string[]>;
}
Expand Down Expand Up @@ -401,10 +405,9 @@ export function toHaveTitle(
expected: string | RegExp,
options: { timeout?: number } = {},
) {
const locator = page.locator(':root') as LocatorEx;
return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => {
return toMatchText.call(this, 'toHaveTitle', page, 'Page', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true });
return await locator._expect('to.have.title', { expectedText, isNot, timeout });
return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout });
}, expected, { receiverLabel: 'page', ...options });
}

Expand All @@ -420,11 +423,10 @@ export function toHaveURL(

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) => {
return toMatchText.call(this, 'toHaveURL', page, 'Page', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase });
return await locator._expect('to.have.url', { expectedText, isNot, timeout });
}, expected, options);
return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout });
}, expected, { receiverLabel: 'page', ...options });
}

export async function toBeOK(
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright/src/matchers/toMatchText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import { EXPECTED_COLOR } from '../common/expectBundle';

import type { MatcherResult } from './matcherHint';
import type { ExpectMatcherState } from '../../types/test';
import type { Locator } from 'playwright-core';
import type { Page, Locator } from 'playwright-core';

export async function toMatchText(
this: ExpectMatcherState,
matcherName: string,
receiver: Locator,
receiverType: string,
receiver: Locator | Page,
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we should pass receiverLabel: 'page' whenever we pass a page, or better yet auto-detect it here? Perhaps default to receiverType.toLowerCase()?

receiverType: 'Locator' | 'Page',
query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: string, log?: string[], timedOut?: boolean }>,
expected: string | RegExp,
options: { timeout?: number, matchSubstring?: boolean, receiverLabel?: string } = {},
Expand All @@ -51,7 +51,7 @@ export async function toMatchText(
) {
// Same format as jest's matcherErrorMessage
throw new Error([
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions),
matcherHint(this, receiverType === 'Locator' ? receiver as Locator : undefined, matcherName, options.receiverLabel ?? 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'));
Expand All @@ -71,7 +71,7 @@ export async function toMatchText(

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

let printedReceived: string | undefined;
Expand Down
3 changes: 2 additions & 1 deletion packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3234,7 +3234,7 @@ export type FrameWaitForSelectorResult = {
element?: ElementHandleChannel,
};
export type FrameExpectParams = {
selector: string,
selector?: string,
expression: string,
expressionArg?: any,
expectedText?: ExpectedTextValue[],
Expand All @@ -3245,6 +3245,7 @@ export type FrameExpectParams = {
timeout: number,
};
export type FrameExpectOptions = {
selector?: string,
expressionArg?: any,
expectedText?: ExpectedTextValue[],
expectedNumber?: number,
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2655,7 +2655,7 @@ Frame:
expect:
title: Expect "{expression}"
parameters:
selector: string
selector: string?
expression: string
expressionArg: json?
expectedText:
Expand Down
8 changes: 7 additions & 1 deletion tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s
});
await context.tracing.start({ name: 'test', screenshots: true, snapshots: true, sources: true });
const page = await context.newPage();
await page.goto(`data:text/html,<!DOCTYPE html><html>Hello world</html>`);
await page.goto(`data:text/html,<!DOCTYPE html><html><title>Hello</title><body>Hello world</body></html>`);
await expect(page).toHaveTitle('Hello');
await expect(page).toHaveURL('data:text/html,<!DOCTYPE html><html><title>Hello</title><body>Hello world</body></html>');
await page.setContent('<!DOCTYPE html><button>Click</button>');
await expect(page.locator('button')).toHaveText('Click');
await expect(page.getByTestId('amazing-btn')).toBeHidden();
Expand Down Expand Up @@ -151,6 +153,8 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => {
await expect(traceViewer.actionTitles).toHaveText([
/Create page/,
/Navigate to "data:"/,
/^Expect "toHaveTitle"[\d]+ms$/,
/^Expect "toHaveURL"[\d]+ms$/,
/Set content/,
/toHaveText.*locator/,
/toBeHidden.*getByTestId/,
Expand Down Expand Up @@ -1827,6 +1831,8 @@ test('should render blob trace received from message', async ({ showTraceViewer
await expect(traceViewer.actionTitles).toHaveText([
/Create page/,
/Navigate to "data:"/,
/toHaveTitle/,
/toHaveURL/,
/Set content/,
/toHaveText.*locator/,
/toBeHidden.*getByTestId/,
Expand Down
4 changes: 2 additions & 2 deletions tests/page/expect-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,15 @@ test.describe('toHaveURL', () => {
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(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(locator).toHaveURL(expected)');
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(locator(':root')).toHaveURL([object Object])`);
expect(stripVTControlCharacters(error.message)).toContain(`expect(page).toHaveURL([object Object])`);
expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}');
});

Expand Down
2 changes: 1 addition & 1 deletion tests/playwright-test/expect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ test('should respect expect.timeout', async ({ runInlineTest }) => {
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(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(locator).toHaveURL(expected)');
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