diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index 78e9c388..442553b5 100644 --- a/bin/accessibility-automation/cypress/index.js +++ b/bin/accessibility-automation/cypress/index.js @@ -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']; @@ -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(); } }) @@ -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; } @@ -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", { @@ -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(); } }) @@ -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); diff --git a/bin/commands/runs.js b/bin/commands/runs.js index df02954a..2f58a904 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -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 @@ -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(); } diff --git a/bin/helpers/archiver.js b/bin/helpers/archiver.js index fb3b3155..a30c072e 100644 --- a/bin/helpers/archiver.js +++ b/bin/helpers/archiver.js @@ -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 = {}; diff --git a/bin/helpers/checkUploaded.js b/bin/helpers/checkUploaded.js index dfc29241..9578d127 100644 --- a/bin/helpers/checkUploaded.js +++ b/bin/helpers/checkUploaded.js @@ -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) { diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index c14ff314..60ce8a11 100644 --- a/bin/helpers/readCypressConfigUtil.js +++ b/bin/helpers/readCypressConfigUtil.js @@ -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] }; @@ -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 }; } diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index 3ec867b9..b8d134c0 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1103,7 +1103,43 @@ exports.getFilesToIgnore = (runSettings, excludeFiles, logging = true) => { return ignoreFiles; } +// Perf: derive directory-pruning patterns from the ignore list. +// readdir-glob (used both by archiver.glob for tests.zip and by hashUtil for the +// spec md5 check) applies `ignore` per-entry AFTER walking, so it still descends +// into node_modules/.git/dist etc. On large monorepos that walk alone takes +// minutes ("Creating tests.zip" stalls). Its `skip` option instead prevents +// DESCENDING into matching directories. Any ignore pattern ending in '/**' is +// safe to reuse as a skip: if a directory matches '/**' then every descendant +// also matches it, so pruning cannot change which files end up included. +exports.getDirectorySkipPatterns = (ignoreFiles) => { + return (ignoreFiles || []).filter((pattern) => + typeof pattern === 'string' && pattern.endsWith('/**') + ); +} + +// 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 = [] @@ -1128,7 +1164,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 }) ); @@ -1158,7 +1194,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 }) ); @@ -1167,7 +1203,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)) @@ -1195,6 +1231,11 @@ exports.getNumberOfSpecFiles = (bsConfig, args, cypressConfig, turboScaleSession bsConfig.run_settings.specs = files; } return files; + } catch (err) { + // 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) => { @@ -1349,6 +1390,10 @@ exports.isJSONInvalid = (err, args) => { } exports.deleteBaseUrlFromError = (err) => { + // 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'); } @@ -1431,7 +1476,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 }) ); @@ -1722,13 +1767,20 @@ exports.fetchZipSize = (fileName) => { } } -const getDirectorySize = async function(dir) { +const getDirectorySize = async function(dir, deadline) { try{ + // Perf: this telemetry-only walk recursively stats every file (it is + // pointed at node_modules in runs.js). On large monorepos it blocked the pipeline + // between archiving and uploading for tens of seconds. Stop descending once the + // deadline passes — folder size is best-effort telemetry, never worth stalling a run. + if (deadline && Date.now() > deadline) { + return 0; + } const subdirs = (await readdir(dir)); const files = await Promise.all(subdirs.map(async (subdir) => { const res = path.resolve(dir, subdir); const s = (await stat(res)); - return s.isDirectory() ? getDirectorySize(res) : (s.size); + return s.isDirectory() ? getDirectorySize(res, deadline) : (s.size); })); return files.reduce((a, f) => a+f, 0); }catch(e){ @@ -1738,10 +1790,15 @@ const getDirectorySize = async function(dir) { } }; -exports.fetchFolderSize = async (dir) => { +exports.fetchFolderSize = async (dir, timeoutMs = 5000) => { try { if(fs.existsSync(dir)){ - return (await getDirectorySize(dir) / 1024 / 1024); + const deadline = Date.now() + timeoutMs; + const size = (await getDirectorySize(dir, deadline) / 1024 / 1024); + if (Date.now() > deadline) { + logger.debug(`Folder size calculation for ${dir} exceeded ${timeoutMs}ms; reporting partial size.`); + } + return size; } return 0; } catch (error) { diff --git a/test/unit/bin/accessibility-automation/cypress/index.js b/test/unit/bin/accessibility-automation/cypress/index.js new file mode 100644 index 00000000..413b75a7 --- /dev/null +++ b/test/unit/bin/accessibility-automation/cypress/index.js @@ -0,0 +1,156 @@ +'use strict'; +const chai = require('chai'); +const expect = chai.expect; + +// Regression guard for the accessibility Cypress plugin's afterEach hook. +// +// IMPORTANT: this is a fast, cheap guard — the authoritative proof runs REAL Cypress +// (see the repro under scripts/ / the PR description). Two things it guards: +// 1. The hook must NOT call `.catch` on a Cypress chain. Cypress `Chainable` has no +// `.catch` (commands are not promises), so doing so throws synchronously and fails +// the hook. The mock `chain` below intentionally has NO `.catch`, so re-introducing +// `cy.wrap(...).then(...).catch(...)` makes invoking the hook throw -> test fails. +// 2. performScan / saveTestResults must ALWAYS settle (never hang, never reject), even +// when the scanner never responds or the window is cross-origin — otherwise cy.wrap's +// timeout fails the hook and Cypress skips the rest of the spec. + +const PLUGIN_PATH = require.resolve('../../../../../bin/accessibility-automation/cypress/index.js'); + +// Cypress Chainable mock — deliberately has `then` but NO `catch` (matches real Cypress). +function chain(promise) { + return { + _promise: promise, + then(onF, onR) { + return chain(promise.then( + (v) => { const r = onF ? onF(v) : v; return (r && r._promise) ? r._promise : r; }, + onR + )); + }, + // NOTE: no `catch` — real Cypress Chainable has none. + performScan() { return this; }, + performScanSubjectQuery() { return this; }, + }; +} + +// mode: 'hang' (scanner never echoes), 'crossorigin' (win access throws), 'ok' +function makeWin(mode) { + const listeners = {}; + const echo = { A11Y_SCAN: 'A11Y_SCAN_FINISHED', A11Y_SAVE_RESULTS: 'A11Y_RESULTS_SAVED' }; + const guard = () => { if (mode === 'crossorigin') throw new Error("Blocked a frame with origin from accessing a cross-origin frame."); }; + return { + dispatched: [], // records every event type dispatched at the page (A11Y_SCAN, ...) + get location() { guard(); return { protocol: 'http:' }; }, + get document() { guard(); return { querySelector: () => ({ id: 'accessibility-automation-element' }) }; }, + addEventListener(type, cb) { (listeners[type] = listeners[type] || []).push(cb); }, + removeEventListener(type, cb) { listeners[type] = (listeners[type] || []).filter((f) => f !== cb); }, + dispatchEvent(e) { + this.dispatched.push(e.type); + const done = echo[e.type]; + if (mode === 'ok' && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} })); + return true; // 'hang' echoes nothing -> relies on the internal always-settle timer + }, + }; +} + +describe('accessibility-automation/cypress afterEach', () => { + let capturedAfterEach; + let theWin; + const unhandled = []; + const failHandlers = []; + const onUnhandled = (reason) => unhandled.push(reason && reason.message ? reason.message : String(reason)); + + before(() => { + process.on('unhandledRejection', onUnhandled); + + global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }; + global.window = { location: { protocol: 'http:' } }; + global.Cypress = { + env: (k) => ({ + BROWSERSTACK_LOGS: false, + IS_ACCESSIBILITY_EXTENSION_LOADED: 'true', + ACCESSIBILITY_EXTENSION_PATH: '/some/ext/path', + ACCESSIBILITY_SCAN_TIMEOUT: 60, // keep the always-settle timer fast for the test + OS: 'win', + })[k], + browser: { isHeaded: true }, + platform: 'linux', + Commands: { add() {}, overwrite() {}, addQuery() {} }, + on() {}, + mocha: { getRunner: () => ({ suite: { ctx: { currentTest: { title: 'TC landing', invocationDetails: { relativeFile: 'src/e2e/landing.cy.ts' } } } } }) }, + }; + global.cy = { + state: () => null, + // Real cy.wrap resolves when the wrapped promise resolves; our fixed promises always resolve. + wrap: (value) => chain((value && typeof value.then === 'function') ? value : Promise.resolve(value)), + window: () => chain(Promise.resolve(theWin)), + task: () => chain(Promise.resolve({ testRunUuid: 'uuid-123' })), + // capture per-test fail listeners the hook registers (cy.on('fail', ...)) + on(evt, fn) { if (evt === 'fail') failHandlers.push(fn); }, + }; + + const realAfterEach = global.afterEach, realBefore = global.before, realBeforeEach = global.beforeEach; + global.afterEach = (fn) => { capturedAfterEach = fn; }; + global.before = () => {}; global.beforeEach = () => {}; + try { + delete require.cache[PLUGIN_PATH]; + require(PLUGIN_PATH); + } finally { + global.afterEach = realAfterEach; global.before = realBefore; global.beforeEach = realBeforeEach; + } + }); + + after(() => { + process.removeListener('unhandledRejection', onUnhandled); + delete global.Cypress; delete global.cy; delete global.window; delete global.CustomEvent; + }); + + function runHook(mode) { + unhandled.length = 0; + theWin = makeWin(mode); + // Must NOT throw synchronously (guards against `.catch` on a cy chain being re-introduced). + expect(() => capturedAfterEach(), 'afterEach hook threw synchronously').to.not.throw(); + return new Promise((r) => setTimeout(r, 300)).then(() => unhandled.slice()); + } + + it('captures the real afterEach hook from the plugin', () => { + expect(capturedAfterEach).to.be.a('function'); + }); + + it('does not throw or leave unhandled rejections when the scan never finishes', async () => { + const rej = await runHook('hang'); + expect(rej, 'unhandled rejection would fail the hook').to.have.length(0); + }); + + it('does not throw or reject when the window is cross-origin (SSO redirect)', async () => { + const rej = await runHook('crossorigin'); + expect(rej).to.have.length(0); + }); + + it('completes cleanly on the happy path', async () => { + const rej = await runHook('ok'); + expect(rej).to.have.length(0); + }); + + // Hardening: any failure raised by the hook's own commands (cy.window on a + // cross-origin page, an unregistered cy.task, ...) must be suppressed via the per-test + // 'fail' listener so it cannot fail the user's test or abort the spec. + it('registers a per-test fail listener that suppresses hook failures (returns false)', async () => { + failHandlers.length = 0; + await runHook('ok'); + expect(failHandlers.length, 'afterEach must register a cy.on("fail") guard').to.be.at.least(1); + const result = failHandlers[0](new Error("The task 'get_test_run_uuid' was not handled")); + expect(result, 'fail handler must return false to suppress the failure').to.equal(false); + }); + + // Hardening: after repeated scan/save timeouts, the circuit opens and the + // plugin stops dispatching A11Y_SCAN entirely so later tests are not stalled. + it('opens the circuit after repeated timeouts and stops dispatching scans', async () => { + // happy path above reset the consecutive-timeout counter; two hang runs produce + // 3 timeouts (scan+save, then scan) which reaches the default circuit limit of 3. + await runHook('hang'); + await runHook('hang'); + const rej = await runHook('hang'); // circuit open: must not dispatch, must not stall + expect(rej).to.have.length(0); + expect(theWin.dispatched, 'no A11Y_SCAN once the circuit is open').to.not.include('A11Y_SCAN'); + }); +}); diff --git a/test/unit/bin/helpers/readCypressConfigUtil.js b/test/unit/bin/helpers/readCypressConfigUtil.js index ce93d4b4..27ad31d7 100644 --- a/test/unit/bin/helpers/readCypressConfigUtil.js +++ b/test/unit/bin/helpers/readCypressConfigUtil.js @@ -304,10 +304,58 @@ describe("readCypressConfigUtil", () => { const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); - + expect(result.tscCommand).to.include('NODE_PATH=path/to/tmpBstackPackages'); expect(result.tscCommand).to.include('tsc-alias'); }); + + // NX/monorepo base tsconfigs can set noEmit/emitDeclarationOnly/composite/ + // noEmitOnError, which suppress or redirect the compiled cypress config JS and break + // the read. The extends temp tsconfig must force a clean self-contained JS emit. + it('should force emit-friendly compilerOptions overrides in extends approach', () => { + const bsConfig = { run_settings: { ts_config_file_path: 'existing/tsconfig.json' } }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(path.resolve('existing/tsconfig.json')).returns(true); + sandbox.stub(fs, 'readFileSync').returns('{}'); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + const tempConfig = JSON.parse(writeFileSyncStub.getCall(0).args[1]); + expect(tempConfig.extends).to.eql(path.resolve('existing/tsconfig.json')); + expect(tempConfig.compilerOptions.noEmit).to.be.false; + expect(tempConfig.compilerOptions.emitDeclarationOnly).to.be.false; + expect(tempConfig.compilerOptions.composite).to.be.false; + expect(tempConfig.compilerOptions.noEmitOnError).to.be.false; + expect(tempConfig.compilerOptions.declaration).to.be.false; + }); + + // Tsc returns a non-zero exit code on any type error (common when a single + // config file is compiled out of its monorepo context). With '&&', tsc-alias would be + // skipped and path aliases left un-rewritten. tsc-alias must run unconditionally. + it('should run tsc-alias unconditionally on Unix (";" not "&&")', () => { + sinon.stub(process, 'platform').value('linux'); + const bsConfig = { run_settings: {} }; + sandbox.stub(fs, 'existsSync').returns(false); + sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + expect(result.tscCommand).to.not.include('&&'); + expect(result.tscCommand).to.match(/--project "[^"]*" ; NODE_PATH=/); + }); + + it('should run tsc-alias unconditionally on Windows ("&" between tsc and tsc-alias)', () => { + sinon.stub(process, 'platform').value('win32'); + const bsConfig = { run_settings: {} }; + sandbox.stub(fs, 'existsSync').returns(false); + sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + // unconditional '&' connects the tsc invocation to the tsc-alias invocation + expect(result.tscCommand).to.match(/--project "[^"]*" & set NODE_PATH=/); + }); }); describe('convertTsConfig', () => { diff --git a/test/unit/bin/helpers/utils.js b/test/unit/bin/helpers/utils.js index 1d24ced6..07f3d57e 100644 --- a/test/unit/bin/helpers/utils.js +++ b/test/unit/bin/helpers/utils.js @@ -770,6 +770,34 @@ describe('utils', () => { }); }); + // Perf: directory-shaped ignore patterns are reused as readdir-glob `skip` + // patterns so the zip/md5 walks prune excluded trees instead of descending into them. + describe('getDirectorySkipPatterns', () => { + it('keeps only patterns ending in /** (safe to prune)', () => { + const ignore = [ + '**/node_modules/**', + 'node_modules/**', + 'dist/**', + 'apps/admin-portal/**', + 'package.json', // file pattern — must NOT be used for pruning + '.env', + '**/README.md', + ]; + chai.expect(utils.getDirectorySkipPatterns(ignore)).to.be.eql([ + '**/node_modules/**', + 'node_modules/**', + 'dist/**', + 'apps/admin-portal/**', + ]); + }); + + it('handles empty/undefined input and non-string entries', () => { + chai.expect(utils.getDirectorySkipPatterns(undefined)).to.be.eql([]); + chai.expect(utils.getDirectorySkipPatterns([])).to.be.eql([]); + chai.expect(utils.getDirectorySkipPatterns([null, 42, 'x/**'])).to.be.eql(['x/**']); + }); + }); + describe('setTestEnvs', () => { it('set env only from args', () => { let argsEnv = 'env3=value3, env4=value4'; @@ -5593,4 +5621,34 @@ describe('utils', () => { }); }); + // glob.sync can crash inside minimatch when a project force-resolves an + // incompatible brace-expansion/minimatch (e.g. brace-expansion@5) via resolutions/overrides. + // That must not abort spec discovery / crash the run. + describe('glob resilience', () => { + afterEach(() => { if (glob.sync.restore) glob.sync.restore(); }); + + it('safeGlobSync returns [] and does not throw when glob.sync throws', () => { + sinon.stub(glob, 'sync').throws(new TypeError('expand is not a function')); + let result; + expect(() => { result = utils.safeGlobSync('**/*.{js,ts}', {}); }).to.not.throw(); + expect(result).to.eql([]); + }); + + it('getNumberOfSpecFiles does not throw and returns [] when glob.sync crashes', () => { + sinon.stub(glob, 'sync').throws(new TypeError('expand is not a function')); + const bsConfig = { run_settings: { cypressProjectDir: '.', cypressTestSuiteType: CYPRESS_V10_AND_ABOVE_TYPE }, browsers: [] }; + let result; + expect(() => { result = utils.getNumberOfSpecFiles(bsConfig, {}, { e2e: { specPattern: '**/*.cy.{js,ts}' } }); }).to.not.throw(); + expect(result).to.eql([]); + }); + + it('deleteBaseUrlFromError returns non-string errors unchanged (no err.replace crash)', () => { + const errObj = new TypeError('some object error'); + expect(() => utils.deleteBaseUrlFromError(errObj)).to.not.throw(); + expect(utils.deleteBaseUrlFromError(errObj)).to.equal(errObj); + // strings are still transformed as before + expect(utils.deleteBaseUrlFromError('To test foo on BrowserStack')).to.equal('To test on BrowserStack'); + }); + }); + });