diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index b2dbc2912e..5e0879b68b 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -105,6 +105,45 @@ var texts = await page.GetByRole(AriaRole.Link).AllTextContentsAsync(); ``` +## method: Locator.and +* since: v1.33 +* langs: + - alias-python: and_ +- returns: <[Locator]> + +Creates a locator that matches both this locator and the argument locator. + +**Usage** + +The following example finds a button with a specific title. + +```js +const button = page.getByRole('button').and(page.getByTitle('Subscribe')); +``` + +```java +Locator button = page.getByRole(AriaRole.BUTTON).and(page.getByTitle("Subscribe")); +``` + +```python async +button = page.get_by_role("button").and_(page.getByTitle("Subscribe")) +``` + +```python sync +button = page.get_by_role("button").and_(page.getByTitle("Subscribe")) +``` + +```csharp +var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe")); +``` + +### param: Locator.and.locator +* since: v1.33 +- `locator` <[Locator]> + +Additional locator to match. + + ## async method: Locator.blur * since: v1.28 diff --git a/docs/src/locators.md b/docs/src/locators.md index dea4ca1a73..35e7612508 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -1056,6 +1056,25 @@ await Expect(page Note that the inner locator is matched starting from the outer one, not from the document root. +### Filter by matching an additional locator + +Method [`method: Locator.and`] narrows down an existing locator by matching an additional locator. For example, you can combine [`method: Page.getByRole`] and [`method: Page.getByTitle`] to match by both role and title. +```js +const button = page.getByRole('button').and(page.getByTitle('Subscribe')); +``` +```java +Locator button = page.getByRole(AriaRole.BUTTON).and(page.getByTitle("Subscribe")); +``` +```python async +button = page.get_by_role("button").and_(page.getByTitle("Subscribe")) +``` +```python sync +button = page.get_by_role("button").and_(page.getByTitle("Subscribe")) +``` +```csharp +var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe")); +``` + ## Chaining Locators You can chain methods that create a locator, like [`method: Page.getByText`] or [`method: Locator.getByRole`], to narrow down the search to a particular part of the page. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 7ffbab7d18..f2fa346294 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -204,6 +204,12 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ` >> nth=${index}`); } + and(locator: Locator): Locator { + if (locator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(locator._selector)); + } + or(locator: Locator): Locator { if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 3aff5a1ffa..267922c439 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -61,6 +61,7 @@ class Locator { self.first = (): Locator => self.locator('nth=0'); self.last = (): Locator => self.locator('nth=-1'); self.nth = (index: number): Locator => self.locator(`nth=${index}`); + self.and = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:and=` + JSON.stringify((locator as any)[selectorSymbol])); self.or = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify((locator as any)[selectorSymbol])); } } @@ -93,6 +94,7 @@ class ConsoleAPI { delete this._injectedScript.window.playwright.first; delete this._injectedScript.window.playwright.last; delete this._injectedScript.window.playwright.nth; + delete this._injectedScript.window.playwright.and; delete this._injectedScript.window.playwright.or; } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 9225fdd38b..ab803d203d 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -113,6 +113,7 @@ export class InjectedScript { this._engines.set('internal:control', this._createControlEngine()); this._engines.set('internal:has', this._createHasEngine()); this._engines.set('internal:has-not', this._createHasNotEngine()); + this._engines.set('internal:and', { queryAll: () => [] }); this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); @@ -212,6 +213,9 @@ export class InjectedScript { for (const part of selector.parts) { if (part.name === 'nth') { roots = this._queryNth(roots, part); + } else if (part.name === 'internal:and') { + const andElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root); + roots = new Set(andElements.filter(e => roots.has(e))); } else if (part.name === 'internal:or') { const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root); roots = new Set(sortInDOMOrder(new Set([...roots, ...orElements]))); diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 13ad30a08b..85ecd5e8c2 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -38,7 +38,7 @@ export class Selectors { 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-not', 'internal:has-text', 'internal:has-not-text', - 'internal:or', + 'internal:and', 'internal:or', 'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid', ]); this._builtinEnginesInMainWorld = new Set([ diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index c5fea64c1c..d310764dc8 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'or'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; @@ -99,6 +99,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame tokens.push(factory.generateLocator(base, 'hasNot', inner)); continue; } + if (part.name === 'internal:and') { + const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); + tokens.push(factory.generateLocator(base, 'and', inner)); + continue; + } if (part.name === 'internal:or') { const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); tokens.push(factory.generateLocator(base, 'or', inner)); @@ -217,6 +222,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `filter({ has: ${body} })`; case 'hasNot': return `filter({ hasNot: ${body} })`; + case 'and': + return `and(${body})`; case 'or': return `or(${body})`; case 'test-id': @@ -291,6 +298,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `filter(has=${body})`; case 'hasNot': return `filter(has_not=${body})`; + case 'and': + return `and_(${body})`; case 'or': return `or_(${body})`; case 'test-id': @@ -374,6 +383,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; case 'hasNot': return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; + case 'and': + return `and(${body})`; case 'or': return `or(${body})`; case 'test-id': @@ -451,6 +462,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `Filter(new() { Has = ${body} })`; case 'hasNot': return `Filter(new() { HasNot = ${body} })`; + case 'and': + return `And(${body})`; case 'or': return `Or(${body})`; case 'test-id': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index aabe0938dc..e243700b2a 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -80,6 +80,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string { .replace(/new[\w]+\.[\w]+options\(\)/g, '') .replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or". + .replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and". .replace(/:/g, '=') .replace(/,re\.ignorecase/g, 'i') .replace(/,pattern.case_insensitive/g, 'i') @@ -104,7 +105,7 @@ function shiftParams(template: string, sub: number) { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { // Recursively handle filter(has=, hasnot=). - // TODO: handle or(locator). + // TODO: handle and(locator), or(locator). while (true) { const hasMatch = template.match(/filter\(,?(has|hasnot)=/); if (!hasMatch) diff --git a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts index 34f16f5c2c..1708672cec 100644 --- a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts @@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; -const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:or', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:and', 'internal:or', 'left-of', 'right-of', 'above', 'below', 'near']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); export type ParsedSelectorPart = { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 9d6b9f1724..e8a6d7b880 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10453,6 +10453,21 @@ export interface Locator { */ allTextContents(): Promise>; + /** + * Creates a locator that matches both this locator and the argument locator. + * + * **Usage** + * + * The following example finds a button with a specific title. + * + * ```js + * const button = page.getByRole('button').and(page.getByTitle('Subscribe')); + * ``` + * + * @param locator Additional locator to match. + */ + and(locator: Locator): Locator; + /** * Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur) on the element. * @param options diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 0d54019c7d..6901ca15c5 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -75,6 +75,11 @@ it('should support playwright.locator({ hasNot })', async ({ page }) => { expect(await page.evaluate(`playwright.locator('div', { hasNot: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('Hi'); }); +it('should support locator.and()', async ({ page }) => { + await page.setContent('
Hi
'); + expect(await page.evaluate(`playwright.locator('div').and(playwright.getByTestId('Hey')).elements.map(e => e.innerHTML)`)).toEqual(['Hi']); +}); + it('should support locator.or()', async ({ page }) => { await page.setContent('
Hi
Hello'); expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 6a403b0791..d322cb91e1 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -383,6 +383,13 @@ it.describe(() => { }); }); +it('asLocator internal:and', async () => { + expect.soft(asLocator('javascript', 'div >> internal:and="span >> article"', false)).toBe(`locator('div').and(locator('span').locator('article'))`); + expect.soft(asLocator('python', 'div >> internal:and="span >> article"', false)).toBe(`locator("div").and_(locator("span").locator("article"))`); + expect.soft(asLocator('java', 'div >> internal:and="span >> article"', false)).toBe(`locator("div").and(locator("span").locator("article"))`); + expect.soft(asLocator('csharp', 'div >> internal:and="span >> article"', false)).toBe(`Locator("div").And(Locator("span").Locator("article"))`); +}); + it('asLocator internal:or', async () => { expect.soft(asLocator('javascript', 'div >> internal:or="span >> article"', false)).toBe(`locator('div').or(locator('span').locator('article'))`); expect.soft(asLocator('python', 'div >> internal:or="span >> article"', false)).toBe(`locator("div").or_(locator("span").locator("article"))`); diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index 93a1967f8d..2e266931ab 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -164,6 +164,19 @@ it('should support locator.filter', async ({ page, trace }) => { await expect(page.locator(`div`).filter({ hasNotText: 'foo' })).toHaveCount(2); }); +it('should support locator.and', async ({ page }) => { + await page.setContent(` +
hello
world
+ hello2world2 + `); + await expect(page.locator('div').and(page.locator('div'))).toHaveCount(2); + await expect(page.locator('div').and(page.getByTestId('foo'))).toHaveText(['hello']); + await expect(page.locator('div').and(page.getByTestId('bar'))).toHaveText(['world']); + await expect(page.getByTestId('foo').and(page.locator('div'))).toHaveText(['hello']); + await expect(page.getByTestId('bar').and(page.locator('span'))).toHaveText(['world2']); + await expect(page.locator('span').and(page.getByTestId(/bar|foo/))).toHaveCount(2); +}); + it('should support locator.or', async ({ page }) => { await page.setContent(`
hello
world`); await expect(page.locator('div').or(page.locator('span'))).toHaveCount(2); diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 461c30a62d..cb19f149c0 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -409,6 +409,19 @@ it('should work with internal:has-not=', async ({ page }) => { expect(await page.$$eval(`section >> internal:has-not="article"`, els => els.length)).toBe(2); }); +it('should work with internal:and=', async ({ page, server }) => { + await page.setContent(` +
hello
world
+ hello2world2 + `); + expect(await page.$$eval(`div >> internal:and="span"`, els => els.map(e => e.textContent))).toEqual([]); + expect(await page.$$eval(`div >> internal:and=".foo"`, els => els.map(e => e.textContent))).toEqual(['hello']); + expect(await page.$$eval(`div >> internal:and=".bar"`, els => els.map(e => e.textContent))).toEqual(['world']); + expect(await page.$$eval(`span >> internal:and="span"`, els => els.map(e => e.textContent))).toEqual(['hello2', 'world2']); + expect(await page.$$eval(`.foo >> internal:and="div"`, els => els.map(e => e.textContent))).toEqual(['hello']); + expect(await page.$$eval(`.bar >> internal:and="span"`, els => els.map(e => e.textContent))).toEqual(['world2']); +}); + it('should work with internal:or=', async ({ page, server }) => { await page.setContent(`
hello