From 35afb056ea0136d65f03357238b66c6fa6650dbd Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 5 Apr 2023 14:13:28 -0700 Subject: [PATCH] feat(locator): filter({ hasNotText }) (#22222) The opposite of `filter({ hasText })`. --- docs/src/api/class-frame.md | 3 ++ docs/src/api/class-framelocator.md | 3 ++ docs/src/api/class-locator.md | 5 ++++ docs/src/api/class-page.md | 3 ++ docs/src/api/params.md | 5 ++++ docs/src/locators.md | 30 +++++++++++++++++++ .../playwright-core/src/client/locator.ts | 4 +++ .../src/server/injected/consoleApi.ts | 4 ++- .../src/server/injected/injectedScript.ts | 14 +++++++++ .../playwright-core/src/server/selectors.ts | 4 ++- .../src/utils/isomorphic/locatorGenerators.ts | 24 ++++++++++++++- .../src/utils/isomorphic/locatorParser.ts | 2 ++ packages/playwright-core/types/types.d.ts | 30 +++++++++++++++++++ tests/library/inspector/console-api.spec.ts | 2 ++ tests/library/locator-generator.spec.ts | 10 +++++++ tests/page/locator-query.spec.ts | 2 ++ 16 files changed, 142 insertions(+), 3 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 8b9dc92d32..b535fdd1c8 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -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]> diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index fb5afd6891..c90ff1fa19 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -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]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index b8d9c8a807..6e2c73b3a6 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -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]> @@ -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 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index ad19ccc1ae..523daa725e 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index e4c11d13d2..7abbc9dd5a 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1037,6 +1037,11 @@ For example, `article` that does not have `div` matches `
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-%% diff --git a/docs/src/locators.md b/docs/src/locators.md index c349a48341..7a47889ed7 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -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. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 309dc0add8..9d76d07b9a 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -29,6 +29,7 @@ import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, get export type LocatorOptions = { hasText?: string | RegExp; + hasNotText?: string | RegExp; has?: Locator; hasNot?: Locator; }; @@ -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) diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 29819eab8e..b84f89e59c 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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) diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index e801ffd953..a28d421b77 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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)); @@ -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[] => { diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index bf56530654..e2add1caa9 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -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', ]); diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 8b1f076dc8..b90640bf57 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' | '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 }; @@ -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)); @@ -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': @@ -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': @@ -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': @@ -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': @@ -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, '\"'); } diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index c5c116043b..c787a5657c 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -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') @@ -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, '') diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index addf0b66cd..d81bbb18ce 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index d2a9c972b9..8311ca56be 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -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 }) => { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 601912dcd6..668f823ffe 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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") })`, @@ -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')`); }); }); diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index 783ee077e8..34bc394b9f 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -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 }) => {