diff --git a/client/src/includes/a11y-result.test.ts b/client/src/includes/a11y-result.test.ts index 5825af0396b5..d4bb72df3842 100644 --- a/client/src/includes/a11y-result.test.ts +++ b/client/src/includes/a11y-result.test.ts @@ -1,5 +1,12 @@ -import { AxeResults } from 'axe-core'; -import { sortAxeViolations, checkImageAltText } from './a11y-result'; +import axe, { AxeResults, Spec } from 'axe-core'; +import { + sortAxeViolations, + WagtailAxeConfiguration, + customChecks, + addCustomChecks, + checkImageAltText, + getA11yReport, +} from './a11y-result'; const mockDocument = `
@@ -56,63 +63,125 @@ describe('sortAxeViolations', () => { }); }); -describe('checkImageAltText', () => { - beforeEach(() => { - document.body.innerHTML = ` - Good alt text with words like GIFted and moTIF - Bad alt text.png - Bad alt text.TIFF more text - https://Bad.alt.text - - - `; +describe('customChecks', () => { + it('should have function values for each custom check', () => { + Object.values(customChecks).forEach((value) => { + expect(typeof value).toBe('function'); + }); }); +}); - it('should not flag images with good alt text', () => { - const image = document.querySelector( - 'img[src="image1.jpg"]', +describe('addCustomChecks', () => { + it('should integrate custom checks into the Axe spec', () => { + const spec: Spec = { + checks: [{ id: 'check-id', evaluate: 'functionName' }], + rules: [ + { + id: 'rule-id', + impact: 'serious', + any: ['check-id'], + }, + ], + }; + const modifiedSpec = addCustomChecks(spec); + const customCheck = modifiedSpec?.checks?.find( + (check) => check.id === 'check-id', ); - if (!image) return; - expect(checkImageAltText(image)).toBe(true); + expect(customCheck).toBeDefined(); + expect(customCheck?.evaluate).toBe('functionName'); }); - it('should flag images with a file extension in the alt text', () => { - const image = document.querySelector( - 'img[src="image2.png"]', - ); - if (!image) return; - expect(checkImageAltText(image)).toBe(false); + it('should return spec unchanged if no custom checks match', () => { + const spec: Spec = { + checks: [{ id: 'non-existent-check', evaluate: '' }], + }; + const modifiedSpec = addCustomChecks(spec); + expect(modifiedSpec).toEqual(spec); }); +}); - it('should flag images with a capitalised file extension in the alt text', () => { - const image = document.querySelector( - 'img[src="image3.tiff"]', - ); - if (!image) return; - expect(checkImageAltText(image)).toBe(false); +// Options for checkImageAltText function +const options = { + pattern: + '\\.(apng|avif|gif|jpg|jpeg|jfif|pjp|png|svg|tif|webp)|(http:\\/\\/|https:\\/\\/|www\\.)', +}; + +describe.each` + text | result + ${'Good alt text with words like GIFted and motif'} | ${true} + ${'Bad alt text.png'} | ${false} + ${'Bad alt text.TIFF more text'} | ${false} + ${'https://Bad.alt.text'} | ${false} + ${''} | ${true} +`('checkImageAltText', ({ text, result }) => { + const resultText = result ? 'should not be flagged' : 'should be flagged'; + test(`alt text: "${text}" ${resultText}`, () => { + const image = document.createElement('img'); + image.setAttribute('alt', text); + expect(checkImageAltText(image, options)).toBe(result); }); +}); - it('should flag images with a file URL in the alt text', () => { - const image = document.querySelector( - 'img[src="image4.png"]', - ); - if (!image) return; - expect(checkImageAltText(image)).toBe(false); +describe('checkImageAltText edge cases', () => { + test('should not flag images with no alt attribute', () => { + const image = document.createElement('img'); + expect(checkImageAltText(image, options)).toBe(true); }); - it('should not flag images with empty alt attribute', () => { - const image = document.querySelector( - 'img[src="https://example.com/image5.gif"]', - ); - if (!image) return; - expect(checkImageAltText(image)).toBe(true); + test('should return null if no pattern is provided', () => { + const image = document.createElement('img'); + image.setAttribute('alt', 'Good alt text with words like GIFted and moTIF'); + expect(checkImageAltText(image, {})).toBeUndefined(); }); +}); - it('should not flag images with no alt attribute', () => { - const image = document.querySelector( - 'img[src="image6.jpg"]', - ); - if (!image) return; - expect(checkImageAltText(image)).toBe(true); +jest.mock('axe-core', () => ({ + configure: jest.fn(), + run: jest.fn(), +})); + +describe('getA11yReport', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should configure Axe with custom rules and return the accessibility report', async () => { + const mockResults = { + violations: [ + { + nodes: [{}, {}, {}], // 3 nodes with violations + }, + ], + }; + (axe.run as jest.Mock).mockResolvedValue(mockResults); + const config: WagtailAxeConfiguration = { + context: 'body', + options: {}, + messages: {}, + spec: { + checks: [{ id: 'check-image-alt-text', evaluate: '' }], + }, + }; + const report = await getA11yReport(config); + expect(axe.configure).toHaveBeenCalled(); + expect(axe.run).toHaveBeenCalledWith(config.context, config.options); + expect(report.results).toEqual(mockResults); + expect(report.a11yErrorsNumber).toBe(3); + }); + + it('should return an accessibility report with zero errors if there are no violations', async () => { + const mockResults = { + violations: [], + }; + (axe.run as jest.Mock).mockResolvedValue(mockResults); + const config: WagtailAxeConfiguration = { + context: 'body', + options: {}, + messages: {}, + spec: { + checks: [{ id: 'check-image-alt-text', evaluate: '' }], + }, + }; + const report = await getA11yReport(config); + expect(report.a11yErrorsNumber).toBe(0); }); }); diff --git a/client/src/includes/a11y-result.ts b/client/src/includes/a11y-result.ts index b9d5f844f400..a507494cfc68 100644 --- a/client/src/includes/a11y-result.ts +++ b/client/src/includes/a11y-result.ts @@ -40,11 +40,11 @@ export const sortAxeViolations = (violations: Result[]) => * Wagtail's Axe configuration object. This should reflect what's returned by * `wagtail.admin.userbar.AccessibilityItem.get_axe_configuration()`. */ -interface WagtailAxeConfiguration { +export interface WagtailAxeConfiguration { context: ElementContext; options: RunOptions; messages: Record; - custom: Spec; + spec: Spec; } /** @@ -74,33 +74,15 @@ export const getAxeConfiguration = ( /** * Custom rule for checking image alt text. This rule checks if the alt text for images - * contains file extensions or URLs, which are considered poor quality alt text. + * contains poor quality text like file extensions or URLs. * The rule will be added via the Axe.configure() API. * https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure */ -export const checkImageAltText = (node: Element) => { - const imageFileExtensions = [ - 'apng', - 'avif', - 'gif', - 'jpg', - 'jpeg', - 'jfif', - 'pjpeg', - 'pjp', - 'png', - 'svg', - 'tif', - 'webp', - ]; - const imageURLs = ['http://', 'https://', 'www.']; - const altTextAntipatterns = new RegExp( - `\\.(${imageFileExtensions.join('|')})|(${imageURLs.join('|')})`, - 'i', - ); +export const checkImageAltText = (node: HTMLImageElement, options) => { + if (!options.pattern) return undefined; - const image = node as HTMLImageElement; - const altText = image.getAttribute('alt') || ''; + const altTextAntipatterns = new RegExp(options.pattern, 'i'); + const altText = node.getAttribute('alt') || ''; const hasBadAltText = altTextAntipatterns.test(altText); return !hasBadAltText; @@ -110,7 +92,7 @@ export const checkImageAltText = (node: Element) => { * Defines custom Axe rules, mapping each check to its corresponding JavaScript function. * This object holds the custom checks that will be added to the Axe configuration. */ -const customChecks = { +export const customChecks = { 'check-image-alt-text': checkImageAltText, // Add other custom checks here }; @@ -119,8 +101,8 @@ const customChecks = { * Configures custom Axe rules by integrating the custom checks with their corresponding * JavaScript functions. It modifies the provided configuration to include these checks. */ -const addCustomChecks = (customConfig: Spec): Spec => { - const modifiedChecks = customConfig?.checks?.map((check) => { +export const addCustomChecks = (spec: Spec): Spec => { + const modifiedChecks = spec?.checks?.map((check) => { if (customChecks[check.id]) { return { ...check, @@ -130,7 +112,7 @@ const addCustomChecks = (customConfig: Spec): Spec => { return check; }); return { - ...customConfig, + ...spec, checks: modifiedChecks, }; }; @@ -141,19 +123,19 @@ interface A11yReport { } /** - * Get accessibility testing results from Axe based on the configurable custom checks, context, and options. + * Get accessibility testing results from Axe based on the configurable custom spec, context, and options. * It integrates custom rules into the Axe configuration before running the tests. */ export const getA11yReport = async ( config: WagtailAxeConfiguration, ): Promise => { - let customConfig = config.custom; + let spec = config.spec; // Apply custom configuration for Axe. Custom 'check-image-alt-text' is enabled by default - if (customConfig) { - if (customConfig.checks) { - customConfig = addCustomChecks(customConfig); + if (spec) { + if (spec.checks) { + spec = addCustomChecks(spec); } - axe.configure(customConfig); + axe.configure(spec); } // Initialise Axe based on the context (whole page body by default) and options ('button-name', empty-heading', 'empty-table-header', 'frame-title', 'heading-order', 'input-button-name', 'link-name', 'p-as-heading', and a custom 'alt-text-quality' rules by default) const results = await axe.run(config.context, config.options); diff --git a/wagtail/admin/userbar.py b/wagtail/admin/userbar.py index a8b7e9512754..9d3656035304 100644 --- a/wagtail/admin/userbar.py +++ b/wagtail/admin/userbar.py @@ -63,34 +63,36 @@ class AccessibilityItem(BaseItem): #: For more details, see `Axe documentation `__. axe_rules = {} - #: A list used to add custom rules to the existing set of Axe rules, or to override the properties of existing Axe rules + #: A list used to add custom rules to the existing set of Axe rules, + #: or to override the properties of existing Axe rules. A custom rule + #: to check the quality of the images alt texts is added to the list + #: and enabled by default. This rule ensures that alt texts don't contain + #: antipatterns like file extensions or URLs. Returns zero false positives. + #: Should be used in conjunction with `axe_custom_checks` #: For more details, see `Axe documentation `__. - axe_custom_rules = [] - - #: A custom rule to check the quality of the Images alt texts. Added to the list of custom rules and enabled by default. - #: Should be used in conjunction with :attr:`_axe_custom_alt_text_check` - #: This rule ensures that alt texts doesn't contain antipatterns like file extensions. Returns zero false positives. - _axe_custom_alt_text_rule = [ + axe_custom_rules = [ { "id": "alt-text-quality", "impact": "serious", "selector": "img[alt]", "tags": ["best-practice"], "any": ["check-image-alt-text"], - "enabled": True, # If ommited, defaults to True - } + "enabled": True, # If ommited, defaults to True and overrrides configs in `axe_run_only` + }, ] - #: A list used to add custom checks to the existing set of Axe checks, or to override the properties of existing Axe checks + #: A list used to add custom checks to the existing set of Axe checks, + #: or to override the properties of existing Axe checks. A custom check + #: for the quality of the images alt texts is added and enabled by default. + #: Should be used in conjunction with `axe_custom_rules` #: For more details, see `Axe documentation `__. - axe_custom_checks = [] - - #: A custom check for the custom Image alt text quality rule. Added to the list of custom checks and enabled by default. - #: Should be used in conjunction with :attr:`_axe_custom_alt_text_rule` - _axe_custom_alt_text_check = [ + axe_custom_checks = [ { "id": "check-image-alt-text", - } + "options": { + "pattern": "\.(apng|avif|gif|jpg|jpeg|jfif|pjp|png|svg|tif|webp)|(http:\/\/|https:\/\/|www.)" + }, + }, ] #: A dictionary that maps axe-core rule IDs to custom translatable strings @@ -139,24 +141,25 @@ def get_axe_rules(self, request): return self.axe_rules def get_axe_custom_rules(self, request): - """Returns TODO return nothing if empty both rules and checks""" - return self.axe_custom_rules + self._axe_custom_alt_text_rule + """Returns custom rules for Axe""" + return self.axe_custom_rules def get_axe_custom_checks(self, request): - """Returns TODO return nothing if empty both rules and checks""" - return self.axe_custom_checks + self._axe_custom_alt_text_check + """Returns custom checks for Axe""" + return self.axe_custom_checks - def get_axe_custom_config(self, request): - """Returns TODO return nothing if empty both rules and checks""" - custom_config = { + def get_axe_spec(self, request): + """Returns spec for Axe, including custom rules and custom checks""" + spec = { "rules": self.get_axe_custom_rules(request), "checks": self.get_axe_custom_checks(request), } - # If both the lists of custom rules and custom checks are empty, no custom configuration should be applied for Axe - if not custom_config["rules"] and not custom_config["checks"]: - custom_config = "" - return custom_config + # If both the lists of custom rules and custom checks are empty, + # no custom configuration should be applied for Axe + if not spec["rules"] and not spec["checks"]: + spec = "" + return spec def get_axe_messages(self, request): """Returns a dictionary that maps axe-core rule IDs to custom translatable strings.""" @@ -197,7 +200,7 @@ def get_axe_configuration(self, request): "context": self.get_axe_context(request), "options": self.get_axe_options(request), "messages": self.get_axe_messages(request), - "custom": self.get_axe_custom_config(request), + "spec": self.get_axe_spec(request), } def get_context_data(self, request):