| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -955,3 +955,76 @@ test('works correctly', async () => { | |||
| 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 | + ``` | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -580,5 +580,14 @@ export interface BrowserPage extends LocatorSelectors { | |||
| 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 | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -6,6 +6,7 @@ export const server = null | |||
| 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,12 +1,15 @@ | |||
| 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,9 +320,12 @@ export const page: BrowserPage = { | |||
| 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 | }, | |
@@ -348,3 +354,35 @@ function convertToSelector(elementOrLocator: Element | Locator): string { | |||
| 348 | 354 | function getTaskFullName(task: RunnerTask): string { | |
| 349 | 355 | return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name | |
| 350 | 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 | + } | ||
| 388 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -35,6 +35,9 @@ page.extend({ | |||
| 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), | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -35,6 +35,9 @@ page.extend({ | |||
| 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), | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -37,6 +37,9 @@ page.extend({ | |||
| 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 | }, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -49,7 +49,7 @@ async function generateContextFile( | |||
| 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,7 +64,7 @@ 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,86 @@ | |||
| 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 | + }) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,15 @@ | |||
| 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 | + }) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments