Skip to content

Commit

Permalink
Move regex config to BE and add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
albinazs committed Jun 5, 2024
1 parent 8424291 commit 484dce0
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 110 deletions.
163 changes: 116 additions & 47 deletions client/src/includes/a11y-result.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="a"></div>
Expand Down Expand Up @@ -56,63 +63,125 @@ describe('sortAxeViolations', () => {
});
});

describe('checkImageAltText', () => {
beforeEach(() => {
document.body.innerHTML = `
<img src="image1.jpg" alt="Good alt text with words like GIFted and moTIF">
<img src="image2.png" alt="Bad alt text.png">
<img src="image3.tiff" alt="Bad alt text.TIFF more text">
<img src="image4.png" alt="https://Bad.alt.text">
<img src="https://example.com/image5.gif" alt="">
<img src="image6.jpg">
`;
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<HTMLImageElement>(
'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<HTMLImageElement>(
'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<HTMLImageElement>(
'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<HTMLImageElement>(
'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<HTMLImageElement>(
'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<HTMLImageElement>(
'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);
});
});
52 changes: 17 additions & 35 deletions client/src/includes/a11y-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
custom: Spec;
spec: Spec;
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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
};
Expand All @@ -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,
Expand All @@ -130,7 +112,7 @@ const addCustomChecks = (customConfig: Spec): Spec => {
return check;
});
return {
...customConfig,
...spec,
checks: modifiedChecks,
};
};
Expand All @@ -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<A11yReport> => {
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);
Expand Down
59 changes: 31 additions & 28 deletions wagtail/admin/userbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,34 +63,36 @@ class AccessibilityItem(BaseItem):
#: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#options-parameter-examples>`__.
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 <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure>`__.
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 <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure>`__.
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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 484dce0

Please sign in to comment.