From 3befc20a8fb0661d268a15b01cf06f619d141bdc Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Thu, 23 Jul 2026 12:53:49 +0530 Subject: [PATCH 1/4] feat(LCAM-1282): central-user test metadata, GRR URL guard, build grouping identifier Port of ashish0305/webdriveriolc#1 ("support of setting metadata") onto the v8 line of the extracted standalone repo (packages/browserstack-service/src). - Add BrowserStackSDK.setTestMetadata() + TestMetadata store, gated on the app_lcnc central user (BROWSERSTACK_CENTRAL_USER). Metadata is keyed per test-run uuid with a fallback and attached to the TestFramework event and the reporter's BTCER payload. - Emit central-user keys in the TestHub product maps. - APIUtils.updateURLSForGRR: validate the GRR URL set via a hasValidGRRUrls type guard and no-op (return false) when incomplete, preventing the TypeError -> prod-collector fallback. - Send grouping_identifier (BROWSERSTACK_BUILD_GROUPING_IDENTIFIER) on build launch. - Force result='skipped' for TestRunSkipped so downstream status is correct. Existing specs updated for the new app_lcnc product-map key and the onBeforeTest instance arg. Build + full test suite pass locally on v8. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/browserStackSdk.ts | 15 +++++ .../browserstack-service/src/cli/apiUtils.ts | 22 +++++++- .../src/cli/modules/testHubModule.ts | 28 +++++++++- .../browserstack-service/src/constants.ts | 4 ++ packages/browserstack-service/src/index.ts | 1 + packages/browserstack-service/src/metadata.ts | 55 +++++++++++++++++++ packages/browserstack-service/src/reporter.ts | 11 ++++ .../browserstack-service/src/testHub/utils.ts | 8 ++- packages/browserstack-service/src/types.ts | 1 + packages/browserstack-service/src/util.ts | 16 ++++++ .../tests/cli/modules/testHubModule.test.ts | 1 + .../funnelInstrumentation.test.ts | 21 ++++--- .../tests/testHub/utils.test.ts | 3 +- 13 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 packages/browserstack-service/src/browserStackSdk.ts create mode 100644 packages/browserstack-service/src/metadata.ts diff --git a/packages/browserstack-service/src/browserStackSdk.ts b/packages/browserstack-service/src/browserStackSdk.ts new file mode 100644 index 0000000..d3b4f94 --- /dev/null +++ b/packages/browserstack-service/src/browserStackSdk.ts @@ -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 = {}) { + TestMetadata.set(metadata) + } +} + +export default BrowserStackSDK diff --git a/packages/browserstack-service/src/cli/apiUtils.ts b/packages/browserstack-service/src/cli/apiUtils.ts index 9eabcae..eb6b780 100644 --- a/packages/browserstack-service/src/cli/apiUtils.ts +++ b/packages/browserstack-service/src/cli/apiUtils.ts @@ -10,7 +10,25 @@ export default class APIUtils { static UPLOAD_LOGS_ADDRESS = 'https://upload-observability.browserstack.com' static EDS_URL = 'https://eds.browserstack.com' - static updateURLSForGRR(apis: GRRUrls) { + static hasValidGRRUrls(apis?: Partial): apis is GRRUrls { + return Boolean( + apis?.automate?.api && + apis?.automate?.upload && + apis?.appAutomate?.api && + apis?.appAutomate?.upload && + apis?.percy?.api && + apis?.appAccessibility?.api && + apis?.observability?.api && + apis?.observability?.upload && + apis?.edsInstrumentation?.api + ) + } + + static updateURLSForGRR(apis?: Partial) { + if (!this.hasValidGRRUrls(apis)) { + return false + } + 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 @@ -21,5 +39,7 @@ export default class APIUtils { this.DATA_ENDPOINT = apis.observability.api this.UPLOAD_LOGS_ADDRESS = apis.observability.upload this.EDS_URL = apis.edsInstrumentation.api + + return true } } diff --git a/packages/browserstack-service/src/cli/modules/testHubModule.ts b/packages/browserstack-service/src/cli/modules/testHubModule.ts index cb1f13c..930f1a9 100644 --- a/packages/browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/browserstack-service/src/cli/modules/testHubModule.ts @@ -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 @@ -34,13 +35,15 @@ export default class TestHubModule extends BaseModule { this.name = 'TestHubModule' this.testhubConfig = testhubConfig - TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this)) - Object.values(TestFrameworkState).forEach(state => { Object.values(HookState).forEach(hook => { TestFramework.registerObserver(state, hook, this.onAllTestEvents.bind(this)) }) }) + // TEST/PRE: sendTestFrameworkEvent must run before onBeforeTest mutates + // TestMetadata.currentTestRunUuid, so sequence them explicitly instead of + // relying on observer registration order. + TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this)) } /** @@ -51,8 +54,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) { 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 @@ -95,6 +106,10 @@ 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) { + TestMetadata.reset() + } } async sendTestFrameworkEvent(args: Record) { @@ -113,7 +128,14 @@ 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 eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData))) + 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 + } + } + const eventJson = Buffer.from(JSON.stringify(testDataObj)) const executionContext = { hash: trackedContext.getId(), threadId: trackedContext.getThreadId().toString(), processId: trackedContext.getProcessId().toString() } const payload: Omit = { platformIndex, diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 48f53f3..c78b654 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -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' diff --git a/packages/browserstack-service/src/index.ts b/packages/browserstack-service/src/index.ts index 3affa66..6b97aab 100644 --- a/packages/browserstack-service/src/index.ts +++ b/packages/browserstack-service/src/index.ts @@ -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' diff --git a/packages/browserstack-service/src/metadata.ts b/packages/browserstack-service/src/metadata.ts new file mode 100644 index 0000000..fecd47c --- /dev/null +++ b/packages/browserstack-service/src/metadata.ts @@ -0,0 +1,55 @@ +import { BStackLogger } from './bstackLogger.js' +import { getCentralUser } from './util.js' + +type Metadata = Record + +class TestMetadata { + private static currentTestRunUuid?: string + private static metadataByTestRunUuid: Record = {} + 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') { + BStackLogger.warn('setTestMetadata: metadata.identifier must be a 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 diff --git a/packages/browserstack-service/src/reporter.ts b/packages/browserstack-service/src/reporter.ts index 736a1a6..1cdd44d 100644 --- a/packages/browserstack-service/src/reporter.ts +++ b/packages/browserstack-service/src/reporter.ts @@ -20,6 +20,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 = {} @@ -288,6 +289,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 } } diff --git a/packages/browserstack-service/src/testHub/utils.ts b/packages/browserstack-service/src/testHub/utils.ts index 215ddbe..8a8e9b1 100644 --- a/packages/browserstack-service/src/testHub/utils.ts +++ b/packages/browserstack-service/src/testHub/utils.ts @@ -2,7 +2,7 @@ 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 { @@ -10,7 +10,8 @@ export const getProductMap = (config: BrowserStackConfig): any => { 'accessibility': config.accessibility as boolean, 'percy': config.percy, 'automate': config.automate, - 'app_automate': config.appAutomate + 'app_automate': config.appAutomate, + ...getCentralUser() } } @@ -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() } } diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index a4ce3b2..e154ffa 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -284,6 +284,7 @@ export interface TestData { hook_type?: string, hooks?: string[], meta?: TestMeta, + app_lcnc?: Record, tags?: string[], test_run_id?: string, product_map?: {} diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 2e8ca78..1cea05c 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -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, @@ -370,6 +372,19 @@ export const processLaunchBuildResponse = (response: LaunchResponse, options: Br processAccessibilityResponse(response, options) } +export type CentralUser = { + app_lcnc: boolean; +} + +export const getCentralUser = (): Partial => { + switch (process.env[BROWSERSTACK_CENTRAL_USER]) { + case 'app_lcnc': + return { app_lcnc: true } + default: + return { app_lcnc: false } + } +} + 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() @@ -396,6 +411,7 @@ export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SD settings: options.accessibilityOptions }, browserstackAutomation: shouldAddServiceVersion(config, options.testObservability), + grouping_identifier: process.env[BROWSERSTACK_BUILD_GROUPING_IDENTIFIER] || '', framework_details: { frameworkName: WDIO_NAMING_PREFIX + config.framework, frameworkVersion: bsConfig.bstackServiceVersion, diff --git a/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts b/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts index 14660db..cb65fad 100644 --- a/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts @@ -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' } diff --git a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts index fe476a6..aa092e8 100644 --- a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts +++ b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts @@ -45,7 +45,8 @@ const expectedEventData = { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -106,7 +107,8 @@ describe('funnelInstrumentation', () => { 'accessibility': false, 'percy': false, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate']), productUsage: expect.objectContaining({ @@ -219,7 +221,8 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -256,7 +259,8 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -302,7 +306,8 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -344,7 +349,8 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -389,7 +395,8 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', diff --git a/packages/browserstack-service/tests/testHub/utils.test.ts b/packages/browserstack-service/tests/testHub/utils.test.ts index 4910e09..93a8971 100644 --- a/packages/browserstack-service/tests/testHub/utils.test.ts +++ b/packages/browserstack-service/tests/testHub/utils.test.ts @@ -27,7 +27,8 @@ describe('getProductMap', () => { 'accessibility': false, 'percy': false, 'automate': true, - 'app_automate': false + 'app_automate': false, + 'app_lcnc': false } expect(productMap).toEqual(expectedProductMap) }) From ce9fdb42195810f2007639ef68ab94c9d4264f75 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Thu, 23 Jul 2026 15:12:56 +0530 Subject: [PATCH 2/4] UT converage --- .../tests/metadata.test.ts | 144 ++++++++++++++++++ .../browserstack-service/tests/util.test.ts | 27 ++++ 2 files changed, 171 insertions(+) create mode 100644 packages/browserstack-service/tests/metadata.test.ts diff --git a/packages/browserstack-service/tests/metadata.test.ts b/packages/browserstack-service/tests/metadata.test.ts new file mode 100644 index 0000000..2664051 --- /dev/null +++ b/packages/browserstack-service/tests/metadata.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +import TestMetadata from '../src/metadata.js' +import BrowserStackSDK from '../src/browserStackSdk.js' +import * as bstackLogger from '../src/bstackLogger.js' +import { BROWSERSTACK_CENTRAL_USER } from '../src/constants.js' + +describe('TestMetadata', () => { + let warnSpy: any + const originalCentralUser = process.env[BROWSERSTACK_CENTRAL_USER] + + beforeEach(() => { + process.env[BROWSERSTACK_CENTRAL_USER] = 'app_lcnc' + TestMetadata.reset() + warnSpy = vi.spyOn(bstackLogger.BStackLogger, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + TestMetadata.reset() + warnSpy.mockRestore() + if (originalCentralUser === undefined) { + delete process.env[BROWSERSTACK_CENTRAL_USER] + } else { + process.env[BROWSERSTACK_CENTRAL_USER] = originalCentralUser + } + }) + + describe('central-user gating', () => { + it('set() is a no-op and get() returns {} when central user is not app_lcnc', () => { + delete process.env[BROWSERSTACK_CENTRAL_USER] + TestMetadata.set({ identifier: 'abc' }) + expect(TestMetadata.get()).toEqual({}) + }) + + it('get() returns {} when central user is not app_lcnc even if data was stored while enabled', () => { + TestMetadata.set({ identifier: 'abc' }) + delete process.env[BROWSERSTACK_CENTRAL_USER] + expect(TestMetadata.get()).toEqual({}) + }) + }) + + describe('set() validation', () => { + it('warns and ignores when identifier is not a string', () => { + TestMetadata.set({ identifier: 123 as any }) + expect(warnSpy).toHaveBeenCalled() + expect(TestMetadata.get()).toEqual({}) + }) + + it('warns and ignores when identifier is missing', () => { + TestMetadata.set({ foo: 'bar' }) + expect(warnSpy).toHaveBeenCalled() + expect(TestMetadata.get()).toEqual({}) + }) + + it('warns and ignores when identifier exceeds 40 characters', () => { + const tooLong = 'x'.repeat(41) + TestMetadata.set({ identifier: tooLong }) + expect(warnSpy).toHaveBeenCalled() + expect(TestMetadata.get()).toEqual({}) + }) + + it('accepts an identifier of exactly 40 characters', () => { + const exactly40 = 'x'.repeat(40) + TestMetadata.set({ identifier: exactly40 }) + expect(warnSpy).not.toHaveBeenCalled() + expect(TestMetadata.get()).toEqual({ identifier: exactly40 }) + }) + }) + + describe('fallback vs per-uuid storage', () => { + it('stores only as fallback when no current test-run uuid is set', () => { + TestMetadata.set({ identifier: 'run-1' }) + expect(TestMetadata.get()).toEqual({ identifier: 'run-1' }) + expect(TestMetadata.get('unknown-uuid')).toEqual({ identifier: 'run-1' }) + }) + + it('stores per-uuid when a current test-run uuid is set', () => { + TestMetadata.setCurrentTestRunUuid('uuid-1') + TestMetadata.set({ identifier: 'run-1' }) + expect(TestMetadata.get('uuid-1')).toEqual({ identifier: 'run-1' }) + }) + + it('falls back to the latest metadata for an unknown uuid', () => { + TestMetadata.setCurrentTestRunUuid('uuid-1') + TestMetadata.set({ identifier: 'run-1' }) + expect(TestMetadata.get('uuid-2')).toEqual({ identifier: 'run-1' }) + }) + + it('returns the correct metadata per uuid across multiple test runs', () => { + TestMetadata.setCurrentTestRunUuid('uuid-1') + TestMetadata.set({ identifier: 'run-1' }) + TestMetadata.setCurrentTestRunUuid('uuid-2') + TestMetadata.set({ identifier: 'run-2' }) + expect(TestMetadata.get('uuid-1')).toEqual({ identifier: 'run-1' }) + expect(TestMetadata.get('uuid-2')).toEqual({ identifier: 'run-2' }) + }) + }) + + describe('reset()', () => { + it('clears current uuid, per-uuid store and fallback', () => { + TestMetadata.setCurrentTestRunUuid('uuid-1') + TestMetadata.set({ identifier: 'run-1' }) + TestMetadata.reset() + expect(TestMetadata.get()).toEqual({}) + expect(TestMetadata.get('uuid-1')).toEqual({}) + }) + }) +}) + +describe('BrowserStackSDK.setTestMetadata', () => { + const originalCentralUser = process.env[BROWSERSTACK_CENTRAL_USER] + + beforeEach(() => { + process.env[BROWSERSTACK_CENTRAL_USER] = 'app_lcnc' + TestMetadata.reset() + vi.spyOn(bstackLogger.BStackLogger, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + TestMetadata.reset() + vi.restoreAllMocks() + if (originalCentralUser === undefined) { + delete process.env[BROWSERSTACK_CENTRAL_USER] + } else { + process.env[BROWSERSTACK_CENTRAL_USER] = originalCentralUser + } + }) + + it('delegates to TestMetadata.set', () => { + const setSpy = vi.spyOn(TestMetadata, 'set') + BrowserStackSDK.setTestMetadata({ identifier: 'run-1' }) + expect(setSpy).toHaveBeenCalledWith({ identifier: 'run-1' }) + }) + + it('makes the metadata retrievable via TestMetadata.get', () => { + BrowserStackSDK.setTestMetadata({ identifier: 'run-1' }) + expect(TestMetadata.get()).toEqual({ identifier: 'run-1' }) + }) + + it('defaults to an empty object when called with no argument', () => { + expect(() => BrowserStackSDK.setTestMetadata()).not.toThrow() + expect(TestMetadata.get()).toEqual({}) + }) +}) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 2b507b4..a49436a 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -2255,3 +2255,30 @@ describe('isMultiRemoteCaps', () => { expect(isMultiRemoteCaps(capsWithNull as any)).toBe(false) }) }) + +describe('getCentralUser', () => { + const originalCentralUser = process.env.BROWSERSTACK_CENTRAL_USER + + afterEach(() => { + if (originalCentralUser === undefined) { + delete process.env.BROWSERSTACK_CENTRAL_USER + } else { + process.env.BROWSERSTACK_CENTRAL_USER = originalCentralUser + } + }) + + it('returns { app_lcnc: true } when the central user is app_lcnc', () => { + process.env.BROWSERSTACK_CENTRAL_USER = 'app_lcnc' + expect(utils.getCentralUser()).toEqual({ app_lcnc: true }) + }) + + it('returns { app_lcnc: false } when the central user env var is unset', () => { + delete process.env.BROWSERSTACK_CENTRAL_USER + expect(utils.getCentralUser()).toEqual({ app_lcnc: false }) + }) + + it('returns { app_lcnc: false } for an unrecognised central user', () => { + process.env.BROWSERSTACK_CENTRAL_USER = 'some_other_user' + expect(utils.getCentralUser()).toEqual({ app_lcnc: false }) + }) +}) From 0f5674058db5bfba921b9937671120878da3b6b2 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Mon, 3 Aug 2026 13:35:00 +0530 Subject: [PATCH 3/4] wdio support --- .../browserstack-service/src/cli/apiUtils.ts | 54 +++++++++++-------- .../src/cli/modules/testHubModule.ts | 11 ++-- packages/browserstack-service/src/metadata.ts | 4 +- packages/browserstack-service/src/util.ts | 6 ++- .../funnelInstrumentation.test.ts | 21 +++----- .../tests/testHub/utils.test.ts | 3 +- .../browserstack-service/tests/util.test.ts | 8 +-- 7 files changed, 54 insertions(+), 53 deletions(-) diff --git a/packages/browserstack-service/src/cli/apiUtils.ts b/packages/browserstack-service/src/cli/apiUtils.ts index eb6b780..5e3ff2d 100644 --- a/packages/browserstack-service/src/cli/apiUtils.ts +++ b/packages/browserstack-service/src/cli/apiUtils.ts @@ -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' @@ -10,35 +12,41 @@ export default class APIUtils { static UPLOAD_LOGS_ADDRESS = 'https://upload-observability.browserstack.com' static EDS_URL = 'https://eds.browserstack.com' - static hasValidGRRUrls(apis?: Partial): apis is GRRUrls { - return Boolean( - apis?.automate?.api && - apis?.automate?.upload && - apis?.appAutomate?.api && - apis?.appAutomate?.upload && - apis?.percy?.api && - apis?.appAccessibility?.api && - apis?.observability?.api && - apis?.observability?.upload && - apis?.edsInstrumentation?.api - ) + static missingGRRUrlKeys(apis?: Partial): 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) { - if (!this.hasValidGRRUrls(apis)) { + const missing = APIUtils.missingGRRUrlKeys(apis) + if (missing.length > 0) { + BStackLogger.warn(`updateURLSForGRR: GRR URLs incomplete — keeping default endpoints. Missing: ${missing.join(', ')}`) return false } - 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 + // 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 } diff --git a/packages/browserstack-service/src/cli/modules/testHubModule.ts b/packages/browserstack-service/src/cli/modules/testHubModule.ts index 1cce33a..71ca20b 100644 --- a/packages/browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/browserstack-service/src/cli/modules/testHubModule.ts @@ -35,15 +35,13 @@ export default class TestHubModule extends BaseModule { this.name = 'TestHubModule' this.testhubConfig = testhubConfig + TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this)) + Object.values(TestFrameworkState).forEach(state => { Object.values(HookState).forEach(hook => { TestFramework.registerObserver(state, hook, this.onAllTestEvents.bind(this)) }) }) - // TEST/PRE: sendTestFrameworkEvent must run before onBeforeTest mutates - // TestMetadata.currentTestRunUuid, so sequence them explicitly instead of - // relying on observer registration order. - TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this)) } /** @@ -55,7 +53,7 @@ export default class TestHubModule extends BaseModule { } private getCurrentTestRunUuid(instance: TestFrameworkInstance): string | undefined { - return (TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) as string | undefined) ?? instance.getRef() + return (TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) as string | undefined) || instance.getRef() } onBeforeTest(args: Record) { @@ -107,7 +105,8 @@ export default class TestHubModule extends BaseModule { this.sendTestFrameworkEvent(args) } - if (testState === TestFrameworkState.TEST && hookState === HookState.POST) { + if (testState === TestFrameworkState.TEST && hookState === HookState.POST && + TestFramework.hasState(instance, TestFrameworkConstants.KEY_TEST_RESULT_AT)) { TestMetadata.reset() } } diff --git a/packages/browserstack-service/src/metadata.ts b/packages/browserstack-service/src/metadata.ts index fecd47c..2d6e0c3 100644 --- a/packages/browserstack-service/src/metadata.ts +++ b/packages/browserstack-service/src/metadata.ts @@ -18,8 +18,8 @@ class TestMetadata { } const testRunIdentifier = metadata.identifier - if (typeof testRunIdentifier !== 'string') { - BStackLogger.warn('setTestMetadata: metadata.identifier must be a string.') + if (typeof testRunIdentifier !== 'string' || testRunIdentifier.length === 0) { + BStackLogger.warn('setTestMetadata: metadata.identifier must be a non-empty string.') return } if (testRunIdentifier.length > 40) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index fa8537f..f210e9e 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -381,7 +381,7 @@ export const getCentralUser = (): Partial => { case 'app_lcnc': return { app_lcnc: true } default: - return { app_lcnc: false } + return {} } } @@ -411,7 +411,9 @@ export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SD settings: options.accessibilityOptions }, browserstackAutomation: shouldAddServiceVersion(config, options.testObservability), - grouping_identifier: process.env[BROWSERSTACK_BUILD_GROUPING_IDENTIFIER] || '', + ...(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, diff --git a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts index aa092e8..fe476a6 100644 --- a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts +++ b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts @@ -45,8 +45,7 @@ const expectedEventData = { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -107,8 +106,7 @@ describe('funnelInstrumentation', () => { 'accessibility': false, 'percy': false, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate']), productUsage: expect.objectContaining({ @@ -221,8 +219,7 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -259,8 +256,7 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -306,8 +302,7 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -349,8 +344,7 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', @@ -395,8 +389,7 @@ describe('funnelInstrumentation', () => { 'accessibility': true, 'percy': true, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false }, product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), framework: 'framework', diff --git a/packages/browserstack-service/tests/testHub/utils.test.ts b/packages/browserstack-service/tests/testHub/utils.test.ts index 93a8971..4910e09 100644 --- a/packages/browserstack-service/tests/testHub/utils.test.ts +++ b/packages/browserstack-service/tests/testHub/utils.test.ts @@ -27,8 +27,7 @@ describe('getProductMap', () => { 'accessibility': false, 'percy': false, 'automate': true, - 'app_automate': false, - 'app_lcnc': false + 'app_automate': false } expect(productMap).toEqual(expectedProductMap) }) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index a49436a..5414eab 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -2272,13 +2272,13 @@ describe('getCentralUser', () => { expect(utils.getCentralUser()).toEqual({ app_lcnc: true }) }) - it('returns { app_lcnc: false } when the central user env var is unset', () => { + it('returns {} when the central user env var is unset', () => { delete process.env.BROWSERSTACK_CENTRAL_USER - expect(utils.getCentralUser()).toEqual({ app_lcnc: false }) + expect(utils.getCentralUser()).toEqual({}) }) - it('returns { app_lcnc: false } for an unrecognised central user', () => { + it('returns {} for an unrecognised central user', () => { process.env.BROWSERSTACK_CENTRAL_USER = 'some_other_user' - expect(utils.getCentralUser()).toEqual({ app_lcnc: false }) + expect(utils.getCentralUser()).toEqual({}) }) }) From 650c514f9f3fbe8f6dd9ed68e819b4a440fc66cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:05:24 +0000 Subject: [PATCH 4/4] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-78.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-78.md diff --git a/.changeset/pr-78.md b/.changeset/pr-78.md new file mode 100644 index 0000000..eb3b2d3 --- /dev/null +++ b/.changeset/pr-78.md @@ -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.