diff --git a/bin/accessibility-automation/constants.js b/bin/accessibility-automation/constants.js index 496667a9..5a1014f3 100644 --- a/bin/accessibility-automation/constants.js +++ b/bin/accessibility-automation/constants.js @@ -1 +1,15 @@ exports.API_URL = 'https://accessibility.browserstack.com/api'; + +exports.ACCESSIBILITY_ENV_VARS = [ + "BROWSERSTACK_TEST_ACCESSIBILITY", + "ACCESSIBILITY_AUTH", + "ACCESSIBILITY_SCANNERVERSION", + "ACCESSIBILITY_COMMANDS_TO_WRAP", + "ACCESSIBILITY_BUILD_END_ONLY", + "ACCESSIBILITY_EXTENSION_PATH", + "ACCESSIBILITY_INCLUDETAGSINTESTINGSCOPE", + "ACCESSIBILITY_EXCLUDETAGSINTESTINGSCOPE", + "BS_A11Y_JWT", + "BS_A11Y_TEST_RUN_ID", + "BROWSERSTACK_ACCESSIBILITY_DEBUG" +]; diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index 78e9c388..3c79e8f5 100644 --- a/bin/accessibility-automation/cypress/index.js +++ b/bin/accessibility-automation/cypress/index.js @@ -4,10 +4,54 @@ const browserStackLog = (message) => { if (!Cypress.env('BROWSERSTACK_LOGS')) return; cy.task('browserstack_log', message); } - -const commandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; -// scroll is not a default function in cypress. -const commandToOverwrite = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; + +// Default commands (fallback) - includes 'scroll' for server compatibility +const defaultCommandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; + +// Valid Cypress commands that can actually be overwritten (excludes 'scroll') +const validCypressCommands = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; + +// Determine effective commands based on server response +let effectiveCommandsToWrap = defaultCommandsToWrap; +let isBuildEndOnlyMode = false; + +// Check if server provided specific commands via environment variables +if (Cypress.env('ACCESSIBILITY_BUILD_END_ONLY') === 'true') { + // Server explicitly wants build-end-only scanning + effectiveCommandsToWrap = []; + isBuildEndOnlyMode = true; + browserStackLog('[A11Y] Server enabled build-end-only mode - disabling all command scanning'); +} else if (Cypress.env('ACCESSIBILITY_COMMANDS_TO_WRAP')) { + try { + const serverCommands = JSON.parse(Cypress.env('ACCESSIBILITY_COMMANDS_TO_WRAP')); + + if (Array.isArray(serverCommands)) { + if (serverCommands.length === 0) { + // Empty array = build-end only + effectiveCommandsToWrap = []; + isBuildEndOnlyMode = true; + browserStackLog('[A11Y] Server provided empty commands - enabling build-end-only mode'); + } else { + // Use server-provided command list + effectiveCommandsToWrap = serverCommands.map(cmd => cmd.name || cmd); + isBuildEndOnlyMode = false; + browserStackLog(`[A11Y] Using server commands: ${effectiveCommandsToWrap.join(', ')}`); + } + } + } catch (error) { + browserStackLog(`[A11Y] Error parsing server commands, using defaults: ${error.message}`); + } +} else { + browserStackLog('[A11Y] No server commands provided, using default command list'); +} + +// Filter to only include VALID Cypress commands that are also in effective commands +const commandToOverwrite = validCypressCommands.filter(cmd => + effectiveCommandsToWrap.includes(cmd) +); + +browserStackLog(`[A11Y] Commands to wrap: ${commandToOverwrite.length} out of ${validCypressCommands.length} valid commands`); +browserStackLog(`[A11Y] Build-end-only mode: ${isBuildEndOnlyMode}`); /* Overrriding the cypress commands to perform Accessibility Scan before Each command @@ -50,6 +94,8 @@ new Promise(async (resolve, reject) => { return resolve(); } + const isBuildEndOnly = Cypress.env('ACCESSIBILITY_BUILD_END_ONLY') === 'true'; + function findAccessibilityAutomationElement() { return win.document.querySelector("#accessibility-automation-element"); } @@ -82,8 +128,23 @@ new Promise(async (resolve, reject) => { } win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete); - const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend }); - win.dispatchEvent(e); + + // Enhanced event with mode information + const scanEvent = new CustomEvent("A11Y_SCAN", { + detail: { + ...payloadToSend, + scanMode: isBuildEndOnlyMode ? "comprehensive-build-end" : "incremental", + timestamp: Date.now() + } + }); + + if (isBuildEndOnlyMode) { + browserStackLog(`[A11Y] Starting comprehensive build-end scan`); + } else { + browserStackLog(`[A11Y] Starting incremental scan`); + } + + win.dispatchEvent(scanEvent); } if (findAccessibilityAutomationElement()) { @@ -299,22 +360,33 @@ const shouldScanForAccessibility = (attributes) => { return shouldScanTestForAccessibility; } -commandToOverwrite.forEach((command) => { - Cypress.Commands.overwrite(command, (originalFn, ...args) => { - const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; - const shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); - const state = cy.state('current'), Subject = 'getSubjectFromChain' in cy; - const stateName = state === null || state === void 0 ? void 0 : state.get('name'); - let stateType = null; - if (!shouldScanTestForAccessibility || (stateName && stateName !== command)) { - return originalFn(...args); - } - if(state !== null && state !== void 0){ - stateType = state.get('type'); - } - performModifiedScan(originalFn, Subject, stateType, ...args); - }); -}); +// Only wrap commands if not in build-end-only mode and we have commands to wrap +if (!isBuildEndOnlyMode && commandToOverwrite.length > 0) { + browserStackLog(`[A11Y] Wrapping ${commandToOverwrite.length} commands for accessibility scanning`); + + commandToOverwrite.forEach((command) => { + Cypress.Commands.overwrite(command, (originalFn, ...args) => { + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; + const shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + const state = cy.state('current'), Subject = 'getSubjectFromChain' in cy; + const stateName = state === null || state === void 0 ? void 0 : state.get('name'); + let stateType = null; + if (!shouldScanTestForAccessibility || (stateName && stateName !== command)) { + return originalFn(...args); + } + if(state !== null && state !== void 0){ + stateType = state.get('type'); + } + + browserStackLog(`[A11Y] Performing command-level scan for: ${command}`); + performModifiedScan(originalFn, Subject, stateType, ...args); + }); + }); + + browserStackLog(`[A11Y] Successfully wrapped ${commandToOverwrite.length} commands for accessibility scanning`); +} else { + browserStackLog(`[A11Y] Command wrapping disabled - using build-end-only scanning mode`); +} afterEach(() => { const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest; @@ -322,6 +394,11 @@ afterEach(() => { let shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); if (!shouldScanTestForAccessibility) return cy.wrap({}); + // Determine current scanning mode + const currentMode = isBuildEndOnlyMode ? 'build-end-only' : 'command-plus-end'; + browserStackLog(`[A11Y] Starting final scan in ${currentMode} mode`); + + // Perform final scan (this happens regardless of mode) cy.wrap(performScan(win), {timeout: 30000}).then(() => { try { let os_data; @@ -347,13 +424,17 @@ afterEach(() => { const payloadToSend = { "thTestRunUuid": testRunUuid, "thBuildUuid": Cypress.env("BROWSERSTACK_TESTHUB_UUID"), - "thJwtToken": Cypress.env("BROWSERSTACK_TESTHUB_JWT") + "thJwtToken": Cypress.env("BROWSERSTACK_TESTHUB_JWT"), + "scanMode": currentMode, + "buildEndOnly": isBuildEndOnlyMode }; - browserStackLog(`Payload to send: ${JSON.stringify(payloadToSend)}`); + + browserStackLog(`[A11Y] Saving results for ${currentMode} mode`); + browserStackLog(`[A11Y] Payload: ${JSON.stringify(payloadToSend)}`); return cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000}); }).then(() => { - browserStackLog(`Saved accessibility test results`); + browserStackLog(`[A11Y] Successfully completed ${currentMode} accessibility scanning and saved results`); }) } catch (er) { diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index 36d484c4..c613e4cb 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -9,9 +9,12 @@ const glob = require('glob'); const helper = require('../helpers/helper'); const { CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS } = require('../helpers/constants'); const { consoleHolder } = require("../testObservability/helper/constants"); +const scripts = require('./scripts'); const supportFileContentMap = {} const HttpsProxyAgent = require('https-proxy-agent'); +// Function to log A11Y debugging info to remote server + exports.checkAccessibilityPlatform = (user_config) => { let accessibility = false; try { @@ -273,3 +276,135 @@ exports.setAccessibilityEventListeners = (bsConfig) => { logger.debug(`Unable to parse support files to set event listeners with error ${e}`, true, e); } } + +// Process server accessibility configuration similar to Node Agent +exports.processServerAccessibilityConfig = (responseData) => { + logger.debug('[A11Y] Processing server accessibility configuration', { responseData }); + + try { + // Use Scripts class to parse server response + scripts.parseFromResponse(responseData); + + // Handle the commandsToWrap structure from the server response + if (responseData.accessibility?.options?.commandsToWrap) { + const commandsToWrapData = responseData.accessibility.options.commandsToWrap; + + // Extract the actual commands array from the nested structure + const serverCommands = commandsToWrapData.commands || []; + + // Store server commands for Cypress to read + process.env.ACCESSIBILITY_COMMANDS_TO_WRAP = JSON.stringify(serverCommands); + + logger.debug(`[A11Y] Server provided ${serverCommands.length} commands for wrapping`, { serverCommands }); + + if (serverCommands.length === 0) { + logger.debug('[A11Y] Server wants build-end-only scanning - command wrapping will be disabled'); + process.env.ACCESSIBILITY_BUILD_END_ONLY = 'true'; + } else { + logger.debug(`[A11Y] Server wants command-level scanning for: ${serverCommands.map(cmd => cmd.name || cmd).join(', ')}`, { commandList: serverCommands.map(cmd => cmd.name || cmd) }); + process.env.ACCESSIBILITY_BUILD_END_ONLY = 'false'; + } + + // Log scriptsToRun if available (Scripts class handles the actual storage) + if (commandsToWrapData.scriptsToRun) { + logger.debug(`[A11Y] Server provided scripts to run: ${commandsToWrapData.scriptsToRun.join(', ')}`, { scriptsToRun: commandsToWrapData.scriptsToRun }); + } + } else { + logger.debug('[A11Y] No server commands provided, using default command list'); + process.env.ACCESSIBILITY_BUILD_END_ONLY = 'false'; + } + + // Process scripts from server response + if (responseData.accessibility?.options?.scripts) { + const serverScripts = responseData.accessibility.options.scripts; + + // Convert array of script objects to a map for easier access + const scriptsMap = {}; + serverScripts.forEach(script => { + scriptsMap[script.name] = script.command; + }); + + logger.debug(`[A11Y] Server provided accessibility scripts: ${Object.keys(scriptsMap).join(', ')}`, { scriptsMap }); + } else { + logger.debug('[A11Y] No server scripts provided, using default scripts'); + } + + // Process capabilities for token and other settings + if (responseData.accessibility?.options?.capabilities) { + const capabilities = responseData.accessibility.options.capabilities; + + capabilities.forEach(cap => { + if (cap.name === 'accessibilityToken') { + process.env.BS_A11Y_JWT = cap.value; + logger.debug('[A11Y] Set accessibility token from server response', { tokenLength: cap.value?.length || 0 }); + } else if (cap.name === 'test_run_id') { + process.env.BS_A11Y_TEST_RUN_ID = cap.value; + logger.debug('[A11Y] Set test run ID from server response', { testRunId: cap.value }); + } else if (cap.name === 'testhub_build_uuid') { + process.env.BROWSERSTACK_TESTHUB_UUID = cap.value; + logger.debug('[A11Y] Set TestHub build UUID from server response', { buildUuid: cap.value }); + } else if (cap.name === 'scannerVersion') { + process.env.ACCESSIBILITY_SCANNERVERSION = cap.value; + logger.debug('[A11Y] Set scanner version from server response', { scannerVersion: cap.value }); + } + }); + } + + logger.debug('[A11Y] Successfully processed server accessibility configuration'); + } catch (error) { + logger.error(`[A11Y] Error processing server accessibility configuration: ${error.message}`); + // Fallback to default behavior + process.env.ACCESSIBILITY_BUILD_END_ONLY = 'false'; + } +}; + +// Check if command should be wrapped based on server response +exports.shouldWrapCommand = (commandName) => { + try { + if (!commandName) { + return false; + } + + // Check if we're in build-end-only mode + if (process.env.ACCESSIBILITY_BUILD_END_ONLY === 'true') { + logger.debug(`[A11Y] Build-end-only mode: not wrapping command ${commandName}`, { commandName, mode: 'build-end-only' }); + return false; + } + + // Use Scripts class to check if command should be wrapped + const shouldWrap = scripts.shouldWrapCommand(commandName); + + // If Scripts class has no commands configured, fallback to checking environment + if (!shouldWrap && process.env.ACCESSIBILITY_COMMANDS_TO_WRAP) { + const serverCommands = JSON.parse(process.env.ACCESSIBILITY_COMMANDS_TO_WRAP); + + if (Array.isArray(serverCommands) && serverCommands.length > 0) { + const envShouldWrap = serverCommands.some(command => { + return (command.name || command).toLowerCase() === commandName.toLowerCase(); + }); + + logger.debug(`[A11Y] shouldWrapCommand: ${commandName} -> ${envShouldWrap} (env-driven)`, { commandName, shouldWrap: envShouldWrap, source: 'environment' }); + return envShouldWrap; + } + } + + // If we got a result from Scripts class, use it + if (scripts.commandsToWrap && scripts.commandsToWrap.length > 0) { + logger.debug(`[A11Y] shouldWrapCommand: ${commandName} -> ${shouldWrap} (scripts-driven)`, { commandName, shouldWrap, source: 'scripts-class' }); + return shouldWrap; + } + + // Fallback to default commands if no server commands + const defaultCommands = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; + const defaultShouldWrap = defaultCommands.includes(commandName.toLowerCase()); + + logger.debug(`[A11Y] shouldWrapCommand: ${commandName} -> ${defaultShouldWrap} (default)`, { commandName, shouldWrap: defaultShouldWrap, source: 'default' }); + return defaultShouldWrap; + } catch (error) { + logger.debug(`[A11Y] Error in shouldWrapCommand: ${error.message}`, { commandName, error: error.message }); + return false; + } +}; + +// Export the Scripts instance for direct access +exports.scripts = scripts; diff --git a/bin/accessibility-automation/scripts.js b/bin/accessibility-automation/scripts.js new file mode 100644 index 00000000..43c5a53e --- /dev/null +++ b/bin/accessibility-automation/scripts.js @@ -0,0 +1,227 @@ +const path = require('path'); +const fs = require('fs'); +const logger = require('../helpers/logger').winstonLogger; +const os = require('os'); +const axios = require('axios'); + +// Function to log A11Y debugging info to remote server + +/** + * Scripts class to manage accessibility automation scripts and commands + * Similar to Node Agent implementation but adapted for Cypress CLI + */ +class Scripts { + constructor() { + this.performScan = null; + this.getResults = null; + this.getResultsSummary = null; + this.saveTestResults = null; + this.commandsToWrap = []; + this.scriptsToRun = []; + + this.browserstackFolderPath = path.join(os.homedir(), '.browserstack'); + this.commandsPath = path.join(this.browserstackFolderPath, 'cypress-commands.json'); + + // Load existing configuration if available + this.fromJson(); + } + + /** + * Parse accessibility configuration from server response + * Matches the actual server response structure + */ + parseFromResponse(responseData) { + logger.debug('[A11Y Scripts] Parsing accessibility configuration from server response', { hasResponseData: !!responseData }); + + try { + // Parse scripts from server response + if (responseData.accessibility?.options?.scripts) { + const serverScripts = responseData.accessibility.options.scripts; + + serverScripts.forEach(script => { + switch (script.name) { + case 'scan': + this.performScan = script.command; + logger.debug('[A11Y Scripts] Loaded scan script from server', { scriptName: 'scan' }); + break; + case 'getResults': + this.getResults = script.command; + logger.debug('[A11Y Scripts] Loaded getResults script from server', { scriptName: 'getResults' }); + break; + case 'getResultsSummary': + this.getResultsSummary = script.command; + logger.debug('[A11Y Scripts] Loaded getResultsSummary script from server', { scriptName: 'getResultsSummary' }); + break; + case 'saveResults': + this.saveTestResults = script.command; + logger.debug('[A11Y Scripts] Loaded saveResults script from server', { scriptName: 'saveResults' }); + break; + default: + logger.debug(`[A11Y Scripts] Unknown script type: ${script.name}`, { unknownScriptName: script.name }); + } + }); + + logger.debug(`[A11Y Scripts] Parsed ${serverScripts.length} scripts from server`, { scriptCount: serverScripts.length }); + } + + // Parse commands to wrap from server response + if (responseData.accessibility?.options?.commandsToWrap) { + const commandsToWrapData = responseData.accessibility.options.commandsToWrap; + + // Extract commands array from nested structure + this.commandsToWrap = commandsToWrapData.commands || []; + + // Extract scripts to run + if (commandsToWrapData.scriptsToRun) { + this.scriptsToRun = commandsToWrapData.scriptsToRun; + logger.debug(`[A11Y Scripts] Scripts to run: ${this.scriptsToRun.join(', ')}`, { scriptsToRun: this.scriptsToRun }); + } + + if (this.commandsToWrap.length === 0) { + logger.debug('[A11Y Scripts] Server sent EMPTY commands array - enabling build-end-only mode', { commandCount: 0 }); + } else { + logger.debug(`[A11Y Scripts] Server sent ${this.commandsToWrap.length} commands to wrap: ${this.commandsToWrap.map(cmd => cmd.name || cmd).join(', ')}`, { + commandCount: this.commandsToWrap.length, + commands: this.commandsToWrap.map(cmd => cmd.name || cmd) + }); + } + } + + // Save configuration to disk for persistence + this.toJson(); + + } catch (error) { + logger.error(`[A11Y Scripts] Error parsing server response: ${error.message}`); + } + } + + /** + * Check if a command should be wrapped for accessibility scanning + * @param {String} method - Command method name + * @returns {Boolean} - Whether the command should be wrapped + */ + shouldWrapCommand(method) { + try { + if (!method || !this.commandsToWrap) { + return false; + } + + const shouldWrap = this.commandsToWrap.findIndex(el => + el.name && el.name.toLowerCase() === method.toLowerCase() + ) !== -1; + + logger.debug(`[A11Y-Scripts] shouldWrapCommand(${method}) -> ${shouldWrap}`, { method, shouldWrap }); + return shouldWrap; + } catch (error) { + logger.debug(`[A11Y-Scripts] Exception in shouldWrapCommand: ${error.message}`, { method, error: error.message }); + return false; + } + } + + /** + * Get script by name + * @param {String} scriptName - Name of the script + * @returns {String|null} - Script content or null if not found + */ + getScript(scriptName) { + switch (scriptName.toLowerCase()) { + case 'scan': + return this.performScan; + case 'getresults': + return this.getResults; + case 'getresultssummary': + return this.getResultsSummary; + case 'saveresults': + case 'savetestresults': + return this.saveTestResults; + default: + logger.debug(`[A11Y-Scripts] Unknown script requested: ${scriptName}`, { scriptName }); + return null; + } + } + + /** + * Save configuration to JSON file + */ + toJson() { + try { + if (!fs.existsSync(this.browserstackFolderPath)) { + fs.mkdirSync(this.browserstackFolderPath, { recursive: true }); + } + + const config = { + scripts: { + scan: this.performScan, + getResults: this.getResults, + getResultsSummary: this.getResultsSummary, + saveResults: this.saveTestResults + }, + commands: this.commandsToWrap, + scriptsToRun: this.scriptsToRun || [], + lastUpdated: new Date().toISOString() + }; + + fs.writeFileSync(this.commandsPath, JSON.stringify(config, null, 2)); + logger.debug(`[A11Y-Scripts] Configuration saved to ${this.commandsPath}`, { configPath: this.commandsPath }); + } catch (error) { + logger.error(`[A11Y-Scripts] Error saving configuration: ${error.message}`); + } + } + + /** + * Load configuration from JSON file + */ + fromJson() { + try { + if (fs.existsSync(this.commandsPath)) { + const config = JSON.parse(fs.readFileSync(this.commandsPath, 'utf8')); + + if (config.scripts) { + this.performScan = config.scripts.scan; + this.getResults = config.scripts.getResults; + this.getResultsSummary = config.scripts.getResultsSummary; + this.saveTestResults = config.scripts.saveResults; + } + + this.commandsToWrap = config.commands || []; + this.scriptsToRun = config.scriptsToRun || []; + + logger.debug(`[A11Y-Scripts] Configuration loaded from ${this.commandsPath}`, { configPath: this.commandsPath }); + } + } catch (error) { + logger.debug(`[A11Y-Scripts] Error loading configuration: ${error.message}`, { error: error.message }); + } + } + + /** + * Clear all configuration + */ + clear() { + this.performScan = null; + this.getResults = null; + this.getResultsSummary = null; + this.saveTestResults = null; + this.commandsToWrap = []; + this.scriptsToRun = []; + + try { + if (fs.existsSync(this.commandsPath)) { + fs.unlinkSync(this.commandsPath); + logger.debug(`[A11Y-Scripts] Configuration file cleared`, { configPath: this.commandsPath }); + } + } catch (error) { + logger.error(`[A11Y-Scripts] Error clearing configuration: ${error.message}`); + } + } + + /** + * Check if we're in build-end-only mode (empty commands array) + * @returns {Boolean} - True if build-end-only mode + */ + isBuildEndOnlyMode() { + return !Array.isArray(this.commandsToWrap) || this.commandsToWrap.length === 0; + } +} + +// Export singleton instance +module.exports = new Scripts(); diff --git a/bin/commands/runs.js b/bin/commands/runs.js index 8bf716f9..26c3a695 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -1,6 +1,8 @@ 'use strict'; const path = require('path'); +// Helper function for server logging to test accessibility flow + const archiver = require("../helpers/archiver"), zipUploader = require("../helpers/zipUpload"), build = require("../helpers/build"), @@ -33,11 +35,10 @@ const { const { createAccessibilityTestRun, setAccessibilityEventListeners, - checkAccessibilityPlatform, supportFileCleanup } = require('../accessibility-automation/helper'); const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper'); -const { shouldProcessEventForTesthub, checkAndSetAccessibility, findAvailablePort } = require('../testhub/utils'); +const { shouldProcessEventForTesthub, findAvailablePort } = require('../testhub/utils'); const TestHubHandler = require('../testhub/testhubHandler'); module.exports = function run(args, rawArgs) { @@ -68,8 +69,14 @@ module.exports = function run(args, rawArgs) { /* Set testObservability & browserstackAutomation flags */ const [isTestObservabilitySession, isBrowserstackInfra] = setTestObservabilityFlags(bsConfig); - const checkAccessibility = checkAccessibilityPlatform(bsConfig); - const isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility; + + // Log initial accessibility state + logger.debug('Initial accessibility configuration', { + 'bsConfig.run_settings.accessibility': bsConfig.run_settings.accessibility, + 'env.BROWSERSTACK_TEST_ACCESSIBILITY': process.env.BROWSERSTACK_TEST_ACCESSIBILITY, + 'system_env_vars': bsConfig.run_settings.system_env_vars + }); + const turboScaleSession = isTurboScaleSession(bsConfig); Constants.turboScaleObj.enabled = turboScaleSession; @@ -113,16 +120,22 @@ module.exports = function run(args, rawArgs) { // set build tag caps utils.setBuildTags(bsConfig, args); - checkAndSetAccessibility(bsConfig, isAccessibilitySession); - const preferredPort = 5348; const port = await findAvailablePort(preferredPort); process.env.REPORTER_API_PORT_NO = port // Send build start to TEST REPORTING AND ANALYTICS if(shouldProcessEventForTesthub()) { + logger.debug('Sending build to TestHub for accessibility processing'); await TestHubHandler.launchBuild(bsConfig, bsConfigPath); utils.setO11yProcessHooks(null, bsConfig, args, null, buildReportData); + + // Log final accessibility state after TestHub processing + logger.debug('Final accessibility configuration after TestHub', { + 'bsConfig.run_settings.accessibility': bsConfig.run_settings.accessibility, + 'env.BROWSERSTACK_TEST_ACCESSIBILITY': process.env.BROWSERSTACK_TEST_ACCESSIBILITY, + 'system_env_vars': bsConfig.run_settings.system_env_vars + }); } // accept the system env list from bsconf and set it @@ -323,6 +336,7 @@ module.exports = function run(args, rawArgs) { logger.debug("Completed build creation"); markBlockEnd('createBuild'); markBlockEnd('total'); + utils.setProcessHooks(data.build_id, bsConfig, bs_local, args, buildReportData); if(isTestObservabilitySession) { utils.setO11yProcessHooks(data.build_id, bsConfig, bs_local, args, buildReportData); diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index 941916a0..5f314a27 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -24,7 +24,8 @@ const usageReporting = require("./usageReporting"), pkg = require('../../package.json'), transports = require('./logger').transports, o11yHelpers = require('../testObservability/helper/helper'), - { OBSERVABILITY_ENV_VARS, TEST_OBSERVABILITY_REPORTER } = require('../testObservability/helper/constants'); + { OBSERVABILITY_ENV_VARS, TEST_OBSERVABILITY_REPORTER } = require('../testObservability/helper/constants'), + { ACCESSIBILITY_ENV_VARS } = require('../accessibility-automation/constants'); const { default: axios } = require("axios"); const { shouldProcessEventForTesthub } = require("../testhub/utils"); @@ -603,6 +604,12 @@ exports.setSystemEnvs = (bsConfig) => { } } catch(e){} + try { + ACCESSIBILITY_ENV_VARS.forEach(key => { + envKeys[key] = process.env[key]; + }); + } catch(e){} + if (Object.keys(envKeys).length === 0) { bsConfig.run_settings.system_env_vars = null; } else { diff --git a/bin/testhub/testhubHandler.js b/bin/testhub/testhubHandler.js index a6b4724a..82cf7b30 100644 --- a/bin/testhub/testhubHandler.js +++ b/bin/testhub/testhubHandler.js @@ -25,7 +25,7 @@ class TestHubHandler { process.env.BS_TESTOPS_BUILD_COMPLETED = false; } - if (testhubUtils.isAccessibilityEnabled()) { + if (testhubUtils.isAccessibilityEnabled(user_config)) { logger.debug( "Exception while creating test run for BrowserStack Accessibility Automation: Missing authentication token" ); @@ -38,7 +38,11 @@ class TestHubHandler { try { const data = await this.generateBuildUpstreamData(user_config); const config = this.getConfig(obsUserName, obsAccessKey); + + logger.debug('[A11Y] Making TestHub API request to:', TESTHUB_CONSTANTS.TESTHUB_BUILD_API); const response = await nodeRequest( "POST", TESTHUB_CONSTANTS.TESTHUB_BUILD_API, data, config); + + logger.debug('[A11Y] TestHub API response received:', JSON.stringify(response.data?.accessibility || 'No accessibility in response', null, 2)); const launchData = this.extractDataFromResponse(user_config, data, response, config); } catch (error) { console.log(error); @@ -53,6 +57,11 @@ class TestHubHandler { static async generateBuildUpstreamData(user_config) { const { buildName, projectName, buildDescription, buildTags } = helper.getBuildDetails(user_config, true); const productMap = testhubUtils.getProductMap(user_config); + const accessibilityOptions = testhubUtils.getAccessibilityOptions(user_config); + + // Log what accessibility data is being sent to server + logger.debug('[A11Y] Sending accessibility options to TestHub server:', JSON.stringify(accessibilityOptions, null, 2)); + const data = { project_name: projectName, name: buildName, @@ -65,12 +74,12 @@ class TestHubHandler { build_run_identifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER, failed_tests_rerun: process.env.BROWSERSTACK_RERUN || false, version_control: await helper.getGitMetaData(), - accessibility: testhubUtils.getAccessibilityOptions(user_config), + accessibility: accessibilityOptions, framework_details: testhubUtils.getFrameworkDetails(), product_map: productMap, browserstackAutomation: productMap["automate"], }; - + return data; } @@ -104,11 +113,20 @@ class TestHubHandler { process.env.BROWSERSTACK_TEST_OBSERVABILITY = "false"; } - if(testhubUtils.isAccessibilityEnabled()) { + // Implement C# SDK pattern: if (accessibilityAutomation.IsAccessibility() || utils.IsAccessibilityInResponse(buildCreationResponse)) + const userAccessibilityEnabled = testhubUtils.isAccessibilityEnabled(user_config); + const serverAutoEnabled = testhubUtils.isAccessibilityInResponse(response.data); + + if (userAccessibilityEnabled || serverAutoEnabled) { + // Match C# SDK: bsConfig.accessibility = true; accessibilityAutomation.ProcessAccessibilityResponse(buildCreationResponse); + logger.debug('[A11Y] Enabling accessibility - either user enabled or server auto-enabled'); + user_config.run_settings.accessibility = true; testhubUtils.setAccessibilityVariables(user_config, response.data); } else { - process.env.BROWSERSTACK_ACCESSIBILITY = 'false'; - testhubUtils.checkAndSetAccessibility(user_config, false) + // Accessibility not enabled by user and not auto-enabled by server + logger.debug('[A11Y] Accessibility not enabled - neither user enabled nor server auto-enabled'); + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'false'; + testhubUtils.checkAndSetAccessibility(user_config, false); } if (testhubUtils.shouldProcessEventForTesthub()) { diff --git a/bin/testhub/utils.js b/bin/testhub/utils.js index 642ecb62..c3d45f5a 100644 --- a/bin/testhub/utils.js +++ b/bin/testhub/utils.js @@ -1,10 +1,12 @@ const os = require("os"); +const https = require('https'); +const accessibilityHelper = require('../accessibility-automation/helper'); + const logger = require("../../bin/helpers/logger").winstonLogger; const TESTHUB_CONSTANTS = require("./constants"); const testObservabilityHelper = require("../../bin/testObservability/helper/helper"); const helper = require("../helpers/helper"); -const accessibilityHelper = require("../accessibility-automation/helper"); const { detect } = require('detect-port'); @@ -26,11 +28,52 @@ exports.getFrameworkDetails = (user_config) => { }; }; -exports.isAccessibilityEnabled = () => { +exports.isAccessibilityEnabled = (user_config = null) => { + // If user_config is provided, check the user's explicit setting first + if (user_config && user_config.run_settings) { + // Check run_settings.accessibility first (explicit user setting) + if (user_config.run_settings.accessibility !== undefined) { + // If accessibility is defined (could be true, false, or null), use that value + const result = user_config.run_settings.accessibility; + logger.debug('[A11Y] isAccessibilityEnabled from config: ' + result + ', raw value: ' + user_config.run_settings.accessibility); + return result; + } else { + // If accessibility is undefined, keep default to null + logger.debug('[A11Y] isAccessibilityEnabled from config: accessibility is undefined, returning null'); + return null; + } + } + + // Fallback to environment variable check if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY !== undefined) { - return process.env.BROWSERSTACK_TEST_ACCESSIBILITY === "true"; + const result = process.env.BROWSERSTACK_TEST_ACCESSIBILITY === "true"; + logger.debug('[A11Y] isAccessibilityEnabled from env:', result, 'env value:', process.env.BROWSERSTACK_TEST_ACCESSIBILITY); + return result; } - logger.debug('Accessibility is disabled'); + + logger.debug('[A11Y] isAccessibilityEnabled: no setting found, returning false'); + return false; +}; + +// Equivalent to C# SDK IsAccessibilityInResponse function +// Checks if server auto-enabled accessibility in the response +exports.isAccessibilityInResponse = (responseData) => { + logger.debug('[A11Y] Checking isAccessibilityInResponse with data: ' + JSON.stringify(responseData)); + + logger.debug('[A11Y] Checking isAccessibilityInResponse with data:', JSON.stringify(responseData?.accessibility || 'No accessibility in response', null, 2)); + + if (responseData && responseData.accessibility) { + if (responseData.accessibility && typeof responseData.accessibility === 'object') { + const successValue = responseData.accessibility.success; + const result = successValue === true; + logger.debug('[A11Y] isAccessibilityInResponse result:', result, 'success value:', successValue); + return result; + } + // If accessibility is null or not an object, treat as false + logger.debug('[A11Y] isAccessibilityInResponse: accessibility is null or not object, returning false'); + return false; + } + logger.debug('[A11Y] isAccessibilityInResponse: no accessibility in response, returning false'); return false; }; @@ -48,7 +91,7 @@ exports.getProductMap = (user_config) => { exports.shouldProcessEventForTesthub = () => { return ( testObservabilityHelper.isTestObservabilitySession() || - exports.isAccessibilityEnabled() + exports.isAccessibilityEnabled() // No user_config available here, use env fallback ); }; @@ -98,27 +141,56 @@ exports.handleErrorForObservability = (error = null) => { }; exports.setAccessibilityVariables = (user_config, responseData) => { + logger.debug('[A11Y] setAccessibilityVariables called with response:', JSON.stringify(responseData?.accessibility || 'No accessibility', null, 2)); + + // Match C# SDK ProcessAccessibilityResponse logic if (!responseData.accessibility) { + logger.debug('[A11Y] No accessibility in response, handling error'); exports.handleErrorForAccessibility(user_config); - return [null, null]; } if (!responseData.accessibility.success) { - exports.handleErrorForAccessibility( - user_config, - responseData.accessibility - ); - + logger.debug('[A11Y] Accessibility success is false, handling error'); + exports.handleErrorForAccessibility(user_config, responseData.accessibility); return [null, null]; } - if (responseData.accessibility.options) { - logger.debug( - `BrowserStack Accessibility Automation Build Hashed ID: ${responseData.build_hashed_id}` - ); - setAccessibilityCypressCapabilities(user_config, responseData); - helper.setBrowserstackCypressCliDependency(user_config); + // Match C# SDK: if (accessibilityResponse["success"].ToString() == "True") + if (responseData.accessibility.success === true) { + logger.debug('[A11Y] Server auto-enabled accessibility - processing response'); + // Set configuration like C# SDK: isAccessibility = true; + user_config.run_settings.accessibility = true; + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'true'; + + if (responseData.accessibility.options) { + logger.debug('[A11Y] Processing accessibility options from server'); + logger.debug(`BrowserStack Accessibility Automation Build Hashed ID: ${responseData.build_hashed_id}`); + + // Process server commands and scripts similar to Node Agent + processServerCommandsAndScripts(responseData); + + setAccessibilityCypressCapabilities(user_config, responseData); + helper.setBrowserstackCypressCliDependency(user_config); + } else { + logger.debug('[A11Y] No accessibility options in server response'); + } + } +}; + +// Process server commands and scripts similar to Node Agent +const processServerCommandsAndScripts = (responseData) => { + logger.debug('[A11Y] Processing server commands and scripts'); + + try { + // Use the helper function to process server accessibility configuration + const processingResult = accessibilityHelper.processServerAccessibilityConfig(responseData); + + logger.debug(`[A11Y] Successfully processed server commands and scripts: ${JSON.stringify(processingResult || {})}`); + } catch (error) { + logger.debug(`[A11Y] Error processing server commands and scripts: ${error.message}`); + // Fallback to default behavior + process.env.ACCESSIBILITY_BUILD_END_ONLY = 'false'; } }; @@ -239,11 +311,15 @@ exports.setTestHubCommonMetaInfo = (user_config, responseData) => { }; exports.checkAndSetAccessibility = (user_config, accessibilityFlag) => { + logger.debug(`[A11Y] checkAndSetAccessibility - Called with accessibilityFlag=${accessibilityFlag}, current config accessibility=${user_config.run_settings.accessibility}`); + if (!accessibilityHelper.isAccessibilitySupportedCypressVersion(user_config.run_settings.cypress_config_file)) { logger.warn(`Accessibility Testing is not supported on Cypress version 9 and below.`); process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'false'; user_config.run_settings.accessibility = false; + + logger.debug(`[A11Y] checkAndSetAccessibility - Cypress version not supported, forced accessibility=false`); return; } @@ -251,16 +327,31 @@ exports.checkAndSetAccessibility = (user_config, accessibilityFlag) => { user_config.run_settings.system_env_vars = []; } - if (!isUndefined(accessibilityFlag)) { - process.env.BROWSERSTACK_TEST_ACCESSIBILITY = accessibilityFlag.toString(); - user_config.run_settings.accessibility = accessibilityFlag; - if ( - !user_config.run_settings.system_env_vars.includes("BROWSERSTACK_TEST_ACCESSIBILITY") - ) { - user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TEST_ACCESSIBILITY=${accessibilityFlag}`); + // Handle accessibility flag setting - improved logic for auto-enable + if (accessibilityFlag !== undefined && accessibilityFlag !== null) { + const accessibilityEnabled = Boolean(accessibilityFlag); + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = accessibilityEnabled.toString(); + user_config.run_settings.accessibility = accessibilityEnabled; + + // Remove existing accessibility env var if present + const originalEnvVarsLength = user_config.run_settings.system_env_vars.length; + user_config.run_settings.system_env_vars = user_config.run_settings.system_env_vars.filter( + envVar => !envVar.startsWith('BROWSERSTACK_TEST_ACCESSIBILITY=') + ); + const filteredEnvVarsLength = user_config.run_settings.system_env_vars.length; + + // Add the current accessibility setting + user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TEST_ACCESSIBILITY=${accessibilityEnabled}`); + + logger.debug(`[A11Y] checkAndSetAccessibility - Set accessibility=${accessibilityEnabled}, removed ${originalEnvVarsLength - filteredEnvVarsLength} duplicate env vars, final env vars: ${JSON.stringify(user_config.run_settings.system_env_vars)}`); + + if (accessibilityEnabled) { + logger.debug("Accessibility enabled for session"); } return; } + + logger.debug(`[A11Y] checkAndSetAccessibility - No accessibility flag provided, exiting without changes`); return; }; @@ -268,7 +359,38 @@ exports.getAccessibilityOptions = (user_config) => { const settings = isUndefined(user_config.run_settings.accessibilityOptions) ? {} : user_config.run_settings.accessibilityOptions; - return { settings: settings }; + + // Get user's explicit accessibility preference (true/false/null) - matches C# SDK pattern + let enabled = null; + + // Check run_settings.accessibility first (highest priority) + if (user_config.run_settings.accessibility === true) { + enabled = true; + logger.debug('[A11Y] User explicitly enabled accessibility via run_settings'); + } else if (user_config.run_settings.accessibility === false) { + enabled = false; + logger.debug('[A11Y] User explicitly disabled accessibility via run_settings'); + } + // Check environment variable (fallback) + else if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY === 'true') { + enabled = true; + logger.debug('[A11Y] User enabled accessibility via environment variable'); + } else if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY === 'false') { + enabled = false; + logger.debug('[A11Y] User disabled accessibility via environment variable'); + } + // Otherwise keep as null for server auto-enable decision + else { + logger.debug('[A11Y] No explicit user setting - sending null for server auto-enable decision'); + } + + const result = { + settings: settings, // Send user preference to server (null = let server decide) + }; + + logger.debug('[A11Y] Final accessibility options for server:', JSON.stringify(result, null, 2)); + + return result; }; exports.appendTestHubParams = (testData, eventType, accessibilityScanInfo) => {