From 1314ebbb97dfd34c116368256f3e46327c836ed9 Mon Sep 17 00:00:00 2001 From: Bhargavi-BS Date: Fri, 19 Jun 2026 14:50:54 +0530 Subject: [PATCH 1/4] 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/4] 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); + }); +}); From 5aec0a0de053655a7a06fe7e421cc0cf0a3747cf Mon Sep 17 00:00:00 2001 From: Bhargavi-BS Date: Wed, 1 Jul 2026 19:32:27 +0530 Subject: [PATCH 3/4] =?UTF-8?q?fix(a11y):=20rework=20afterEach=20=E2=80=94?= =?UTF-8?q?=20remove=20invalid=20cy=20.catch,=20make=20scans=20always-sett?= =?UTF-8?q?le=20(SDK-6463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier afterEach used .catch() on Cypress chains (cy.wrap(...).then(...).catch(...)). Cypress Chainable has no .catch (commands are not promises), so it threw 'cy.wrap(...).then(...).catch is not a function' synchronously in the hook, failing it and aborting the rest of the spec — the customer's exact symptom. Rework: performScan/saveTestResults now ALWAYS settle (internal timeout tunable via ACCESSIBILITY_SCAN_TIMEOUT, default 25s) and guard cross-origin window access, so cy.wrap's 30s timeout never fires and the hook never throws. Verified with REAL Cypress (headed): the old code reproduces 'skipping all of the remaining tests'; the reworked code runs all tests and completes the scan path (Payload to send / Saved accessibility test results logged). Co-Authored-By: Claude Opus 4.8 --- bin/accessibility-automation/cypress/index.js | 131 ++++++++++-------- .../accessibility-automation/cypress/index.js | 86 +++++------- 2 files changed, 107 insertions(+), 110 deletions(-) diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index fcd359f8..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(); } }) @@ -354,20 +372,11 @@ 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 index 9fe3d876..4cbb7f0f 100644 --- a/test/unit/bin/accessibility-automation/cypress/index.js +++ b/test/unit/bin/accessibility-automation/cypress/index.js @@ -2,16 +2,21 @@ 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. +// 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'); -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) +// Cypress Chainable mock — deliberately has `then` but NO `catch` (matches real Cypress). function chain(promise) { return { _promise: promise, @@ -21,26 +26,26 @@ function chain(promise) { onR )); }, - catch(onR) { return chain(promise.catch(onR)); }, + // NOTE: no `catch` — real Cypress Chainable has none. performScan() { return this; }, performScanSubjectQuery() { return this; }, }; } -// fake window. mode: 'hang' (scan never finishes), 'scanOnly' (scan ok, save hangs), 'ok' +// 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 { - location: { protocol: 'http:' }, - document: { querySelector: () => ({ id: 'accessibility-automation-element' }) }, + 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]; - const shouldEcho = mode === 'ok' || (mode === 'scanOnly' && e.type === 'A11Y_SCAN'); - if (shouldEcho && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} })); - return true; + if (mode === 'ok' && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} })); + return true; // 'hang' echoes nothing -> relies on the internal always-settle timer }, }; } @@ -61,6 +66,7 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => { 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 }, @@ -71,39 +77,21 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => { }; 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)); - }, + // 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() {}, }; - // 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; + const realAfterEach = global.afterEach, realBefore = global.before, realBeforeEach = global.beforeEach; global.afterEach = (fn) => { capturedAfterEach = fn; }; - global.before = () => {}; - global.beforeEach = () => {}; + global.before = () => {}; global.beforeEach = () => {}; try { delete require.cache[PLUGIN_PATH]; require(PLUGIN_PATH); } finally { - global.afterEach = realAfterEach; - global.before = realBefore; - global.beforeEach = realBeforeEach; + global.afterEach = realAfterEach; global.before = realBefore; global.beforeEach = realBeforeEach; } }); @@ -115,27 +103,27 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => { 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))); + // 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 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 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 fail the hook when saving results never finishes', async () => { - const timeouts = await runHook('scanOnly'); - expect(timeouts).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 normally on the happy path', async () => { - const timeouts = await runHook('ok'); - expect(timeouts).to.have.length(0); + it('completes cleanly on the happy path', async () => { + const rej = await runHook('ok'); + expect(rej).to.have.length(0); }); }); From 4b14e0f3a77d5db955ba6a9d6993a18fb5eb0d97 Mon Sep 17 00:00:00 2001 From: Bhargavi-BS Date: Wed, 1 Jul 2026 19:38:37 +0530 Subject: [PATCH 4/4] fix(specs): survive glob/minimatch crash from incompatible brace-expansion (SDK-6463) glob.sync (glob@7->minimatch@3, and even glob@10->minimatch@9) throws 'expand is not a function' / 'brace_expansion_1.default is not a function' when a project force-resolves brace-expansion to an incompatible major (e.g. 5.x) via yarn resolutions / npm overrides. That crash aborted getNumberOfSpecFiles and produced builds with 0 executed tests (or crashed the run). A secondary bug in deleteBaseUrlFromError (err.replace on a non-string) then masked the real error. - Wrap glob.sync in safeGlobSync: log once and return [] on failure so spec discovery and the run proceed (specs are resolved on BrowserStack regardless of the local count). - Backstop try/catch in getNumberOfSpecFiles so it never throws. - Guard deleteBaseUrlFromError against non-string errors. Verified against a real glob@7 + brace-expansion@5 crash. Adds regression tests. Co-Authored-By: Claude Opus 4.8 --- bin/helpers/utils.js | 39 ++++++++++++++++++++++++++++++---- test/unit/bin/helpers/utils.js | 30 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) 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/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'); + }); + }); + });