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 = '
hello
' const screen = page.elementLocator(document.body) expect(screen.getByText('hello').element()).toBe(document.querySelector('span')); -}) \ No newline at end of file +}) + +describe('locator.and', () => { + test('can find a button matching multiple criteria', () => { + document.body.innerHTML = ` + + ` + const locator = page.getByRole('button').and(page.getByTitle('Testing framework')) + expect(locator.element()).toBe(document.querySelector('button')) + }) + + test('throws an error if matches multiple elements', () => { + document.body.innerHTML = ` + + + ` + const locator = page.getByRole('button').and(page.getByTitle('Testing framework')) + expect(() => locator.element()).toThrowError( + `strict mode violation: getByRole('button').and(getByTitle('Testing framework')) resolved to 2 elements` + ) + }) +}) + +describe('locator.or', () => { + test('can find a button matching one of the criteria', () => { + document.body.innerHTML = ` + + ` + const locator1 = page.getByRole('button').or(page.getByTitle('Non-existing title')) + expect(locator1.element()).toBe(document.querySelector('button')) + + const locator2 = page.getByTitle('Testing framework').or(page.getByRole('alertdialog')) + expect(locator2.element()).toBe(document.querySelector('button')) + }) + + test('can find a button matching both criteria', () => { + document.body.innerHTML = ` + + ` + const locator = page.getByRole('button').or(page.getByTitle('Testing framework')) + expect(locator.element()).toBe(document.querySelector('button')) + }) + + test('throws an error if matches multiple elements', () => { + document.body.innerHTML = ` + + Vitest + ` + const locator = page.getByRole('button').or(page.getByTitle('Testing framework')) + expect(() => locator.element()).toThrowError( + `strict mode violation: getByRole('button').or(getByTitle('Testing framework')) resolved to 2 elements` + ) + }) +}) + +describe('locator.filter', () => { + test('can find element with a text inside', () => { + document.body.innerHTML = ` + + ` + const locator = page.getByRole('button').filter({ hasText: 'Vitest' }) + expect(locator.element()).toBe(document.querySelector('button')) + }) + + test('can find element with a text deep inside', () => { + document.body.innerHTML = ` +
Vitest
+ ` + const locator = page.getByRole('article').filter({ hasText: 'Vitest' }) + expect(locator.element()).toBe(document.querySelector('article')) + }) + + test('can find element with non matching text', () => { + document.body.innerHTML = ` + + ` + const locator1 = page.getByRole('button').filter({ hasNotText: 'Non-existing text' }) + expect(locator1.element()).toBe(document.querySelector('button')) + + const locator2 = page.getByRole('button').filter({ hasNotText: 'Vitest' }) + expect(() => locator2.element()).toThrowError( + `Cannot find element with locator: getByRole('button').filter({ hasNotText: 'Vitest' })` + ) + }) + + test('can find element with non matching text deep inside', () => { + document.body.innerHTML = ` +
Vitest
+ ` + const locator1 = page.getByRole('article').filter({ hasNotText: 'Non-existing text' }) + expect(locator1.element()).toBe(document.querySelector('article')) + + const locator2 = page.getByRole('article').filter({ hasNotText: 'Vitest' }) + expect(() => locator2.element()).toThrowError( + `Cannot find element with locator: getByRole('article').filter({ hasNotText: 'Vitest' })` + ) + }) + + test('can find element with a locator inside', () => { + document.body.innerHTML = ` +
+ ` + + const locator1 = page.getByRole('article').filter({ has: page.getByRole('button') }) + expect(locator1.element()).toBe(document.querySelector('article')) + + const locator2 = page.getByRole('article').filter({ has: page.getByRole('button').filter({ hasText: 'Vitest' }) }) + expect(locator2.element()).toBe(document.querySelector('article')) + + const locator3 = page.getByRole('article').filter({ has: page.getByRole('alert') }) + expect(() => locator3.element()).toThrowError( + `Cannot find element with locator: getByRole('article').filter({ has: getByRole('alert') })` + ) + + // locators reversed + const locator4 = page.getByRole('button').filter({ has: page.getByRole('article') }) + expect(() => locator4.element()).toThrowError( + `Cannot find element with locator: getByRole('button').filter({ has: getByRole('article') })` + ) + + document.body.innerHTML = ` +
+
+ ` + + expect(() => locator1.element()).toThrowError( + `strict mode violation: getByRole('article').filter({ has: getByRole('button') }) resolved to 2 elements` + ) + }) + + test('can find element with hasNot', () => { + document.body.innerHTML = ` +
+ ` + + const locator1 = page.getByRole('article').filter({ hasNot: page.getByRole('alert') }) + expect(locator1.element()).toBe(document.querySelector('article')) + + const locator2 = page.getByRole('article').filter({ hasNot: page.getByRole('button') }) + expect(() => locator2.element()).toThrowError( + `Cannot find element with locator: getByRole('article').filter({ hasNot: getByRole('button') })` + ) + }) +}) diff --git a/test/browser/specs/locators.test.ts b/test/browser/specs/locators.test.ts index 55003c55a..da766deb7 100644 --- a/test/browser/specs/locators.test.ts +++ b/test/browser/specs/locators.test.ts @@ -14,6 +14,9 @@ test('locators work correctly', async () => { expect(stdout).toReportPassedTest('query.test.ts', browser) }) - expect(stdout).toReportSummaryTestFiles({ passed: instances.length * 2 }) - expect(stdout).toReportSummaryTests({ passed: instances.length * 3 }) + const COUNT_TEST_FILES = 2 + const COUNT_TESTS_OVERALL = 14 + + expect(stdout).toReportSummaryTestFiles({ passed: instances.length * COUNT_TEST_FILES }) + expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL }) })