Skip to content

Commit 2308cbf

Browse files
authored
feat(browser): add length property to locators, toHaveLength now accepts locators (#8512)
1 parent a8c7f73 commit 2308cbf

File tree

7 files changed

+79
-8
lines changed

7 files changed

+79
-8
lines changed

docs/guide/browser/locators.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,25 @@ test('works correctly', async () => {
956956
```
957957
:::
958958
959+
### length
960+
961+
This getter returns a number of elements that this locator is matching. It is equivalent to calling `locator.elements().length`.
962+
963+
Consider the following DOM structure:
964+
965+
```html
966+
<button>Click Me!</button>
967+
<button>Don't click me!</button>
968+
```
969+
970+
This property will always succeed:
971+
972+
```ts
973+
page.getByRole('button').length // ✅ 2
974+
page.getByRole('button', { title: 'Click Me!' }).length // ✅ 1
975+
page.getByRole('alert').length // ✅ 0
976+
```
977+
959978
## Custom Locators <Version>3.2.0</Version> <Badge type="danger">advanced</Badge> {#custom-locators}
960979
961980
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.

packages/browser/context.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,12 @@ export interface Locator extends LocatorSelectors {
475475
*/
476476
readonly selector: string
477477

478+
/**
479+
* The number of elements that this locator is matching.
480+
* @see {@link https://vitest.dev/guide/browser/locators#length}
481+
*/
482+
readonly length: number
483+
478484
/**
479485
* Click on an element. You can use the options to set the cursor position.
480486
* @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-click}

packages/browser/src/client/tester/expect-element.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,39 @@
11
import type { Locator } from '@vitest/browser/context'
22
import type { ExpectPollOptions } from 'vitest'
33
import { chai, expect } from 'vitest'
4+
import { getType } from 'vitest/internal/browser'
45
import { matchers } from './expect'
56
import { processTimeoutOptions } from './utils'
67

7-
function element<T extends Element | null | Locator>(elementOrLocator: T, options?: ExpectPollOptions): unknown {
8-
if (elementOrLocator != null && !(elementOrLocator instanceof Element) && !('element' in elementOrLocator)) {
9-
throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of Element or Locator, received ${typeof elementOrLocator}`)
8+
const kLocator = Symbol.for('$$vitest:locator')
9+
10+
function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrLocator: T, options?: ExpectPollOptions): unknown {
11+
if (elementOrLocator != null && !(elementOrLocator instanceof HTMLElement) && !(elementOrLocator instanceof SVGElement) && !(kLocator in elementOrLocator)) {
12+
throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of HTMLElement, SVGElement or Locator, received ${getType(elementOrLocator)}`)
1013
}
1114

12-
return expect.poll<Element | null>(function element(this: object) {
15+
return expect.poll<HTMLElement | SVGElement | null>(function element(this: object) {
1316
if (elementOrLocator instanceof Element || elementOrLocator == null) {
1417
return elementOrLocator
1518
}
1619
chai.util.flag(this, '_poll.element', true)
1720

1821
const isNot = chai.util.flag(this, 'negate') as boolean
1922
const name = chai.util.flag(this, '_name') as string
20-
// element selector uses prettyDOM under the hood, which is an expensive call
21-
// that should not be called on each failed locator attempt to avoid memory leak:
22-
// https://github.com/vitest-dev/vitest/issues/7139
23-
const isLastPollAttempt = chai.util.flag(this, '_isLastPollAttempt')
2423
// special case for `toBeInTheDocument` matcher
2524
if (isNot && name === 'toBeInTheDocument') {
2625
return elementOrLocator.query()
2726
}
27+
if (name === 'toHaveLength') {
28+
// we know that `toHaveLength` requires multiple elements,
29+
// but types generally expect a single one
30+
return elementOrLocator.elements() as unknown as HTMLElement
31+
}
32+
33+
// element selector uses prettyDOM under the hood, which is an expensive call
34+
// that should not be called on each failed locator attempt to avoid memory leak:
35+
// https://github.com/vitest-dev/vitest/issues/7139
36+
const isLastPollAttempt = chai.util.flag(this, '_isLastPollAttempt')
2837

2938
if (isLastPollAttempt) {
3039
return elementOrLocator.element()

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,24 @@ export const selectorEngine: Ivya = Ivya.create({
4343
testIdAttribute: server.config.browser.locators.testIdAttribute,
4444
})
4545

46+
const kLocator = Symbol.for('$$vitest:locator')
47+
4648
export abstract class Locator {
4749
public abstract selector: string
4850

4951
private _parsedSelector: ParsedSelector | undefined
5052
protected _container?: Element | undefined
5153
protected _pwSelector?: string | undefined
5254

55+
constructor() {
56+
Object.defineProperty(this, kLocator, {
57+
enumerable: false,
58+
writable: false,
59+
configurable: false,
60+
value: kLocator,
61+
})
62+
}
63+
5364
public click(options: UserEventClickOptions = {}): Promise<void> {
5465
return this.triggerCommand<void>('__vitest_click', this.selector, options)
5566
}
@@ -222,6 +233,10 @@ export abstract class Locator {
222233
return selectorEngine.querySelectorAll(parsedSelector, document.documentElement) as (HTMLElement | SVGElement)[]
223234
}
224235

236+
public get length(): number {
237+
return this.elements().length
238+
}
239+
225240
public all(): Locator[] {
226241
return this.elements().map(element => this.elementLocator(element))
227242
}

packages/vitest/src/public/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * as SpyModule from '@vitest/spy'
1414
export {
1515
format,
1616
getSafeTimers,
17+
getType,
1718
inspect,
1819
stringify,
1920
} from '@vitest/utils'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { render } from './utils';
3+
import { page } from '@vitest/browser/context';
4+
5+
describe('.toHaveLength', () => {
6+
test('accepts locator', async () => {
7+
render(`
8+
<button></button>
9+
<button></button>
10+
<button></button>
11+
`)
12+
13+
await expect.element(page.getByRole('button')).toHaveLength(3)
14+
await expect.element(page.getByRole('button')).not.toHaveLength(0)
15+
16+
expect(page.getByRole('button')).toHaveLength(3)
17+
expect(page.getByRole('button')).not.toHaveLength(0)
18+
})
19+
})

test/core/test/exports.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ it('exports snapshot', async ({ skip, task }) => {
6262
"collectTests": "function",
6363
"format": "function",
6464
"getSafeTimers": "function",
65+
"getType": "function",
6566
"inspect": "function",
6667
"loadDiffConfig": "function",
6768
"loadSnapshotSerializers": "function",
@@ -221,6 +222,7 @@ it('exports snapshot', async ({ skip, task }) => {
221222
"collectTests": "function",
222223
"format": "function",
223224
"getSafeTimers": "function",
225+
"getType": "function",
224226
"inspect": "function",
225227
"loadDiffConfig": "function",
226228
"loadSnapshotSerializers": "function",

0 commit comments

Comments
 (0)