import type { local } from 'webdriver' import logger from '@wdio/logger' import { SessionManager } from './session.js' import { getMobileContext, getNativeContext } from '../utils/mobile.js' import { environment } from '../environment.js' const log = logger('webdriverio:context') const COMMANDS_REQUIRING_RESET = ['deleteSession', 'refresh', 'switchToParentFrame'] export function getContextManager(browser: WebdriverIO.Browser) { return SessionManager.getSessionManager(browser, ContextManager) } export type FlatContextTree = Omit & { children: string[] } /** * This class is responsible for managing context in a WebDriver session. Many BiDi commands * require to be executed in a specific context. This class is responsible for keeping track * of the current context and providing the current context the user is in. */ export class ContextManager extends SessionManager { #browser: WebdriverIO.Browser #currentContext?: string #mobileContext?: string #isNativeContext: boolean #getContextSupport = true constructor(browser: WebdriverIO.Browser) { super(browser, ContextManager.name) this.#browser = browser const capabilities = this.#browser.capabilities this.#isNativeContext = getNativeContext({ capabilities, isMobile: this.#browser.isMobile }) this.#mobileContext = getMobileContext({ capabilities, isAndroid: this.#browser.isAndroid, isNativeContext: this.#isNativeContext }) /** * Listens for the 'closeWindow' browser command to handle context changes. * (classic + bidi) */ this.#browser.on('result', this.#onCommandResultBidiAndClassic.bind(this)) // only listen to command events if we are in a bidi session or a mobile session // Adding the check for mobile in the `this.isEnabled()` method breaking the method and throws // `Test execution failed: TypeError: __privateGet(...).sessionSubscribe is not a function` if (!this.isEnabled() && !this.#browser.isMobile) { return } /** * Listens for the 'switchToWindow' browser command to handle context changes. * Updates the browsingContext with the context passed in 'switchToWindow'. */ this.#browser.on('command', this.#onCommand.bind(this)) /** * Listens for the 'closeWindow' browser command to handle context changes. */ if (this.#browser.isMobile) { this.#browser.on('result', this.#onCommandResultMobile.bind(this)) } else { /** * Listen to the 'browsingContext.navigationStarted' event to handle context changes * through navigation within e.g. frames. */ this.#browser.sessionSubscribe({ events: ['browsingContext.navigationStarted'] }) this.#browser.on('browsingContext.navigationStarted', async (nav) => { /** * no need to do anything as we navigate within the same context */ if (!this.#currentContext || nav.context === this.#currentContext) { return } /** * a navigation event may have changed the tree structure, so we need to get the * current tree and see if our context is still there, if not, we need to reset * the context to the first context in the tree. */ const { contexts } = await this.#browser.browsingContextGetTree({}) /** * check if the context is still in the tree, if not, switch to... */ const hasContext = this.findContext(this.#currentContext, contexts, 'byContextId') /** * ...the context we are navigating to */ const newContext = contexts.find((context) => context.context === nav.context) if (!hasContext && newContext) { this.setCurrentContext(newContext.context) this.#browser.switchToWindow(this.#currentContext) return } }) } } removeListeners(): void { super.removeListeners() this.#browser.off('result', this.#onCommandResultBidiAndClassic.bind(this)) this.#browser.off('command', this.#onCommand.bind(this)) if (this.#browser.isMobile) { this.#browser.off('result', this.#onCommandResultMobile.bind(this)) } } #onCommandResultBidiAndClassic(event: { command: string, result: unknown }) { /** * the `closeWindow` command returns: * > the result of running the remote end steps for the Get Window Handles command, with session, URL variables and parameters. */ if (event.command === 'closeWindow') { const windowHandles = (event.result as { value?: string[] }).value || [] if (windowHandles.length === 0) { throw new Error('All window handles were removed, causing WebdriverIO to close the session.') } this.#currentContext = windowHandles[0] return this.#browser.switchToWindow(this.#currentContext) } } #onCommand(event: { command: string, body: unknown }) { /** * update frame context if user switches using 'switchToParentFrame' */ if (event.command === 'switchToParentFrame') { if (!this.#currentContext) { return } return this.#browser.browsingContextGetTree({}).then(({ contexts }) => { const parentContext = this.findParentContext(this.#currentContext!, contexts) if (!parentContext) { return } this.setCurrentContext(parentContext.context) }) } /** * update frame context if user switches using 'switchToWindow' * which is WebDriver Classic only */ if (event.command === 'switchToWindow') { this.setCurrentContext((event.body as { handle: string }).handle) } /** * reset current context if: * - user uses 'switchToParentFrame' which only impacts WebDriver Classic commands * - user uses 'refresh' which resets the context in Classic and so should in Bidi * - user uses 'reload' to reload the session */ if (COMMANDS_REQUIRING_RESET.includes(event.command)) { this.#currentContext = undefined } /** * Keep track of the context to which we switch */ if (this.#browser.isMobile && event.command === 'switchAppiumContext') { this.#mobileContext = (event.body as { name: string }).name } } #onCommandResultMobile(event: { command: string, result: unknown }) { if (event.command === 'getAppiumContext') { this.setCurrentContext((event.result as { value: string }).value) } if ( event.command === 'switchAppiumContext' && (event.result as { value: string | null }).value === null && this.#mobileContext ) { this.setCurrentContext(this.#mobileContext) } } /** * set context at the start of the session */ async initialize () { /** * don't run this in unit tests */ if (environment.value.variables.WDIO_UNIT_TESTS) { return '' } /** * If we're in a mobile context, we need to get the current context if it's not already set. */ if ( this.#browser.isMobile && !this.#isNativeContext && !this.#mobileContext && this.#getContextSupport ) { const context = await this.#browser.getContext().catch((err) => { log.warn( `Error getting context: ${err}\n\n` + `WebDriver capabilities: ${JSON.stringify(this.#browser.capabilities)}\n` + `Requested WebDriver capabilities: ${JSON.stringify(this.#browser.requestedCapabilities)}` ) /** * Avoid continuing fetching the context if the environment does not support it */ if (err.message.includes('Request failed with status code 405')) { this.#getContextSupport = false } return undefined }) this.#mobileContext = typeof context === 'string' ? context : typeof context === 'object' ? context.id : undefined } const windowHandle = this.#mobileContext || await this.#browser.getWindowHandle() this.setCurrentContext(windowHandle) return windowHandle } setCurrentContext (context: string) { this.#currentContext = context if (this.#browser.isMobile) { this.#isNativeContext = context ? context === 'NATIVE_APP' : this.#isNativeContext this.#mobileContext = context || undefined } } async getCurrentContext () { if (!this.#currentContext) { return this.initialize() } return this.#currentContext } get isNativeContext() { return this.#isNativeContext } get mobileContext() { return this.#mobileContext } /** * Get the flat context tree for the current session * @returns a flat list of all contexts in the current session */ async getFlatContextTree (): Promise> { const tree = await this.#browser.browsingContextGetTree({}) const mapContext = (context: local.BrowsingContextInfo): string[] => [ context.context, ...(context.children || []).map(mapContext).flat(Infinity) as string[] ] /** * transform context tree into a flat list of context objects with references * to children */ const allContexts: Record = tree.contexts.map(mapContext).flat(Infinity) .reduce((acc, ctx: string) => { const context = this.findContext(ctx, tree.contexts, 'byContextId') acc[ctx] = context as unknown as FlatContextTree return acc }, {} as Record) return allContexts } /** * Find the parent context of a given context id * @param contextId the context id you want to find the parent of * @param contexts the list of contexts to search through returned from `browsingContextGetTree` * @returns the parent context of the context with the given id */ findParentContext (contextId: string, contexts: local.BrowsingContextInfoList): local.BrowsingContextInfo | undefined { for (const context of contexts) { if (context.children?.some((child) => child.context === contextId)) { return context } if (Array.isArray(context.children) && context.children.length > 0) { const result = this.findParentContext(contextId, context.children) if (result) { return result } } } return undefined } /** * Find a context by URL or ID * @param urlOrId The URL or ID of the context to find * @param contexts The list of contexts to search through returned from `browsingContextGetTree` * @param matcherType The type of matcher to use to find the context * @returns The context with the given URL or ID */ findContext ( urlOrId: string, contexts: local.BrowsingContextInfoList | null, matcherType: 'byUrl' | 'byUrlContaining' | 'byContextId' ): local.BrowsingContextInfo | undefined { const matcher = { byUrl, byUrlContaining, byContextId }[matcherType] for (const context of contexts || []) { if (matcher(context, urlOrId)) { return context } if (Array.isArray(context.children) && context.children.length > 0) { const result = this.findContext(urlOrId, context.children, matcherType) if (result) { return result } } } return undefined } } function byUrl (context: local.BrowsingContextInfo, url: string) { return context.url === url } function byUrlContaining (context: local.BrowsingContextInfo, url: string) { return context.url.includes(url) } function byContextId (context: local.BrowsingContextInfo, contextId: string) { return context.context === contextId }