[BUG-14514] - Switch frame to an iframe in a Shadow DOM (#14589)

* Squashed commit of the following:

commit ed6225080da8cc5e48c91afdd7bcc8709495b06c
Author: Vishnu Vardhan <vishnu.p@browserstack.com>
Date:   Wed Jun 25 16:04:25 2025 +0530

    Trigger EasyCLA

commit da54fcf7f8da91e1a880ad8287c8cbd1a2b1a75d
Author: Vishnu Vardhan <vishnu.p@browserstack.com>
Date:   Tue Jun 24 15:59:12 2025 +0530

    Comment

commit ad9316ea5691180751dcf2238c63bd52b3e09da4
Merge: 97e851abe ff2154c00
Author: Vishnu Vardhan <vardhan688@gmail.com>
Date:   Tue Jun 24 13:44:09 2025 +0530

    Merge branch 'main' into bug-14514

commit 97e851abeaa3f1fc708c88aa03752c1f002c0cc0
Merge: 5bb2673ac 24855c2a9
Author: Vishnu Vardhan <vardhan688@gmail.com>
Date:   Mon Jun 23 16:02:41 2025 +0530

    Merge branch 'main' into bug-14514

commit 5bb2673ac7e86c9a8f896f583c84a8793382d3dd
Merge: ac1ad4946 d80354dbd
Author: Vishnu Vardhan <vardhan688@gmail.com>
Date:   Fri Jun 20 12:58:50 2025 +0530

    Merge branch 'main' into bug-14514

commit ac1ad494620dfea5fb3868064f06b74051c26bea
Merge: 04711904c 072e55137
Author: Vishnu Vardhan <vardhan688@gmail.com>
Date:   Thu Jun 19 13:13:03 2025 +0530

    Merge branch 'main' into bug-14514

commit 04711904c2abb386a7b519dd9ea67490d7c2d01f
Merge: d70b3b1eb d344dd4db
Author: Vishnu Vardhan <vishnu.p@browserstack.com>
Date:   Wed Jun 18 16:08:30 2025 +0530

    Merge branch 'bug-14514' of github.com:vishnuv688/webdriverio into bug-14514

commit d70b3b1ebda25d9003ed02272b71ef78b310bdd7
Author: Vishnu Vardhan <vishnu.p@browserstack.com>
Date:   Wed Jun 18 16:07:11 2025 +0530

    Fixes webdriverio#14514

commit d344dd4db2fa9546fbff78a20c4d591d933e2fc5
Merge: 35af4399e 5818efe49
Author: Vishnu Vardhan <vardhan688@gmail.com>
Date:   Wed Jun 18 15:52:47 2025 +0530

    Merge branch 'main' into bug-14514

commit 35af4399ea0f2b6c3172ff5b0670fde283397e19
Merge: 44fe5f51e 41ddf78f4
Author: Vishnu Vardhan <vishnu.p@browserstack.com>
Date:   Wed Jun 18 15:48:48 2025 +0530

    Merge branch 'bug-14514' of github.com:vishnuv688/webdriverio into bug-14514

commit 44fe5f51ec13d31c0a6a11b34c574d2c4c330bdd
Author: VishnuVardhan P <vishnu.p@browserstack.com>
Date:   Wed Jun 18 15:42:21 2025 +0530

    [BUG-14514] - Switch frame to an iframe in a Shadow DOM

commit 41ddf78f4fb742511eba1aa512cff788dd77d6e6
Author: Vishnu Vardhan P <vishnu.p@browserstack.com@Vishnus-MacBook-Air.local>
Date:   Wed Jun 18 15:42:21 2025 +0530

    [BUG-14514] - Switch frame to an iframe in a Shadow DOM

* Moved shadowDOM file from utils to scripts

* fix lint errors

* Update packages/wdio-utils/src/initializeServices.ts

* Review changes

* Reproduction E2E test case

* Added E2E test case when url is passed to switchFrame

* Resolved test failures caused by recent changes

* Resolved E2E test failures caused by recent changes

---------

Co-authored-by: Christian Bromann <git@bromann.dev>
This commit is contained in:
Vishnu Vardhan 2025-07-23 23:23:37 +05:30 committed by GitHub
parent a12bd00d96
commit 1caf4b09fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 145 additions and 1 deletions

View file

@ -665,6 +665,30 @@ describe('main suite 1', () => {
})
})
})
describe('switchFrame with iframe in shadow DOM', () => {
beforeEach(async () => {
await browser.url('https://guinea-pig.webdriver.io/iframeInShadowDom.html')
})
it('should switch to iframe inside shadow root via element', async () => {
const host = await browser.$('#wrapper')
const iframe = await host.shadow$('iframe')
// Switch to the iframe inside the shadow DOM
await browser.switchFrame(await browser.$(iframe))
const [title, url] = await browser.execute(() => [document.title, document.URL])
expect(title).toBe('Iframe Target')
expect(url).toContain('iframeTarget.html')
})
it('should work when using the url', async () => {
await browser.switchFrame('https://guinea-pig.webdriver.io/iframeTarget.html')
await expect($('h1')).toHaveText('Iframe Target')
})
})
})
describe('open resources with different protocols', () => {

View file

@ -7,6 +7,7 @@ import { LocalValue } from '../../utils/bidi/value.js'
import { parseScriptResult } from '../../utils/bidi/index.js'
import { SCRIPT_PREFIX, SCRIPT_SUFFIX } from '../constant.js'
import type { ChainablePromiseElement } from '../../types.js'
import findIframeInShadowDOM from '../../scripts/shadowDom.js'
const log = logger('webdriverio:switchFrame')
@ -215,6 +216,33 @@ export async function switchFrame (
}))
}))).flat(Infinity) as FrameResult[]
// if we didn't find any frames, we try to find an iframe in the shadow DOM
// that matches the url fragment or context id
if (allFrames.length === 0) {
const urlFragment = typeof context === 'string'
? context.split('/').pop() ?? ''
: ''
// Execute browser-side script to locate a shadow DOM iframe with matching URL
const iframeFound = await this.execute(findIframeInShadowDOM, urlFragment)
// If an iframe was found in the shadow DOM, and it's a valid WebDriver element reference,
// convert it into a WebdriverIO-compatible element using `this.$`,
// then attempt to switch the frame context to it.
if (
iframeFound &&
typeof iframeFound === 'object' &&
iframeFound[ELEMENT_KEY]
) {
const iframeElement = await this.$(iframeFound)
if (iframeElement) {
return this.switchFrame(iframeElement)
}
}
// If we found an iframe in the shadow DOM but couldn't resolve it to a WebdriverIO element
log.warn(`Shadow DOM iframe with src containing "${urlFragment}" found, but could not be resolved into a WebdriverIO element.`)
}
/**
* Our desired frame may be somewhere nested in other frames. In order to properly
* switch to it, we need to ensure we switch into all nested frames first.

View file

@ -0,0 +1,28 @@
/**
* This function will be stringified and executed in the browser context.
* It searches for an <iframe> element within any nested Shadow DOM that contains the given URL fragment.
*/
export default function findIframeInShadowDOM(fragment: string): HTMLIFrameElement | null {
function findIframe(root: ShadowRoot | Document): HTMLIFrameElement | null {
// Search all iframes in the current root
const allElements = Array.from(root.querySelectorAll('iframe'))
for (const el of allElements) {
if (el instanceof HTMLIFrameElement && el.src.includes(fragment)) {
return el
}
}
// Recursively search through all shadow hosts
const shadowHosts = Array.from(root.querySelectorAll('*'))
for (const host of shadowHosts) {
const maybeShadowRoot = (host as HTMLElement).shadowRoot
if (maybeShadowRoot) {
const result = findIframe(maybeShadowRoot)
if (result) {return result}
}
}
return null
}
return findIframe(document)
}

View file

@ -148,6 +148,71 @@ describe('switchFrame command', () => {
expect(contextManager.setCurrentContext).toBeCalledWith('5D4662C2B4465334DFD34239BA1E9E66')
})
it('should NOT re-resolve if element already has an elementId', async () => {
// Mock the resolved element
const resolvedElem = {
elementId: 'elem-456',
[ELEMENT_KEY]: 'elem-456',
selector: 'iframe',
waitForExist: vi.fn().mockResolvedValue(true),
isExisting: vi.fn().mockResolvedValue(true),
isElement: true,
getElement: vi.fn()
} as any
resolvedElem.getElement.mockResolvedValue(resolvedElem)
const switchToFrame = vi.spyOn(browser, 'switchToFrame').mockResolvedValue(undefined)
await browser.switchFrame(resolvedElem)
expect(switchToFrame).toHaveBeenCalledWith(expect.objectContaining({
[ELEMENT_KEY]: 'elem-456'
}))
})
it('should re-resolve element if elementId is missing', async () => {
// Mock the resolved element
const resolvedElement = {
selector: 'iframe',
elementId: 'elem-789',
[ELEMENT_KEY]: 'elem-789',
waitForExist: vi.fn().mockResolvedValue(true),
isExisting: vi.fn().mockResolvedValue(true),
isElement: true
}
// Spy on browser.$ to simulate re-resolving the selector
const $spy = vi.spyOn(browser, '$').mockResolvedValue(resolvedElement as any)
const switchToFrame = vi.spyOn(browser, 'switchToFrame').mockResolvedValue(undefined)
// Fake unresolved element (missing elementId/ELEMENT_KEY)
const unresolvedElement = {
selector: 'iframe',
parent: browser,
isElement: true,
elementId: undefined,
[ELEMENT_KEY]: undefined,
async getElement() {
if (!this.elementId && typeof this.selector === 'string') {
const resolved = await this.parent.$(this.selector)
this.elementId = resolved.elementId
this[ELEMENT_KEY] = resolved[ELEMENT_KEY]
return resolved
}
return this as any
}
}
await browser.switchFrame(unresolvedElement as any)
// Assert: re-resolution happened and switchToFrame was called with resolved element
expect($spy).toHaveBeenCalledWith('iframe')
expect(switchToFrame).toHaveBeenCalledWith(expect.objectContaining({
[ELEMENT_KEY]: 'elem-789'
}))
})
it('should switch context for delayed iframe URL', async () => {
const resolvedTree = {
'1': {
@ -238,6 +303,5 @@ describe('switchFrame command', () => {
expect(getFlatContextTreeMock).toHaveBeenCalledTimes(1)
expect(switchToFrameMock).toHaveBeenCalled()
})
})
})