Skip to content

Commit e6fbd8d

Browse files
authored
feat(browser): custom locators API (#7993)
1 parent d1a1df0 commit e6fbd8d

File tree

12 files changed

+254
-3
lines changed

12 files changed

+254
-3
lines changed

docs/guide/browser/locators.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,3 +955,76 @@ test('works correctly', async () => {
955955
})
956956
```
957957
:::
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+
```

packages/browser/context.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,5 +580,14 @@ export interface BrowserPage extends LocatorSelectors {
580580
elementLocator(element: Element): Locator
581581
}
582582

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+
583592
export const page: BrowserPage
584593
export const cdp: () => CDPSession

packages/browser/context.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const server = null
66
export const userEvent = null
77
export const cdp = null
88
export const commands = null
9+
export const locators = null
910

1011
const pool = globalThis.__vitest_worker__?.ctx?.pool
1112

packages/browser/src/client/tester/context.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event'
22
import type { RunnerTask } from 'vitest'
33
import type {
4+
BrowserLocators,
45
BrowserPage,
56
Locator,
67
UserEvent,
78
} from '../../../context'
89
import type { IframeViewportEvent } from '../client'
910
import type { BrowserRunnerState } from '../utils'
11+
import type { Locator as LocatorAPI } from './locators/index'
12+
import { getElementLocatorSelectors } from '@vitest/browser/utils'
1013
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
1114
import { convertElementToCssSelector, processTimeoutOptions } from './utils'
1215

@@ -317,9 +320,12 @@ export const page: BrowserPage = {
317320
elementLocator() {
318321
throw new Error(`Method "elementLocator" is not implemented in the "${provider}" provider.`)
319322
},
323+
_createLocator() {
324+
throw new Error(`Method "_createLocator" is not implemented in the "${provider}" provider.`)
325+
},
320326
extend(methods) {
321327
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)
323329
}
324330
return page
325331
},
@@ -348,3 +354,35 @@ function convertToSelector(elementOrLocator: Element | Locator): string {
348354
function getTaskFullName(task: RunnerTask): string {
349355
return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name
350356
}
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+
}

packages/browser/src/client/tester/locators/playwright.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ page.extend({
3535
return new PlaywrightLocator(getByTitleSelector(title, options))
3636
},
3737

38+
_createLocator(selector: string) {
39+
return new PlaywrightLocator(selector)
40+
},
3841
elementLocator(element: Element) {
3942
return new PlaywrightLocator(
4043
selectorEngine.generateSelectorSimple(element),

packages/browser/src/client/tester/locators/preview.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ page.extend({
3535
return new PreviewLocator(getByTitleSelector(title, options))
3636
},
3737

38+
_createLocator(selector: string) {
39+
return new PreviewLocator(selector)
40+
},
3841
elementLocator(element: Element) {
3942
return new PreviewLocator(
4043
selectorEngine.generateSelectorSimple(element),

packages/browser/src/client/tester/locators/webdriverio.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ page.extend({
3737
return new WebdriverIOLocator(getByTitleSelector(title, options))
3838
},
3939

40+
_createLocator(selector: string) {
41+
return new WebdriverIOLocator(selector)
42+
},
4043
elementLocator(element: Element) {
4144
return new WebdriverIOLocator(selectorEngine.generateSelectorSimple(element))
4245
},

packages/browser/src/node/plugins/pluginContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ async function generateContextFile(
4949
const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`)
5050

5151
return `
52-
import { page, createUserEvent, cdp } from '${distContextPath}'
52+
import { page, createUserEvent, cdp, locators } from '${distContextPath}'
5353
${userEventNonProviderImport}
5454
5555
export const server = {
@@ -64,7 +64,7 @@ export const server = {
6464
}
6565
export const commands = server.commands
6666
export const userEvent = createUserEvent(_userEventSetup)
67-
export { page, cdp }
67+
export { page, cdp, locators }
6868
`
6969
}
7070

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff 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+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff 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+
})

test/browser/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"test-timeout": "vitest --root ./fixtures/timeout",
1515
"test-mocking-watch": "vitest --root ./fixtures/mocking-watch",
1616
"test-locators": "vitest --root ./fixtures/locators",
17+
"test-locators-custom": "vitest --root ./fixtures/locators-custom",
1718
"test-different-configs": "vitest --root ./fixtures/multiple-different-configs",
1819
"test-setup-file": "vitest --root ./fixtures/setup-file",
1920
"test-snapshots": "vitest --root ./fixtures/update-snapshot",

test/browser/specs/locators.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,22 @@ test('locators work correctly', async () => {
2020
expect(stdout).toReportSummaryTestFiles({ passed: instances.length * COUNT_TEST_FILES })
2121
expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL })
2222
})
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+
})

0 commit comments

Comments
 (0)