diff --git a/.changeset/feat-skip-app-override.md b/.changeset/feat-skip-app-override.md new file mode 100644 index 0000000..f38f7d5 --- /dev/null +++ b/.changeset/feat-skip-app-override.md @@ -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. diff --git a/packages/browserstack-service/src/cli/modules/automateModule.ts b/packages/browserstack-service/src/cli/modules/automateModule.ts index 0ae124f..1a0b6d9 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 } 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' @@ -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 { @@ -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 { diff --git a/packages/browserstack-service/src/config.ts b/packages/browserstack-service/src/config.ts index 394ad0d..c406770 100644 --- a/packages/browserstack-service/src/config.ts +++ b/packages/browserstack-service/src/config.ts @@ -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 @@ -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() diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 759b322..ca9d73f 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -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' diff --git a/packages/browserstack-service/src/insights-handler.ts b/packages/browserstack-service/src/insights-handler.ts index 8139506..b18279a 100644 --- a/packages/browserstack-service/src/insights-handler.ts +++ b/packages/browserstack-service/src/insights-handler.ts @@ -26,7 +26,8 @@ import { o11yClassErrorHandler, removeAnsiColors, getObservabilityProduct, - generateHashCodeFromFields + generateHashCodeFromFields, + isTrue } from './util.js' import type { TestData, @@ -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) diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index a1c2380..95a8377 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -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' @@ -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) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index c5f21d1..bed50eb 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -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 ( diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index a41d085..16f77b6 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -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. diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 136eebd..910dc59 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -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 diff --git a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts index 9d9aa4b..c423553 100644 --- a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts @@ -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', () => ({ @@ -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, diff --git a/packages/browserstack-service/tests/config.test.ts b/packages/browserstack-service/tests/config.test.ts index c245657..13c1eb8 100644 --- a/packages/browserstack-service/tests/config.test.ts +++ b/packages/browserstack-service/tests/config.test.ts @@ -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) + }) }) diff --git a/packages/browserstack-service/tests/insights-handler.test.ts b/packages/browserstack-service/tests/insights-handler.test.ts index ae97e21..521c9ea 100644 --- a/packages/browserstack-service/tests/insights-handler.test.ts +++ b/packages/browserstack-service/tests/insights-handler.test.ts @@ -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') diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 7cfd42e..6ea13a7 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -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) + }) +}) diff --git a/packages/browserstack-service/tests/skipAppOverride.test.ts b/packages/browserstack-service/tests/skipAppOverride.test.ts new file mode 100644 index 0000000..1fbe185 --- /dev/null +++ b/packages/browserstack-service/tests/skipAppOverride.test.ts @@ -0,0 +1,91 @@ +import path from 'node:path' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +import * as bstackLogger from '../src/bstackLogger.js' +import { validateSkipAppOverride } from '../src/util.js' +import { NOT_ALLOWED_KEYS_IN_CAPS } from '../src/constants.js' + +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) + +// Isolate the warning surface — BStackLogger.warn otherwise writes to the log file + wdio logger. +const warnSpy = vi.spyOn(bstackLogger.BStackLogger, 'warn').mockImplementation(() => {}) +vi.spyOn(bstackLogger.BStackLogger, 'logToFile').mockImplementation(() => {}) + +const STANDARD_WARNING = '[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.' +const CONFLICT_WARNING = 'Conflict detected. skipAppOverride: true is active; ignoring the app provided in the browserstack service options.' +const MISSING_APP_ERROR = 'App capability is missing. When skipAppOverride is set to \'false\', a valid app capability (hash/shareable id/path/custom_id) must be provided.' + +describe('validateSkipAppOverride', () => { + beforeEach(() => { + warnSpy.mockClear() + delete process.env.BROWSERSTACK_SKIP_APP_OVERRIDE_WARNED + }) + afterEach(() => { + delete process.env.BROWSERSTACK_SKIP_APP_OVERRIDE_WARNED + }) + + it('is a no-op when options is undefined', () => { + expect(() => validateSkipAppOverride(undefined)).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('emits the standard warning verbatim once when skipAppOverride:true with no app', () => { + const options = { skipAppOverride: true } as any + validateSkipAppOverride(options) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith(STANDARD_WARNING) + expect(options.app).toBeUndefined() + }) + + it('coerces the string "true" (3-state semantics)', () => { + validateSkipAppOverride({ skipAppOverride: 'true' } as any) + expect(warnSpy).toHaveBeenCalledWith(STANDARD_WARNING) + }) + + it('edge-1: skipAppOverride:true + app => conflict warning verbatim and app is cleared', () => { + const options = { skipAppOverride: true, app: 'bs://abc' } as any + validateSkipAppOverride(options) + expect(warnSpy).toHaveBeenCalledWith(STANDARD_WARNING) + expect(warnSpy).toHaveBeenCalledWith(CONFLICT_WARNING) + expect(options.app).toBeUndefined() + }) + + it('edge-2: explicit false + no app => throws the exact missing-app error', () => { + expect(() => validateSkipAppOverride({ skipAppOverride: false } as any)).toThrow(MISSING_APP_ERROR) + }) + + it('edge-2: string "false" + no app => throws', () => { + expect(() => validateSkipAppOverride({ skipAppOverride: 'false' } as any)).toThrow(MISSING_APP_ERROR) + }) + + it('explicit false WITH an app => no throw, no warning', () => { + expect(() => validateSkipAppOverride({ skipAppOverride: false, app: 'bs://abc' } as any)).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('edge-3: unset + no app => no-op, no warning, no throw', () => { + const options = {} as any + expect(() => validateSkipAppOverride(options)).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('emit-once: a second call in the same process does not re-warn', () => { + validateSkipAppOverride({ skipAppOverride: true } as any) + expect(warnSpy).toHaveBeenCalledTimes(1) + warnSpy.mockClear() + validateSkipAppOverride({ skipAppOverride: true } as any) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('never emits a Tier-2 "does not support App Automate" warning (WDIO supports App Automate)', () => { + validateSkipAppOverride({ skipAppOverride: true } as any) + const messages = warnSpy.mock.calls.map((c) => String(c[0])) + expect(messages.some((m) => m.includes('does not support App Automate'))).toBe(false) + }) +}) + +describe('NOT_ALLOWED_KEYS_IN_CAPS cloud-leak strip', () => { + it('includes skipAppOverride so it is never forwarded to bstack:options', () => { + expect(NOT_ALLOWED_KEYS_IN_CAPS).toContain('skipAppOverride') + }) +})