Skip to content

Commit 33a1b19

Browse files
fix(webdriverio): properly switch context using switchToParentFrame() (webdriverio#14070)
* fix(webdriverio): properly switch context using switchToParentFrame() * fix e2e test * fix unit test * revert wrong change * fix(webdriverio): allow to take screenshots of frames (webdriverio#14071) * fix(webdriverio): allow to take screenshots of frames * Update packages/webdriverio/src/commands/browser/saveScreenshot.ts Co-authored-by: Swastik Baranwal <swstkbaranwal@gmail.com> --------- Co-authored-by: Swastik Baranwal <swstkbaranwal@gmail.com> * fix(webdriverio): properly switch context using switchToParentFrame() * pr feedback --------- Co-authored-by: Swastik Baranwal <swstkbaranwal@gmail.com>
1 parent fa496fa commit 33a1b19

File tree

9 files changed

+202
-71
lines changed

9 files changed

+202
-71
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,4 @@ examples/wdio/mocha/*.json
117117
e2e/screenshot.png
118118
website/community/events
119119
website/events.json
120+
e2e/wdio/headless/*.png

__mocks__/fetch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ const requestMock: any = vi.fn().mockImplementation((uri, params) => {
290290
result = true
291291
} else if (body.script.includes('mobile:')) {
292292
result = true
293+
} else if (body.script.includes('document.URL')) {
294+
result = 'https://webdriver.io/?foo=bar'
293295
} else {
294296
result = script.apply(this, args)
295297
}

e2e/wdio/headless/test.e2e.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import url from 'node:url'
55
import path from 'node:path'
66
import { browser, $, expect } from '@wdio/globals'
77

8+
import { imageSize } from 'image-size'
9+
810
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
911

1012
describe('main suite 1', () => {
@@ -549,6 +551,48 @@ describe('main suite 1', () => {
549551
await browser.switchFrame($('iframe'))
550552
await expect($('#tinymce')).toBePresent()
551553
})
554+
555+
describe('switchToParentFrame', () => {
556+
it('switches to parent (not top-level)', async () => {
557+
await browser.url('https://guinea-pig.webdriver.io/iframe.html')
558+
await expect($('h1')).toHaveText('Frame Demo')
559+
await expect($('h2')).not.toExist()
560+
await expect($('h3')).not.toExist()
561+
562+
await browser.switchFrame($('#A'))
563+
await expect($('h1')).not.toExist()
564+
await expect($('h2')).toHaveText('IFrame A')
565+
await expect($('h3')).not.toExist()
566+
567+
await browser.switchFrame($('#A2'))
568+
await expect($('h1')).not.toExist()
569+
await expect($('h2')).not.toExist()
570+
await expect($('h3')).toHaveText('IFrame A2')
571+
572+
await browser.switchToParentFrame()
573+
await expect($('h1')).not.toExist()
574+
await expect($('h2')).toHaveText('IFrame A')
575+
await expect($('h3')).not.toExist()
576+
})
577+
578+
after(() => browser.switchFrame(null))
579+
})
580+
581+
describe('taking screenshots', () => {
582+
it('should take a screenshot of the iframe', async () => {
583+
await browser.url('https://guinea-pig.webdriver.io/iframe.html')
584+
await browser.switchFrame($('#A'))
585+
await browser.switchFrame($('#A2'))
586+
587+
const screenshotPath = path.resolve(__dirname, 'iframe.png')
588+
await browser.saveScreenshot(screenshotPath)
589+
const dimensions = imageSize(screenshotPath)
590+
expect(dimensions.width).toBe(187)
591+
expect(dimensions.height).toBe(85)
592+
})
593+
594+
after(() => browser.switchFrame(null))
595+
})
552596
})
553597

554598
describe('open resources with different protocols', () => {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"glob": "^11.0.0",
108108
"globals": "^15.12.0",
109109
"husky": "^9.1.4",
110+
"image-size": "^1.2.0",
110111
"inquirer": "^11.0.1",
111112
"jsdom": "^25.0.0",
112113
"lerna": "8.1.9",

packages/webdriverio/src/commands/browser/switchFrame.ts

Lines changed: 21 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import logger from '@wdio/logger'
2-
import { ELEMENT_KEY, type local, type remote } from 'webdriver'
2+
import { ELEMENT_KEY, type remote } from 'webdriver'
33
import type { ElementReference } from '@wdio/protocols'
44

5-
import { getContextManager } from '../../session/context.js'
5+
import { getContextManager, type FlatContextTree } from '../../session/context.js'
66
import { LocalValue } from '../../utils/bidi/value.js'
77
import { parseScriptResult } from '../../utils/bidi/index.js'
88
import { SCRIPT_PREFIX, SCRIPT_SUFFIX } from '../constant.js'
99
import type { ChainablePromiseElement } from '../../types.js'
1010

11-
type FlatContextTree = Omit<local.BrowsingContextInfo, 'children'> & { children: string[] }
1211
const log = logger('webdriverio:switchFrame')
1312

1413
/**
@@ -94,6 +93,8 @@ export async function switchFrame (
9493
return switchToFrame(this, context)
9594
}
9695

96+
const sessionContext = getContextManager(this)
97+
9798
/**
9899
* if context is `null` the user is switching to the top level frame
99100
* which is always represented by the value of `getWindowHandle`
@@ -115,15 +116,15 @@ export async function switchFrame (
115116
let newContextId: string | undefined
116117

117118
const urlContext = (
118-
findContext(context, tree.contexts, byUrl) ||
119+
sessionContext.findContext(context, tree.contexts, 'byUrl') ||
119120
/**
120121
* In case the user provides an url without `/` at the end, e.g. `https://example.com`,
121122
* the `browsingContextGetTree` command may return a context with the url `https://example.com/`.
122123
*/
123-
findContext(`${context}/`, tree.contexts, byUrl)
124+
sessionContext.findContext(`${context}/`, tree.contexts, 'byUrl')
124125
)
125-
const urlContextContaining = findContext(context, tree.contexts, byUrlContaining)
126-
const contextIdContext = findContext(context, tree.contexts, byContextId)
126+
const urlContextContaining = sessionContext.findContext(context, tree.contexts, 'byUrlContaining')
127+
const contextIdContext = sessionContext.findContext(context, tree.contexts, 'byContextId')
127128
if (urlContext) {
128129
log.info(`Found context by url "${urlContext.url}" with context id "${urlContext.context}"`)
129130
newContextId = urlContext.context
@@ -139,9 +140,8 @@ export async function switchFrame (
139140
throw new Error(`No frame with url or id "${context}" found!`)
140141
}
141142

142-
const sessionContext = getContextManager(this)
143143
const currentContext = await sessionContext.getCurrentContext()
144-
const allContexts = await getFlatContextTree(this)
144+
const allContexts = await sessionContext.getFlatContextTree()
145145

146146
/**
147147
* Fetch all iframes located in any available frame
@@ -252,6 +252,7 @@ export async function switchFrame (
252252
await switchToFrame(this, contextToSwitch.frameElement)
253253
}
254254

255+
sessionContext.setCurrentContext(newContextId)
255256
return newContextId
256257
}
257258

@@ -273,7 +274,7 @@ export async function switchFrame (
273274
* the function for each of them.
274275
*/
275276
if (typeof context === 'function') {
276-
const allContexts = await getFlatContextTree(this)
277+
const allContexts = await sessionContext.getFlatContextTree()
277278
const allContextIds = Object.keys(allContexts)
278279
for (const contextId of allContextIds) {
279280
const functionDeclaration = new Function(`
@@ -319,7 +320,6 @@ function switchToFrameHelper (browser: WebdriverIO.Browser, context: string) {
319320
}
320321

321322
async function switchToFrameUsingElement (browser: WebdriverIO.Browser, element: WebdriverIO.Element) {
322-
// await switchToFrame(browser, element)
323323
const frame = await browser.execute(
324324
(iframe: unknown) => (iframe as HTMLIFrameElement).contentWindow,
325325
element
@@ -332,60 +332,6 @@ async function switchToFrameUsingElement (browser: WebdriverIO.Browser, element:
332332
return frame.context
333333
}
334334

335-
function byUrl (context: local.BrowsingContextInfo, url: string) {
336-
return context.url === url
337-
}
338-
339-
function byUrlContaining (context: local.BrowsingContextInfo, url: string) {
340-
return context.url.includes(url)
341-
}
342-
343-
function byContextId (context: local.BrowsingContextInfo, contextId: string) {
344-
return context.context === contextId
345-
}
346-
347-
function findContext (
348-
urlOrId: string,
349-
contexts: local.BrowsingContextInfoList | null,
350-
matcher: typeof byUrl | typeof byUrlContaining | typeof byContextId
351-
): local.BrowsingContextInfo | undefined {
352-
for (const context of contexts || []) {
353-
if (matcher(context, urlOrId)) {
354-
return context
355-
}
356-
357-
if (Array.isArray(context.children) && context.children.length > 0) {
358-
const result = findContext(urlOrId, context.children, matcher)
359-
if (result) {
360-
return result
361-
}
362-
}
363-
}
364-
365-
return undefined
366-
}
367-
368-
async function getFlatContextTree (browser: WebdriverIO.Browser): Promise<Record<string, FlatContextTree>> {
369-
const tree = await browser.browsingContextGetTree({})
370-
371-
const mapContext = (context: local.BrowsingContextInfo): string[] => [
372-
context.context,
373-
...(context.children || []).map(mapContext).flat(Infinity) as string[]
374-
]
375-
376-
/**
377-
* transform context tree into a flat list of context objects with references
378-
* to children
379-
*/
380-
const allContexts: Record<string, FlatContextTree> = tree.contexts.map(mapContext).flat(Infinity)
381-
.reduce((acc, ctx: string) => {
382-
const context = findContext(ctx, tree.contexts, byContextId)
383-
acc[ctx] = context as unknown as FlatContextTree
384-
return acc
385-
}, {} as Record<string, FlatContextTree>)
386-
return allContexts
387-
}
388-
389335
/**
390336
* While we deprecated the `switchToFrame` command for users, we still
391337
* have to use it internally to enable support for WebDriver Classic.
@@ -394,7 +340,16 @@ async function getFlatContextTree (browser: WebdriverIO.Browser): Promise<Record
394340
*/
395341
function switchToFrame (browser: WebdriverIO.Browser, frame: ElementReference | number | null) {
396342
process.env.DISABLE_WEBDRIVERIO_DEPRECATION_WARNINGS = 'true'
397-
return browser.switchToFrame(frame).finally(() => {
343+
return browser.switchToFrame(frame).finally(async () => {
344+
const sessionContext = getContextManager(browser)
345+
const [frameTree, documentUrl] = await Promise.all([
346+
sessionContext.getFlatContextTree(),
347+
browser.execute(() => document.URL)
348+
])
349+
const frame = Object.values(frameTree).find((ctx) => ctx.url === documentUrl)
350+
if (frame) {
351+
switchToFrameHelper(browser, frame.context)
352+
}
398353
delete process.env.DISABLE_WEBDRIVERIO_DEPRECATION_WARNINGS
399354
})
400355
}

packages/webdriverio/src/node/saveScreenshot.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,18 @@ export async function saveScreenshot (
4949
const browser = getBrowserObject(this)
5050
const contextManager = getContextManager(browser)
5151
const context = await contextManager.getCurrentContext()
52-
const { data } = await this.browsingContextCaptureScreenshot({ context })
52+
const tree = await this.browsingContextGetTree({})
53+
54+
/**
55+
* WebDriver Bidi doesn't allow to take a screenshot of an iframe, it fails with:
56+
* "unsupported operation - Non-top-level 'context'". Therefor we need to check if
57+
* we are within an iframe and if so, take a screenshot of the document element
58+
* instead.
59+
*/
60+
const { data } = contextManager.findParentContext(context, tree.contexts)
61+
? await browser.$('html').getElement().then(
62+
(el) => this.takeElementScreenshot(el.elementId).then((data) => ({ data })))
63+
: await this.browsingContextCaptureScreenshot({ context })
5364
screenBuffer = data
5465
} else {
5566
screenBuffer = await this.takeScreenshot()

packages/webdriverio/src/session/context.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { local } from 'webdriver'
12
import logger from '@wdio/logger'
23

34
import { SessionManager } from './session.js'
@@ -10,6 +11,8 @@ export function getContextManager(browser: WebdriverIO.Browser) {
1011
return SessionManager.getSessionManager(browser, ContextManager)
1112
}
1213

14+
export type FlatContextTree = Omit<local.BrowsingContextInfo, 'children'> & { children: string[] }
15+
1316
/**
1417
* This class is responsible for managing context in a WebDriver session. Many BiDi commands
1518
* require to be executed in a specific context. This class is responsible for keeping track
@@ -81,6 +84,23 @@ export class ContextManager extends SessionManager {
8184
}
8285

8386
#onCommand(event: { command: string, body: unknown }) {
87+
/**
88+
* update frame context if user switches using 'switchToParentFrame'
89+
*/
90+
if (event.command === 'switchToParentFrame') {
91+
if (!this.#currentContext) {
92+
return
93+
}
94+
95+
return this.#browser.browsingContextGetTree({}).then(({ contexts }) => {
96+
const parentContext = this.findParentContext(this.#currentContext!, contexts)
97+
if (!parentContext) {
98+
return
99+
}
100+
this.setCurrentContext(parentContext.context)
101+
})
102+
}
103+
84104
/**
85105
* update frame context if user switches using 'switchToWindow'
86106
* which is WebDriver Classic only
@@ -181,4 +201,97 @@ export class ContextManager extends SessionManager {
181201
get mobileContext() {
182202
return this.#mobileContext
183203
}
204+
205+
/**
206+
* Get the flat context tree for the current session
207+
* @returns a flat list of all contexts in the current session
208+
*/
209+
async getFlatContextTree (): Promise<Record<string, FlatContextTree>> {
210+
const tree = await this.#browser.browsingContextGetTree({})
211+
212+
const mapContext = (context: local.BrowsingContextInfo): string[] => [
213+
context.context,
214+
...(context.children || []).map(mapContext).flat(Infinity) as string[]
215+
]
216+
217+
/**
218+
* transform context tree into a flat list of context objects with references
219+
* to children
220+
*/
221+
const allContexts: Record<string, FlatContextTree> = tree.contexts.map(mapContext).flat(Infinity)
222+
.reduce((acc, ctx: string) => {
223+
const context = this.findContext(ctx, tree.contexts, 'byContextId')
224+
acc[ctx] = context as unknown as FlatContextTree
225+
return acc
226+
}, {} as Record<string, FlatContextTree>)
227+
return allContexts
228+
}
229+
230+
/**
231+
* Find the parent context of a given context id
232+
* @param contextId the context id you want to find the parent of
233+
* @param contexts the list of contexts to search through returned from `browsingContextGetTree`
234+
* @returns the parent context of the context with the given id
235+
*/
236+
findParentContext (contextId: string, contexts: local.BrowsingContextInfoList): local.BrowsingContextInfo | undefined {
237+
for (const context of contexts) {
238+
if (context.children?.some((child) => child.context === contextId)) {
239+
return context
240+
}
241+
242+
if (Array.isArray(context.children) && context.children.length > 0) {
243+
const result = this.findParentContext(contextId, context.children)
244+
if (result) {
245+
return result
246+
}
247+
}
248+
}
249+
250+
return undefined
251+
}
252+
253+
/**
254+
* Find a context by URL or ID
255+
* @param urlOrId The URL or ID of the context to find
256+
* @param contexts The list of contexts to search through returned from `browsingContextGetTree`
257+
* @param matcherType The type of matcher to use to find the context
258+
* @returns The context with the given URL or ID
259+
*/
260+
findContext (
261+
urlOrId: string,
262+
contexts: local.BrowsingContextInfoList | null,
263+
matcherType: 'byUrl' | 'byUrlContaining' | 'byContextId'
264+
): local.BrowsingContextInfo | undefined {
265+
const matcher = {
266+
byUrl,
267+
byUrlContaining,
268+
byContextId
269+
}[matcherType]
270+
for (const context of contexts || []) {
271+
if (matcher(context, urlOrId)) {
272+
return context
273+
}
274+
275+
if (Array.isArray(context.children) && context.children.length > 0) {
276+
const result = this.findContext(urlOrId, context.children, matcherType)
277+
if (result) {
278+
return result
279+
}
280+
}
281+
}
282+
283+
return undefined
284+
}
285+
}
286+
287+
function byUrl (context: local.BrowsingContextInfo, url: string) {
288+
return context.url === url
289+
}
290+
291+
function byUrlContaining (context: local.BrowsingContextInfo, url: string) {
292+
return context.url.includes(url)
293+
}
294+
295+
function byContextId (context: local.BrowsingContextInfo, contextId: string) {
296+
return context.context === contextId
184297
}

0 commit comments

Comments
 (0)