Skip to content

Commit bc254a8

Browse files
feat: enhance getContext(s)|switchContext (webdriverio#14958)
* feat: add custom app identifier for switchContext * feat: add waitForWebviewMs for Android
1 parent bcb28fb commit bc254a8

7 files changed

Lines changed: 148 additions & 8 deletions

File tree

packages/webdriverio/src/commands/mobile/getContext.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logger from '@wdio/logger'
22
import type { Context, DetailedContext } from '@wdio/protocols'
3-
4-
import type { AppiumDetailedCrossPlatformContexts } from '../../types.js'
3+
import type { AppiumDetailedCrossPlatformContexts, GetContextsOptions } from '../../types.js'
54

65
const log = logger('webdriver')
76

@@ -99,10 +98,23 @@ const log = logger('webdriver')
9998
})
10099
* </example>
101100
*
101+
* <example>
102+
* :wait.for.webview.test.js
103+
* it('should wait for webview to become available before retrieving context', async () => {
104+
* // For Android
105+
* await driver.getContext({
106+
* returnDetailedContext: true,
107+
* // Wait for webview to become available at the Appium level before WebdriverIO's retry logic
108+
* waitForWebviewMs: 3000, // Wait 3 seconds for webview to become available
109+
* })
110+
* })
111+
* </example>
112+
*
102113
* @param {GetContextsOptions=} options The `getContext` options (optional)
103114
* @param {boolean=} options.returnDetailedContext By default, we only return the context name based on the default Appium `context` API, which is only a string. If you want to get back detailed context information, set this to `true`. Default is `false` (optional).
104115
* @param {number=} options.androidWebviewConnectionRetryTime The time in milliseconds to wait between each retry to connect to the webview. Default is `500` ms (optional). <br /><strong>ANDROID-ONLY</strong>
105116
* @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). <br /><strong>ANDROID-ONLY</strong>
117+
* @param {number=} options.waitForWebviewMs The time in milliseconds to wait for webviews to become available before returning contexts. This parameter is passed directly to the Appium `mobile: getContexts` command. Default is `0` ms (optional). <br /><strong>ANDROID-ONLY</strong> <br />This is useful when you know that a webview is loading but needs additional time to become available. This option works at the Appium level, before WebdriverIO's retry logic (`androidWebviewConnectionRetryTime` and `androidWebviewConnectTimeout`) is applied.
106118
* @skipUsage
107119
*/
108120
export async function getContext(
@@ -111,6 +123,7 @@ export async function getContext(
111123
returnDetailedContext?: boolean,
112124
androidWebviewConnectionRetryTime?: number,
113125
androidWebviewConnectTimeout?: number,
126+
waitForWebviewMs?: number,
114127
}
115128
): Promise<string | DetailedContext> {
116129
const browser = this
@@ -133,10 +146,10 @@ export async function getContext(
133146
async function getDetailedContext(
134147
browser: WebdriverIO.Browser,
135148
currentAppiumContext: string,
136-
options?: { androidWebviewConnectionRetryTime?: number, androidWebviewConnectTimeout?: number },
149+
options?: Pick<GetContextsOptions, 'androidWebviewConnectionRetryTime' | 'androidWebviewConnectTimeout' | 'waitForWebviewMs'>,
137150
): Promise<string | DetailedContext> {
138151
const detailedContexts = await browser.getContexts({
139-
...{ options },
152+
...options,
140153
// Defaults
141154
returnDetailedContexts: true, // We want to get back the detailed context information
142155
isAndroidWebviewVisible: true, // We only want to get back the visible webviews

packages/webdriverio/src/commands/mobile/getContexts.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,26 @@ const log = logger('webdriver')
150150
})
151151
* </example>
152152
*
153+
* <example>
154+
* :wait.for.webview.test.js
155+
* it('should wait for webview to become available before retrieving contexts', async () => {
156+
* // For Android
157+
* await driver.getContexts({
158+
* returnDetailedContexts: true,
159+
* // Wait for webview to become available at the Appium level before WebdriverIO's retry logic
160+
* waitForWebviewMs: 3000, // Wait 3 seconds for webview to become available
161+
* })
162+
* })
163+
* </example>
164+
*
153165
* @param {GetContextsOptions=} options The `getContexts` options (optional)
154166
* @param {boolean=} options.returnDetailedContexts By default, we only return the context names based on the default Appium `contexts` API. If you want to get all data, you can set this to `true`. Default is `false` (optional).
155167
* @param {number=} options.androidWebviewConnectionRetryTime The time in milliseconds to wait between each retry to connect to the webview. Default is `500` ms (optional). <br /><strong>ANDROID-ONLY</strong>
156168
* @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). <br /><strong>ANDROID-ONLY</strong>
157169
* @param {boolean=} options.filterByCurrentAndroidApp By default, we return all webviews. If you want to filter the webviews by the current Android app that is opened, you can set this to `true`. Default is `false` (optional). <br /><strong>NOTE:</strong> Be aware that you can also NOT find any Webview based on this "restriction". <br /><strong>ANDROID-ONLY</strong>
158170
* @param {boolean=} options.isAndroidWebviewVisible By default, we only return the webviews that are attached and visible. If you want to get all webviews, you can set this to `false` (optional). Default is `true`. <br /><strong>ANDROID-ONLY</strong>
159171
* @param {boolean=} options.returnAndroidDescriptionData By default, no Android Webview (Chrome) description description data. If you want to get all data, you can set this to `true`. Default is `false` (optional). <br />By enabling this option you will get extra data in the response, see the `description.data.test.js` for more information. <br /><strong>ANDROID-ONLY</strong>
172+
* @param {number=} options.waitForWebviewMs The time in milliseconds to wait for webviews to become available before returning contexts. This parameter is passed directly to the Appium `mobile: getContexts` command. Default is `0` ms (optional). <br /><strong>ANDROID-ONLY</strong> <br />This is useful when you know that a webview is loading but needs additional time to become available. This option works at the Appium level, before WebdriverIO's retry logic (`androidWebviewConnectionRetryTime` and `androidWebviewConnectTimeout`) is applied.
160173
* @skipUsage
161174
*/
162175
export async function getContexts(
@@ -220,6 +233,7 @@ type GetCurrentContexts = {
220233
filterByCurrentAndroidApp: boolean;
221234
isAndroidWebviewVisible: boolean;
222235
returnAndroidDescriptionData: boolean;
236+
waitForWebviewMs?: number;
223237
}
224238

225239
type ParsedAndroidContexts = {
@@ -380,8 +394,11 @@ async function getCurrentContexts({
380394
filterByCurrentAndroidApp,
381395
isAndroidWebviewVisible,
382396
returnAndroidDescriptionData,
397+
waitForWebviewMs,
383398
}: GetCurrentContexts): Promise<AppiumDetailedCrossPlatformContexts> {
384-
const contexts = await browser.execute('mobile: getContexts') as IosDetailedContext[] | AndroidChromeInternalContexts
399+
const contexts = await (waitForWebviewMs !== undefined
400+
? browser.execute('mobile: getContexts', { waitForWebviewMs })
401+
: browser.execute('mobile: getContexts')) as IosDetailedContext[] | AndroidChromeInternalContexts
385402

386403
// The logic for iOS is clear, we can just return the contexts which will be an array of objects with more data (see the type) instead of only strings
387404
if (browser.isIOS) {

packages/webdriverio/src/commands/mobile/switchContext.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const log = logger('webdriver')
3535
* - **Simplified Switching**: If you know the `title` or `url` of the desired webview, this method eliminates the need for
3636
* additional calls to `getContexts` or combining multiple methods like `switchContext({id})` and `getTitle()`.
3737
* - **Automatic Context Matching**: Finds the best match for a context based on:
38-
* - Platform-specific identifiers (`bundleId` for iOS, `packageName` for Android).
38+
* - Platform-specific identifiers (`bundleId` for iOS, `packageName` for Android). By default, uses the active app identifier, but you can provide a custom `appIdentifier` to search in a specific app (useful for overlays or non-active apps).
3939
* - Exact or partial matches for `title` or `url` (supports both strings and regular expressions).
4040
* - Android-specific checks to ensure webviews are attached and visible.
4141
* - **Fine-Grained Control**: Custom retry intervals and timeouts (Android-only) allow you to handle delays in webview initialization.
@@ -105,8 +105,25 @@ const log = logger('webdriver')
105105
})
106106
* </example>
107107
*
108+
* <example>
109+
* :app.identifier.test.js
110+
* it('should switch to a webview by providing a specific app identifier (bundleId for iOS or packageName for Android)', async () => {
111+
* // For Android, provide the packageName to search in a specific app (useful for overlays or non-active apps)
112+
* await driver.switchContext({
113+
* appIdentifier: 'com.otherApp',
114+
* title: 'Other Apps',
115+
* })
116+
* // For iOS, provide the bundleId to search in a specific app
117+
* await driver.switchContext({
118+
* appIdentifier: 'com.apple.mobilesafari',
119+
* url: /.*apple.com/,
120+
* })
121+
* })
122+
* </example>
123+
*
108124
* @param {string|SwitchContextOptions} context The name of the context to switch to. An object with more context options can be provided.
109125
* @param {SwitchContextOptions} options switchContext command options
126+
* @param {string=} options.appIdentifier The app identifier to search in. For iOS, this should be the `bundleId`. For Android, this should be the `packageName`. If not provided, the method will use the active app identifier. This is useful when you need to search for webviews in overlays or non-active apps that are not recognized as the "active" app.
110127
* @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.<br /><strong>IMPORTANT:</strong> When you use options then or the `title` or the `url` property is required.
111128
* @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.<br /><strong>IMPORTANT:</strong> When you use options then or the `title` or the `url` property is required.
112129
* @param {number=} options.androidWebviewConnectionRetryTime The time in milliseconds to wait between each retry to connect to the webview. Default is `500` ms (optional). <br /><strong>ANDROID-ONLY</strong> and will only be used when a `title` or `url` is provided.
@@ -165,8 +182,13 @@ async function switchToContext(
165182
const contexts = await browser.getContexts(getContextsOptions) as AppiumDetailedCrossPlatformContexts
166183

167184
// 2. Find the matching context
168-
// @ts-expect-error
169-
const identifier = browser.isIOS ? (await browser.execute('mobile: activeAppInfo'))?.bundleId : await browser.getCurrentPackage()
185+
let identifier: string
186+
if (options.appIdentifier) {
187+
identifier = options.appIdentifier
188+
} else {
189+
// @ts-expect-error
190+
identifier = browser.isIOS ? (await browser.execute('mobile: activeAppInfo'))?.bundleId : await browser.getCurrentPackage()
191+
}
170192
const { matchingContext, reasons } = findMatchingContext({ browser, contexts, identifier, ...(options?.title && { title: options.title }), ...(options?.url && { url: options.url }) })
171193

172194
if (!matchingContext) {

packages/webdriverio/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ export type GetContextsOptions = {
653653
isAndroidWebviewVisible?: boolean;
654654
returnAndroidDescriptionData?: boolean;
655655
returnDetailedContexts?: boolean;
656+
waitForWebviewMs?: number;
656657
}
657658

658659
export type ActiveAppInfo = {

packages/webdriverio/tests/commands/mobile/__snapshots__/switchContext.test.ts.snap

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,23 @@ exports[`switchContext test > should throw an error when no matching context is
5252
- App bundleId 'com.foo' did not match: 'WEBVIEW_86152.1'
5353
- Title 'No matching Title' did not match: 'Apple']
5454
`;
55+
56+
exports[`switchContext test > should throw an error when the provided appIdentifier does not match any context 1`] = `
57+
[Error: We parsed a total of 5 Webviews but did not find a matching context. The reasons are:
58+
- Webview 1: 'NATIVE_APP'
59+
- Skipped context because it is NATIVE_APP
60+
- Webview 2: 'WEBVIEW_com.wdiodemoapp'
61+
- App packageName 'com.nonexistent.app' did not match: 'WEBVIEW_com.wdiodemoapp'
62+
- Title 'Some Title' did not match: 'WebdriverIO · Next-gen browser and mobile automation test framework for Node.js | WebdriverIO'
63+
- Webview 3: 'WEBVIEW_com.otherApp'
64+
- App packageName 'com.nonexistent.app' did not match: 'WEBVIEW_com.otherApp'
65+
- Title 'Some Title' did not match: 'Other Apps · I am just another app'
66+
- Webview 4: 'WEBVIEW_com.otherNonVisibleApp'
67+
- App packageName 'com.nonexistent.app' did not match: 'WEBVIEW_com.otherNonVisibleApp'
68+
- Title 'Some Title' did not match: 'Other Apps · I am just another app which is not visible'
69+
- Additional Android checks failed
70+
- Webview 5: 'WEBVIEW_chrome'
71+
- App packageName 'com.nonexistent.app' did not match: 'WEBVIEW_chrome'
72+
- Title 'Some Title' did not match: 'Android | Get more done with Google on Android-phones and devices'
73+
- Additional Android checks failed]
74+
`;

packages/webdriverio/tests/commands/mobile/getContexts.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,4 +510,20 @@ describe('getContexts test', () => {
510510
expect(emptyContext.packageName).toBe('com.ismobile.android.blaandroid')
511511
}
512512
})
513+
514+
it('should pass waitForWebviewMs parameter to mobile: getContexts when provided', async () => {
515+
browser = await remote({
516+
baseUrl: 'http://foobar.com',
517+
capabilities: {
518+
browserName: 'foobar',
519+
mobileMode: true,
520+
platformName: 'iOS',
521+
} as any
522+
})
523+
const executeSpy = vi.spyOn(browser, 'execute').mockResolvedValue(iOSContexts)
524+
await browser.getContexts({ returnDetailedContexts: true, waitForWebviewMs: 3000 })
525+
526+
expect(executeSpy).toHaveBeenCalledTimes(1)
527+
expect(executeSpy).toHaveBeenCalledWith('mobile: getContexts', { waitForWebviewMs: 3000 })
528+
})
513529
})

packages/webdriverio/tests/commands/mobile/switchContext.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,55 @@ describe('switchContext test', () => {
295295
getCurrentPackageSpy.mockRestore()
296296
switchAppiumContextSpy.mockRestore()
297297
})
298+
299+
it('should find a matching context when using a custom appIdentifier', async () => {
300+
logSpy = vi.spyOn(log, 'info')
301+
browser = await remote({
302+
baseUrl: 'http://foobar.com',
303+
capabilities: {
304+
browserName: 'foobar',
305+
mobileMode: true,
306+
platformName: 'Android',
307+
} as any
308+
})
309+
const getContextsSpy = vi.spyOn(browser, 'getContexts').mockResolvedValue(androidContexts)
310+
const switchAppiumContextSpy = vi.spyOn(browser, 'switchAppiumContext')
311+
const switchToWindowSpy = vi.spyOn(browser, 'switchToWindow')
312+
313+
// Use appIdentifier to search in a different app than the active one
314+
await browser.switchContext({
315+
appIdentifier: 'com.otherApp',
316+
title: 'Other Apps',
317+
})
318+
expect(getContextsSpy).toHaveBeenCalledTimes(1)
319+
expect(logSpy).toHaveBeenCalledWith('WebdriverIO found a matching context:', JSON.stringify(androidContexts[2], null, 2))
320+
expect(switchAppiumContextSpy).toHaveBeenCalledWith('WEBVIEW_com.otherApp')
321+
expect(switchToWindowSpy).toHaveBeenCalledWith(androidContexts[2].webviewPageId)
322+
323+
logSpy.mockRestore()
324+
getContextsSpy.mockRestore()
325+
switchAppiumContextSpy.mockRestore()
326+
switchToWindowSpy.mockRestore()
327+
})
328+
329+
it('should throw an error when the provided appIdentifier does not match any context', async () => {
330+
browser = await remote({
331+
baseUrl: 'http://foobar.com',
332+
capabilities: {
333+
browserName: 'foobar',
334+
mobileMode: true,
335+
platformName: 'Android',
336+
} as any
337+
})
338+
const getContextsSpy = vi.spyOn(browser, 'getContexts').mockResolvedValue(androidContexts)
339+
const switchAppiumContextSpy = vi.spyOn(browser, 'switchAppiumContext')
340+
341+
await expect(browser.switchContext({
342+
appIdentifier: 'com.nonexistent.app',
343+
title: 'Some Title',
344+
})).rejects.toThrowErrorMatchingSnapshot()
345+
346+
getContextsSpy.mockRestore()
347+
switchAppiumContextSpy.mockRestore()
348+
})
298349
})

0 commit comments

Comments
 (0)