Skip to content
122 changes: 70 additions & 52 deletions bin/accessibility-automation/cypress/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,56 +44,69 @@ const performModifiedScan = (originalFn, Subject, stateType, ...args) => {
}

const performScan = (win, payloadToSend) =>
new Promise(async (resolve, reject) => {
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
if (!isHttpOrHttps) {
return resolve();
}
new Promise((resolve) => {
// SDK-6463: this promise MUST always settle (never hang, never reject). It runs inside the
// global afterEach; if it hangs, cy.wrap()'s 30s timeout fails the hook and Cypress skips
// the rest of the spec. Failure modes guarded here:
// - the injected scanner never dispatches A11Y_SCAN_FINISHED (page mid-navigation / slow scan)
// - win is cross-origin (e.g. an SSO redirect) so win.location / win.document throw synchronously
let settled = false;
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
const overallTimer = setTimeout(() => finish("Accessibility scan timed out"), overallTimeout);

function findAccessibilityAutomationElement() {
return win.document.querySelector("#accessibility-automation-element");
}
try {
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
if (!isHttpOrHttps) {
return finish();
}

function waitForScannerReadiness(retryCount = 100, retryInterval = 100) {
return new Promise(async (resolve, reject) => {
let count = 0;
const intervalID = setInterval(async () => {
if (count > retryCount) {
clearInterval(intervalID);
return reject(
new Error(
"Accessibility Automation Scanner is not ready on the page."
)
);
} else if (findAccessibilityAutomationElement()) {
clearInterval(intervalID);
return resolve("Scanner set");
} else {
count += 1;
}
}, retryInterval);
});
}
function findAccessibilityAutomationElement() {
return win.document.querySelector("#accessibility-automation-element");
}

function startScan() {
function onScanComplete() {
win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete);
return resolve();
function waitForScannerReadiness(retryCount = 100, retryInterval = 100) {
return new Promise((resolve, reject) => {
let count = 0;
const intervalID = setInterval(() => {
if (count > retryCount) {
clearInterval(intervalID);
return reject(
new Error(
"Accessibility Automation Scanner is not ready on the page."
)
);
} else if (findAccessibilityAutomationElement()) {
clearInterval(intervalID);
return resolve("Scanner set");
} else {
count += 1;
}
}, retryInterval);
});
}

win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete);
const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend });
win.dispatchEvent(e);
}
function startScan() {
function onScanComplete() {
win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete);
return finish();
}

if (findAccessibilityAutomationElement()) {
startScan();
} else {
waitForScannerReadiness()
.then(startScan)
.catch(async (err) => {
return resolve("Scanner is not ready on the page after multiple retries. performscan");
});
win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete);
const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend });
win.dispatchEvent(e);
}

if (findAccessibilityAutomationElement()) {
startScan();
} else {
waitForScannerReadiness()
.then(startScan)
.catch(() => finish("Scanner is not ready on the page after multiple retries. performscan"));
}
} catch (err) {
// cross-origin window access or any unexpected error must not fail the hook
finish();
}
})

Expand Down Expand Up @@ -206,11 +219,17 @@ new Promise((resolve) => {
});

const saveTestResults = (win, payloadToSend) =>
new Promise( (resolve, reject) => {
new Promise((resolve) => {
// SDK-6463: must always settle (see performScan note) so a slow/absent A11Y_RESULTS_SAVED
// event or a cross-origin window cannot fail the afterEach hook.
let settled = false;
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
const overallTimer = setTimeout(() => finish("Accessibility results save timed out"), overallTimeout);
try {
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
if (!isHttpOrHttps) {
resolve("Unable to save accessibility results, Invalid URL.");
finish("Unable to save accessibility results, Invalid URL.");
return;
}

Expand Down Expand Up @@ -241,7 +260,8 @@ new Promise( (resolve, reject) => {

function saveResults() {
function onResultsSaved(event) {
return resolve();
win.removeEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
return finish();
}
win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
const e = new CustomEvent("A11Y_SAVE_RESULTS", {
Expand All @@ -255,13 +275,11 @@ new Promise( (resolve, reject) => {
} else {
waitForScannerReadiness()
.then(saveResults)
.catch(async (err) => {
return resolve("Scanner is not ready on the page after multiple retries. after run");
});
.catch(() => finish("Scanner is not ready on the page after multiple retries. after run"));
}
} catch(error) {
browserStackLog(`Error in saving results with error: ${error.message}`);
return resolve();
browserStackLog(`Error in saving results with error: ${error.message}`);
finish();
}

})
Expand Down
33 changes: 29 additions & 4 deletions bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,20 @@ function generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, c
"listEmittedFiles": true,
// Ensure these are always set regardless of base tsconfig
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
"esModuleInterop": true,
// Force a clean, self-contained JS emit even when the extended tsconfig
// (common in NX / monorepo setups) sets options that suppress or redirect
// the JS output. Without these overrides, base options such as
// noEmit / emitDeclarationOnly / composite / noEmitOnError leave the
// compiled cypress config missing, surfacing as
// "Cypress config file not found at: ...tmpBstackCompiledJs/..." (SDK-6463).
"noEmit": false,
"emitDeclarationOnly": false,
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmitOnError": false,
"incremental": false
},
include: [cypress_config_filepath]
};
Expand Down Expand Up @@ -137,13 +150,25 @@ function generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, c
? `set NODE_PATH=${bstack_node_modules_path}`
: `NODE_PATH="${bstack_node_modules_path}"`;

const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" && ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
// Use '&' (unconditional) instead of '&&' between tsc and tsc-alias so the alias
// rewrite ALWAYS runs even when tsc exits non-zero. tsc returns a non-zero exit
// code on any type error (very common when a single config file is compiled out of
// its normal monorepo project context), which with '&&' would skip tsc-alias and
// leave path aliases (e.g. @org/lib) un-rewritten -> the compiled config fails to
// require -> "Cypress config file not found" (SDK-6463). convertTsConfig already
// tolerates tsc errors by parsing the emitted-files output.
const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" & ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Warning — [FEATURE/FIX CORRECTNESS] Windows & introduces parallel execution, not sequential-unconditional

Problem

On Windows CMD, cmd1 & cmd2 executes both commands concurrentlycmd2 starts immediately without waiting for cmd1 to finish. This is the opposite of Unix ;, which is strictly sequential.

In this context: if tsc-alias starts before tsc has written the compiled .js to tmpBstackCompiledJs/, it processes a not-yet-emitted file — the alias rewrite is a silent no-op, and the compiled config still can't be require()d.

The intent stated in the comment is "run tsc-alias regardless of tsc's exit code, but after tsc completes." & satisfies "regardless of exit code" but not "after tsc completes."

Unix ; (the else branch) is exactly right. Windows & is not the equivalent.

Suggested Fix

Replace & with || cd. (a no-op fallback that preserves sequencing):

// Windows: '|| cd.' is the CMD idiom for "run B sequentially, regardless of A's exit code"
const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" || cd. && ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;

Or, if you prefer PowerShell (more readable, avoids CMD-ism):

const tscCommand = `powershell -Command "& '${typescript_path}' --project '${tempTsConfigPath}'; & '${tsc_alias_path}' --project '${tempTsConfigPath}' --verbose"`;

If you've empirically verified that & doesn't race in practice (e.g., because CMD buffers background-job start in non-interactive mode), add a code comment documenting that and the warning is acknowledged.

Confidence: 🟢 Objectively verifiable — Windows CMD & operator semantics are documented; the race is reproducible by running both commands in CMD with a slow tsc invocation.

logger.info(`TypeScript compilation command: ${tscCommand}`);
return { tscCommand, tempTsConfigPath };
} else {
// Unix/Linux/macOS: Use ; to separate commands or && to chain
// Unix/Linux/macOS: Use ';' (unconditional) between tsc and tsc-alias so the alias
// rewrite ALWAYS runs even when tsc exits non-zero (type errors are common when a
// single config file is compiled out of its monorepo context). With '&&', a tsc
// error would skip tsc-alias and leave path aliases (e.g. @org/lib) un-rewritten,
// making the compiled config impossible to require (SDK-6463). convertTsConfig
// already tolerates tsc errors by parsing the emitted-files output.
const nodePathPrefix = `NODE_PATH=${bstack_node_modules_path}`;
const tscCommand = `${nodePathPrefix} node "${typescript_path}" --project "${tempTsConfigPath}" && ${nodePathPrefix} node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
const tscCommand = `${nodePathPrefix} node "${typescript_path}" --project "${tempTsConfigPath}" ; ${nodePathPrefix} node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
logger.info(`TypeScript compilation command: ${tscCommand}`);
return { tscCommand, tempTsConfigPath };
}
Expand Down
39 changes: 35 additions & 4 deletions bin/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,29 @@ exports.getFilesToIgnore = (runSettings, excludeFiles, logging = true) => {
return ignoreFiles;
}

// SDK-6463: glob.sync can throw deep inside minimatch (e.g. "expand is not a function" /
// "brace_expansion_1.default is not a function") when a project force-resolves an
// incompatible 'brace-expansion'/'minimatch' major (e.g. brace-expansion@5) across the
// dependency tree via yarn resolutions / npm overrides. That crash used to abort spec
// discovery in getNumberOfSpecFiles and produce a build with 0 executed tests (or crash the
// run entirely). Degrade gracefully: log once and return no matches so the run still proceeds
// (specs are resolved on BrowserStack regardless of the local count).
let _loggedGlobFailure = false;
const safeGlobSync = (pattern, options) => {
try {
return glob.sync(pattern, options);
} catch (err) {
if (!_loggedGlobFailure) {
_loggedGlobFailure = true;
logger.warn(`Could not enumerate spec files locally (glob failed: ${err && err.message}). This usually means an incompatible 'brace-expansion'/'minimatch' version was forced via package resolutions/overrides. Continuing — specs will be resolved on BrowserStack; local parallelisation may be reduced.`);
}
return [];
}
};
exports.safeGlobSync = safeGlobSync;

exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession=false) => {
try {
let defaultSpecFolder
let testFolderPath
let globCypressConfigSpecPatterns = []
Expand All @@ -1128,7 +1150,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
const filesMatched = [];
globCypressConfigSpecPatterns.forEach(specPattern => {
filesMatched.push(
...glob.sync(specPattern, {
...safeGlobSync(specPattern, {
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
})
);
Expand Down Expand Up @@ -1158,7 +1180,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
let fileMatchedWithConfigSpecPattern = []
globCypressConfigSpecPatterns.forEach(specPattern => {
fileMatchedWithConfigSpecPattern.push(
...glob.sync(specPattern, {
...safeGlobSync(specPattern, {
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
})
);
Expand All @@ -1167,7 +1189,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession

let files
if (globSearchPattern) {
let fileMatchedWithBstackSpecPattern = glob.sync(globSearchPattern, {
let fileMatchedWithBstackSpecPattern = safeGlobSync(globSearchPattern, {
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
});
fileMatchedWithBstackSpecPattern = fileMatchedWithBstackSpecPattern.map((file) => path.resolve(bsConfig.run_settings.cypressProjectDir, file))
Expand Down Expand Up @@ -1195,6 +1217,11 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
bsConfig.run_settings.specs = files;
}
return files;
} catch (err) {
// SDK-6463 backstop: never let spec-counting crash the run. Proceed without a local count.
logger.warn(`Could not determine spec files locally: ${err && err.message}. Continuing; specs will be resolved on BrowserStack.`);
return [];
}
};

exports.sanitizeSpecsPattern = (pattern) => {
Expand Down Expand Up @@ -1349,6 +1376,10 @@ exports.isJSONInvalid = (err, args) => {
}

exports.deleteBaseUrlFromError = (err) => {
// SDK-6463: guard against non-string errors. This is called from the run's error handler
// (isJSONInvalid); if a real Error object reaches it, err.replace(...) throws a secondary
// TypeError that masks the original failure.
if (typeof err !== 'string') return err;
return err.replace(/To test ([\s\S]*)on BrowserStack/g, 'To test on BrowserStack');
}

Expand Down Expand Up @@ -1431,7 +1462,7 @@ exports.setEnforceSettingsConfig = (bsConfig, args) => {
let specFilesMatched = [];
specConfigs.forEach(specPattern => {
specFilesMatched.push(
...glob.sync(specPattern, {
...safeGlobSync(specPattern, {
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
})
);
Expand Down
Loading
Loading