Skip to content

Commit 63949b1

Browse files
feat(browser): introduce and, or and filter locators (#7463)
Co-authored-by: Ari Perkkiö <[email protected]>
1 parent e5851e4 commit 63949b1

File tree

6 files changed

+370
-4
lines changed

6 files changed

+370
-4
lines changed

docs/guide/browser/locators.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,148 @@ It is sugar for `nth(-1)`.
450450
page.getByRole('textbox').last() // ✅
451451
```
452452

453+
## and
454+
455+
```ts
456+
function and(locator: Locator): Locator
457+
```
458+
459+
This method creates a new locator that matches both the parent and provided locator. The following example finds a button with a specific title:
460+
461+
```ts
462+
page.getByRole('button').and(page.getByTitle('Subscribe'))
463+
```
464+
465+
## or
466+
467+
```ts
468+
function or(locator: Locator): Locator
469+
```
470+
471+
This method creates a new locator that matches either one or both locators.
472+
473+
::: warning
474+
Note that if locator matches more than a single element, calling another method might throw an error if it expects a single element:
475+
476+
```tsx
477+
<>
478+
<button>Click me</button>
479+
<a href="https://vitest.dev">Error happened!</a>
480+
</>
481+
482+
page.getByRole('button')
483+
.or(page.getByRole('link'))
484+
.click() // ❌ matches multiple elements
485+
```
486+
:::
487+
488+
## filter
489+
490+
```ts
491+
function filter(options: LocatorOptions): Locator
492+
```
493+
494+
This methods narrows down the locator according to the options, such as filtering by text. It can be chained to apply multiple filters.
495+
496+
### has
497+
498+
- **Type:** `Locator`
499+
500+
This options narrows down the selector to match elements that contain other elements matching provided locator. For example, with this HTML:
501+
502+
```html{1,3}
503+
<article>
504+
<div>Vitest</div>
505+
</article>
506+
<article>
507+
<div>Rolldown</div>
508+
</article>
509+
```
510+
511+
We can narrow down the locator to only find the `article` with `Vitest` text inside:
512+
513+
```ts
514+
page.getByRole('article').filter({ has: page.getByText('Vitest') }) // ✅
515+
```
516+
517+
::: warning
518+
Provided locator (`page.getByText('Vitest')` in the example) must be relative to the parent locator (`page.getByRole('article')` in the example). It will be queried starting with the parent locator, not the document root.
519+
520+
Meaning, you cannot pass down a locator that queries the element outside of the parent locator:
521+
522+
```ts
523+
page.getByText('Vitest').filter({ has: page.getByRole('article') }) // ❌
524+
```
525+
526+
This example will fail because the `article` element is outside the element with `Vitest` text.
527+
:::
528+
529+
::: tip
530+
This method can be chained to narrow down the element even further:
531+
532+
```ts
533+
page.getByRole('article')
534+
.filter({ has: page.getByRole('button', { name: 'delete row' }) })
535+
.filter({ has: page.getByText('Vitest') })
536+
```
537+
:::
538+
539+
### hasNot
540+
541+
- **Type:** `Locator`
542+
543+
This option narrows down the selector to match elements that do not contain other elements matching provided locator. For example, with this HTML:
544+
545+
```html{1,3}
546+
<article>
547+
<div>Vitest</div>
548+
</article>
549+
<article>
550+
<div>Rolldown</div>
551+
</article>
552+
```
553+
554+
We can narrow down the locator to only find the `article` that doesn't have `Rolldown` inside.
555+
556+
```ts
557+
page.getByRole('article')
558+
.filter({ hasNot: page.getByText('Rolldown') }) // ✅
559+
page.getByRole('article')
560+
.filter({ hasNot: page.getByText('Vitest') }) // ❌
561+
```
562+
563+
::: warning
564+
Note that provided locator is queried against the parent, not the document root, just like [`has`](#has) option.
565+
:::
566+
567+
### hasText
568+
569+
- **Type:** `string | RegExp`
570+
571+
This options narrows down the selector to only match elements that contain provided text somewhere inside. When the `string` is passed, matching is case-insensitive and searches for a substring.
572+
573+
```html{1,3}
574+
<article>
575+
<div>Vitest</div>
576+
</article>
577+
<article>
578+
<div>Rolldown</div>
579+
</article>
580+
```
581+
582+
Both locators will find the same element because the search is case-insensitive:
583+
584+
```ts
585+
page.getByRole('article').filter({ hasText: 'Vitest' }) // ✅
586+
page.getByRole('article').filter({ hasText: 'Vite' }) // ✅
587+
```
588+
589+
### hasNotText
590+
591+
- **Type:** `string | RegExp`
592+
593+
This options narrows down the selector to only match elements that do not contain provided text somewhere inside. When the `string` is passed, matching is case-insensitive and searches for a substring.
594+
453595
## Methods
454596

455597
All methods are asynchronous and must be awaited. Since Vitest 3, tests will fail if a method is not awaited.

packages/browser/context.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,21 @@ export interface Locator extends LocatorSelectors {
452452
* @see {@link https://vitest.dev/guide/browser/locators#last}
453453
*/
454454
last(): Locator
455+
/**
456+
* Returns a locator that matches both the current locator and the provided locator.
457+
* @see {@link https://vitest.dev/guide/browser/locators#and}
458+
*/
459+
and(locator: Locator): Locator
460+
/**
461+
* Returns a locator that matches either the current locator or the provided locator.
462+
* @see {@link https://vitest.dev/guide/browser/locators#or}
463+
*/
464+
or(locator: Locator): Locator
465+
/**
466+
* Narrows existing locator according to the options.
467+
* @see {@link https://vitest.dev/guide/browser/locators#filter}
468+
*/
469+
filter(options: LocatorOptions): Locator
455470
}
456471

457472
export interface UserEventTabOptions {
@@ -506,6 +521,13 @@ export const server: {
506521
config: SerializedConfig
507522
}
508523

524+
export interface LocatorOptions {
525+
hasText?: string | RegExp
526+
hasNotText?: string | RegExp
527+
has?: Locator
528+
hasNot?: Locator
529+
}
530+
509531
/**
510532
* Handler for user interactions. The support is provided by the browser provider (`playwright` or `webdriverio`).
511533
* If used with `preview` provider, fallbacks to simulated events via `@testing-library/user-event`.

packages/browser/src/client/tester/locators/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from 'ivya'
2626
import { ensureAwaited, getBrowserState } from '../../utils'
2727
import { getElementError } from '../public-utils'
28+
import { escapeForTextSelector } from '../utils'
2829

2930
// we prefer using playwright locators because they are more powerful and support Shadow DOM
3031
export const selectorEngine: Ivya = Ivya.create({
@@ -167,6 +168,42 @@ export abstract class Locator {
167168
return this.locator(getByTitleSelector(title, options))
168169
}
169170

171+
public filter(filter: LocatorOptions): Locator {
172+
const selectors = []
173+
174+
if (filter?.hasText) {
175+
selectors.push(`internal:has-text=${escapeForTextSelector(filter.hasText, false)}`)
176+
}
177+
178+
if (filter?.hasNotText) {
179+
selectors.push(`internal:has-not-text=${escapeForTextSelector(filter.hasNotText, false)}`)
180+
}
181+
182+
if (filter?.has) {
183+
const locator = filter.has as Locator
184+
selectors.push(`internal:has=${JSON.stringify(locator._pwSelector || locator.selector)}`)
185+
}
186+
187+
if (filter?.hasNot) {
188+
const locator = filter.hasNot as Locator
189+
selectors.push(`internal:has-not=${JSON.stringify(locator._pwSelector || locator.selector)}`)
190+
}
191+
192+
if (!selectors.length) {
193+
throw new Error(`Locator.filter expects at least one filter. None provided.`)
194+
}
195+
196+
return this.locator(selectors.join(' >> '))
197+
}
198+
199+
public and(locator: Locator): Locator {
200+
return this.locator(`internal:and=${JSON.stringify(locator._pwSelector || locator.selector)}`)
201+
}
202+
203+
public or(locator: Locator): Locator {
204+
return this.locator(`internal:or=${JSON.stringify(locator._pwSelector || locator.selector)}`)
205+
}
206+
170207
public query(): Element | null {
171208
const parsedSelector = this._parsedSelector || (this._parsedSelector = selectorEngine.parseSelector(this._pwSelector || this.selector))
172209
return selectorEngine.querySelector(parsedSelector, document.documentElement, true)

packages/browser/src/client/tester/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,22 @@ export function getIframeScale(): number {
178178
}
179179
return scale
180180
}
181+
182+
function escapeRegexForSelector(re: RegExp): string {
183+
// Unicode mode does not allow "identity character escapes", so we do not escape and
184+
// hope that it does not contain quotes and/or >> signs.
185+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape
186+
// TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}.
187+
if (re.unicode || (re as any).unicodeSets) {
188+
return String(re)
189+
}
190+
// Even number of backslashes followed by the quote -> insert a backslash.
191+
return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>')
192+
}
193+
194+
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
195+
if (typeof text !== 'string') {
196+
return escapeRegexForSelector(text)
197+
}
198+
return `${JSON.stringify(text)}${exact ? 's' : 'i'}`
199+
}

0 commit comments

Comments
 (0)