From 7a4b94e66ceb16b576255cd2545f2ebfb4961b14 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 Jan 2021 16:39:49 -0800 Subject: [PATCH] feat(selectors): nth-match selector (#5081) Introduces :nth-match(ul > li, 3) css extension, with one-based index. --- docs/src/selectors.md | 53 ++++++++++++++++++++++++ src/server/common/selectorParser.ts | 2 +- src/server/injected/selectorEvaluator.ts | 23 +++++++++- test/selectors-misc.spec.ts | 35 ++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/docs/src/selectors.md b/docs/src/selectors.md index 89f581e990..f200d12605 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -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"]`. +## 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 +
+
+
+``` + +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 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. diff --git a/src/server/common/selectorParser.ts b/src/server/common/selectorParser.ts index 05c8925c62..28f867ecb3 100644 --- a/src/server/common/selectorParser.ts +++ b/src/server/common/selectorParser.ts @@ -26,7 +26,7 @@ export type ParsedSelector = { 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 { const result = parseSelectorV1(selector); diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index b5c2357030..a61d61ae27 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -15,6 +15,7 @@ */ import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser'; +import { customCSSNames } from '../common/selectorParser'; export type QueryContext = { scope: Element | Document; @@ -45,7 +46,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _scoreMap: Map | undefined; constructor(extraEngines: Map) { - // Note: keep predefined names in sync with Selectors class. for (const [name, engine] of extraEngines) this._engines.set(name, engine); this._engines.set('not', notEngine); @@ -63,6 +63,14 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._engines.set('above', createPositionEngine('above', boxAbove)); this._engines.set('below', createPositionEngine('below', boxBelow)); 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 @@ -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 { if (element.parentElement) return element.parentElement; diff --git a/test/selectors-misc.spec.ts b/test/selectors-misc.spec.ts index a3633acddc..4dc28f007f 100644 --- a/test/selectors-misc.spec.ts +++ b/test/selectors-misc.spec.ts @@ -47,6 +47,41 @@ it('should work with :visible', async ({page}) => { expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); }); +it('should work with :nth-match', async ({page}) => { + await page.setContent(` +
+
+
+
+ `); + 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}) => { /*