Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: New API #105

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Changes from all commits
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
200 changes: 121 additions & 79 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import axe from 'axe-core';

// TODO: After all handlers
// TODO: Default console reporter

// https://github.com/cypress-io/code-coverage

// Send an event we can subscribe to inside a plugin
after(() => {
cy.task('cypressAxeAfterAll');
console.log('🦜🦜🦜🦜 AFTER 🦜🦜🦜🦜');
});

Cypress.on('test:after:run', () => {
console.log('🦜🦜🦜🦜 test:after:run 🦜🦜🦜🦜');
});

declare global {
interface Window {
axe: typeof axe;
Expand All @@ -11,16 +26,58 @@ declare global {
namespace Cypress {
interface Chainable {
injectAxe: typeof injectAxe;
configureAxe: typeof configureAxe;
configureCypressAxe: typeof configureCypressAxe;
checkA11y: typeof checkA11y;
}
}
}

export interface Options extends axe.RunOptions {
includedImpacts?: string[];
type Reporter = (results: RunResults[]) => void;

interface RunResults {
/** Test file */
filename: string;
/** Label passed to checkA11y() */
label?: string;
/** All results returned by Axe, including violations and passes */
results: axe.AxeResults;
}

interface CypressAxeOptions {
/** Axe options object that is passed to axe.configure() */
axeOptions?: axe.Spec;
/** Axe run options object that is passed to each axe.run() call */
axeRunOptions?: axe.RunOptions;
/** Returns true when the assertion should fail */
shouldFail?: (results: axe.AxeResults) => boolean;
/** Custom reporters */
reporters?: Reporter[];
}

type CypressAxeRunOptions = Omit<CypressAxeOptions, 'axeOptions'>;

let config: CypressAxeOptions = {};

const shouldFailDefault = ({ violations }: axe.AxeResults) =>
violations.length > 0;

const formatFailureMessage = (violations: axe.Result[]) =>
`${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} ${violations.length === 1 ? 'was' : 'were'} detected`;

const formatViolationMessage = (violation: axe.Result) =>
`${violation.id} on ${violation.nodes.length} element${
violation.nodes.length === 1 ? '' : 's'
}`;

const isEmptyObjectorNull = (value: any) => {
if (value == null) {
return true;
}
return Object.entries(value).length === 0 && value.constructor === Object;
};

export const injectAxe = () => {
const fileName =
typeof require?.resolve === 'function'
Expand All @@ -33,94 +90,79 @@ export const injectAxe = () => {
);
};

export const configureAxe = (configurationOptions = {}) => {
export const configureCypressAxe = ({
axeOptions = {},
...options
}: CypressAxeOptions = {}) => {
cy.window({ log: false }).then((win) => {
return win.axe.configure(configurationOptions);
config = { ...config, ...options };
return win.axe.configure(axeOptions);
});
};

function isEmptyObjectorNull(value: any) {
if (value == null) {
return true;
}
return Object.entries(value).length === 0 && value.constructor === Object;
}

const checkA11y = (
context?: axe.ElementContext,
options?: Options,
violationCallback?: (violations: axe.Result[]) => void,
skipFailures = false
options?: CypressAxeRunOptions,
label?: string
) => {
cy.window({ log: false })
.then((win) => {
if (isEmptyObjectorNull(context)) {
context = undefined;
}
if (isEmptyObjectorNull(options)) {
options = undefined;
}
if (isEmptyObjectorNull(violationCallback)) {
violationCallback = undefined;
}
const { includedImpacts, ...axeOptions } = options || {};
return win.axe
.run(context || win.document, axeOptions)
.then(({ violations }) => {
return includedImpacts &&
Array.isArray(includedImpacts) &&
Boolean(includedImpacts.length)
? violations.filter(
(v) => v.impact && includedImpacts.includes(v.impact)
)
: violations;
cy.window({ log: false }).then((win) => {
if (isEmptyObjectorNull(context)) {
context = undefined;
}
if (isEmptyObjectorNull(options)) {
options = undefined;
}
// TODO: Merge with global options
const {
axeRunOptions = {},
shouldFail = shouldFailDefault,
reporters = [],
} = options || {};
return win.axe
.run(context || win.document, axeRunOptions)
.then((results) => {
// TODO
reporters.forEach((reporter) => {
const filename = ''; // TODO
reporter([{ filename, label, results }]);
});
})
.then((violations) => {
if (violations.length) {
if (violationCallback) {
violationCallback(violations);

// If we have any violations, fail the run
if (shouldFail(results)) {
assert.equal(
results.violations.length,
0,
formatFailureMessage(results.violations)
);
}
violations.forEach((v) => {
const selectors = v.nodes
.reduce<string[]>((acc, node) => acc.concat(node.target), [])
.join(', ');

Cypress.log({
$el: Cypress.$(selectors),
name: 'a11y error!',
consoleProps: () => v,
message: `${v.id} on ${v.nodes.length} Node${
v.nodes.length === 1 ? '' : 's'
}`,
});
});
}

return cy.wrap(violations, { log: false });
})
.then((violations) => {
if (!skipFailures) {
assert.equal(
violations.length,
0,
`${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} ${violations.length === 1 ? 'was' : 'were'} detected`
);
} else if (violations.length) {
Cypress.log({
name: 'a11y violation summary',
message: `${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} ${violations.length === 1 ? 'was' : 'were'} detected`,
});
}
});
// Log each violation type
if (results.violations.length > 0) {
results.violations.forEach((violation) => {
const selectors = violation.nodes
.reduce<string[]>((acc, node) => acc.concat(node.target), [])
.join(', ');

Cypress.log({
$el: Cypress.$(selectors),
name: 'Accessibility violation',
consoleProps: () => violation,
message: formatViolationMessage(violation),
});
});
}
});
});
};

Cypress.Commands.add('injectAxe', injectAxe);

Cypress.Commands.add('configureAxe', configureAxe);
Cypress.Commands.add('configureCypressAxe', configureCypressAxe);

Cypress.Commands.add('checkA11y', checkA11y);
Cypress.Commands.add(
'checkA11y',
{
prevSubject: 'optional',
},
checkA11y
);