Skip to content

Commit

Permalink
feat: Locator.filter(locator) (#21975)
Browse files Browse the repository at this point in the history
Produces a locator that matches both locators.
Implemented through `internal:and` selector.

Fixes #19551.
  • Loading branch information
dgozman committed Mar 27, 2023
1 parent 47e5c02 commit 525097d
Show file tree
Hide file tree
Showing 21 changed files with 175 additions and 40 deletions.
42 changes: 40 additions & 2 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ Value to set for the `<input>`, `<textarea>` or `[contenteditable]` element.
### option: Locator.fill.timeout = %%-input-timeout-js-%%
* since: v1.14

## method: Locator.filter
## method: Locator.filter#1
* since: v1.22
- returns: <[Locator]>

Expand Down Expand Up @@ -946,9 +946,47 @@ await rowLocator
.ScreenshotAsync();
```

### option: Locator.filter.-inline- = %%-locator-options-list-v1.14-%%
### option: Locator.filter#1.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.22


## method: Locator.filter#2
* since: v1.33
- 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').filter(page.getByTitle('Subscribe'));
```

```java
Locator button = page.getByRole(AriaRole.BUTTON).filter(page.getByTitle("Subscribe"));
```

```python async
button = page.get_by_role("button").filter(page.getByTitle("Subscribe"))
```

```python sync
button = page.get_by_role("button").filter(page.getByTitle("Subscribe"))
```

```csharp
var button = page.GetByRole(AriaRole.Button).Filter(page.GetByTitle("Subscribe"));
```

### param: Locator.filter#2.locator
* since: v1.33
- `locator` <[Locator]>

Additional locator to match.


## method: Locator.first
* since: v1.14
- returns: <[Locator]>
Expand Down
4 changes: 2 additions & 2 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,7 @@ Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-sele

## template-locator-locator

The method finds an element matching the specified selector in the locator's subtree. It also accepts filter options, similar to [`method: Locator.filter`] method.
The method finds an element matching the specified selector in the locator's subtree. It also accepts filter options, similar to [`method: Locator.filter#1`] method.

[Learn more about locators](../locators.md).

Expand Down Expand Up @@ -1293,7 +1293,7 @@ use: {

Allows locating elements that contain given text.

See also [`method: Locator.filter`] that allows to match by another criteria, like an accessible role, and then filter by the text content.
See also [`method: Locator.filter#1`] that allows to match by another criteria, like an accessible role, and then filter by the text content.


**Usage**
Expand Down
32 changes: 28 additions & 4 deletions docs/src/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ Consider the following DOM structure where we want to click on the buy button of

### Filter by text

Locators can be filtered by text with the [`method: Locator.filter`] method. It will search for a particular string somewhere inside the element, possibly in a descendant element, case-insensitively. You can also pass a regular expression.
Locators can be filtered by text with the [`method: Locator.filter#1`] method. It will search for a particular string somewhere inside the element, possibly in a descendant element, case-insensitively. You can also pass a regular expression.

```js
await page
Expand Down Expand Up @@ -883,7 +883,7 @@ await page
.ClickAsync();
```

### Filter by another locator
### Filter by child/descendant

Locators support an option to only select elements that have a descendant matching another locator. You can therefore filter by any other locator such as a [`method: Locator.getByRole`], [`method: Locator.getByTestId`], [`method: Locator.getByText`] etc.

Expand Down Expand Up @@ -985,6 +985,30 @@ 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.filter#2`] 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').filter(page.getByTitle('Subscribe'));
```

```java
Locator button = page.getByRole(AriaRole.BUTTON).filter(page.getByTitle("Subscribe"));
```

```python async
button = page.get_by_role("button").filter(page.getByTitle("Subscribe"))
```

```python sync
button = page.get_by_role("button").filter(page.getByTitle("Subscribe"))
```

```csharp
var button = page.GetByRole(AriaRole.Button).Filter(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.
Expand Down Expand Up @@ -1198,7 +1222,7 @@ await page.GetByText("orange").ClickAsync();
```

#### Filter by text
Use the [`method: Locator.filter`] to locate a specific item in a list.
Use the [`method: Locator.filter#1`] to locate a specific item in a list.

For example, consider the following DOM structure:

Expand Down Expand Up @@ -1303,7 +1327,7 @@ However, use this method with caution. Often times, the page might change, and t

### Chaining filters

When you have elements with various similarities, you can use the [`method: Locator.filter`] method to select the right one. You can also chain multiple filters to narrow down the selection.
When you have elements with various similarities, you can use the [`method: Locator.filter#1`] method to select the right one. You can also chain multiple filters to narrow down the selection.

For example, consider the following DOM structure:

Expand Down
2 changes: 1 addition & 1 deletion docs/src/other-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ If a selector needs to include `>>` in the body, it should be escaped inside a s
### Intermediate matches

:::warning
We recommend [filtering by another locator](./locators.md#filter-by-another-locator) to locate elements that contain other elements.
We recommend [filtering by another locator](./locators.md#filter-by-childdescendant) to locate elements that contain other elements.
:::

By default, chained selectors resolve to an element queried by the last selector. A selector can be prefixed with `*` to capture elements that are queried by an intermediate selector.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/release-notes-csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ Note that the new methods [`method: Page.routeFromHAR`] and [`method: BrowserCon

Read more in [our documentation](./locators.md#locate-by-role).

- New [`method: Locator.filter`] API to filter an existing locator
- New [`method: Locator.filter#1`] API to filter an existing locator

```csharp
var buttons = page.Locator("role=button");
Expand Down
2 changes: 1 addition & 1 deletion docs/src/release-notes-java.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ Note that the new methods [`method: Page.routeFromHAR`] and [`method: BrowserCon

Read more in [our documentation](./locators.md#locate-by-role).

- New [`method: Locator.filter`] API to filter an existing locator
- New [`method: Locator.filter#1`] API to filter an existing locator

```java
Locator buttonsLocator = page.locator("role=button");
Expand Down
4 changes: 2 additions & 2 deletions docs/src/release-notes-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Note: **component tests only**, does not affect end-to-end tests.

### Browser Versions

* Chromium 112.0.5615.29
* Chromium 112.0.5615.29
* Mozilla Firefox 111.0
* WebKit 16.4

Expand Down Expand Up @@ -786,7 +786,7 @@ WebServer is now considered "ready" if request to the specified port has any of

Read more in [our documentation](./locators.md#locate-by-role).

- New [`method: Locator.filter`] API to filter an existing locator
- New [`method: Locator.filter#1`] API to filter an existing locator

```js
const buttons = page.locator('role=button');
Expand Down
2 changes: 1 addition & 1 deletion docs/src/release-notes-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ Note that the new methods [`method: Page.routeFromHAR`] and [`method: BrowserCon

Read more in [our documentation](./locators.md#locate-by-role).

- New [`method: Locator.filter`] API to filter an existing locator
- New [`method: Locator.filter#1`] API to filter an existing locator

```py
buttons = page.locator("role=button")
Expand Down
11 changes: 9 additions & 2 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,15 @@ export class Locator implements api.Locator {
return new FrameLocator(this._frame, this._selector + ' >> ' + selector);
}

filter(options?: LocatorOptions): Locator {
return new Locator(this._frame, this._selector, options);
filter(options?: LocatorOptions): Locator;
filter(locator: Locator): Locator;
filter(optionsOrLocator?: LocatorOptions | Locator): Locator {
if (optionsOrLocator instanceof Locator) {
if (optionsOrLocator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);
return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(optionsOrLocator._selector));
}
return new Locator(this._frame, this._selector, optionsOrLocator);
}

async elementHandle(options?: TimeoutOptions): Promise<ElementHandle<SVGElement | HTMLElement>> {
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/server/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ class Locator {
self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options));
self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options));
self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options));
self.filter = (options?: { hasText?: string | RegExp, has?: Locator }): Locator => new Locator(injectedScript, selector, options);
self.filter = (optionsOrLocator?: { hasText?: string | RegExp, has?: Locator } | Locator): Locator => {
if (optionsOrLocator instanceof Locator)
return new Locator(injectedScript, selectorBase + ` >> internal:and=` + JSON.stringify((optionsOrLocator as any)[selectorSymbol]));
return new Locator(injectedScript, selector, optionsOrLocator);
};
self.first = (): Locator => self.locator('nth=0');
self.last = (): Locator => self.locator('nth=-1');
self.nth = (index: number): Locator => self.locator(`nth=${index}`);
Expand Down
12 changes: 4 additions & 8 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export class InjectedScript {
this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:or', { queryAll: () => [] });
this._engines.set('internal:and', { queryAll: () => [] });
this._engines.set('internal:label', this._createInternalLabelEngine());
this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:has-text', this._createInternalHasTextEngine());
Expand Down Expand Up @@ -170,14 +171,6 @@ export class InjectedScript {
return new Set<Element>(list.slice(nth, nth + 1));
}

private _queryOr(elements: Set<Element>, part: ParsedSelectorPart): Set<Element> {
const list = [...elements];
let nth = +part.body;
if (nth === -1)
nth = list.length - 1;
return new Set<Element>(list.slice(nth, nth + 1));
}

private _queryLayoutSelector(elements: Set<Element>, part: ParsedSelectorPart, originalRoot: Node): Set<Element> {
const name = part.name as LayoutSelectorName;
const body = part.body as NestedSelectorBody;
Expand Down Expand Up @@ -222,6 +215,9 @@ export class InjectedScript {
} else if (part.name === 'internal:or') {
const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root);
roots = new Set(sortInDOMOrder(new Set([...roots, ...orElements])));
} 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 (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) {
roots = this._queryLayoutSelector(roots, part, root);
} else {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class Selectors {
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', 'internal:or',
'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', 'internal:or', 'internal:and',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid',
]);
this._builtinEnginesInMainWorld = new Set([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'frame' | 'or';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'frame' | 'or' | 'and';
export type LocatorBase = 'page' | 'locator' | 'frame-locator';

type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp };
Expand Down Expand Up @@ -91,6 +91,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
tokens.push(factory.generateLocator(base, 'or', 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:label') {
const { exact, text } = detectExact(part.body as string);
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
Expand Down Expand Up @@ -202,6 +207,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `filter({ has: ${body} })`;
case 'or':
return `or(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id':
return `getByTestId(${this.quote(body as string)})`;
case 'text':
Expand Down Expand Up @@ -272,6 +279,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `filter(has=${body})`;
case 'or':
return `or_(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id':
return `get_by_test_id(${this.quote(body as string)})`;
case 'text':
Expand Down Expand Up @@ -351,6 +360,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'or':
return `or(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id':
return `getByTestId(${this.quote(body as string)})`;
case 'text':
Expand Down Expand Up @@ -424,6 +435,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `Filter(new() { Has = ${body} })`;
case 'or':
return `Or(${body})`;
case 'and':
return `Filter(${body})`;
case 'test-id':
return `GetByTestId(${this.quote(body as string)})`;
case 'text':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function shiftParams(template: string, sub: number) {

function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=).
// TODO: handle or(locator) as well.
// TODO: handle or(locator) and filter(locator).
while (true) {
const hasMatch = template.match(/filter\(,?has=/);
if (!hasMatch)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:or', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNames = new Set(['internal:has', 'internal:or', 'internal:and', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);

export type ParsedSelectorPart = {
Expand Down

0 comments on commit 525097d

Please sign in to comment.