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/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/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); + }); +}); 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', () => {