mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
feat: Locator.not(locator) (#22066)
This commit is contained in:
parent
9aaf2b028e
commit
e6148bb725
@ -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]>
|
||||
|
@ -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.
|
||||
|
@ -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.`);
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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([
|
||||
|
@ -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':
|
||||
|
@ -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)
|
||||
|
@ -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 = {
|
||||
|
15
packages/playwright-core/types/types.d.ts
vendored
15
packages/playwright-core/types/types.d.ts
vendored
@ -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.
|
||||
*
|
||||
|
@ -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('<div class=foo>Hi</div><div class=bar>Hello</div>');
|
||||
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('<div data-testid=Hey>Hi</div>');
|
||||
expect(await page.evaluate(`playwright.locator('div').filter(playwright.getByTestId('Hey')).elements.map(e => e.innerHTML)`)).toEqual(['Hi']);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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(`<div class=foo>hello</div><div class=bar>world</div>`);
|
||||
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];
|
||||
|
@ -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(`
|
||||
<div class=foo>hello</div>
|
||||
<div class=bar>world</div>
|
||||
`);
|
||||
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;
|
||||
|
Loading…
Reference in New Issue
Block a user