diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 7120e44887..4a48d4dbf1 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -918,6 +918,16 @@ Attribute name to get the value for. ### option: Frame.getByLabelText.exact = %%-locator-get-by-text-exact-%% +## method: Frame.getByPlaceholderText +* since: v1.27 +- returns: <[Locator]> + +%%-template-locator-get-by-placeholder-text-%% + +### param: Frame.getByPlaceholderText.text = %%-locator-get-by-text-text-%% +### option: Frame.getByPlaceholderText.exact = %%-locator-get-by-text-exact-%% + + ## method: Frame.getByRole * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 920c0c8411..78b02dc374 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -124,6 +124,16 @@ in that iframe. ### option: FrameLocator.getByLabelText.exact = %%-locator-get-by-text-exact-%% +## method: FrameLocator.getByPlaceholderText +* since: v1.27 +- returns: <[Locator]> + +%%-template-locator-get-by-placeholder-text-%% + +### param: FrameLocator.getByPlaceholderText.text = %%-locator-get-by-text-text-%% +### option: FrameLocator.getByPlaceholderText.exact = %%-locator-get-by-text-exact-%% + + ## method: FrameLocator.getByRole * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 233772b357..7f3465bfeb 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -644,6 +644,16 @@ Attribute name to get the value for. ### option: Locator.getByLabelText.exact = %%-locator-get-by-text-exact-%% +## method: Locator.getByPlaceholderText +* since: v1.27 +- returns: <[Locator]> + +%%-template-locator-get-by-placeholder-text-%% + +### param: Locator.getByPlaceholderText.text = %%-locator-get-by-text-text-%% +### option: Locator.getByPlaceholderText.exact = %%-locator-get-by-text-exact-%% + + ## method: Locator.getByRole * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index cee5229031..63f1083951 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2193,6 +2193,16 @@ Attribute name to get the value for. ### option: Page.getByLabelText.exact = %%-locator-get-by-text-exact-%% +## method: Page.getByPlaceholderText +* since: v1.27 +- returns: <[Locator]> + +%%-template-locator-get-by-placeholder-text-%% + +### param: Page.getByPlaceholderText.text = %%-locator-get-by-text-text-%% +### option: Page.getByPlaceholderText.exact = %%-locator-get-by-text-exact-%% + + ## method: Page.getByRole * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 29d2087e71..669aa40d0d 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1188,6 +1188,13 @@ Allows locating input elements by the text of the associated label. For example, ``` +## template-locator-get-by-placeholder-text + +Allows locating input elements by the placeholder text. For example, this method will find the input by placeholder "Country": + +```html + +``` ## template-locator-get-by-role diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 20ab160358..a3420ee834 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -307,6 +307,10 @@ export class Frame extends ChannelOwner implements api.Fr return this.locator(Locator.getByLabelTextSelector(text, options)); } + getByPlaceholderText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(Locator.getByPlaceholderTextSelector(text, options)); + } + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { return this.locator(Locator.getByTextSelector(text, options)); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index c169934012..90cd069684 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -52,7 +52,7 @@ export class Locator implements api.Locator { } static getByTestIdSelector(testId: string): string { - return `css=[${Locator._testIdAttributeName}=${JSON.stringify(testId)}]`; + return `attr=[${Locator._testIdAttributeName}=${JSON.stringify(testId)}]`; } static getByLabelTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { @@ -63,6 +63,12 @@ export class Locator implements api.Locator { return selector + ' >> control=resolve-label'; } + static getByPlaceholderTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + if (!isString(text)) + return `attr=[placeholder=${text}]`; + return `attr=[placeholder=${JSON.stringify(text)}${options?.exact ? 's' : 'i'}]`; + } + static getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { if (!isString(text)) return `text=${text}`; @@ -197,6 +203,10 @@ export class Locator implements api.Locator { return this.locator(Locator.getByLabelTextSelector(text, options)); } + getByPlaceholderText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(Locator.getByPlaceholderTextSelector(text, options)); + } + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { return this.locator(Locator.getByTextSelector(text, options)); } @@ -386,6 +396,11 @@ export class FrameLocator implements api.FrameLocator { getByLabelText(text: string | RegExp, options?: { exact?: boolean }): Locator { return this.locator(Locator.getByLabelTextSelector(text, options)); } + + getByPlaceholderText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(Locator.getByPlaceholderTextSelector(text, options)); + } + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { return this.locator(Locator.getByTextSelector(text, options)); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 65ed01b573..b28d87d216 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -572,6 +572,10 @@ export class Page extends ChannelOwner implements api.Page return this.mainFrame().getByLabelText(text, options); } + getByPlaceholderText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByPlaceholderText(text, options); + } + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { return this.mainFrame().getByText(text, options); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 1aae3c635c..804896d1a9 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -19,6 +19,7 @@ import { XPathEngine } from './xpathSelectorEngine'; import { ReactEngine } from './reactSelectorEngine'; import { VueEngine } from './vueSelectorEngine'; import { RoleEngine } from './roleSelectorEngine'; +import { parseAttributeSelector } from '../isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText } from './selectorUtils'; @@ -104,6 +105,7 @@ export class InjectedScript { this._engines.set('visible', this._createVisibleEngine()); this._engines.set('control', this._createControlEngine()); this._engines.set('has', this._createHasEngine()); + this._engines.set('attr', this._createNamedAttributeEngine()); for (const { name, engine } of customEngines) this._engines.set(name, engine); @@ -271,6 +273,31 @@ export class InjectedScript { }; } + private _createNamedAttributeEngine(): SelectorEngine { + const queryList = (root: SelectorRoot, selector: string): Element[] => { + const parsed = parseAttributeSelector(selector, true); + if (parsed.name || parsed.attributes.length !== 1) + throw new Error('Malformed attribute selector: ' + selector); + const { name, value, caseSensitive } = parsed.attributes[0]; + const lowerCaseValue = caseSensitive ? null : value.toLowerCase(); + let matcher: (s: string) => boolean; + if (value instanceof RegExp) + matcher = s => !!s.match(value); + else if (caseSensitive) + matcher = s => s === value; + else + matcher = s => s.toLowerCase().includes(lowerCaseValue!); + const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, `[${name}]`); + return elements.filter(e => matcher(e.getAttribute(name)!)); + }; + + return { + queryAll: (root: SelectorRoot, selector: string): Element[] => { + return queryList(root, selector); + } + }; + } + private _createControlEngine(): SelectorEngineV2 { return { queryAll(root: SelectorRoot, body: any) { diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 8a63d90e10..ea449efc4e 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -46,7 +46,7 @@ export class Selectors { 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', 'nth', 'visible', 'control', 'has', - 'role', + 'role', 'attr' ]); this._builtinEnginesInMainWorld = new Set([ '_react', '_vue', diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 33bc0c443f..e5d4a52324 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2475,6 +2475,24 @@ export interface Page { exact?: boolean; }): Locator; + /** + * Allows locating input elements by the placeholder text. For example, this method will find the input by placeholder + * "Country": + * + * ```html + * + * ``` + * + * @param text Text to locate the element for. + * @param options + */ + getByPlaceholderText(text: string|RegExp, options?: { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. + */ + exact?: boolean; + }): Locator; + /** * Allows locating elements by their [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles), * [ARIA attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-attributes) and @@ -5509,6 +5527,24 @@ export interface Frame { exact?: boolean; }): Locator; + /** + * Allows locating input elements by the placeholder text. For example, this method will find the input by placeholder + * "Country": + * + * ```html + * + * ``` + * + * @param text Text to locate the element for. + * @param options + */ + getByPlaceholderText(text: string|RegExp, options?: { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. + */ + exact?: boolean; + }): Locator; + /** * Allows locating elements by their [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles), * [ARIA attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-attributes) and @@ -9891,6 +9927,24 @@ export interface Locator { exact?: boolean; }): Locator; + /** + * Allows locating input elements by the placeholder text. For example, this method will find the input by placeholder + * "Country": + * + * ```html + * + * ``` + * + * @param text Text to locate the element for. + * @param options + */ + getByPlaceholderText(text: string|RegExp, options?: { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. + */ + exact?: boolean; + }): Locator; + /** * Allows locating elements by their [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles), * [ARIA attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-attributes) and @@ -15094,6 +15148,24 @@ export interface FrameLocator { exact?: boolean; }): Locator; + /** + * Allows locating input elements by the placeholder text. For example, this method will find the input by placeholder + * "Country": + * + * ```html + * + * ``` + * + * @param text Text to locate the element for. + * @param options + */ + getByPlaceholderText(text: string|RegExp, options?: { + /** + * Whether to find an exact match: case-sensitive and whole-string. Default to false. + */ + exact?: boolean; + }): Locator; + /** * Allows locating elements by their [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles), * [ARIA attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-attributes) and diff --git a/tests/page/locator-frame.spec.ts b/tests/page/locator-frame.spec.ts index 42e5e3b582..6e43190ffb 100644 --- a/tests/page/locator-frame.spec.ts +++ b/tests/page/locator-frame.spec.ts @@ -35,7 +35,7 @@ async function routeIframe(page: Page) { 1 2 - + `, contentType: 'text/html' }).catch(() => {}); @@ -239,7 +239,7 @@ it('locator.frameLocator should not throw on first/last/nth', async ({ page, ser await expect(button3).toHaveText('Hello from iframe-3.html'); }); -it('role and text coverage', async ({ page, server }) => { +it('getBy coverage', async ({ page, server }) => { await routeIframe(page); await page.goto(server.EMPTY_PAGE); const button1 = page.frameLocator('iframe').getByRole('button'); @@ -248,6 +248,8 @@ it('role and text coverage', async ({ page, server }) => { await expect(button1).toHaveText('Hello iframe'); await expect(button2).toHaveText('Hello iframe'); await expect(button3).toHaveText('Hello iframe'); - const input = page.frameLocator('iframe').getByLabelText('Name'); - await expect(input).toHaveValue(''); + const input1 = page.frameLocator('iframe').getByLabelText('Name'); + await expect(input1).toHaveValue(''); + const input2 = page.frameLocator('iframe').getByPlaceholderText('Placeholder'); + await expect(input2).toHaveValue(''); }); diff --git a/tests/page/selectors-css.spec.ts b/tests/page/selectors-css.spec.ts index 2732307b02..37d09afc08 100644 --- a/tests/page/selectors-css.spec.ts +++ b/tests/page/selectors-css.spec.ts @@ -413,15 +413,3 @@ it('css on the handle should be relative', async ({ page }) => { expect(await div.$eval(`.find-me`, e => e.id)).toBe('target2'); expect(await page.$eval(`div >> .find-me`, e => e.id)).toBe('target2'); }); - -it('getByTestId should work', async ({ page }) => { - await page.setContent('
Hello world
'); - await expect(page.getByTestId('Hello')).toHaveText('Hello world'); - await expect(page.mainFrame().getByTestId('Hello')).toHaveText('Hello world'); - await expect(page.locator('div').getByTestId('Hello')).toHaveText('Hello world'); -}); - -it('getByTestId should escape id', async ({ page }) => { - await page.setContent(`
Hello world
`); - await expect(page.getByTestId('He"llo')).toHaveText('Hello world'); -}); diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts new file mode 100644 index 0000000000..d68ae6f2a1 --- /dev/null +++ b/tests/page/selectors-get-by.spec.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * 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 { test as it, expect } from './pageTest'; + +it('getByTestId should work', async ({ page }) => { + await page.setContent('
Hello world
'); + await expect(page.getByTestId('Hello')).toHaveText('Hello world'); + await expect(page.mainFrame().getByTestId('Hello')).toHaveText('Hello world'); + await expect(page.locator('div').getByTestId('Hello')).toHaveText('Hello world'); +}); + +it('getByTestId should escape id', async ({ page }) => { + await page.setContent(`
Hello world
`); + await expect(page.getByTestId('He"llo')).toHaveText('Hello world'); +}); + +it('getByText should work', async ({ page }) => { + await page.setContent(`
yo
ya
\nye
`); + expect(await page.getByText('ye').evaluate(e => e.outerHTML)).toContain('>\nye '); + expect(await page.getByText(/ye/).evaluate(e => e.outerHTML)).toContain('>\nye '); + + await page.setContent(`
ye
ye
`); + expect(await page.getByText('ye', { exact: true }).first().evaluate(e => e.outerHTML)).toContain('> ye '); +}); + +it('getByLabelText should work', async ({ page }) => { + await page.setContent(`
`); + expect(await page.getByText('Name').evaluate(e => e.nodeName)).toBe('LABEL'); + expect(await page.getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); + expect(await page.mainFrame().getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); + expect(await page.locator('div').getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); +}); + +it('getByPlaceholderText should work', async ({ page }) => { + await page.setContent(`
+ + +
`); + await expect(page.getByPlaceholderText('hello')).toHaveCount(2); + await expect(page.getByPlaceholderText('Hello', { exact: true })).toHaveCount(1); + await expect(page.getByPlaceholderText(/wor/i)).toHaveCount(1); + + // Coverage + await expect(page.mainFrame().getByPlaceholderText('hello')).toHaveCount(2); + await expect(page.locator('div').getByPlaceholderText('hello')).toHaveCount(2); +}); diff --git a/tests/page/selectors-text.spec.ts b/tests/page/selectors-text.spec.ts index 3b369a5260..ff57d6dcee 100644 --- a/tests/page/selectors-text.spec.ts +++ b/tests/page/selectors-text.spec.ts @@ -454,11 +454,3 @@ it('should work with paired quotes in the middle of selector', async ({ page }) // Should double escape inside quoted text. await expect(page.locator(`div >> text='pattern "^-?\\\\d+$"'`)).toBeVisible(); }); - -it('getByLabelText should work', async ({ page, asset }) => { - await page.setContent(`
`); - expect(await page.getByText('Name').evaluate(e => e.nodeName)).toBe('LABEL'); - expect(await page.getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); - expect(await page.mainFrame().getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); - expect(await page.locator('div').getByLabelText('Name').evaluate(e => e.nodeName)).toBe('INPUT'); -});