Skip to content

Commit 80f07ed

Browse files
authored
fix!: represent locator as an object instead of a string (#10212)
1 parent f7822eb commit 80f07ed

54 files changed

Lines changed: 638 additions & 214 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/api/browser/locators.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,19 +1051,70 @@ Internally, this method calls `.elements` and wraps every element using [`page.e
10511051

10521052
- [See `locator.elements()`](#elements)
10531053

1054+
### serialize
1055+
1056+
```ts
1057+
function serialize(): SerializedLocator
1058+
```
1059+
1060+
Returns a JSON-serializable representation of the locator. The returned object has two fields:
1061+
1062+
- [`selector`](#selector): the provider-specific selector string used to query the element at runtime.
1063+
- `locator`: a human-readable description of the locator (e.g. `getByRole('button')`), used for error messages and tracing. Equivalent to calling [`asLocator()`](#aslocator).
1064+
1065+
This is primarily intended for forwarding a locator to a [browser command](/api/browser/commands), which runs in Node and cannot receive a live `Locator` instance:
1066+
1067+
```ts
1068+
import { commands, page } from 'vitest/browser'
1069+
1070+
await commands.myCommand(page.getByRole('button').serialize())
1071+
```
1072+
1073+
::: tip
1074+
Vitest automatically serializes any `Locator` argument passed to a command, so calling `serialize()` explicitly is rarely necessary. You can also use `JSON.stringify(locator)` (it calls [`toJSON`](#tojson) internally), which produces the same result.
1075+
:::
1076+
1077+
### toJSON
1078+
1079+
```ts
1080+
function toJSON(): SerializedLocator
1081+
```
1082+
1083+
Alias of [`serialize`](#serialize). Defined so that `JSON.stringify(locator)` and structured-clone-based transports return a `SerializedLocator` object.
1084+
1085+
### asLocator
1086+
1087+
```ts
1088+
function asLocator(): string
1089+
```
1090+
1091+
Returns a human-readable description of the locator using the JavaScript locator syntax (e.g. `getByRole('button', { name: 'Submit' })`). This is the same string exposed as the `locator` field of [`serialize()`](#serialize) and is used in error messages and traces.
1092+
1093+
```ts
1094+
import { page } from 'vitest/browser'
1095+
1096+
const button = page.getByRole('button', { name: 'Submit' })
1097+
button.asLocator() // "getByRole('button', { name: 'Submit' })"
1098+
```
1099+
1100+
::: tip
1101+
Use [`selector`](#selector) when you need the provider-specific string to forward to a [browser command](/api/browser/commands). Use `asLocator()` only for diagnostic output. The returned string is not meant to be re-used to query elements.
1102+
:::
1103+
10541104
## Properties
10551105

10561106
### selector
10571107

1058-
The `selector` is a string that will be used to locate the element by the browser provider. Playwright will use a `playwright` locator syntax while `preview` and `webdriverio` will use CSS.
1108+
The `selector` is a string that will be used to locate the element by the browser provider. Playwright will use a `playwright` locator syntax, and `preview` and `webdriverio` will use CSS.
10591109

10601110
::: danger
10611111
You should not use this string in your test code. The `selector` string should only be used when working with the Commands API:
10621112

10631113
```ts [commands.ts]
10641114
import type { BrowserCommand } from 'vitest/node'
1115+
import type { SerializedLocator } from '@vitest/browser'
10651116
1066-
const test: BrowserCommand<string> = function test(context, selector) {
1117+
const test: BrowserCommand<SerializedLocator> = function test(context, { selector }) {
10671118
// playwright
10681119
await context.iframe.locator(selector).click()
10691120
// webdriverio
@@ -1076,8 +1127,8 @@ import { test } from 'vitest'
10761127
import { commands, page } from 'vitest/browser'
10771128
10781129
test('works correctly', async () => {
1079-
await commands.test(page.getByText('Hello').selector) // ✅
1080-
// vitest will automatically unwrap it to a string
1130+
await commands.test(page.getByText('Hello').serialize()) // ✅
1131+
// vitest will automatically unwrap it to a SerializedLocator
10811132
await commands.test(page.getByText('Hello')) // ✅
10821133
})
10831134
```

docs/guide/migration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ test('example', { sequential: true }, async () => { /* ... */ }) // [!code --]
3434
test('example', { concurrent: false }, async () => { /* ... */ }) // [!code ++]
3535
```
3636

37+
### Locators in Commands are Serialized as Objects
38+
39+
Locators forwarded to [browser commands](/api/browser/commands) are now serialized as a `SerializedLocator` object instead of a bare selector string. The object exposes two fields:
40+
41+
- `selector`: the provider-specific selector string (the same value commands previously received).
42+
- `locator`: a human-readable representation of the locator (e.g. `getByRole('button')`), used for error messages and tracing.
43+
44+
Update any custom commands that accept a locator to destructure `selector` from the new object:
45+
46+
```ts
47+
import type { SerializedLocator } from '@vitest/browser'
48+
import type { BrowserCommandContext } from 'vitest/node'
49+
50+
export async function customClick(
51+
context: BrowserCommandContext,
52+
selector: string, // [!code --]
53+
{ selector }: SerializedLocator, // [!code ++]
54+
) {
55+
await context.page.locator(selector).click()
56+
}
57+
```
58+
3759
## Migrating to Vitest 4.0 {#vitest-4}
3860

3961
::: warning Prerequisites

eslint.config.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,38 @@ export default antfu(
142142
'unicorn/consistent-function-scoping': 'off',
143143
},
144144
},
145+
{
146+
files: [`packages/browser/src/client/orchestrator.ts`],
147+
rules: {
148+
'no-restricted-imports': [
149+
'error',
150+
{
151+
paths: ['vitest/internal/browser', 'vitest/node'],
152+
},
153+
],
154+
},
155+
},
156+
// ivya should be loaded only once in "ivya chunk" (see browser rollup config)
157+
{
158+
files: [`packages/browser/${GLOB_SRC}`],
159+
ignores: [
160+
// aria snapshots
161+
`packages/browser/src/vendor-types.ts`,
162+
`packages/browser/src/client/tester/aria.ts`,
163+
// primary use case - creates the engine
164+
`packages/browser/src/client/tester/locators.ts`,
165+
// uses utils from ivya to reuse locator syntax
166+
`packages/browser/src/client/tester/expect/${GLOB_SRC}`,
167+
// used as a type
168+
`packages/browser/src/client/utils.ts`,
169+
],
170+
rules: {
171+
'no-restricted-imports': [
172+
'error',
173+
{
174+
paths: ['ivya', 'ivya/utils', 'ivya/aria'],
175+
},
176+
],
177+
},
178+
},
145179
)

packages/browser-playwright/src/commands/dragAndDrop.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export const dragAndDrop: UserEventCommand<UserEvent['dragAndDrop']> = async (
99
) => {
1010
const frame = await context.frame()
1111
await frame.dragAndDrop(
12-
source,
13-
target,
12+
source.selector,
13+
target.selector,
1414
options_,
1515
)
1616
}

packages/browser-playwright/src/commands/screenshot.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SerializedLocator } from '@vitest/browser'
12
import type { ScreenshotOptions } from 'vitest/browser'
23
import type { BrowserCommandContext } from 'vitest/node'
34
import { mkdir } from 'node:fs/promises'
@@ -6,8 +7,8 @@ import { dirname, normalize } from 'pathe'
67
import { getDescribedLocator } from './utils'
78

89
interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> {
9-
element?: string
10-
mask?: readonly string[]
10+
element?: SerializedLocator
11+
mask?: readonly SerializedLocator[]
1112
}
1213

1314
const SCREENSHOT_STYLES = /* css */`
@@ -71,7 +72,7 @@ export async function takeScreenshot(
7172
return { buffer, path }
7273
}
7374

74-
const buffer = await getDescribedLocator(context, 'body').screenshot({
75+
const buffer = await getDescribedLocator(context, { selector: 'body', locator: 'locator(\'body\')' }).screenshot({
7576
...options,
7677
mask,
7778
path: savePath,

packages/browser-playwright/src/commands/select.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SerializedLocator } from '@vitest/browser'
12
import type { ElementHandle } from 'playwright'
23
import type { UserEvent } from 'vitest/browser'
34
import type { UserEventCommand } from './utils'
@@ -9,7 +10,7 @@ export const selectOptions: UserEventCommand<UserEvent['selectOptions']> = async
910
userValues,
1011
options = {},
1112
) => {
12-
const value = userValues as any as (string | { element: string })[]
13+
const value = userValues as any as (string | { element: SerializedLocator })[]
1314
const selectElement = getDescribedLocator(context, selector)
1415

1516
const values = await Promise.all(value.map(async (v) => {

packages/browser-playwright/src/commands/trace.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SerializedLocator } from '@vitest/browser'
12
import type { ParsedStack } from 'vitest'
23
import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node'
34
import type { PlaywrightBrowserProvider } from '../playwright'
@@ -58,7 +59,7 @@ export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async (
5859
throw new TypeError(`The ${context.provider.name} provider does not support tracing.`)
5960
}
6061

61-
export const markTrace: BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> = async (
62+
export const markTrace: BrowserCommand<[payload: { name: string; element?: SerializedLocator; stack?: string }]> = async (
6263
context,
6364
payload,
6465
) => {
@@ -69,14 +70,14 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri
6970
if (!context.provider.tracingContexts.has(context.sessionId)) {
7071
return
7172
}
72-
const { name, selector, stack } = payload
73+
const { name, element, stack } = payload
7374
const location = parseLocation(context, stack)
7475
// mark trace via group/groupEnd with dummy calls to force snapshot.
7576
// https://github.com/microsoft/playwright/issues/39308
7677
await context.context.tracing.group(name, { location })
7778
try {
78-
if (selector) {
79-
const locator = getDescribedLocator(context, selector) as any
79+
if (element) {
80+
const locator = getDescribedLocator(context, element) as any
8081
if (typeof locator._expect === 'function') {
8182
await locator._expect('to.be.attached', {
8283
isNot: false,

packages/browser-playwright/src/commands/upload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import type { SerializedLocator } from '@vitest/browser'
12
import type { UserEventUploadOptions } from 'vitest/browser'
23
import type { UserEventCommand } from './utils'
34
import { resolve } from 'pathe'
45
import { getDescribedLocator } from './utils'
56

6-
export const upload: UserEventCommand<(element: string, files: Array<string | {
7+
export const upload: UserEventCommand<(element: SerializedLocator, files: Array<string | {
78
name: string
89
mimeType: string
910
base64: string

packages/browser-playwright/src/commands/utils.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import type { SerializedLocator } from '@vitest/browser'
12
import type { Locator } from 'vitest/browser'
23
import type { BrowserCommand, BrowserCommandContext } from 'vitest/node'
3-
import { asLocator } from '@vitest/browser'
44

55
export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<
66
ConvertUserEventParameters<Parameters<T>>
77
>
88

9-
type ConvertElementToLocator<T> = T extends Element | Locator ? string : T
9+
type ConvertElementToLocator<T> = T extends Element | Locator ? SerializedLocator : T
1010
type ConvertUserEventParameters<T extends unknown[]> = {
1111
[K in keyof T]: ConvertElementToLocator<T[K]>;
1212
}
@@ -23,10 +23,10 @@ export function defineBrowserCommand<T extends unknown[]>(
2323
// - getByRole('button')
2424
export function getDescribedLocator(
2525
context: BrowserCommandContext,
26-
selector: string,
26+
{ locator, selector }: SerializedLocator,
2727
): ReturnType<BrowserCommandContext['iframe']['locator']> {
28-
const locator = context.iframe.locator(selector)
29-
return typeof locator.describe === 'function'
30-
? locator.describe(asLocator('javascript', selector))
31-
: locator
28+
const iframeLocator = context.iframe.locator(selector)
29+
return typeof iframeLocator.describe === 'function'
30+
? iframeLocator.describe(locator)
31+
: iframeLocator
3232
}

packages/browser-webdriverio/src/commands/clear.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { UserEventCommand } from './utils'
33

44
export const clear: UserEventCommand<UserEvent['clear']> = async (
55
context,
6-
selector,
6+
{ selector },
77
) => {
88
const browser = context.browser
99
await browser.$(selector).clearValue()

0 commit comments

Comments
 (0)