Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1314ebb
fix(config): robust TS cypress-config compile in NX monorepos (SDK-6463)
Bhargavi-BS Jun 19, 2026
e16768e
fix(a11y): tolerate accessibility scan/save timeouts in afterEach (SD…
Bhargavi-BS Jun 19, 2026
94a33c2
Merge branch 'master' into sdk-6463-nx-tsconfig-and-a11y-afterEach-fixes
Bhargavi-BS Jun 22, 2026
5aec0a0
fix(a11y): rework afterEach — remove invalid cy .catch, make scans al…
Bhargavi-BS Jul 1, 2026
4b14e0f
fix(specs): survive glob/minimatch crash from incompatible brace-expa…
Bhargavi-BS Jul 1, 2026
9b8d34b
Merge pull request #1138 from browserstack/sdk-6463-glob-brace-expans…
Bhargavi-BS Jul 2, 2026
16c0023
fix(a11y): catch afterEach failures via cy.on('fail') + circuit-break…
Bhargavi-BS Jul 2, 2026
613a136
perf(upload): prune excluded directories from zip/md5 walks + cap tel…
Bhargavi-BS Jul 5, 2026
fbccd75
Merge pull request #1139 from browserstack/sdk-6463-a11y-afterEach-fa…
Bhargavi-BS Jul 5, 2026
a2fb72b
Merge branch 'master' into sdk-6463-nx-tsconfig-and-a11y-afterEach-fixes
Bhargavi-BS Jul 5, 2026
5edbea6
chore: remove ticket identifiers from code comments
Bhargavi-BS Jul 6, 2026
dcac4df
chore: remove ticket identifiers from code comments
Bhargavi-BS Jul 6, 2026
609d50d
perf(upload): compute node_modules telemetry size concurrently with t…
Bhargavi-BS Jul 6, 2026
f22ffcd
Merge pull request #1146 from browserstack/sdk-6463-nx-tsconfig-and-a…
hamza-browserstack Jul 6, 2026
15d0c4a
Merge branch 'sdk-6463-zip-md5-dir-pruning' into drop_sdk_6463
Bhargavi-BS Jul 6, 2026
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);
}


// 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) => {
// 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) => {
// 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(() => {
// 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
9 changes: 8 additions & 1 deletion bin/commands/runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ module.exports = function run(args, rawArgs) {

let test_zip_size = utils.fetchZipSize(path.join(process.cwd(), config.fileName));
let npm_zip_size = utils.fetchZipSize(path.join(process.cwd(), config.packageFileName));
let node_modules_size = await utils.fetchFolderSize(path.join(process.cwd(), "node_modules"));
// Perf: node_modules size is instrumentation-only, so don't block the upload on
// walking the tree — start the walk here and await it only after the upload has
// completed (in the common case it resolves while the upload is in flight, adding
// zero wall-clock; fetchFolderSize never rejects, so the floating promise is safe).
let nodeModulesSizePromise = utils.fetchFolderSize(path.join(process.cwd(), "node_modules"));

if (Constants.turboScaleObj.enabled) {
// Note: Calculating md5 here for turboscale force-upload so that we don't need to re-calculate at hub
Expand All @@ -306,6 +310,9 @@ module.exports = function run(args, rawArgs) {
markBlockEnd('zip.zipUpload');
markBlockEnd('zip');

// Walk was started before the upload; usually already resolved by now.
let node_modules_size = await nodeModulesSizePromise;

if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY === 'true') {
supportFileCleanup();
}
Expand Down
7 changes: 6 additions & 1 deletion bin/helpers/archiver.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ const archiveSpecs = (runSettings, filePath, excludeFiles, md5data) => {

let ignoreFiles = utils.getFilesToIgnore(runSettings, excludeFiles);
logger.debug(`Patterns ignored during zip ${ignoreFiles}`);
archive.glob(`**/*.+(${Constants.allowedFileTypes.join("|")})`, { cwd: cypressFolderPath, matchBase: true, ignore: ignoreFiles, dot:true });
// Perf: `ignore` filters entries only AFTER the walk visits them, so the
// globber still descends into node_modules/.git/etc. `skip` prunes those directories
// from the traversal entirely — on large monorepos this cuts zip creation from
// minutes to seconds without changing the archive contents.
let skipDirectories = utils.getDirectorySkipPatterns(ignoreFiles);
archive.glob(`**/*.+(${Constants.allowedFileTypes.join("|")})`, { cwd: cypressFolderPath, matchBase: true, ignore: ignoreFiles, skip: skipDirectories, dot:true });

let packageJSON = {};

Expand Down
3 changes: 3 additions & 0 deletions bin/helpers/checkUploaded.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const checkSpecsMd5 = (runSettings, args, instrumentBlocks) => {
let options = {
cwd: cypressFolderPath,
ignore: ignoreFiles,
// Perf: prune ignored directories from the md5 walk instead of
// filtering entries after descending into them (see utils.getDirectorySkipPatterns).
skip: utils.getDirectorySkipPatterns(ignoreFiles),
pattern: `**/*.+(${Constants.allowedFileTypes.join("|")})`
};
hashHelper.hashWrapper(options, instrumentBlocks).then(function (data) {
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/...".
"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". 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`;
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. 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
Loading
Loading