Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-skip-app-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": minor
---

Add a `skipAppOverride` service option for App Automate (Appium) runs. With `skipAppOverride: true` the service classifies the session as App Automate even when no `app` option is set, does not upload an app, and does not inject an `appium:app` capability — the user supplies the app reference themselves (e.g. a pre-uploaded `bs://` hash as a driver capability or via `BROWSERSTACK_APP_ID`). Setting it together with an `app` option logs a conflict warning and ignores the `app` option; `skipAppOverride: false` with no `app` fails fast with a configuration error before any session starts. Unset keeps existing behaviour unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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'
Expand Down Expand Up @@ -201,7 +201,10 @@ export default class AutomateModule extends BaseModule {
async (sessionId: string, sessionName: string, config: { user: string; key: string; }) => {
try {
const auth = Buffer.from(`${config.user}:${config.key}`).toString('base64')
const isAppAutomate = this.config.app
// 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)
if (isAppAutomate) {
this.logger.info('Marking session name for App Automate')
} else {
Expand Down Expand Up @@ -241,7 +244,10 @@ 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')
const isAppAutomate = this.config.app
// 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)
if (isAppAutomate) {
this.logger.info('Marking session status for App Automate')
} else {
Expand Down
7 changes: 5 additions & 2 deletions packages/browserstack-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { isUndefined } from './util.js'
import { 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
Expand Down Expand Up @@ -102,7 +102,10 @@ class BrowserStackConfig {
this.percy = options.percy || false
this.accessibility = options.accessibility
this.app = options.app
this.appAutomate = !isUndefined(options.app) || detectAppAutomate(capabilities)
// `skipAppOverride: true` marks an App Automate run even when no `app` is set — the user
// supplies the app themselves via the appium:app driver capability, so classification must not
// depend on an app value being present. Mirrors isAppAutomateSession() in the Node SDK.
this.appAutomate = !isUndefined(options.app) || isTrue(options.skipAppOverride) || detectAppAutomate(capabilities)
this.automate = !this.appAutomate
this.buildIdentifier = options.buildIdentifier
this.sdkRunID = uuidv4()
Expand Down
2 changes: 1 addition & 1 deletion packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const DEFAULT_WAIT_TIMEOUT_FOR_PENDING_UPLOADS = 5000 // 5s
export const DEFAULT_WAIT_INTERVAL_FOR_PENDING_UPLOADS = 100 // 100ms
export const BSTACK_SERVICE_VERSION = bstackServiceVersion

export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'testManagementOptions']
export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'testManagementOptions', 'skipAppOverride']
export const BROWSERSTACK_TEST_PLAN_ID = 'BROWSERSTACK_TEST_PLAN_ID'

export const LOGS_FILE = 'logs/bstack-wdio-service.log'
Expand Down
9 changes: 8 additions & 1 deletion packages/browserstack-service/src/insights-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
o11yClassErrorHandler,
removeAnsiColors,
getObservabilityProduct,
generateHashCodeFromFields
generateHashCodeFromFields,
isTrue
} from './util.js'
import type {
TestData,
Expand Down Expand Up @@ -108,6 +109,12 @@ class _InsightsHandler {
}

_isAppAutomate(): boolean {
// Honor skipAppOverride here too: this handler recomputes App Automate from caps for the
// Observability product attribution, but a skipAppOverride run has no injected `appium:app`
// cap, so without this it would misclassify as 'automate'. Mirrors service._isAppAutomate().
if (isTrue(this._options?.skipAppOverride)) {
return true
}
const browserDesiredCapabilities = (this._browser?.capabilities ?? {})
const desiredCapabilities = (this._userCaps ?? {}) as WebdriverIO.Capabilities
return !!browserDesiredCapabilities['appium:app'] || !!desiredCapabilities['appium:app'] || !!(desiredCapabilities['appium:options']?.app)
Expand Down
15 changes: 14 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import {
mergeChromeOptions,
isValidEnabledValue,
isMultiRemoteCaps,
coerceStringBooleans
coerceStringBooleans,
validateSkipAppOverride
} from './util.js'
import CrashReporter from './crash-reporter.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
Expand Down Expand Up @@ -249,6 +250,18 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
async onPrepare (config: Options.Testrunner, capabilities: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT)

// skipAppOverride: emit the fixed warning once + handle the 3 edge cases before anything
// else. Runs once here in the launcher (main process). Edge-2 (explicit false + no app) is a
// deliberate pre-session config error, surfaced as SevereServiceError so the run aborts cleanly.
try {
validateSkipAppOverride(this._options)
} catch (error) {
throw new SevereServiceError((error as Error).message)
}
// Keep the config singleton consistent: validateSkipAppOverride clears this._options.app on the
// edge-1 conflict, but browserStackConfig.app was copied earlier in the constructor.
this.browserStackConfig.app = this._options.app

// Send Funnel start request
await sendStart(this.browserStackConfig)

Expand Down
7 changes: 7 additions & 0 deletions packages/browserstack-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,13 @@ export default class BrowserstackService implements Services.ServiceInstance {
}

_isAppAutomate(): boolean {
// `skipAppOverride: true` is an App Automate run where the app cap may be supplied by the
// user via the `appium:app` driver capability rather than an SDK-injected one. This worker-local
// check has no access to BrowserStackConfig, so honor the option directly — otherwise the
// session mis-routes to the Automate endpoint and a11y/insights misclassify.
if (isTrue(this._options.skipAppOverride)) {
return true
}
const browserDesiredCapabilities = (this._browser?.capabilities ?? {})
const desiredCapabilities = (this._caps ?? {}) as WebdriverIO.Capabilities
return (
Expand Down
11 changes: 11 additions & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ export interface BrowserstackConfig {
* @default undefined
*/
app?: string | AppConfig;
/**
* Treat this session as App Automate without the SDK managing the app.
* When `true`, the SDK classifies the run as App Automate, does NOT upload
* an app, and does NOT inject an `appium:app` capability — you supply the
* app reference yourself via the `appium:app` driver capability.
* When explicitly `false` and no `app` is provided, the SDK throws a
* config error before the run starts. Leaving it unset preserves existing
* behaviour.
* @default undefined
*/
skipAppOverride?: boolean;
/**
* Enable routing connections from BrowserStack cloud through your computer.
* You will also need to set `browserstack.local` to true in browser capabilities.
Expand Down
47 changes: 47 additions & 0 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,53 @@ export function isFalse(value?: unknown) {
return (value + '').toLowerCase() === 'false'
}

/**
* skipAppOverride validation chokepoint — ported from the Node SDK's
* validateSkipAppOverride (helper.js) / Java BrowserStackJavaAgent.processAppOptions.
* Called ONCE from the launcher (main process, pre-session). Emits the fixed warning,
* handles the three edge cases, and clears the app on conflict so it is neither uploaded
* nor injected. There is NO Tier-2 branch: every WebdriverIO test framework
* (mocha/jasmine/cucumber) supports App Automate.
*
* The standard warning and the edge-2 error are verbatim with the other BrowserStack SDKs
* (BrowserStackJavaAgent#processAppOptions). The edge-1 conflict warning is INTENTIONALLY adapted for
* WebdriverIO — it says "browserstack service options" (where WDIO reads `app`), not "browserstack.yml" —
* so please do NOT "fix" it back to the yml wording to match the other SDKs.
* Graceful: never throws except the deliberate edge-2 config error (explicit false + no app),
* which fires pre-session so the run aborts cleanly.
*
* Mutates `options.app` (edge-1). 3-state via isTrue/isFalse — never `!options.skipAppOverride`.
*/
export function validateSkipAppOverride(options?: BrowserstackConfig & BrowserstackOptions) {
if (isUndefined(options)) {
return
}

const skipAppOverride = (options as BrowserstackConfig).skipAppOverride
const alreadyWarned = isTrue(process.env.BROWSERSTACK_SKIP_APP_OVERRIDE_WARNED)

// Standard warning — fires whenever skipAppOverride is true, before any edge return.
if (isTrue(skipAppOverride) && !alreadyWarned) {
BStackLogger.warn('[BrowserStack] \'skipAppOverride: true\' is set. The SDK will treat this session as App Automate and will NOT manage app upload or inject app capabilities. If you intended to run an Automate (browser/website) session, remove \'skipAppOverride\' — leaving it set will cause Automate sessions to behave incorrectly.')
process.env.BROWSERSTACK_SKIP_APP_OVERRIDE_WARNED = 'true'
}

// Edge 1: app specified + skipAppOverride:true → warn, clear the app so it is not
// uploaded/injected, proceed App Automate.
if (isTrue(skipAppOverride) && !isUndefined((options as BrowserstackConfig).app)) {
BStackLogger.warn('Conflict detected. skipAppOverride: true is active; ignoring the app provided in the browserstack service options.')
;(options as BrowserstackConfig).app = undefined
return
}

// Edge 2: app not specified + skipAppOverride explicitly false → pre-session config error.
if (isFalse(skipAppOverride) && isUndefined((options as BrowserstackConfig).app)) {
throw new Error('App capability is missing. When skipAppOverride is set to \'false\', a valid app capability (hash/shareable id/path/custom_id) must be provided.')
}

// Edge 3 (and default): unset + no app → unchanged behavior (no-op).
}

export function frameworkSupportsHook(hook: string, framework?: string) {
if (framework === 'mocha' && (hook === 'before' || hook === 'after' || hook === 'beforeEach' || hook === 'afterEach')) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ vi.mock('../../../src/cli/cliLogger.js', () => ({
}))

vi.mock('../../../src/util.js', () => ({
isBrowserstackSession: vi.fn(() => true)
isBrowserstackSession: vi.fn(() => true),
isTrue: vi.fn((value) => (value + '').toLowerCase() === 'true')
}))

vi.mock('../../../src/instrumentation/performance/performance-tester.js', () => ({
Expand Down Expand Up @@ -401,6 +402,38 @@ describe('AutomateModule', () => {
)
})

it('routes markSessionName to the App Automate endpoint when skipAppOverride is true and no app is set', async () => {
(automateModule.config as any).app = undefined
;(automateModule.config as any).skipAppOverride = true

vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ success: true })
} as any)

await automateModule.markSessionName('test-session-id', 'test-session-name', { user: 'testuser', key: 'testkey' })

expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('app-automate/sessions'),
expect.any(Object)
)
})

it('routes markSessionStatus to the App Automate endpoint when skipAppOverride is true and no app is set', async () => {
(automateModule.config as any).app = undefined
;(automateModule.config as any).skipAppOverride = true

vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ success: true })
} as any)

await automateModule.markSessionStatus('test-session-id', 'passed', undefined, { user: 'testuser', key: 'testkey' })

expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('app-automate/sessions'),
expect.objectContaining({ method: 'PUT' })
)
})

it('should handle onBeforeTest with skipSessionName enabled', async () => {
const configWithSkip = {
...mockConfig,
Expand Down
23 changes: 23 additions & 0 deletions packages/browserstack-service/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,27 @@ describe('BrowserStackConfig appAutomate detection', () => {
} as any)
expect(cfg.appAutomate).toBe(true)
})

it('marks app_automate when skipAppOverride is true even with no app and no app caps', () => {
const cfg = new BrowserStackConfig({ skipAppOverride: true } as any, baseConfig, [
{ platformName: 'android', 'appium:deviceName': 'Samsung Galaxy S23 Ultra' },
] as any)
expect(cfg.appAutomate).toBe(true)
expect(cfg.automate).toBe(false)
})

it('marks app_automate when skipAppOverride is the string "true" (3-state coercion)', () => {
const cfg = new BrowserStackConfig({ skipAppOverride: 'true' } as any, baseConfig, [
{ platformName: 'android' },
] as any)
expect(cfg.appAutomate).toBe(true)
})

it('does NOT force app_automate when skipAppOverride is false and caps are web-only', () => {
const cfg = new BrowserStackConfig({ skipAppOverride: false } as any, baseConfig, [
{ browserName: 'chrome' },
] as any)
expect(cfg.appAutomate).toBe(false)
expect(cfg.automate).toBe(true)
})
})
14 changes: 14 additions & 0 deletions packages/browserstack-service/tests/insights-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ it('should initialize correctly', () => {
expect(insightsHandler['_framework']).toEqual('framework')
})

describe('_isAppAutomate skipAppOverride', () => {
it('classifies App Automate (product=app-automate) when skipAppOverride is set with no app cap', () => {
const handler = new InsightsHandler(browser, 'framework', undefined, { skipAppOverride: true } as any)
expect(handler._isAppAutomate()).toBe(true)
expect(handler['_platformMeta']?.product).toBe('app-automate')
})

it('classifies Automate when skipAppOverride is unset and no app cap is present', () => {
const handler = new InsightsHandler(browser, 'framework', undefined, {} as any)
expect(handler._isAppAutomate()).toBe(false)
expect(handler['_platformMeta']?.product).toBe('automate')
})
})

describe('before', () => {
const isBrowserstackSessionSpy = vi.spyOn(utils, 'isBrowserstackSession')

Expand Down
12 changes: 12 additions & 0 deletions packages/browserstack-service/tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2380,3 +2380,15 @@ describe('beforeHook (CLI hook reporting)', () => {
expect(service['_insightsHandler']!.beforeHook).toHaveBeenCalledTimes(1)
})
})

describe('_isAppAutomate honors skipAppOverride', () => {
it('returns true when skipAppOverride is set even with no appium:app cap', () => {
const svc = new BrowserstackService({ skipAppOverride: true } as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} } as any)
expect(svc._isAppAutomate()).toBe(true)
})

it('returns false for a web session with no app and no skipAppOverride', () => {
const svc = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} } as any)
expect(svc._isAppAutomate()).toBe(false)
})
})
Loading
Loading