Skip to content

Commit

Permalink
feat(locator): filter({ hasNotText }) (#22222)
Browse files Browse the repository at this point in the history
The opposite of `filter({ hasText })`.
  • Loading branch information
dgozman committed Apr 5, 2023
1 parent 29643a7 commit 35afb05
Show file tree
Hide file tree
Showing 16 changed files with 142 additions and 3 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 @@ -1348,6 +1348,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Frame.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

### option: Frame.locator.hasNotText = %%-locator-option-has-not-text-%%
* 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 @@ -205,6 +205,9 @@ Returns locator to the last matching frame.
### option: FrameLocator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

### option: FrameLocator.locator.hasNotText = %%-locator-option-has-not-text-%%
* 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 @@ -991,6 +991,9 @@ await rowLocator
### option: Locator.filter.hasNot = %%-locator-option-has-not-%%
* since: v1.33

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

## method: Locator.first
* since: v1.14
- returns: <[Locator]>
Expand Down Expand Up @@ -1508,6 +1511,8 @@ var banana = await page.GetByRole(AriaRole.Listitem).Last(1);
### option: Locator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

### option: Locator.locator.hasNotText = %%-locator-option-has-not-text-%%
* 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 @@ -2687,6 +2687,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Page.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33

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

## method: Page.mainFrame
* since: v1.8
- returns: <[Frame]>
Expand Down
5 changes: 5 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,11 @@ For example, `article` that does not have `div` matches `<article><span>Playwrig

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

## locator-option-has-not-text
- `hasNotText` <[string]|[RegExp]>

Matches elements that do not contain 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.

## locator-options-list-v1.14
- %%-locator-option-has-text-%%
- %%-locator-option-has-%%
Expand Down
30 changes: 30 additions & 0 deletions docs/src/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,36 @@ await page
.ClickAsync();
```

Alternatively, filter by **not having** text:

```js
// 5 in-stock items
await expect(page.getByRole('listitem').filter({ hasNotText: 'Out of stock' })).toHaveCount(5);
```

```java
// 5 in-stock items
assertThat(page.getByRole(AriaRole.LISTITEM)
.filter(new Locator.FilterOptions().setHasNotText("Out of stock")))
.hasCount(5);
```

```python async
# 5 in-stock items
await expect(page.get_by_role("listitem").filter(has_not_text="Out of stock")).to_have_count(5)
```

```python sync
# 5 in-stock items
expect(page.get_by_role("listitem").filter(has_not_text="Out of stock")).to_have_count(5)
```

```csharp
// 5 in-stock items
await Expect(page.getByRole(AriaRole.Listitem).Filter(new() { HasNotText = "Out of stock" }))
.ToHaveCountAsync(5);
```

### Filter by child/descendant

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.
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, get

export type LocatorOptions = {
hasText?: string | RegExp;
hasNotText?: string | RegExp;
has?: Locator;
hasNot?: Locator;
};
Expand All @@ -44,6 +45,9 @@ export class Locator implements api.Locator {
if (options?.hasText)
this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;

if (options?.hasNotText)
this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;

if (options?.has) {
const locator = options.has;
if (locator._frame !== frame)
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,11 +29,13 @@ class Locator {
element: Element | undefined;
elements: Element[] | undefined;

constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, has?: Locator, hasNot?: Locator }) {
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: 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?.hasNotText)
selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;
if (options?.has)
selector += ` >> internal:has=` + JSON.stringify((options.has as any)[selectorSymbol]);
if (options?.hasNot)
Expand Down
14 changes: 14 additions & 0 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class InjectedScript {
this._engines.set('internal:label', this._createInternalLabelEngine());
this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:has-text', this._createInternalHasTextEngine());
this._engines.set('internal:has-not-text', this._createInternalHasNotTextEngine());
this._engines.set('internal:attr', this._createNamedAttributeEngine());
this._engines.set('internal:testid', this._createNamedAttributeEngine());
this._engines.set('internal:role', createRoleEngine(true));
Expand Down Expand Up @@ -309,6 +310,19 @@ export class InjectedScript {
};
}

private _createInternalHasNotTextEngine(): SelectorEngine {
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
const element = root as Element;
const text = elementText(this._evaluator._cacheText, element);
const { matcher } = createTextMatcher(selector, true);
return matcher(text) ? [] : [element];
}
};
}

private _createInternalLabelEngine(): SelectorEngine {
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/server/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ 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-not', 'internal:has-text',
'nth', 'visible', 'internal:control',
'internal:has', 'internal:has-not',
'internal:has-text', 'internal:has-not-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' | 'hasNot' | 'frame' | 'or' | 'and' | 'not';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-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 @@ -81,6 +81,14 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame
continue;
}
}
if (part.name === 'internal:has-not-text') {
const { exact, text } = detectExact(part.body as string);
// There is no locator equivalent for strict has-not-text, leave it as is.
if (!exact) {
tokens.push(factory.generateLocator(base, 'has-not-text', text, { exact }));
continue;
}
}
if (part.name === 'internal:has') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
tokens.push(factory.generateLocator(base, 'has', inner));
Expand Down Expand Up @@ -213,6 +221,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `getByRole(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `filter({ hasText: ${this.toHasText(body as string)} })`;
case 'has-not-text':
return `filter({ hasNotText: ${this.toHasText(body as string)} })`;
case 'has':
return `filter({ has: ${body} })`;
case 'hasNot':
Expand Down Expand Up @@ -289,6 +299,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `get_by_role(${this.quote(body as string)}${attrString})`;
case 'has-text':
return `filter(has_text=${this.toHasText(body as string)})`;
case 'has-not-text':
return `filter(has_not_text=${this.toHasText(body as string)})`;
case 'has':
return `filter(has=${body})`;
case 'hasNot':
Expand Down Expand Up @@ -374,6 +386,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`;
case 'has-text':
return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`;
case 'has-not-text':
return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`;
case 'has':
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'hasNot':
Expand Down Expand Up @@ -453,6 +467,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`;
case 'has-text':
return `Filter(new() { ${this.toHasText(body)} })`;
case 'has-not-text':
return `Filter(new() { ${this.toHasNotText(body)} })`;
case 'has':
return `Filter(new() { Has = ${body} })`;
case 'hasNot':
Expand Down Expand Up @@ -499,6 +515,12 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `HasText = ${this.quote(body)}`;
}

private toHasNotText(body: string | RegExp) {
if (isRegExp(body))
return `HasNotTextRegex = ${this.regexToString(body)}`;
return `HasNotText = ${this.quote(body)}`;
}

private quote(text: string) {
return escapeWithQuotes(text, '\"');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string {
.replace(/get_by_alt_text/g, 'getbyalttext')
.replace(/get_by_test_id/g, 'getbytestid')
.replace(/get_by_([\w]+)/g, 'getby$1')
.replace(/has_not_text/g, 'hasnottext')
.replace(/has_text/g, 'hastext')
.replace(/has_not/g, 'hasnot')
.replace(/frame_locator/g, 'framelocator')
Expand Down Expand Up @@ -152,6 +153,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/last(\(\))?/g, 'nth=-1')
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
.replace(/,exact=false/g, '')
Expand Down
30 changes: 30 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3225,6 +3225,12 @@ export interface Page {
*/
hasNot?: Locator;

/**
* Matches elements that do not contain 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.
*/
hasNotText?: string|RegExp;

/**
* 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 @@ -6610,6 +6616,12 @@ export interface Frame {
*/
hasNot?: Locator;

/**
* Matches elements that do not contain 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.
*/
hasNotText?: string|RegExp;

/**
* 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 @@ -10851,6 +10863,12 @@ export interface Locator {
*/
hasNot?: Locator;

/**
* Matches elements that do not contain 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.
*/
hasNotText?: string|RegExp;

/**
* 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 @@ -11507,6 +11525,12 @@ export interface Locator {
*/
hasNot?: Locator;

/**
* Matches elements that do not contain 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.
*/
hasNotText?: string|RegExp;

/**
* 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 @@ -17169,6 +17193,12 @@ export interface FrameLocator {
*/
hasNot?: Locator;

/**
* Matches elements that do not contain 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.
*/
hasNotText?: string|RegExp;

/**
* 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
2 changes: 2 additions & 0 deletions tests/library/inspector/console-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ it('should support playwright.locator.values', async ({ page }) => {
expect(await page.evaluate(`playwright.locator('div', { hasText: /ELL/ }).elements.length`)).toBe(0);
expect(await page.evaluate(`playwright.locator('div', { hasText: /ELL/i }).elements.length`)).toBe(1);
expect(await page.evaluate(`playwright.locator('div', { hasText: /Hello/ }).elements.length`)).toBe(1);
expect(await page.evaluate(`playwright.locator('div', { hasNotText: /Bar/ }).elements.length`)).toBe(0);
expect(await page.evaluate(`playwright.locator('div', { hasNotText: /Hello/ }).elements.length`)).toBe(1);
});

it('should support playwright.locator({ has })', async ({ page }) => {
Expand Down
10 changes: 10 additions & 0 deletions tests/library/locator-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,15 @@ it('reverse engineer hasText', async ({ page }) => {
});
});

it('reverse engineer hasNotText', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').filter({ hasNotText: 'wo"rld\n' }))).toEqual({
csharp: `GetByText("Hello").Filter(new() { HasNotText = "wo\\"rld\\n" })`,
java: `getByText("Hello").filter(new Locator.FilterOptions().setHasNotText("wo\\"rld\\n"))`,
javascript: `getByText('Hello').filter({ hasNotText: 'wo"rld\\n' })`,
python: `get_by_text("Hello").filter(has_not_text="wo\\"rld\\n")`,
});
});

it('reverse engineer has', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').filter({ has: page.locator('div').getByText('bye') }))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Has = Locator("div").GetByText("bye") })`,
Expand Down Expand Up @@ -370,6 +379,7 @@ it.describe(() => {
});

expect.soft(asLocator('javascript', 'div >> internal:has-text="foo"s', false)).toBe(`locator('div').locator('internal:has-text="foo"s')`);
expect.soft(asLocator('javascript', 'div >> internal:has-not-text="foo"s', false)).toBe(`locator('div').locator('internal:has-not-text="foo"s')`);
});
});

Expand Down
2 changes: 2 additions & 0 deletions tests/page/locator-query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ it('should support locator.filter', async ({ page, trace }) => {
await expect(page.locator(`div`).filter({ hasNot: page.locator('span', { hasText: 'world' }) })).toHaveCount(1);
await expect(page.locator(`div`).filter({ hasNot: page.locator('section') })).toHaveCount(2);
await expect(page.locator(`div`).filter({ hasNot: page.locator('span') })).toHaveCount(0);
await expect(page.locator(`div`).filter({ hasNotText: 'hello' })).toHaveCount(1);
await expect(page.locator(`div`).filter({ hasNotText: 'foo' })).toHaveCount(2);
});

it('should support locator.or', async ({ page }) => {
Expand Down

0 comments on commit 35afb05

Please sign in to comment.