Skip to content

Commit fbccd75

Browse files
authored
Merge pull request #1139 from browserstack/sdk-6463-a11y-afterEach-fail-guard
fix(SDK-6463): catch a11y afterEach failures (cy.on fail guard) + circuit-break repeated scan timeouts
2 parents 9b8d34b + 16c0023 commit fbccd75

2 files changed

Lines changed: 71 additions & 4 deletions

File tree

  • bin/accessibility-automation/cypress
  • test/unit/bin/accessibility-automation/cypress

bin/accessibility-automation/cypress/index.js

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,27 @@ const browserStackLog = (message) => {
44
if (!Cypress.env('BROWSERSTACK_LOGS')) return;
55
cy.task('browserstack_log', message);
66
}
7-
7+
8+
// SDK-6463: circuit breaker for a dead/unresponsive accessibility scanner.
9+
// Each hung scan/save costs up to ACCESSIBILITY_SCAN_TIMEOUT (default 25s). Without a
10+
// breaker, a scanner that never responds stalls EVERY test's afterEach (and every
11+
// wrapped command) by that much. After N consecutive timeouts we stop attempting
12+
// accessibility work for the remainder of this spec file (module state resets per spec).
13+
let consecutiveA11yTimeouts = 0;
14+
let a11yCircuitOpen = false;
15+
let a11yCircuitLogged = false;
16+
const getA11yCircuitLimit = () => parseInt(Cypress.env('ACCESSIBILITY_SCAN_CIRCUIT_LIMIT')) || 3;
17+
const noteA11yTimeout = () => {
18+
consecutiveA11yTimeouts += 1;
19+
if (!a11yCircuitOpen && consecutiveA11yTimeouts >= getA11yCircuitLimit()) {
20+
a11yCircuitOpen = true;
21+
// eslint-disable-next-line no-console
22+
console.warn('BrowserStack Accessibility: scanner did not respond ' + consecutiveA11yTimeouts + ' consecutive times; skipping accessibility scans for the remaining tests in this spec.');
23+
}
24+
};
25+
const noteA11ySuccess = () => { consecutiveA11yTimeouts = 0; };
26+
27+
828
const commandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
929
// scroll is not a default function in cypress.
1030
const commandToOverwrite = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin'];
@@ -50,10 +70,14 @@ new Promise((resolve) => {
5070
// the rest of the spec. Failure modes guarded here:
5171
// - the injected scanner never dispatches A11Y_SCAN_FINISHED (page mid-navigation / slow scan)
5272
// - win is cross-origin (e.g. an SSO redirect) so win.location / win.document throw synchronously
73+
if (a11yCircuitOpen) {
74+
// Scanner has repeatedly not responded in this spec — don't stall this test too.
75+
return resolve("Accessibility scan skipped: scanner unresponsive (circuit open)");
76+
}
5377
let settled = false;
5478
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
5579
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
56-
const overallTimer = setTimeout(() => finish("Accessibility scan timed out"), overallTimeout);
80+
const overallTimer = setTimeout(() => { noteA11yTimeout(); finish("Accessibility scan timed out"); }, overallTimeout);
5781

5882
try {
5983
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
@@ -89,6 +113,7 @@ new Promise((resolve) => {
89113
function startScan() {
90114
function onScanComplete() {
91115
win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete);
116+
if (!settled) noteA11ySuccess();
92117
return finish();
93118
}
94119

@@ -222,10 +247,13 @@ const saveTestResults = (win, payloadToSend) =>
222247
new Promise((resolve) => {
223248
// SDK-6463: must always settle (see performScan note) so a slow/absent A11Y_RESULTS_SAVED
224249
// event or a cross-origin window cannot fail the afterEach hook.
250+
if (a11yCircuitOpen) {
251+
return resolve("Accessibility results save skipped: scanner unresponsive (circuit open)");
252+
}
225253
let settled = false;
226254
const finish = (val) => { if (!settled) { settled = true; clearTimeout(overallTimer); resolve(val); } };
227255
const overallTimeout = parseInt(Cypress.env('ACCESSIBILITY_SCAN_TIMEOUT')) || 25000;
228-
const overallTimer = setTimeout(() => finish("Accessibility results save timed out"), overallTimeout);
256+
const overallTimer = setTimeout(() => { noteA11yTimeout(); finish("Accessibility results save timed out"); }, overallTimeout);
229257
try {
230258
const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol);
231259
if (!isHttpOrHttps) {
@@ -261,6 +289,7 @@ new Promise((resolve) => {
261289
function saveResults() {
262290
function onResultsSaved(event) {
263291
win.removeEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
292+
if (!settled) noteA11ySuccess();
264293
return finish();
265294
}
266295
win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved);
@@ -335,6 +364,17 @@ commandToOverwrite.forEach((command) => {
335364
});
336365

337366
afterEach(() => {
367+
// SDK-6463: nothing that happens inside this accessibility hook may fail the user's
368+
// test or abort the remaining tests in the spec. Cypress chains have no .catch, so
369+
// suppress any failure raised while this hook's commands run (e.g. cy.window() on a
370+
// cross-origin page after an SSO redirect, or a cy.task that is not registered) via
371+
// the per-test 'fail' listener. Returning false prevents Cypress from failing the
372+
// test; the listener is scoped to the current test and auto-removed afterwards.
373+
cy.on('fail', (err) => {
374+
// eslint-disable-next-line no-console
375+
console.warn(`BrowserStack Accessibility: suppressed afterEach error: ${err && err.message}`);
376+
return false;
377+
});
338378
const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest;
339379
cy.window().then(async (win) => {
340380
let shouldScanTestForAccessibility = shouldScanForAccessibility(attributes);

test/unit/bin/accessibility-automation/cypress/index.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ function makeWin(mode) {
3838
const echo = { A11Y_SCAN: 'A11Y_SCAN_FINISHED', A11Y_SAVE_RESULTS: 'A11Y_RESULTS_SAVED' };
3939
const guard = () => { if (mode === 'crossorigin') throw new Error("Blocked a frame with origin from accessing a cross-origin frame."); };
4040
return {
41+
dispatched: [], // records every event type dispatched at the page (A11Y_SCAN, ...)
4142
get location() { guard(); return { protocol: 'http:' }; },
4243
get document() { guard(); return { querySelector: () => ({ id: 'accessibility-automation-element' }) }; },
4344
addEventListener(type, cb) { (listeners[type] = listeners[type] || []).push(cb); },
4445
removeEventListener(type, cb) { listeners[type] = (listeners[type] || []).filter((f) => f !== cb); },
4546
dispatchEvent(e) {
47+
this.dispatched.push(e.type);
4648
const done = echo[e.type];
4749
if (mode === 'ok' && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} }));
4850
return true; // 'hang' echoes nothing -> relies on the internal always-settle timer
@@ -54,6 +56,7 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => {
5456
let capturedAfterEach;
5557
let theWin;
5658
const unhandled = [];
59+
const failHandlers = [];
5760
const onUnhandled = (reason) => unhandled.push(reason && reason.message ? reason.message : String(reason));
5861

5962
before(() => {
@@ -81,7 +84,8 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => {
8184
wrap: (value) => chain((value && typeof value.then === 'function') ? value : Promise.resolve(value)),
8285
window: () => chain(Promise.resolve(theWin)),
8386
task: () => chain(Promise.resolve({ testRunUuid: 'uuid-123' })),
84-
on() {},
87+
// capture per-test fail listeners the hook registers (cy.on('fail', ...))
88+
on(evt, fn) { if (evt === 'fail') failHandlers.push(fn); },
8589
};
8690

8791
const realAfterEach = global.afterEach, realBefore = global.before, realBeforeEach = global.beforeEach;
@@ -126,4 +130,27 @@ describe('accessibility-automation/cypress afterEach (SDK-6463)', () => {
126130
const rej = await runHook('ok');
127131
expect(rej).to.have.length(0);
128132
});
133+
134+
// SDK-6463 hardening: any failure raised by the hook's own commands (cy.window on a
135+
// cross-origin page, an unregistered cy.task, ...) must be suppressed via the per-test
136+
// 'fail' listener so it cannot fail the user's test or abort the spec.
137+
it('registers a per-test fail listener that suppresses hook failures (returns false)', async () => {
138+
failHandlers.length = 0;
139+
await runHook('ok');
140+
expect(failHandlers.length, 'afterEach must register a cy.on("fail") guard').to.be.at.least(1);
141+
const result = failHandlers[0](new Error("The task 'get_test_run_uuid' was not handled"));
142+
expect(result, 'fail handler must return false to suppress the failure').to.equal(false);
143+
});
144+
145+
// SDK-6463 hardening: after repeated scan/save timeouts, the circuit opens and the
146+
// plugin stops dispatching A11Y_SCAN entirely so later tests are not stalled.
147+
it('opens the circuit after repeated timeouts and stops dispatching scans', async () => {
148+
// happy path above reset the consecutive-timeout counter; two hang runs produce
149+
// 3 timeouts (scan+save, then scan) which reaches the default circuit limit of 3.
150+
await runHook('hang');
151+
await runHook('hang');
152+
const rej = await runHook('hang'); // circuit open: must not dispatch, must not stall
153+
expect(rej).to.have.length(0);
154+
expect(theWin.dispatched, 'no A11Y_SCAN once the circuit is open').to.not.include('A11Y_SCAN');
155+
});
129156
});

0 commit comments

Comments
 (0)