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/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..878ef5d0 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1103,7 +1103,29 @@ exports.getFilesToIgnore = (runSettings, excludeFiles, logging = true) => { return ignoreFiles; } +// 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 +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 }) ); @@ -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 }) ); @@ -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)) @@ -1195,6 +1217,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 +1376,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 +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 }) ); 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..ed791d72 100644 --- a/test/unit/bin/helpers/utils.js +++ b/test/unit/bin/helpers/utils.js @@ -5593,4 +5593,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'); + }); + }); + });