Skip to content

Commit f22ffcd

Browse files
Merge pull request #1146 from browserstack/sdk-6463-nx-tsconfig-and-a11y-afterEach-fixes
Sdk 6463 nx tsconfig and a11y after each fixes
2 parents 0dbede3 + 5edbea6 commit f22ffcd

6 files changed

Lines changed: 409 additions & 61 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/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
}

bin/helpers/utils.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,29 @@ exports.getFilesToIgnore = (runSettings, excludeFiles, logging = true) => {
11031103
return ignoreFiles;
11041104
}
11051105

1106+
// glob.sync can throw deep inside minimatch (e.g. "expand is not a function" /
1107+
// "brace_expansion_1.default is not a function") when a project force-resolves an
1108+
// incompatible 'brace-expansion'/'minimatch' major (e.g. brace-expansion@5) across the
1109+
// dependency tree via yarn resolutions / npm overrides. That crash used to abort spec
1110+
// discovery in getNumberOfSpecFiles and produce a build with 0 executed tests (or crash the
1111+
// run entirely). Degrade gracefully: log once and return no matches so the run still proceeds
1112+
// (specs are resolved on BrowserStack regardless of the local count).
1113+
let _loggedGlobFailure = false;
1114+
const safeGlobSync = (pattern, options) => {
1115+
try {
1116+
return glob.sync(pattern, options);
1117+
} catch (err) {
1118+
if (!_loggedGlobFailure) {
1119+
_loggedGlobFailure = true;
1120+
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.`);
1121+
}
1122+
return [];
1123+
}
1124+
};
1125+
exports.safeGlobSync = safeGlobSync;
1126+
11061127
exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession=false) => {
1128+
try {
11071129
let defaultSpecFolder
11081130
let testFolderPath
11091131
let globCypressConfigSpecPatterns = []
@@ -1128,7 +1150,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
11281150
const filesMatched = [];
11291151
globCypressConfigSpecPatterns.forEach(specPattern => {
11301152
filesMatched.push(
1131-
...glob.sync(specPattern, {
1153+
...safeGlobSync(specPattern, {
11321154
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
11331155
})
11341156
);
@@ -1158,7 +1180,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
11581180
let fileMatchedWithConfigSpecPattern = []
11591181
globCypressConfigSpecPatterns.forEach(specPattern => {
11601182
fileMatchedWithConfigSpecPattern.push(
1161-
...glob.sync(specPattern, {
1183+
...safeGlobSync(specPattern, {
11621184
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
11631185
})
11641186
);
@@ -1167,7 +1189,7 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
11671189

11681190
let files
11691191
if (globSearchPattern) {
1170-
let fileMatchedWithBstackSpecPattern = glob.sync(globSearchPattern, {
1192+
let fileMatchedWithBstackSpecPattern = safeGlobSync(globSearchPattern, {
11711193
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
11721194
});
11731195
fileMatchedWithBstackSpecPattern = fileMatchedWithBstackSpecPattern.map((file) => path.resolve(bsConfig.run_settings.cypressProjectDir, file))
@@ -1195,6 +1217,11 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession
11951217
bsConfig.run_settings.specs = files;
11961218
}
11971219
return files;
1220+
} catch (err) {
1221+
// Backstop: never let spec-counting crash the run. Proceed without a local count.
1222+
logger.warn(`Could not determine spec files locally: ${err && err.message}. Continuing; specs will be resolved on BrowserStack.`);
1223+
return [];
1224+
}
11981225
};
11991226

12001227
exports.sanitizeSpecsPattern = (pattern) => {
@@ -1349,6 +1376,10 @@ exports.isJSONInvalid = (err, args) => {
13491376
}
13501377

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

@@ -1431,7 +1462,7 @@ exports.setEnforceSettingsConfig = (bsConfig, args) => {
14311462
let specFilesMatched = [];
14321463
specConfigs.forEach(specPattern => {
14331464
specFilesMatched.push(
1434-
...glob.sync(specPattern, {
1465+
...safeGlobSync(specPattern, {
14351466
cwd: bsConfig.run_settings.cypressProjectDir, matchBase: true, ignore: ignoreFiles
14361467
})
14371468
);

0 commit comments

Comments
 (0)