Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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) => {
Comment thread
Bhargavi-BS marked this conversation as resolved.
Outdated
// 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`;
Comment thread
Bhargavi-BS marked this conversation as resolved.
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