[CORL-720] Integratejest-axe (#2741)

* feat: axe checks for tests

* test: add another axe check

* fix: tests
This commit is contained in:
Vinh
2019-12-06 05:44:16 +08:00
committed by Kim Gardner
parent 51bfde8cf8
commit 7615dc2aaf
35 changed files with 283 additions and 40 deletions
+38 -9
View File
@@ -3904,19 +3904,23 @@
}
},
"@types/jest": {
"version": "24.0.13",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.13.tgz",
"integrity": "sha512-3m6RPnO35r7Dg+uMLj1+xfZaOgIHHHut61djNjzwExXN4/Pm9has9C6I1KMYSfz7mahDhWUOVg4HW/nZdv5Pww==",
"version": "24.0.23",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.23.tgz",
"integrity": "sha512-L7MBvwfNpe7yVPTXLn32df/EK+AMBFAFvZrRuArGs7npEWnlziUXK+5GMIUTI4NIuwok3XibsjXCs5HxviYXjg==",
"dev": true,
"requires": {
"@types/jest-diff": "*"
"jest-diff": "^24.3.0"
}
},
"@types/jest-diff": {
"version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jest-diff/-/jest-diff-20.0.1.tgz",
"integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==",
"dev": true
"@types/jest-axe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/jest-axe/-/jest-axe-3.2.1.tgz",
"integrity": "sha512-sn+MFd66gNnvhtBkbQBY6q2aznzLXUIN/jJqXd11D0P+PbUnDrthqyOj81O8BLhEYopmUXIp/ktVvdtj/1GZdw==",
"dev": true,
"requires": {
"@types/jest": "*",
"axe-core": "^3.0.3"
}
},
"@types/joi": {
"version": "13.3.0",
@@ -4184,6 +4188,11 @@
"@types/node": "*"
}
},
"@types/prettier": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.19.0.tgz",
"integrity": "sha512-gDE8JJEygpay7IjA/u3JiIURvwZW08f0cZSZLAzFoX/ZmeqvS0Sqv+97aKuHpNsalAMMhwPe+iAS6fQbfmbt7A=="
},
"@types/prop-types": {
"version": "15.5.8",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz",
@@ -19836,6 +19845,26 @@
}
}
},
"jest-axe": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jest-axe/-/jest-axe-3.2.0.tgz",
"integrity": "sha512-QSQwSwG72/cpmhJU0fBsaUUvu9mb2uAqhccGQVG6JbIV8sK+aIXh8hYl7vxraMF/I6soQod1aqSdD/j7LjpVFQ==",
"dev": true,
"requires": {
"axe-core": "3.3.1",
"chalk": "2.4.2",
"jest-matcher-utils": "24.8.0",
"lodash.merge": "4.6.2"
},
"dependencies": {
"axe-core": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-3.3.1.tgz",
"integrity": "sha512-gw1T0JptHPF4AdLLqE8yQq3Z7YvsYkpFmFWd84r6hnq/QoKRr8icYHFumhE7wYl5TVIHgVlchMyJsAYh0CfwCQ==",
"dev": true
}
}
},
"jest-changed-files": {
"version": "24.8.0",
"resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-24.8.0.tgz",
+4 -1
View File
@@ -59,6 +59,7 @@
"dependencies": {
"@coralproject/bunyan-prettystream": "^0.1.4",
"@metascraper/helpers": "^5.7.21",
"@types/prettier": "^1.19.0",
"abort-controller": "^3.0.0",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.8.1",
@@ -186,7 +187,8 @@
"@types/html-to-text": "^1.4.31",
"@types/html-webpack-plugin": "^3.2.0",
"@types/ioredis": "^4.0.10",
"@types/jest": "^24.0.13",
"@types/jest": "^24.0.23",
"@types/jest-axe": "^3.2.1",
"@types/joi": "^13.0.8",
"@types/jsdom": "^12.2.3",
"@types/jsonwebtoken": "^7.2.7",
@@ -300,6 +302,7 @@
"husky": "^2.2.0",
"intersection-observer": "^0.6.0",
"jest": "^24.8.0",
"jest-axe": "^3.2.0",
"jest-junit": "^6.4.0",
"jest-localstorage-mock": "^2.4.0",
"jest-mock-console": "^1.0.0",
+4 -3
View File
@@ -23,9 +23,10 @@ export default class ChokidarWatcher implements Watcher {
let firstError: Error | null = null;
// If this is set, a pending promise is waiting for the next result.
let pending:
| ({ resolve: (result: string) => void; reject: (error: Error) => void })
| null = null;
let pending: {
resolve: (result: string) => void;
reject: (error: Error) => void;
} | null = null;
// Only start client if we have something to watch.
if (paths.length) {
+1 -1
View File
@@ -48,7 +48,7 @@ export default class SaneWatcher implements Watcher {
const queue: string[] = [];
// If this is set, a pending promise is waiting for the next result.
let pending: ({ resolve: (result: string) => void }) | null = null;
let pending: { resolve: (result: string) => void } | null = null;
// Only start client if we have something to watch.
if (paths.length) {
@@ -31,7 +31,9 @@ it("renders missing confirm token", async () => {
replaceHistoryLocation("http://localhost/account/email/confirm");
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("renders form", async () => {
@@ -57,6 +59,7 @@ it("renders form", async () => {
);
});
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
restMock.verify();
});
@@ -98,6 +101,7 @@ it("renders error from server", async () => {
);
});
restMock.verify();
expect(await within(root).axe()).toHaveNoViolations();
}
});
@@ -32,6 +32,7 @@ it("renders missing reset token", async () => {
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("renders form", async () => {
@@ -57,6 +58,7 @@ it("renders form", async () => {
);
});
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
restMock.verify();
});
@@ -98,6 +100,7 @@ it("renders error from server", async () => {
);
});
restMock.verify();
expect(await within(root).axe()).toHaveNoViolations();
}
});
@@ -32,6 +32,7 @@ it("renders missing confirm token", async () => {
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("renders form", async () => {
@@ -53,6 +54,7 @@ it("renders form", async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
restMock.verify();
});
@@ -93,6 +95,7 @@ it("renders error from server", async () => {
);
});
restMock.verify();
expect(await within(root).axe()).toHaveNoViolations();
}
});
@@ -68,6 +68,7 @@ it("renders addEmailAddress view", async () => {
expect(toJSON(root)).toMatchSnapshot();
});
});
expect(await within(root).axe()).toHaveNoViolations();
});
it("shows error when submitting empty form", async () => {
@@ -60,6 +60,7 @@ async function createTestRenderer(
it("renders createPassword view", async () => {
const { root } = await createTestRenderer();
expect(toJSON(root)).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("shows error when submitting empty form", async () => {
@@ -58,6 +58,7 @@ async function createTestRenderer(
it("renders createUsername view", async () => {
const { root } = await createTestRenderer();
expect(toJSON(root)).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("shows error when submitting empty form", async () => {
@@ -64,6 +64,7 @@ afterEach(async () => {
it("renders forgot password view", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("shows error when submitting empty form", async () => {
@@ -58,6 +58,7 @@ async function createTestRenderer(
it("renders sign in view", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("renders sign in view with error", async () => {
@@ -53,6 +53,7 @@ async function createTestRenderer(customResolver: any = {}) {
it("renders sign up form", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("shows error when submitting empty form", async () => {
@@ -48,7 +48,7 @@ export function denormalizeComments(commentList: Array<Fixture<GQLComment>>) {
}
export function denormalizeStory(story: Fixture<GQLStory>) {
const commentNodes =
const commentEdges =
(story.comments &&
story.comments.edges &&
story.comments.edges.map((edge: any) => ({
@@ -61,20 +61,21 @@ export function denormalizeStory(story: Fixture<GQLStory>) {
};
if (commentsPageInfo.endCursor === undefined) {
commentsPageInfo.endCursor =
commentNodes.length > 0
? commentNodes[commentNodes.length - 1].node.createdAt
commentEdges.length > 0
? commentEdges[commentEdges.length - 1].node.createdAt
: null;
}
const featuredCommentsCount = commentNodes.filter(
n => n.tags && n.tags.some((t: GQLTag) => t.code === GQLTAG.FEATURED)
const featuredCommentsCount = commentEdges.filter(
e =>
e.node.tags && e.node.tags.some((t: GQLTag) => t.code === GQLTAG.FEATURED)
).length;
return createFixture<GQLStory>({
...story,
comments: { edges: commentNodes, pageInfo: commentsPageInfo },
comments: { edges: commentEdges, pageInfo: commentsPageInfo },
commentCounts: {
...story.commentCounts,
totalPublished: commentNodes.length,
totalPublished: commentEdges.length,
tags: {
...(story.commentCounts && story.commentCounts.tags),
FEATURED: featuredCommentsCount,
@@ -46,3 +46,4 @@ export {
default as overwriteQueryResolver,
createQueryResolverOverwrite,
} from "./overwriteQueryResolver";
export { default as toHTML } from "./toHTML";
@@ -0,0 +1,89 @@
import { stripIndent } from "common-tags";
import prettier from "prettier";
import { ReactTestInstance } from "react-test-renderer";
import toJSON, { ReactTestRendererNode } from "./toJSON";
function convertPropertyToString(prop: string, value: any): string {
let propOut = prop;
let valueOut = "";
if (propOut === "dangerouslySetInnerHTML") {
return "";
}
// React uses `htmlFor` instead of `for` because of js restrictions.
if (propOut === "htmlFor") {
propOut = "for";
}
switch (typeof value) {
case "function":
valueOut = "[Function]";
break;
case "string":
valueOut = value;
break;
case "undefined":
valueOut = propOut;
return "";
case "boolean":
// Usually true means the property has been set without a value
// and false the property is not set.
// Exception: aria-labels need to be set to "true" / "false".
if (!prop.startsWith("aria-")) {
return value ? propOut : "";
}
// fall through
default:
valueOut = JSON.stringify(value);
}
valueOut = valueOut.replace('"', "&quot;");
return `${propOut}="${valueOut}"`;
}
function convertJSONToHTML(
node: ReactTestRendererNode | ReactTestRendererNode[]
): string {
if (typeof node === "string") {
return node;
}
if (Array.isArray(node)) {
return node.map(c => convertJSONToHTML(c)).join("\n");
}
const props = Object.keys(node.props)
.map(k => convertPropertyToString(k, node.props[k]))
.join(" ");
let innerHTML = "";
if ("dangerouslySetInnerHTML" in node.props) {
innerHTML = node.props.dangerouslySetInnerHTML.__html;
} else if (node.children) {
innerHTML = convertJSONToHTML(node.children);
}
if (innerHTML === "") {
return `<${node.type} ${props} />`;
}
return stripIndent`
<${node.type} ${props}>
${innerHTML}
</${node.type}>`;
}
/**
* Turns a ReactTestInstance into its HTML representation.
*/
export default function toHTML(
inst: ReactTestInstance,
options: { pretty?: boolean } = {}
) {
const result = toJSON(inst);
if (result === null) {
return "";
}
const output = convertJSONToHTML(result);
return options.pretty ? prettier.format(output, { parser: "html" }) : output;
}
@@ -1,12 +1,12 @@
import { ReactTestInstance } from "react-test-renderer";
interface ReactTestRendererJSON {
export interface ReactTestRendererJSON {
type: string;
props: { [propName: string]: any };
children: null | ReactTestRendererNode[];
$$typeof?: symbol; // Optional because we add it with defineProperty().
}
type ReactTestRendererNode = ReactTestRendererJSON | string;
export type ReactTestRendererNode = ReactTestRendererJSON | string;
export function toJSONRecursive(
inst: ReactTestInstance
@@ -1,3 +1,4 @@
import { axe } from "jest-axe";
import { ReactTestInstance } from "react-test-renderer";
import { getByID, queryByID } from "./byID";
@@ -22,6 +23,7 @@ import {
queryByType,
queryParentByType,
} from "./byType";
import toHTML from "./toHTML";
import toJSON from "./toJSON";
type Func0<R> = () => R;
@@ -68,6 +70,17 @@ export default function within(container: ReactTestInstance) {
queryByType: applyContainer(container, queryByType),
queryParentByType: applyContainer(container, queryParentByType),
queryAllByType: applyContainer(container, queryAllByType),
toJSON: () => toJSON(container),
toJSON: applyContainer(container, toJSON),
toHTML: applyContainer(container, toHTML),
/**
* Check for some accessibility violations
*
* Example use:
* `expect(await within(container).axe()).toHaveNoViolations();`
*/
axe: () => axe(toHTML(container)),
/** Output the html representation of the container */
// eslint-disable-next-line no-console
debug: () => console.log(toHTML(container, { pretty: true })),
};
}
@@ -253,6 +253,75 @@ exports[`renders comment stream 1`] = `
className="TabBar-root TabBar-secondary coral coral-tabBarSecondary coral-tabBarComments StreamContainer-tabBarRoot"
role="tablist"
>
<div
className="StreamContainer-featuredCommentsTabContainer"
>
<li
className="Tab-root"
id="tab-FEATURED_COMMENTS"
role="presentation"
>
<button
aria-controls="tabPane-FEATURED_COMMENTS"
aria-selected={true}
className="BaseButton-root Tab-button Tab-secondary StreamContainer-featuredCommentsTabButton Tab-active StreamContainer-fixedTab coral coral-tabBarSecondary-tab coral-tabBarComments-featured StreamContainer-featuredCommentsTab"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
role="tab"
type="button"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter gutter Flex-spacing-1"
>
<span>
Featured
</span>
<span
className="Counter-root Counter-colorPrimary Counter-sizeSmall coral coral-counter"
data-testid="comments-featuredCount"
>
<span
className="Counter-text"
>
2
</span>
</span>
</div>
</button>
</li>
<div
className="Popover-root Tooltip-root StreamContainer-featuredCommentsInfo coral coral-tabBarComments-featuredTooltip"
>
<button
aria-label="Toggle featured comments tooltip"
className="BaseButton-root TooltipButton-button"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm Icon-colorPrimary"
>
info
</i>
</button>
<div
aria-hidden={true}
aria-labelledby="comments-featuredCommentPopover-ariainfo"
id="comments-featuredCommentPopover"
role="dialog"
/>
</div>
</div>
<li
className="Tab-root"
id="tab-ALL_COMMENTS"
@@ -261,7 +330,7 @@ exports[`renders comment stream 1`] = `
<button
aria-controls="tabPane-ALL_COMMENTS"
aria-selected={false}
className="BaseButton-root Tab-button Tab-secondary coral coral-tabBarSecondary-tab coral-tabBarComments-allComments"
className="BaseButton-root Tab-button Tab-secondary StreamContainer-fixedTab coral coral-tabBarSecondary-tab coral-tabBarComments-allComments"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -53,4 +53,5 @@ it("renders comment stream", async () => {
within(testRenderer.root).getByTestID("comments-featuredComments-log")
);
expect(within(testRenderer.root).toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
@@ -93,6 +93,7 @@ it("renders permalink view", async () => {
within(testRenderer.root).getByTestID("current-tab-pane")
);
expect(within(tabPane).toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("show all comments", async () => {
@@ -125,6 +125,8 @@ it("post a reply", async () => {
}),
});
expect(await within(form).axe()).toHaveNoViolations();
// Write reply .
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => {
@@ -100,6 +100,7 @@ it("edit a comment", async () => {
.props.onClick()
);
expect(within(comment).toJSON()).toMatchSnapshot("edit form");
expect(await within(comment).axe()).toHaveNoViolations();
act(() =>
testRenderer.root
@@ -119,6 +119,8 @@ it("loads more comments", async () => {
within(testRenderer.root).getByTestID("comments-allComments-log")
);
expect(await within(streamLog).axe()).toHaveNoViolations();
// Get amount of comments before.
const commentsBefore = within(streamLog).getAllByTestID(/^comment-/).length;
@@ -42,4 +42,5 @@ it("renders reply list", async () => {
);
// Wait for loading.
expect(within(commentReplyList).toJSON()).toMatchSnapshot();
expect(await within(commentReplyList).axe()).toHaveNoViolations();
});
@@ -45,4 +45,5 @@ it("renders comment stream", async () => {
within(testRenderer.root).getByTestID("comments-allComments-log")
);
expect(within(testRenderer.root).toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
@@ -1,6 +1,6 @@
import sinon from "sinon";
import { act, wait, waitForElement, within } from "coral-framework/testHelpers";
import { act, waitForElement, within } from "coral-framework/testHelpers";
import { moderators, settings, stories } from "../fixtures";
import create from "./create";
@@ -42,11 +42,7 @@ async function createTestRenderer(
}
it("renders configure", async () => {
const { tabPane } = await createTestRenderer();
await act(async () => {
await wait(() => {
expect(within(tabPane).toJSON()).toMatchSnapshot();
});
});
const { tabPane, testRenderer } = await createTestRenderer();
expect(within(tabPane).toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
@@ -67,6 +67,7 @@ it("renders the empty settings pane", async () => {
testRenderer: { root },
} = await createTestRenderer();
expect(within(root).toJSON()).toMatchSnapshot();
expect(await within(root).axe()).toHaveNoViolations();
});
it("doesn't show the change password pane when local auth is disabled", async () => {
@@ -112,6 +113,7 @@ it("render password change form", async () => {
const newPassword = await waitForElement(() =>
within(form).getByID("newPassword", { exact: false })
);
expect(await within(changePassword).axe()).toHaveNoViolations();
// Submit an empty form.
act(() => {
@@ -9,7 +9,7 @@ import {
within,
} from "coral-framework/testHelpers";
import { baseUser, settings, stories } from "../fixtures";
import { settings, stories, userWithEmail } from "../fixtures";
import create from "./create";
const story = stories[0];
@@ -23,7 +23,7 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => baseUser,
viewer: () => userWithEmail,
stream: () => story,
},
}),
@@ -49,7 +49,7 @@ describe("change email form", () => {
const setup = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
viewer: () => baseUser,
viewer: () => userWithEmail,
},
Mutation: {
updateEmail: ({ variables }) => {
@@ -58,7 +58,7 @@ describe("change email form", () => {
});
return {
user: {
...baseUser,
...userWithEmail,
email: "updated_email@test.com",
},
};
@@ -77,6 +77,7 @@ describe("change email form", () => {
act(() => {
editButton.props.onClick();
});
expect(await within(changeEmail).axe()).toHaveNoViolations();
const form = within(changeEmail).getByType("form");
act(() => {
form.props.onSubmit();
@@ -76,7 +76,9 @@ describe("with recently changed username", () => {
const form = within(changeUsername).queryByType("form");
const message = within(changeUsername).queryByText(
"Your username has been changed in the last 14 days",
{ exact: false }
{
exact: false,
}
);
expect(form).toBeNull();
expect(message).toBeTruthy();
@@ -106,10 +108,13 @@ describe("with new username", () => {
act(() => {
editButton.props.onClick();
});
expect(await within(changeUsername).axe()).toHaveNoViolations();
within(changeUsername).getByType("form");
const message = within(changeUsername).queryByText(
"Your username has been changed in the last 14 days",
{ exact: false }
{
exact: false,
}
);
expect(message).toBeNull();
});
@@ -109,6 +109,8 @@ describe("delete account steps", () => {
nextButton.props.onClick();
});
}
expect(await within(modal).axe()).toHaveNoViolations();
const form = within(modal).getByType("form");
const confirm = within(modal).getByTestID("confirm-page-confirmation");
const password = within(modal).getByTestID("confirm-page-password");
@@ -87,6 +87,7 @@ it("renders profile", async () => {
within(testRenderer.root).getByTestID("profile-commentHistory")
);
expect(within(commentHistory).toJSON()).toMatchSnapshot();
expect(await within(commentHistory).axe()).toHaveNoViolations();
});
it("loads more comments", async () => {
@@ -73,6 +73,7 @@ it("render notifications form", async () => {
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("profile-account-notifications")
);
expect(await within(container).axe()).toHaveNoViolations();
const form = within(container).getByType("form");
// Get the form fields.
@@ -1,3 +1,5 @@
import { toHaveNoViolations } from "jest-axe";
import expectAndFail from "./expectAndFail";
// Automatically unmock console.
@@ -17,3 +19,5 @@ process.on("unhandledRejection", err => {
// eslint-disable-next-line no-console
console.error(err);
});
expect.extend(toHaveNoViolations);
+1 -1
View File
@@ -43,7 +43,7 @@ const Icon: FunctionComponent<Props> = props => {
return (
<i
className={rootClassName}
aria-hidden="true"
aria-hidden={rest["aria-label"] ? "false" : "true"}
{...rest}
ref={forwardRef}
/>