feat(locators): reland locator.and(locator) (#22850)

Removed in #22223.
Fixes #22585.
This commit is contained in:
Dmitry Gozman 2023-05-05 11:14:01 -07:00 committed by GitHub
parent 42328478ea
commit 160888df99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 141 additions and 4 deletions

View file

@ -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 ## async method: Locator.blur
* since: v1.28 * since: v1.28

View file

@ -1056,6 +1056,25 @@ 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.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 ## 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.

View file

@ -204,6 +204,12 @@ export class Locator implements api.Locator {
return new Locator(this._frame, this._selector + ` >> nth=${index}`); 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 { or(locator: Locator): Locator {
if (locator._frame !== this._frame) if (locator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`); throw new Error(`Locators must belong to the same frame.`);

View file

@ -61,6 +61,7 @@ class Locator {
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}`);
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])); 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.first;
delete this._injectedScript.window.playwright.last; delete this._injectedScript.window.playwright.last;
delete this._injectedScript.window.playwright.nth; delete this._injectedScript.window.playwright.nth;
delete this._injectedScript.window.playwright.and;
delete this._injectedScript.window.playwright.or; delete this._injectedScript.window.playwright.or;
} }

View file

@ -113,6 +113,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:has-not', this._createHasNotEngine()); this._engines.set('internal:has-not', this._createHasNotEngine());
this._engines.set('internal:and', { queryAll: () => [] });
this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:or', { 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));
@ -212,6 +213,9 @@ export class InjectedScript {
for (const part of selector.parts) { for (const part of selector.parts) {
if (part.name === 'nth') { if (part.name === 'nth') {
roots = this._queryNth(roots, part); 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') { } 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])));

View file

@ -38,7 +38,7 @@ export class Selectors {
'nth', 'visible', 'internal:control', 'nth', 'visible', 'internal:control',
'internal:has', 'internal:has-not', 'internal:has', 'internal:has-not',
'internal:has-text', 'internal:has-not-text', 'internal:has-text', 'internal:has-not-text',
'internal:or', 'internal:and', 'internal:or',
'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-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'; 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 };
@ -99,6 +99,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
tokens.push(factory.generateLocator(base, 'hasNot', inner)); tokens.push(factory.generateLocator(base, 'hasNot', 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:or') { if (part.name === 'internal:or') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
tokens.push(factory.generateLocator(base, 'or', inner)); tokens.push(factory.generateLocator(base, 'or', inner));
@ -217,6 +222,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `filter({ has: ${body} })`; return `filter({ has: ${body} })`;
case 'hasNot': case 'hasNot':
return `filter({ hasNot: ${body} })`; return `filter({ hasNot: ${body} })`;
case 'and':
return `and(${body})`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'test-id': case 'test-id':
@ -291,6 +298,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `filter(has=${body})`; return `filter(has=${body})`;
case 'hasNot': case 'hasNot':
return `filter(has_not=${body})`; return `filter(has_not=${body})`;
case 'and':
return `and_(${body})`;
case 'or': case 'or':
return `or_(${body})`; return `or_(${body})`;
case 'test-id': case 'test-id':
@ -374,6 +383,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'hasNot': case 'hasNot':
return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`;
case 'and':
return `and(${body})`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'test-id': case 'test-id':
@ -451,6 +462,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `Filter(new() { Has = ${body} })`; return `Filter(new() { Has = ${body} })`;
case 'hasNot': case 'hasNot':
return `Filter(new() { HasNot = ${body} })`; return `Filter(new() { HasNot = ${body} })`;
case 'and':
return `And(${body})`;
case 'or': case 'or':
return `Or(${body})`; return `Or(${body})`;
case 'test-id': case 'test-id':

View file

@ -80,6 +80,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string {
.replace(/new[\w]+\.[\w]+options\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '')
.replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase())
.replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or". .replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or".
.replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and".
.replace(/:/g, '=') .replace(/:/g, '=')
.replace(/,re\.ignorecase/g, 'i') .replace(/,re\.ignorecase/g, 'i')
.replace(/,pattern.case_insensitive/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 { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=, hasnot=). // Recursively handle filter(has=, hasnot=).
// TODO: handle or(locator). // TODO: handle and(locator), or(locator).
while (true) { while (true) {
const hasMatch = template.match(/filter\(,?(has|hasnot)=/); const hasMatch = template.match(/filter\(,?(has|hasnot)=/);
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: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']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
export type ParsedSelectorPart = { export type ParsedSelectorPart = {

View file

@ -10453,6 +10453,21 @@ export interface Locator {
*/ */
allTextContents(): Promise<Array<string>>; allTextContents(): Promise<Array<string>>;
/**
* 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. * Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur) on the element.
* @param options * @param options

View file

@ -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'); 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('<div data-testid=Hey>Hi</div>');
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 }) => { 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']);

View file

@ -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 () => { 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('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"))`); expect.soft(asLocator('python', 'div >> internal:or="span >> article"', false)).toBe(`locator("div").or_(locator("span").locator("article"))`);

View file

@ -164,6 +164,19 @@ it('should support locator.filter', async ({ page, trace }) => {
await expect(page.locator(`div`).filter({ hasNotText: 'foo' })).toHaveCount(2); await expect(page.locator(`div`).filter({ hasNotText: 'foo' })).toHaveCount(2);
}); });
it('should support locator.and', 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').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 }) => { it('should support locator.or', async ({ page }) => {
await page.setContent(`<div>hello</div><span>world</span>`); await page.setContent(`<div>hello</div><span>world</span>`);
await expect(page.locator('div').or(page.locator('span'))).toHaveCount(2); await expect(page.locator('div').or(page.locator('span'))).toHaveCount(2);

View file

@ -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); 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(`
<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('should work with internal:or=', async ({ page, server }) => { it('should work with internal:or=', async ({ page, server }) => {
await page.setContent(` await page.setContent(`
<div>hello</div> <div>hello</div>