diff --git a/docs/guide/browser/locators.md b/docs/guide/browser/locators.md
index f175e7e02..655974ea4 100644
--- a/docs/guide/browser/locators.md
+++ b/docs/guide/browser/locators.md
@@ -450,6 +450,148 @@ It is sugar for `nth(-1)`.
page.getByRole('textbox').last() // ✅
```
+## and
+
+```ts
+function and(locator: Locator): Locator
+```
+
+This method creates a new locator that matches both the parent and provided locator. The following example finds a button with a specific title:
+
+```ts
+page.getByRole('button').and(page.getByTitle('Subscribe'))
+```
+
+## or
+
+```ts
+function or(locator: Locator): Locator
+```
+
+This method creates a new locator that matches either one or both locators.
+
+::: warning
+Note that if locator matches more than a single element, calling another method might throw an error if it expects a single element:
+
+```tsx
+<>
+
+ Error happened!
+>
+
+page.getByRole('button')
+ .or(page.getByRole('link'))
+ .click() // ❌ matches multiple elements
+```
+:::
+
+## filter
+
+```ts
+function filter(options: LocatorOptions): Locator
+```
+
+This methods narrows down the locator according to the options, such as filtering by text. It can be chained to apply multiple filters.
+
+### has
+
+- **Type:** `Locator`
+
+This options narrows down the selector to match elements that contain other elements matching provided locator. For example, with this HTML:
+
+```html{1,3}
+
+
Vitest
+
+
+
Rolldown
+
+```
+
+We can narrow down the locator to only find the `article` with `Vitest` text inside:
+
+```ts
+page.getByRole('article').filter({ has: page.getByText('Vitest') }) // ✅
+```
+
+::: warning
+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.
+
+Meaning, you cannot pass down a locator that queries the element outside of the parent locator:
+
+```ts
+page.getByText('Vitest').filter({ has: page.getByRole('article') }) // ❌
+```
+
+This example will fail because the `article` element is outside the element with `Vitest` text.
+:::
+
+::: tip
+This method can be chained to narrow down the element even further:
+
+```ts
+page.getByRole('article')
+ .filter({ has: page.getByRole('button', { name: 'delete row' }) })
+ .filter({ has: page.getByText('Vitest') })
+```
+:::
+
+### hasNot
+
+- **Type:** `Locator`
+
+This option narrows down the selector to match elements that do not contain other elements matching provided locator. For example, with this HTML:
+
+```html{1,3}
+
+
Vitest
+
+
+
Rolldown
+
+```
+
+We can narrow down the locator to only find the `article` that doesn't have `Rolldown` inside.
+
+```ts
+page.getByRole('article')
+ .filter({ hasNot: page.getByText('Rolldown') }) // ✅
+page.getByRole('article')
+ .filter({ hasNot: page.getByText('Vitest') }) // ❌
+```
+
+::: warning
+Note that provided locator is queried against the parent, not the document root, just like [`has`](#has) option.
+:::
+
+### hasText
+
+- **Type:** `string | RegExp`
+
+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.
+
+```html{1,3}
+
+
Vitest
+
+
+
Rolldown
+
+```
+
+Both locators will find the same element because the search is case-insensitive:
+
+```ts
+page.getByRole('article').filter({ hasText: 'Vitest' }) // ✅
+page.getByRole('article').filter({ hasText: 'Vite' }) // ✅
+```
+
+### hasNotText
+
+- **Type:** `string | RegExp`
+
+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.
+
## Methods
All methods are asynchronous and must be awaited. Since Vitest 3, tests will fail if a method is not awaited.
diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts
index ee1a2a156..a5025e4d9 100644
--- a/packages/browser/context.d.ts
+++ b/packages/browser/context.d.ts
@@ -452,6 +452,21 @@ export interface Locator extends LocatorSelectors {
* @see {@link https://vitest.dev/guide/browser/locators#last}
*/
last(): Locator
+ /**
+ * Returns a locator that matches both the current locator and the provided locator.
+ * @see {@link https://vitest.dev/guide/browser/locators#and}
+ */
+ and(locator: Locator): Locator
+ /**
+ * Returns a locator that matches either the current locator or the provided locator.
+ * @see {@link https://vitest.dev/guide/browser/locators#or}
+ */
+ or(locator: Locator): Locator
+ /**
+ * Narrows existing locator according to the options.
+ * @see {@link https://vitest.dev/guide/browser/locators#filter}
+ */
+ filter(options: LocatorOptions): Locator
}
export interface UserEventTabOptions {
@@ -506,6 +521,13 @@ export const server: {
config: SerializedConfig
}
+export interface LocatorOptions {
+ hasText?: string | RegExp
+ hasNotText?: string | RegExp
+ has?: Locator
+ hasNot?: Locator
+}
+
/**
* Handler for user interactions. The support is provided by the browser provider (`playwright` or `webdriverio`).
* If used with `preview` provider, fallbacks to simulated events via `@testing-library/user-event`.
diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts
index fcdff7241..1885e68b0 100644
--- a/packages/browser/src/client/tester/locators/index.ts
+++ b/packages/browser/src/client/tester/locators/index.ts
@@ -25,6 +25,7 @@ import {
} from 'ivya'
import { ensureAwaited, getBrowserState } from '../../utils'
import { getElementError } from '../public-utils'
+import { escapeForTextSelector } from '../utils'
// we prefer using playwright locators because they are more powerful and support Shadow DOM
export const selectorEngine: Ivya = Ivya.create({
@@ -167,6 +168,42 @@ export abstract class Locator {
return this.locator(getByTitleSelector(title, options))
}
+ public filter(filter: LocatorOptions): Locator {
+ const selectors = []
+
+ if (filter?.hasText) {
+ selectors.push(`internal:has-text=${escapeForTextSelector(filter.hasText, false)}`)
+ }
+
+ if (filter?.hasNotText) {
+ selectors.push(`internal:has-not-text=${escapeForTextSelector(filter.hasNotText, false)}`)
+ }
+
+ if (filter?.has) {
+ const locator = filter.has as Locator
+ selectors.push(`internal:has=${JSON.stringify(locator._pwSelector || locator.selector)}`)
+ }
+
+ if (filter?.hasNot) {
+ const locator = filter.hasNot as Locator
+ selectors.push(`internal:has-not=${JSON.stringify(locator._pwSelector || locator.selector)}`)
+ }
+
+ if (!selectors.length) {
+ throw new Error(`Locator.filter expects at least one filter. None provided.`)
+ }
+
+ return this.locator(selectors.join(' >> '))
+ }
+
+ public and(locator: Locator): Locator {
+ return this.locator(`internal:and=${JSON.stringify(locator._pwSelector || locator.selector)}`)
+ }
+
+ public or(locator: Locator): Locator {
+ return this.locator(`internal:or=${JSON.stringify(locator._pwSelector || locator.selector)}`)
+ }
+
public query(): Element | null {
const parsedSelector = this._parsedSelector || (this._parsedSelector = selectorEngine.parseSelector(this._pwSelector || this.selector))
return selectorEngine.querySelector(parsedSelector, document.documentElement, true)
diff --git a/packages/browser/src/client/tester/utils.ts b/packages/browser/src/client/tester/utils.ts
index e6b1a1fdb..acf8e1086 100644
--- a/packages/browser/src/client/tester/utils.ts
+++ b/packages/browser/src/client/tester/utils.ts
@@ -178,3 +178,22 @@ export function getIframeScale(): number {
}
return scale
}
+
+function escapeRegexForSelector(re: RegExp): string {
+ // Unicode mode does not allow "identity character escapes", so we do not escape and
+ // hope that it does not contain quotes and/or >> signs.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape
+ // TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}.
+ if (re.unicode || (re as any).unicodeSets) {
+ return String(re)
+ }
+ // Even number of backslashes followed by the quote -> insert a backslash.
+ return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>')
+}
+
+export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
+ if (typeof text !== 'string') {
+ return escapeRegexForSelector(text)
+ }
+ return `${JSON.stringify(text)}${exact ? 's' : 'i'}`
+}
diff --git a/test/browser/fixtures/locators/query.test.ts b/test/browser/fixtures/locators/query.test.ts
index 4b74bd6ba..68a8d5f96 100644
--- a/test/browser/fixtures/locators/query.test.ts
+++ b/test/browser/fixtures/locators/query.test.ts
@@ -1,5 +1,5 @@
import { page } from '@vitest/browser/context';
-import { afterEach, expect, test } from 'vitest';
+import { afterEach, describe, expect, test } from 'vitest';
afterEach(() => {
document.body.innerHTML = ''
@@ -13,4 +13,147 @@ test('can find elements inside the body', () => {
document.body.innerHTML = '