diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index 78e9c388..9facdde1 100644 --- a/bin/accessibility-automation/cypress/index.js +++ b/bin/accessibility-automation/cypress/index.js @@ -44,56 +44,69 @@ 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) => { + // SDK-6463: 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 + 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(() => 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); + 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 +219,17 @@ new Promise((resolve) => { }); const saveTestResults = (win, payloadToSend) => -new Promise( (resolve, reject) => { +new Promise((resolve) => { + // SDK-6463: must always settle (see performScan note) so a slow/absent A11Y_RESULTS_SAVED + // event or a cross-origin window cannot fail the afterEach hook. + 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(() => 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 +260,8 @@ new Promise( (resolve, reject) => { function saveResults() { function onResultsSaved(event) { - return resolve(); + win.removeEventListener("A11Y_RESULTS_SAVED", onResultsSaved); + return finish(); } win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved); const e = new CustomEvent("A11Y_SAVE_RESULTS", { @@ -255,13 +275,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(); } }) diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index c14ff314..e06123f6 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/..." (SDK-6463). + "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" (SDK-6463). 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 (SDK-6463). 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 b15414fb..9e01a02f 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1103,7 +1103,29 @@ exports.getFilesToIgnore = (runSettings, excludeFiles, logging = true) => { return ignoreFiles; } +// SDK-6463: 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) { + // SDK-6463 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) => { + // SDK-6463: 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..4cbb7f0f --- /dev/null +++ b/test/unit/bin/accessibility-automation/cypress/index.js @@ -0,0 +1,129 @@ +'use strict'; +const chai = require('chai'); +const expect = chai.expect; + +// SDK-6463 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 { + 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) { + 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 (SDK-6463)', () => { + let capturedAfterEach; + let theWin; + const unhandled = []; + 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' })), + on() {}, + }; + + 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); + }); +}); diff --git a/test/unit/bin/helpers/readCypressConfigUtil.js b/test/unit/bin/helpers/readCypressConfigUtil.js index ce93d4b4..f32ee203 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'); }); + + // SDK-6463: 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 (SDK-6463)', () => { + 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; + }); + + // SDK-6463: 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 "&&") (SDK-6463)', () => { + 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) (SDK-6463)', () => { + 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..f5545b31 100644 --- a/test/unit/bin/helpers/utils.js +++ b/test/unit/bin/helpers/utils.js @@ -5593,4 +5593,34 @@ describe('utils', () => { }); }); + // SDK-6463: 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('SDK-6463 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'); + }); + }); + });