-
Notifications
You must be signed in to change notification settings - Fork 44
fix(SDK-6463): robust TS config compile in NX monorepos + tolerate a11y afterEach scan timeouts #1131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
fix(SDK-6463): robust TS config compile in NX monorepos + tolerate a11y afterEach scan timeouts #1131
Changes from all commits
1314ebb
e16768e
94a33c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Suggestion — [GRACEFUL DEGRADATION] Silent scan timeout gives zero user feedback on Accessibility dashboard
Problem
The
.catchis the right call here — preventing a hung scan from skipping remaining tests is the correct priority. However, the catch only logs viabrowserStackLog, which is suppressed whenBROWSERSTACK_LOGS: false(a common CI configuration inCypress.env).When a scan times out under that configuration: the test passes, no accessibility results are recorded, and the Accessibility dashboard shows no data for that test — with no indication of why it's missing.
Suggested Fix
Consider also emitting a lightweight signal when the catch fires, e.g. via
cy.task:Not a blocker — the current tradeoff (graceful degradation over partial data) is intentional and valid. Worth a follow-up ticket if the Accessibility team wants to surface timeout counts on the dashboard.