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}) => {
/*