Skip to content

Commit

Permalink
fix(browser): support shadow root and svg elements (#6036)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Jul 4, 2024
1 parent 8f65ae9 commit 2e3c872
Show file tree
Hide file tree
Showing 13 changed files with 306 additions and 128 deletions.
148 changes: 86 additions & 62 deletions packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,6 @@ import type { BrowserRPC } from '../client'

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

function convertElementToXPath(element: Element) {
if (!element || !(element instanceof Element)) {
throw new Error(
`Expected DOM element to be an instance of Element, received ${typeof element}`,
)
}

return getPathTo(element)
}

function getPathTo(element: Element): string {
if (element.id !== '') {
return `id("${element.id}")`
}

if (!element.parentNode || element === document.documentElement) {
return element.tagName
}

let ix = 0
const siblings = element.parentNode.childNodes
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i]
if (sibling === element) {
return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${
ix + 1
}]`
}
if (
sibling.nodeType === 1
&& (sibling as Element).tagName === element.tagName
) {
ix++
}
}
return 'invalid xpath'
}

// @ts-expect-error not typed global
const state = (): WorkerGlobalState => __vitest_worker__
// @ts-expect-error not typed global
Expand All @@ -60,37 +22,99 @@ function triggerCommand<T>(command: string, ...args: any[]) {

const provider = runner().provider

function convertElementToCssSelector(element: Element) {
if (!element || !(element instanceof Element)) {
throw new Error(
`Expected DOM element to be an instance of Element, received ${typeof element}`,
)
}

return getUniqueCssSelector(element)
}

function getUniqueCssSelector(el: Element) {
const path = []
let parent: null | ParentNode
let hasShadowRoot = false
// eslint-disable-next-line no-cond-assign
while (parent = getParent(el)) {
if ((parent as Element).shadowRoot) {
hasShadowRoot = true
}

const tag = el.tagName
if (el.id) {
path.push(`#${el.id}`)
}
else if (!el.nextElementSibling && !el.previousElementSibling) {
path.push(tag)
}
else {
let index = 0
let sameTagSiblings = 0
let elementIndex = 0

for (const sibling of parent.children) {
index++
if (sibling.tagName === tag) {
sameTagSiblings++
}
if (sibling === el) {
elementIndex = index
}
}

if (sameTagSiblings > 1) {
path.push(`${tag}:nth-child(${elementIndex})`)
}
else {
path.push(tag)
}
}
el = parent as Element
};
return `${provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}`.toLowerCase()
}

function getParent(el: Element) {
const parent = el.parentNode
if (parent instanceof ShadowRoot) {
return parent.host
}
return parent
}

export const userEvent: UserEvent = {
// TODO: actually setup userEvent with config options
setup() {
return userEvent
},
click(element: Element, options: UserEventClickOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_click', xpath, options)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_click', css, options)
},
dblClick(element: Element, options: UserEventClickOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_dblClick', xpath, options)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_dblClick', css, options)
},
tripleClick(element: Element, options: UserEventClickOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_tripleClick', xpath, options)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_tripleClick', css, options)
},
selectOptions(element, value) {
const values = provider === 'webdriverio'
? getWebdriverioSelectOptions(element, value)
: getSimpleSelectOptions(element, value)
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_selectOptions', xpath, values)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_selectOptions', css, values)
},
type(element: Element, text: string, options: UserEventTypeOptions = {}) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_type', xpath, text, options)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_type', css, text, options)
},
clear(element: Element) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_clear', xpath)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_clear', css)
},
tab(options: UserEventTabOptions = {}) {
return triggerCommand('__vitest_tab', options)
Expand All @@ -99,23 +123,23 @@ export const userEvent: UserEvent = {
return triggerCommand('__vitest_keyboard', text)
},
hover(element: Element) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_hover', xpath)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_hover', css)
},
unhover(element: Element) {
const xpath = convertElementToXPath(element.ownerDocument.body)
return triggerCommand('__vitest_hover', xpath)
const css = convertElementToCssSelector(element.ownerDocument.body)
return triggerCommand('__vitest_hover', css)
},

// non userEvent events, but still useful
fill(element: Element, text: string, options) {
const xpath = convertElementToXPath(element)
return triggerCommand('__vitest_fill', xpath, text, options)
const css = convertElementToCssSelector(element)
return triggerCommand('__vitest_fill', css, text, options)
},
dragAndDrop(source: Element, target: Element, options = {}) {
const sourceXpath = convertElementToXPath(source)
const targetXpath = convertElementToXPath(target)
return triggerCommand('__vitest_dragAndDrop', sourceXpath, targetXpath, options)
const sourceCss = convertElementToCssSelector(source)
const targetCss = convertElementToCssSelector(target)
return triggerCommand('__vitest_dragAndDrop', sourceCss, targetCss, options)
},
}

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

return [{ index }]
Expand All @@ -162,7 +186,7 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[]
function getSimpleSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
return (Array.isArray(value) ? value : [value]).map((v) => {
if (typeof v !== 'string') {
return { element: convertElementToXPath(v) }
return { element: convertElementToCssSelector(v) }
}
return v
})
Expand Down Expand Up @@ -220,7 +244,7 @@ export const page: BrowserPage = {
return triggerCommand('__vitest_screenshot', name, {
...options,
element: options.element
? convertElementToXPath(options.element)
? convertElementToCssSelector(options.element)
: undefined,
})
},
Expand Down
7 changes: 3 additions & 4 deletions packages/browser/src/node/commands/clear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'

export const clear: UserEventCommand<UserEvent['clear']> = async (
context,
xpath,
selector,
) => {
if (context.provider instanceof PlaywrightBrowserProvider) {
const { iframe } = context
const element = iframe.locator(`xpath=${xpath}`)
const element = iframe.locator(`css=${selector}`)
await element.clear({
timeout: 1000,
})
}
else if (context.provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
const element = await browser.$(markedXpath)
const element = await browser.$(selector)
await element.clearValue()
}
else {
Expand Down
21 changes: 9 additions & 12 deletions packages/browser/src/node/commands/click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,20 @@ import type { UserEventCommand } from './utils'

export const click: UserEventCommand<UserEvent['click']> = async (
context,
xpath,
selector,
options = {},
) => {
const provider = context.provider
if (provider instanceof PlaywrightBrowserProvider) {
const tester = context.iframe
await tester.locator(`xpath=${xpath}`).click({
await tester.locator(`css=${selector}`).click({
timeout: 1000,
...options,
})
}
else if (provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
await browser.$(markedXpath).click(options as any)
await browser.$(selector).click(options as any)
}
else {
throw new TypeError(`Provider "${provider.name}" doesn't support click command`)
Expand All @@ -28,18 +27,17 @@ export const click: UserEventCommand<UserEvent['click']> = async (

export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (
context,
xpath,
selector,
options = {},
) => {
const provider = context.provider
if (provider instanceof PlaywrightBrowserProvider) {
const tester = context.iframe
await tester.locator(`xpath=${xpath}`).dblclick(options)
await tester.locator(`css=${selector}`).dblclick(options)
}
else if (provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
await browser.$(markedXpath).doubleClick()
await browser.$(selector).doubleClick()
}
else {
throw new TypeError(`Provider "${provider.name}" doesn't support dblClick command`)
Expand All @@ -48,25 +46,24 @@ export const dblClick: UserEventCommand<UserEvent['dblClick']> = async (

export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async (
context,
xpath,
selector,
options = {},
) => {
const provider = context.provider
if (provider instanceof PlaywrightBrowserProvider) {
const tester = context.iframe
await tester.locator(`xpath=${xpath}`).click({
await tester.locator(`css=${selector}`).click({
timeout: 1000,
...options,
clickCount: 3,
})
}
else if (provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
await browser
.action('pointer', { parameters: { pointerType: 'mouse' } })
// move the pointer over the button
.move({ origin: await browser.$(markedXpath) })
.move({ origin: await browser.$(selector) })
// simulate 3 clicks
.down()
.up()
Expand Down
10 changes: 4 additions & 6 deletions packages/browser/src/node/commands/dragAndDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,17 @@ export const dragAndDrop: UserEventCommand<UserEvent['dragAndDrop']> = async (
if (context.provider instanceof PlaywrightBrowserProvider) {
const frame = await context.frame()
await frame.dragAndDrop(
`xpath=${source}`,
`xpath=${target}`,
`css=${source}`,
`css=${target}`,
{
timeout: 1000,
...options,
},
)
}
else if (context.provider instanceof WebdriverBrowserProvider) {
const sourceXpath = `//${source}`
const targetXpath = `//${target}`
const $source = context.browser.$(sourceXpath)
const $target = context.browser.$(targetXpath)
const $source = context.browser.$(source)
const $target = context.browser.$(target)
const duration = (options as any)?.duration ?? 10

// https://github.com/webdriverio/webdriverio/issues/8022#issuecomment-1700919670
Expand Down
7 changes: 3 additions & 4 deletions packages/browser/src/node/commands/fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'

export const fill: UserEventCommand<UserEvent['fill']> = async (
context,
xpath,
selector,
text,
options = {},
) => {
if (context.provider instanceof PlaywrightBrowserProvider) {
const { iframe } = context
const element = iframe.locator(`xpath=${xpath}`)
const element = iframe.locator(`css=${selector}`)
await element.fill(text, { timeout: 1000, ...options })
}
else if (context.provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
await browser.$(markedXpath).setValue(text)
await browser.$(selector).setValue(text)
}
else {
throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`)
Expand Down
7 changes: 3 additions & 4 deletions packages/browser/src/node/commands/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import type { UserEventCommand } from './utils'

export const hover: UserEventCommand<UserEvent['hover']> = async (
context,
xpath,
selector,
options = {},
) => {
if (context.provider instanceof PlaywrightBrowserProvider) {
await context.iframe.locator(`xpath=${xpath}`).hover({
await context.iframe.locator(`css=${selector}`).hover({
timeout: 1000,
...options,
})
}
else if (context.provider instanceof WebdriverBrowserProvider) {
const browser = context.browser
const markedXpath = `//${xpath}`
await browser.$(markedXpath).moveTo(options)
await browser.$(selector).moveTo(options)
}
else {
throw new TypeError(`Provider "${context.provider.name}" does not support hover`)
Expand Down
Loading

0 comments on commit 2e3c872

Please sign in to comment.