Skip to content

Commit

Permalink
feat: ability to filter violations based on selector keywords (#641)
Browse files Browse the repository at this point in the history
* feat: ability to filter violations based on selctor keywords

* feat: ability to filter violations based on selctor keywords
  • Loading branch information
navateja-alagam committed Feb 9, 2024
1 parent 5d4b0fb commit 987dfd0
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 7 deletions.
6 changes: 5 additions & 1 deletion cSpell.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
"wdio",
"webdriverio",
"webm",
"Vidyard"
"Vidyard",
"pdnejk",
"slds",
"labelledby",
"describedby"
],
"flagWords": ["master-slave", "slave", "blacklist", "whitelist"],
"allowCompoundWords": true
Expand Down
7 changes: 7 additions & 0 deletions packages/assert/__tests__/__snapshots__/assert.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/assert/__tests__/assert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ describe('assertAccessible API', () => {
await checkA11yErrorFunc(() => assertAccessible(elem));
});

it('should throw error with HTML element with a11y issues when passed with selector keywords', async () => {
document.body.innerHTML = domWithA11yIssues;
const elements = document.getElementsByTagName('body');
expect(elements).toHaveLength(1);
const elem = elements[0];
expect(elem).toBeTruthy();
process.env.SELECTOR_FILTER_KEYWORDS = 'lightning-';
await checkA11yErrorFunc(() => assertAccessible(elem));
delete process.env.SELECTOR_FILTER_KEYWORDS;
});

// eslint-disable-next-line jest/expect-expect
it.each(['', 'non-existent-audio.mp3', audioURL])(
'should test audio without timing-out using src %#',
Expand Down
7 changes: 5 additions & 2 deletions packages/assert/src/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import * as axe from 'axe-core';
import { defaultRuleset } from '@sa11y/preset-rules';
import { A11yError } from '@sa11y/format';
import { A11yError, exceptionListFilterSelectorKeywords } from '@sa11y/format';
import { A11yConfig, AxeResults, getViolations } from '@sa11y/common';

/**
Expand Down Expand Up @@ -40,6 +40,9 @@ export async function getViolationsJSDOM(
* */
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function assertAccessible(context: A11yCheckableContext = document, rules: A11yConfig = defaultRuleset) {
const violations = await getViolationsJSDOM(context, rules);
let violations = await getViolationsJSDOM(context, rules);
if (process.env.SELECTOR_FILTER_KEYWORDS) {
violations = exceptionListFilterSelectorKeywords(violations, process.env.SELECTOR_FILTER_KEYWORDS.split(','));
}
A11yError.checkAndThrow(violations);
}
99 changes: 98 additions & 1 deletion packages/format/__tests__/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,89 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { exceptionListFilter } from '../src';
import { exceptionListFilter, exceptionListFilterSelectorKeywords } from '../src';
import { AxeResults } from '@sa11y/common';
import { getViolations } from './format.test';
import { expect } from '@jest/globals';

const mockViolations = [
{
id: 'aria-allowed-attr',
impact: 'serious',
tags: ['cat.aria', 'wcag2a', 'wcag412'],
description: "Ensures ARIA attributes are allowed for an element's role",
help: 'Elements must only use allowed ARIA attributes',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.7/aria-allowed-attr?application=axeAPI',
nodes: [
{
any: [],
all: [],
none: [
{
id: 'aria-prohibited-attr',
data: {
role: null,
nodeName: 'lightning-button-icon',
messageKey: 'noRoleSingular',
prohibited: ['aria-label'],
},
relatedNodes: [],
impact: 'serious',
message:
'aria-label attribute cannot be used on a lightning-button-icon with no valid role attribute.',
},
],
impact: 'serious',
html: '<lightning-button-icon lwc-2pdnejk934a="" class="slds-button slds-button_icon slds-button_icon-small slds-float_right slds-popover__close" aria-label="AgentWhisper.CloseDialog" title="AgentWhisper.CloseDialog"></lightning-button-icon>',
target: ['lightning-button-icon'],
},
],
},
{
id: 'aria-dialog-name',
impact: 'serious',
tags: ['cat.aria', 'best-practice'],
description: 'Ensures every ARIA dialog and alertdialog node has an accessible name',
help: 'ARIA dialog and alertdialog nodes should have an accessible name',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.7/aria-dialog-name?application=axeAPI',
nodes: [
{
any: [
{
id: 'aria-label',
data: null,
relatedNodes: [],
impact: 'serious',
message: 'aria-label attribute does not exist or is empty',
},
{
id: 'aria-labelledby',
data: null,
relatedNodes: [],
impact: 'serious',
message:
'aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty',
},
{
id: 'non-empty-title',
data: {
messageKey: 'noAttr',
},
relatedNodes: [],
impact: 'serious',
message: 'Element has no title attribute',
},
],
all: [],
none: [],
impact: 'serious',
html: '<section lwc-2pdnejk934a="" class="slds-popover popover-position-fixed slds-nubbin_top popover-container-bottom" aria-describedby="dialog-body-id-113-0" aria-labelledby="dialog-heading-id-5-0" role="dialog" style="--scrollOffset: 0px;">',
target: ['section'],
},
],
},
];

let violations: AxeResults = [];
beforeAll(async () => {
violations = await getViolations();
Expand Down Expand Up @@ -61,4 +139,23 @@ describe('a11y results filter', () => {
expect(ruleIDs).toContain(validRule);
expect(ruleIDs.filter((ruleID) => ruleID !== validRule)).toStrictEqual(filteredRuleIDs);
});

it('should filter violations based on selector keywords', () => {
// add ancestry keys
mockViolations[0].nodes[0]['ancestry'] = [
'html > body > agent-whisper-popover > section > lightning-button-icon:nth-child(1)',
];
mockViolations[1].nodes[0]['ancestry'] = ['html > body > agent-whisper-popover > section'];

const filteredViolations = exceptionListFilterSelectorKeywords(mockViolations as AxeResults, ['lightning-']);
expect(filteredViolations).toHaveLength(1);
});

it('should not filter violations if no ancestry keys defined', () => {
delete mockViolations[0].nodes[0]['ancestry'];
delete mockViolations[1].nodes[0]['ancestry'];

const filteredViolations = exceptionListFilterSelectorKeywords(mockViolations as AxeResults, ['lightning-']);
expect(filteredViolations).toHaveLength(2);
});
});
37 changes: 37 additions & 0 deletions packages/format/src/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import * as axe from 'axe-core';
import { AxeResults, ExceptionList } from '@sa11y/common';

/**
Expand Down Expand Up @@ -34,3 +35,39 @@ export function exceptionListFilter(violations: AxeResults, exceptionList: Excep

return filteredViolations;
}

/**
* Filter a11y violations from axe based on given selectors filter keywords
* @param violations - List of violations found with axe
* @param selectorFilterKeywords - List of selector keywords to filter violations for
*/
export function exceptionListFilterSelectorKeywords(
violations: AxeResults,
selectorFilterKeywords: string[]
): AxeResults {
const filteredViolations: AxeResults = [];
for (const violation of violations) {
const filteredNodes: axe.NodeResult[] = [];
for (const node of violation.nodes) {
const isSelectorFilterKeywordsExists = checkSelectorFilterKeyWordsExists(node, selectorFilterKeywords);
if (!isSelectorFilterKeywordsExists) {
filteredNodes.push(node);
}
}
if (filteredNodes.length > 0) {
violation.nodes = filteredNodes;
filteredViolations.push(violation);
}
}
return filteredViolations;
}

function checkSelectorFilterKeyWordsExists(node: axe.NodeResult, selectorFilterKeywords: string[]): boolean {
const selectorAncestry = node.ancestry?.flat(Infinity) ?? [];
let isExists = false;
selectorFilterKeywords.some((keyword) => {
isExists = selectorAncestry.some((selector) => selector.includes(keyword));
return isExists;
});
return isExists;
}
2 changes: 1 addition & 1 deletion packages/format/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
*/

export { A11yError, Options } from './format';
export { exceptionListFilter } from './filter';
export { exceptionListFilter, exceptionListFilterSelectorKeywords } from './filter';
export { A11yResult, A11yResults, appendWcag } from './result';
7 changes: 7 additions & 0 deletions packages/jest/__tests__/automatic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,11 @@ describe('automatic checks call', () => {
await expect(automaticCheck({ cleanupAfterEach: true })).rejects.toThrow('1 Accessibility');
delete process.env.SA11Y_CUSTOM_RULES;
});

it('should pass filter selector keywords', async () => {
document.body.innerHTML = domWithA11yIssues;
process.env.SELECTOR_FILTER_KEYWORDS = 'lightning-';
await expect(automaticCheck({ filesFilter: nonExistentFilePaths })).rejects.toThrow();
delete process.env.SELECTOR_FILTER_KEYWORDS;
});
});
10 changes: 8 additions & 2 deletions packages/jest/src/automatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { AxeResults, log, useCustomRules } from '@sa11y/common';
import { getViolationsJSDOM } from '@sa11y/assert';
import { A11yError } from '@sa11y/format';
import { A11yError, exceptionListFilterSelectorKeywords } from '@sa11y/format';
import { isTestUsingFakeTimer } from './matcher';
import { expect } from '@jest/globals';
import { adaptA11yConfig, adaptA11yConfigCustomRules } from './setup';
Expand Down Expand Up @@ -74,7 +74,7 @@ export async function automaticCheck(opts: AutoCheckOpts = defaultAutoCheckOpts)
return;
}

const violations: AxeResults = [];
let violations: AxeResults = [];
const currentDocumentHtml = document.body.innerHTML;
if (originalDocumentBodyHtml) {
document.body.innerHTML = originalDocumentBodyHtml;
Expand Down Expand Up @@ -107,6 +107,12 @@ export async function automaticCheck(opts: AutoCheckOpts = defaultAutoCheckOpts)
// TODO (spike): Disable stack trace for automatic checks.
// Will this affect all errors globally?
// Error.stackTraceLimit = 0;
if (process.env.SELECTOR_FILTER_KEYWORDS) {
violations = exceptionListFilterSelectorKeywords(
violations,
process.env.SELECTOR_FILTER_KEYWORDS.split(',')
);
}
A11yError.checkAndThrow(violations, { deduplicate: opts.consolidateResults });
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/jest/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,13 @@ export function registerSa11yMatcher(): void {
export function adaptA11yConfigCustomRules(config: A11yConfig, customRules: string[]): A11yConfig {
const adaptedConfig = JSON.parse(JSON.stringify(config)) as A11yConfig;
adaptedConfig.runOnly.values = customRules;
adaptedConfig.ancestry = true;
return adaptedConfig;
}
export function adaptA11yConfig(config: A11yConfig, filterRules = disabledRules): A11yConfig {
// TODO (refactor): Move into preset-rules pkg as a generic rules filter util
const adaptedConfig = JSON.parse(JSON.stringify(config)) as A11yConfig;
adaptedConfig.runOnly.values = config.runOnly.values.filter((rule) => !filterRules.includes(rule));
adaptedConfig.ancestry = true;
return adaptedConfig;
}

0 comments on commit 987dfd0

Please sign in to comment.