-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathaccessibility-handler.ts
More file actions
699 lines (613 loc) · 32.2 KB
/
Copy pathaccessibility-handler.ts
File metadata and controls
699 lines (613 loc) · 32.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
import util from 'node:util'
import type { Capabilities, Frameworks, Options } from '@wdio/types'
import type { BrowserstackConfig, BrowserstackOptions } from './types.js'
import type { ITestCaseHookParameter } from './cucumber-types.js'
import Listener from './testOps/listener.js'
// Define better types for accessibility
interface PlatformA11yMeta {
browser_name?: string
browser_version?: string
device?: string
platform?: string
[key: string]: unknown
}
interface AccessibilityOptions {
includeIssueType?: string[]
excludeIssueType?: string[]
[key: string]: unknown
}
interface TestMetadata {
[testId: string]: {
scanTestForAccessibility?: boolean
accessibilityScanStarted?: boolean
[key: string]: unknown
}
}
interface A11yScanSessionMap {
[sessionId: string]: boolean
}
interface CommandInfo {
name: string
class?: string
[key: string]: unknown
}
interface TestExtensionData {
thTestRunUuid?: string
thBuildUuid?: string
thJwtToken?: string
[key: string]: unknown
}
import {
getA11yResultsSummary,
getAppA11yResultsSummary,
getA11yResults,
performA11yScan,
getUniqueIdentifier,
getUniqueIdentifierForCucumber,
isAccessibilityAutomationSession,
isAppAccessibilityAutomationSession,
isBrowserstackSession,
o11yClassErrorHandler,
shouldScanTestForAccessibility,
validateCapsWithA11y,
shouldAddServiceVersion,
validateCapsWithNonBstackA11y,
isTrue,
validateCapsWithAppA11y,
getAppA11yResults,
executeAccessibilityScript,
isFalse,
getHookType,
frameworkSupportsHook
} from './util.js'
import accessibilityScripts from './scripts/accessibility-scripts.js'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js'
import { BStackLogger } from './bstackLogger.js'
class _AccessibilityHandler {
/**
* Frameworks whose per-test lifecycle flows through beforeTest/afterTest.
* WDIO's jasmine adapter emits the same service hooks as mocha (SDK-7190);
* 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
private _accessibility?: boolean
private _turboscale?: boolean
private _options: BrowserstackConfig & BrowserstackOptions
private _config: Options.Testrunner
private _accessibilityOptions?: AccessibilityOptions
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()
constructor (
private _browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
_capabilities: Capabilities.ResolvedTestrunnerCapabilities,
_options : BrowserstackConfig & BrowserstackOptions,
private isAppAutomate: boolean,
_config : Options.Testrunner,
private _framework?: string,
_accessibilityAutomation?: boolean | string,
_turboscale?: boolean | string,
_accessibilityOpts?: AccessibilityOptions
) {
const caps = (this._browser as WebdriverIO.Browser).capabilities as WebdriverIO.Capabilities
this._platformA11yMeta = {
browser_name: caps?.browserName,
// @ts-expect-error invalid caps property
browser_version: caps?.browserVersion || (caps as WebdriverIO.Capabilities)?.version || 'latest',
platform_name: caps?.platformName,
platform_version: this._getCapabilityValue(caps, 'appium:platformVersion', 'platformVersion'),
os_name: this._getCapabilityValue(_capabilities, 'os', 'os'),
os_version: this._getCapabilityValue(_capabilities, 'osVersion', 'os_version')
}
this._caps = _capabilities
this._accessibility = isTrue(_accessibilityAutomation)
this._accessibilityOptions = _accessibilityOpts
this._autoScanning = !isFalse(this._accessibilityOptions?.autoScanning)
this._options = _options
this._config= _config
this._turboscale = isTrue(_turboscale)
}
setSuiteFile(filename: string) {
this._suiteFile = filename
}
_getCapabilityValue(caps: Capabilities.ResolvedTestrunnerCapabilities, capType: string, legacyCapType: string) {
if (caps) {
if (capType === 'accessibility') {
if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (isTrue((caps as WebdriverIO.Capabilities)['bstack:options']?.accessibility))) {
return (caps as WebdriverIO.Capabilities)['bstack:options']?.accessibility
} else if (isTrue((caps as WebdriverIO.Capabilities)['browserstack.accessibility'])) {
return (caps as WebdriverIO.Capabilities)['browserstack.accessibility']
}
} else if (capType === 'deviceName') {
if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (caps as WebdriverIO.Capabilities)['bstack:options']?.deviceName) {
return (caps as WebdriverIO.Capabilities)['bstack:options']?.deviceName
} else if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (caps as WebdriverIO.Capabilities)['bstack:options']?.device) {
return (caps as WebdriverIO.Capabilities)['bstack:options']?.device
} else if ((caps as WebdriverIO.Capabilities)['appium:deviceName']) {
return (caps as WebdriverIO.Capabilities)['appium:deviceName']
}
} else if (capType === 'goog:chromeOptions' && (caps as WebdriverIO.Capabilities)['goog:chromeOptions']) {
return (caps as WebdriverIO.Capabilities)['goog:chromeOptions']
} else {
const bstackOptions = (caps as WebdriverIO.Capabilities)['bstack:options']
if ( bstackOptions && bstackOptions?.[capType as keyof Capabilities.BrowserStackCapabilities]) {
return bstackOptions?.[capType as keyof Capabilities.BrowserStackCapabilities]
} else if ((caps as WebdriverIO.Capabilities)[legacyCapType as keyof WebdriverIO.Capabilities]) {
return (caps as WebdriverIO.Capabilities)[legacyCapType as keyof WebdriverIO.Capabilities]
}
}
}
}
/**
* A reload replaces the session id under a driver object that otherwise carries on
* unchanged. `_sessionId` is captured once in `before()`, so without this every later
* scan-gate lookup and every results call keeps addressing the session that already ended.
*/
onSessionReload(oldSessionId: string, newSessionId: string) {
try {
if (!newSessionId || oldSessionId === newSessionId) {
return
}
if (oldSessionId in AccessibilityHandler._a11yScanSessionMap) {
AccessibilityHandler._a11yScanSessionMap[newSessionId] = AccessibilityHandler._a11yScanSessionMap[oldSessionId]
delete AccessibilityHandler._a11yScanSessionMap[oldSessionId]
}
if (this._sessionId === oldSessionId) {
this._sessionId = newSessionId
}
} catch (error) {
BStackLogger.debug(`Exception while migrating accessibility state across session reload: ${error}`)
}
}
async before(sessionId: string) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.CONFIG_EVENTS.ACCESSIBILITY)
this._sessionId = sessionId
this._accessibility = isTrue(this._getCapabilityValue(this._caps, 'accessibility', 'browserstack.accessibility'))
//checks for running ALLY on non-bstack infra
if (
isAccessibilityAutomationSession(this._accessibility) &&
(
this._turboscale ||
!shouldAddServiceVersion(this._config, this._options.testObservability)
) &&
validateCapsWithNonBstackA11y(
this._platformA11yMeta.browser_name as string,
this._platformA11yMeta?.browser_version as string
)
){
this._accessibility = true
} else {
if (isAccessibilityAutomationSession(this._accessibility) && !this.isAppAutomate) {
const deviceName = this._getCapabilityValue(this._caps, 'deviceName', 'device')
const chromeOptions = this._getCapabilityValue(this._caps, 'goog:chromeOptions', '') as Capabilities.ChromeOptions
this._accessibility = validateCapsWithA11y(deviceName as string, this._platformA11yMeta as unknown as Record<string, string>, chromeOptions)
}
if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
this._accessibility = validateCapsWithAppA11y(this._platformA11yMeta)
}
}
// Safely add accessibility methods to browser instance with proper typing
const browserWithA11y = this._browser as WebdriverIO.Browser & {
getAccessibilityResultsSummary: () => Promise<Record<string, unknown>>,
getAccessibilityResults: () => Promise<Array<Record<string, unknown>>>,
performScan: () => Promise<Record<string, unknown> | undefined>,
startA11yScanning: () => Promise<void>,
stopA11yScanning: () => Promise<void>
}
browserWithA11y.getAccessibilityResultsSummary = async () => {
if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
return await getAppA11yResultsSummary(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, this._sessionId)
}
return await getA11yResultsSummary(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
}
browserWithA11y.getAccessibilityResults = async () => {
if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
return await getAppA11yResults(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, this._sessionId)
}
return await getA11yResults(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
}
browserWithA11y.performScan = async () => {
// 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,
accessibilityScanStarted : true
}
}
await this._setAnnotation('Accessibility scanning was triggered manually')
return results
}
browserWithA11y.startA11yScanning = async () => {
if (this._testIdentifier === null){
BStackLogger.warn('Accessibility scanning cannot be started from outside the test')
return
}
// `this._sessionId` rather than the captured `sessionId`: commandWrapper reads the
// former, and a reload moves it, so writing the captured id would leave the user's
// start/stop addressing a session that has ended.
AccessibilityHandler._a11yScanSessionMap[this._sessionId ?? sessionId] = true
this._testMetadata[this._testIdentifier as string] = {
scanTestForAccessibility : true,
accessibilityScanStarted : true
}
await this._setAnnotation('Accessibility scanning has started')
}
browserWithA11y.stopA11yScanning = async () => {
if (this._testIdentifier === null){
BStackLogger.warn('Accessibility scanning cannot be stopped from outside the test')
return
}
AccessibilityHandler._a11yScanSessionMap[this._sessionId ?? sessionId] = false
await this._setAnnotation('Accessibility scanning has stopped')
}
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) {
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
}
accessibilityScripts.commandsToWrap
.filter((command) => command.name && command.class)
.forEach((command) => {
const browser = this._browser as WebdriverIO.Browser
try {
// element commands aren't on browser; use orig when present, otherwise rely on overwriteCommand's origFunction
const orig = browser[command.name as keyof WebdriverIO.Browser]
const prevImpl = orig ? orig.bind(browser) : undefined
// @ts-expect-error fix type
browser.overwriteCommand(command.name, this.commandWrapper.bind(this, command, prevImpl), command.class === 'Element')
} catch (error) {
BStackLogger.debug(`Exception in overwrite command ${command.name} - ${error}`)
}
})
PerformanceTester.end(PERFORMANCE_SDK_EVENTS.CONFIG_EVENTS.ACCESSIBILITY)
}
// 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)
) {
/* This is to be used when test events are sent */
Listener.setTestRunAccessibilityVar(false)
return
}
/* jasmine test objects carry the spec name in `description` (`title` is unset) */
const testTitle = test.title ?? test.description
// @ts-expect-error fix type
const shouldScanTest = this._autoScanning && shouldScanTestForAccessibility(suiteTitle, testTitle, this._accessibilityOptions)
const testIdentifier = this.getIdentifier(test)
this._testIdentifier = testIdentifier
if (this._sessionId) {
/* For case with multiple tests under one browser, before hook of 2nd test should change this map value */
AccessibilityHandler._a11yScanSessionMap[this._sessionId] = shouldScanTest
}
/* This is to be used when test events are sent */
Listener.setTestRunAccessibilityVar(this._accessibility && shouldScanTest)
this._testMetadata[testIdentifier] = {
scanTestForAccessibility : shouldScanTest,
accessibilityScanStarted : true
}
this._testMetadata[testIdentifier].accessibilityScanStarted = shouldScanTest
if (shouldScanTest) {
BStackLogger.info('Automate test case execution has started.')
}
} catch (error) {
BStackLogger.error(`Exception in starting accessibility automation scan for this test case ${error}`)
}
}
async afterTest (suiteTitle: string | undefined, test: Frameworks.Test) {
BStackLogger.debug('Accessibility after test hook. Before sending test stop event')
if (
!AccessibilityHandler.TEST_HOOK_FRAMEWORKS.includes(this._framework as string) ||
!this.shouldRunTestHooks(this._browser, this._accessibility)
) {
return
}
try {
const testIdentifier = this.getIdentifier(test)
const accessibilityScanStarted = this._testMetadata[testIdentifier]?.accessibilityScanStarted
const shouldScanTestForAccessibility = this._testMetadata[testIdentifier]?.scanTestForAccessibility
if (!accessibilityScanStarted) {
return
}
if (shouldScanTestForAccessibility) {
BStackLogger.info('Automate test case execution has ended. Processing for accessibility testing is underway. ')
const dataForExtension = {
'thTestRunUuid': process.env.TEST_ANALYTICS_ID,
'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID,
'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT
}
await this.sendTestStopEvent((this._browser as WebdriverIO.Browser), dataForExtension)
BStackLogger.info('Accessibility testing for this test case has ended.')
}
} catch (error) {
BStackLogger.error(`Accessibility results could not be processed for the test case ${test.title}. Error : ${error}`)
}
}
/**
* Cucumber Only
*/
async beforeScenario (world: ITestCaseHookParameter) {
this._testContextSeen = true
const pickleData = world.pickle
const gherkinDocument = world.gherkinDocument
const featureData = gherkinDocument.feature
const uniqueId = getUniqueIdentifierForCucumber(world)
this._testIdentifier = uniqueId
if (!this.shouldRunTestHooks(this._browser, this._accessibility)) {
/* This is to be used when test events are sent */
Listener.setTestRunAccessibilityVar(false)
return
}
try {
// @ts-expect-error fix type
const shouldScanScenario = this._autoScanning && shouldScanTestForAccessibility(featureData?.name, pickleData.name, this._accessibilityOptions, world, true)
this._testMetadata[uniqueId] = {
scanTestForAccessibility : shouldScanScenario,
accessibilityScanStarted : true
}
this._testMetadata[uniqueId].accessibilityScanStarted = shouldScanScenario
if (this._sessionId) {
/* For case with multiple tests under one browser, before hook of 2nd test should change this map value */
AccessibilityHandler._a11yScanSessionMap[this._sessionId] = shouldScanScenario
}
/* This is to be used when test events are sent */
Listener.setTestRunAccessibilityVar(this._accessibility && shouldScanScenario)
if (shouldScanScenario) {
BStackLogger.info('Automate test case execution has started.')
}
} catch (error) {
BStackLogger.error(`Exception in starting accessibility automation scan for this test case ${error}`)
}
}
async afterScenario (world: ITestCaseHookParameter) {
BStackLogger.debug('Accessibility after scenario hook. Before sending test stop event')
if (!this.shouldRunTestHooks(this._browser, this._accessibility)) {
return
}
const pickleData = world.pickle
try {
const uniqueId = getUniqueIdentifierForCucumber(world)
const accessibilityScanStarted = this._testMetadata[uniqueId]?.accessibilityScanStarted
const shouldScanTestForAccessibility = this._testMetadata[uniqueId]?.scanTestForAccessibility
if (!accessibilityScanStarted) {
return
}
if (shouldScanTestForAccessibility) {
BStackLogger.info('Automate test case execution has ended. Processing for accessibility testing is underway. ')
const dataForExtension = {
'thTestRunUuid': process.env.TEST_ANALYTICS_ID,
'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID,
'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT
}
await this.sendTestStopEvent(( this._browser as WebdriverIO.Browser), dataForExtension)
BStackLogger.info('Accessibility testing for this test case has ended.')
}
} catch (error) {
BStackLogger.error(`Accessibility results could not be processed for the test case ${pickleData.name}. Error : ${error}`)
}
}
/**
* 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 {
this._testContextSeen = true
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
*/
private async commandWrapper (command: CommandInfo, prevImpl: Function, origFunction: Function, ...args: unknown[]) {
const skipScanForBidiWindowCommand = AccessibilityHandler.shouldSkipScanForBidiWindowCommand(this._browser, command)
if (
this._sessionId && AccessibilityHandler._a11yScanSessionMap[this._sessionId] &&
!skipScanForBidiWindowCommand &&
(
!command.name.includes('execute') ||
!AccessibilityHandler.shouldPatchExecuteScript(args.length ? args[0] as string : null)
)
) {
BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`)
// 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`)
}
const impl = prevImpl || origFunction
return impl(...args)
}
private async sendTestStopEvent(browser: WebdriverIO.Browser, dataForExtension: TestExtensionData) {
BStackLogger.debug('Performing scan before saving results')
if (AccessibilityHandler._a11yScanSessionMap[this._sessionId as string]) {
await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN, async () => {
await performA11yScan(this.isAppAutomate, browser, true, true)
}, { command: 'afterTest' })()
}
if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
return
}
await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.SAVE_RESULTS, async () => {
if (accessibilityScripts.saveTestResults) {
const results: unknown = await executeAccessibilityScript(browser, accessibilityScripts.saveTestResults, dataForExtension)
BStackLogger.debug(util.format(results as string))
} else {
BStackLogger.error('saveTestResults script is null or undefined')
}
})()
}
private getIdentifier (test: Frameworks.Test | ITestCaseHookParameter) {
if ('pickle' in test) {
return getUniqueIdentifierForCucumber(test)
}
return getUniqueIdentifier(test, this._framework)
}
private shouldRunTestHooks(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | null, isAccessibility?: boolean | string) {
if (!browser) {
return false
}
return isAccessibilityAutomationSession(isAccessibility)
}
private async checkIfPageOpened(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, testIdentifier: string, shouldScanTest?: boolean) {
let pageOpen = false
this._testMetadata[testIdentifier] = {
scanTestForAccessibility : shouldScanTest,
accessibilityScanStarted : true
}
try {
const currentURL = await (browser as WebdriverIO.Browser).getUrl()
const url = new URL(currentURL)
pageOpen = url?.protocol === 'http:' || url?.protocol === 'https:'
} catch {
pageOpen = false
}
return pageOpen
}
private static shouldPatchExecuteScript(script: string | null): boolean {
if (!script || typeof script !== 'string') {
return true
}
return (
script.toLowerCase().indexOf('browserstack_executor') !== -1 ||
script.toLowerCase().indexOf('browserstack_accessibility_automation_script') !== -1
)
}
/**
* SDK-5047: Window/context-management commands whose surrounding injected
* accessibility scan (a browser.execute) races the WebdriverIO v9 core
* ContextManager while it (re)binds the browsing context during
* session-start window churn on BiDi sessions (e.g. Chrome), surfacing
* "no such window: target window already closed / web view not found".
*/
private static readonly BIDI_WINDOW_CONTEXT_COMMANDS = new Set<string>([
'getWindowHandle', 'getWindowHandles', 'switchToWindow', 'switchWindow',
'newWindow', 'closeWindow', 'switchFrame', 'switchToFrame', 'switchToParentFrame'
])
/**
* BiDi detection that also covers MultiRemote: the aggregate multiremote
* object does not expose `isBidi` itself — the flag lives on each child
* instance — so the session counts as BiDi when the top-level flag is set
* OR any multiremote child instance reports `isBidi`.
*/
private static isBidiSession(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | undefined): boolean {
const b = browser as (WebdriverIO.Browser & { isBidi?: boolean, instances?: string[] }) | undefined
if (b?.isBidi === true) {
return true
}
if (Array.isArray(b?.instances)) {
const children = b as unknown as Record<string, { isBidi?: boolean } | undefined>
return b.instances.some((name) => children[name]?.isBidi === true)
}
return false
}
/**
* Returns true when the injected pre-command accessibility scan must be
* skipped: only on BiDi sessions, and only for window/context-management
* commands. Non-BiDi sessions and all other commands are unaffected and
* keep scanning exactly as before.
*/
private static shouldSkipScanForBidiWindowCommand(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | undefined, command: CommandInfo): boolean {
return Boolean(
command?.name &&
AccessibilityHandler.isBidiSession(browser) &&
AccessibilityHandler.BIDI_WINDOW_CONTEXT_COMMANDS.has(command.name)
)
}
private async _setAnnotation(message: string) {
if (this._accessibility && isBrowserstackSession(this._browser)) {
await (this._browser as WebdriverIO.Browser).executeScript(`browserstack_executor: ${JSON.stringify({
action: 'annotate',
arguments: {
data: message,
level: 'info'
}
})}`, [])
}
}
}
// https://github.com/microsoft/TypeScript/issues/6543
const AccessibilityHandler: typeof _AccessibilityHandler = o11yClassErrorHandler(_AccessibilityHandler)
type AccessibilityHandler = _AccessibilityHandler
export default AccessibilityHandler