Skip to content

Commit e16768e

Browse files
Bhargavi-BSclaude
andcommitted
fix(a11y): tolerate accessibility scan/save timeouts in afterEach (SDK-6463)
A hung accessibility scan made the 30s cy.wrap() time out and fail the afterEach hook, which makes Cypress skip ALL remaining tests in the spec (they surface as 'skipped' instead of running). Add .catch handlers to both cy.wrap(..., {timeout:30000}) chains (performScan and saveTestResults) so a timeout is logged instead of cascading into skipped tests. Adds a regression test that loads the real plugin afterEach and asserts tolerance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1314ebb commit e16768e

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

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

bin/accessibility-automation/cypress/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,20 @@ afterEach(() => {
354354
return cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000});
355355
}).then(() => {
356356
browserStackLog(`Saved accessibility test results`);
357+
}).catch((err) => {
358+
// SDK-6463: a slow/hung results-save must not bubble up and fail the
359+
// afterEach hook (which would make Cypress skip the rest of the spec).
360+
browserStackLog(`Accessibility afterEach: saving results timed out or failed: ${err && err.message}`);
357361
})
358362

359363
} catch (er) {
360364
browserStackLog(`Error in saving results with error: ${er.message}`);
361365
}
366+
}).catch((err) => {
367+
// SDK-6463: a hung/slow accessibility scan must NOT fail the afterEach hook.
368+
// A failing afterEach makes Cypress skip ALL remaining tests in the spec
369+
// (they surface as "skipped" instead of running). Swallow + log instead.
370+
browserStackLog(`Accessibility afterEach: scan timed out or failed: ${err && err.message}`);
362371
})
363372
});
364373
})
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict';
2+
const chai = require('chai');
3+
const expect = chai.expect;
4+
5+
// SDK-6463 regression test for the accessibility Cypress plugin's afterEach hook.
6+
// A hung/slow accessibility scan or results-save must NOT fail the afterEach hook,
7+
// because a failing afterEach makes Cypress skip all remaining tests in the spec
8+
// (they surface as "skipped"). The two cy.wrap(..., {timeout: 30000}) chains must
9+
// tolerate a timeout (catch + log) instead of letting it bubble up.
10+
11+
const PLUGIN_PATH = require.resolve('../../../../../bin/accessibility-automation/cypress/index.js');
12+
const WRAP_TIMEOUT_SIM_MS = 20; // stand-in for the real 30000ms so the test runs fast
13+
14+
// chainable that mimics Cypress command chaining (.then unwraps nested chainables)
15+
function chain(promise) {
16+
return {
17+
_promise: promise,
18+
then(onF, onR) {
19+
return chain(promise.then(
20+
(v) => { const r = onF ? onF(v) : v; return (r && r._promise) ? r._promise : r; },
21+
onR
22+
));
23+
},
24+
catch(onR) { return chain(promise.catch(onR)); },
25+
performScan() { return this; },
26+
performScanSubjectQuery() { return this; },
27+
};
28+
}
29+
30+
// fake window. mode: 'hang' (scan never finishes), 'scanOnly' (scan ok, save hangs), 'ok'
31+
function makeWin(mode) {
32+
const listeners = {};
33+
const echo = { A11Y_SCAN: 'A11Y_SCAN_FINISHED', A11Y_SAVE_RESULTS: 'A11Y_RESULTS_SAVED' };
34+
return {
35+
location: { protocol: 'http:' },
36+
document: { querySelector: () => ({ id: 'accessibility-automation-element' }) },
37+
addEventListener(type, cb) { (listeners[type] = listeners[type] || []).push(cb); },
38+
removeEventListener(type, cb) { listeners[type] = (listeners[type] || []).filter((f) => f !== cb); },
39+
dispatchEvent(e) {
40+
const done = echo[e.type];
41+
const shouldEcho = mode === 'ok' || (mode === 'scanOnly' && e.type === 'A11Y_SCAN');
42+
if (shouldEcho && done) (listeners[done] || []).forEach((cb) => cb({ detail: {} }));
43+
return true;
44+
},
45+
};
46+
}
47+
48+
describe('accessibility-automation/cypress afterEach (SDK-6463)', () => {
49+
let capturedAfterEach;
50+
let theWin;
51+
const unhandled = [];
52+
const onUnhandled = (reason) => unhandled.push(reason && reason.message ? reason.message : String(reason));
53+
54+
before(() => {
55+
process.on('unhandledRejection', onUnhandled);
56+
57+
global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } };
58+
global.window = { location: { protocol: 'http:' } };
59+
global.Cypress = {
60+
env: (k) => ({
61+
BROWSERSTACK_LOGS: false,
62+
IS_ACCESSIBILITY_EXTENSION_LOADED: 'true',
63+
ACCESSIBILITY_EXTENSION_PATH: '/some/ext/path',
64+
OS: 'win',
65+
})[k],
66+
browser: { isHeaded: true },
67+
platform: 'linux',
68+
Commands: { add() {}, overwrite() {}, addQuery() {} },
69+
on() {},
70+
mocha: { getRunner: () => ({ suite: { ctx: { currentTest: { title: 'TC landing', invocationDetails: { relativeFile: 'src/e2e/landing.cy.ts' } } } } }) },
71+
};
72+
global.cy = {
73+
state: () => null,
74+
wrap: (value, opts) => {
75+
if (value && typeof value.then === 'function') {
76+
const realTimeout = (opts && opts.timeout) || 0;
77+
const waitMs = realTimeout ? Math.min(realTimeout, WRAP_TIMEOUT_SIM_MS) : WRAP_TIMEOUT_SIM_MS;
78+
const timed = new Promise((resolve, reject) => {
79+
let done = false;
80+
value.then((v) => { if (!done) { done = true; resolve(v); } }, (e) => { if (!done) { done = true; reject(e); } });
81+
setTimeout(() => { if (!done) { done = true; reject(new Error(`cy.wrap() timed out waiting ${realTimeout}ms to complete.`)); } }, waitMs);
82+
});
83+
return chain(timed);
84+
}
85+
return chain(Promise.resolve(value));
86+
},
87+
window: () => chain(Promise.resolve(theWin)),
88+
task: () => chain(Promise.resolve({ testRunUuid: 'uuid-123' })),
89+
on() {},
90+
};
91+
92+
// Temporarily capture the plugin's global afterEach registration without
93+
// registering it as a real mocha hook, then restore mocha's own globals.
94+
const realAfterEach = global.afterEach;
95+
const realBefore = global.before;
96+
const realBeforeEach = global.beforeEach;
97+
global.afterEach = (fn) => { capturedAfterEach = fn; };
98+
global.before = () => {};
99+
global.beforeEach = () => {};
100+
try {
101+
delete require.cache[PLUGIN_PATH];
102+
require(PLUGIN_PATH);
103+
} finally {
104+
global.afterEach = realAfterEach;
105+
global.before = realBefore;
106+
global.beforeEach = realBeforeEach;
107+
}
108+
});
109+
110+
after(() => {
111+
process.removeListener('unhandledRejection', onUnhandled);
112+
delete global.Cypress; delete global.cy; delete global.window; delete global.CustomEvent;
113+
});
114+
115+
function runHook(mode) {
116+
unhandled.length = 0;
117+
theWin = makeWin(mode);
118+
capturedAfterEach(); // invoke the real hook callback (fire-and-forget, as Cypress does)
119+
return new Promise((r) => setTimeout(r, WRAP_TIMEOUT_SIM_MS + 100)).then(() =>
120+
unhandled.filter((m) => /cy\.wrap\(\) timed out/.test(m)));
121+
}
122+
123+
it('captures the real afterEach hook from the plugin', () => {
124+
expect(capturedAfterEach).to.be.a('function');
125+
});
126+
127+
it('does not fail the hook when the accessibility scan never finishes', async () => {
128+
const timeouts = await runHook('hang');
129+
expect(timeouts, 'an uncaught cy.wrap timeout would fail the hook and skip remaining tests').to.have.length(0);
130+
});
131+
132+
it('does not fail the hook when saving results never finishes', async () => {
133+
const timeouts = await runHook('scanOnly');
134+
expect(timeouts).to.have.length(0);
135+
});
136+
137+
it('completes normally on the happy path', async () => {
138+
const timeouts = await runHook('ok');
139+
expect(timeouts).to.have.length(0);
140+
});
141+
});

0 commit comments

Comments
 (0)