From c281a007cdce112ad42159a9a328562c23c5c3a9 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Sat, 20 Jun 2026 13:13:50 +0530 Subject: [PATCH 1/3] 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/3] 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); } } From 6bea75293944c00cddc4379a6ad3f790fdf735e3 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Thu, 25 Jun 2026 16:53:32 +0530 Subject: [PATCH 3/3] Address PR review comments on accessibility plugin detection - Memoize parsed cypress config in readCypressConfigFile (module-level cache keyed by resolved path) so the detection, capabilityHelper and video-config call sites share a single compile + child require per run. - Widen static binding detection to cover `import * as X` and bias the strict source-scan fallback toward keeping accessibility enabled when the plugin path is present but the binding can't be statically parsed (named/dynamic imports), avoiding silently disabling a billed feature. Still disables on positive evidence (path absent, or binding parsed and never called). - Guarantee cleanup of the detection flag file: delete any stale flag before the child runs, and unlink it in readCypressConfigFile's finally block. - Document the stripComments scrubber as intentionally best-effort/lossy. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/accessibility-automation/helper.js | 45 ++++++++++++++++---- bin/accessibility-automation/plugin/index.js | 6 --- bin/helpers/readCypressConfigUtil.js | 37 +++++++++++++++- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index d1383eab..0cd0850f 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -41,9 +41,18 @@ exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => { return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); } +// Token identifying the accessibility plugin module path in source. +const ACCESSIBILITY_PLUGIN_PATH_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. +// by the static scans below. +// +// NOTE: this is an intentionally best-effort / lossy scrubber, NOT a real parser. +// It can also strip `//` or `/* */` sequences that appear inside string literals, +// and the `[^:]` guard only avoids `://` (URLs). This is acceptable because these +// static scans are a secondary signal: the authoritative "is the plugin imported" +// check is the require-load marker (BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED), so a +// mis-stripped string literal cannot cause a false import detection. const stripComments = (src) => { return src .replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments @@ -59,11 +68,19 @@ const readConfigSource = (user_config) => { }; // Finds the symbol the accessibility plugin is imported as, via require() or -// import, regardless of path style. Returns the binding name or null. +// import, regardless of path style. Handles `require()`, default `import X from`, +// and namespace `import * as X from`. Returns the binding name or null. Named +// (`import { X }`) and dynamic (`await import(...)`) forms are not parsed here — +// the strict fallback biases toward keeping accessibility on for those (see +// isAccessibilityPluginImportedAndCalledInSource). 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 importNamespaceMatch = content.match(/import\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/); + const importDefaultMatch = content.match(/import\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/); + return (requireMatch && requireMatch[1]) || + (importNamespaceMatch && importNamespaceMatch[1]) || + (importDefaultMatch && importDefaultMatch[1]) || + null; }; const isBindingCalled = (content, binding) => { @@ -89,15 +106,25 @@ const isAccessibilityPluginInvokedInSource = (user_config) => { } }; -// 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. +// Pure static fallback: 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. Biased toward KEEPING accessibility enabled: if the plugin +// path is present but we cannot confidently parse the import binding (e.g. named +// `import { X }` or dynamic `await import(...)`), we do not disable — silently +// turning off a billed feature based on a lossy source scan is worse than leaving +// it on. We only return false when there is positive evidence the plugin is not +// wired in (path absent, or binding parsed and demonstrably never called). const isAccessibilityPluginImportedAndCalledInSource = (user_config) => { try { const content = readConfigSource(user_config); if (content === null) return false; + // Plugin path not referenced at all -> definitely not imported. + if (!content.includes(ACCESSIBILITY_PLUGIN_PATH_TOKEN)) return false; const binding = getAccessibilityPluginBinding(content); - if (!binding) return false; + // Path present but binding not parseable (namespace/named/dynamic import) -> + // keep accessibility on rather than risk a false disable. + if (!binding) return true; + // Binding parsed: trust the precise call check (catches import-without-call). return isBindingCalled(content, binding); } catch (error) { logger.debug(`Unable to scan cypress config for accessibility plugin: ${error.message || error}`); diff --git a/bin/accessibility-automation/plugin/index.js b/bin/accessibility-automation/plugin/index.js index 5b601240..c51b2606 100644 --- a/bin/accessibility-automation/plugin/index.js +++ b/bin/accessibility-automation/plugin/index.js @@ -3,12 +3,6 @@ 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) => { diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index 52d17bda..c14ff314 100644 --- a/bin/helpers/readCypressConfigUtil.js +++ b/bin/helpers/readCypressConfigUtil.js @@ -8,6 +8,8 @@ const constants = require("./constants"); const utils = require("./utils"); const logger = require('./logger').winstonLogger; +const parsedCypressConfigCache = new Map(); + // Defense-in-depth: reject file paths containing shell metacharacters. // This guards against command injection even if execFileSync is ever // replaced with a shell-based exec in the future. @@ -226,6 +228,14 @@ exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => { }; const args = [require_module_helper_path, cypress_config_filepath]; + // Remove any stale detection flag from a crashed prior run so we never read an + // outdated value if the child fails to write a fresh one. + if (fs.existsSync(config.accessibilityPluginFlagFileName)) { + try { + fs.unlinkSync(config.accessibilityPluginFlagFileName) + } catch (e) { /* best-effort */ } + } + logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`); cp.execFileSync('node', args, execOptions); @@ -253,6 +263,13 @@ exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => { exports.readCypressConfigFile = (bsConfig) => { const cypress_config_filepath = path.resolve(bsConfig.run_settings.cypressConfigFilePath) + + // Return the memoized parse if this exact config was already read in this run. + if (parsedCypressConfigCache.has(cypress_config_filepath)) { + logger.debug(`Using memoized cypress config for: ${cypress_config_filepath}`); + return parsedCypressConfigCache.get(cypress_config_filepath); + } + try { const cypress_config_filename = bsConfig.run_settings.cypress_config_filename const bstack_node_modules_path = path.join(path.resolve(config.packageDirName), 'node_modules') @@ -260,12 +277,19 @@ exports.readCypressConfigFile = (bsConfig) => { logger.debug(`cypress config path: ${cypress_config_filepath}`); + let parsedConfig; if (conf_lang == 'js' || conf_lang == 'cjs') { - return this.loadJsFile(cypress_config_filepath, bstack_node_modules_path) + parsedConfig = this.loadJsFile(cypress_config_filepath, bstack_node_modules_path) } else if (conf_lang === 'ts') { const compiled_cypress_config_filepath = this.convertTsConfig(bsConfig, cypress_config_filepath, bstack_node_modules_path) - return this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path) + parsedConfig = this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path) } + + // Cache only successful parses so a later call can retry on failure. + if (parsedConfig !== undefined) { + parsedCypressConfigCache.set(cypress_config_filepath, parsedConfig); + } + return parsedConfig; } catch (error) { const errorMessage = `Error while reading cypress config: ${error.message}` const errorCode = 'cypress_config_file_read_failed' @@ -285,5 +309,14 @@ exports.readCypressConfigFile = (bsConfig) => { if (fs.existsSync(complied_js_dir)) { fs.rmdirSync(complied_js_dir, { recursive: true }) } + // Guaranteed cleanup of the accessibility-plugin detection flag file, even + // if loadJsFile threw before its own read/unlink of the flag. + if (fs.existsSync(config.accessibilityPluginFlagFileName)) { + try { + fs.unlinkSync(config.accessibilityPluginFlagFileName) + } catch (cleanupErr) { + logger.debug(`Unable to remove accessibility plugin flag file: ${cleanupErr.message || cleanupErr}`); + } + } } }