From 1693744acae3bc99767bba5e3b4fdd8b99af5013 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Wed, 22 Jul 2026 12:40:06 +0530 Subject: [PATCH 1/2] feat: central-user test metadata, GRR URL guard, build grouping identifier Port of ashish0305/webdriveriolc#1 ("support of setting metadata") to the extracted standalone service repo. Path mapping: packages/wdio-browserstack-service/src -> 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 TestFramework events / the BTCER reporter payload. - Emit central-user keys in the TestHub product maps (adapted to the repo's LTS entries+filter shape so app_lcnc is always present). - 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. 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 | 8 +++ .../browserstack-service/src/testHub/utils.ts | 6 +- packages/browserstack-service/src/types.ts | 1 + packages/browserstack-service/src/util.ts | 16 ++++++ 10 files changed, 149 insertions(+), 7 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 d10fb90..f3468df 100644 --- a/packages/browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/browserstack-service/src/cli/modules/testHubModule.ts @@ -16,6 +16,7 @@ import type AutomationFrameworkInstance from '../instances/automationFrameworkIn import AutomationFramework from '../frameworks/automationFramework.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' import { isLoadTestingSession, getLtsSessionId } from '../../util.js' +import TestMetadata from '../../metadata.js' /** * TestHub Module for BrowserStack @@ -35,13 +36,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)) } /** @@ -52,8 +55,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 autoInstance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance const instances = [autoInstance] args.autoInstance = instances @@ -96,6 +107,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) { @@ -114,10 +129,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 = { platformIndex, diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 759b322..60c30ab 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -104,6 +104,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 8d9d50f..26b47a5 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 import type { Options, Capabilities } from '@wdio/types' 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 4fd6b73..dd03274 100644 --- a/packages/browserstack-service/src/reporter.ts +++ b/packages/browserstack-service/src/reporter.ts @@ -25,6 +25,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 = {} @@ -333,6 +334,13 @@ class _TestReporter extends WDIOReporter { } if (eventType === 'TestRunSkipped') { + // For TestRunSkipped (mocha this.skip()), mocha sets testStats.state = 'pending'. + // Force result to 'skipped' so the BTCER event downstream gets the correct status. + testData.result = 'skipped' + const appLcncMetaData = TestMetadata.get(testData.uuid) + if (Object.keys(appLcncMetaData).length > 0) { + testData.app_lcnc = appLcncMetaData + } eventType = 'TestRunFinished' } diff --git a/packages/browserstack-service/src/testHub/utils.ts b/packages/browserstack-service/src/testHub/utils.ts index f66e3cb..7c26a6b 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, isLoadTestingSession } from '../util.js' +import { getCentralUser, isTrue, isLoadTestingSession } from '../util.js' interface ErrorType { key: string @@ -31,7 +31,7 @@ export const getProductMap = (config: BrowserStackConfig): { [key: string]: bool if (lts) { entries.push(['lts', true]) } - return Object.fromEntries(entries.filter(([, v]) => v !== null)) as { [key: string]: boolean } + return { ...Object.fromEntries(entries.filter(([, v]) => v !== null)), ...getCentralUser() } as { [key: string]: boolean } } export const shouldProcessEventForTesthub = (eventType: string): boolean => { @@ -113,5 +113,5 @@ export const getProductMapForBuildStartCall = (config: BrowserStackConfig, acces if (lts) { entries.push(['lts', true]) } - return Object.fromEntries(entries.filter(([, v]) => v !== null)) as { [key: string]: boolean } + return { ...Object.fromEntries(entries.filter(([, v]) => v !== null)), ...getCentralUser() } as { [key: string]: boolean } } diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index a41d085..4ace143 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -285,6 +285,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 136eebd..3a70f12 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -35,6 +35,8 @@ import { BROWSERSTACK_LTS_SESSION_ID, TESTOPS_SCREENSHOT_ENV, BROWSERSTACK_TESTHUB_UUID, + BROWSERSTACK_CENTRAL_USER, + BROWSERSTACK_BUILD_GROUPING_IDENTIFIER, PERF_MEASUREMENT_ENV, RERUN_ENV, BROWSERSTACK_TEST_PLAN_ID, @@ -78,6 +80,10 @@ export type GitMetaData = { remotes: Array<{ name: string; url: string }>; } +export type CentralUser = { + app_lcnc: boolean; +} + export const DEFAULT_REQUEST_CONFIG = { headers: { 'Content-Type': 'application/json', @@ -366,6 +372,15 @@ export const processLaunchBuildResponse = (response: LaunchResponse, options: Br processAccessibilityResponse(response, options) } +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) { const launchBuildUsage = UsageStats.getInstance().launchBuildUsage launchBuildUsage.triggered() @@ -392,6 +407,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, From cffb870714df0e3d95ac99d5fb0e7c462cadeb82 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Wed, 22 Jul 2026 13:02:39 +0530 Subject: [PATCH 2/2] test: align existing suites with central-user product-map keys Follow-up to the central-user port; keeps src byte-faithful to ashish0305/webdriveriolc#1 and confines the fixes to tests. - getProductMap / funnel instrumentation now emit app_lcnc: false for every user (getCentralUser defaults to { app_lcnc: false }), so add the key to the expected product maps in testHub/utils and funnelInstrumentation specs. - onBeforeTest now reads args.instance to resolve the test-run uuid; give the onBeforeTest spec a mock instance (with getRef), matching the real event args (instance is always present for test events, as onAllTestEvents already assumes). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/cli/modules/testHubModule.test.ts | 1 + .../tests/instrumentation/funnelInstrumentation.test.ts | 3 ++- packages/browserstack-service/tests/testHub/utils.test.ts | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts b/packages/browserstack-service/tests/cli/modules/testHubModule.test.ts index 47316f2..d5c4be9 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 150830e..c3bf569 100644 --- a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts +++ b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts @@ -41,7 +41,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', diff --git a/packages/browserstack-service/tests/testHub/utils.test.ts b/packages/browserstack-service/tests/testHub/utils.test.ts index a0c0707..b936740 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) })