Skip to content

Commit 2e3c872

Browse files
authored
fix(browser): support shadow root and svg elements (#6036)
1 parent 8f65ae9 commit 2e3c872

File tree

13 files changed

+306
-128
lines changed

13 files changed

+306
-128
lines changed

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

+86-62
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,6 @@ import type { BrowserRPC } from '../client'
55

66
// this file should not import anything directly, only types
77

8-
function convertElementToXPath(element: Element) {
9-
if (!element || !(element instanceof Element)) {
10-
throw new Error(
11-
`Expected DOM element to be an instance of Element, received ${typeof element}`,
12-
)
13-
}
14-
15-
return getPathTo(element)
16-
}
17-
18-
function getPathTo(element: Element): string {
19-
if (element.id !== '') {
20-
return `id("${element.id}")`
21-
}
22-
23-
if (!element.parentNode || element === document.documentElement) {
24-
return element.tagName
25-
}
26-
27-
let ix = 0
28-
const siblings = element.parentNode.childNodes
29-
for (let i = 0; i < siblings.length; i++) {
30-
const sibling = siblings[i]
31-
if (sibling === element) {
32-
return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${
33-
ix + 1
34-
}]`
35-
}
36-
if (
37-
sibling.nodeType === 1
38-
&& (sibling as Element).tagName === element.tagName
39-
) {
40-
ix++
41-
}
42-
}
43-
return 'invalid xpath'
44-
}
45-
468
// @ts-expect-error not typed global
479
const state = (): WorkerGlobalState => __vitest_worker__
4810
// @ts-expect-error not typed global
@@ -60,37 +22,99 @@ function triggerCommand<T>(command: string, ...args: any[]) {
6022

6123
const provider = runner().provider
6224

25+
function convertElementToCssSelector(element: Element) {
26+
if (!element || !(element instanceof Element)) {
27+
throw new Error(
28+
`Expected DOM element to be an instance of Element, received ${typeof element}`,
29+
)
30+
}
31+
32+
return getUniqueCssSelector(element)
33+
}
34+
35+
function getUniqueCssSelector(el: Element) {
36+
const path = []
37+
let parent: null | ParentNode
38+
let hasShadowRoot = false
39+
// eslint-disable-next-line no-cond-assign
40+
while (parent = getParent(el)) {
41+
if ((parent as Element).shadowRoot) {
42+
hasShadowRoot = true
43+
}
44+
45+
const tag = el.tagName
46+
if (el.id) {
47+
path.push(`#${el.id}`)
48+
}
49+
else if (!el.nextElementSibling && !el.previousElementSibling) {
50+
path.push(tag)
51+
}
52+
else {
53+
let index = 0
54+
let sameTagSiblings = 0
55+
let elementIndex = 0
56+
57+
for (const sibling of parent.children) {
58+
index++
59+
if (sibling.tagName === tag) {
60+
sameTagSiblings++
61+
}
62+
if (sibling === el) {
63+
elementIndex = index
64+
}
65+
}
66+
67+
if (sameTagSiblings > 1) {
68+
path.push(`${tag}:nth-child(${elementIndex})`)
69+
}
70+
else {
71+
path.push(tag)
72+
}
73+
}
74+
el = parent as Element
75+
};
76+
return `${provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}`.toLowerCase()
77+
}
78+
79+
function getParent(el: Element) {
80+
const parent = el.parentNode
81+
if (parent instanceof ShadowRoot) {
82+
return parent.host
83+
}
84+
return parent
85+
}
86+
6387
export const userEvent: UserEvent = {
6488
// TODO: actually setup userEvent with config options
6589
setup() {
6690
return userEvent
6791
},
6892
click(element: Element, options: UserEventClickOptions = {}) {
69-
const xpath = convertElementToXPath(element)
70-
return triggerCommand('__vitest_click', xpath, options)
93+
const css = convertElementToCssSelector(element)
94+
return triggerCommand('__vitest_click', css, options)
7195
},
7296
dblClick(element: Element, options: UserEventClickOptions = {}) {
73-
const xpath = convertElementToXPath(element)
74-
return triggerCommand('__vitest_dblClick', xpath, options)
97+
const css = convertElementToCssSelector(element)
98+
return triggerCommand('__vitest_dblClick', css, options)
7599
},
76100
tripleClick(element: Element, options: UserEventClickOptions = {}) {
77-
const xpath = convertElementToXPath(element)
78-
return triggerCommand('__vitest_tripleClick', xpath, options)
101+
const css = convertElementToCssSelector(element)
102+
return triggerCommand('__vitest_tripleClick', css, options)
79103
},
80104
selectOptions(element, value) {
81105
const values = provider === 'webdriverio'
82106
? getWebdriverioSelectOptions(element, value)
83107
: getSimpleSelectOptions(element, value)
84-
const xpath = convertElementToXPath(element)
85-
return triggerCommand('__vitest_selectOptions', xpath, values)
108+
const css = convertElementToCssSelector(element)
109+
return triggerCommand('__vitest_selectOptions', css, values)
86110
},
87111
type(element: Element, text: string, options: UserEventTypeOptions = {}) {
88-
const xpath = convertElementToXPath(element)
89-
return triggerCommand('__vitest_type', xpath, text, options)
112+
const css = convertElementToCssSelector(element)
113+
return triggerCommand('__vitest_type', css, text, options)
90114
},
91115
clear(element: Element) {
92-
const xpath = convertElementToXPath(element)
93-
return triggerCommand('__vitest_clear', xpath)
116+
const css = convertElementToCssSelector(element)
117+
return triggerCommand('__vitest_clear', css)
94118
},
95119
tab(options: UserEventTabOptions = {}) {
96120
return triggerCommand('__vitest_tab', options)
@@ -99,23 +123,23 @@ export const userEvent: UserEvent = {
99123
return triggerCommand('__vitest_keyboard', text)
100124
},
101125
hover(element: Element) {
102-
const xpath = convertElementToXPath(element)
103-
return triggerCommand('__vitest_hover', xpath)
126+
const css = convertElementToCssSelector(element)
127+
return triggerCommand('__vitest_hover', css)
104128
},
105129
unhover(element: Element) {
106-
const xpath = convertElementToXPath(element.ownerDocument.body)
107-
return triggerCommand('__vitest_hover', xpath)
130+
const css = convertElementToCssSelector(element.ownerDocument.body)
131+
return triggerCommand('__vitest_hover', css)
108132
},
109133

110134
// non userEvent events, but still useful
111135
fill(element: Element, text: string, options) {
112-
const xpath = convertElementToXPath(element)
113-
return triggerCommand('__vitest_fill', xpath, text, options)
136+
const css = convertElementToCssSelector(element)
137+
return triggerCommand('__vitest_fill', css, text, options)
114138
},
115139
dragAndDrop(source: Element, target: Element, options = {}) {
116-
const sourceXpath = convertElementToXPath(source)
117-
const targetXpath = convertElementToXPath(target)
118-
return triggerCommand('__vitest_dragAndDrop', sourceXpath, targetXpath, options)
140+
const sourceCss = convertElementToCssSelector(source)
141+
const targetCss = convertElementToCssSelector(target)
142+
return triggerCommand('__vitest_dragAndDrop', sourceCss, targetCss, options)
119143
},
120144
}
121145

@@ -137,7 +161,7 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[]
137161
if (typeof optionValue !== 'string') {
138162
const index = options.indexOf(optionValue as HTMLOptionElement)
139163
if (index === -1) {
140-
throw new Error(`The element ${convertElementToXPath(optionValue)} was not found in the "select" options.`)
164+
throw new Error(`The element ${convertElementToCssSelector(optionValue)} was not found in the "select" options.`)
141165
}
142166

143167
return [{ index }]
@@ -162,7 +186,7 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[]
162186
function getSimpleSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
163187
return (Array.isArray(value) ? value : [value]).map((v) => {
164188
if (typeof v !== 'string') {
165-
return { element: convertElementToXPath(v) }
189+
return { element: convertElementToCssSelector(v) }
166190
}
167191
return v
168192
})
@@ -220,7 +244,7 @@ export const page: BrowserPage = {
220244
return triggerCommand('__vitest_screenshot', name, {
221245
...options,
222246
element: options.element
223-
? convertElementToXPath(options.element)
247+
? convertElementToCssSelector(options.element)
224248
: undefined,
225249
})
226250
},

packages/browser/src/node/commands/clear.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'
55

66
export const clear: UserEventCommand<UserEvent['clear']> = async (
77
context,
8-
xpath,
8+
selector,
99
) => {
1010
if (context.provider instanceof PlaywrightBrowserProvider) {
1111
const { iframe } = context
12-
const element = iframe.locator(`xpath=${xpath}`)
12+
const element = iframe.locator(`css=${selector}`)
1313
await element.clear({
1414
timeout: 1000,
1515
})
1616
}
1717
else if (context.provider instanceof WebdriverBrowserProvider) {
1818
const browser = context.browser
19-
const markedXpath = `//${xpath}`
20-
const element = await browser.$(markedXpath)
19+
const element = await browser.$(selector)
2120
await element.clearValue()
2221
}
2322
else {

packages/browser/src/node/commands/click.ts

+9-12
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,20 @@ import type { UserEventCommand } from './utils'
55

66
export const click: UserEventCommand<UserEvent['click']> = async (
77
context,
8-
xpath,
8+
selector,
99
options = {},
1010
) => {
1111
const provider = context.provider
1212
if (provider instanceof PlaywrightBrowserProvider) {
1313
const tester = context.iframe
14-
await tester.locator(`xpath=${xpath}`).click({
14+
await tester.locator(`css=${selector}`).click({
1515
timeout: 1000,
1616
...options,
1717
})
1818
}
1919
else if (provider instanceof WebdriverBrowserProvider) {
2020
const browser = context.browser
21-
const markedXpath = `//${xpath}`
22-
await browser.$(markedXpath).click(options as any)
21+
await browser.$(selector).click(options as any)
2322
}
2423
else {
2524
throw new TypeError(`Provider "${provider.name}" doesn't support click command`)
@@ -28,18 +27,17 @@ export const click: UserEventCommand<UserEvent['click']> = async (
2827

2928
export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (
3029
context,
31-
xpath,
30+
selector,
3231
options = {},
3332
) => {
3433
const provider = context.provider
3534
if (provider instanceof PlaywrightBrowserProvider) {
3635
const tester = context.iframe
37-
await tester.locator(`xpath=${xpath}`).dblclick(options)
36+
await tester.locator(`css=${selector}`).dblclick(options)
3837
}
3938
else if (provider instanceof WebdriverBrowserProvider) {
4039
const browser = context.browser
41-
const markedXpath = `//${xpath}`
42-
await browser.$(markedXpath).doubleClick()
40+
await browser.$(selector).doubleClick()
4341
}
4442
else {
4543
throw new TypeError(`Provider "${provider.name}" doesn't support dblClick command`)
@@ -48,25 +46,24 @@ export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (
4846

4947
export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async (
5048
context,
51-
xpath,
49+
selector,
5250
options = {},
5351
) => {
5452
const provider = context.provider
5553
if (provider instanceof PlaywrightBrowserProvider) {
5654
const tester = context.iframe
57-
await tester.locator(`xpath=${xpath}`).click({
55+
await tester.locator(`css=${selector}`).click({
5856
timeout: 1000,
5957
...options,
6058
clickCount: 3,
6159
})
6260
}
6361
else if (provider instanceof WebdriverBrowserProvider) {
6462
const browser = context.browser
65-
const markedXpath = `//${xpath}`
6663
await browser
6764
.action('pointer', { parameters: { pointerType: 'mouse' } })
6865
// move the pointer over the button
69-
.move({ origin: await browser.$(markedXpath) })
66+
.move({ origin: await browser.$(selector) })
7067
// simulate 3 clicks
7168
.down()
7269
.up()

packages/browser/src/node/commands/dragAndDrop.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,17 @@ export const dragAndDrop: UserEventCommand<UserEvent['dragAndDrop']> = async (
1212
if (context.provider instanceof PlaywrightBrowserProvider) {
1313
const frame = await context.frame()
1414
await frame.dragAndDrop(
15-
`xpath=${source}`,
16-
`xpath=${target}`,
15+
`css=${source}`,
16+
`css=${target}`,
1717
{
1818
timeout: 1000,
1919
...options,
2020
},
2121
)
2222
}
2323
else if (context.provider instanceof WebdriverBrowserProvider) {
24-
const sourceXpath = `//${source}`
25-
const targetXpath = `//${target}`
26-
const $source = context.browser.$(sourceXpath)
27-
const $target = context.browser.$(targetXpath)
24+
const $source = context.browser.$(source)
25+
const $target = context.browser.$(target)
2826
const duration = (options as any)?.duration ?? 10
2927

3028
// https://github.com/webdriverio/webdriverio/issues/8022#issuecomment-1700919670

packages/browser/src/node/commands/fill.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'
55

66
export const fill: UserEventCommand<UserEvent['fill']> = async (
77
context,
8-
xpath,
8+
selector,
99
text,
1010
options = {},
1111
) => {
1212
if (context.provider instanceof PlaywrightBrowserProvider) {
1313
const { iframe } = context
14-
const element = iframe.locator(`xpath=${xpath}`)
14+
const element = iframe.locator(`css=${selector}`)
1515
await element.fill(text, { timeout: 1000, ...options })
1616
}
1717
else if (context.provider instanceof WebdriverBrowserProvider) {
1818
const browser = context.browser
19-
const markedXpath = `//${xpath}`
20-
await browser.$(markedXpath).setValue(text)
19+
await browser.$(selector).setValue(text)
2120
}
2221
else {
2322
throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`)

packages/browser/src/node/commands/hover.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'
55

66
export const hover: UserEventCommand<UserEvent['hover']> = async (
77
context,
8-
xpath,
8+
selector,
99
options = {},
1010
) => {
1111
if (context.provider instanceof PlaywrightBrowserProvider) {
12-
await context.iframe.locator(`xpath=${xpath}`).hover({
12+
await context.iframe.locator(`css=${selector}`).hover({
1313
timeout: 1000,
1414
...options,
1515
})
1616
}
1717
else if (context.provider instanceof WebdriverBrowserProvider) {
1818
const browser = context.browser
19-
const markedXpath = `//${xpath}`
20-
await browser.$(markedXpath).moveTo(options)
19+
await browser.$(selector).moveTo(options)
2120
}
2221
else {
2322
throw new TypeError(`Provider "${context.provider.name}" does not support hover`)

0 commit comments

Comments
 (0)