Skip to content

Commit

Permalink
feat(locator): filter({ hasNot }) (#22219)
Browse files Browse the repository at this point in the history
This is the opposite of `filter({ has })`.
  • Loading branch information
dgozman committed Apr 5, 2023
1 parent 8dd4317 commit bc1de5f
Show file tree
Hide file tree
Showing 18 changed files with 187 additions and 8 deletions.
3 changes: 3 additions & 0 deletions docs/src/api/class-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Frame.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14

### option: Frame.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

## method: Frame.name
* since: v1.8
- returns: <[string]>
Expand Down
3 changes: 3 additions & 0 deletions docs/src/api/class-framelocator.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ Returns locator to the last matching frame.
### option: FrameLocator.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.17

### option: FrameLocator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

## method: FrameLocator.nth
* since: v1.17
- returns: <[FrameLocator]>
Expand Down
5 changes: 5 additions & 0 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,8 @@ await rowLocator
### option: Locator.filter.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.22

### option: Locator.filter.hasNot = %%-locator-option-has-not-%%
* since: v1.33

## method: Locator.first
* since: v1.14
Expand Down Expand Up @@ -1503,6 +1505,9 @@ var banana = await page.GetByRole(AriaRole.Listitem).Last(1);
### option: Locator.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14

### option: Locator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33


## method: Locator.not
* since: v1.33
Expand Down
3 changes: 3 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Page.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14

### option: Page.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

## method: Page.mainFrame
* since: v1.8
- returns: <[Frame]>
Expand Down
8 changes: 8 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,14 @@ For example, `article` that has `text=Playwright` matches `<article><div>Playwri

Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.

## locator-option-has-not
- `hasNot` <[Locator]>

Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the outer one.
For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.

Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.

## locator-options-list-v1.14
- %%-locator-option-has-text-%%
- %%-locator-option-has-%%
Expand Down
43 changes: 42 additions & 1 deletion docs/src/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ await page

### Filter by child/descendant

Locators support an option to only select elements that have a descendant matching another locator. You can therefore filter by any other locator such as a [`method: Locator.getByRole`], [`method: Locator.getByTestId`], [`method: Locator.getByText`] etc.
Locators support an option to only select elements that have or have not a descendant matching another locator. You can therefore filter by any other locator such as a [`method: Locator.getByRole`], [`method: Locator.getByTestId`], [`method: Locator.getByText`] etc.

```html card
<ul>
Expand Down Expand Up @@ -983,6 +983,47 @@ await Expect(page
.toHaveCountAsync(1);
```

We can also filter by **not having** a matching element inside

```js
await expect(page
.getByRole('listitem')
.filter({ hasNot: page.getByText('Product 2') }))
.toHaveCount(1);
```

```java
assertThat(page
.getByRole(AriaRole.LISTITEM)
.filter(new Locator.FilterOptions().setHasNot(page.getByText("Product 2")))
.hasCount(1);
```

```python async
await expect(
page.get_by_role("listitem").filter(
has_not=page.get_by_role("heading", name="Product 2")
)
).to_have_count(1)
```

```python sync
expect(
page.get_by_role("listitem").filter(
has_not=page.get_by_role("heading", name="Product 2")
)
).to_have_count(1)
```

```csharp
await Expect(page
.GetByRole(AriaRole.Listitem)
.Filter(new() {
HasNot = page.GetByRole(AriaRole.Heading, new() { Name = "Product 2" })
})
.toHaveCountAsync(1);
```

Note that the inner locator is matched starting from the outer one, not from the document root.

### Filter by matching an additional locator
Expand Down
8 changes: 8 additions & 0 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, get
export type LocatorOptions = {
hasText?: string | RegExp;
has?: Locator;
hasNot?: Locator;
};

export class Locator implements api.Locator {
Expand All @@ -49,6 +50,13 @@ export class Locator implements api.Locator {
throw new Error(`Inner "has" locator must belong to the same frame.`);
this._selector += ` >> internal:has=` + JSON.stringify(locator._selector);
}

if (options?.hasNot) {
const locator = options.hasNot;
if (locator._frame !== frame)
throw new Error(`Inner "hasNot" locator must belong to the same frame.`);
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
}
}

private async _withElement<R>(task: (handle: ElementHandle<SVGElement | HTMLElement>, timeout?: number) => Promise<R>, timeout?: number): Promise<R> {
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ class Locator {
element: Element | undefined;
elements: Element[] | undefined;

constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, has?: Locator, hasNot?: Locator }) {
(this as any)[selectorSymbol] = selector;
(this as any)[injectedScriptSymbol] = injectedScript;
if (options?.hasText)
selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.has)
selector += ` >> internal:has=` + JSON.stringify((options.has as any)[selectorSymbol]);
if (options?.hasNot)
selector += ` >> internal:has-not=` + JSON.stringify((options.hasNot as any)[selectorSymbol]);
if (selector) {
const parsed = injectedScript.parseSelector(selector);
this.element = injectedScript.querySelector(parsed, injectedScript.document, false);
Expand Down
11 changes: 11 additions & 0 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export class InjectedScript {
this._engines.set('visible', this._createVisibleEngine());
this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:has-not', this._createHasNotEngine());
this._engines.set('internal:or', { queryAll: () => [] });
this._engines.set('internal:and', { queryAll: () => [] });
this._engines.set('internal:not', { queryAll: () => [] });
Expand Down Expand Up @@ -377,6 +378,16 @@ export class InjectedScript {
return { queryAll };
}

private _createHasNotEngine(): SelectorEngine {
const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
const has = !!this.querySelector(body.parsed, root, false);
return has ? [] : [root as Element];
};
return { queryAll };
}

private _createVisibleEngine(): SelectorEngine {
const queryAll = (root: SelectorRoot, body: string) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ 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',
'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-not', 'internal:has-text',
'internal:or', 'internal:and', 'internal:not',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid',
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'not';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'hasNot' | '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 };
Expand Down Expand Up @@ -86,6 +86,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
tokens.push(factory.generateLocator(base, 'has', inner));
continue;
}
if (part.name === 'internal:has-not') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
tokens.push(factory.generateLocator(base, 'hasNot', inner));
continue;
}
if (part.name === 'internal:or') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
tokens.push(factory.generateLocator(base, 'or', inner));
Expand Down Expand Up @@ -210,6 +215,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `filter({ hasText: ${this.toHasText(body as string)} })`;
case 'has':
return `filter({ has: ${body} })`;
case 'hasNot':
return `filter({ hasNot: ${body} })`;
case 'or':
return `or(${body})`;
case 'and':
Expand Down Expand Up @@ -284,6 +291,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `filter(has_text=${this.toHasText(body as string)})`;
case 'has':
return `filter(has=${body})`;
case 'hasNot':
return `filter(has_not=${body})`;
case 'or':
return `or_(${body})`;
case 'and':
Expand Down Expand Up @@ -367,6 +376,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`;
case 'has':
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'hasNot':
return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`;
case 'or':
return `or(${body})`;
case 'and':
Expand Down Expand Up @@ -444,6 +455,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `Filter(new() { ${this.toHasText(body)} })`;
case 'has':
return `Filter(new() { Has = ${body} })`;
case 'hasNot':
return `Filter(new() { HasNot = ${body} })`;
case 'or':
return `Or(${body})`;
case 'and':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string {
.replace(/get_by_test_id/g, 'getbytestid')
.replace(/get_by_([\w]+)/g, 'getby$1')
.replace(/has_text/g, 'hastext')
.replace(/has_not/g, 'hasnot')
.replace(/frame_locator/g, 'framelocator')
.replace(/[{}\s]/g, '')
.replace(/new\(\)/g, '')
Expand Down Expand Up @@ -102,10 +103,10 @@ function shiftParams(template: string, sub: number) {
}

function transform(template: string, params: TemplateParams, testIdAttributeName: string): string {
// Recursively handle filter(has=).
// Recursively handle filter(has=, hasnot=).
// TODO: handle or(locator), not(locator), and(locator).
while (true) {
const hasMatch = template.match(/filter\(,?has=/);
const hasMatch = template.match(/filter\(,?(has|hasnot)=/);
if (!hasMatch)
break;

Expand All @@ -129,6 +130,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName));

// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
// Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again.
template = template.substring(0, start - 1) + `2=$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end), paramsCountInHas - 1);

// Replace inner params with $5 value.
Expand All @@ -151,6 +153,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
.replace(/,exact=false/g, '')
.replace(/,exact=true/g, 's')
.replace(/\,/g, '][');
Expand Down Expand Up @@ -180,7 +183,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
})
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
const param = params[+ordinal - 1];
if (t.startsWith('internal:has='))
if (t.startsWith('internal:has=') || t.startsWith('internal:has-not='))
return param.text;
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
return escapeForAttributeSelector(param.text, suffix === 's');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', 'internal:not', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', '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 = {
Expand Down
40 changes: 40 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3217,6 +3217,14 @@ export interface Page {
*/
has?: Locator;

/**
* Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the
* outer one. For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
hasNot?: Locator;

/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. When
* passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches
Expand Down Expand Up @@ -6594,6 +6602,14 @@ export interface Frame {
*/
has?: Locator;

/**
* Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the
* outer one. For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
hasNot?: Locator;

/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. When
* passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches
Expand Down Expand Up @@ -10827,6 +10843,14 @@ export interface Locator {
*/
has?: Locator;

/**
* Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the
* outer one. For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
hasNot?: Locator;

/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. When
* passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches
Expand Down Expand Up @@ -11475,6 +11499,14 @@ export interface Locator {
*/
has?: Locator;

/**
* Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the
* outer one. For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
hasNot?: Locator;

/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. When
* passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches
Expand Down Expand Up @@ -17125,6 +17157,14 @@ export interface FrameLocator {
*/
has?: Locator;

/**
* Matches elements that do not contain an element that matches an inner locator. Inner locator is queried against the
* outer one. For example, `article` that does not have `div` matches `<article><span>Playwright</span></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
hasNot?: Locator;

/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. When
* passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches
Expand Down
6 changes: 6 additions & 0 deletions tests/library/inspector/console-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ it('should support playwright.locator({ has })', async ({ page }) => {
expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('span');
});

it('should support playwright.locator({ hasNot })', async ({ page }) => {
await page.setContent('<div>Hi</div><div><span>Hello</span></div>');
expect(await page.evaluate(`playwright.locator('div', { hasNot: playwright.locator('span') }).element.innerHTML`)).toContain('Hi');
expect(await page.evaluate(`playwright.locator('div', { hasNot: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('Hi');
});

it('should support locator.or()', async ({ page }) => {
await page.setContent('<div>Hi</div><span>Hello</span>');
expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']);
Expand Down

0 comments on commit bc1de5f

Please sign in to comment.