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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import got from 'got'
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 @@ -199,7 +199,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
@@ -1,7 +1,7 @@
import type { AppConfig, BrowserstackConfig } from './types.js'
import type { Options } from '@wdio/types'
import TestOpsConfig from './testOps/testOpsConfig.js'
import { isUndefined } from './util.js'
import { isTrue, isUndefined } from './util.js'
import { v4 as uuidv4 } from 'uuid'
import { BStackLogger } from './bstackLogger.js'

Expand Down Expand Up @@ -40,7 +40,10 @@ class BrowserStackConfig {
this.percy = options.percy || false
this.accessibility = options.accessibility !== undefined ? options.accessibility : null
this.app = options.app
this.appAutomate = !isUndefined(options.app)
// `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)
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 @@ -47,7 +47,7 @@ export const BSTACK_SERVICE_VERSION = bstackServiceVersion
export const UPDATED_CLI_ENDPOINT = 'sdk/v1/update_cli'
export const CLI_STOP_TIMEOUT = 3000

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

export const LOGS_FILE = 'logs/bstack-wdio-service.log'
export const CLI_DEBUG_LOGS_FILE = 'log/sdk-cli-debug.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 @@ -22,7 +22,8 @@ import {
isUndefined,
o11yClassErrorHandler,
removeAnsiColors,
getObservabilityProduct
getObservabilityProduct,
isTrue
} from './util.js'
import type {
TestData,
Expand Down Expand Up @@ -82,6 +83,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 ?? {}) as Capabilities.DesiredCapabilities
const desiredCapabilities = (this._userCaps ?? {}) as Capabilities.DesiredCapabilities
return !!browserDesiredCapabilities['appium:app'] || !!desiredCapabilities['appium:app'] || !!(( desiredCapabilities as any)['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 @@ -45,7 +45,8 @@ import {
normalizeTestReportingConfig,
normalizeTestReportingEnvVariables,
isValidEnabledValue,
isMultiRemoteCaps
isMultiRemoteCaps,
validateSkipAppOverride
} from './util.js'
import { getProductMap } from './testHub/utils.js'
import CrashReporter from './crash-reporter.js'
Expand Down Expand Up @@ -219,6 +220,18 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
async onPrepare (config: Options.Testrunner, capabilities: Capabilities.RemoteCapabilities) {
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 @@ -695,6 +695,13 @@ export default class BrowserstackService implements Services.ServiceInstance {
await this._printSessionURL()
}
_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 ?? {}) as Capabilities.DesiredCapabilities
const desiredCapabilities = (this._caps ?? {}) as Capabilities.DesiredCapabilities
return !!browserDesiredCapabilities['appium:app'] || !!desiredCapabilities['appium:app'] || !!(( desiredCapabilities as any)['appium:options']?.app)
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 @@ -1365,6 +1365,53 @@ export function isFalse(value?: any) {
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) {
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
@@ -1,4 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import got from 'got'
import AutomateModule from '../../../src/cli/modules/automateModule.js'
import type { Options } from '@wdio/types'

Expand Down Expand Up @@ -31,7 +32,8 @@ vi.mock('got', () => ({
}))

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

describe('AutomateModule', () => {
Expand Down Expand Up @@ -124,6 +126,30 @@ describe('AutomateModule', () => {
await expect(automateModule.markSessionName(sessionId, sessionName, config)).resolves.toBeUndefined()
})

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(got).mockResolvedValue({ body: {} } as any)

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

expect(got).toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('app-automate/sessions') })
)
})

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(got).mockResolvedValue({ body: {} } as any)

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

expect(got).toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('app-automate/sessions') })
)
})

it('should handle onBeforeTest with skipSessionName enabled', async () => {
const configWithSkip = {
...mockConfig,
Expand Down
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 @@ -73,6 +73,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 @@ -1905,3 +1905,15 @@ describe('ignoreHooksStatus feature', () => {
})
})
})

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