From 1314ebbb97dfd34c116368256f3e46327c836ed9 Mon Sep 17 00:00:00 2001 From: Bhargavi-BS Date: Fri, 19 Jun 2026 14:50:54 +0530 Subject: [PATCH 1/2] fix(config): robust TS cypress-config compile in NX monorepos (SDK-6463) When the cypress config is TypeScript and lives in an NX/monorepo, the extends temp tsconfig inherited base options (noEmit / emitDeclarationOnly / composite / noEmitOnError) that suppress or redirect the compiled JS, and any tsc type-error short-circuited the '&&'-chained tsc-alias so path aliases were left un-rewritten. The compiled config then could not be found/required, the error was silently swallowed, and getNumberOfSpecFiles fell back to a default glob that found 0 specs -> setParallels collapsed parallels to 1. Force emit-friendly overrides in the extends temp tsconfig and run tsc-alias unconditionally (& / ; instead of &&). Adds regression tests covering the emit-override and unconditional-alias behavior. Co-Authored-By: Claude Opus 4.8 --- bin/helpers/readCypressConfigUtil.js | 33 ++++++++++-- .../unit/bin/helpers/readCypressConfigUtil.js | 50 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index 735d2000..f41e03fd 100644 --- a/bin/helpers/readCypressConfigUtil.js +++ b/bin/helpers/readCypressConfigUtil.js @@ -90,7 +90,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] }; @@ -135,13 +148,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/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', () => { From e16768e9250c9d8364fe008a09894d8335c00a45 Mon Sep 17 00:00:00 2001 From: Bhargavi-BS Date: Fri, 19 Jun 2026 14:50:54 +0530 Subject: [PATCH 2/2] fix(a11y): tolerate accessibility scan/save timeouts in afterEach (SDK-6463) A hung accessibility scan made the 30s cy.wrap() time out and fail the afterEach hook, which makes Cypress skip ALL remaining tests in the spec (they surface as 'skipped' instead of running). Add .catch handlers to both cy.wrap(..., {timeout:30000}) chains (performScan and saveTestResults) so a timeout is logged instead of cascading into skipped tests. Adds a regression test that loads the real plugin afterEach and asserts tolerance. Co-Authored-By: Claude Opus 4.8 --- bin/accessibility-automation/cypress/index.js | 9 ++ .../accessibility-automation/cypress/index.js | 141 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 test/unit/bin/accessibility-automation/cypress/index.js diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index 78e9c388..fcd359f8 100644 --- a/bin/accessibility-automation/cypress/index.js +++ b/bin/accessibility-automation/cypress/index.js @@ -354,11 +354,20 @@ afterEach(() => { return cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000}); }).then(() => { browserStackLog(`Saved accessibility test results`); + }).catch((err) => { + // SDK-6463: a slow/hung results-save must not bubble up and fail the + // afterEach hook (which would make Cypress skip the rest of the spec). + browserStackLog(`Accessibility afterEach: saving results timed out or failed: ${err && err.message}`); }) } catch (er) { browserStackLog(`Error in saving results with error: ${er.message}`); } + }).catch((err) => { + // SDK-6463: a hung/slow accessibility scan must NOT fail the afterEach hook. + // A failing afterEach makes Cypress skip ALL remaining tests in the spec + // (they surface as "skipped" instead of running). Swallow + log instead. + browserStackLog(`Accessibility afterEach: scan timed out or failed: ${err && err.message}`); }) }); }) 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..9fe3d876 --- /dev/null +++ b/test/unit/bin/accessibility-automation/cypress/index.js @@ -0,0 +1,141 @@ +'use strict'; +const chai = require('chai'); +const expect = chai.expect; + +// SDK-6463 regression test for the accessibility Cypress plugin's afterEach hook. +// A hung/slow accessibility scan or results-save must NOT fail the afterEach hook, +// because a failing afterEach makes Cypress skip all remaining tests in the spec +// (they surface as "skipped"). The two cy.wrap(..., {timeout: 30000}) chains must +// tolerate a timeout (catch + log) instead of letting it bubble up. + +const PLUGIN_PATH = require.resolve('../../../../../bin/accessibility-automation/cypress/index.js'); +const WRAP_TIMEOUT_SIM_MS = 20; // stand-in for the real 30000ms so the test runs fast + +// chainable that mimics Cypress command chaining (.then unwraps nested chainables) +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 + )); + }, + catch(onR) { return chain(promise.catch(onR)); }, + performScan() { return this; }, + performScanSubjectQuery() { return this; }, + }; +} + +// fake window. mode: 'hang' (scan never finishes), 'scanOnly' (scan ok, save hangs), 'ok' +function makeWin(mode) { + const listeners = {}; + const echo = { A11Y_SCAN: 'A11Y_SCAN_FINISHED', A11Y_SAVE_RESULTS: 'A11Y_RESULTS_SAVED' }; + return { + location: { protocol: 'http:' }, + document: { 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]; + const shouldEcho = mode === 'ok' || (mode === 'scanOnly' && e.type === 'A11Y_SCAN'); + if (shouldEcho && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} })); + return true; + }, + }; +} + +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', + 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, + wrap: (value, opts) => { + if (value && typeof value.then === 'function') { + const realTimeout = (opts && opts.timeout) || 0; + const waitMs = realTimeout ? Math.min(realTimeout, WRAP_TIMEOUT_SIM_MS) : WRAP_TIMEOUT_SIM_MS; + const timed = new Promise((resolve, reject) => { + let done = false; + value.then((v) => { if (!done) { done = true; resolve(v); } }, (e) => { if (!done) { done = true; reject(e); } }); + setTimeout(() => { if (!done) { done = true; reject(new Error(`cy.wrap() timed out waiting ${realTimeout}ms to complete.`)); } }, waitMs); + }); + return chain(timed); + } + return chain(Promise.resolve(value)); + }, + window: () => chain(Promise.resolve(theWin)), + task: () => chain(Promise.resolve({ testRunUuid: 'uuid-123' })), + on() {}, + }; + + // Temporarily capture the plugin's global afterEach registration without + // registering it as a real mocha hook, then restore mocha's own globals. + const realAfterEach = global.afterEach; + const realBefore = global.before; + const 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); + capturedAfterEach(); // invoke the real hook callback (fire-and-forget, as Cypress does) + return new Promise((r) => setTimeout(r, WRAP_TIMEOUT_SIM_MS + 100)).then(() => + unhandled.filter((m) => /cy\.wrap\(\) timed out/.test(m))); + } + + it('captures the real afterEach hook from the plugin', () => { + expect(capturedAfterEach).to.be.a('function'); + }); + + it('does not fail the hook when the accessibility scan never finishes', async () => { + const timeouts = await runHook('hang'); + expect(timeouts, 'an uncaught cy.wrap timeout would fail the hook and skip remaining tests').to.have.length(0); + }); + + it('does not fail the hook when saving results never finishes', async () => { + const timeouts = await runHook('scanOnly'); + expect(timeouts).to.have.length(0); + }); + + it('completes normally on the happy path', async () => { + const timeouts = await runHook('ok'); + expect(timeouts).to.have.length(0); + }); +});