Skip to content

Commit 62c6f6e

Browse files
feat(browserstack-service): stamp hook_run_uuid on App A11y scans in the v9 CLI runtime path [APPA11Y-5542]
The earlier commits fixed accessibility-handler.ts (the classic/non-CLI path), but v9 App Automate runs through the CLI observer runtime (cli/modules/accessibilityModule.ts), where the hook-scan change was inert. This ports the same contract into the active v9 path. - Register hook-lifecycle observers (BEFORE_ALL / BEFORE_EACH / AFTER_EACH / AFTER_ALL, PRE + POST). PRE (onHookStart) captures the hook's run uuid from KEY_HOOK_ID — the same uuid reported to TestHub as the hook run — and opens the scan gate for the hook window so DOM-changing commands inside hooks trigger scans. POST (onHookEnd) clears the uuid so subsequent test-body scans are not mis-stamped. - Thread currentHookRunUuid through every scan entry point: the web per-command commandWrapper and both manual performScan() patches, into performScanCli(..., hookRunUuid) -> _getParamsForAppAccessibility, which emits thHookRunUuid (dropped when undefined, so in-test and lifecycle scans are unchanged). Matches SeleniumHub #14162 appAllyScan -> hook_run_uuid. - Add 6 unit tests for the hook-scan logic in the CLI module. Verified end-to-end on real BrowserStack sessions (web + Android App Automate): scans fired inside before/beforeEach/afterEach carry the correct per-hook uuid; test-body and end-of-test lifecycle scans carry none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3fe6561 commit 62c6f6e

3 files changed

Lines changed: 159 additions & 6 deletions

File tree

packages/browserstack-service/src/cli/modules/accessibilityModule.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import PerformanceTester from '../../instrumentation/performance/performance-tes
1818
import * as PERFORMANCE_SDK_EVENTS from '../../instrumentation/performance/constants.js'
1919
import type { FetchDriverExecuteParamsEventRequest, FetchDriverExecuteParamsEventResponse } from '../../grpc/index.js'
2020
import { GrpcClient } from '../grpcClient.js'
21+
import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js'
2122

2223
export default class AccessibilityModule extends BaseModule {
2324

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

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

67+
async onHookStart(args: Record<string, unknown>) {
68+
try {
69+
const testInstance: TestFrameworkInstance = (args?.instance as TestFrameworkInstance) || TestFramework.getTrackedInstance()
70+
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
71+
if (!testInstance || !autoInstance) {
72+
return
73+
}
74+
const sessionId = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID)
75+
// KEY_HOOK_ID is stamped on the instance at hook PRE (wdioMochaTestFramework.trackEvent)
76+
// and is the SAME uuid reported to TestHub as the hook run, so a scan tagged with it can
77+
// be self-joined onto the wrapping test in BTCER.
78+
const hookRunUuid = TestFramework.getState(testInstance, TestFrameworkConstants.KEY_HOOK_ID) as string | undefined
79+
this.currentHookRunUuid = hookRunUuid || null
80+
81+
if (!this.accessibility) {
82+
return
83+
}
84+
// Open the scan gate for the hook window so DOM-changing commands issued inside
85+
// before/beforeEach/afterEach/after hooks trigger scans (web per-command path). The
86+
// following onBeforeTest re-computes the per-test gate, so this only affects the hook.
87+
if (this.autoScanning && sessionId !== undefined && sessionId !== null) {
88+
this.accessibilityMap.set(sessionId, true)
89+
}
90+
} catch (error) {
91+
this.logger.error(`Exception in accessibility onHookStart: ${error}`)
92+
}
93+
}
94+
95+
async onHookEnd() {
96+
// Hook finished: subsequent (test-body) scans must not be stamped with the hook uuid.
97+
this.currentHookRunUuid = null
98+
}
99+
55100
async onBeforeExecute() {
56101
try {
57102
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
@@ -116,7 +161,8 @@ export default class AccessibilityModule extends BaseModule {
116161
if (!this.accessibility && !this.isAppAccessibility){
117162
return
118163
}
119-
return await this.performScanCli(browser)
164+
// If invoked from inside a hook, currentHookRunUuid stamps the scan for the hook.
165+
return await this.performScanCli(browser, undefined, this.currentHookRunUuid)
120166
}
121167

122168
(browser as WebdriverIO.Browser).startA11yScanning = async () => {
@@ -174,7 +220,7 @@ export default class AccessibilityModule extends BaseModule {
174220
!this.shouldPatchExecuteScript(args.length ? args[0] as string : null)
175221
) {
176222
try {
177-
await this.performScanCli(browser, command.name)
223+
await this.performScanCli(browser, command.name, this.currentHookRunUuid)
178224
this.logger.debug(`Accessibility scan performed after ${command.name} command`)
179225
} catch (scanError) {
180226
this.logger.debug(`Error performing accessibility scan after ${command.name}: ${scanError}`)
@@ -244,7 +290,7 @@ export default class AccessibilityModule extends BaseModule {
244290
if (!this.accessibility && !this.isAppAccessibility){
245291
return
246292
}
247-
const results = await this.performScanCli(browser)
293+
const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid)
248294
if (results){
249295
const testIdentifier = String(testInstance.getContext().getId())
250296
this.testMetadata[testIdentifier] = {
@@ -387,7 +433,8 @@ export default class AccessibilityModule extends BaseModule {
387433

388434
private async performScanCli(
389435
browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
390-
commandName?: string
436+
commandName?: string,
437+
hookRunUuid?: string | null
391438
): Promise<Record<string, unknown> | undefined> {
392439
return await PerformanceTester.measureWrapper(
393440
PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN,
@@ -400,7 +447,7 @@ export default class AccessibilityModule extends BaseModule {
400447
if (this.isAppAccessibility) {
401448
const testName=this.currentTestName || undefined
402449
const results: unknown = await (browser as WebdriverIO.Browser).execute(
403-
formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName))) as string,
450+
formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid))) as string,
404451
{}
405452
)
406453
BStackLogger.debug(util.format(results as string))

packages/browserstack-service/tests/accessibility-handler.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,45 @@ describe('beforeHook / afterHook (hook scans)', () => {
468468
await jasmineHandler.beforeHook({ title: '"before each" hook' } as any, {}, 'hook-uuid-3')
469469
expect(jasmineHandler['_currentHookRunUuid']).toBeNull()
470470
})
471+
472+
it('actually PASSES the hook uuid to performA11yScan for a scan fired inside a hook', async () => {
473+
vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true)
474+
const scanSpy = vi.spyOn(utils, 'performA11yScan').mockResolvedValue(undefined)
475+
// enter a hook window -> sets the scan gate + _currentHookRunUuid
476+
await accessibilityHandler.beforeHook(
477+
{ title: '"before each" hook', parent: 'suite' } as any,
478+
{ currentTest: { parent: 'suite', title: 'test' } },
479+
'hook-uuid-42'
480+
)
481+
// simulate a wrapped DOM-changing command running inside the hook
482+
const orig = vi.fn().mockResolvedValue('ok')
483+
await accessibilityHandler['commandWrapper']({ name: 'click', class: 'Element' } as any, undefined as any, orig, 'arg')
484+
expect(scanSpy).toHaveBeenCalled()
485+
const lastCall = scanSpy.mock.calls[scanSpy.mock.calls.length - 1]
486+
// performA11yScan(isAppAutomate, browser, isBS, isA11y, commandName, testName, hookRunUuid)
487+
expect(lastCall[lastCall.length - 1]).toBe('hook-uuid-42')
488+
})
489+
490+
it('does NOT stamp a hook uuid on a test-body scan (afterHook cleared it)', async () => {
491+
vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true)
492+
const scanSpy = vi.spyOn(utils, 'performA11yScan').mockResolvedValue(undefined)
493+
await accessibilityHandler.beforeHook(
494+
{ title: '"before each" hook', parent: 'suite' } as any,
495+
{ currentTest: { parent: 'suite', title: 'test' } },
496+
'hook-uuid-77'
497+
)
498+
await accessibilityHandler.afterHook() // hook ends -> uuid cleared, gate remains on
499+
const orig = vi.fn().mockResolvedValue('ok')
500+
await accessibilityHandler['commandWrapper']({ name: 'click', class: 'Element' } as any, undefined as any, orig, 'arg')
501+
expect(scanSpy).toHaveBeenCalled()
502+
const lastCall = scanSpy.mock.calls[scanSpy.mock.calls.length - 1]
503+
expect(lastCall[lastCall.length - 1]).toBeNull()
504+
})
505+
506+
it('_getParamsForAppAccessibility puts the hook uuid on the scan payload as thHookRunUuid', () => {
507+
expect(utils._getParamsForAppAccessibility('click', 'testName', 'hook-uuid-9').thHookRunUuid).toBe('hook-uuid-9')
508+
expect(utils._getParamsForAppAccessibility('click', 'testName').thHookRunUuid).toBeUndefined()
509+
})
471510
})
472511

473512
describe('beforeTest', () => {

packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ vi.mock('../../../src/cli/grpcClient.js', () => ({
5151
}))
5252

5353
import AccessibilityModule from '../../../src/cli/modules/accessibilityModule.js'
54-
import { validateCapsWithA11y, validateCapsWithAppA11y } from '../../../src/util.js'
54+
import { validateCapsWithA11y, validateCapsWithAppA11y, _getParamsForAppAccessibility } from '../../../src/util.js'
5555
import accessibilityScripts from '../../../src/scripts/accessibility-scripts.js'
5656
import TestFramework from '../../../src/cli/frameworks/testFramework.js'
5757
import AutomationFramework from '../../../src/cli/frameworks/automationFramework.js'
@@ -443,4 +443,71 @@ describe('AccessibilityModule', () => {
443443
)
444444
})
445445
})
446+
447+
// APPA11Y-5542: scans fired inside test hooks must carry the hook's run uuid
448+
// (thHookRunUuid) so the backend (SeleniumHub appAllyScan -> hook_run_uuid) can
449+
// reconcile them onto the wrapping test. onHookStart captures the uuid + opens the
450+
// scan gate for the hook window; onHookEnd clears it so test-body scans are unstamped.
451+
describe('onHookStart / onHookEnd (hook scans)', () => {
452+
it('registers hook observers for every hook lifecycle state (PRE + POST)', () => {
453+
for (const state of [
454+
TestFrameworkState.BEFORE_ALL,
455+
TestFrameworkState.BEFORE_EACH,
456+
TestFrameworkState.AFTER_EACH,
457+
TestFrameworkState.AFTER_ALL
458+
]) {
459+
expect(TestFramework.registerObserver).toHaveBeenCalledWith(state, HookState.PRE, expect.any(Function))
460+
expect(TestFramework.registerObserver).toHaveBeenCalledWith(state, HookState.POST, expect.any(Function))
461+
}
462+
})
463+
464+
it('captures the hook run uuid and opens the scan gate at hook start', async () => {
465+
vi.mocked(TestFramework.getState).mockReturnValue('hook-uuid-123')
466+
vi.mocked(AutomationFramework.getState).mockImplementation((instance: any, key: string) =>
467+
(key.includes('session_id') ? 12345 : {}) as any)
468+
469+
await accessibilityModule.onHookStart({ instance: mockTestInstance } as any)
470+
471+
expect(accessibilityModule.currentHookRunUuid).toBe('hook-uuid-123')
472+
expect(accessibilityModule.accessibilityMap.get(12345)).toBe(true)
473+
})
474+
475+
it('does not open the scan gate when accessibility is disabled', async () => {
476+
accessibilityModule.accessibility = false
477+
vi.mocked(TestFramework.getState).mockReturnValue('hook-uuid-123')
478+
479+
await accessibilityModule.onHookStart({ instance: mockTestInstance } as any)
480+
481+
expect(accessibilityModule.currentHookRunUuid).toBe('hook-uuid-123')
482+
expect(accessibilityModule.accessibilityMap.get(12345)).toBeUndefined()
483+
})
484+
485+
it('clears the hook run uuid at hook end so later test-body scans are unstamped', async () => {
486+
accessibilityModule.currentHookRunUuid = 'hook-uuid-123'
487+
488+
await accessibilityModule.onHookEnd()
489+
490+
expect(accessibilityModule.currentHookRunUuid).toBeNull()
491+
})
492+
493+
it('threads the hook run uuid into the app scan payload (thHookRunUuid)', async () => {
494+
accessibilityModule.accessibility = true
495+
accessibilityModule.isAppAccessibility = true
496+
mockBrowser.execute.mockResolvedValue({ scanned: true })
497+
498+
await (accessibilityModule as any).performScanCli(mockBrowser, 'click', 'hook-uuid-99')
499+
500+
expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, 'hook-uuid-99')
501+
})
502+
503+
it('passes no hook uuid for an ordinary (non-hook) app scan', async () => {
504+
accessibilityModule.accessibility = true
505+
accessibilityModule.isAppAccessibility = true
506+
mockBrowser.execute.mockResolvedValue({ scanned: true })
507+
508+
await (accessibilityModule as any).performScanCli(mockBrowser, 'click')
509+
510+
expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, undefined)
511+
})
512+
})
446513
})

0 commit comments

Comments
 (0)