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/pr-78.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": minor
---

- Added support for attaching custom test metadata to test runs via `BrowserStackSDK.setTestMetadata()` for central-user integrations.
15 changes: 15 additions & 0 deletions packages/browserstack-service/src/browserStackSdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import TestMetadata from './metadata.js'

export class BrowserStackSDK {
/**
* Attach metadata to the current test run.
*
* @param metadata - Metadata object. Must include an `identifier` string
* (max 40 characters); calls without a valid identifier are ignored.
*/
static setTestMetadata(metadata: Record<string, any> = {}) {
TestMetadata.set(metadata)
}
}

export default BrowserStackSDK
50 changes: 39 additions & 11 deletions packages/browserstack-service/src/cli/apiUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { BStackLogger } from './cliLogger.js'

export default class APIUtils {
static FUNNEL_INSTRUMENTATION_URL = 'https://api.browserstack.com/sdk/v1/event'
static BROWSERSTACK_AUTOMATE_API_URL = 'https://api.browserstack.com'
Expand All @@ -10,16 +12,42 @@ export default class APIUtils {
static UPLOAD_LOGS_ADDRESS = 'https://upload-observability.browserstack.com'
static EDS_URL = 'https://eds.browserstack.com'

static updateURLSForGRR(apis: GRRUrls) {
this.FUNNEL_INSTRUMENTATION_URL = `${apis.automate.api}/sdk/v1/event`
this.BROWSERSTACK_AUTOMATE_API_URL = apis.automate.api
this.BROWSERSTACK_AA_API_URL = apis.appAutomate.api
this.BROWSERSTACK_PERCY_API_URL = apis.percy.api
this.BROWSERSTACK_AUTOMATE_API_CLOUD_URL = apis.automate.upload
this.BROWSERSTACK_AA_API_CLOUD_URL = apis.appAutomate.upload
this.APP_ALLY_ENDPOINT = `${apis.appAccessibility.api}/automate`
this.DATA_ENDPOINT = apis.observability.api
this.UPLOAD_LOGS_ADDRESS = apis.observability.upload
this.EDS_URL = apis.edsInstrumentation.api
static missingGRRUrlKeys(apis?: Partial<GRRUrls>): string[] {
const checks: Array<[string, unknown]> = [
['automate.api', apis?.automate?.api],
['automate.upload', apis?.automate?.upload],
['appAutomate.api', apis?.appAutomate?.api],
['appAutomate.upload', apis?.appAutomate?.upload],
['percy.api', apis?.percy?.api],
['appAccessibility.api', apis?.appAccessibility?.api],
['observability.api', apis?.observability?.api],
['observability.upload', apis?.observability?.upload],
['edsInstrumentation.api', apis?.edsInstrumentation?.api]
]
return checks.filter(([, value]) => !value).map(([key]) => key)
}

static updateURLSForGRR(apis?: Partial<GRRUrls>) {
const missing = APIUtils.missingGRRUrlKeys(apis)
if (missing.length > 0) {
BStackLogger.warn(`updateURLSForGRR: GRR URLs incomplete — keeping default endpoints. Missing: ${missing.join(', ')}`)
return false
}

// Validated above: every field read below is present. Cast is scoped to
// this method so no unsound `apis is GRRUrls` predicate leaks to callers.
const grrUrls = apis as GRRUrls
this.FUNNEL_INSTRUMENTATION_URL = `${grrUrls.automate.api}/sdk/v1/event`
this.BROWSERSTACK_AUTOMATE_API_URL = grrUrls.automate.api
this.BROWSERSTACK_AA_API_URL = grrUrls.appAutomate.api
this.BROWSERSTACK_PERCY_API_URL = grrUrls.percy.api
this.BROWSERSTACK_AUTOMATE_API_CLOUD_URL = grrUrls.automate.upload
this.BROWSERSTACK_AA_API_CLOUD_URL = grrUrls.appAutomate.upload
this.APP_ALLY_ENDPOINT = `${grrUrls.appAccessibility.api}/automate`
this.DATA_ENDPOINT = grrUrls.observability.api
this.UPLOAD_LOGS_ADDRESS = grrUrls.observability.upload
this.EDS_URL = grrUrls.edsInstrumentation.api

return true
}
}
23 changes: 22 additions & 1 deletion packages/browserstack-service/src/cli/modules/testHubModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import WdioMochaTestFramework from '../frameworks/wdioMochaTestFramework.js'
import type AutomationFrameworkInstance from '../instances/automationFrameworkInstance.js'
import AutomationFramework from '../frameworks/automationFramework.js'
import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js'
import TestMetadata from '../../metadata.js'

/**
* TestHub Module for BrowserStack
Expand Down Expand Up @@ -51,8 +52,16 @@ export default class TestHubModule extends BaseModule {
return TestHubModule.MODULE_NAME
}

private getCurrentTestRunUuid(instance: TestFrameworkInstance): string | undefined {
return (TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) as string | undefined) || instance.getRef()
}

onBeforeTest(args: Record<string, unknown>) {
this.logger.debug('onBeforeTest: Called after test hook from cli configured module!!!')
const instance = args.instance as TestFrameworkInstance
const testUuid = this.getCurrentTestRunUuid(instance)
TestMetadata.setCurrentTestRunUuid(testUuid)

const autoInstace = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance
const instances = [autoInstace]
args.autoInstance = instances
Expand Down Expand Up @@ -95,6 +104,11 @@ export default class TestHubModule extends BaseModule {
if (testState === TestFrameworkState.TEST || CLIUtils.matchHookRegex(testState.toString().split('.')[1])) {
this.sendTestFrameworkEvent(args)
}

if (testState === TestFrameworkState.TEST && hookState === HookState.POST &&
TestFramework.hasState(instance, TestFrameworkConstants.KEY_TEST_RESULT_AT)) {
TestMetadata.reset()
}
}

async sendTestFrameworkEvent(args: Record<string, unknown>) {
Expand All @@ -113,10 +127,17 @@ export default class TestHubModule extends BaseModule {
this.logger.debug(`sendTestFrameworkEvent for testState: ${testFrameworkState} hookState: ${testHookState}`)
const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0
const uuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef()
const testDataObj = Object.fromEntries(testData)
if (!testDataObj.app_lcnc) {
const appLcncMeta = TestMetadata.get(uuid as string)
if (appLcncMeta && Object.keys(appLcncMeta).length > 0) {
testDataObj.app_lcnc = appLcncMeta
}
}
// Nested values such as test_hooks_started/test_hooks_finished are JS Maps, which
// JSON.stringify would serialise to `{}` and strip the hook data. Convert any Map to
// a plain object so the binary receives populated hook maps.
const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData), (_key, value) => value instanceof Map ? Object.fromEntries(value) : value))
const eventJson = Buffer.from(JSON.stringify(testDataObj, (_key, value) => value instanceof Map ? Object.fromEntries(value) : value))
const executionContext = { hash: trackedContext.getId(), threadId: trackedContext.getThreadId().toString(), processId: trackedContext.getProcessId().toString() }
const payload: Omit<TestFrameworkEventRequest, 'binSessionId'> = {
platformIndex,
Expand Down
4 changes: 4 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export const BROWSERSTACK_TESTHUB_UUID = 'BROWSERSTACK_TESTHUB_UUID'
// To store test run uuid
export const TEST_ANALYTICS_ID = 'TEST_ANALYTICS_ID'

// Central user mode for BrowserStack integrations.
export const BROWSERSTACK_CENTRAL_USER = 'BROWSERSTACK_CENTRAL_USER'
export const BROWSERSTACK_BUILD_GROUPING_IDENTIFIER = 'BROWSERSTACK_BUILD_GROUPING_IDENTIFIER'

// Whether to collect performance instrumentation or not
export const PERF_MEASUREMENT_ENV = 'BROWSERSTACK_O11Y_PERF_MEASUREMENT'

Expand Down
1 change: 1 addition & 0 deletions packages/browserstack-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const log4jsAppender = { configure }
export const BStackTestOpsLogger = logReportingAPI

import * as Percy from './Percy/PercySDK.js'
export { BrowserStackSDK } from './browserStackSdk.js'
export const PercySDK = Percy

export * from './types.js'
Expand Down
55 changes: 55 additions & 0 deletions packages/browserstack-service/src/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { BStackLogger } from './bstackLogger.js'
import { getCentralUser } from './util.js'

type Metadata = Record<string, any>

class TestMetadata {
private static currentTestRunUuid?: string
private static metadataByTestRunUuid: Record<string, Metadata> = {}
private static fallbackMetadata: Metadata = {}

static setCurrentTestRunUuid(testRunUuid?: string) {
TestMetadata.currentTestRunUuid = testRunUuid
}

static set(metadata: Metadata = {}) {
if (!getCentralUser().app_lcnc) {
return
}

const testRunIdentifier = metadata.identifier
if (typeof testRunIdentifier !== 'string' || testRunIdentifier.length === 0) {
BStackLogger.warn('setTestMetadata: metadata.identifier must be a non-empty string.')
return
}
if (testRunIdentifier.length > 40) {
BStackLogger.warn(`setTestMetadata: identifier "${testRunIdentifier}" exceeds the 40-character limit.`)
return
}
TestMetadata.fallbackMetadata = metadata

if (TestMetadata.currentTestRunUuid) {
TestMetadata.metadataByTestRunUuid[TestMetadata.currentTestRunUuid] = metadata
}
}

static get(testRunUuid?: string): Metadata {
if (!getCentralUser().app_lcnc) {
return {}
}

if (testRunUuid) {
return TestMetadata.metadataByTestRunUuid[testRunUuid] || TestMetadata.fallbackMetadata || {}
}

return TestMetadata.fallbackMetadata || {}
}

static reset() {
TestMetadata.currentTestRunUuid = undefined
TestMetadata.metadataByTestRunUuid = {}
TestMetadata.fallbackMetadata = {}
}
}

export default TestMetadata
11 changes: 11 additions & 0 deletions packages/browserstack-service/src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { BStackLogger } from './bstackLogger.js'
import type { Capabilities } from '@wdio/types'
import Listener from './testOps/listener.js'
import TestMetadata from './metadata.js'

class _TestReporter extends WDIOReporter {
private _capabilities: WebdriverIO.Capabilities = {}
Expand Down Expand Up @@ -316,6 +317,16 @@ class _TestReporter extends WDIOReporter {
testData.hook_type = testData.name?.toLowerCase() ? getHookType(testData.name.toLowerCase()) : 'undefined'
}

// For TestRunSkipped (mocha this.skip()), mocha sets testStats.state = 'pending'.
// Force result to 'skipped' so the BTCER event downstream gets the correct status.
if (eventType === 'TestRunSkipped') {
testData.result = 'skipped'
const appLcncMetaData = TestMetadata.get(testData.uuid)
if (Object.keys(appLcncMetaData).length > 0) {
testData.app_lcnc = appLcncMetaData
}
}

return testData
}
}
Expand Down
8 changes: 5 additions & 3 deletions packages/browserstack-service/src/testHub/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
import { BROWSERSTACK_PERCY, BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_ACCESSIBILITY } from '../constants.js'
import type BrowserStackConfig from '../config.js'
import { BStackLogger } from '../bstackLogger.js'
import { isTrue } from '../util.js'
import { getCentralUser, isTrue } from '../util.js'

export const getProductMap = (config: BrowserStackConfig): any => {
return {
'observability': config.testObservability.enabled,
'accessibility': config.accessibility as boolean,
'percy': config.percy,
'automate': config.automate,
'app_automate': config.appAutomate
'app_automate': config.appAutomate,
...getCentralUser()
}
}

Expand Down Expand Up @@ -70,6 +71,7 @@ export const getProductMapForBuildStartCall = (config: BrowserStackConfig, acces
accessibility: accessibilityAutomation,
percy: config.percy,
automate: config.automate,
app_automate: config.appAutomate
app_automate: config.appAutomate,
...getCentralUser()
}
}
1 change: 1 addition & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export interface TestData {
hook_type?: string,
hooks?: string[],
meta?: TestMeta,
app_lcnc?: Record<string, any>,
tags?: string[],
test_run_id?: string,
product_map?: {}
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 @@ -33,6 +33,8 @@ import {
BSTACK_A11Y_POLLING_TIMEOUT,
TESTOPS_SCREENSHOT_ENV,
BROWSERSTACK_TESTHUB_UUID,
BROWSERSTACK_CENTRAL_USER,
BROWSERSTACK_BUILD_GROUPING_IDENTIFIER,
PERF_MEASUREMENT_ENV,
RERUN_ENV,
TESTOPS_BUILD_COMPLETED_ENV,
Expand Down Expand Up @@ -370,6 +372,19 @@ export const processLaunchBuildResponse = (response: LaunchResponse, options: Br
processAccessibilityResponse(response, options)
}

export type CentralUser = {
app_lcnc: boolean;
}

export const getCentralUser = (): Partial<CentralUser> => {
switch (process.env[BROWSERSTACK_CENTRAL_USER]) {
case 'app_lcnc':
return { app_lcnc: true }
default:
return {}
}
}

export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.TESTHUB_EVENTS.START, o11yErrorHandler(async function launchTestSession(options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, bsConfig: UserConfig, bStackConfig: BrowserStackConfig, accessibilityAutomation: boolean | null) {
const launchBuildUsage = UsageStats.getInstance().launchBuildUsage
launchBuildUsage.triggered()
Expand All @@ -396,6 +411,9 @@ export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SD
settings: options.accessibilityOptions
},
browserstackAutomation: shouldAddServiceVersion(config, options.testObservability),
...(process.env[BROWSERSTACK_BUILD_GROUPING_IDENTIFIER]
? { grouping_identifier: process.env[BROWSERSTACK_BUILD_GROUPING_IDENTIFIER] }
: {}),
framework_details: {
frameworkName: WDIO_NAMING_PREFIX + config.framework,
frameworkVersion: bsConfig.bstackServiceVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ describe('TestHubModule', () => {
const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue()

const mockArgs = {
instance: { getRef: vi.fn(() => 'test-run-ref') },
test: { title: 'Test Login Functionality' } as Frameworks.Test,
suiteTitle: 'Login Suite'
}
Expand Down
Loading