Skip to content
This repository has been archived by the owner on Nov 6, 2024. It is now read-only.

test(a11y): automates generation of component list for a11y testing #43

Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
test(a11y): automates generation of component list for a11y testing
calebtr-metro committed May 5, 2022
commit a0fd0dc7e0aaf63ec3f212d54e5e6f8f848d1019
17 changes: 17 additions & 0 deletions a11y.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = {
storybookBuildDir: '.out',
pa11y: {
includeNotices: false,
includeWarnings: false,
runners: ['axe'],
},
// A11y linting is done on a component-by-component
// basis, which results in the linter reporting some errors that
// should be ignored. These codes and descriptions allow for those
// errors to be targeted specifically.
ignore: {
codes: ['landmark-one-main', 'page-has-heading-one'],
descriptions: ['Ensures all page content is contained by landmarks'],
stories: [''],
},
};
668 changes: 658 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@
"description": "Compound is the default Emulsify system, and includes variants for the platforms that Emulsify supports.",
"main": "index.js",
"scripts": {
"a11y": "npm run storybook:build && npx sb extract .out .out/stories.json && ./scripts/a11y.js -r",
"build": "webpack --config ./webpack/webpack.prod.js",
"develop": "concurrently --raw \"npm run webpack\" \"npm run storybook\"",
"format": "npm run lint-fix; npm run prettier-fix",
@@ -94,9 +95,11 @@
"imagemin-webpack-plugin": "^2.4.2",
"lint-staged": "^11.2.6",
"mini-css-extract-plugin": "^1.6.2",
"pa11y": "^5.3.1",
"postcss": "^8.1.7",
"postcss-loader": "^4.0.4",
"prettier": "^2.4.1",
"ramda": "^0.27.1",
"semantic-release": "^18.0.0",
"stylelint": "^13.13.1",
"stylelint-config-prettier": "^9.0.3",
96 changes: 96 additions & 0 deletions scripts/a11y.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* @file a11y.js
* Contains a script that, when executed, will execute a11y linting tools
* against the storybook build.
*/

const R = require('ramda');
const path = require('path');
const pa11y = require('pa11y');
const chalk = require('chalk');
const {
storybookBuildDir,
pa11y: pa11yConfig,
ignore,
} = require('../a11y.config.js');

// Build list of stories from storybook; stories.json is generated from
// `npx sb extract .out stories.json`.
const stories = require('../.out/stories.json');

// Honor ignore rules in config
const componentIds = Object.keys(stories.stories);
const components = componentIds.filter((id) => !ignore.stories.includes(id));

const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir);
const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html');

const severityToColor = R.cond([
[R.equals('error'), R.always('red')],
[R.equals('warning'), R.always('yellow')],
[R.equals('notice'), R.always('blue')],
]);

const issueIsValid = ({ code, runnerExtras: { description } }) =>
ignore.codes.includes(code) || ignore.descriptions.includes(description)
? false
: true;

const logIssue = ({ type: severity, message, context, selector }) => {
console.log(`
severity: ${chalk[severityToColor(severity)](severity)}
message: ${message}
context: ${context}
selector: ${selector}
`);
};

const logReport = ({ issues, pageUrl }) => {
const validIssues = issues.filter(issueIsValid);
const hasIssues = validIssues.length > 0;

if (hasIssues) {
console.log(chalk.red(`Issues found in component: ${pageUrl}`));
validIssues.map(logIssue);
} else {
console.log(chalk.green(`No issues found in component: ${pageUrl}`));
}

return hasIssues;
};

const lintComponent = async (name) =>
pa11y(`${STORYBOOK_IFRAME}?id=${name}`, {
includeNotices: true,
includeWarnings: true,
runners: ['axe'],
...pa11yConfig,
});

const lintReportAndExit = R.pipe(
R.map(lintComponent),
(p) => Promise.all(p),
R.andThen(
R.pipe(
R.map(logReport),
R.reject(R.equals(false)),
R.unless(R.isEmpty, () => process.exit(1)),
),
),
);

// Only perform linting/reporting when instructed.
/* istanbul ignore next */
if (R.pathEq(['argv', 2], '-r')(process)) {
lintReportAndExit(components);
}

module.exports = {
severityToColor,
issueIsValid,
logIssue,
logReport,
lintComponent,
lintReportAndExit,
};
157 changes: 157 additions & 0 deletions scripts/a11y.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
const mockExit = jest
.spyOn(global.process, 'exit')
.mockImplementation(() => {});
jest.mock('pa11y', () => jest.fn());
jest.spyOn(global.console, 'log').mockImplementation(() => {});
const pa11y = require('pa11y');
const path = require('path');
const {
severityToColor,
issueIsValid,
logIssue,
logReport,
lintComponent,
lintReportAndExit,
} = require('./a11y');
const {
ignore,
storybookBuildDir,
pa11y: pa11yConfig,
} = require('../a11y.config.js');

const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir);
const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html');

pa11y.mockResolvedValue('very official report');

describe('a11y', () => {
beforeEach(() => {
global.console.log.mockClear();
global.process.exit.mockClear();
});
it('can map axe issue severity to the correct chalk color', () => {
expect.assertions(3);
expect(severityToColor('error')).toBe('red');
expect(severityToColor('warning')).toBe('yellow');
expect(severityToColor('notice')).toBe('blue');
});

it('identifies invalid issues based on the code or the description', () => {
expect.assertions(3);
expect(
issueIsValid({
code: ignore.codes[0],
runnerExtras: {},
}),
).toBe(false);
expect(
issueIsValid({
runnerExtras: {
description: ignore.descriptions[0],
},
}),
).toBe(false);
expect(issueIsValid({ code: 'chicken', runnerExtras: {} })).toBe(true);
});

it('can use an axe issue to generate a single log message about the issue', () => {
expect.assertions(1);
logIssue({
type: 'error',
message: 'this chicken is not fried enough.',
context: 'https://example.com',
selector: 'kfc > popeyes > .chicken',
});
expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot(`
"
severity: error
message: this chicken is not fried enough.
context: https://example.com
selector: kfc > popeyes > .chicken
"
`);
});

it('can log a whole axe report', () => {
const report = {
issues: [
{
type: 'error',
message: 'this pizza is too soggy',
context: 'https://example.com',
selector: 'pizza > .hut',
runnerExtras: {},
},
{
type: 'error',
message: 'this pasta is undercooked',
context: 'https://example.com',
selector: 'olive > .garden',
runnerExtras: {},
},
],
pageUrl: 'https://example/component.html',
};
expect(logReport(report)).toBe(true);
expect(global.console.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"Issues found in component: https://example/component.html",
],
Array [
"
severity: error
message: this pizza is too soggy
context: https://example.com
selector: pizza > .hut
",
],
Array [
"
severity: error
message: this pasta is undercooked
context: https://example.com
selector: olive > .garden
",
],
]
`);
});

it('logs about a component having no issue if a report comes back empty', () => {
expect(logReport({ issues: [], pageUrl: 'papa-johns' })).toBe(false);
expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot(
`"No issues found in component: papa-johns"`,
);
});

it('can call pa11y with the full path to a component', async () => {
expect.assertions(2);
await expect(lintComponent('chicken-strips')).resolves.toBe(
'very official report',
);
expect(pa11y).toHaveBeenCalledWith(
`${STORYBOOK_IFRAME}?id=chicken-strips`,
pa11yConfig,
);
});

it('runs linter, reports on issues, and exits with code "1" if valid issues are found', async () => {
expect.assertions(1);
pa11y.mockResolvedValueOnce({
issues: [
{
type: 'error',
message: 'these 7 layer supreme burritos do not taste that good',
context: 'https://example.com',
selector: 'taco > bell > .burrito',
runnerExtras: {},
},
],
pageUrl: '/path/to/taco-bell',
});

await lintReportAndExit(['taco-bell']);
expect(global.process.exit).toHaveBeenCalledWith(1);
});
});