import logger from '@wdio/logger' import type { AndroidDetailedContext, AppiumDetailedCrossPlatformContexts, IosDetailedContext, SwitchContextOptions } from '../../types.js' const log = logger('webdriver') /** * Switch to a specific context using a given Webview `name`, `title`, or `url`. * * This method enhances the default Appium `context` command by offering more flexibility and precision * for switching between native and webview contexts in hybrid mobile applications. * * ### How Contexts Work * For an overview of Hybrid Apps and webviews, refer to the [Hybrid Apps documentation](/docs/api/mobile#hybrid-apps). * Below is a summary of how the `switchContext` command addresses common challenges: * * #### Android Challenges * - Webviews often contain multiple pages (similar to browser tabs). Identifying the correct page requires additional * metadata such as `title` or `url`, which is not provided by default Appium methods. * - Default Appium methods return only basic context names (e.g., `WEBVIEW_{packageName}`) without details about * the content or pages within the webview. * - Switching contexts on Android involves two steps, which are handled automatically by this method: * 1. Switch to the Webview context using `WEBVIEW_{packageName}`. * 2. Select the appropriate page within the Webview using the `switchToWindow` method. * * #### iOS Challenges * - Webviews are identified by generic IDs (e.g., `WEBVIEW_{id}`), which do not provide information about the content * or the app screen they correspond to. * - Determining the correct webview for interaction often requires trial and error. * * The `switchContext` method simplifies this process by retrieving detailed metadata (e.g., `title`, `url`, and visibility) * to ensure accurate and reliable context switching. * * ### Why Use This Method? * - **Simplified Switching**: If you know the `title` or `url` of the desired webview, this method eliminates the need for * additional calls to `getContexts` or combining multiple methods like `switchContext({id})` and `getTitle()`. * - **Automatic Context Matching**: Finds the best match for a context based on: * - Platform-specific identifiers (`bundleId` for iOS, `packageName` for Android). * - Exact or partial matches for `title` or `url` (supports both strings and regular expressions). * - Android-specific checks to ensure webviews are attached and visible. * - **Fine-Grained Control**: Custom retry intervals and timeouts (Android-only) allow you to handle delays in webview initialization. * - **Default Appium Method Access**: If needed, you can use the default Appium `switchContext` command via `driver.switchAppiumContext()`. * * :::info Notes and Limitations * * - If the `title` or `url` of the desired webview is known, this method can automatically locate and switch to the matching context without additional `getContexts` calls. * - Android-specific options like `androidWebviewConnectionRetryTime` and `androidWebviewConnectTimeout` are not applicable to iOS. * - Logs reasons for context-matching failures to assist with debugging. * - When using an object as input, either `title` or `url` is required. * * ::: * * :example.test.js it('should switch to a webview by name and uses the default Appium `context`-method', async () => { // For Android, the context will be '`WEBVIEW_{packageName}`' await driver.switchContext('WEBVIEW_com.wdiodemoapp') // For iOS, the context will be 'WEBVIEW_{number}' await driver.switchContext('WEBVIEW_94703.19') }) * * * :exact.title.test.js it('should switch to a webview and match a webview based on an EXACT match of the `title` of the webview', async () => { await driver.switchContext({ // In this case the title needs to be an exact match title: 'Webview Title', }) }) * * * :exact.url.test.js it('should switch to a webview and match a webview based on an EXACT match of the `title` of the webview', async () => { await driver.switchContext({ // In this case the url needs to be an exact match url: 'https://webdriver.io', }) }) * * * :regex.title.url.test.js it('should switch to a webview and match a webview based on regex match of the `title` and `url` of the webview', async () => { await driver.switchContext({ // The title should NOT end with 'foo' title: /^(?!.*foo$)/, // Matches any string that contains the substring `docs/api/mobile/switchContext` url: /.*docs\/api\/mobile\/switchContext/, }) }) * * * :android.context.waits.test.js it('should switch to a webview for Android but wait longer to connect and find a webview based on provided options', async () => { await driver.switchContext({ // In this case the title need to be an exact match title: 'Webview Title', // For Android we might need to wait a bit longer to connect to the webview, so we can provide some additional options androidWebviewConnectionRetryTime: 1*1000, // Retry every 1 second androidWebviewConnectTimeout: 10*1000, // Timeout after 10 seconds }) }) * * * @param {string|SwitchContextOptions} context The name of the context to switch to. An object with more context options can be provided. * @param {SwitchContextOptions} options switchContext command options * @param {string|RegExp=} options.title The title of the page to switch to. This will be the content of the title-tag of a webviewpage. You can use a string that needs to fully match or or a regular expression.
IMPORTANT: When you use options then or the `title` or the `url` property is required. * @param {string|RegExp=} options.url The url of the page to switch to. This will be the `url` of a webviewpage. You can use a string that needs to fully match or or a regular expression.
IMPORTANT: When you use options then or the `title` or the `url` property is required. * @param {number=} options.androidWebviewConnectionRetryTime The time in milliseconds to wait between each retry to connect to the webview. Default is `500` ms (optional).
ANDROID-ONLY and will only be used when a `title` or `url` is provided. * @param {number=} options.androidWebviewConnectTimeout The maximum amount of time in milliseconds to wait for a web view page to be detected. Default is `5000` ms (optional).
ANDROID-ONLY and will only be used when a `title` or `url` is provided. * @skipUsage */ export async function switchContext( this: WebdriverIO.Browser, options: string | SwitchContextOptions ) { const browser = this if (!browser.isMobile) { throw new Error('The `switchContext` command is only available for mobile platforms.') } if (!options) { throw new Error('You need to provide at least a context name to switch to. See https://webdriver.io/docs/api/mobile/switchContext for more information.') } if (typeof options === 'string') { log.info('The standard Appium `context`-method is used. If you want to switch to a webview with a specific title or url, please provide an object with the `title` or `url` property. See https://webdriver.io/docs/api/mobile/switchContext for more information.') return browser.switchAppiumContext(options as string) } if (!options.title && !options.url) { throw new Error('You need to provide at least a `title` or `url` property to use full potential of the `switchContext` command. See https://webdriver.io/docs/api/mobile/switchContext for more information.') } return switchToContext({ browser, options }) } type FindMatchingContextOptions = { browser: WebdriverIO.Browser; contexts: AppiumDetailedCrossPlatformContexts; identifier: string; title?: string | RegExp; url?: string | RegExp; } type FindMatchingContextOutput = { matchingContext: AndroidDetailedContext | IosDetailedContext | undefined; reasons: string[] } async function switchToContext( { browser, options }:{ browser: WebdriverIO.Browser, options: SwitchContextOptions } ) { // 1. Get all the detailed contexts const getContextsOptions = { returnDetailedContexts: true, filterByCurrentAndroidApp: false, isAndroidWebviewVisible: false, returnAndroidDescriptionData: true, ...(options?.androidWebviewConnectionRetryTime && { androidWebviewConnectionRetryTime: options.androidWebviewConnectionRetryTime }), ...(options?.androidWebviewConnectTimeout && { androidWebviewConnectTimeout: options.androidWebviewConnectTimeout }), } const contexts = await browser.getContexts(getContextsOptions) as AppiumDetailedCrossPlatformContexts // 2. Find the matching context // @ts-expect-error const identifier = browser.isIOS ? (await browser.execute('mobile: activeAppInfo'))?.bundleId : await browser.getCurrentPackage() const { matchingContext, reasons } = findMatchingContext({ browser, contexts, identifier, ...(options?.title && { title: options.title }), ...(options?.url && { url: options.url }) }) if (!matchingContext) { throw new Error(reasons.join('\n')) } log.info('WebdriverIO found a matching context:', JSON.stringify(matchingContext, null, 2)) // 3. Android works differently, it has a Webview that can hold multiple pages, also comparable with tabs in a browser if (!browser.isIOS) { // So first switch to the Webview const webviewName = `WEBVIEW_${identifier}` await browser.switchAppiumContext(webviewName) } // 4. Switch to the correct context/page // For iOS we can just use the `browser.switchAppiumContext()` method to switch to the webview, // but for Android we are already in the webview. We now need to switch to the correct page inside the webview // that will be done by using the `browser.switchToWindow()` method const switchFunction: (context: string) => Promise = browser.isIOS ? browser.switchAppiumContext.bind(browser) : browser.switchToWindow.bind(browser) const matchingContextId = (browser.isIOS ? matchingContext.id : (matchingContext as AndroidDetailedContext).webviewPageId) as string // Now switch to the correct context return switchFunction(matchingContextId) } function findMatchingContext({ browser: { isIOS }, contexts, identifier, title, url, }: FindMatchingContextOptions):FindMatchingContextOutput { const reasons: string[] = [] reasons.push(`We parsed a total of ${contexts.length} Webviews but did not find a matching context. The reasons are:`) const matchingContext = contexts.find((context, index) => { reasons.push(`- Webview ${index + 1}: '${context.id}'`) if (context.id === 'NATIVE_APP') { reasons.push(' - Skipped context because it is NATIVE_APP') return false } const idMatch = isIOS ? (context as IosDetailedContext).bundleId === identifier : (context as AndroidDetailedContext).packageName === identifier const titleMatches = title ? title instanceof RegExp ? title.test(context.title || '') : context.title?.includes(title) : true const urlMatches = url ? url instanceof RegExp ? url.test(context.url || '') : context.url?.includes(url) : true const additionalAndroidChecks = isIOS ? true : (context as AndroidDetailedContext).androidWebviewData?.attached && (context as AndroidDetailedContext).androidWebviewData?.visible if (!idMatch) {reasons.push(` - App ${isIOS ? 'bundleId' : 'packageName'} '${identifier}' did not match: '${context.id}'`)} if (!titleMatches) {reasons.push(` - Title '${title}' did not match: '${context.title}'`)} if (!urlMatches) {reasons.push(` - URL '${url}' did not match: '${context.url}'`)} if (!additionalAndroidChecks) {reasons.push(' - Additional Android checks failed')} return idMatch && titleMatches && urlMatches && additionalAndroidChecks }) return { matchingContext, reasons } }