Skip to content
9 changes: 9 additions & 0 deletions bin/accessibility-automation/cypress/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Copy link
Copy Markdown
Collaborator

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 .catch is the right call here — preventing a hung scan from skipping remaining tests is the correct priority. However, the catch only logs via browserStackLog, which is suppressed when BROWSERSTACK_LOGS: false (a common CI configuration in Cypress.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:

}).catch((err) => {
    browserStackLog(`Accessibility afterEach: scan timed out or failed: ${err && err.message}`);
    // Optionally: surface a "scan skipped" marker to the Accessibility dashboard
    // so it shows "scan skipped — timeout" instead of silently missing data.
    // cy.task('bstack:accessibility_scan_skipped', { reason: err && err.message }, { log: false });
})

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.

// 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}`);
})
});
})
Expand Down
33 changes: 29 additions & 4 deletions bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
};
Expand Down Expand Up @@ -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`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning — [FEATURE/FIX CORRECTNESS] Windows & introduces parallel execution, not sequential-unconditional

Problem

On Windows CMD, cmd1 & cmd2 executes both commands concurrentlycmd2 starts immediately without waiting for cmd1 to finish. This is the opposite of Unix ;, which is strictly sequential.

In this context: if tsc-alias starts before tsc has written the compiled .js to tmpBstackCompiledJs/, it processes a not-yet-emitted file — the alias rewrite is a silent no-op, and the compiled config still can't be require()d.

The intent stated in the comment is "run tsc-alias regardless of tsc's exit code, but after tsc completes." & satisfies "regardless of exit code" but not "after tsc completes."

Unix ; (the else branch) is exactly right. Windows & is not the equivalent.

Suggested Fix

Replace & with || cd. (a no-op fallback that preserves sequencing):

// Windows: '|| cd.' is the CMD idiom for "run B sequentially, regardless of A's exit code"
const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" || cd. && ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`;

Or, if you prefer PowerShell (more readable, avoids CMD-ism):

const tscCommand = `powershell -Command "& '${typescript_path}' --project '${tempTsConfigPath}'; & '${tsc_alias_path}' --project '${tempTsConfigPath}' --verbose"`;

If you've empirically verified that & doesn't race in practice (e.g., because CMD buffers background-job start in non-interactive mode), add a code comment documenting that and the warning is acknowledged.

Confidence: 🟢 Objectively verifiable — Windows CMD & operator semantics are documented; the race is reproducible by running both commands in CMD with a slow tsc invocation.

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 };
}
Expand Down
141 changes: 141 additions & 0 deletions test/unit/bin/accessibility-automation/cypress/index.js
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);
});
});
50 changes: 49 additions & 1 deletion test/unit/bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading