Skip to content

Commit 9b8d34b

Browse files
authored
Merge pull request #1138 from browserstack/sdk-6463-glob-brace-expansion-resilience
fix(SDK-6463): survive glob/minimatch crash from incompatible brace-expansion (0-test builds)
2 parents 5aec0a0 + 4b14e0f commit 9b8d34b

10 files changed

Lines changed: 293 additions & 8 deletions

File tree

bin/accessibility-automation/helper.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,142 @@ exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => {
4141
return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension);
4242
}
4343

44+
// Token identifying the accessibility plugin module path in source.
45+
const ACCESSIBILITY_PLUGIN_PATH_TOKEN = 'accessibility-automation/plugin';
46+
47+
// Strip JS/TS comments so that commented-out plugin imports/calls are ignored
48+
// by the static scans below.
49+
//
50+
// NOTE: this is an intentionally best-effort / lossy scrubber, NOT a real parser.
51+
// It can also strip `//` or `/* */` sequences that appear inside string literals,
52+
// and the `[^:]` guard only avoids `://` (URLs). This is acceptable because these
53+
// static scans are a secondary signal: the authoritative "is the plugin imported"
54+
// check is the require-load marker (BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED), so a
55+
// mis-stripped string literal cannot cause a false import detection.
56+
const stripComments = (src) => {
57+
return src
58+
.replace(/\/\*[\s\S]*?\*\//g, ' ') // block comments
59+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // line comments (skip URLs like http://)
60+
};
61+
62+
// Reads the cypress config source (comments stripped). Returns null if it cannot
63+
// be read.
64+
const readConfigSource = (user_config) => {
65+
const configPath = user_config.run_settings && user_config.run_settings.cypressConfigFilePath;
66+
if (!configPath || !fs.existsSync(configPath)) return null;
67+
return stripComments(fs.readFileSync(configPath, { encoding: 'utf-8' }));
68+
};
69+
70+
// Finds the symbol the accessibility plugin is imported as, via require() or
71+
// import, regardless of path style. Handles `require()`, default `import X from`,
72+
// and namespace `import * as X from`. Returns the binding name or null. Named
73+
// (`import { X }`) and dynamic (`await import(...)`) forms are not parsed here —
74+
// the strict fallback biases toward keeping accessibility on for those (see
75+
// isAccessibilityPluginImportedAndCalledInSource).
76+
const getAccessibilityPluginBinding = (content) => {
77+
const requireMatch = content.match(/(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*require\(\s*['"][^'"]*accessibility-automation\/plugin['"]\s*\)/);
78+
const importNamespaceMatch = content.match(/import\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/);
79+
const importDefaultMatch = content.match(/import\s+([A-Za-z0-9_$]+)\s+from\s+['"][^'"]*accessibility-automation\/plugin['"]/);
80+
return (requireMatch && requireMatch[1]) ||
81+
(importNamespaceMatch && importNamespaceMatch[1]) ||
82+
(importDefaultMatch && importDefaultMatch[1]) ||
83+
null;
84+
};
85+
86+
const isBindingCalled = (content, binding) => {
87+
const callRegex = new RegExp('\\b' + binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(');
88+
return callRegex.test(content);
89+
};
90+
91+
// Static check: confirm the (already-imported) accessibility plugin is actually
92+
// invoked in the config source. Lenient — if the import binding cannot be located
93+
// via static parsing (unusual syntax) or the source cannot be read, we do NOT
94+
// veto the require-based detection (return true), to avoid wrongly disabling
95+
// valid configs.
96+
const isAccessibilityPluginInvokedInSource = (user_config) => {
97+
try {
98+
const content = readConfigSource(user_config);
99+
if (content === null) return true;
100+
const binding = getAccessibilityPluginBinding(content);
101+
if (!binding) return true;
102+
return isBindingCalled(content, binding);
103+
} catch (error) {
104+
logger.debug(`Unable to verify accessibility plugin invocation: ${error.message || error}`);
105+
return true;
106+
}
107+
};
108+
109+
// Pure static fallback: used only when the config could not be required (e.g. a
110+
// TypeScript config before BrowserStack packages are installed), so such users
111+
// are still evaluated. Biased toward KEEPING accessibility enabled: if the plugin
112+
// path is present but we cannot confidently parse the import binding (e.g. named
113+
// `import { X }` or dynamic `await import(...)`), we do not disable — silently
114+
// turning off a billed feature based on a lossy source scan is worse than leaving
115+
// it on. We only return false when there is positive evidence the plugin is not
116+
// wired in (path absent, or binding parsed and demonstrably never called).
117+
const isAccessibilityPluginImportedAndCalledInSource = (user_config) => {
118+
try {
119+
const content = readConfigSource(user_config);
120+
if (content === null) return false;
121+
// Plugin path not referenced at all -> definitely not imported.
122+
if (!content.includes(ACCESSIBILITY_PLUGIN_PATH_TOKEN)) return false;
123+
const binding = getAccessibilityPluginBinding(content);
124+
// Path present but binding not parseable (namespace/named/dynamic import) ->
125+
// keep accessibility on rather than risk a false disable.
126+
if (!binding) return true;
127+
// Binding parsed: trust the precise call check (catches import-without-call).
128+
return isBindingCalled(content, binding);
129+
} catch (error) {
130+
logger.debug(`Unable to scan cypress config for accessibility plugin: ${error.message || error}`);
131+
return false;
132+
}
133+
};
134+
135+
/**
136+
* Determines whether the BrowserStack accessibility plugin is genuinely wired
137+
* into the user's cypress config, i.e. both imported AND invoked.
138+
*
139+
* Detection combines two signals:
140+
* 1) Require-load: reading the cypress config executes its top-level requires;
141+
* the plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load, which
142+
* readCypressConfigFile propagates back as a definitive 'true'/'false'. This
143+
* tells us whether the plugin is imported (and does not false-positive on a
144+
* commented-out require, since commented code never executes).
145+
* 2) Static source scan: confirms the imported plugin binding is actually called
146+
* in the config — so "imported but never called" is treated as not loaded.
147+
*
148+
* If the config could not be required (env var stays undefined, e.g. a TS config
149+
* before packages are installed), we fall back to a pure static scan that checks
150+
* for both import and invocation.
151+
*/
152+
exports.isAccessibilityPluginLoaded = (user_config) => {
153+
try {
154+
// Reset before reading so a stale value from a previous run cannot leak in.
155+
delete process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED;
156+
const { readCypressConfigFile } = require('../helpers/readCypressConfigUtil');
157+
readCypressConfigFile(user_config);
158+
159+
const detection = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED;
160+
if (detection === 'true') {
161+
// Imported via require — additionally require that it is actually invoked.
162+
const called = isAccessibilityPluginInvokedInSource(user_config);
163+
if (!called) {
164+
logger.debug('Accessibility plugin is imported but not invoked in the cypress config; treating as not loaded.');
165+
}
166+
return called;
167+
}
168+
if (detection === 'false') return false;
169+
170+
// Inconclusive (config could not be required) — fall back to a static scan
171+
// that checks for both import and invocation.
172+
logger.debug('Accessibility plugin detection inconclusive from config require; falling back to source scan.');
173+
return isAccessibilityPluginImportedAndCalledInSource(user_config);
174+
} catch (error) {
175+
logger.debug(`Unable to determine if accessibility plugin is loaded: ${error.message || error}`);
176+
return isAccessibilityPluginImportedAndCalledInSource(user_config);
177+
}
178+
}
179+
44180
exports.createAccessibilityTestRun = async (user_config, framework) => {
45181

46182
try {

bin/accessibility-automation/plugin/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ const { decodeJWTToken } = require("../../helpers/utils");
33
const utils = require('../../helpers/utils');
44
const http = require('http');
55

6+
process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true';
7+
68
const browserstackAccessibility = (on, config) => {
9+
// Also set on invocation, so that a runtime read of the plugin reflects that
10+
// it was actually called within setupNodeEvents.
11+
process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = 'true';
712
let browser_validation = true;
813
if (process.env.BROWSERSTACK_ACCESSIBILITY_DEBUG === 'true') {
914
config.env.BROWSERSTACK_LOGS = 'true';

bin/commands/runs.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ const {
3030
printBuildLink
3131
} = require('../testObservability/helper/helper');
3232

33-
const {
33+
const {
3434
createAccessibilityTestRun,
3535
setAccessibilityEventListeners,
3636
checkAccessibilityPlatform,
37+
isAccessibilityPluginLoaded,
3738
supportFileCleanup
3839
} = require('../accessibility-automation/helper');
3940
const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper');
@@ -42,6 +43,10 @@ const TestHubHandler = require('../testhub/testhubHandler');
4243

4344
module.exports = function run(args, rawArgs) {
4445
utils.normalizeTestReportingEnvVars();
46+
// Tracks the case where accessibility was requested but the plugin is not
47+
// wired into the cypress config; surfaced in the end-of-session EDS event so
48+
// such builds can be excluded from accessibility stability queries.
49+
let accessibilityPluginNotLoaded = false;
4550
markBlockStart('preBuild');
4651
// set debug mode (--cli-debug)
4752
utils.setDebugMode(args);
@@ -69,7 +74,7 @@ module.exports = function run(args, rawArgs) {
6974
/* Set testObservability & browserstackAutomation flags */
7075
const [isTestObservabilitySession, isBrowserstackInfra] = setTestObservabilityFlags(bsConfig);
7176
const checkAccessibility = checkAccessibilityPlatform(bsConfig);
72-
const isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility;
77+
let isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility;
7378
const turboScaleSession = isTurboScaleSession(bsConfig);
7479
Constants.turboScaleObj.enabled = turboScaleSession;
7580

@@ -113,6 +118,15 @@ module.exports = function run(args, rawArgs) {
113118
// set build tag caps
114119
utils.setBuildTags(bsConfig, args);
115120

121+
// If accessibility is requested but the BrowserStack accessibility plugin is
122+
// not loaded in the cypress config, explicitly disable accessibility before
123+
// the build start event so the build is not treated as an accessibility build.
124+
if (isAccessibilitySession && isBrowserstackInfra && !isAccessibilityPluginLoaded(bsConfig)) {
125+
logger.warn(Constants.userMessages.ACCESSIBILITY_PLUGIN_NOT_LOADED);
126+
accessibilityPluginNotLoaded = true;
127+
isAccessibilitySession = false;
128+
}
129+
116130
checkAndSetAccessibility(bsConfig, isAccessibilitySession);
117131

118132
const preferredPort = 5348;
@@ -422,6 +436,7 @@ module.exports = function run(args, rawArgs) {
422436
unique_id: utils.generateUniqueHash(),
423437
package_error: utils.checkError(packageData),
424438
checkmd5_error: utils.checkError(md5data),
439+
accessibility_plugin_not_loaded: accessibilityPluginNotLoaded,
425440
build_id: data.build_id,
426441
test_zip_size: test_zip_size,
427442
npm_zip_size: npm_zip_size,

bin/helpers/config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ config.retries = 5;
2626
config.networkErrorExitCode = 2;
2727
config.compiledConfigJsDirName = 'tmpBstackCompiledJs';
2828
config.configJsonFileName = 'tmpCypressConfig.json';
29+
// Temp file used to surface, from the child process that requires the cypress
30+
// config, whether the BrowserStack accessibility plugin was loaded by it.
31+
config.accessibilityPluginFlagFileName = 'tmpA11yPluginLoaded.json';
2932

3033
// turboScale
3134
config.turboScaleMd5Sum = `${config.turboScaleUrl}/md5sumcheck`;

bin/helpers/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const syncCLI = {
1919
};
2020

2121
const userMessages = {
22+
ACCESSIBILITY_PLUGIN_NOT_LOADED:
23+
"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.",
2224
BUILD_FAILED: "Build creation failed.",
2325
BUILD_GENERATE_REPORT_FAILED:
2426
"Generating report for the build <build-id> failed.",

bin/helpers/readCypressConfigUtil.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const constants = require("./constants");
88
const utils = require("./utils");
99
const logger = require('./logger').winstonLogger;
1010

11+
const parsedCypressConfigCache = new Map();
12+
1113
// Defense-in-depth: reject file paths containing shell metacharacters.
1214
// This guards against command injection even if execFileSync is ever
1315
// replaced with a shell-based exec in the future.
@@ -251,31 +253,68 @@ exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => {
251253
};
252254
const args = [require_module_helper_path, cypress_config_filepath];
253255

256+
// Remove any stale detection flag from a crashed prior run so we never read an
257+
// outdated value if the child fails to write a fresh one.
258+
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
259+
try {
260+
fs.unlinkSync(config.accessibilityPluginFlagFileName)
261+
} catch (e) { /* best-effort */ }
262+
}
263+
254264
logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`);
255265
cp.execFileSync('node', args, execOptions);
256266

257267
const cypress_config = JSON.parse(fs.readFileSync(config.configJsonFileName).toString())
258268
if (fs.existsSync(config.configJsonFileName)) {
259269
fs.unlinkSync(config.configJsonFileName)
260270
}
271+
272+
// Propagate accessibility-plugin detection (written by requireModule.js in the
273+
// child process) back into the parent process via an env var. We set it
274+
// explicitly to 'true'/'false' only when the config was actually required, so
275+
// callers can distinguish a definitive result from "could not read".
276+
try {
277+
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
278+
const flag = JSON.parse(fs.readFileSync(config.accessibilityPluginFlagFileName).toString());
279+
process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED = (flag && flag.accessibilityPluginLoaded) ? 'true' : 'false';
280+
fs.unlinkSync(config.accessibilityPluginFlagFileName);
281+
}
282+
} catch (err) {
283+
logger.debug(`Unable to read accessibility plugin detection flag: ${err.message}`);
284+
}
285+
261286
return cypress_config
262287
}
263288

264289
exports.readCypressConfigFile = (bsConfig) => {
265290
const cypress_config_filepath = path.resolve(bsConfig.run_settings.cypressConfigFilePath)
291+
292+
// Return the memoized parse if this exact config was already read in this run.
293+
if (parsedCypressConfigCache.has(cypress_config_filepath)) {
294+
logger.debug(`Using memoized cypress config for: ${cypress_config_filepath}`);
295+
return parsedCypressConfigCache.get(cypress_config_filepath);
296+
}
297+
266298
try {
267299
const cypress_config_filename = bsConfig.run_settings.cypress_config_filename
268300
const bstack_node_modules_path = path.join(path.resolve(config.packageDirName), 'node_modules')
269301
const conf_lang = this.detectLanguage(cypress_config_filename)
270302

271303
logger.debug(`cypress config path: ${cypress_config_filepath}`);
272304

305+
let parsedConfig;
273306
if (conf_lang == 'js' || conf_lang == 'cjs') {
274-
return this.loadJsFile(cypress_config_filepath, bstack_node_modules_path)
307+
parsedConfig = this.loadJsFile(cypress_config_filepath, bstack_node_modules_path)
275308
} else if (conf_lang === 'ts') {
276309
const compiled_cypress_config_filepath = this.convertTsConfig(bsConfig, cypress_config_filepath, bstack_node_modules_path)
277-
return this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path)
310+
parsedConfig = this.loadJsFile(compiled_cypress_config_filepath, bstack_node_modules_path)
278311
}
312+
313+
// Cache only successful parses so a later call can retry on failure.
314+
if (parsedConfig !== undefined) {
315+
parsedCypressConfigCache.set(cypress_config_filepath, parsedConfig);
316+
}
317+
return parsedConfig;
279318
} catch (error) {
280319
const errorMessage = `Error while reading cypress config: ${error.message}`
281320
const errorCode = 'cypress_config_file_read_failed'
@@ -295,5 +334,14 @@ exports.readCypressConfigFile = (bsConfig) => {
295334
if (fs.existsSync(complied_js_dir)) {
296335
fs.rmdirSync(complied_js_dir, { recursive: true })
297336
}
337+
// Guaranteed cleanup of the accessibility-plugin detection flag file, even
338+
// if loadJsFile threw before its own read/unlink of the flag.
339+
if (fs.existsSync(config.accessibilityPluginFlagFileName)) {
340+
try {
341+
fs.unlinkSync(config.accessibilityPluginFlagFileName)
342+
} catch (cleanupErr) {
343+
logger.debug(`Unable to remove accessibility plugin flag file: ${cleanupErr.message || cleanupErr}`);
344+
}
345+
}
298346
}
299347
}

bin/helpers/requireModule.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ if (fs.existsSync(config.configJsonFileName)) {
1212

1313
// write module in temporary json file
1414
fs.writeFileSync(config.configJsonFileName, JSON.stringify(mod))
15+
16+
// Requiring the cypress config above executes its top-level requires, which
17+
// includes the BrowserStack accessibility plugin when the user has wired it in.
18+
// The plugin sets BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED on load; surface that
19+
// back to the parent CLI process via a temp flag file.
20+
try {
21+
const accessibilityPluginLoaded = process.env.BROWSERSTACK_ACCESSIBILITY_PLUGIN_LOADED === 'true';
22+
fs.writeFileSync(
23+
config.accessibilityPluginFlagFileName,
24+
JSON.stringify({ accessibilityPluginLoaded })
25+
);
26+
} catch (err) {
27+
// best-effort: detection falls back to "not loaded" if this fails
28+
}

0 commit comments

Comments
 (0)