Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/pr-176.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": minor
---

- Accessibility scans now run for driver commands issued from your WDIO config's hooks — `before()` and `beforeSuite` before the run starts, and `afterSuite`/`after` during teardown — so screens visited outside your tests are covered.
- `browser.performScan()` called from a config hook is no longer attributed to a test that has not started.
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 @@ -84,6 +84,15 @@ class _AccessibilityHandler {
* cucumber goes through beforeScenario/afterScenario instead.
*/
private static readonly TEST_HOOK_FRAMEWORKS = ['mocha', 'jasmine']
// Frameworks whose config-level hooks are covered by the pre-test window. Jasmine is
// excluded deliberately — App Accessibility is not supported there and its behaviour must
// not change; multiremote is excluded in the guard below, having no session id to gate on.
private static readonly PRE_TEST_SCAN_FRAMEWORKS = ['mocha', 'cucumber']

// Latched at the first framework hook or test of the session and never reset. Before it, a
// scan can only have come from a WDIO config hook; after it everything behaves as it always
// has, so nothing downstream of the first test changes.
private _testContextSeen = false
private _platformA11yMeta: PlatformA11yMeta
private _caps: Capabilities.ResolvedTestrunnerCapabilities
private _suiteFile?: string
Expand Down Expand Up @@ -243,7 +252,9 @@ class _AccessibilityHandler {
}

browserWithA11y.performScan = async () => {
const results = await performA11yScan(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
// Same parentage rule as the auto path, and the hook uuid the manual path never carried
// — a manual scan inside a framework hook used to land as a NULL hook row.
const results = await performA11yScan(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, undefined, undefined, this._currentHookRunUuid, this.hasNoParent)
if (results) {
this._testMetadata[this._testIdentifier as string] = {
scanTestForAccessibility : true,
Expand Down Expand Up @@ -282,6 +293,19 @@ class _AccessibilityHandler {
if (!this._accessibility) {
return
}

// WDIO's config-level hooks run before any test exists, so the per-test gate below has
// not been computed yet and driver commands issued there went unscanned. Every other
// validation still applies — an a11y-capable session (returned above), autoScanning, a
// supported framework, a real session id. The include/exclude tag filter is the one
// exception: it matches on suite and test titles, and neither exists yet.
//
// The framework allowlist is about SCANNING, not about attribution: App Accessibility is
// not supported on jasmine, so it must gain no scans it did not have before.
if (this._autoScanning && this.supportsPreTestWindow() && sessionId) {
Comment thread
kamal-kaur04 marked this conversation as resolved.
AccessibilityHandler._a11yScanSessionMap[sessionId] = true
BStackLogger.debug('Accessibility scan gate opened ahead of the first test')
}
if (!('overwriteCommand' in this._browser && Array.isArray(accessibilityScripts.commandsToWrap))) {
return
}
Expand All @@ -304,8 +328,20 @@ class _AccessibilityHandler {

}

// Nothing can own a scan before the framework has started anything. Defined once: the auto
// path and the user-facing performScan() must not answer this differently.
private get hasNoParent(): boolean {
return !this._currentHookRunUuid && !this._testContextSeen
}

private supportsPreTestWindow(): boolean {
return AccessibilityHandler.PRE_TEST_SCAN_FRAMEWORKS.includes(this._framework as string) &&
!this._browser?.isMultiremote
}

async beforeTest (suiteTitle: string | undefined, test: Frameworks.Test) {
try {
this._testContextSeen = true
if (
!AccessibilityHandler.TEST_HOOK_FRAMEWORKS.includes(this._framework as string) ||
!this.shouldRunTestHooks(this._browser, this._accessibility)
Expand Down Expand Up @@ -385,6 +421,7 @@ class _AccessibilityHandler {
* Cucumber Only
*/
async beforeScenario (world: ITestCaseHookParameter) {
this._testContextSeen = true
const pickleData = world.pickle
const gherkinDocument = world.gherkinDocument
const featureData = gherkinDocument.feature
Expand Down Expand Up @@ -465,6 +502,7 @@ class _AccessibilityHandler {
*/
async beforeHook (test: Frameworks.Test | undefined, context: unknown, hookRunUuid?: string | null) {
try {
this._testContextSeen = true
if (!this._accessibility || !this.shouldRunTestHooks(this._browser, this._accessibility)) {
return
}
Expand Down Expand Up @@ -513,7 +551,16 @@ class _AccessibilityHandler {
)
) {
BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`)
await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name, undefined, this._currentHookRunUuid)
// Parentless only before the framework has started anything: no hook run to own the
// scan and no test seen yet in this session. Once either has happened the latch stays
// set, so every later hook keeps the attribution it has always had.
// See the CLI module: the gate outlives the session now, and a scan attempted after
// the session is gone logs an error where main was silent.
if (!(this._browser as WebdriverIO.Browser)?.sessionId) {
BStackLogger.debug('Skipping accessibility scan: the session has ended')
} else {
await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name, undefined, this._currentHookRunUuid, this.hasNoParent)
}
} 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 @@ -27,6 +27,8 @@ export default class AccessibilityModule extends BaseModule {
scriptInstance: typeof accessibilityScripts
accessibility: boolean = false
autoScanning: boolean = true
// Latched at the first framework hook or test and never reset — see the classic handler.
testContextSeen: boolean = false
isAppAccessibility: boolean
isNonBstackA11y: boolean
accessibilityConfig: Accessibility
Expand Down Expand Up @@ -76,6 +78,7 @@ export default class AccessibilityModule extends BaseModule {
// 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
this.testContextSeen = true

const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
if (!testInstance || !autoInstance) {
Expand Down Expand Up @@ -216,7 +219,7 @@ export default class AccessibilityModule extends BaseModule {
return
}
// If invoked from inside a hook, currentHookRunUuid stamps the scan for the hook.
return await this.performScanCli(browser, undefined, this.currentHookRunUuid)
return await this.performScanCli(browser, undefined, this.currentHookRunUuid, this.hasNoParent)
}

(browser as WebdriverIO.Browser).startA11yScanning = async () => {
Expand Down Expand Up @@ -263,6 +266,19 @@ export default class AccessibilityModule extends BaseModule {
})
}

// WDIO's config-level hooks (before, beforeSuite) run before any test or framework
// hook exists, so onHookStart — which returns early without a test instance — cannot
// cover them, and driver commands issued there went unscanned. Every validation
// onHookStart applies still applies here: an a11y-capable session (returned above)
// and autoScanning. The include/exclude tag filter is the one exception — it matches
// on suite and test titles, and in this window neither exists yet. onBeforeTest
// re-computes the per-test gate, tags included, so this only affects the window.
const preTestSessionId = this.currentSessionId()
if (this.autoScanning && preTestSessionId !== undefined && preTestSessionId !== null) {
Comment thread
kamal-kaur04 marked this conversation as resolved.
this.accessibilityMap.set(preTestSessionId, true)
this.logger.debug('Accessibility scan gate opened ahead of the first test')
}

} catch (error) {
this.logger.error(`Error in onBeforeExecute: ${error}`)
}
Expand All @@ -276,13 +292,25 @@ export default class AccessibilityModule extends BaseModule {
if (sessionId && this.accessibilityMap.get(sessionId)) {
const browser = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser

// The gate now outlives the last test, so a command issued once the session has
// been deleted (afterSession) would reach the scan and fail with "A sessionId is
// required for this command" — an error log where there used to be silence. The
// driver is the only thing that knows: no sessionId, no session.
if (!browser?.sessionId) {
this.logger.debug('Skipping accessibility scan: the session has ended')
return await originFunction(...args)
}

// Perform accessibility scan before command if script is available
if (
!command.name.includes('execute') ||
!this.shouldPatchExecuteScript(args.length ? args[0] as string : null)
) {
try {
await this.performScanCli(browser, command.name, this.currentHookRunUuid)
// Parentless only before the framework has started anything: no hook run
// and no test seen yet. The wrapper never knows which hook it is in, and
// does not need to.
await this.performScanCli(browser, command.name, this.currentHookRunUuid, this.hasNoParent)
this.logger.debug(`Accessibility scan performed after ${command.name} command`)
} catch (scanError) {
this.logger.debug(`Error performing accessibility scan after ${command.name}: ${scanError}`)
Expand All @@ -309,6 +337,7 @@ export default class AccessibilityModule extends BaseModule {
const test = (args.test && typeof args.test === 'object' ? args.test as { title?: string } : {}) || {}

this.currentTestName = test.title || null
this.testContextSeen = true
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()

Expand Down Expand Up @@ -354,7 +383,7 @@ export default class AccessibilityModule extends BaseModule {
if (!this.accessibility && !this.isAppAccessibility){
return
}
const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid)
const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid, this.hasNoParent)
if (results){
const testIdentifier = String(testInstance.getContext().getId())
this.testMetadata[testIdentifier] = {
Expand Down Expand Up @@ -391,7 +420,6 @@ export default class AccessibilityModule extends BaseModule {

const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()
const sessionId = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID)

if (!autoInstance || !testInstance) {
this.logger.error('No tracked instances found for accessibility after test')
Expand Down Expand Up @@ -435,7 +463,11 @@ export default class AccessibilityModule extends BaseModule {
} else {
this.logger.warn('No driver found to send accessibility test stop event')
}
this.accessibilityMap.delete(sessionId)
// The gate deliberately stays as the test left it. Deleting it here stopped every
Comment thread
kamal-kaur04 marked this conversation as resolved.
// scan between tests — afterSuite, after, and the next suite's beforeSuite all went
// unscanned on this flow, while the classic flow (which never deleted) scanned them.
// Leaving the entry rather than forcing it true keeps a tag-excluded test and a
// user's stopA11yScanning() in effect.

// Clean up test metadata
TestFramework.setState(testInstance, `accessibility_metadata_${testIdentifier}`, null)
Expand Down Expand Up @@ -495,10 +527,16 @@ export default class AccessibilityModule extends BaseModule {
return false
}

// See the classic handler: one definition, used at every scan site.
private get hasNoParent(): boolean {
return !this.currentHookRunUuid && !this.testContextSeen
}

private async performScanCli(
browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
commandName?: string,
hookRunUuid?: string | null
hookRunUuid?: string | null,
isGlobalHook?: boolean
): Promise<Record<string, unknown> | undefined> {
return await PerformanceTester.measureWrapper(
PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN,
Expand All @@ -511,7 +549,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, hookRunUuid))) as string,
formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid, isGlobalHook))) as string,
{}
)
BStackLogger.debug(util.format(results as string))
Expand Down
11 changes: 7 additions & 4 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,9 +595,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, hookRunUuid?: string | null ): { thTestRunUuid: any, thHookRunUuid: 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, isGlobalHook?: boolean ): { 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,
// A scan from a WDIO config-level hook belongs to no test. TEST_ANALYTICS_ID in that
// window holds a uuid the framework minted at instance creation — a test that has not
// started — so sending it would attribute the scan to a test it did not come from.
'thTestRunUuid': isGlobalHook ? undefined : 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,
Expand All @@ -611,7 +614,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, hookRunUuid?: string | null,) : 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, isGlobalHook?: boolean,) : Promise<{ [key: string]: any; } | undefined> => {

if (!isAccessibilityAutomationSession(isAccessibility)) {
BStackLogger.warn('Not an Accessibility Automation session, cannot perform Accessibility scan.')
Expand All @@ -620,7 +623,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, hookRunUuid))) as string, {})
const results: unknown = await (browser as WebdriverIO.Browser).execute(formatString(AccessibilityScripts.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid, isGlobalHook))) as string, {})
BStackLogger.debug(util.format(results as string))
return ( results as { [key: string]: any; } | undefined )
}
Expand Down
Loading
Loading