feat: Locator.filter(locator) (#21975)

Produces a locator that matches both locators.
Implemented through `internal:and` selector.

Fixes #19551.
This commit is contained in:
Dmitry Gozman 2023-03-27 14:29:30 -07:00 committed by GitHub
parent 47e5c02a21
commit 525097d465
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 175 additions and 40 deletions

View file

@ -888,7 +888,7 @@ Value to set for the `<input>`, `<textarea>` or `[contenteditable]` element.
### option: Locator.fill.timeout = %%-input-timeout-js-%% ### option: Locator.fill.timeout = %%-input-timeout-js-%%
* since: v1.14 * since: v1.14
## method: Locator.filter ## method: Locator.filter#1
* since: v1.22 * since: v1.22
- returns: <[Locator]> - returns: <[Locator]>
@ -946,9 +946,47 @@ await rowLocator
.ScreenshotAsync(); .ScreenshotAsync();
``` ```
### option: Locator.filter.-inline- = %%-locator-options-list-v1.14-%% ### option: Locator.filter#1.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.22 * 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 ## method: Locator.first
* since: v1.14 * since: v1.14
- returns: <[Locator]> - returns: <[Locator]>

View file

@ -1233,7 +1233,7 @@ Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-sele
## template-locator-locator ## 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). [Learn more about locators](../locators.md).
@ -1293,7 +1293,7 @@ use: {
Allows locating elements that contain given text. 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** **Usage**

View file

@ -806,7 +806,7 @@ Consider the following DOM structure where we want to click on the buy button of
### Filter by text ### 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 ```js
await page await page
@ -883,7 +883,7 @@ await page
.ClickAsync(); .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. 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.
@ -985,6 +985,30 @@ await Expect(page
Note that the inner locator is matched starting from the outer one, not from the document root. 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 ## 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. 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.
@ -1198,7 +1222,7 @@ await page.GetByText("orange").ClickAsync();
``` ```
#### Filter by text #### 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: For example, consider the following DOM structure:
@ -1303,7 +1327,7 @@ However, use this method with caution. Often times, the page might change, and t
### Chaining filters ### 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: For example, consider the following DOM structure:

View file

@ -967,7 +967,7 @@ If a selector needs to include `>>` in the body, it should be escaped inside a s
### Intermediate matches ### Intermediate matches
:::warning :::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. 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.

View file

@ -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). 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 ```csharp
var buttons = page.Locator("role=button"); var buttons = page.Locator("role=button");

View file

@ -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). 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 ```java
Locator buttonsLocator = page.locator("role=button"); Locator buttonsLocator = page.locator("role=button");

View file

@ -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). 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 ```js
const buttons = page.locator('role=button'); const buttons = page.locator('role=button');

View file

@ -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). 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 ```py
buttons = page.locator("role=button") buttons = page.locator("role=button")

View file

@ -168,8 +168,15 @@ export class Locator implements api.Locator {
return new FrameLocator(this._frame, this._selector + ' >> ' + selector); return new FrameLocator(this._frame, this._selector + ' >> ' + selector);
} }
filter(options?: LocatorOptions): Locator { filter(options?: LocatorOptions): Locator;
return new Locator(this._frame, this._selector, options); 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>> { async elementHandle(options?: TimeoutOptions): Promise<ElementHandle<SVGElement | HTMLElement>> {

View file

@ -53,7 +53,11 @@ class Locator {
self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options)); 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.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.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.first = (): Locator => self.locator('nth=0');
self.last = (): Locator => self.locator('nth=-1'); self.last = (): Locator => self.locator('nth=-1');
self.nth = (index: number): Locator => self.locator(`nth=${index}`); self.nth = (index: number): Locator => self.locator(`nth=${index}`);

View file

@ -114,6 +114,7 @@ export class InjectedScript {
this._engines.set('internal:control', this._createControlEngine()); this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine()); this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:or', { queryAll: () => [] });
this._engines.set('internal:and', { queryAll: () => [] });
this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:label', this._createInternalLabelEngine());
this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:has-text', this._createInternalHasTextEngine()); this._engines.set('internal:has-text', this._createInternalHasTextEngine());
@ -170,14 +171,6 @@ export class InjectedScript {
return new Set<Element>(list.slice(nth, nth + 1)); 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> { private _queryLayoutSelector(elements: Set<Element>, part: ParsedSelectorPart, originalRoot: Node): Set<Element> {
const name = part.name as LayoutSelectorName; const name = part.name as LayoutSelectorName;
const body = part.body as NestedSelectorBody; const body = part.body as NestedSelectorBody;
@ -222,6 +215,9 @@ export class InjectedScript {
} else if (part.name === 'internal:or') { } else if (part.name === 'internal:or') {
const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root); const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root);
roots = new Set(sortInDOMOrder(new Set([...roots, ...orElements]))); 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)) { } else if (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) {
roots = this._queryLayoutSelector(roots, part, root); roots = this._queryLayoutSelector(roots, part, root);
} else { } else {

View file

@ -35,7 +35,7 @@ export class Selectors {
'data-testid', 'data-testid:light', 'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light', 'data-test-id', 'data-test-id:light',
'data-test', 'data-test: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', 'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid',
]); ]);
this._builtinEnginesInMainWorld = new Set([ this._builtinEnginesInMainWorld = new Set([

View file

@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi
import type { ParsedSelector } from './selectorParser'; import type { ParsedSelector } from './selectorParser';
export type Language = 'javascript' | 'python' | 'java' | 'csharp'; 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'; export type LocatorBase = 'page' | 'locator' | 'frame-locator';
type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp };
@ -91,6 +91,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
tokens.push(factory.generateLocator(base, 'or', inner)); tokens.push(factory.generateLocator(base, 'or', inner));
continue; 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') { if (part.name === 'internal:label') {
const { exact, text } = detectExact(part.body as string); const { exact, text } = detectExact(part.body as string);
tokens.push(factory.generateLocator(base, 'label', text, { exact })); tokens.push(factory.generateLocator(base, 'label', text, { exact }));
@ -202,6 +207,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `filter({ has: ${body} })`; return `filter({ has: ${body} })`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id': case 'test-id':
return `getByTestId(${this.quote(body as string)})`; return `getByTestId(${this.quote(body as string)})`;
case 'text': case 'text':
@ -272,6 +279,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `filter(has=${body})`; return `filter(has=${body})`;
case 'or': case 'or':
return `or_(${body})`; return `or_(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id': case 'test-id':
return `get_by_test_id(${this.quote(body as string)})`; return `get_by_test_id(${this.quote(body as string)})`;
case 'text': case 'text':
@ -351,6 +360,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'and':
return `filter(${body})`;
case 'test-id': case 'test-id':
return `getByTestId(${this.quote(body as string)})`; return `getByTestId(${this.quote(body as string)})`;
case 'text': case 'text':
@ -424,6 +435,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `Filter(new() { Has = ${body} })`; return `Filter(new() { Has = ${body} })`;
case 'or': case 'or':
return `Or(${body})`; return `Or(${body})`;
case 'and':
return `Filter(${body})`;
case 'test-id': case 'test-id':
return `GetByTestId(${this.quote(body as string)})`; return `GetByTestId(${this.quote(body as string)})`;
case 'text': case 'text':

View file

@ -102,7 +102,7 @@ function shiftParams(template: string, sub: number) {
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=). // Recursively handle filter(has=).
// TODO: handle or(locator) as well. // TODO: handle or(locator) and filter(locator).
while (true) { while (true) {
const hasMatch = template.match(/filter\(,?has=/); const hasMatch = template.match(/filter\(,?has=/);
if (!hasMatch) if (!hasMatch)

View file

@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser';
export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; 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']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
export type ParsedSelectorPart = { export type ParsedSelectorPart = {

View file

@ -2686,7 +2686,7 @@ export interface Page {
/** /**
* Allows locating elements that contain given text. * Allows locating elements that contain given text.
* *
* See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) that allows to * See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1) that allows to
* match by another criteria, like an accessible role, and then filter by the text content. * match by another criteria, like an accessible role, and then filter by the text content.
* *
* **Usage** * **Usage**
@ -6107,7 +6107,7 @@ export interface Frame {
/** /**
* Allows locating elements that contain given text. * Allows locating elements that contain given text.
* *
* See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) that allows to * See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1) that allows to
* match by another criteria, like an accessible role, and then filter by the text content. * match by another criteria, like an accessible role, and then filter by the text content.
* *
* **Usage** * **Usage**
@ -10785,6 +10785,21 @@ export interface Locator {
hasText?: string|RegExp; hasText?: string|RegExp;
}): Locator; }): 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'));
* ```
*
* @param locator Additional locator to match.
*/
filter(locator: Locator): Locator;
/** /**
* Returns locator to the first matching element. * Returns locator to the first matching element.
*/ */
@ -11065,7 +11080,7 @@ export interface Locator {
/** /**
* Allows locating elements that contain given text. * Allows locating elements that contain given text.
* *
* See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) that allows to * See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1) that allows to
* match by another criteria, like an accessible role, and then filter by the text content. * match by another criteria, like an accessible role, and then filter by the text content.
* *
* **Usage** * **Usage**
@ -11409,7 +11424,7 @@ export interface Locator {
/** /**
* The method finds an element matching the specified selector in the locator's subtree. It also accepts filter * The method finds an element matching the specified selector in the locator's subtree. It also accepts filter
* options, similar to [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) * options, similar to [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1)
* method. * method.
* *
* [Learn more about locators](https://playwright.dev/docs/locators). * [Learn more about locators](https://playwright.dev/docs/locators).
@ -16952,7 +16967,7 @@ export interface FrameLocator {
/** /**
* Allows locating elements that contain given text. * Allows locating elements that contain given text.
* *
* See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) that allows to * See also [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1) that allows to
* match by another criteria, like an accessible role, and then filter by the text content. * match by another criteria, like an accessible role, and then filter by the text content.
* *
* **Usage** * **Usage**
@ -17036,7 +17051,7 @@ export interface FrameLocator {
/** /**
* The method finds an element matching the specified selector in the locator's subtree. It also accepts filter * The method finds an element matching the specified selector in the locator's subtree. It also accepts filter
* options, similar to [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter) * options, similar to [locator.filter([options])](https://playwright.dev/docs/api/class-locator#locator-filter-1)
* method. * method.
* *
* [Learn more about locators](https://playwright.dev/docs/locators). * [Learn more about locators](https://playwright.dev/docs/locators).

View file

@ -67,11 +67,16 @@ it('should support playwright.locator({ has })', async ({ page }) => {
expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('span'); expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('span');
}); });
it('should support playwright.or()', async ({ page }) => { it('should support locator.or()', async ({ page }) => {
await page.setContent('<div>Hi</div><span>Hello</span>'); await page.setContent('<div>Hi</div><span>Hello</span>');
expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']); expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']);
}); });
it('should support locator.filter(locator)', async ({ page }) => {
await page.setContent('<div data-testid=Hey>Hi</div>');
expect(await page.evaluate(`playwright.locator('div').filter(playwright.getByTestId('Hey')).elements.map(e => e.innerHTML)`)).toEqual(['Hi']);
});
it('should support playwright.getBy*', async ({ page }) => { it('should support playwright.getBy*', async ({ page }) => {
await page.setContent('<span>Hello</span><span title="world">World</span>'); await page.setContent('<span>Hello</span><span title="world">World</span>');
expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello'); expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello');

View file

@ -359,6 +359,13 @@ it('asLocator internal:or', async () => {
expect.soft(asLocator('csharp', 'div >> internal:or="span >> article"', false)).toBe(`Locator("div").Or(Locator("span").Locator("article"))`); expect.soft(asLocator('csharp', 'div >> internal:or="span >> article"', false)).toBe(`Locator("div").Or(Locator("span").Locator("article"))`);
}); });
it('asLocator internal:and', async () => {
expect.soft(asLocator('javascript', 'div >> internal:and="span >> article"', false)).toBe(`locator('div').filter(locator('span').locator('article'))`);
expect.soft(asLocator('python', 'div >> internal:and="span >> article"', false)).toBe(`locator("div").filter(locator("span").locator("article"))`);
expect.soft(asLocator('java', 'div >> internal:and="span >> article"', false)).toBe(`locator("div").filter(locator("span").locator("article"))`);
expect.soft(asLocator('csharp', 'div >> internal:and="span >> article"', false)).toBe(`Locator("div").Filter(Locator("span").Locator("article"))`);
});
it('parse locators strictly', () => { it('parse locators strictly', () => {
const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span'; const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span';

View file

@ -171,6 +171,19 @@ it('should support locator.or', async ({ page }) => {
await expect(page.locator('span').or(page.locator('article'))).toHaveText('world'); await expect(page.locator('span').or(page.locator('article'))).toHaveText('world');
}); });
it('should support locator.filter(locator)', async ({ page }) => {
await page.setContent(`
<div data-testid=foo>hello</div><div data-testid=bar>world</div>
<span data-testid=foo>hello2</span><span data-testid=bar>world2</span>
`);
await expect(page.locator('div').filter(page.locator('div'))).toHaveCount(2);
await expect(page.locator('div').filter(page.getByTestId('foo'))).toHaveText(['hello']);
await expect(page.locator('div').filter(page.getByTestId('bar'))).toHaveText(['world']);
await expect(page.getByTestId('foo').filter(page.locator('div'))).toHaveText(['hello']);
await expect(page.getByTestId('bar').filter(page.locator('span'))).toHaveText(['world2']);
await expect(page.locator('span').filter(page.getByTestId(/bar|foo/))).toHaveCount(2);
});
it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => { it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => {
await page.goto(server.PREFIX + '/frames/two-frames.html'); await page.goto(server.PREFIX + '/frames/two-frames.html');
const child = page.frames()[1]; const child = page.frames()[1];

View file

@ -414,6 +414,19 @@ it('should work with internal:or=', async ({ page, server }) => {
expect(await page.locator(`span >> internal:or="article"`).textContent()).toBe('world'); expect(await page.locator(`span >> internal:or="article"`).textContent()).toBe('world');
}); });
it('should work with internal:and=', async ({ page, server }) => {
await page.setContent(`
<div class=foo>hello</div><div class=bar>world</div>
<span class=foo>hello2</span><span class=bar>world2</span>
`);
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('chaining should work with large DOM @smoke', async ({ page, server }) => { it('chaining should work with large DOM @smoke', async ({ page, server }) => {
await page.evaluate(() => { await page.evaluate(() => {
let last = document.body; let last = document.body;

View file

@ -35,14 +35,14 @@ module.exports = function lint(documentation, jsSources, apiFileName) {
continue; continue;
} }
for (const [methodName, params] of methods) { for (const [methodName, params] of methods) {
const member = docClass.membersArray.find(m => m.alias === methodName && m.kind !== 'event'); const members = docClass.membersArray.filter(m => m.alias === methodName && m.kind !== 'event');
if (!member) { if (!members.length) {
errors.push(`Missing documentation for "${className}.${methodName}"`); errors.push(`Missing documentation for "${className}.${methodName}"`);
continue; continue;
} }
const memberParams = paramsForMember(member);
for (const paramName of params) { for (const paramName of params) {
if (!memberParams.has(paramName)) const found = members.some(member => paramsForMember(member).has(paramName));
if (!found)
errors.push(`Missing documentation for "${className}.${methodName}.${paramName}"`); errors.push(`Missing documentation for "${className}.${methodName}.${paramName}"`);
} }
} }