From c281a007cdce112ad42159a9a328562c23c5c3a9 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Sat, 20 Jun 2026 13:13:50 +0530 Subject: [PATCH 1/2] Disable accessibility when browserstackAccessibility plugin not loaded When a build requests accessibility (browserstack.json/browser caps) but the browserstackAccessibility plugin is not wired into the cypress config, the CLI now detects this before the build start event, explicitly disables accessibility so the build is not counted as an a11y build, warns the user with the setup doc link, and instruments the end-of-session EDS event (accessibility_plugin_not_loaded) so such builds can be excluded from stability queries. Detection is code-based: the plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED when its module is loaded/invoked by the cypress config; requireModule writes a flag file the parent process reads back. Falls back to a raw-source scan only when the config could not be required (e.g. TS before bstack packages are installed). Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/accessibility-automation/helper.js | 47 ++++++++++++++++++++ bin/accessibility-automation/plugin/index.js | 11 +++++ bin/commands/runs.js | 19 +++++++- bin/helpers/config.js | 3 ++ bin/helpers/constants.js | 2 + bin/helpers/readCypressConfigUtil.js | 15 +++++++ bin/helpers/requireModule.js | 14 ++++++ 7 files changed, 109 insertions(+), 2 deletions(-) diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index e0ec145f..c0049edd 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -41,6 +41,53 @@ exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => { return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); } +// Fallback: scan the raw cypress config source for the accessibility plugin +// import. Used only when the config could not be required (e.g. a TypeScript +// config before BrowserStack packages are installed), so that such users are +// not wrongly disabled. The substring matches require()/import of the plugin +// regardless of path style or imported symbol name. +const ACCESSIBILITY_PLUGIN_IMPORT_TOKEN = 'accessibility-automation/plugin'; + +const scanConfigForAccessibilityPlugin = (user_config) => { + try { + const configPath = user_config.run_settings && user_config.run_settings.cypressConfigFilePath; + if (!configPath || !fs.existsSync(configPath)) return false; + const content = fs.readFileSync(configPath, { encoding: 'utf-8' }); + return content.includes(ACCESSIBILITY_PLUGIN_IMPORT_TOKEN); + } catch (error) { + logger.debug(`Unable to scan cypress config for accessibility plugin: ${error.message || error}`); + return false; + } +}; + +/** + * Determines whether the BrowserStack accessibility plugin is loaded in the + * user's cypress config. Reading the cypress config executes its top-level + * requires; the accessibility plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED + * when loaded, which readCypressConfigFile propagates back to this process as a + * definitive 'true'/'false'. If the config could not be required (env var stays + * undefined), we fall back to a raw-text scan so users are not wrongly disabled. + */ +exports.isAccessibilityPluginLoaded = (user_config) => { + try { + // Reset before reading so a stale value from a previous run cannot leak in. + delete process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED; + const { readCypressConfigFile } = require('../helpers/readCypressConfigUtil'); + readCypressConfigFile(user_config); + + const detection = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED; + if (detection === 'true') return true; + if (detection === 'false') return false; + + // Inconclusive (config could not be required) — fall back to a text scan. + logger.debug('Accessibility plugin detection inconclusive from config require; falling back to source scan.'); + return scanConfigForAccessibilityPlugin(user_config); + } catch (error) { + logger.debug(`Unable to determine if accessibility plugin is loaded: ${error.message || error}`); + return scanConfigForAccessibilityPlugin(user_config); + } +} + exports.createAccessibilityTestRun = async (user_config, framework) => { try { diff --git a/bin/accessibility-automation/plugin/index.js b/bin/accessibility-automation/plugin/index.js index 5ea42676..5b601240 100644 --- a/bin/accessibility-automation/plugin/index.js +++ b/bin/accessibility-automation/plugin/index.js @@ -3,7 +3,18 @@ const { decodeJWTToken } = require("../../helpers/utils"); const utils = require('../../helpers/utils'); const http = require('http'); +// Marker set as soon as this plugin module is loaded by the user's cypress +// config (via `require('browserstack-cypress-cli/bin/accessibility-automation/plugin')`). +// The CLI reads the cypress config (which executes its top-level requires) before +// sending the build start event, and uses this marker to determine whether the +// accessibility plugin is actually wired in. Unlike a static text scan of the +// config file, this does NOT false-positive on commented-out requires. +process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true'; + const browserstackAccessibility = (on, config) => { + // Also set on invocation, so that a runtime read of the plugin reflects that + // it was actually called within setupNodeEvents. + process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true'; let browser_validation = true; if (process.env.BROWSERSTACK_ACCESSIBILITY_DEBUG === 'true') { config.env.BROWSERSTACK_LOGS = 'true'; diff --git a/bin/commands/runs.js b/bin/commands/runs.js index 4d13d51e..df02954a 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -30,10 +30,11 @@ const { printBuildLink } = require('../testObservability/helper/helper'); -const { +const { createAccessibilityTestRun, setAccessibilityEventListeners, checkAccessibilityPlatform, + isAccessibilityPluginLoaded, supportFileCleanup } = require('../accessibility-automation/helper'); const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper'); @@ -42,6 +43,10 @@ const TestHubHandler = require('../testhub/testhubHandler'); module.exports = function run(args, rawArgs) { utils.normalizeTestReportingEnvVars(); + // Tracks the case where accessibility was requested but the plugin is not + // wired into the cypress config; surfaced in the end-of-session EDS event so + // such builds can be excluded from accessibility stability queries. + let accessibilityPluginNotLoaded = false; markBlockStart('preBuild'); // set debug mode (--cli-debug) utils.setDebugMode(args); @@ -69,7 +74,7 @@ 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; + let isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility; const turboScaleSession = isTurboScaleSession(bsConfig); Constants.turboScaleObj.enabled = turboScaleSession; @@ -113,6 +118,15 @@ module.exports = function run(args, rawArgs) { // set build tag caps utils.setBuildTags(bsConfig, args); + // If accessibility is requested but the BrowserStack accessibility plugin is + // not loaded in the cypress config, explicitly disable accessibility before + // the build start event so the build is not treated as an accessibility build. + if (isAccessibilitySession && isBrowserstackInfra && !isAccessibilityPluginLoaded(bsConfig)) { + logger.warn(Constants.userMessages.ACCESSIBILITY_PLUGIN_NOT_LOADED); + accessibilityPluginNotLoaded = true; + isAccessibilitySession = false; + } + checkAndSetAccessibility(bsConfig, isAccessibilitySession); const preferredPort = 5348; @@ -422,6 +436,7 @@ module.exports = function run(args, rawArgs) { unique_id: utils.generateUniqueHash(), package_error: utils.checkError(packageData), checkmd5_error: utils.checkError(md5data), + accessibility_plugin_not_loaded: accessibilityPluginNotLoaded, build_id: data.build_id, test_zip_size: test_zip_size, npm_zip_size: npm_zip_size, diff --git a/bin/helpers/config.js b/bin/helpers/config.js index e689a61c..991f8201 100644 --- a/bin/helpers/config.js +++ b/bin/helpers/config.js @@ -26,6 +26,9 @@ config.retries = 5; config.networkErrorExitCode = 2; config.compiledConfigJsDirName = 'tmpBstackCompiledJs'; config.configJsonFileName = 'tmpCypressConfig.json'; +// Temp file used to surface, from the child process that requires the cypress +// config, whether the BrowserStack accessibility plugin was loaded by it. +config.accessibilityPluginFlagFileName = 'tmpA11yPluginLoaded.json'; // turboScale config.turboScaleMd5Sum = `${config.turboScaleUrl}/md5sumcheck`; diff --git a/bin/helpers/constants.js b/bin/helpers/constants.js index d55dc988..c0bdd495 100644 --- a/bin/helpers/constants.js +++ b/bin/helpers/constants.js @@ -19,6 +19,8 @@ const syncCLI = { }; const userMessages = { + ACCESSIBILITY_PLUGIN_NOT_LOADED: + "BrowserStack Accessibility Automation plugin is not loaded in your cypress config file. Disabling accessibility for this build. Please follow https://www.browserstack.com/docs/accessibility/automated-tests/get-started/cypress to enable accessibility testing.", BUILD_FAILED: "Build creation failed.", BUILD_GENERATE_REPORT_FAILED: "Generating report for the build failed.", diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index 735d2000..52d17bda 100644 --- a/bin/helpers/readCypressConfigUtil.js +++ b/bin/helpers/readCypressConfigUtil.js @@ -233,6 +233,21 @@ exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => { if (fs.existsSync(config.configJsonFileName)) { fs.unlinkSync(config.configJsonFileName) } + + // Propagate accessibility-plugin detection (written by requireModule.js in the + // child process) back into the parent process via an env var. We set it + // explicitly to 'true'/'false' only when the config was actually required, so + // callers can distinguish a definitive result from "could not read". + try { + if (fs.existsSync(config.accessibilityPluginFlagFileName)) { + const flag = JSON.parse(fs.readFileSync(config.accessibilityPluginFlagFileName).toString()); + process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = (flag && flag.accessibilityPluginLoaded) ? 'true' : 'false'; + fs.unlinkSync(config.accessibilityPluginFlagFileName); + } + } catch (err) { + logger.debug(`Unable to read accessibility plugin detection flag: ${err.message}`); + } + return cypress_config } diff --git a/bin/helpers/requireModule.js b/bin/helpers/requireModule.js index 90abd0e0..283ffd8f 100644 --- a/bin/helpers/requireModule.js +++ b/bin/helpers/requireModule.js @@ -12,3 +12,17 @@ if (fs.existsSync(config.configJsonFileName)) { // write module in temporary json file fs.writeFileSync(config.configJsonFileName, JSON.stringify(mod)) + +// Requiring the cypress config above executes its top-level requires, which +// includes the BrowserStack accessibility plugin when the user has wired it in. +// The plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load; surface that +// back to the parent CLI process via a temp flag file. +try { + const accessibilityPluginLoaded = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED === 'true'; + fs.writeFileSync( + config.accessibilityPluginFlagFileName, + JSON.stringify({ accessibilityPluginLoaded }) + ); +} catch (err) { + // best-effort: detection falls back to "not loaded" if this fails +} From a7bea68198296fc7af8a4fd735d296f453f58013 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Mon, 22 Jun 2026 12:32:23 +0530 Subject: [PATCH 2/2] fixed the case of plugin loaded but not invoked --- bin/accessibility-automation/helper.js | 104 ++++++++++++++++++++----- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index c0049edd..d1383eab 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -41,19 +41,64 @@ exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => { return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); } -// Fallback: scan the raw cypress config source for the accessibility plugin -// import. Used only when the config could not be required (e.g. a TypeScript -// config before BrowserStack packages are installed), so that such users are -// not wrongly disabled. The substring matches require()/import of the plugin -// regardless of path style or imported symbol name. -const ACCESSIBILITY_PLUGIN_IMPORT_TOKEN = 'accessibility-automation/plugin'; +// Strip JS/TS comments so that commented-out plugin imports/calls are ignored +// by the static scans below. Best-effort: handles block and line comments while +// avoiding `://` in URLs. +const stripComments = (src) => { + return src + .replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments + .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // line comments (skip URLs like http://) +}; + +// Reads the cypress config source (comments stripped). Returns null if it cannot +// be read. +const readConfigSource = (user_config) => { + const configPath = user_config.run_settings && user_config.run_settings.cypressConfigFilePath; + if (!configPath || !fs.existsSync(configPath)) return null; + return stripComments(fs.readFileSync(configPath, { encoding: 'utf-8' })); +}; + +// Finds the symbol the accessibility plugin is imported as, via require() or +// import, regardless of path style. Returns the binding name or null. +const getAccessibilityPluginBinding = (content) => { + const requireMatch = content.match(/(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*require\(\s*['"][^'"]*accessibility-automation\/plugin['"]\s*\)/); + const importMatch = content.match(/import\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/); + return (requireMatch && requireMatch[1]) || (importMatch && importMatch[1]) || null; +}; + +const isBindingCalled = (content, binding) => { + const callRegex = new RegExp('\\b' + binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\('); + return callRegex.test(content); +}; -const scanConfigForAccessibilityPlugin = (user_config) => { +// Static check: confirm the (already-imported) accessibility plugin is actually +// invoked in the config source. Lenient — if the import binding cannot be located +// via static parsing (unusual syntax) or the source cannot be read, we do NOT +// veto the require-based detection (return true), to avoid wrongly disabling +// valid configs. +const isAccessibilityPluginInvokedInSource = (user_config) => { try { - const configPath = user_config.run_settings && user_config.run_settings.cypressConfigFilePath; - if (!configPath || !fs.existsSync(configPath)) return false; - const content = fs.readFileSync(configPath, { encoding: 'utf-8' }); - return content.includes(ACCESSIBILITY_PLUGIN_IMPORT_TOKEN); + const content = readConfigSource(user_config); + if (content === null) return true; + const binding = getAccessibilityPluginBinding(content); + if (!binding) return true; + return isBindingCalled(content, binding); + } catch (error) { + logger.debug(`Unable to verify accessibility plugin invocation: ${error.message || error}`); + return true; + } +}; + +// Pure static fallback: confirm the plugin is BOTH imported AND invoked. Used +// only when the config could not be required (e.g. a TypeScript config before +// BrowserStack packages are installed), so such users are still evaluated. +const isAccessibilityPluginImportedAndCalledInSource = (user_config) => { + try { + const content = readConfigSource(user_config); + if (content === null) return false; + const binding = getAccessibilityPluginBinding(content); + if (!binding) return false; + return isBindingCalled(content, binding); } catch (error) { logger.debug(`Unable to scan cypress config for accessibility plugin: ${error.message || error}`); return false; @@ -61,12 +106,21 @@ const scanConfigForAccessibilityPlugin = (user_config) => { }; /** - * Determines whether the BrowserStack accessibility plugin is loaded in the - * user's cypress config. Reading the cypress config executes its top-level - * requires; the accessibility plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED - * when loaded, which readCypressConfigFile propagates back to this process as a - * definitive 'true'/'false'. If the config could not be required (env var stays - * undefined), we fall back to a raw-text scan so users are not wrongly disabled. + * Determines whether the BrowserStack accessibility plugin is genuinely wired + * into the user's cypress config, i.e. both imported AND invoked. + * + * Detection combines two signals: + * 1) Require-load: reading the cypress config executes its top-level requires; + * the plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load, which + * readCypressConfigFile propagates back as a definitive 'true'/'false'. This + * tells us whether the plugin is imported (and does not false-positive on a + * commented-out require, since commented code never executes). + * 2) Static source scan: confirms the imported plugin binding is actually called + * in the config — so "imported but never called" is treated as not loaded. + * + * If the config could not be required (env var stays undefined, e.g. a TS config + * before packages are installed), we fall back to a pure static scan that checks + * for both import and invocation. */ exports.isAccessibilityPluginLoaded = (user_config) => { try { @@ -76,15 +130,23 @@ exports.isAccessibilityPluginLoaded = (user_config) => { readCypressConfigFile(user_config); const detection = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED; - if (detection === 'true') return true; + if (detection === 'true') { + // Imported via require — additionally require that it is actually invoked. + const called = isAccessibilityPluginInvokedInSource(user_config); + if (!called) { + logger.debug('Accessibility plugin is imported but not invoked in the cypress config; treating as not loaded.'); + } + return called; + } if (detection === 'false') return false; - // Inconclusive (config could not be required) — fall back to a text scan. + // Inconclusive (config could not be required) — fall back to a static scan + // that checks for both import and invocation. logger.debug('Accessibility plugin detection inconclusive from config require; falling back to source scan.'); - return scanConfigForAccessibilityPlugin(user_config); + return isAccessibilityPluginImportedAndCalledInSource(user_config); } catch (error) { logger.debug(`Unable to determine if accessibility plugin is loaded: ${error.message || error}`); - return scanConfigForAccessibilityPlugin(user_config); + return isAccessibilityPluginImportedAndCalledInSource(user_config); } }