Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/browserstack-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"undici": "^6.24.0",
"uuid": "^11.1.0",
"winston-transport": "^4.5.0",
"yauzl": "^3.0.0",
"yauzl": "^3.4.0",
"@wdio/logger": "^9.0.0",
"@wdio/reporter": "^9.0.0",
"@wdio/types": "^9.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
// The WebdriverIO.Browser accessibility augmentations
// (getAccessibilityResultsSummary, getAccessibilityResults, performScan,
// startA11yScanning, stopA11yScanning) now live in src/index.ts inside
// `declare global { namespace WebdriverIO { ... } }`, so that tsc emits them
// into build/index.d.ts for consumers. They were moved out of this ambient
// file to avoid duplicate-declaration conflicts. The setCustomTags augmentation
// stays here (it is not part of the a11y type-shipping fix).
declare namespace WebdriverIO {
interface Browser {
getAccessibilityResultsSummary: () => Promise<Record<string, unknown>>,
getAccessibilityResults: () => Promise<Array<Record<string, unknown>>>,
performScan: () => Promise<Record<string, unknown> | undefined>,
startA11yScanning: () => Promise<void>,
stopA11yScanning: () => Promise<void>
setCustomTags: (key: string, value: string) => Promise<void>
}

interface MultiRemoteBrowser {
getAccessibilityResultsSummary: () => Promise<Record<string, unknown>>,
getAccessibilityResults: () => Promise<Array<Record<string, unknown>>>,
performScan: () => Promise<Record<string, unknown> | undefined>,
startA11yScanning: () => Promise<void>,
stopA11yScanning: () => Promise<void>
setCustomTags: (key: string, value: string) => Promise<void>
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/// <reference path="./@types/bstack-service-types.d.ts" />
import util from 'node:util'

import type { Capabilities, Frameworks, Options } from '@wdio/types'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const TestFrameworkConstants = {
KEY_TEST_FAILURE_REASON : 'test_failure_reason',
KEY_TEST_LOGS : 'test_logs',
KEY_TEST_META : 'test_meta',
KEY_CUSTOM_TAGS : 'custom_metadata',
KEY_TEST_DEFERRED : 'test_deferred',
KEY_SESSION_NAME : 'test_session_name',
KEY_AUTOMATE_SESSION_NAME : 'automate_session_name',
Expand Down
9 changes: 9 additions & 0 deletions packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,17 @@ export class GrpcClient {
}
const { platformIndex, testFrameworkName, testFrameworkVersion, testFrameworkState, testHookState, testUuid, automationSessions, capabilities, executionContext } = data
const sessions = automationSessions.map((automationSession) => {
// LTS: pass through optional product field so testHubModule's
// ltsActive override (sets product='loadTesting' on the
// AutomationSession) actually reaches the binary via the
// gRPC TestSessionEvent payload. Without this passthrough
// the binary's makeIntegrations falls back to 'automate'
// and test_run.origin lands as Automate instead of
// LoadTesting. Mirror of py-sdk a245a814 +
// browserstack-binary's existing s?.product read.
return AutomationSessionConstructor.create({
provider: automationSession.provider,
product: automationSession.product,
frameworkName: automationSession.frameworkName,
frameworkVersion: automationSession.frameworkVersion,
frameworkSessionId: automationSession.frameworkSessionId,
Expand Down
5 changes: 5 additions & 0 deletions packages/browserstack-service/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import WdioMochaTestFramework from './frameworks/wdioMochaTestFramework.js'
import WdioAutomationFramework from './frameworks/wdioAutomationFramework.js'
import WebdriverIOModule from './modules/webdriverIOModule.js'
import AccessibilityModule from './modules/accessibilityModule.js'
import CustomTagsModule from './modules/customTagsModule.js'
import { isTurboScale, processAccessibilityResponse, shouldAddServiceVersion } from '../util.js'
import ObservabilityModule from './modules/observabilityModule.js'
import type { BrowserstackConfig, BrowserstackOptions, LaunchResponse } from '../types.js'
Expand Down Expand Up @@ -166,6 +167,10 @@ export class BrowserstackCLI {

this.modules[TestHubModule.MODULE_NAME] = new TestHubModule(startBinResponse.testhub)

// Custom-tag (multi Test-Case-ID) tagging rides the per-test event_json
// to TestHub, so it is gated on the testhub pipeline being active.
this.modules[CustomTagsModule.MODULE_NAME] = new CustomTagsModule()

if (startBinResponse.accessibility?.success){
process.env[BROWSERSTACK_ACCESSIBILITY] = 'true'
const options = this.options as BrowserstackConfig & BrowserstackOptions
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/// <reference path="../../@types/bstack-service-types.d.ts" />
import BaseModule from './baseModule.js'
import { BrowserstackCLI } from '../index.js'
import { BStackLogger } from '../cliLogger.js'
Expand Down
122 changes: 122 additions & 0 deletions packages/browserstack-service/src/cli/modules/customTagsModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/// <reference path="../../@types/bstack-service-types.d.ts" />
import BaseModule from './baseModule.js'
import { BStackLogger } from '../cliLogger.js'
import TestFramework from '../frameworks/testFramework.js'
import AutomationFramework from '../frameworks/automationFramework.js'
import type AutomationFrameworkInstance from '../instances/automationFrameworkInstance.js'
import type TestFrameworkInstance from '../instances/testFrameworkInstance.js'
import { AutomationFrameworkState } from '../states/automationFrameworkState.js'
import { HookState } from '../states/hookState.js'
import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js'
import { CLIUtils } from '../cliUtils.js'
import WdioMochaTestFramework from '../frameworks/wdioMochaTestFramework.js'
import { mergeIntoTags, parseCommaSeparatedValues } from '../../customTags.js'
import type { CustomMetadata } from '../../customTags.js'

/**
* CustomTagsModule — CLI/gRPC path registration for `browser.setCustomTags`.
*
* Mirrors AccessibilityModule: registers the browser method in onBeforeExecute()
* (observer-bound to AutomationFrameworkState.CREATE / HookState.POST), instantiated
* from BrowserstackCLI.loadModules() whenever the binary is up.
*
* The per-test custom_metadata is merged directly into the tracked
* TestFrameworkInstance map under KEY_CUSTOM_TAGS ('custom_metadata'). That whole
* map is serialized verbatim into event_json (testHubModule.sendTestFrameworkEvent),
* so the binary reads it as event.custom_metadata. The instance is created fresh
* per test, so per-instance merge gives test-scoped union/dedupe; the binary is a
* strict pass-through and does NOT consolidate across events.
*/
export default class CustomTagsModule extends BaseModule {

logger = BStackLogger
name: string
static MODULE_NAME = 'CustomTagsModule'

constructor() {
super()
this.name = 'CustomTagsModule'
AutomationFramework.registerObserver(AutomationFrameworkState.CREATE, HookState.POST, this.onBeforeExecute.bind(this))
}

getModuleName() {
return CustomTagsModule.MODULE_NAME
}

async onBeforeExecute() {
try {
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
if (!autoInstance) {
this.logger.debug('CustomTagsModule: No tracked automation instance found!')
return
}

const browser = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser
if (!browser) {
this.logger.debug('CustomTagsModule: No browser instance found for setCustomTags registration')
return
}

(browser as WebdriverIO.Browser).setCustomTags = async (key: string, value: string): Promise<void> => {
try {
if (!key || !value) {
this.logger.warn('setCustomTags: key and value are required; ignoring call')
return
}

const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()
if (!testInstance) {
this.logger.warn('setCustomTags called outside of a resolvable test context; ignoring')
return
}

const values = parseCommaSeparatedValues(value)
if (values.length === 0) {
this.logger.warn(`setCustomTags: no usable values parsed from "${value}"; ignoring call`)
return
}

// Merge into the per-test instance map (test-scoped accumulator).
const existing = (TestFramework.getState(testInstance, TestFrameworkConstants.KEY_CUSTOM_TAGS) as CustomMetadata) || {}
mergeIntoTags(existing, key, values)
testInstance.updateMultipleEntries({
[TestFrameworkConstants.KEY_CUSTOM_TAGS]: existing
})

// Hook-level: if a hook is currently active, also stamp custom_metadata
// onto the active hook so binary reads hook.custom_metadata.
this.applyToActiveHook(testInstance, key, values)

this.logger.debug(`setCustomTags: merged key=${key} values=${JSON.stringify(values)}`)
} catch (error) {
this.logger.warn(`setCustomTags: error while recording custom tags: ${error}`)
}
}
} catch (error) {
this.logger.error(`Error in CustomTagsModule.onBeforeExecute: ${error}`)
}
}

/**
* If the test framework is currently inside a hook, merge custom_metadata onto
* the last-started hook record so the binary receives hook.custom_metadata.
* Best-effort: silently no-ops when no active hook is resolvable.
*/
private applyToActiveHook(testInstance: TestFrameworkInstance, key: string, values: string[]) {
try {
const currentState = testInstance.getCurrentTestState().toString()
if (!CLIUtils.matchHookRegex(currentState)) {
return
}
const hook = WdioMochaTestFramework.lastActiveHook(testInstance, WdioMochaTestFramework.KEY_HOOK_LAST_STARTED)
if (!hook) {
return
}
const hookTags = (hook[TestFrameworkConstants.KEY_CUSTOM_TAGS] as CustomMetadata) || {}
mergeIntoTags(hookTags, key, values)
hook[TestFrameworkConstants.KEY_CUSTOM_TAGS] = hookTags
} catch (error) {
this.logger.debug(`setCustomTags: could not apply to active hook: ${error}`)
}
}
}
42 changes: 35 additions & 7 deletions 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 { isLoadTestingSession, getLtsSessionId } from '../../util.js'

/**
* TestHub Module for BrowserStack
Expand Down Expand Up @@ -182,22 +183,49 @@ export default class TestHubModule extends BaseModule {
}

this.logger.debug(`sendTestSessionEvent: instance iteration ${JSON.stringify(autoInstances)}`)
// LTS: BLU runner pods route through a local Selenium hub
// so KEY_IS_BROWSERSTACK_HUB resolves to false — without
// this gate the AutomationSession lands with provider=
// unknown_grid and TestHub's o11y classifier sets
// test_run.origin=UnknownGrid. Mirrors py-sdk 3af3bba6
// (force provider='browserstack' + product='loadTesting'
// under LTS) and 0efca1ae (override frameworkSessionId
// with the LTS pod-iteration env id).
// Hoisted above the loop — env vars are stable for the
// process lifetime, no need to re-read per iteration.
const ltsActive = isLoadTestingSession()
const ltsSessionId = ltsActive ? getLtsSessionId() : ''
// Process automation instances
for (const autoInstance of autoInstances) {
const sessionProvider = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_IS_BROWSERSTACK_HUB) as boolean
const sessionProvider = ltsActive
? 'browserstack'
: 'unknown_grid'
: (AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_IS_BROWSERSTACK_HUB) as boolean
? 'browserstack'
: 'unknown_grid')

// Null-safe: under LTS the local-Selenium AutomationFrameworkInstance
// may not have KEY_FRAMEWORK_SESSION_ID populated yet (the binary's
// hub assigns sessionId later than KEY_IS_BROWSERSTACK_HUB). Without
// the guard, AutomationFramework.getState returns undefined and the
// chained .toString() throws TypeError before the (ltsActive &&
// ltsSessionId) ternary on line 215 has a chance to fall through to
// ltsSessionId. Default to '' so the ternary path stays untouched
// and the non-LTS fall-through is still safe.
const driverFrameworkSessionId = (
AutomationFramework.getState(
autoInstance,
AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID,
)?.toString() ?? ''
)

const automationSession: AutomationSession = {
provider: sessionProvider,
ref: autoInstance.getRef(),
hubUrl: this.config.hubUrl as string,
frameworkSessionId: AutomationFramework.getState(
autoInstance,
AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID,
).toString(),
frameworkSessionId: (ltsActive && ltsSessionId) ? ltsSessionId : driverFrameworkSessionId,
frameworkName: autoInstance.frameworkName,
frameworkVersion: autoInstance.frameworkVersion
frameworkVersion: autoInstance.frameworkVersion,
...(ltsActive ? { product: 'loadTesting' } : {})
}
this.logger.debug(`sendTestSessionEvent: automationSession: ${JSON.stringify(automationSession)}`)

Expand Down
7 changes: 7 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ export const BROWSERSTACK_ACCESSIBILITY = 'BROWSERSTACK_ACCESSIBILITY'
// Whether session is a test reporting session
export const BROWSERSTACK_OBSERVABILITY = 'BROWSERSTACK_OBSERVABILITY'

// Load Testing Service (LTS) env vars: the pod-iteration session id (its
// presence is the single source of truth for "this run is an LTS pod
// iteration") and an optional opt-in flag for local repro / CI runs that
// don't have a real session id.
export const BROWSERSTACK_LTS_SESSION_ID = 'BROWSERSTACK_LTS_SESSION_ID'
export const BROWSERSTACK_LTS = 'BROWSERSTACK_LTS'

// New Test Reporting and Analytics environment variables
export const BROWSERSTACK_TEST_REPORTING = 'BROWSERSTACK_TEST_REPORTING'
export const BROWSERSTACK_TEST_REPORTING_DEBUG = 'BROWSERSTACK_TEST_REPORTING_DEBUG'
Expand Down
91 changes: 91 additions & 0 deletions packages/browserstack-service/src/custom-tags-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/// <reference path="./@types/bstack-service-types.d.ts" />
import InsightsHandler from './insights-handler.js'
import { CustomTagAccumulator } from './customTags.js'
import type { CustomMetadata } from './customTags.js'
import { o11yClassErrorHandler } from './util.js'
import { BStackLogger } from './bstackLogger.js'

/**
* Legacy/Listener-path handler for `browser.setCustomTags`.
*
* Registered from service.ts in the `!BrowserstackCLI.getInstance().isRunning()`
* branch (alongside AccessibilityHandler.before() / InsightsHandler.before()).
* Deliberately NOT folded into InsightsHandler.before() — that method registers
* no browser methods and is SKIPPED when the CLI is running.
*
* Accumulates (key, value) pairs per active test, keyed on
* InsightsHandler.currentTest.uuid (set in beforeTest / setTestData). The
* accumulator is static so InsightsHandler.getRunData can drain it into the
* TestData.custom_metadata field at TestRunFinished (flushed in afterTest).
*/
class _CustomTagsHandler {
// Static so the flush sink (InsightsHandler.getRunData) can read it without a
// handler reference. Keyed on the per-test uuid.
public static accumulator: CustomTagAccumulator = new CustomTagAccumulator()

constructor(
private _browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
private _caps: WebdriverIO.Capabilities | Record<string, unknown>,
private _framework?: string
) {}

before() {
const register = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser) => {
(browser as WebdriverIO.Browser).setCustomTags = async (key: string, value: string): Promise<void> => {
try {
if (this._framework !== 'mocha') {
BStackLogger.warn('setCustomTags is only supported for the mocha framework; ignoring call')
return
}
if (!key || !value) {
BStackLogger.warn('setCustomTags: key and value are required; ignoring call')
return
}
const testUuid = InsightsHandler.currentTest.uuid
if (!testUuid) {
BStackLogger.warn('setCustomTags called outside of a resolvable test context; ignoring')
return
}
const added = _CustomTagsHandler.accumulator.add(testUuid, key, value)
if (!added) {
BStackLogger.warn(`setCustomTags: no usable values parsed from "${value}"; ignoring call`)
return
}
BStackLogger.debug(`setCustomTags: recorded key=${key} value="${value}" for test=${testUuid}`)
} catch (error) {
BStackLogger.warn(`setCustomTags: error while recording custom tags: ${error}`)
}
}
}

// Fan out to every MultiRemote instance (mirror service._executeCommand),
// plus the top-level browser object the user calls directly.
register(this._browser)
if ((this._browser as WebdriverIO.Browser).isMultiremote) {
const multiRemoteBrowser = this._browser as unknown as WebdriverIO.MultiRemoteBrowser
Object.keys(this._caps).forEach((browserName) => {
try {
const instance = multiRemoteBrowser.getInstance(browserName)
if (instance) {
register(instance)
}
} catch (error) {
BStackLogger.debug(`setCustomTags: could not register on multiremote instance ${browserName}: ${error}`)
}
})
}
}

/** Drain the accumulated custom_metadata for a test uuid; clears after read. */
static drain(testUuid: string | undefined): CustomMetadata | undefined {
const data = _CustomTagsHandler.accumulator.get(testUuid)
_CustomTagsHandler.accumulator.clear(testUuid)
return data
}
}

// https://github.com/microsoft/TypeScript/issues/6543
const CustomTagsHandler: typeof _CustomTagsHandler = o11yClassErrorHandler(_CustomTagsHandler)
type CustomTagsHandler = _CustomTagsHandler

export default CustomTagsHandler
Loading
Loading