Skip to content
Open
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) {
Comment on lines +442 to +444

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the hookRunUuid is null should we proceed further?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in f9abbf5. When the hook's run uuid can't be resolved we now return early and do not open the scan gate, so an untracked hook never produces a NULL-attributed scan (behaviour stays unchanged for that hook — matches the class doc's "additive when hookRunUuid is absent").

Applied the same guard to the active v9 CLI path (cli/modules/accessibilityModule.onHookStart) too, since that's the path that actually runs in v9 (this handler is inert there). Added a unit test asserting the gate stays closed when the hook uuid is null. Accessibility suite 74/74 green, tsc clean.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: reverted this guard in 86cbbe4. Decision — a missing hook run uuid must not block the scan: hook-window commands should always be scanned for accessibility coverage; when the hook uuid can't be resolved the scan simply goes out without thHookRunUuid (unstamped) rather than being skipped. So the answer to "should we proceed if hookRunUuid is null?" is yes, proceed and scan — only the thHookRunUuid stamping is conditional on the uuid being present (it's dropped when undefined). Restored the original onHookStart/beforeHook behaviour on both paths.

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