Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions bin/accessibility-automation/constants.js
Original file line number Diff line number Diff line change
@@ -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"
];
131 changes: 106 additions & 25 deletions bin/accessibility-automation/cypress/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -299,29 +360,45 @@ 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;
cy.window().then(async (win) => {
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;
Expand All @@ -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) {
Expand Down
135 changes: 135 additions & 0 deletions bin/accessibility-automation/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these need to be added at setSystemEnvs, else won't be available in browserstack terminal

Maintain something like OBSERVABILITY_ENV_VARS for a11y as well

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made the changes


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;
Loading