Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 110 additions & 52 deletions bin/accessibility-automation/cypress/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,27 @@ const browserStackLog = (message) => {
if (!Cypress.env('BROWSERSTACK_LOGS')) return;
cy.task('browserstack_log', message);
}


// SDK-6463: circuit breaker for a dead/unresponsive accessibility scanner.
// Each hung scan/save costs up to ACCESSIBILITY_SCAN_TIMEOUT (default 25s). Without a
// breaker, a scanner that never responds stalls EVERY test's afterEach (and every
// wrapped command) by that much. After N consecutive timeouts we stop attempting
// accessibility work for the remainder of this spec file (module state resets per spec).
let consecutiveA11yTimeouts = 0;
let a11yCircuitOpen = false;
let a11yCircuitLogged = false;
const getA11yCircuitLimit = () => parseInt(Cypress.env('ACCESSIBILITY_SCAN_CIRCUIT_LIMIT')) || 3;
const noteA11yTimeout = () => {
consecutiveA11yTimeouts += 1;
if (!a11yCircuitOpen && consecutiveA11yTimeouts >= getA11yCircuitLimit()) {
a11yCircuitOpen = true;
// eslint-disable-next-line no-console
console.warn('BrowserStack Accessibility: scanner did not respond ' + consecutiveA11yTimeouts + ' consecutive times; skipping accessibility scans for the remaining tests in this spec.');
}
};
const noteA11ySuccess = () => { consecutiveA11yTimeouts = 0; };


const commandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
// scroll is not a default function in cypress.
const commandToOverwrite = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
Expand Down Expand Up @@ -44,56 +64,74 @@ 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
if (a11yCircuitOpen) {
// Scanner has repeatedly not responded in this spec — don't stall this test too.
return resolve("Accessibility scan skipped: scanner unresponsive (circuit open)");
}
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(() => { noteA11yTimeout(); 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);
if (!settled) noteA11ySuccess();
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 +244,20 @@ 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.
if (a11yCircuitOpen) {
return resolve("Accessibility results save skipped: scanner unresponsive (circuit open)");
}
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(() => { noteA11yTimeout(); 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 +288,9 @@ new Promise( (resolve, reject) => {

function saveResults() {
function onResultsSaved(event) {
return resolve();
win.removeEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
if (!settled) noteA11ySuccess();
return finish();
}
win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
const e = new CustomEvent("A11Y_SAVE_RESULTS", {
Expand All @@ -255,13 +304,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 Expand Up @@ -317,6 +364,17 @@ commandToOverwrite.forEach((command) => {
});

afterEach(() => {
// SDK-6463: nothing that happens inside this accessibility hook may fail the user's
// test or abort the remaining tests in the spec. Cypress chains have no .catch, so
// suppress any failure raised while this hook's commands run (e.g. cy.window() on a
// cross-origin page after an SSO redirect, or a cy.task that is not registered) via
// the per-test 'fail' listener. Returning false prevents Cypress from failing the
// test; the listener is scoped to the current test and auto-removed afterwards.
cy.on('fail', (err) => {
// eslint-disable-next-line no-console
console.warn(`BrowserStack Accessibility: suppressed afterEach error: ${err && err.message}`);
return false;
});
const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest;
cy.window().then(async (win) => {
let shouldScanTestForAccessibility = shouldScanForAccessibility(attributes);
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