Skip to content

Commit 4bb40cd

Browse files
authored
Master merge
2 parents bc813ef + 8731a23 commit 4bb40cd

9 files changed

Lines changed: 485 additions & 68 deletions

File tree

bin/accessibility-automation/cypress/index.js

Lines changed: 110 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,27 @@ const browserStackLog = (message) => {
44
if (!Cypress.env('BROWSERSTACK_LOGS')) return;
55
cy.task('browserstack_log', message);
66
}
7-
7+
8+
// Circuit breaker for a dead/unresponsive accessibility scanner.
9+
// Each hung scan/save costs up to ACCESSIBILITY_SCAN_TIMEOUT (default 25s). Without a
10+
// breaker, a scanner that never responds stalls EVERY test's afterEach (and every
11+
// wrapped command) by that much. After N consecutive timeouts we stop attempting
12+
// accessibility work for the remainder of this spec file (module state resets per spec).
13+
let consecutiveA11yTimeouts = 0;
14+
let a11yCircuitOpen = false;
15+
let a11yCircuitLogged = false;
16+
const getA11yCircuitLimit = () => parseInt(Cypress.env('ACCESSIBILITY_SCAN_CIRCUIT_LIMIT')) || 3;
17+
const noteA11yTimeout = () => {
18+
consecutiveA11yTimeouts += 1;
19+
if (!a11yCircuitOpen && consecutiveA11yTimeouts >= getA11yCircuitLimit()) {
20+
a11yCircuitOpen = true;
21+
// eslint-disable-next-line no-console
22+
console.warn('BrowserStack Accessibility: scanner did not respond ' + consecutiveA11yTimeouts + ' consecutive times; skipping accessibility scans for the remaining tests in this spec.');
23+
}
24+
};
25+
const noteA11ySuccess = () => { consecutiveA11yTimeouts = 0; };
26+
27+
828
const commandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
929
// scroll is not a default function in cypress.
1030
const commandToOverwrite = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
@@ -44,56 +64,74 @@ const performModifiedScan = (originalFn, Subject, stateType, ...args) => {
4464
}
4565

4666
const performScan = (win, payloadToSend) =>
47-
new Promise(async (resolve, reject) => {
48-
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
49-
if (!isHttpOrHttps) {
50-
return resolve();
67+
new Promise((resolve) => {
68+
// This promise MUST always settle (never hang, never reject). It runs inside the
69+
// global afterEach; if it hangs, cy.wrap()'s 30s timeout fails the hook and Cypress skips
70+
// the rest of the spec. Failure modes guarded here:
71+
// - the injected scanner never dispatches A11Y_SCAN_FINISHED (page mid-navigation / slow scan)
72+
// - win is cross-origin (e.g. an SSO redirect) so win.location / win.document throw synchronously
73+
if (a11yCircuitOpen) {
74+
// Scanner has repeatedly not responded in this spec — don't stall this test too.
75+
return resolve("Accessibility scan skipped: scanner unresponsive (circuit open)");
5176
}
77+
let settled = false;
78+
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
79+
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
80+
const overallTimer = setTimeout(() => { noteA11yTimeout(); finish("Accessibility scan timed out"); }, overallTimeout);
5281

53-
function findAccessibilityAutomationElement() {
54-
return win.document.querySelector("#accessibility-automation-element");
55-
}
82+
try {
83+
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
84+
if (!isHttpOrHttps) {
85+
return finish();
86+
}
5687

57-
function waitForScannerReadiness(retryCount = 100, retryInterval = 100) {
58-
return new Promise(async (resolve, reject) => {
59-
let count = 0;
60-
const intervalID = setInterval(async () => {
61-
if (count > retryCount) {
62-
clearInterval(intervalID);
63-
return reject(
64-
new Error(
65-
"Accessibility Automation Scanner is not ready on the page."
66-
)
67-
);
68-
} else if (findAccessibilityAutomationElement()) {
69-
clearInterval(intervalID);
70-
return resolve("Scanner set");
71-
} else {
72-
count += 1;
73-
}
74-
}, retryInterval);
75-
});
76-
}
88+
function findAccessibilityAutomationElement() {
89+
return win.document.querySelector("#accessibility-automation-element");
90+
}
7791

78-
function startScan() {
79-
function onScanComplete() {
80-
win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete);
81-
return resolve();
92+
function waitForScannerReadiness(retryCount = 100, retryInterval = 100) {
93+
return new Promise((resolve, reject) => {
94+
let count = 0;
95+
const intervalID = setInterval(() => {
96+
if (count > retryCount) {
97+
clearInterval(intervalID);
98+
return reject(
99+
new Error(
100+
"Accessibility Automation Scanner is not ready on the page."
101+
)
102+
);
103+
} else if (findAccessibilityAutomationElement()) {
104+
clearInterval(intervalID);
105+
return resolve("Scanner set");
106+
} else {
107+
count += 1;
108+
}
109+
}, retryInterval);
110+
});
82111
}
83112

84-
win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete);
85-
const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend });
86-
win.dispatchEvent(e);
87-
}
113+
function startScan() {
114+
function onScanComplete() {
115+
win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete);
116+
if (!settled) noteA11ySuccess();
117+
return finish();
118+
}
88119

89-
if (findAccessibilityAutomationElement()) {
90-
startScan();
91-
} else {
92-
waitForScannerReadiness()
93-
.then(startScan)
94-
.catch(async (err) => {
95-
return resolve("Scanner is not ready on the page after multiple retries. performscan");
96-
});
120+
win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete);
121+
const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend });
122+
win.dispatchEvent(e);
123+
}
124+
125+
if (findAccessibilityAutomationElement()) {
126+
startScan();
127+
} else {
128+
waitForScannerReadiness()
129+
.then(startScan)
130+
.catch(() => finish("Scanner is not ready on the page after multiple retries. performscan"));
131+
}
132+
} catch (err) {
133+
// cross-origin window access or any unexpected error must not fail the hook
134+
finish();
97135
}
98136
})
99137

@@ -206,11 +244,20 @@ new Promise((resolve) => {
206244
});
207245

208246
const saveTestResults = (win, payloadToSend) =>
209-
new Promise( (resolve, reject) => {
247+
new Promise((resolve) => {
248+
// Must always settle (see performScan note) so a slow/absent A11Y_RESULTS_SAVED
249+
// event or a cross-origin window cannot fail the afterEach hook.
250+
if (a11yCircuitOpen) {
251+
return resolve("Accessibility results save skipped: scanner unresponsive (circuit open)");
252+
}
253+
let settled = false;
254+
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
255+
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
256+
const overallTimer = setTimeout(() => { noteA11yTimeout(); finish("Accessibility results save timed out"); }, overallTimeout);
210257
try {
211258
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
212259
if (!isHttpOrHttps) {
213-
resolve("Unable to save accessibility results, Invalid URL.");
260+
finish("Unable to save accessibility results, Invalid URL.");
214261
return;
215262
}
216263

@@ -241,7 +288,9 @@ new Promise( (resolve, reject) => {
241288

242289
function saveResults() {
243290
function onResultsSaved(event) {
244-
return resolve();
291+
win.removeEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
292+
if (!settled) noteA11ySuccess();
293+
return finish();
245294
}
246295
win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
247296
const e = new CustomEvent("A11Y_SAVE_RESULTS", {
@@ -255,13 +304,11 @@ new Promise( (resolve, reject) => {
255304
} else {
256305
waitForScannerReadiness()
257306
.then(saveResults)
258-
.catch(async (err) => {
259-
return resolve("Scanner is not ready on the page after multiple retries. after run");
260-
});
307+
.catch(() => finish("Scanner is not ready on the page after multiple retries. after run"));
261308
}
262309
} catch(error) {
263-
browserStackLog(`Error in saving results with error: ${error.message}`);
264-
return resolve();
310+
browserStackLog(`Error in saving results with error: ${error.message}`);
311+
finish();
265312
}
266313

267314
})
@@ -317,6 +364,17 @@ commandToOverwrite.forEach((command) => {
317364
});
318365

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

bin/commands/runs.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ module.exports = function run(args, rawArgs) {
279279

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

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

313+
// Walk was started before the upload; usually already resolved by now.
314+
let node_modules_size = await nodeModulesSizePromise;
315+
309316
if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY === 'true') {
310317
supportFileCleanup();
311318
}

bin/helpers/archiver.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ const archiveSpecs = (runSettings, filePath, excludeFiles, md5data) => {
5858

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

6368
let packageJSON = {};
6469

bin/helpers/checkUploaded.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const checkSpecsMd5 = (runSettings, args, instrumentBlocks) => {
2525
let options = {
2626
cwd: cypressFolderPath,
2727
ignore: ignoreFiles,
28+
// Perf: prune ignored directories from the md5 walk instead of
29+
// filtering entries after descending into them (see utils.getDirectorySkipPatterns).
30+
skip: utils.getDirectorySkipPatterns(ignoreFiles),
2831
pattern: `**/*.+(${Constants.allowedFileTypes.join("|")})`
2932
};
3033
hashHelper.hashWrapper(options, instrumentBlocks).then(function (data) {

bin/helpers/readCypressConfigUtil.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,20 @@ function generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, c
9292
"listEmittedFiles": true,
9393
// Ensure these are always set regardless of base tsconfig
9494
"allowSyntheticDefaultImports": true,
95-
"esModuleInterop": true
95+
"esModuleInterop": true,
96+
// Force a clean, self-contained JS emit even when the extended tsconfig
97+
// (common in NX / monorepo setups) sets options that suppress or redirect
98+
// the JS output. Without these overrides, base options such as
99+
// noEmit / emitDeclarationOnly / composite / noEmitOnError leave the
100+
// compiled cypress config missing, surfacing as
101+
// "Cypress config file not found at: ...tmpBstackCompiledJs/...".
102+
"noEmit": false,
103+
"emitDeclarationOnly": false,
104+
"composite": false,
105+
"declaration": false,
106+
"declarationMap": false,
107+
"noEmitOnError": false,
108+
"incremental": false
96109
},
97110
include: [cypress_config_filepath]
98111
};
@@ -137,13 +150,25 @@ function generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, c
137150
? `set NODE_PATH=${bstack_node_modules_path}`
138151
: `NODE_PATH="${bstack_node_modules_path}"`;
139152

140-
const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" && ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
153+
// Use '&' (unconditional) instead of '&&' between tsc and tsc-alias so the alias
154+
// rewrite ALWAYS runs even when tsc exits non-zero. tsc returns a non-zero exit
155+
// code on any type error (very common when a single config file is compiled out of
156+
// its normal monorepo project context), which with '&&' would skip tsc-alias and
157+
// leave path aliases (e.g. @org/lib) un-rewritten -> the compiled config fails to
158+
// require -> "Cypress config file not found". convertTsConfig already
159+
// tolerates tsc errors by parsing the emitted-files output.
160+
const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" & ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
141161
logger.info(`TypeScript compilation command: ${tscCommand}`);
142162
return { tscCommand, tempTsConfigPath };
143163
} else {
144-
// Unix/Linux/macOS: Use ; to separate commands or && to chain
164+
// Unix/Linux/macOS: Use ';' (unconditional) between tsc and tsc-alias so the alias
165+
// rewrite ALWAYS runs even when tsc exits non-zero (type errors are common when a
166+
// single config file is compiled out of its monorepo context). With '&&', a tsc
167+
// error would skip tsc-alias and leave path aliases (e.g. @org/lib) un-rewritten,
168+
// making the compiled config impossible to require. convertTsConfig
169+
// already tolerates tsc errors by parsing the emitted-files output.
145170
const nodePathPrefix = `NODE_PATH=${bstack_node_modules_path}`;
146-
const tscCommand = `${nodePathPrefix} node "${typescript_path}" --project "${tempTsConfigPath}" && ${nodePathPrefix} node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
171+
const tscCommand = `${nodePathPrefix} node "${typescript_path}" --project "${tempTsConfigPath}" ; ${nodePathPrefix} node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;
147172
logger.info(`TypeScript compilation command: ${tscCommand}`);
148173
return { tscCommand, tempTsConfigPath };
149174
}

0 commit comments

Comments
 (0)