From bc1de5f28dd2ebc4dc1cf090f12b7ab3a8ae45a9 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 5 Apr 2023 12:45:46 -0700 Subject: [PATCH] feat(locator): filter({ hasNot }) (#22219) This is the opposite of `filter({ has })`. --- 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 | 8 ++++ docs/src/locators.md | 43 ++++++++++++++++++- .../playwright-core/src/client/locator.ts | 8 ++++ .../src/server/injected/consoleApi.ts | 4 +- .../src/server/injected/injectedScript.ts | 11 +++++ .../playwright-core/src/server/selectors.ts | 2 +- .../src/utils/isomorphic/locatorGenerators.ts | 15 ++++++- .../src/utils/isomorphic/locatorParser.ts | 9 ++-- .../src/utils/isomorphic/selectorParser.ts | 2 +- packages/playwright-core/types/types.d.ts | 40 +++++++++++++++++ tests/library/inspector/console-api.spec.ts | 6 +++ tests/library/locator-generator.spec.ts | 21 +++++++++ tests/page/locator-query.spec.ts | 3 ++ tests/page/selectors-misc.spec.ts | 9 ++++ 18 files changed, 187 insertions(+), 8 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 994e92628f..8b9dc92d32 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -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]> diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 48829a775d..fb5afd6891 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -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]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 44cc6af3f6..03c72a2fd9 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -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 @@ -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 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 2068614f75..ad19ccc1ae 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 2534012219..e4c11d13d2 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1029,6 +1029,14 @@ For example, `article` that has `text=Playwright` matches `
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 `
Playwright
`. + +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-%% diff --git a/docs/src/locators.md b/docs/src/locators.md index 4327a7fc00..c349a48341 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -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
    @@ -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 diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 8ddaea7049..309dc0add8 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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 { @@ -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(task: (handle: ElementHandle, timeout?: number) => Promise, timeout?: number): Promise { diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 1e97b8a6d3..29819eab8e 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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); diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index ae3371bb91..e801ffd953 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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: () => [] }); @@ -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 */) diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 37b13b2d9a..bf56530654 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -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', ]); diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 0dd81a5d00..8b1f076dc8 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' | '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 }; @@ -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)); @@ -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': @@ -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': @@ -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': @@ -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': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index 982346ca8c..c5c116043b 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -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, '') @@ -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; @@ -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. @@ -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, ']['); @@ -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'); diff --git a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts index 6c32765cee..75130a098d 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', '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 = { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cc8b66d0ab..4791fe268d 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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 `
    Playwright
    `. + * + * 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 @@ -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 `
    Playwright
    `. + * + * 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 @@ -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 `
    Playwright
    `. + * + * 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 @@ -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 `
    Playwright
    `. + * + * 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 @@ -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 `
    Playwright
    `. + * + * 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 diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index c3ffbf2c2a..d2a9c972b9 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -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('
    Hi
    Hello
    '); + 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('
    Hi
    Hello'); expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 00734219ae..601912dcd6 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -312,6 +312,27 @@ it('reverse engineer has', async ({ page }) => { }); }); +it('reverse engineer hasNot', async ({ page }) => { + expect.soft(generate(page.getByText('Hello').filter({ hasNot: page.locator('div').getByText('bye') }))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { HasNot = Locator("div").GetByText("bye") })`, + java: `getByText("Hello").filter(new Locator.FilterOptions().setHasNot(locator("div").getByText("bye")))`, + javascript: `getByText('Hello').filter({ hasNot: locator('div').getByText('bye') })`, + python: `get_by_text("Hello").filter(has_not=locator("div").get_by_text("bye"))`, + }); + + const locator = page + .locator('section') + .filter({ has: page.locator('div').filter({ hasNot: page.locator('span') }) }) + .filter({ hasText: 'foo' }) + .filter({ hasNot: page.locator('a') }); + expect.soft(generate(locator)).toEqual({ + csharp: `Locator("section").Filter(new() { Has = Locator("div").Filter(new() { HasNot = Locator("span") }) }).Filter(new() { HasText = "foo" }).Filter(new() { HasNot = Locator("a") })`, + java: `locator("section").filter(new Locator.FilterOptions().setHas(locator("div").filter(new Locator.FilterOptions().setHasNot(locator("span"))))).filter(new Locator.FilterOptions().setHasText("foo")).filter(new Locator.FilterOptions().setHasNot(locator("a")))`, + javascript: `locator('section').filter({ has: locator('div').filter({ hasNot: locator('span') }) }).filter({ hasText: 'foo' }).filter({ hasNot: locator('a') })`, + python: `locator("section").filter(has=locator("div").filter(has_not=locator("span"))).filter(has_text="foo").filter(has_not=locator("a"))`, + }); +}); + it('reverse engineer frameLocator', async ({ page }) => { const locator = page .frameLocator('iframe') diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index a4fb5d3887..783ee077e8 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -157,6 +157,9 @@ it('should support locator.filter', async ({ page, trace }) => { has: page.locator('span'), hasText: 'world', })).toHaveCount(1); + 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); }); it('should support locator.or', async ({ page }) => { diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index fa7ae70f67..dcd820658a 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -400,6 +400,15 @@ it('should work with internal:has=', async ({ page, server }) => { expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"'); }); +it('should work with internal:has-not=', async ({ page }) => { + await page.setContent(`

    `); + expect(await page.$$eval(`section >> internal:has-not="span"`, els => els.length)).toBe(1); + expect(await page.$$eval(`section >> internal:has-not="span, div, br"`, els => els.length)).toBe(0); + expect(await page.$$eval(`section >> internal:has-not="br"`, els => els.length)).toBe(1); + expect(await page.$$eval(`section >> internal:has-not="span, div"`, els => els.length)).toBe(1); + expect(await page.$$eval(`section >> internal:has-not="article"`, els => els.length)).toBe(2); +}); + it('should work with internal:or=', async ({ page, server }) => { await page.setContent(`
    hello