diff --git a/.eslintrc b/.eslintrc index 09780ca..9ee7cbc 100644 --- a/.eslintrc +++ b/.eslintrc @@ -108,6 +108,8 @@ "by": "readonly", "expect": "readonly", "browser": "readonly", - "Key": "readonly" + "Key": "readonly", + "URLSearchParams": "readonly" + } } diff --git a/nightwatch/globals.js b/nightwatch/globals.js index ecc9e65..bd1f602 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -260,7 +260,7 @@ module.exports = { }); eventBroadcaster.on('TestRunStarted', async (test) => { - process.env.VALID_ALLY_PLATFORM = accessibilityAutomation.validateA11yCaps(browser); + process.env.VALID_ALLY_PLATFORM = process.env.BROWSERSTACK_APP_AUTOMATE ? accessibilityAutomation.validateAppA11yCaps(test.metadata.sessionCapabilities) : accessibilityAutomation.validateA11yCaps(browser); await accessibilityAutomation.beforeEachExecution(test); if (testRunner !== 'cucumber'){ const uuid = TestMap.storeTestDetails(test); @@ -357,7 +357,9 @@ module.exports = { if (helper.isAccessibilitySession() && !settings.parallel_mode) { accessibilityAutomation.setAccessibilityCapabilities(settings); accessibilityAutomation.commandWrapper(); - helper.patchBrowserTerminateCommand(); + if (!process.env.BROWSERSTACK_APP_AUTOMATE){ + helper.patchBrowserTerminateCommand(); + }; } } catch (err){ Logger.debug(`Exception while setting Accessibility Automation capabilities. Error ${err}`); @@ -489,8 +491,14 @@ module.exports = { }, async beforeEach(settings) { - browser.getAccessibilityResults = () => { return accessibilityAutomation.getAccessibilityResults() }; - browser.getAccessibilityResultsSummary = () => { return accessibilityAutomation.getAccessibilityResultsSummary() }; + if (helper.isAppAccessibilitySession()){ + browser.getAccessibilityResults = () => { return accessibilityAutomation.getAppAccessibilityResults(browser) }; + browser.getAccessibilityResultsSummary = () => { return accessibilityAutomation.getAppAccessibilityResultsSummary(browser) }; + } else { + browser.getAccessibilityResults = () => { return accessibilityAutomation.getAccessibilityResults() }; + browser.getAccessibilityResultsSummary = () => { return accessibilityAutomation.getAccessibilityResultsSummary() }; + } + // await accessibilityAutomation.beforeEachExecution(browser); }, // This will be run after each test suite is finished @@ -531,7 +539,9 @@ module.exports = { if (helper.isAccessibilitySession()) { accessibilityAutomation.setAccessibilityCapabilities(settings); accessibilityAutomation.commandWrapper(); - helper.patchBrowserTerminateCommand(); + if (!process.env.BROWSERSTACK_APP_AUTOMATE){ + helper.patchBrowserTerminateCommand(); + }; } } catch (err){ Logger.debug(`Exception while setting Accessibility Automation capabilities. Error ${err}`); diff --git a/src/accessibilityAutomation.js b/src/accessibilityAutomation.js index d8f75c1..7e914eb 100644 --- a/src/accessibilityAutomation.js +++ b/src/accessibilityAutomation.js @@ -1,6 +1,7 @@ const path = require('path'); const helper = require('./utils/helper'); const Logger = require('./utils/logger'); +const {APP_ALLY_ENDPOINT, APP_ALLY_ISSUES_SUMMARY_ENDPOINT, APP_ALLY_ISSUES_ENDPOINT} = require('./utils/constants'); const util = require('util'); const AccessibilityScripts = require('./scripts/accessibilityScripts'); @@ -162,6 +163,24 @@ class AccessibilityAutomation { return false; } + validateAppA11yCaps(capabilities = {}) { + /* Check if the current driver platform is eligible for AppAccessibility scan */ + if ( + capabilities?.platformName && + String(capabilities?.platformName).toLowerCase() === 'android' && + capabilities?.platformVersion && + parseInt(capabilities?.platformVersion?.toString()) < 11 + ) { + Logger.warn( + 'App Accessibility Automation tests are supported on OS version 11 and above for Android devices.' + ); + + return false; + } + + return true; + } + async beforeEachExecution(testMetaData) { try { this.currentTest = browser.currentTest; @@ -169,7 +188,13 @@ class AccessibilityAutomation { testMetaData ); this.currentTest.accessibilityScanStarted = true; - this._isAccessibilitySession = this.validateA11yCaps(browser); + + this._isAppAccessibility = helper.isAppAccessibilitySession(); + if (this._isAppAccessibility) { + this._isAccessibilitySession = this.validateAppA11yCaps(testMetaData.metadata.sessionCapabilities); + } else { + this._isAccessibilitySession = this.validateA11yCaps(browser); + } if (this.isAccessibilityAutomationSession() && browser && this._isAccessibilitySession) { try { @@ -267,10 +292,9 @@ class AccessibilityAutomation { } if (this.currentTest.shouldScanTestForAccessibility === false) { - Logger.info('Skipping Accessibility scan for this test as it\'s disabled.'); - return; } + try { const browser = browserInstance; @@ -279,6 +303,16 @@ class AccessibilityAutomation { return; } + + if (helper.isAppAccessibilitySession()){ + const results = await browser.executeScript( + helper.formatString(AccessibilityScripts.performScan, JSON.stringify(this.getParamsForAppAccessibility(commandName))), + {} + ); + Logger.debug(util.inspect(results)); + + return results; + } AccessibilityAutomation.pendingAllyReq++; const results = await browser.executeAsyncScript(AccessibilityScripts.performScan, { method: commandName || '' @@ -297,9 +331,79 @@ class AccessibilityAutomation { } } + async getAppAccessibilityResults(browser) { + if (!helper.isBrowserstackInfra()) { + return []; + } + + if (!helper.isAppAccessibilitySession()) { + Logger.warn('Not an Accessibility Automation session, cannot retrieve Accessibility results.'); + + return []; + } + + try { + const apiUrl = `${APP_ALLY_ENDPOINT}/${APP_ALLY_ISSUES_ENDPOINT}`; + const apiRespone = await this.getAppA11yResultResponse(apiUrl, browser, browser.sessionId); + const result = apiRespone?.data?.data?.issues; + Logger.debug(`Results: ${JSON.stringify(result)}`); + + return result; + } catch (error) { + Logger.error('No accessibility results were found.'); + Logger.debug(`getAppAccessibilityResults Failed. Error: ${error}`); + + return []; + } + + } + + async getAppAccessibilityResultsSummary(browser) { + if (!helper.isBrowserstackInfra()) { + return {}; + } + + if (!helper.isAppAccessibilitySession()) { + Logger.warn('Not an Accessibility Automation session, cannot retrieve Accessibility results summary.'); + + return {}; + } + try { + const apiUrl = `${APP_ALLY_ENDPOINT}/${APP_ALLY_ISSUES_SUMMARY_ENDPOINT}`; + const apiRespone = await this.getAppA11yResultResponse(apiUrl, browser, browser.sessionId); + const result = apiRespone?.data?.data?.summary; + Logger.debug(`Results Summary: ${JSON.stringify(result)}`); + + return result; + } catch (error) { + Logger.error('No accessibility result summary were found.'); + Logger.debug(`getAppAccessibilityResultsSummary Failed. Error: ${error}`); + + return {}; + } + } + + async getAppA11yResultResponse(apiUrl, browser, sessionId){ + Logger.debug('Performing scan before getting results/results summary'); + await this.performScan(browser); + + const upperTimeLimit = process.env.BSTACK_A11Y_POLLING_TIMEOUT ? Date.now() + parseInt(process.env.BSTACK_A11Y_POLLING_TIMEOUT) * 1000 : Date.now() + 30000; + const params = {test_run_uuid: process.env.TEST_RUN_UUID, session_id: sessionId, timestamp: Date.now()}; // Query params to pass + const header = {Authorization: `Bearer ${process.env.BSTACK_A11Y_JWT}`}; + const apiRespone = await helper.pollApi(apiUrl, params, header, upperTimeLimit); + Logger.debug(`Polling Result: ${JSON.stringify(apiRespone.message)}`); + + return apiRespone; + + } + + async saveAccessibilityResults(browser, dataForExtension = {}) { Logger.debug('Performing scan before saving results'); await this.performScan(browser); + if (helper.isAppAccessibilitySession()){ + return; + } const results = await browser.executeAsyncScript(AccessibilityScripts.saveTestResults, dataForExtension); return results; @@ -336,7 +440,12 @@ class AccessibilityAutomation { const originalCommandFn = originalCommand.command; originalCommand.command = async function(...args) { - await accessibilityInstance.performScan(browser, commandName); + if ( + !commandName.includes('execute') || + !accessibilityInstance.shouldPatchExecuteScript(args.length ? args[0] : null) + ) { + await accessibilityInstance.performScan(browser, commandName); + } return originalCommandFn.apply(this, args); }; @@ -347,6 +456,28 @@ class AccessibilityAutomation { } } } + + shouldPatchExecuteScript(script) { + if (!script || typeof script !== 'string') { + return true; + } + + return ( + script.toLowerCase().indexOf('browserstack_executor') !== -1 || + script.toLowerCase().indexOf('browserstack_accessibility_automation_script') !== -1 + ); + } + + getParamsForAppAccessibility(commandName) { + return { + 'thTestRunUuid': process.env.TEST_RUN_UUID, + 'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID, + 'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT, + 'authHeader': process.env.BSTACK_A11Y_JWT, + 'scanTimestamp': Date.now(), + 'method': commandName + }; + } } module.exports = AccessibilityAutomation; diff --git a/src/testObservability.js b/src/testObservability.js index 11f78ee..4735312 100644 --- a/src/testObservability.js +++ b/src/testObservability.js @@ -235,6 +235,7 @@ class TestObservability { accessibilityScripts.store(); } } + process.env.IS_APP_ACCESSIBILITY = accessibilityAutomation.isAccessibilityAutomationSession() && helper.isAppAutomate(); } diff --git a/src/utils/constants.js b/src/utils/constants.js index 4460c4e..953e360 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -18,7 +18,9 @@ exports.EVENTS = { SCREENSHOT: 'testObservability:screenshot' }; exports.ACCESSIBILITY_URL= 'https://accessibility.browserstack.com/api'; - +exports.APP_ALLY_ENDPOINT = 'https://app-accessibility.browserstack.com/automate'; +exports.APP_ALLY_ISSUES_SUMMARY_ENDPOINT ='api/v1/issues-summary'; +exports.APP_ALLY_ISSUES_ENDPOINT = 'api/v1/issues'; // Maximum size of VCS info which is allowed exports.MAX_GIT_META_DATA_SIZE_IN_BYTES = 64 * 1024; diff --git a/src/utils/helper.js b/src/utils/helper.js index c7eb798..3d84078 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -17,6 +17,7 @@ const LogPatcher = require('./logPatcher'); const BSTestOpsPatcher = new LogPatcher({}); const sessions = {}; const {execSync} = require('child_process'); +const request = require('@cypress/request'); console = {}; Object.keys(consoleHolder).forEach(method => { @@ -101,6 +102,10 @@ exports.isTestHubBuild = (pluginSettings = {}, isBuildStart = false) => { }; +exports.isAppAccessibilitySession = () => { + return process.env.IS_APP_ACCESSIBILITY === 'true'; +}; + exports.isAccessibilityEnabled = (settings) => { if (process.argv.includes('--disable-accessibility')) {return false} @@ -1305,3 +1310,102 @@ exports.patchBrowserTerminateCommand = () =>{ }; }; +exports.formatString = (template, ...values) => { + let i = 0; + if (template === null) { + return ''; + } + + return template.replace(/%s/g, () => { + const value = values[i++]; + + return value !== null && value !== undefined ? value : ''; + }); +}; + +exports.pollApi = async (url, params, headers, upperLimit, startTime = Date.now()) => { + params.timestamp = Math.round(Date.now() / 1000); + Logger.debug(`current timestamp ${params.timestamp}`); + + try { + const queryString = new URLSearchParams(params).toString(); + const fullUrl = `${url}?${queryString}`; + + const response = await new Promise((resolve, reject) => { + request({ + method: 'GET', + url: fullUrl, + headers: headers, + json: false + }, (error, response, body) => { + if (error) { + reject(error); + } else { + resolve(response); + } + }); + }); + + const responseData = JSON.parse(response.body); + + if (response.statusCode === 404) { + const nextPollTime = parseInt(response.headers?.next_poll_time, 10) * 1000; + Logger.debug(`nextPollTime: ${nextPollTime}`); + + if (isNaN(nextPollTime)) { + Logger.warn('Invalid or missing `nextPollTime` header. Stopping polling.'); + + return { + data: {}, + headers: response.headers || {}, + message: 'Invalid nextPollTime header value. Polling stopped.' + }; + } + + // Stop polling if the upper time limit is reached + if (nextPollTime > upperLimit) { + Logger.warn('Polling stopped due to upper time limit.'); + + return { + data: {}, + headers: response.headers || {}, + message: 'Polling stopped due to upper time limit.' + }; + } + + const elapsedTime = Math.max(0, nextPollTime - Date.now()); + Logger.debug( + `elapsedTime ${elapsedTime} nextPollTimes ${nextPollTime} upperLimit ${upperLimit}` + ); + + Logger.debug(`Polling for results again in ${elapsedTime}ms`); + + // Wait for the specified time and poll again + await new Promise((resolve) => setTimeout(resolve, elapsedTime)); + + return exports.pollApi(url, params, headers, upperLimit, startTime); + } + + return { + data: responseData, + headers: response.headers, + message: 'Polling succeeded.' + }; + } catch (error) { + if (error.response) { + throw { + data: {}, + headers: {}, + message: error.response.body ? JSON.parse(error.response.body).message : 'Unknown error' + }; + } else { + Logger.error(`Unexpected error occurred: ${error}`); + + return {data: {}, headers: {}, message: 'Unexpected error occurred.'}; + } + } +}; + + + +