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/package-lock.json b/package-lock.json index 4f9a492..36c8b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nightwatch/browserstack", - "version": "3.6.2", + "version": "3.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@nightwatch/browserstack", - "version": "3.6.2", + "version": "3.7.1", "license": "MIT", "dependencies": { "@cypress/request": "^3.0.1", @@ -14,6 +14,7 @@ "git-last-commit": "^1.0.1", "git-repo-info": "^2.1.1", "gitconfiglocal": "^2.1.0", + "glob": "^7.2.0", "strip-ansi": "^6.0.1", "uuid": "^9.0.0", "winston-transport": "^4.5.0" @@ -1584,10 +1585,11 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -3947,9 +3949,9 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "requires": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 8277a3d..67f8894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nightwatch/browserstack", - "version": "3.6.2", + "version": "3.7.1", "description": "Nightwatch plugin for integration with browserstack.", "main": "index.js", "scripts": { @@ -45,7 +45,8 @@ "@cypress/request": "^3.0.1", "strip-ansi": "^6.0.1", "winston-transport": "^4.5.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "glob": "^7.2.0" }, "devDependencies": { "chai": "^4.3.7", 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 ba103d6..3d84078 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -2,6 +2,7 @@ const os = require('os'); const fs = require('fs'); const fsPromises = fs.promises; const path = require('path'); +const glob = require('glob'); const {promisify} = require('util'); const gitRepoInfo = require('git-repo-info'); const gitconfig = require('gitconfiglocal'); @@ -16,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 => { @@ -100,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} @@ -888,84 +894,7 @@ exports.truncateString = (field, truncateSizeInBytes) => { // Helper function to check if a pattern contains glob characters exports.isGlobPattern = (pattern) => { - return pattern.includes('*') || pattern.includes('?') || pattern.includes('['); -}; - -// Helper function to recursively find files matching a pattern -exports.findFilesRecursively = (dir, pattern) => { - const files = []; - try { - if (!fs.existsSync(dir)) { - return files; - } - - const entries = fs.readdirSync(dir, {withFileTypes: true}); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Recursively search subdirectories - files.push(...exports.findFilesRecursively(fullPath, pattern)); - } else if (entry.isFile()) { - const relativePath = path.relative(process.cwd(), fullPath); - - // Enhanced pattern matching for glob patterns - if (exports.matchesGlobPattern(relativePath, pattern)) { - files.push(relativePath); - } - } - } - } catch (err) { - Logger.debug(`Error reading directory ${dir}: ${err.message}`); - } - - return files; -}; - -// Helper function to match a file path against a glob pattern -exports.matchesGlobPattern = (filePath, pattern) => { - // Normalize paths to use forward slashes - const normalizedPath = filePath.replace(/\\/g, '/'); - const normalizedPattern = pattern.replace(/\\/g, '/'); - - // Convert glob pattern to regex step by step - let regexPattern = normalizedPattern; - - // First, handle ** patterns (must be done before single *) - // ** should match zero or more directories - regexPattern = regexPattern.replace(/\*\*/g, '§DOUBLESTAR§'); - - // Escape regex special characters except the placeholders - regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - - // Now handle single * and ? patterns - regexPattern = regexPattern.replace(/\*/g, '[^/]*'); // * matches anything except path separators - regexPattern = regexPattern.replace(/\?/g, '[^/]'); // ? matches single character except path separator - - // Finally, replace ** placeholder with regex for any path (including zero directories) - regexPattern = regexPattern.replace(/§DOUBLESTAR§/g, '.*?'); - - // Special case: if pattern ends with /**/* we need to handle direct files in the base directory - // Convert patterns like "dir/**/*" to also match "dir/*" - if (normalizedPattern.includes('/**/')) { - const baseRegex = regexPattern; - const alternativeRegex = regexPattern.replace(/\/\.\*\?\//g, '/'); - regexPattern = `(?:${baseRegex}|${alternativeRegex})`; - } - - // Ensure pattern matches from start to end - regexPattern = '^' + regexPattern + '$'; - - try { - const regex = new RegExp(regexPattern); - - return regex.test(normalizedPath); - } catch (err) { - Logger.debug(`Error in glob pattern matching: ${err.message}`); - - return false; - } + return glob.hasMagic(pattern); }; // Helper function to resolve and collect test files from a path/pattern @@ -1039,36 +968,17 @@ exports.findTestFilesInDirectory = (dir) => { exports.expandGlobPattern = (pattern) => { Logger.debug(`Expanding glob pattern: ${pattern}`); - // Extract the base directory from the pattern - const parts = pattern.split(/[/\\]/); - let baseDir = '.'; - let patternStart = 0; - - // Find the first part that contains glob characters - for (let i = 0; i < parts.length; i++) { - if (exports.isGlobPattern(parts[i])) { - patternStart = i; - break; - } - if (i === 0 && parts[i] !== '.') { - baseDir = parts[i]; - } else if (i > 0) { - baseDir = path.join(baseDir, parts[i]); - } - } - - // If baseDir doesn't exist, try current directory - if (!fs.existsSync(baseDir)) { - Logger.debug(`Base directory ${baseDir} doesn't exist, using current directory`); - baseDir = '.'; + try { + const files = glob.sync(pattern); + + Logger.debug(`Found ${files.length} files matching pattern: ${pattern}`); + + return files; + } catch (err) { + Logger.debug(`Error expanding glob pattern: ${err.message}`); + + return []; } - - Logger.debug(`Base directory: ${baseDir}, Pattern: ${pattern}`); - - const files = exports.findFilesRecursively(baseDir, pattern); - Logger.debug(`Found ${files.length} files matching pattern: ${pattern}`); - - return files; }; /** @@ -1400,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.'}; + } + } +}; + + + +