From 1d4e2fe98c4b23fdfb960b7f059dc9e369a3f565 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 11 Aug 2021 11:06:09 -0700 Subject: [PATCH] feat(nth): make nth and visible selectors public (#8142) --- docs/src/selectors.md | 68 ++++++++++++++++--- src/client/locator.ts | 6 +- src/server/injected/injectedScript.ts | 13 ++-- src/server/selectors.ts | 2 +- .../supplements/injected/selectorGenerator.ts | 2 +- tests/page/locator-query.spec.ts | 2 +- tests/page/selectors-misc.spec.ts | 44 ++++++++++++ 7 files changed, 115 insertions(+), 22 deletions(-) diff --git a/docs/src/selectors.md b/docs/src/selectors.md index 50eb299360..c76f13d544 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -407,10 +407,15 @@ await page.ClickAsync("button"); ## Selecting visible elements -The `:visible` pseudo-class in CSS selectors matches the elements that are -[visible](./actionability.md#visible). For example, `input` matches all the inputs on the page, while -`input:visible` matches only visible inputs. This is useful to distinguish elements that are very -similar but differ in visibility. +There are two ways of selecting only [visible](./actionability.md#visible) elements with Playwright: +- `:visible` pseudo-class in CSS selectors +- `visible=` selector engine + +If you prefer your selectors to be CSS and don't want to rely on [chaining selectors](#chaining-selectors), use `:visible` pseudo class like so: `input:visible`. If you prefer combining selector engines, use `input >> visible=true`. The latter allows you combining `text=`, `xpath=` and other selector engines with the visibility filter. + +For example, `input` matches all the inputs on the page, while +`input:visible` and `input >> visible=true` only match visible inputs. This is useful to distinguish elements +that are very similar but differ in visibility. :::note It's usually better to follow the [best practices](#best-practices) and find a more reliable way to @@ -446,28 +451,29 @@ Consider a page with two buttons, first invisible and second visible. await page.ClickAsync("button"); ``` -* This will find a second button, because it is visible, and then click it. +* These will find a second button, because it is visible, and then click it. ```js await page.click('button:visible'); + await page.click('button >> visible=true'); ``` ```java page.click("button:visible"); + page.click("button >> visible=true"); ``` ```python async await page.click("button:visible") + await page.click("button >> visible=true") ``` ```python sync page.click("button:visible") + page.click("button >> visible=true") ``` ```csharp await page.ClickAsync("button:visible"); + await page.ClickAsync("button >> visible=true"); ``` -Use `:visible` with caution, because it has two major drawbacks: -* When elements change their visibility dynamically, `:visible` will give unpredictable results based on the timing. -* `:visible` forces a layout and may lead to querying being slow, especially when used with `page.waitForSelector(selector[, options])` method. - ## Selecting elements that contain other elements The `:has()` pseudo-class is an [experimental CSS pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/:has). It returns an element if any of the selectors passed as parameters @@ -661,6 +667,50 @@ converts `'//html/body'` to `'xpath=//html/body'`. `xpath` does not pierce shadow roots ::: +## N-th element selector + +You can narrow down query to the n-th match using the `nth=` selector. Unlike CSS's nth-match, provided index is 0-based. + +```js +// Click first button +await page.click('button >> nth=0'); + +// Click last button +await page.click('button >> nth=-1'); +``` + +```java +// Click first button +page.click("button >> nth=0"); + +// Click last button +page.click("button >> nth=-1"); +``` + +```python async +# Click first button +await page.click("button >> nth=0") + +# Click last button +await page.click("button >> nth=-1") +``` + +```python sync +# Click first button +page.click("button >> nth=0") + +# Click last button +page.click("button >> nth=-1") +``` + +```csharp +// Click first button +await page.ClickAsync("button >> nth=0"); + +// Click last button +await page.ClickAsync("button >> nth=-1"); +``` + ## React selectors :::note diff --git a/src/client/locator.ts b/src/client/locator.ts index 5d81708c87..5bb7db78af 100644 --- a/src/client/locator.ts +++ b/src/client/locator.ts @@ -94,15 +94,15 @@ export class Locator implements api.Locator { } first(): Locator { - return new Locator(this._frame, this._selector + ' >> _nth=first'); + return new Locator(this._frame, this._selector + ' >> nth=0'); } last(): Locator { - return new Locator(this._frame, this._selector + ` >> _nth=last`); + return new Locator(this._frame, this._selector + ` >> nth=-1`); } nth(index: number): Locator { - return new Locator(this._frame, this._selector + ` >> _nth=${index}`); + return new Locator(this._frame, this._selector + ` >> nth=${index}`); } async focus(options?: TimeoutOptions): Promise { diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index 53ec8e6f4d..ec2c0e6924 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -77,9 +77,8 @@ export class InjectedScript { this._engines.set('data-test', this._createAttributeEngine('data-test', true)); this._engines.set('data-test:light', this._createAttributeEngine('data-test', false)); this._engines.set('css', this._createCSSEngine()); - this._engines.set('_first', { queryAll: () => [] }); - this._engines.set('_visible', { queryAll: () => [] }); - this._engines.set('_nth', { queryAll: () => [] }); + this._engines.set('nth', { queryAll: () => [] }); + this._engines.set('visible', { queryAll: () => [] }); for (const { name, engine } of customEngines) this._engines.set(name, engine); @@ -116,11 +115,11 @@ export class InjectedScript { return roots; const part = selector.parts[index]; - if (part.name === '_nth') { + if (part.name === 'nth') { let filtered: ElementMatch[] = []; - if (part.body === 'first') { + if (part.body === '0') { filtered = roots.slice(0, 1); - } else if (part.body === 'last') { + } else if (part.body === '-1') { if (roots.length) filtered = roots.slice(roots.length - 1); } else { @@ -137,7 +136,7 @@ export class InjectedScript { return this._querySelectorRecursively(filtered, selector, index + 1, queryCache); } - if (part.name === '_visible') { + if (part.name === 'visible') { const visible = Boolean(part.body); return roots.filter(match => visible === isVisible(match.element)); } diff --git a/src/server/selectors.ts b/src/server/selectors.ts index 5ff5a50296..4dd5e6aaa5 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -45,7 +45,7 @@ export class Selectors { 'data-testid', 'data-testid:light', 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', - '_visible', '_nth' + 'nth', 'visible' ]); this._builtinEnginesInMainWorld = new Set([ '_react', '_vue', diff --git a/src/server/supplements/injected/selectorGenerator.ts b/src/server/supplements/injected/selectorGenerator.ts index d7fcd8696d..6fe7eccb01 100644 --- a/src/server/supplements/injected/selectorGenerator.ts +++ b/src/server/supplements/injected/selectorGenerator.ts @@ -282,7 +282,7 @@ function escapeForRegex(text: string): string { } function quoteString(text: string): string { - return `"${text.replaceAll(/"/g, '\\"').replaceAll(/\n/g, '\\n')}"`; + return `"${text.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; } function joinTokens(tokens: SelectorToken[]): string { diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index cd4a1357a1..8df66d652e 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -44,7 +44,7 @@ it('should respect nth()', async ({page}) => { it('should throw on capture w/ nth()', async ({page}) => { await page.setContent(`

A

`); - const e = await page.locator('*css=div >> p').nth(0).click().catch(e => e); + const e = await page.locator('*css=div >> p').nth(1).click().catch(e => e); expect(e.message).toContain(`Can't query n-th element`); }); diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 808c834bab..15178f005e 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -58,6 +58,26 @@ it('should work with :visible', async ({page}) => { expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); }); +it('should work with >> visible=', async ({page}) => { + await page.setContent(` +
+
+
+
+ `); + expect(await page.$('div >> visible=true')).toBe(null); + + const error = await page.waitForSelector(`div >> visible=true`, { timeout: 100 }).catch(e => e); + expect(error.message).toContain('100ms'); + + const promise = page.waitForSelector(`div >> visible=true`, { state: 'attached' }); + await page.$eval('#target2', div => div.textContent = 'Now visible'); + const element = await promise; + expect(await element.evaluate(e => e.id)).toBe('target2'); + + expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2'); +}); + it('should work with :nth-match', async ({page}) => { await page.setContent(`
@@ -93,6 +113,30 @@ it('should work with :nth-match', async ({page}) => { expect(await element.evaluate(e => e.id)).toBe('target3'); }); +it('should work with nth=', async ({page}) => { + await page.setContent(` +
+
+
+
+ `); + expect(await page.$('div >> nth=2')).toBe(null); + expect(await page.$eval('div >> nth=0', e => e.id)).toBe('target1'); + expect(await page.$eval('div >> nth=1', e => e.id)).toBe('target2'); + expect(await page.$eval('section > div >> nth=1', e => e.id)).toBe('target2'); + expect(await page.$eval('section, div >> nth=1', e => e.id)).toBe('target1'); + expect(await page.$eval('div, section >> nth=2', e => e.id)).toBe('target2'); + + const promise = page.waitForSelector(`div >> nth=2`, { 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}) => { /*