[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): custom locators API (#7993)

authored by

Vladimir and committed by
GitHub
(May 17, 2025, 5:39 PM +0200) e6fbd8d2 d1a1df03

+254 -3
+9
packages/browser/context.d.ts
··· 580 580 elementLocator(element: Element): Locator 581 581 } 582 582 583 + export interface BrowserLocators { 584 + createElementLocators(element: Element): LocatorSelectors 585 + extend(methods: { 586 + [K in keyof LocatorSelectors]?: (...args: Parameters<LocatorSelectors[K]>) => ReturnType<LocatorSelectors[K]> | string 587 + }): void 588 + } 589 + 590 + export const locators: BrowserLocators 591 + 583 592 export const page: BrowserPage 584 593 export const cdp: () => CDPSession
+1
packages/browser/context.js
··· 6 6 export const userEvent = null 7 7 export const cdp = null 8 8 export const commands = null 9 + export const locators = null 9 10 10 11 const pool = globalThis.__vitest_worker__?.ctx?.pool 11 12
+1
test/browser/package.json
··· 14 14 "test-timeout": "vitest --root ./fixtures/timeout", 15 15 "test-mocking-watch": "vitest --root ./fixtures/mocking-watch", 16 16 "test-locators": "vitest --root ./fixtures/locators", 17 + "test-locators-custom": "vitest --root ./fixtures/locators-custom", 17 18 "test-different-configs": "vitest --root ./fixtures/multiple-different-configs", 18 19 "test-setup-file": "vitest --root ./fixtures/setup-file", 19 20 "test-snapshots": "vitest --root ./fixtures/update-snapshot",
+73
docs/guide/browser/locators.md
··· 955 955 }) 956 956 ``` 957 957 ::: 958 + 959 + ## Custom Locators <Version>3.2.0</Version> <Badge type="danger">advanced</Badge> {#custom-locators} 960 + 961 + You can extend built-in locators API by defining an object of locator factories. These methods will exist as methods on the `page` object and any created locator. 962 + 963 + These locators can be useful if built-in locators are not enough. For example, when you use a custom framework for your UI. 964 + 965 + The locator factory needs to return a selector string or a locator itself. 966 + 967 + ::: tip 968 + The selector syntax is identical to Playwright locators. Please, read [their guide](https://playwright.dev/docs/other-locators) to better understand how to work with them. 969 + ::: 970 + 971 + ```ts 972 + import { locators } from '@vitest/browser/context' 973 + 974 + locators.extend({ 975 + getByArticleTitle(title) { 976 + return `[data-title="${title}"]` 977 + }, 978 + getByArticleCommentsCount(count) { 979 + return `.comments :text("${count} comments")` 980 + }, 981 + async previewComments() { 982 + // you have access to the current locator via "this" 983 + // beware that if the method was called on `page`, `this` will be `page`, 984 + // not the locator! 985 + if (this !== page) { 986 + await this.click() 987 + } 988 + // ... 989 + } 990 + }) 991 + 992 + // if you are using typescript, you can extend LocatorSelectors interface 993 + // to have the autocompletion in locators.extend, page.* and locator.* methods 994 + declare module '@vitest/browser/context' { 995 + interface LocatorSelectors { 996 + // if the custom method returns a string, it will be converted into a locator 997 + // if it returns anything else, then it will be returned as usual 998 + getByArticleTitle(title: string): Locator 999 + getByArticleCommentsCount(count: number): Locator 1000 + 1001 + // Vitest will return a promise and won't try to convert it into a locator 1002 + previewComments(this: Locator): Promise<void> 1003 + } 1004 + } 1005 + ``` 1006 + 1007 + If the method is called on the global `page` object, then selector will be applied to the whole page. In the example bellow, `getByArticleTitle` will find all elements with an attribute `data-title` with the value of `title`. However, if the method is called on the locator, then it will be scoped to that locator. 1008 + 1009 + ```html 1010 + <article data-title="Hello, World!"> 1011 + Hello, World! 1012 + <button id="comments">2 comments</button> 1013 + </article> 1014 + 1015 + <article data-title="Hello, Vitest!"> 1016 + Hello, Vitest! 1017 + <button id="comments">0 comments</button> 1018 + </article> 1019 + ``` 1020 + 1021 + ```ts 1022 + const articles = page.getByRole('article') 1023 + const worldArticle = page.getByArticleTitle('Hello, World!') // ✅ 1024 + const commentsElement = worldArticle.getByArticleCommentsCount(2) // ✅ 1025 + const wrongCommentsElement = worldArticle.getByArticleCommentsCount(0) // ❌ 1026 + const wrongElement = page.getByArticleTitle('No Article!') // ❌ 1027 + 1028 + await commentsElement.previewComments() // ✅ 1029 + await wrongCommentsElement.previewComments() // ❌ 1030 + ```
+19
test/browser/specs/locators.test.ts
··· 20 20 expect(stdout).toReportSummaryTestFiles({ passed: instances.length * COUNT_TEST_FILES }) 21 21 expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL }) 22 22 }) 23 + 24 + test('custom locators work', async () => { 25 + const { stderr, stdout } = await runBrowserTests({ 26 + root: './fixtures/locators-custom', 27 + reporters: [['verbose', { isTTY: false }]], 28 + }) 29 + 30 + expect(stderr).toReportNoErrors() 31 + 32 + instances.forEach(({ browser }) => { 33 + expect(stdout).toReportPassedTest('basic.test.tsx', browser) 34 + }) 35 + 36 + const COUNT_TEST_FILES = 1 37 + const COUNT_TESTS_OVERALL = 4 38 + 39 + expect(stdout).toReportSummaryTestFiles({ passed: instances.length * COUNT_TEST_FILES }) 40 + expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL }) 41 + })
+86
test/browser/fixtures/locators-custom/basic.test.tsx
··· 1 + import { type Locator, locators, page } from '@vitest/browser/context'; 2 + import { beforeEach, expect, test } from 'vitest'; 3 + 4 + declare module '@vitest/browser/context' { 5 + interface LocatorSelectors { 6 + getByCustomTitle: (title: string) => Locator 7 + getByNestedTitle: (title: string) => Locator 8 + updateHtml(this: Locator, html: string): Promise<void> 9 + updateDocumentHtml(this: BrowserPage, html: string): Promise<void> 10 + } 11 + } 12 + 13 + locators.extend({ 14 + getByCustomTitle(title) { 15 + return `[data-title="${title}"]` 16 + }, 17 + getByNestedTitle(title) { 18 + return `[data-parent] >> [data-title="${title}"]` 19 + }, 20 + async updateHtml(this: Locator, html) { 21 + this.element().innerHTML = html 22 + }, 23 + async updateDocumentHtml(html) { 24 + document.body.innerHTML = html 25 + }, 26 + }) 27 + 28 + beforeEach(() => { 29 + document.body.innerHTML = '' 30 + }) 31 + 32 + test('new selector works on both page and locator', async () => { 33 + document.body.innerHTML = ` 34 + <article> 35 + <h1>Hello World</h1> 36 + <div data-title="Hello World">Text Content</div> 37 + </article> 38 + ` 39 + 40 + await expect.element(page.getByCustomTitle('Hello World')).toBeVisible() 41 + await expect.element(page.getByRole('article').getByCustomTitle('Hello World')).toBeVisible() 42 + 43 + await expect.element(page.getByCustomTitle('NonExisting Title')).not.toBeInTheDocument() 44 + }) 45 + 46 + test('new nested selector works on both page and locator', async () => { 47 + document.body.innerHTML = ` 48 + <article> 49 + <h1>Hello World</h1> 50 + <div data-parent> 51 + <div data-title="Hello World">Text Content</div> 52 + </div> 53 + </article> 54 + ` 55 + 56 + await expect.element(page.getByNestedTitle('Hello World')).toBeVisible() 57 + await expect.element(page.getByRole('article').getByNestedTitle('Hello World')).toBeVisible() 58 + 59 + await expect.element(page.getByNestedTitle('NonExisting Title')).not.toBeInTheDocument() 60 + }) 61 + 62 + test('new added method works on the locator', async () => { 63 + document.body.innerHTML = ` 64 + <div data-title="Hello World">Text Content</div> 65 + ` 66 + 67 + const title = page.getByCustomTitle('Hello World') 68 + 69 + await expect.element(title).toHaveTextContent('Text Content') 70 + 71 + await title.updateHtml('New Content') 72 + 73 + await expect.element(title).toHaveTextContent('New Content') 74 + }) 75 + 76 + test('new added method works on the page', async () => { 77 + document.body.innerHTML = ` 78 + Hello World 79 + ` 80 + 81 + expect(document.body).toHaveTextContent('Hello World') 82 + 83 + await page.updateDocumentHtml('New Content') 84 + 85 + expect(document.body).toHaveTextContent('New Content') 86 + })
+15
test/browser/fixtures/locators-custom/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url' 2 + import { defineConfig } from 'vitest/config' 3 + import { instances, provider } from '../../settings' 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 + test: { 8 + browser: { 9 + enabled: true, 10 + provider, 11 + headless: true, 12 + instances, 13 + }, 14 + }, 15 + })
+39 -1
packages/browser/src/client/tester/context.ts
··· 1 1 import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event' 2 2 import type { RunnerTask } from 'vitest' 3 3 import type { 4 + BrowserLocators, 4 5 BrowserPage, 5 6 Locator, 6 7 UserEvent, 7 8 } from '../../../context' 8 9 import type { IframeViewportEvent } from '../client' 9 10 import type { BrowserRunnerState } from '../utils' 11 + import type { Locator as LocatorAPI } from './locators/index' 12 + import { getElementLocatorSelectors } from '@vitest/browser/utils' 10 13 import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' 11 14 import { convertElementToCssSelector, processTimeoutOptions } from './utils' 12 15 ··· 317 320 elementLocator() { 318 321 throw new Error(`Method "elementLocator" is not implemented in the "${provider}" provider.`) 319 322 }, 323 + _createLocator() { 324 + throw new Error(`Method "_createLocator" is not implemented in the "${provider}" provider.`) 325 + }, 320 326 extend(methods) { 321 327 for (const key in methods) { 322 - (page as any)[key] = (methods as any)[key] 328 + (page as any)[key] = (methods as any)[key].bind(page) 323 329 } 324 330 return page 325 331 }, ··· 347 353 348 354 function getTaskFullName(task: RunnerTask): string { 349 355 return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name 356 + } 357 + 358 + export const locators: BrowserLocators = { 359 + createElementLocators: getElementLocatorSelectors, 360 + extend(methods) { 361 + const Locator = page._createLocator('css=body').constructor as typeof LocatorAPI 362 + for (const method in methods) { 363 + const cb = (methods as any)[method] as (...args: any[]) => string | Locator 364 + // @ts-expect-error types are hard to make work 365 + Locator.prototype[method] = function (...args: any[]) { 366 + const selectorOrLocator = cb.call(this, ...args) 367 + if (typeof selectorOrLocator === 'string') { 368 + return this.locator(selectorOrLocator) 369 + } 370 + return selectorOrLocator 371 + } 372 + page[method as 'getByRole'] = function (...args: any[]) { 373 + const selectorOrLocator = cb.call(this, ...args) 374 + if (typeof selectorOrLocator === 'string') { 375 + return page._createLocator(selectorOrLocator) 376 + } 377 + return selectorOrLocator 378 + } 379 + } 380 + }, 381 + } 382 + 383 + declare module '@vitest/browser/context' { 384 + interface BrowserPage { 385 + /** @internal */ 386 + _createLocator: (selector: string) => Locator 387 + } 350 388 }
+2 -2
packages/browser/src/node/plugins/pluginContext.ts
··· 49 49 const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) 50 50 51 51 return ` 52 - import { page, createUserEvent, cdp } from '${distContextPath}' 52 + import { page, createUserEvent, cdp, locators } from '${distContextPath}' 53 53 ${userEventNonProviderImport} 54 54 55 55 export const server = { ··· 64 64 } 65 65 export const commands = server.commands 66 66 export const userEvent = createUserEvent(_userEventSetup) 67 - export { page, cdp } 67 + export { page, cdp, locators } 68 68 ` 69 69 } 70 70
+3
packages/browser/src/client/tester/locators/playwright.ts
··· 35 35 return new PlaywrightLocator(getByTitleSelector(title, options)) 36 36 }, 37 37 38 + _createLocator(selector: string) { 39 + return new PlaywrightLocator(selector) 40 + }, 38 41 elementLocator(element: Element) { 39 42 return new PlaywrightLocator( 40 43 selectorEngine.generateSelectorSimple(element),
+3
packages/browser/src/client/tester/locators/preview.ts
··· 35 35 return new PreviewLocator(getByTitleSelector(title, options)) 36 36 }, 37 37 38 + _createLocator(selector: string) { 39 + return new PreviewLocator(selector) 40 + }, 38 41 elementLocator(element: Element) { 39 42 return new PreviewLocator( 40 43 selectorEngine.generateSelectorSimple(element),
+3
packages/browser/src/client/tester/locators/webdriverio.ts
··· 37 37 return new WebdriverIOLocator(getByTitleSelector(title, options)) 38 38 }, 39 39 40 + _createLocator(selector: string) { 41 + return new WebdriverIOLocator(selector) 42 + }, 40 43 elementLocator(element: Element) { 41 44 return new WebdriverIOLocator(selectorEngine.generateSelectorSimple(element)) 42 45 },