diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index e0ec145f..d1383eab 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -41,6 +41,115 @@ exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => { return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); } +// 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); +}; + +// 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 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; + } +}; + +/** + * 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 { + // 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') { + // 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 static scan + // that checks for both import and invocation. + logger.debug('Accessibility plugin detection inconclusive from config require; falling back to source scan.'); + return isAccessibilityPluginImportedAndCalledInSource(user_config); + } catch (error) { + logger.debug(`Unable to determine if accessibility plugin is loaded: ${error.message || error}`); + return isAccessibilityPluginImportedAndCalledInSource(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 +}