From 11b8ab09ea77250233e124a39186de7810e7c6ff Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Wed, 15 Jul 2026 16:17:07 +0530 Subject: [PATCH] feat(browserstack-service): stamp hook_run_uuid on App A11y scans fired inside hooks [APPA11Y-5542] App A11y scans fired inside test hooks (before/after, beforeEach/afterEach) were dropped or misattributed. The SDK now stamps the hook's run UUID (thHookRunUuid) on scans fired inside a supported hook, so SeleniumHub (appAllyHandler, PR #14162) relays it as hook_run_uuid and app-accessibility reconciles the scan onto the wrapping test case. Additive: in-test scans are unchanged (field dropped when absent). - util: _getParamsForAppAccessibility / performA11yScan thread optional hookRunUuid (thHookRunUuid) - insights-handler: expose getCurrentHook() so a11y reuses the HookRunStarted uuid (= hook BTCER uuid) - accessibility-handler: beforeHook/afterHook set _currentHookRunUuid + enable the scan gate during mocha hook windows (per-test hooks honour include/exclude scope; suite hooks use autoScanning) - service: wire accessibilityHandler.beforeHook/afterHook, passing the shared hook uuid - tests: cover hook-uuid stamp/clear + unsupported-framework no-op Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/accessibility-handler.ts | 50 ++++++++++++++++++- .../src/insights-handler.ts | 6 +++ packages/browserstack-service/src/service.ts | 4 ++ packages/browserstack-service/src/util.ts | 9 ++-- .../tests/accessibility-handler.test.ts | 37 ++++++++++++++ 5 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index f46b53e..eb30d39 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -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' @@ -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() @@ -418,6 +422,48 @@ 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 + } + 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 (_test?: Frameworks.Test, _context?: unknown, _result?: Frameworks.TestResult, _hookRunUuid?: string | null) { + // Hook finished: subsequent (test-body) scans must not be stamped as hook scans. + this._currentHookRunUuid = null + } + /* * private methods */ @@ -433,7 +479,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`) } diff --git a/packages/browserstack-service/src/insights-handler.ts b/packages/browserstack-service/src/insights-handler.ts index 8139506..a2c80a1 100644 --- a/packages/browserstack-service/src/insights-handler.ts +++ b/packages/browserstack-service/src/insights-handler.ts @@ -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(), diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index c5f21d1..614160a 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -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' }) @@ -440,6 +443,7 @@ export default class BrowserstackService implements Services.ServiceInstance { } await this._insightsHandler?.afterHook(test, result) + await this._accessibilityHandler?.afterHook(test as Frameworks.Test, context, result, this._insightsHandler?.getCurrentHook()?.uuid) } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_HOOK, { hookType: 'beforeTest' }) diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 136eebd..7a81048 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -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, @@ -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.') @@ -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 ) } diff --git a/packages/browserstack-service/tests/accessibility-handler.test.ts b/packages/browserstack-service/tests/accessibility-handler.test.ts index ddaf472..21d6559 100644 --- a/packages/browserstack-service/tests/accessibility-handler.test.ts +++ b/packages/browserstack-service/tests/accessibility-handler.test.ts @@ -433,6 +433,43 @@ describe('afterScenario', () => { }) }) +describe('beforeHook / afterHook (hook scans)', () => { + beforeEach(() => { + accessibilityHandler = new AccessibilityHandler(browser, caps, options, false, config, 'mocha', true, false, accessibilityOpts) + vi.spyOn(utils, 'isBrowserstackSession').mockReturnValue(true) + vi.spyOn(utils, 'isAccessibilityAutomationSession').mockReturnValue(true) + accessibilityHandler['_sessionId'] = 'session123' + }) + + it('stamps the hook run uuid inside a supported mocha per-test hook', async () => { + vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true) + await accessibilityHandler.beforeHook( + { title: '"before each" hook', parent: 'suite' } as any, + { currentTest: { parent: 'suite', title: 'test' } }, + 'hook-uuid-1' + ) + expect(accessibilityHandler['_currentHookRunUuid']).toBe('hook-uuid-1') + }) + + it('clears the hook run uuid on afterHook so test-body scans are not stamped', async () => { + await accessibilityHandler.beforeHook( + { title: '"after each" hook', parent: 'suite' } as any, + { currentTest: { parent: 'suite', title: 'test' } }, + 'hook-uuid-2' + ) + expect(accessibilityHandler['_currentHookRunUuid']).toBe('hook-uuid-2') + await accessibilityHandler.afterHook({ title: '"after each" hook' } as any, {}, { passed: true } as any, 'hook-uuid-2') + expect(accessibilityHandler['_currentHookRunUuid']).toBeNull() + }) + + it('does not stamp when the framework does not support hooks', async () => { + const jasmineHandler = new AccessibilityHandler(browser, caps, options, false, config, 'jasmine', true, false, accessibilityOpts) + jasmineHandler['_sessionId'] = 'session123' + await jasmineHandler.beforeHook({ title: '"before each" hook' } as any, {}, 'hook-uuid-3') + expect(jasmineHandler['_currentHookRunUuid']).toBeNull() + }) +}) + describe('beforeTest', () => { let executeAsyncSpy: any let executeSpy: any