Skip to content
5 changes: 5 additions & 0 deletions .changeset/pr-100.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 12 additions & 8 deletions packages/browserstack-service/src/cli/modules/automateModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,23 @@ 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
}
return this.hasAppCapInFrameworkState()
}

async markSessionName(sessionId: string, sessionName: string, config: { user: string; key: string; }): Promise<void> {
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 {
Expand Down Expand Up @@ -244,10 +251,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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/browserstack-service/src/cli/modules/baseModule.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
}
}
16 changes: 15 additions & 1 deletion packages/browserstack-service/src/cli/modules/percyModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { hasAppCap, isTrue } from '../../util.js'
import type TestFrameworkInstance from '../instances/testFrameworkInstance.js'

export default class PercyModule extends BaseModule {
Expand Down Expand Up @@ -35,6 +36,19 @@ 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
}
return this.hasAppCapInFrameworkState()
}

async onAfterCreate(args: Record<string, unknown>) {
this.browser = args.browser as WebdriverIO.Browser

Expand All @@ -46,7 +60,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<string, unknown>).percyCaptureMode as string,
this.browser,
Expand Down
16 changes: 1 addition & 15 deletions packages/browserstack-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
if (APP_AUTOMATE_CAP_KEYS.some(key => !isUndefined(record[key]))) {
return true
}
const appiumOptions = record['appium:options'] as Record<string, unknown> | undefined
return !!(appiumOptions && !isUndefined(appiumOptions.app))
}

function detectAppAutomate(capabilities?: Capabilities.TestrunnerCapabilities): boolean {
if (!capabilities) {
return false
Expand Down
18 changes: 18 additions & 0 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
if (APP_AUTOMATE_CAP_KEYS.some(key => !isUndefined(record[key]))) {
return true
}
const appiumOptions = record['appium:options'] as Record<string, unknown> | undefined
return !!(appiumOptions && !isUndefined(appiumOptions.app))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can export another function here:

Suggested change
import AutomationFramework from './cli/frameworks/automationFramework.js'
import { AutomationFrameworkConstants } from './cli/frameworks/constants/automationFrameworkConstants.js'
export function hasAppCapInFrameworkState(): boolean {
const autoInstance = AutomationFramework.getTrackedInstance()
const keys = [
AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES,
AutomationFrameworkConstants.KEY_CAPABILITIES
]
return keys.some(key =>
hasAppCap(AutomationFramework.getState(autoInstance, key) as WebdriverIO.Capabilities | undefined)
)
}

export const isAppAccessibilityAutomationSession = (accessibilityFlag?: boolean | string, isAppAutomate?: boolean) => {
const accessibilityAutomation = isAccessibilityAutomationSession(accessibilityFlag)
return accessibilityAutomation && isAppAutomate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}))

Expand Down
Loading