From bc302225c42cf742a9beb20e45b3ce5b73066ce3 Mon Sep 17 00:00:00 2001 From: Priyanka Gadhiya Date: Mon, 27 Jul 2026 16:00:52 +0530 Subject: [PATCH 1/6] fix(automate): detect App Automate from appium:app capability for session name/status (SDK-7093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app is supplied only via the appium:app capability (not the top-level browserstack service `app` option), this.config.app is undefined, so isAppAutomate resolved false and the session name/status PUT was routed to the web Automate endpoint for an App Automate session — returning a swallowed 404, leaving the static sessionName capability on the dashboard. Route both markSessionName and markSessionStatus through a new isAppAutomate() helper that also inspects the input + resolved capabilities for appium:app / appium:options.app, mirroring accessibilityModule.isAppAutomateSession. Co-Authored-By: Claude Opus 4.8 --- .../src/cli/modules/automateModule.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/browserstack-service/src/cli/modules/automateModule.ts b/packages/browserstack-service/src/cli/modules/automateModule.ts index 1a0b6d9..a301ca6 100644 --- a/packages/browserstack-service/src/cli/modules/automateModule.ts +++ b/packages/browserstack-service/src/cli/modules/automateModule.ts @@ -195,16 +195,32 @@ export default class AutomateModule extends BaseModule { this.sessionMap.clear() } + // An App Automate session is identified by the service-level app / skipAppOverride flag, + // OR an app supplied only via the appium:app / appium:options.app capability — which the + // service-level config (this.config) does not carry. Mirrors accessibilityModule.isAppAutomateSession. + private isAppAutomate(): boolean { + if (this.config.app || isTrue(this.config.skipAppOverride)) { + return true + } + // The appium:app capability survives only in the raw INPUT capabilities; the resolved + // capabilities BrowserStack echoes back drop it. Check both, like accessibilityModule. + const autoInstance = AutomationFramework.getTrackedInstance() + for (const key of [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES]) { + const c = (AutomationFramework.getState(autoInstance, key) ?? {}) as Record + if (c['appium:app'] || (c['appium:options'] as { app?: unknown } | undefined)?.app) { + return true + } + } + return false + } + async markSessionName(sessionId: string, sessionName: string, config: { user: string; key: string; }): Promise { return await PerformanceTester.measureWrapper( PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.SESSION_NAME, async (sessionId: string, sessionName: string, config: { user: string; key: string; }) => { try { const auth = Buffer.from(`${config.user}:${config.key}`).toString('base64') - // skipAppOverride runs App Automate without an app value, so route session - // name/status to the App Automate endpoint on the flag too (config echoed from - // the binary carries skipAppOverride via the binconfig service options). - const isAppAutomate = this.config.app || isTrue(this.config.skipAppOverride) + const isAppAutomate = this.isAppAutomate() if (isAppAutomate) { this.logger.info('Marking session name for App Automate') } else { @@ -244,10 +260,7 @@ export default class AutomateModule extends BaseModule { async (sessionId: string, sessionStatus: 'passed' | 'failed', sessionErrorMessage: string | undefined, config: { user: string; key: string; }) => { try { const auth = Buffer.from(`${config.user}:${config.key}`).toString('base64') - // skipAppOverride runs App Automate without an app value, so route session - // name/status to the App Automate endpoint on the flag too (config echoed from - // the binary carries skipAppOverride via the binconfig service options). - const isAppAutomate = this.config.app || isTrue(this.config.skipAppOverride) + const isAppAutomate = this.isAppAutomate() if (isAppAutomate) { this.logger.info('Marking session status for App Automate') } else { From 4b19d7a4e3ed4394859b66683ecfcb0aca9582b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:32:18 +0000 Subject: [PATCH 2/6] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-100.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-100.md diff --git a/.changeset/pr-100.md b/.changeset/pr-100.md new file mode 100644 index 0000000..14d083e --- /dev/null +++ b/.changeset/pr-100.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed App Automate session names not updating to the test title when the app is provided via the `appium:app` capability. From dc27d93ff0c828e415ea8fc2a42294b7f9af858f Mon Sep 17 00:00:00 2001 From: Priyanka Gadhiya Date: Mon, 27 Jul 2026 17:45:10 +0530 Subject: [PATCH 3/6] fix(percy,automate): reuse shared hasAppCap App-Automate detector (SDK-7093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address SDK PR review findings on #100: - Promote hasAppCap + APP_AUTOMATE_CAP_KEYS from config.ts (file-local) to util.ts as a shared export; config.ts reuses it (no behavior change). - automateModule.isAppAutomate() reuses hasAppCap — now covers all app-cap keys (appium:app / appium:bundleId / appium:appPackage / appium:appActivity / appium:options.app) instead of only appium:app. - percyModule: fix the same service-config-only detection (`'app' in this.config`) that misrouted Percy screenshots to the web Automate path for appium:app sessions — now resolves App Automate via hasAppCap over browser + input caps. - changeset: note now covers session name, status, and Percy routing. Co-Authored-By: Claude Opus 4.8 --- .changeset/pr-100.md | 2 +- .../src/cli/modules/automateModule.ts | 15 +++++---------- .../src/cli/modules/percyModule.ts | 19 ++++++++++++++++++- packages/browserstack-service/src/config.ts | 16 +--------------- packages/browserstack-service/src/util.ts | 18 ++++++++++++++++++ 5 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.changeset/pr-100.md b/.changeset/pr-100.md index 14d083e..4340091 100644 --- a/.changeset/pr-100.md +++ b/.changeset/pr-100.md @@ -2,4 +2,4 @@ "@wdio/browserstack-service": patch --- -- Fixed App Automate session names not updating to the test title when the app is provided via the `appium:app` capability. +- Fixed App Automate session name and status (and Percy screenshot routing) not being applied when the app is provided via an `appium:app`-style capability instead of the top-level `app` option — the requests were routed to the web Automate endpoint and silently no-op'd. diff --git a/packages/browserstack-service/src/cli/modules/automateModule.ts b/packages/browserstack-service/src/cli/modules/automateModule.ts index a301ca6..a09855c 100644 --- a/packages/browserstack-service/src/cli/modules/automateModule.ts +++ b/packages/browserstack-service/src/cli/modules/automateModule.ts @@ -6,7 +6,7 @@ import { HookState } from '../states/hookState.js' import type { Frameworks, Options } from '@wdio/types' import AutomationFramework from '../frameworks/automationFramework.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' -import { isBrowserstackSession, isTrue } from '../../util.js' +import { hasAppCap, isBrowserstackSession, isTrue } from '../../util.js' import type TestFrameworkInstance from '../instances/testFrameworkInstance.js' import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js' import PerformanceTester from '../../instrumentation/performance/performance-tester.js' @@ -202,16 +202,11 @@ export default class AutomateModule extends BaseModule { if (this.config.app || isTrue(this.config.skipAppOverride)) { return true } - // The appium:app capability survives only in the raw INPUT capabilities; the resolved - // capabilities BrowserStack echoes back drop it. Check both, like accessibilityModule. + // The app capability survives only in the raw INPUT capabilities; the resolved + // capabilities BrowserStack echoes back drop it. Check both, via the shared hasAppCap detector. const autoInstance = AutomationFramework.getTrackedInstance() - for (const key of [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES]) { - const c = (AutomationFramework.getState(autoInstance, key) ?? {}) as Record - if (c['appium:app'] || (c['appium:options'] as { app?: unknown } | undefined)?.app) { - return true - } - } - return false + return [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES] + .some(key => hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)) } async markSessionName(sessionId: string, sessionName: string, config: { user: string; key: string; }): Promise { diff --git a/packages/browserstack-service/src/cli/modules/percyModule.ts b/packages/browserstack-service/src/cli/modules/percyModule.ts index 6792bff..bb3008e 100644 --- a/packages/browserstack-service/src/cli/modules/percyModule.ts +++ b/packages/browserstack-service/src/cli/modules/percyModule.ts @@ -8,6 +8,8 @@ import { AutomationFrameworkState } from '../states/automationFrameworkState.js' import PercyHandler from '../../Percy/Percy-Handler.js' import type { Capabilities } from '@wdio/types' import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js' +import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' +import { hasAppCap, isTrue } from '../../util.js' import type TestFrameworkInstance from '../instances/testFrameworkInstance.js' export default class PercyModule extends BaseModule { @@ -35,6 +37,21 @@ export default class PercyModule extends BaseModule { return PercyModule.MODULE_NAME } + // App supplied via the service `app` option / skipAppOverride, OR via an appium:app-style driver + // capability (which this.config does not carry) — resolved from the caps via the shared hasAppCap + // detector so Percy routes to the App Automate screenshot path. Mirrors automateModule.isAppAutomate(). + private isAppAutomateSession(): boolean { + if ('app' in this.config || isTrue(this.config.skipAppOverride)) { + return true + } + if (hasAppCap(this.browser?.capabilities as WebdriverIO.Capabilities | undefined)) { + return true + } + const autoInstance = AutomationFramework.getTrackedInstance() + return [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES] + .some(key => hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)) + } + async onAfterCreate(args: Record) { this.browser = args.browser as WebdriverIO.Browser @@ -46,7 +63,7 @@ export default class PercyModule extends BaseModule { this.logger.warn('PercyModule: Percy capture mode is not defined in the configuration, skipping Percy initialization') return } - this.isAppAutomate = this.isAppAutomate || 'app' in this.config + this.isAppAutomate = this.isAppAutomate || this.isAppAutomateSession() this.percyHandler = new PercyHandler( (this.percyConfig as Record).percyCaptureMode as string, this.browser, diff --git a/packages/browserstack-service/src/config.ts b/packages/browserstack-service/src/config.ts index c406770..8012e53 100644 --- a/packages/browserstack-service/src/config.ts +++ b/packages/browserstack-service/src/config.ts @@ -2,23 +2,9 @@ import type { AppConfig, BrowserstackConfig } from './types.js' import type { Capabilities, Options } from '@wdio/types' import { v4 as uuidv4 } from 'uuid' import TestOpsConfig from './testOps/testOpsConfig.js' -import { isTrue, isUndefined } from './util.js' +import { hasAppCap, isTrue, isUndefined } from './util.js' import { BStackLogger } from './bstackLogger.js' -const APP_AUTOMATE_CAP_KEYS = ['appium:app', 'appium:bundleId', 'appium:appPackage', 'appium:appActivity'] as const - -function hasAppCap(cap: WebdriverIO.Capabilities | undefined): boolean { - if (!cap || typeof cap !== 'object') { - return false - } - const record = cap as Record - if (APP_AUTOMATE_CAP_KEYS.some(key => !isUndefined(record[key]))) { - return true - } - const appiumOptions = record['appium:options'] as Record | undefined - return !!(appiumOptions && !isUndefined(appiumOptions.app)) -} - function detectAppAutomate(capabilities?: Capabilities.TestrunnerCapabilities): boolean { if (!capabilities) { return false diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 3d0254d..6213ee8 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -554,6 +554,24 @@ export const isAccessibilityAutomationSession = (accessibilityFlag?: boolean | s return false } +export const APP_AUTOMATE_CAP_KEYS = ['appium:app', 'appium:bundleId', 'appium:appPackage', 'appium:appActivity'] as const + +// An App Automate session is identified by an app capability on the driver caps +// (appium:app / appium:bundleId / appium:appPackage / appium:appActivity / appium:options.app). +// Shared by config-time detection and the CLI Automate/Percy modules so App-Automate +// endpoint routing stays consistent across every place that resolves it from capabilities. +export function hasAppCap(cap?: WebdriverIO.Capabilities): boolean { + if (!cap || typeof cap !== 'object') { + return false + } + const record = cap as Record + if (APP_AUTOMATE_CAP_KEYS.some(key => !isUndefined(record[key]))) { + return true + } + const appiumOptions = record['appium:options'] as Record | undefined + return !!(appiumOptions && !isUndefined(appiumOptions.app)) +} + export const isAppAccessibilityAutomationSession = (accessibilityFlag?: boolean | string, isAppAutomate?: boolean) => { const accessibilityAutomation = isAccessibilityAutomationSession(accessibilityFlag) return accessibilityAutomation && isAppAutomate From 42b647e5d9c0918f9472071b468cbf34e04366de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:15:22 +0000 Subject: [PATCH 4/6] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-100.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-100.md b/.changeset/pr-100.md index 4340091..14d083e 100644 --- a/.changeset/pr-100.md +++ b/.changeset/pr-100.md @@ -2,4 +2,4 @@ "@wdio/browserstack-service": patch --- -- Fixed App Automate session name and status (and Percy screenshot routing) not being applied when the app is provided via an `appium:app`-style capability instead of the top-level `app` option — the requests were routed to the web Automate endpoint and silently no-op'd. +- Fixed App Automate session names not updating to the test title when the app is provided via the `appium:app` capability. From f7619c635de9d20c9c014ef435d1f7722d9c17cc Mon Sep 17 00:00:00 2001 From: Priyanka Gadhiya Date: Mon, 27 Jul 2026 17:55:50 +0530 Subject: [PATCH 5/6] test(automate,percy): mock hasAppCap + AutomationFramework caps accessors (SDK-7093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reuse refactor made automateModule + percyModule depend on the shared util.hasAppCap and (for the capability fallback) AutomationFramework getTrackedInstance/getState. Update the unit-test mocks accordingly: - automateModule.test: add hasAppCap to the wholesale util.js mock (was undefined → threw → swallowed → fetch never called). - percyModules.test: add getTrackedInstance/getState to the AutomationFramework mock so onAfterCreate's isAppAutomateSession() no longer throws. Co-Authored-By: Claude Opus 4.8 --- .../tests/cli/modules/automateModule.test.ts | 3 ++- .../tests/cli/modules/percyModules.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts index c423553..d3f47c4 100644 --- a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts @@ -36,7 +36,8 @@ vi.mock('../../../src/cli/cliLogger.js', () => ({ vi.mock('../../../src/util.js', () => ({ isBrowserstackSession: vi.fn(() => true), - isTrue: vi.fn((value) => (value + '').toLowerCase() === 'true') + isTrue: vi.fn((value) => (value + '').toLowerCase() === 'true'), + hasAppCap: vi.fn(() => false) })) vi.mock('../../../src/instrumentation/performance/performance-tester.js', () => ({ diff --git a/packages/browserstack-service/tests/cli/modules/percyModules.test.ts b/packages/browserstack-service/tests/cli/modules/percyModules.test.ts index 33faf58..7641e75 100644 --- a/packages/browserstack-service/tests/cli/modules/percyModules.test.ts +++ b/packages/browserstack-service/tests/cli/modules/percyModules.test.ts @@ -9,7 +9,9 @@ vi.mock('../../../src/cli/frameworks/testFramework.js', () => ({ vi.mock('../../../src/cli/frameworks/automationFramework.js', () => ({ default: { - registerObserver: vi.fn() + registerObserver: vi.fn(), + getTrackedInstance: vi.fn(), + getState: vi.fn() } })) From 64c67a7e6ac8bba0cc405cf1a68cffcbd40e6dbc Mon Sep 17 00:00:00 2001 From: Priyanka Gadhiya Date: Tue, 28 Jul 2026 14:45:48 +0530 Subject: [PATCH 6/6] refactor(automate,percy): extract shared hasAppCapInFrameworkState to BaseModule (SDK-7093) Addresses the PR #100 DRY review suggestions: automateModule.isAppAutomate() and percyModule.isAppAutomateSession() duplicated the same getTrackedInstance() + [INPUT_CAPABILITIES, CAPABILITIES] hasAppCap loop. Extract it once into BaseModule.hasAppCapInFrameworkState() (the common parent of both modules); both now delegate to it. Placed on BaseModule rather than as an exported util.ts function (as literally suggested) to avoid a circular import: util.ts -> automationFramework.ts -> cliUtils.ts -> util.ts. BaseModule already sits above both modules and can import AutomationFramework + the shared util.hasAppCap without a cycle. Co-Authored-By: Claude Opus 4.8 --- .../src/cli/modules/automateModule.ts | 8 ++------ .../src/cli/modules/baseModule.ts | 12 ++++++++++++ .../src/cli/modules/percyModule.ts | 5 +---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/browserstack-service/src/cli/modules/automateModule.ts b/packages/browserstack-service/src/cli/modules/automateModule.ts index a09855c..1a0aa04 100644 --- a/packages/browserstack-service/src/cli/modules/automateModule.ts +++ b/packages/browserstack-service/src/cli/modules/automateModule.ts @@ -6,7 +6,7 @@ import { HookState } from '../states/hookState.js' import type { Frameworks, Options } from '@wdio/types' import AutomationFramework from '../frameworks/automationFramework.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' -import { hasAppCap, isBrowserstackSession, isTrue } from '../../util.js' +import { isBrowserstackSession, isTrue } from '../../util.js' import type TestFrameworkInstance from '../instances/testFrameworkInstance.js' import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js' import PerformanceTester from '../../instrumentation/performance/performance-tester.js' @@ -202,11 +202,7 @@ export default class AutomateModule extends BaseModule { if (this.config.app || isTrue(this.config.skipAppOverride)) { return true } - // The app capability survives only in the raw INPUT capabilities; the resolved - // capabilities BrowserStack echoes back drop it. Check both, via the shared hasAppCap detector. - const autoInstance = AutomationFramework.getTrackedInstance() - return [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES] - .some(key => hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)) + return this.hasAppCapInFrameworkState() } async markSessionName(sessionId: string, sessionName: string, config: { user: string; key: string; }): Promise { diff --git a/packages/browserstack-service/src/cli/modules/baseModule.ts b/packages/browserstack-service/src/cli/modules/baseModule.ts index 75baf1c..8ca4ae8 100644 --- a/packages/browserstack-service/src/cli/modules/baseModule.ts +++ b/packages/browserstack-service/src/cli/modules/baseModule.ts @@ -1,5 +1,8 @@ import { BStackLogger } from '../cliLogger.js' import type { SDKClient } from '../../grpc/index.js' +import AutomationFramework from '../frameworks/automationFramework.js' +import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' +import { hasAppCap } from '../../util.js' /** * Base class for BrowserStack modules @@ -54,4 +57,13 @@ export default class BaseModule { BStackLogger.debug(`Configured module ${this.getModuleName()} with binSessionId=${binSessionId}, platformIndex=${platformIndex}`) } + + // Shared App Automate detection from the tracked framework capabilities: the app cap survives + // only in the raw INPUT capabilities (the resolved caps BrowserStack echoes back drop it), so + // check both. Reused by AutomateModule + PercyModule to keep endpoint/product routing consistent. + protected hasAppCapInFrameworkState(): boolean { + const autoInstance = AutomationFramework.getTrackedInstance() + return [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES] + .some(key => hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)) + } } diff --git a/packages/browserstack-service/src/cli/modules/percyModule.ts b/packages/browserstack-service/src/cli/modules/percyModule.ts index bb3008e..b50bd49 100644 --- a/packages/browserstack-service/src/cli/modules/percyModule.ts +++ b/packages/browserstack-service/src/cli/modules/percyModule.ts @@ -8,7 +8,6 @@ import { AutomationFrameworkState } from '../states/automationFrameworkState.js' import PercyHandler from '../../Percy/Percy-Handler.js' import type { Capabilities } from '@wdio/types' import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js' -import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' import { hasAppCap, isTrue } from '../../util.js' import type TestFrameworkInstance from '../instances/testFrameworkInstance.js' @@ -47,9 +46,7 @@ export default class PercyModule extends BaseModule { if (hasAppCap(this.browser?.capabilities as WebdriverIO.Capabilities | undefined)) { return true } - const autoInstance = AutomationFramework.getTrackedInstance() - return [AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES, AutomationFrameworkConstants.KEY_CAPABILITIES] - .some(key => hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)) + return this.hasAppCapInFrameworkState() } async onAfterCreate(args: Record) {