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 = `
-
-
-
-
-
-
- `;
+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):