Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
// 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`;
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