feat(selectors): nth-match selector (#5081)

Introduces :nth-match(ul > li, 3) css extension, with one-based index.
This commit is contained in:
Dmitry Gozman 2021-01-21 16:39:49 -08:00 committed by GitHub
parent 8f06761ba1
commit 7a4b94e66c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 111 additions and 2 deletions

View File

@ -284,6 +284,59 @@ converts `'//html/body'` to `'xpath=//html/body'`.
Attribute engines are selecting based on the corresponding attribute value. For example: `data-test-id=foo` is equivalent to `css=[data-test-id="foo"]`, and `id:light=foo` is equivalent to `css:light=[id="foo"]`. Attribute engines are selecting based on the corresponding attribute value. For example: `data-test-id=foo` is equivalent to `css=[data-test-id="foo"]`, and `id:light=foo` is equivalent to `css:light=[id="foo"]`.
## Pick n-th match from the query result
Sometimes page contains a number of similar elements, and it is hard to select a particular one. For example:
```html
<section> <button>Buy</button> </section>
<article><div> <button>Buy</button> </div></article>
<div><div> <button>Buy</button> </div></div>
```
In this case, `:nth-match(:text("Buy"), 3)` will select the third button from the snippet above. Note that index is one-based.
```js
// Click the third "Buy" button
await page.click(':nth-match(:text("Buy"), 3)');
```
```python async
# Click the third "Buy" button
await page.click(":nth-match(:text('Buy'), 3)"
```
```python sync
# Click the third "Buy" button
page.click(":nth-match(:text('Buy'), 3)"
```
`:nth-match()` is also useful to wait until a specified number of elements appear, using [`method: Page.waitForSelector`].
```js
// Wait until all three buttons are visible
await page.waitForSelector(':nth-match(:text("Buy"), 3)');
```
```python async
# Wait until all three buttons are visible
await page.wait_for_selector(":nth-match(:text('Buy'), 3)")
```
```python sync
# Wait until all three buttons are visible
page.wait_for_selector(":nth-match(:text('Buy'), 3)")
```
:::note
Unlike [`:nth-child()`](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child), elements do not have to be siblings, they could be anywhere on the page. In the snippet above, all three buttons match `:text("Buy")` selector, and `:nth-match()` selects the third button.
:::
:::note
It is usually possible to distinguish elements by some attribute or text content. In this case,
prefer using [text] or [css] selectors over the `:nth-match()`.
:::
## Chaining selectors ## Chaining selectors
Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, e.g. `selector1 >> selector2 >> selectors3`. When selectors are chained, next one is queried relative to the previous one's result. Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, e.g. `selector1 >> selector2 >> selectors3`. When selectors are chained, next one is queried relative to the previous one's result.

View File

@ -26,7 +26,7 @@ export type ParsedSelector = {
capture?: number, capture?: number,
}; };
const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near']); export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
export function parseSelector(selector: string): ParsedSelector { export function parseSelector(selector: string): ParsedSelector {
const result = parseSelectorV1(selector); const result = parseSelectorV1(selector);

View File

@ -15,6 +15,7 @@
*/ */
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser'; import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
import { customCSSNames } from '../common/selectorParser';
export type QueryContext = { export type QueryContext = {
scope: Element | Document; scope: Element | Document;
@ -45,7 +46,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
private _scoreMap: Map<Element, number> | undefined; private _scoreMap: Map<Element, number> | undefined;
constructor(extraEngines: Map<string, SelectorEngine>) { constructor(extraEngines: Map<string, SelectorEngine>) {
// Note: keep predefined names in sync with Selectors class.
for (const [name, engine] of extraEngines) for (const [name, engine] of extraEngines)
this._engines.set(name, engine); this._engines.set(name, engine);
this._engines.set('not', notEngine); this._engines.set('not', notEngine);
@ -63,6 +63,14 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
this._engines.set('above', createPositionEngine('above', boxAbove)); this._engines.set('above', createPositionEngine('above', boxAbove));
this._engines.set('below', createPositionEngine('below', boxBelow)); this._engines.set('below', createPositionEngine('below', boxBelow));
this._engines.set('near', createPositionEngine('near', boxNear)); this._engines.set('near', createPositionEngine('near', boxNear));
this._engines.set('nth-match', nthMatchEngine);
const allNames = Array.from(this._engines.keys());
allNames.sort();
const parserNames = Array.from(customCSSNames).slice();
parserNames.sort();
if (allNames.join('|') !== parserNames.join('|'))
throw new Error(`Please keep customCSSNames in sync with evaluator engines`);
} }
// This is the only function we should use for querying, because it does // This is the only function we should use for querying, because it does
@ -513,6 +521,19 @@ function createPositionEngine(name: string, scorer: (box1: DOMRect, box2: DOMRec
}; };
} }
const nthMatchEngine: SelectorEngine = {
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
let index = args[args.length - 1];
if (args.length < 2)
throw new Error(`"nth-match" engine expects non-empty selector list and an index argument`);
if (typeof index !== 'number' || index < 1)
throw new Error(`"nth-match" engine expects a one-based index as the last argument`);
const elements = isEngine.query!(context, args.slice(0, args.length - 1), evaluator);
index--; // one-based
return index < elements.length ? [elements[index]] : [];
},
};
export function parentElementOrShadowHost(element: Element): Element | undefined { export function parentElementOrShadowHost(element: Element): Element | undefined {
if (element.parentElement) if (element.parentElement)
return element.parentElement; return element.parentElement;

View File

@ -47,6 +47,41 @@ it('should work with :visible', async ({page}) => {
expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); expect(await page.$eval('div:visible', div => div.id)).toBe('target2');
}); });
it('should work with :nth-match', async ({page}) => {
await page.setContent(`
<section>
<div id=target1></div>
<div id=target2></div>
</section>
`);
expect(await page.$(':nth-match(div, 3)')).toBe(null);
expect(await page.$eval(':nth-match(div, 1)', e => e.id)).toBe('target1');
expect(await page.$eval(':nth-match(div, 2)', e => e.id)).toBe('target2');
expect(await page.$eval(':nth-match(section > div, 2)', e => e.id)).toBe('target2');
expect(await page.$eval(':nth-match(section, div, 2)', e => e.id)).toBe('target1');
expect(await page.$eval(':nth-match(div, section, 3)', e => e.id)).toBe('target2');
expect(await page.$$eval(':is(:nth-match(div, 1), :nth-match(div, 2))', els => els.length)).toBe(2);
let error;
error = await page.$(':nth-match(div, bar, 0)').catch(e => e);
expect(error.message).toContain(`"nth-match" engine expects a one-based index as the last argument`);
error = await page.$(':nth-match(2)').catch(e => e);
expect(error.message).toContain(`"nth-match" engine expects non-empty selector list and an index argument`);
error = await page.$(':nth-match(div, bar, foo)').catch(e => e);
expect(error.message).toContain(`"nth-match" engine expects a one-based index as the last argument`);
const promise = page.waitForSelector(`:nth-match(div, 3)`, { state: 'attached' });
await page.$eval('section', section => {
const div = document.createElement('div');
div.setAttribute('id', 'target3');
section.appendChild(div);
});
const element = await promise;
expect(await element.evaluate(e => e.id)).toBe('target3');
});
it('should work with position selectors', async ({page}) => { it('should work with position selectors', async ({page}) => {
/* /*