Skip to content
51 changes: 49 additions & 2 deletions packages/browserstack-service/src/accessibility-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ import {
validateCapsWithAppA11y,
getAppA11yResults,
executeAccessibilityScript,
isFalse
isFalse,
getHookType,
frameworkSupportsHook
} from './util.js'
import accessibilityScripts from './scripts/accessibility-scripts.js'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
Expand All @@ -87,6 +89,8 @@ class _AccessibilityHandler {
private _autoScanning: boolean = true
private _testIdentifier: string | null = null
private _testMetadata: TestMetadata = {}
/* Set while a supported hook is executing; scans fired in this window are stamped with it. */
private _currentHookRunUuid: string | null = null
private static _a11yScanSessionMap: A11yScanSessionMap = {}
private _sessionId: string | null = null
private listener = Listener.getInstance()
Expand Down Expand Up @@ -418,6 +422,49 @@ class _AccessibilityHandler {
}
}

/**
* Hook scans. A driver command executed inside a test hook (before/after, beforeEach/afterEach,
* cucumber hooks) should fire an accessibility scan carrying the hook's run UUID so the backend
* (SeleniumHub appAllyHandler -> app-accessibility) reconciles it onto the wrapping test case
* instead of collapsing into a NULL row. `hookRunUuid` is the SAME uuid the SDK reports to
* TestHub as HookRunStarted (InsightsHandler.getCurrentHook) = the hook's BTCER uuid.
* Additive: when hookRunUuid is absent, in-test scan behaviour is unchanged.
*/
async beforeHook (test: Frameworks.Test | undefined, context: unknown, hookRunUuid?: string | null) {
try {
if (!this._accessibility || !this.shouldRunTestHooks(this._browser, this._accessibility)) {
return
}
if (!frameworkSupportsHook('before', this._framework)) {
return
}

this._currentHookRunUuid = hookRunUuid || null

if (this._framework === 'mocha' && this._sessionId) {
let shouldScan = this._autoScanning
const hookType = (test && typeof test.title === 'string') ? getHookType(test.title) : 'unknown'
const wrappedTest = (context as { currentTest?: Frameworks.Test } | undefined)?.currentTest
if ((hookType === 'BEFORE_EACH' || hookType === 'AFTER_EACH') && wrappedTest) {
let suiteTitle: unknown = wrappedTest.parent
if (suiteTitle && typeof suiteTitle === 'object') {
suiteTitle = (suiteTitle as { title?: string }).title
}
// @ts-expect-error fix type
shouldScan = this._autoScanning && shouldScanTestForAccessibility(suiteTitle as string | undefined, wrappedTest.title, this._accessibilityOptions)
}
AccessibilityHandler._a11yScanSessionMap[this._sessionId] = shouldScan
}
} catch (error) {
BStackLogger.error(`Exception in accessibility automation beforeHook: ${error}`)
}
}

async afterHook () {
// Hook finished: subsequent (test-body) scans must not be stamped as hook scans.
this._currentHookRunUuid = null
}

/*
* private methods
*/
Expand All @@ -433,7 +480,7 @@ class _AccessibilityHandler {
)
) {
BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`)
await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name)
await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name, undefined, this._currentHookRunUuid)
} else if (skipScanForBidiWindowCommand) {
BStackLogger.debug(`SDK-5047: skipping accessibility scan for BiDi window/context command '${command.name}' to avoid racing the WebdriverIO ContextManager during session-start window churn`)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import PerformanceTester from '../../instrumentation/performance/performance-tes
import * as PERFORMANCE_SDK_EVENTS from '../../instrumentation/performance/constants.js'
import type { FetchDriverExecuteParamsEventRequest, FetchDriverExecuteParamsEventResponse } from '../../grpc/index.js'
import { GrpcClient } from '../grpcClient.js'
import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js'

export default class AccessibilityModule extends BaseModule {

Expand All @@ -34,6 +35,10 @@ export default class AccessibilityModule extends BaseModule {
LOG_DISABLED_SHOWN: Map<number, boolean>
testMetadata: Record<string, { [key: string]: unknown; }> = {}
currentTestName: string | null = null
// The run uuid of the hook currently executing (set at hook PRE, cleared at hook POST).
// Any scan fired while this is set is stamped with it as thHookRunUuid so the backend
// (SeleniumHub appAllyScan → hook_run_uuid) can reconcile the scan onto the wrapping test.
currentHookRunUuid: string | null = null

constructor(accessibilityConfig: Accessibility, isNonBstackA11y: boolean) {
super()
Expand All @@ -42,6 +47,13 @@ export default class AccessibilityModule extends BaseModule {
AutomationFramework.registerObserver(AutomationFrameworkState.CREATE, HookState.POST, this.onBeforeExecute.bind(this))
TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this))
TestFramework.registerObserver(TestFrameworkState.TEST, HookState.POST, this.onAfterTest.bind(this))
// Track the hook window for every hook state the framework supports. PRE captures the
// hook's run uuid + opens the scan gate; POST clears the uuid so later test-body scans
// are not mis-stamped as hook scans.
for (const hookFrameworkState of [TestFrameworkState.BEFORE_ALL, TestFrameworkState.BEFORE_EACH, TestFrameworkState.AFTER_EACH, TestFrameworkState.AFTER_ALL]) {
TestFramework.registerObserver(hookFrameworkState, HookState.PRE, this.onHookStart.bind(this))
TestFramework.registerObserver(hookFrameworkState, HookState.POST, this.onHookEnd.bind(this))
}
this.accessibility = Boolean(accessibilityConfig)
const accessibilityOptions = (BrowserstackCLI.getInstance().options as Record<string, unknown>)?.accessibilityOptions as { [key: string]: string | boolean | undefined }
this.autoScanning = Boolean(accessibilityOptions?.autoScanning ?? true)
Expand All @@ -52,6 +64,44 @@ export default class AccessibilityModule extends BaseModule {
this.isNonBstackA11y = isNonBstackA11y
}

async onHookStart(args: Record<string, unknown>) {
try {
const testInstance: TestFrameworkInstance = (args?.instance as TestFrameworkInstance) || TestFramework.getTrackedInstance()
// KEY_HOOK_ID is stamped on the instance at hook PRE (wdioMochaTestFramework.trackEvent)
// and is the SAME uuid reported to TestHub as the hook run, so a scan tagged with it can
// be self-joined onto the wrapping test in BTCER. Capture it FIRST — it needs only the
// test instance. The automation-framework instance (below) is required solely for the web
// per-command scan gate; on the app CLI path an explicit performScan() carries the uuid
// regardless, and autoInstance is not always resolvable at hook time — so gating the
// capture on autoInstance would silently drop app hook-scan stamping.
const hookRunUuid = testInstance ? (TestFramework.getState(testInstance, TestFrameworkConstants.KEY_HOOK_ID) as string | undefined) : undefined
this.currentHookRunUuid = hookRunUuid || null

const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
if (!testInstance || !autoInstance) {
return
}
const sessionId = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID)

if (!this.accessibility) {
return
}
// Open the scan gate for the hook window so DOM-changing commands issued inside
// before/beforeEach/afterEach/after hooks trigger scans (web per-command path). The
// following onBeforeTest re-computes the per-test gate, so this only affects the hook.
if (this.autoScanning && sessionId !== undefined && sessionId !== null) {
this.accessibilityMap.set(sessionId, true)
}
} catch (error) {
this.logger.error(`Exception in accessibility onHookStart: ${error}`)
}
}

async onHookEnd() {
// Hook finished: subsequent (test-body) scans must not be stamped with the hook uuid.
this.currentHookRunUuid = null
}

async onBeforeExecute() {
try {
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
Expand Down Expand Up @@ -116,7 +166,8 @@ export default class AccessibilityModule extends BaseModule {
if (!this.accessibility && !this.isAppAccessibility){
return
}
return await this.performScanCli(browser)
// If invoked from inside a hook, currentHookRunUuid stamps the scan for the hook.
return await this.performScanCli(browser, undefined, this.currentHookRunUuid)
}

(browser as WebdriverIO.Browser).startA11yScanning = async () => {
Expand Down Expand Up @@ -174,7 +225,7 @@ export default class AccessibilityModule extends BaseModule {
!this.shouldPatchExecuteScript(args.length ? args[0] as string : null)
) {
try {
await this.performScanCli(browser, command.name)
await this.performScanCli(browser, command.name, this.currentHookRunUuid)
this.logger.debug(`Accessibility scan performed after ${command.name} command`)
} catch (scanError) {
this.logger.debug(`Error performing accessibility scan after ${command.name}: ${scanError}`)
Expand Down Expand Up @@ -244,7 +295,7 @@ export default class AccessibilityModule extends BaseModule {
if (!this.accessibility && !this.isAppAccessibility){
return
}
const results = await this.performScanCli(browser)
const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid)
if (results){
const testIdentifier = String(testInstance.getContext().getId())
this.testMetadata[testIdentifier] = {
Expand Down Expand Up @@ -387,7 +438,8 @@ export default class AccessibilityModule extends BaseModule {

private async performScanCli(
browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
commandName?: string
commandName?: string,
hookRunUuid?: string | null
): Promise<Record<string, unknown> | undefined> {
return await PerformanceTester.measureWrapper(
PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN,
Expand All @@ -400,7 +452,7 @@ export default class AccessibilityModule extends BaseModule {
if (this.isAppAccessibility) {
const testName=this.currentTestName || undefined
const results: unknown = await (browser as WebdriverIO.Browser).execute(
formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName))) as string,
formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid))) as string,
{}
)
BStackLogger.debug(util.format(results as string))
Expand Down
6 changes: 6 additions & 0 deletions packages/browserstack-service/src/insights-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ class _InsightsHandler {
}
}

/* Exposes the current hook run so other handlers (e.g. AccessibilityHandler) can
stamp the same hook UUID we report to TestHub as HookRunStarted. */
getCurrentHook(): CurrentRunInfo {
return this._currentHook
}

async sendScenarioObjectSkipped(scenario: Scenario, feature: Feature, uri: string) {
const testMetaData: TestMeta = {
uuid: uuidv4(),
Expand Down
4 changes: 4 additions & 0 deletions packages/browserstack-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ export default class BrowserstackService implements Services.ServiceInstance {
}

await this._insightsHandler?.beforeHook(test, context)
// Reuse the exact hook UUID InsightsHandler just reported to TestHub (HookRunStarted)
// so a11y hook scans carry a hook_run_uuid that matches the hook's BTCER row.
await this._accessibilityHandler?.beforeHook(test as Frameworks.Test, context, this._insightsHandler?.getCurrentHook()?.uuid)
}

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_HOOK, { hookType: 'afterHook' })
Expand Down Expand Up @@ -440,6 +443,7 @@ export default class BrowserstackService implements Services.ServiceInstance {
}

await this._insightsHandler?.afterHook(test, result)
await this._accessibilityHandler?.afterHook()
}

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_HOOK, { hookType: 'beforeTest' })
Expand Down
9 changes: 6 additions & 3 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,9 +571,12 @@ export const formatString = (template: (string | null), ...values: (string | nul
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const _getParamsForAppAccessibility = ( commandName?: string, testName?: string ): { thTestRunUuid: any, thBuildUuid: any, thJwtToken: any, authHeader: any, scanTimestamp: number, method: string | undefined, testName: string | undefined } => {
export const _getParamsForAppAccessibility = ( commandName?: string, testName?: string, hookRunUuid?: string | null ): { thTestRunUuid: any, thHookRunUuid: any, thBuildUuid: any, thJwtToken: any, authHeader: any, scanTimestamp: number, method: string | undefined, testName: string | undefined } => {
return {
'thTestRunUuid': process.env.TEST_ANALYTICS_ID,
// Present only when the scan fires inside a hook (dropped by JSON.stringify when undefined,
// so in-test scans are unchanged). SeleniumHub appAllyHandler relays this as `hook_run_uuid`.
'thHookRunUuid': hookRunUuid || undefined,
'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID,
'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT,
'authHeader': process.env.BSTACK_A11Y_JWT,
Expand All @@ -584,7 +587,7 @@ export const _getParamsForAppAccessibility = ( commandName?: string, testName?:
}

/* eslint-disable @typescript-eslint/no-explicit-any */
export const performA11yScan = async (isAppAutomate: boolean, browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, isBrowserStackSession?: boolean, isAccessibility?: boolean | string, commandName?: string, testName?: string,) : Promise<{ [key: string]: any; } | undefined> => {
export const performA11yScan = async (isAppAutomate: boolean, browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, isBrowserStackSession?: boolean, isAccessibility?: boolean | string, commandName?: string, testName?: string, hookRunUuid?: string | null,) : Promise<{ [key: string]: any; } | undefined> => {

if (!isAccessibilityAutomationSession(isAccessibility)) {
BStackLogger.warn('Not an Accessibility Automation session, cannot perform Accessibility scan.')
Expand All @@ -593,7 +596,7 @@ export const performA11yScan = async (isAppAutomate: boolean, browser: Webdriver

try {
if (isAppAccessibilityAutomationSession(isAccessibility, isAppAutomate)) {
const results: unknown = await (browser as WebdriverIO.Browser).execute(formatString(AccessibilityScripts.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName))) as string, {})
const results: unknown = await (browser as WebdriverIO.Browser).execute(formatString(AccessibilityScripts.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid))) as string, {})
BStackLogger.debug(util.format(results as string))
return ( results as { [key: string]: any; } | undefined )
}
Expand Down
Loading
Loading