feat(locator): filter({ hasNot }) (#22219)

This is the opposite of `filter({ has })`.
This commit is contained in:
Dmitry Gozman 2023-04-05 12:45:46 -07:00 committed by GitHub
parent 8dd431745d
commit bc1de5f28d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 187 additions and 8 deletions

View File

@ -1345,6 +1345,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Frame.locator.-inline- = %%-locator-options-list-v1.14-%% ### option: Frame.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14 * since: v1.14
### option: Frame.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33
## method: Frame.name ## method: Frame.name
* since: v1.8 * since: v1.8
- returns: <[string]> - returns: <[string]>

View File

@ -202,6 +202,9 @@ Returns locator to the last matching frame.
### option: FrameLocator.locator.-inline- = %%-locator-options-list-v1.14-%% ### option: FrameLocator.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.17 * since: v1.17
### option: FrameLocator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33
## method: FrameLocator.nth ## method: FrameLocator.nth
* since: v1.17 * since: v1.17
- returns: <[FrameLocator]> - returns: <[FrameLocator]>

View File

@ -988,6 +988,8 @@ await rowLocator
### option: Locator.filter.-inline- = %%-locator-options-list-v1.14-%% ### option: Locator.filter.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.22 * since: v1.22
### option: Locator.filter.hasNot = %%-locator-option-has-not-%%
* since: v1.33
## method: Locator.first ## method: Locator.first
* since: v1.14 * 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-%% ### option: Locator.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14 * since: v1.14
### option: Locator.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33
## method: Locator.not ## method: Locator.not
* since: v1.33 * since: v1.33

View File

@ -2684,6 +2684,9 @@ Returns whether the element is [visible](../actionability.md#visible). [`option:
### option: Page.locator.-inline- = %%-locator-options-list-v1.14-%% ### option: Page.locator.-inline- = %%-locator-options-list-v1.14-%%
* since: v1.14 * since: v1.14
### option: Page.locator.hasNot = %%-locator-option-has-not-%%
* since: v1.33
## method: Page.mainFrame ## method: Page.mainFrame
* since: v1.8 * since: v1.8
- returns: <[Frame]> - returns: <[Frame]>

View File

@ -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. 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-options-list-v1.14
- %%-locator-option-has-text-%% - %%-locator-option-has-text-%%
- %%-locator-option-has-%% - %%-locator-option-has-%%

View File

@ -885,7 +885,7 @@ await page
### Filter by child/descendant ### 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 ```html card
<ul> <ul>
@ -983,6 +983,47 @@ await Expect(page
.toHaveCountAsync(1); .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. Note that the inner locator is matched starting from the outer one, not from the document root.
### Filter by matching an additional locator ### Filter by matching an additional locator

View File

@ -30,6 +30,7 @@ import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, get
export type LocatorOptions = { export type LocatorOptions = {
hasText?: string | RegExp; hasText?: string | RegExp;
has?: Locator; has?: Locator;
hasNot?: Locator;
}; };
export class Locator implements api.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.`); throw new Error(`Inner "has" locator must belong to the same frame.`);
this._selector += ` >> internal:has=` + JSON.stringify(locator._selector); 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> { private async _withElement<R>(task: (handle: ElementHandle<SVGElement | HTMLElement>, timeout?: number) => Promise<R>, timeout?: number): Promise<R> {

View File

@ -29,13 +29,15 @@ class Locator {
element: Element | undefined; element: Element | undefined;
elements: 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)[selectorSymbol] = selector;
(this as any)[injectedScriptSymbol] = injectedScript; (this as any)[injectedScriptSymbol] = injectedScript;
if (options?.hasText) if (options?.hasText)
selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.has) if (options?.has)
selector += ` >> internal:has=` + JSON.stringify((options.has as any)[selectorSymbol]); 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) { if (selector) {
const parsed = injectedScript.parseSelector(selector); const parsed = injectedScript.parseSelector(selector);
this.element = injectedScript.querySelector(parsed, injectedScript.document, false); this.element = injectedScript.querySelector(parsed, injectedScript.document, false);

View File

@ -112,6 +112,7 @@ export class InjectedScript {
this._engines.set('visible', this._createVisibleEngine()); this._engines.set('visible', this._createVisibleEngine());
this._engines.set('internal:control', this._createControlEngine()); this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine()); 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:or', { queryAll: () => [] });
this._engines.set('internal:and', { queryAll: () => [] }); this._engines.set('internal:and', { queryAll: () => [] });
this._engines.set('internal:not', { queryAll: () => [] }); this._engines.set('internal:not', { queryAll: () => [] });
@ -377,6 +378,16 @@ export class InjectedScript {
return { queryAll }; 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 { private _createVisibleEngine(): SelectorEngine {
const queryAll = (root: SelectorRoot, body: string) => { const queryAll = (root: SelectorRoot, body: string) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)

View File

@ -35,7 +35,7 @@ export class Selectors {
'data-testid', 'data-testid:light', 'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light', 'data-test-id', 'data-test-id:light',
'data-test', 'data-test: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', 'internal:or', 'internal:and', 'internal:not',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid', 'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid',
]); ]);

View File

@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi
import type { ParsedSelector } from './selectorParser'; import type { ParsedSelector } from './selectorParser';
export type Language = 'javascript' | 'python' | 'java' | 'csharp'; 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'; export type LocatorBase = 'page' | 'locator' | 'frame-locator';
type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; 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)); tokens.push(factory.generateLocator(base, 'has', inner));
continue; 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') { if (part.name === 'internal:or') {
const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed);
tokens.push(factory.generateLocator(base, 'or', inner)); tokens.push(factory.generateLocator(base, 'or', inner));
@ -210,6 +215,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `filter({ hasText: ${this.toHasText(body as string)} })`; return `filter({ hasText: ${this.toHasText(body as string)} })`;
case 'has': case 'has':
return `filter({ has: ${body} })`; return `filter({ has: ${body} })`;
case 'hasNot':
return `filter({ hasNot: ${body} })`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'and': case 'and':
@ -284,6 +291,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `filter(has_text=${this.toHasText(body as string)})`; return `filter(has_text=${this.toHasText(body as string)})`;
case 'has': case 'has':
return `filter(has=${body})`; return `filter(has=${body})`;
case 'hasNot':
return `filter(has_not=${body})`;
case 'or': case 'or':
return `or_(${body})`; return `or_(${body})`;
case 'and': case 'and':
@ -367,6 +376,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`;
case 'has': case 'has':
return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; return `filter(new ${clazz}.FilterOptions().setHas(${body}))`;
case 'hasNot':
return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`;
case 'or': case 'or':
return `or(${body})`; return `or(${body})`;
case 'and': case 'and':
@ -444,6 +455,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `Filter(new() { ${this.toHasText(body)} })`; return `Filter(new() { ${this.toHasText(body)} })`;
case 'has': case 'has':
return `Filter(new() { Has = ${body} })`; return `Filter(new() { Has = ${body} })`;
case 'hasNot':
return `Filter(new() { HasNot = ${body} })`;
case 'or': case 'or':
return `Or(${body})`; return `Or(${body})`;
case 'and': case 'and':

View File

@ -72,6 +72,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string {
.replace(/get_by_test_id/g, 'getbytestid') .replace(/get_by_test_id/g, 'getbytestid')
.replace(/get_by_([\w]+)/g, 'getby$1') .replace(/get_by_([\w]+)/g, 'getby$1')
.replace(/has_text/g, 'hastext') .replace(/has_text/g, 'hastext')
.replace(/has_not/g, 'hasnot')
.replace(/frame_locator/g, 'framelocator') .replace(/frame_locator/g, 'framelocator')
.replace(/[{}\s]/g, '') .replace(/[{}\s]/g, '')
.replace(/new\(\)/g, '') .replace(/new\(\)/g, '')
@ -102,10 +103,10 @@ function shiftParams(template: string, sub: number) {
} }
function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { 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). // TODO: handle or(locator), not(locator), and(locator).
while (true) { while (true) {
const hasMatch = template.match(/filter\(,?has=/); const hasMatch = template.match(/filter\(,?(has|hasnot)=/);
if (!hasMatch) if (!hasMatch)
break; break;
@ -129,6 +130,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, 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(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); template = template.substring(0, start - 1) + `2=$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end), paramsCountInHas - 1);
// Replace inner params with $5 value. // Replace inner params with $5 value.
@ -151,6 +153,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/nth\(([^)]+)\)/g, 'nth=$1') .replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
.replace(/,exact=false/g, '') .replace(/,exact=false/g, '')
.replace(/,exact=true/g, 's') .replace(/,exact=true/g, 's')
.replace(/\,/g, ']['); .replace(/\,/g, '][');
@ -180,7 +183,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
}) })
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
const param = params[+ordinal - 1]; const param = params[+ordinal - 1];
if (t.startsWith('internal:has=')) if (t.startsWith('internal:has=') || t.startsWith('internal:has-not='))
return param.text; return param.text;
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role')) if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
return escapeForAttributeSelector(param.text, suffix === 's'); return escapeForAttributeSelector(param.text, suffix === 's');

View File

@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser';
export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; 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']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
export type ParsedSelectorPart = { export type ParsedSelectorPart = {

View File

@ -3217,6 +3217,14 @@ export interface Page {
*/ */
has?: 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 * 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 * 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; 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 * 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 * 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; 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 * 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 * 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; 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 * 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 * 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; 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 * 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 * passed a [string], matching is case-insensitive and searches for a substring. For example, `"Playwright"` matches

View File

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

View File

@ -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 }) => { it('reverse engineer frameLocator', async ({ page }) => {
const locator = page const locator = page
.frameLocator('iframe') .frameLocator('iframe')

View File

@ -157,6 +157,9 @@ it('should support locator.filter', async ({ page, trace }) => {
has: page.locator('span'), has: page.locator('span'),
hasText: 'world', hasText: 'world',
})).toHaveCount(1); })).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 }) => { it('should support locator.or', async ({ page }) => {

View File

@ -400,6 +400,15 @@ it('should work with internal:has=', async ({ page, server }) => {
expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"'); expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"');
}); });
it('should work with internal:has-not=', async ({ page }) => {
await page.setContent(`<section><span></span><div></div></section><section><br></section>`);
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 }) => { it('should work with internal:or=', async ({ page, server }) => {
await page.setContent(` await page.setContent(`
<div>hello</div> <div>hello</div>