From e6148bb725db6648d033ade239f485274849ec63 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 30 Mar 2023 08:52:30 -0700 Subject: [PATCH] feat: Locator.not(locator) (#22066) --- docs/src/api/class-locator.md | 40 +++++++++++++++++++ docs/src/locators.md | 24 +++++++++++ .../playwright-core/src/client/locator.ts | 6 +++ .../src/server/injected/consoleApi.ts | 2 + .../src/server/injected/injectedScript.ts | 4 ++ .../playwright-core/src/server/selectors.ts | 3 +- .../src/utils/isomorphic/locatorGenerators.ts | 15 ++++++- .../src/utils/isomorphic/locatorParser.ts | 5 ++- .../src/utils/isomorphic/selectorParser.ts | 2 +- packages/playwright-core/types/types.d.ts | 15 +++++++ tests/library/inspector/console-api.spec.ts | 5 +++ tests/library/locator-generator.spec.ts | 7 ++++ tests/page/locator-query.spec.ts | 11 +++++ tests/page/selectors-misc.spec.ts | 13 ++++++ 14 files changed, 147 insertions(+), 5 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 0d2b7b677c..d00787a39a 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1501,6 +1501,46 @@ var banana = await page.GetByRole(AriaRole.Listitem).Last(1); ### option: Locator.locator.-inline- = %%-locator-options-list-v1.14-%% * since: v1.14 + +## method: Locator.not +* since: v1.33 +* langs: + - alias-python: not_ +- returns: <[Locator]> + +Creates a locator that **matches this** locator, but **not the argument** locator. + +**Usage** + +The following example finds a button that does not have title `"Subscribe"`. + +```js +const button = page.getByRole('button').not(page.getByTitle('Subscribe')); +``` + +```java +Locator button = page.getByRole(AriaRole.BUTTON).not(page.getByTitle("Subscribe")); +``` + +```python async +button = page.get_by_role("button").not_(page.getByTitle("Subscribe")) +``` + +```python sync +button = page.get_by_role("button").not_(page.getByTitle("Subscribe")) +``` + +```csharp +var button = page.GetByRole(AriaRole.Button).Not(page.GetByTitle("Subscribe")); +``` + +### param: Locator.not.locator +* since: v1.33 +- `locator` <[Locator]> + +Locator that must not match. + + ## method: Locator.nth * since: v1.14 - returns: <[Locator]> diff --git a/docs/src/locators.md b/docs/src/locators.md index 78d8b9b8ac..232a4fe123 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -1009,6 +1009,30 @@ button = page.get_by_role("button").filter(page.getByTitle("Subscribe")) var button = page.GetByRole(AriaRole.Button).Filter(page.GetByTitle("Subscribe")); ``` +### Filter by **not** matching an additional locator + +Method [`method: Locator.not`] narrows down an existing locator by ensuring that target element **does not match** an additional locator. For example, you can combine [`method: Page.getByRole`] and [`method: Page.getByTitle`] to match by role and ensure that title does not match. + +```js +const button = page.getByRole('button').not(page.getByTitle('Subscribe')); +``` + +```java +Locator button = page.getByRole(AriaRole.BUTTON).not(page.getByTitle("Subscribe")); +``` + +```python async +button = page.get_by_role("button").not_(page.getByTitle("Subscribe")) +``` + +```python sync +button = page.get_by_role("button").not_(page.getByTitle("Subscribe")) +``` + +```csharp +var button = page.GetByRole(AriaRole.Button).Not(page.GetByTitle("Subscribe")); +``` + ## 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. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 3f2a5893f4..8b3a5065bb 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -199,6 +199,12 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ` >> nth=${index}`); } + not(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:not=` + JSON.stringify(locator._selector)); + } + or(locator: Locator): Locator { if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 57bb163c8c..312086f898 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -62,6 +62,7 @@ class Locator { self.last = (): Locator => self.locator('nth=-1'); self.nth = (index: number): Locator => self.locator(`nth=${index}`); self.or = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify((locator as any)[selectorSymbol])); + self.not = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:not=` + JSON.stringify((locator as any)[selectorSymbol])); } } @@ -94,6 +95,7 @@ class ConsoleAPI { delete this._injectedScript.window.playwright.last; delete this._injectedScript.window.playwright.nth; delete this._injectedScript.window.playwright.or; + delete this._injectedScript.window.playwright.not; } private _querySelector(selector: string, strict: boolean): (Element | undefined) { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 85d6ffd131..ae3371bb91 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -114,6 +114,7 @@ export class InjectedScript { this._engines.set('internal:has', this._createHasEngine()); this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:and', { queryAll: () => [] }); + this._engines.set('internal:not', { queryAll: () => [] }); this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:has-text', this._createInternalHasTextEngine()); @@ -217,6 +218,9 @@ export class InjectedScript { } 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:not') { + const notElements = new Set(this.querySelectorAll((part.body as NestedSelectorBody).parsed, root)); + roots = new Set([...roots].filter(e => !notElements.has(e))); } else if (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) { roots = this._queryLayoutSelector(roots, part, root); } else { diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index ef27059bf0..37b13b2d9a 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -35,7 +35,8 @@ export class Selectors { 'data-testid', 'data-testid:light', 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', - 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', 'internal:or', 'internal:and', + 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', + 'internal:or', 'internal:and', 'internal:not', 'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid', ]); this._builtinEnginesInMainWorld = new Set([ diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 600c6a66e4..32bf5cea81 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi import type { ParsedSelector } from './selectorParser'; 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' | 'frame' | 'or' | 'and'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'frame' | 'or' | 'and' | 'not'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; @@ -96,6 +96,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame tokens.push(factory.generateLocator(base, 'and', inner)); continue; } + if (part.name === 'internal:not') { + const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); + tokens.push(factory.generateLocator(base, 'not', inner)); + continue; + } if (part.name === 'internal:label') { const { exact, text } = detectExact(part.body as string); tokens.push(factory.generateLocator(base, 'label', text, { exact })); @@ -209,6 +214,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `or(${body})`; case 'and': return `filter(${body})`; + case 'not': + return `not(${body})`; case 'test-id': return `getByTestId(${this.quote(body as string)})`; case 'text': @@ -281,6 +288,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `or_(${body})`; case 'and': return `filter(${body})`; + case 'not': + return `not_(${body})`; case 'test-id': return `get_by_test_id(${this.quote(body as string)})`; case 'text': @@ -362,6 +371,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `or(${body})`; case 'and': return `filter(${body})`; + case 'not': + return `not(${body})`; case 'test-id': return `getByTestId(${this.quote(body as string)})`; case 'text': @@ -437,6 +448,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `Or(${body})`; case 'and': return `Filter(${body})`; + case 'not': + return `Not(${body})`; case 'test-id': return `GetByTestId(${this.quote(body as string)})`; case 'text': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index f06d2d759a..d839375cff 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -77,7 +77,8 @@ function parseLocator(locator: string, testIdAttributeName: string): string { .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') .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(/\.not_\(/g, 'not(') // Python has "not_" instead of "not". .replace(/:/g, '=') .replace(/,re\.ignorecase/g, 'i') .replace(/,pattern.case_insensitive/g, 'i') @@ -102,7 +103,7 @@ function shiftParams(template: string, sub: number) { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { // Recursively handle filter(has=). - // TODO: handle or(locator) and filter(locator). + // TODO: handle or(locator), not(locator) and filter(locator). while (true) { const hasMatch = template.match(/filter\(,?has=/); if (!hasMatch) diff --git a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts index 381aa5326b..6c32765cee 100644 --- a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts @@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; -const kNestedSelectorNames = new Set(['internal:has', 'internal:or', 'internal:and', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNames = new Set(['internal:has', 'internal:or', 'internal:and', 'internal:not', 'left-of', 'right-of', 'above', 'below', 'near']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); export type ParsedSelectorPart = { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 8c838a0c2f..5f8e915cc1 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -11658,6 +11658,21 @@ export interface Locator { hasText?: string|RegExp; }): Locator; + /** + * Creates a locator that **matches this** locator, but **not the argument** locator. + * + * **Usage** + * + * The following example finds a button that does not have title `"Subscribe"`. + * + * ```js + * const button = page.getByRole('button').not(page.getByTitle('Subscribe')); + * ``` + * + * @param locator Locator that must not match. + */ + not(locator: Locator): Locator; + /** * Returns locator to the n-th matching element. It's zero based, `nth(0)` selects the first element. * diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index b99a143276..1eaf535012 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -72,6 +72,11 @@ it('should support locator.or()', async ({ page }) => { expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']); }); +it('should support locator.not()', async ({ page }) => { + await page.setContent('
Hi
Hello
'); + expect(await page.evaluate(`playwright.locator('div').not(playwright.locator('.foo')).elements.map(e => e.innerHTML)`)).toEqual(['Hello']); +}); + it('should support locator.filter(locator)', async ({ page }) => { await page.setContent('
Hi
'); expect(await page.evaluate(`playwright.locator('div').filter(playwright.getByTestId('Hey')).elements.map(e => e.innerHTML)`)).toEqual(['Hi']); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 8c35b5b7f0..ec24e4af68 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -366,6 +366,13 @@ it('asLocator internal:and', async () => { expect.soft(asLocator('csharp', 'div >> internal:and="span >> article"', false)).toBe(`Locator("div").Filter(Locator("span").Locator("article"))`); }); +it('asLocator internal:not', async () => { + expect.soft(asLocator('javascript', 'div >> internal:not="span >> article"', false)).toBe(`locator('div').not(locator('span').locator('article'))`); + expect.soft(asLocator('python', 'div >> internal:not="span >> article"', false)).toBe(`locator("div").not_(locator("span").locator("article"))`); + expect.soft(asLocator('java', 'div >> internal:not="span >> article"', false)).toBe(`locator("div").not(locator("span").locator("article"))`); + expect.soft(asLocator('csharp', 'div >> internal:not="span >> article"', false)).toBe(`Locator("div").Not(Locator("span").Locator("article"))`); +}); + it('parse locators strictly', () => { const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span'; diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index ebc3f569c4..f0c8c22613 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -184,6 +184,17 @@ it('should support locator.filter(locator)', async ({ page }) => { await expect(page.locator('span').filter(page.getByTestId(/bar|foo/))).toHaveCount(2); }); +it('should support locator.not', async ({ page }) => { + await page.setContent(`
hello
world
`); + await expect(page.locator('div').not(page.locator('span'))).toHaveCount(2); + await expect(page.locator('div').not(page.locator('span'))).toHaveText(['hello', 'world']); + await expect(page.locator('div').not(page.locator('.foo'))).toHaveText(['world']); + await expect(page.locator('div').not(page.locator('.bar'))).toHaveText(['hello']); + await expect(page.locator('.foo').not(page.locator('.bar'))).toHaveText(['hello']); + await expect(page.locator('.foo').not(page.locator('div'))).toHaveText([]); + await expect(page.locator('div').not(page.locator('div'))).toHaveText([]); +}); + it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => { await page.goto(server.PREFIX + '/frames/two-frames.html'); const child = page.frames()[1]; diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index d9069357db..fa7ae70f67 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -427,6 +427,19 @@ it('should work with internal:and=', async ({ page, server }) => { expect(await page.$$eval(`.bar >> internal:and="span"`, els => els.map(e => e.textContent))).toEqual(['world2']); }); +it('should work with internal:not=', async ({ page, server }) => { + await page.setContent(` +
hello
+
world
+ `); + expect(await page.$$eval(`div >> internal:not="span"`, els => els.map(e => e.textContent))).toEqual(['hello', 'world']); + expect(await page.$$eval(`div >> internal:not=".foo"`, els => els.map(e => e.textContent))).toEqual(['world']); + expect(await page.$$eval(`div >> internal:not=".bar"`, els => els.map(e => e.textContent))).toEqual(['hello']); + expect(await page.$$eval(`div >> internal:not="div"`, els => els.map(e => e.textContent))).toEqual([]); + expect(await page.$$eval(`span >> internal:not="div"`, els => els.map(e => e.textContent))).toEqual([]); + expect(await page.$$eval(`.foo >> internal:not=".bar"`, els => els.map(e => e.textContent))).toEqual(['hello']); +}); + it('chaining should work with large DOM @smoke', async ({ page, server }) => { await page.evaluate(() => { let last = document.body;