Skip to content

Commit 63949b1

Browse files
feat(browser): introduce and, or and filter locators (#7463)
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy