diff --git a/cypress.config.ts b/cypress.config.ts index f631f4498ae..0436068c760 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ return config; }, - specPattern: './src/**/*.spec.tsx', + specPattern: ['./src/**/*.spec.tsx', './src/**/*.a11y.tsx'], // scripts/cypress.js splits this using the CLI --spec argument video: false, }, }); diff --git a/package.json b/package.json index eb0327de924..b9452dd5e4b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test-staged": "yarn lint && node scripts/test-staged.js", "test-cypress": "node ./scripts/cypress", "test-cypress-dev": "node ./scripts/cypress --dev", + "test-cypress-a11y": "node ./scripts/cypress --a11y", "combine-test-coverage": "sh ./scripts/combine-coverage.sh", "start-test-server": "BABEL_MODULES=false NODE_ENV=puppeteer NODE_OPTIONS=--max-old-space-size=4096 webpack-dev-server --config src-docs/webpack.config.js --port 9999", "yo-component": "yo ./generator-eui/app/component.js", diff --git a/scripts/cypress.js b/scripts/cypress.js index a4d77f6ce1b..714e322b7ba 100644 --- a/scripts/cypress.js +++ b/scripts/cypress.js @@ -20,9 +20,11 @@ const argv = yargs(hideBin(process.argv)) 'skip-css': { type: 'boolean' }, dev: { type: 'boolean' }, theme: { type: 'string', default: 'light', choices: ['light', 'dark'] }, + a11y: { type: 'boolean' }, }).argv; const isDev = argv.hasOwnProperty('dev'); +const isA11y = argv.hasOwnProperty('a11y'); const skipScss = argv.hasOwnProperty('skip-css'); const theme = argv.theme; @@ -39,12 +41,20 @@ if (!skipScss) { console.log(info('Not compiling SCSS, disabled by --skip-css')); } +// compile dev and a11y options for how to run tests (headless, local UI) +// and whether to run component tests or axe checks. +const testParams = isDev + ? 'open --component' + : `run --component --browser chrome ${ + isA11y ? '--spec="./src/**/*.a11y.tsx"' : '--spec="./src/**/*.spec.tsx"' + }`; + const cypressCommandParts = [ 'cross-env', // windows support `THEME=${theme}`, // pass the theme 'BABEL_MODULES=false', // let webpack receive ES Module code 'NODE_ENV=cypress_test', // enable code coverage checks - `cypress ${isDev ? 'open --component' : 'run --component --browser chrome'}`, + `cypress ${testParams}`, ...argv._, // pass any extra options given to this script ]; const cypressCommand = cypressCommandParts.join(' '); diff --git a/src/components/accordion/accordion.a11y.tsx b/src/components/accordion/accordion.a11y.tsx new file mode 100644 index 00000000000..9e8d34e4869 --- /dev/null +++ b/src/components/accordion/accordion.a11y.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// + +import React from 'react'; +import { EuiAccordion, EuiAccordionProps } from './index'; +import { EuiPanel } from '../../components/panel'; +import { htmlIdGenerator } from '../../services'; + +const baseProps: EuiAccordionProps = { + buttonContent: 'Click me to toggle', + id: htmlIdGenerator()(), + initialIsOpen: false, +}; + +const noArrow = { arrowDisplay: 'none' }; +const noArrowProps: EuiAccordionProps = Object.assign(baseProps, noArrow); + +describe('EuiAccordion', () => { + describe('Automated accessibility check', () => { + it('has zero violations when expanded', () => { + cy.mount( + + + Any content inside of EuiAccordion will appear + here. We will include a link to confirm focus. + + + ); + cy.get('button.euiAccordion__button').click(); + cy.checkAxe(); + }); + }); +}); diff --git a/src/components/accordion/accordion.spec.tsx b/src/components/accordion/accordion.spec.tsx index 2f2ae30801c..812f5acc739 100644 --- a/src/components/accordion/accordion.spec.tsx +++ b/src/components/accordion/accordion.spec.tsx @@ -133,19 +133,4 @@ describe('EuiAccordion', () => { cy.focused().contains('a link'); }); }); - - describe('Automated accessibility check', () => { - it('has zero violations when expanded', () => { - cy.mount( - - - Any content inside of EuiAccordion will appear - here. We will include a link to confirm focus. - - - ); - cy.get('button.euiAccordion__button').click(); - cy.checkAxe(); - }); - }); }); diff --git a/src/components/context_menu/context_menu_panel.a11y.tsx b/src/components/context_menu/context_menu_panel.a11y.tsx new file mode 100644 index 00000000000..cad0fab2114 --- /dev/null +++ b/src/components/context_menu/context_menu_panel.a11y.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// + +import React from 'react'; + +import { EuiContextMenuItem } from './context_menu_item'; +import { EuiContextMenuPanel } from './context_menu_panel'; + +const items = [ + + Option A + , + + Option B + , + + Option C + , +]; + +describe('EuiContextMenuPanel', () => { + describe('Automated accessibility check', () => { + it('has zero violations', () => { + const showNextPanelHandler = cy.stub(); + cy.mount( + + ); + cy.checkAxe(); + }); + }); +}); diff --git a/src/components/context_menu/context_menu_panel.spec.tsx b/src/components/context_menu/context_menu_panel.spec.tsx index 8e85784792e..16c4b83016e 100644 --- a/src/components/context_menu/context_menu_panel.spec.tsx +++ b/src/components/context_menu/context_menu_panel.spec.tsx @@ -389,17 +389,4 @@ describe('EuiContextMenuPanel', () => { }); }); }); - - describe('Automated accessibility check', () => { - it('has zero violations', () => { - const showNextPanelHandler = cy.stub(); - cy.mount( - - ); - cy.checkAxe(); - }); - }); }); diff --git a/src/components/selectable/selectable.a11y.tsx b/src/components/selectable/selectable.a11y.tsx new file mode 100644 index 00000000000..57c6e0f5a33 --- /dev/null +++ b/src/components/selectable/selectable.a11y.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// + +import React, { useState } from 'react'; + +import { EuiButton } from '../button'; +import { EuiPopover } from '../popover'; +import { EuiSelectable, EuiSelectableProps } from './selectable'; + +const options: EuiSelectableProps['options'] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, +]; + +const EuiSelectableListboxOnly = (args) => { + return ( + + {(list) => <>{list}} + + ); +}; + +const EuiSelectableWithSearchInput = (args) => { + return ( + + {(list, search) => ( + <> + {search} + {list} + + )} + + ); +}; + +describe('EuiSelectable', () => { + describe('with a `searchable` configuration', () => { + it('has no accessibility errors', () => { + const onChange = cy.stub(); + cy.realMount(); + cy.checkAxe(); + }); + }); + + describe('without a `searchable` configuration', () => { + it('has no accessibility errors', () => { + const onChange = cy.stub(); + cy.realMount( + + ); + cy.checkAxe(); + }); + }); + + describe('nested in `EuiPopover` component', () => { + const EuiSelectableNested = () => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onChange = () => {}; + const onClosePopover = () => {}; + const onButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const button = ( + + Show popover + + ); + + return ( + + + {(list) => <>{list}} + + + ); + }; + + it('has no accessibility errors', () => { + cy.realMount(); + cy.get('button').realClick(); + cy.get('li[role=option]').first(); // Make sure the EuiSelectable is rendered before a11y check + cy.checkAxe(); + }); + }); +}); diff --git a/src/components/selectable/selectable.spec.tsx b/src/components/selectable/selectable.spec.tsx index 34b875c13fe..d04ab874e08 100644 --- a/src/components/selectable/selectable.spec.tsx +++ b/src/components/selectable/selectable.spec.tsx @@ -196,12 +196,6 @@ describe('EuiSelectable', () => { ]); }); }); - - it('has no accessibility errors', () => { - const onChange = cy.stub(); - cy.realMount(); - cy.checkAxe(); - }); }); describe('without a `searchable` configuration', () => { @@ -232,17 +226,6 @@ describe('EuiSelectable', () => { ]); }); }); - - it('has no accessibility errors', () => { - const onChange = cy.stub(); - cy.realMount( - - ); - cy.checkAxe(); - }); }); describe('nested in `EuiPopover` component', () => { @@ -290,12 +273,5 @@ describe('EuiSelectable', () => { cy.realPress('Enter'); expect(cy.get('ul[role=listbox]')).to.exist; }); - - it('has no accessibility errors', () => { - cy.realMount(); - cy.get('button').realClick(); - cy.get('li[role=option]').first(); // Make sure the EuiSelectable is rendered before a11y check - cy.checkAxe(); - }); }); }); diff --git a/tsconfig-cypress.json b/tsconfig-cypress.json index ae5994f7ec1..a036a30cc5d 100644 --- a/tsconfig-cypress.json +++ b/tsconfig-cypress.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "include": [ "./src/**/*.spec.tsx", + "./src/**/*.a11y.tsx", ], "compilerOptions": { "types": ["cypress", "cypress-real-events", "cypress-axe"] diff --git a/tsconfig.json b/tsconfig.json index 9fd0842eae7..0aeb26c0061 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,5 +59,5 @@ "./src/**/*", "./src-docs/**/*" ], - "exclude": ["node_modules", "**/*/*.spec.tsx"] + "exclude": ["node_modules", "**/*/*.spec.tsx", "**/*/*.a11y.tsx"] } diff --git a/wiki/cypress-testing.md b/wiki/cypress-testing.md index 192aab798b7..9d0490947ce 100644 --- a/wiki/cypress-testing.md +++ b/wiki/cypress-testing.md @@ -4,6 +4,8 @@ `yarn test-cypress` runs component tests headlessly (no window appears) +`yarn test-cypress-a11y` runs component accessibility tests headlessly (no window appears) + `yarn test-cypress-dev` launches a chrome window controlled by Cypress, which lists out the discovered tests and allows executing/interacting from that window. ### Setting the theme @@ -12,7 +14,7 @@ By default tests are run using the light theme. Dark mode can be enabled by pass ### Skipping CSS compilation -To ensure tests use up-to-date styles, the test runner compiles our SCSS to CSS before executing Cypress. This adds some processing time before the tests can run, and often the existing locally-built styles are still accurate. The CSS compilation step can be skipped by passing the `--skip-css` flag to `yarn test-cypress` and `yarn test-cypress-dev`. +To ensure tests use up-to-date styles, the test runner compiles our SCSS to CSS before executing Cypress. This adds some processing time before the tests can run, and often the existing locally-built styles are still accurate. The CSS compilation step can be skipped by passing the `--skip-css` flag to `yarn test-cypress`, `yarn test-cypress-dev` and `yarn test-cypress-a11y`. ### Cypress arguments @@ -71,8 +73,12 @@ describe('TestComponent', () => { #### Naming your test files -Create Cypress test files with the name pattern of `{component name}.spec.tsx` in the same directory which -contains `{component name}.tsx`. +Create Cypress test files with the following name patterns to run in specific situations: + +* `{component name}.spec.tsx` will run a full component test as part of every build +* `{component name}.a11y.tsx` will run an accessibility test using [Cypress Axe](#cypress-axe) + +These test files should be in the same directory as `{component name}.tsx`. #### Do's and don'ts @@ -82,11 +88,14 @@ contains `{component name}.tsx`. * DON'T extend the `cy.` global namespace - instead prefer to import helper functions directly ### Cypress Axe -EUI components are tested for accessibility as part of the documentation build, but these do not test changes to the DOM such as accordions being opened, or modal dialogs being triggered. [cypress-axe](https://github.com/component-driven/cypress-axe) allows us to interact with components as users would, and run additional automatic axe scans. +EUI components are tested for accessibility as part of a scheduled task. This allows us to test changes to the DOM such as accordions being opened, or modal dialogs being triggered, more comprehensively. We use [cypress-axe](https://github.com/component-driven/cypress-axe) to access the underlying axe-core API methods and rulesets. #### How to write cypress-axe tests ```jsx +// Names must follow the `{component name}.a11y.tsx` pattern to be run correctly +// accordion.a11y.tsx + describe('Automated accessibility check', () => { it('has zero violations when expanded', () => { cy.mount( @@ -103,14 +112,18 @@ describe('Automated accessibility check', () => { }); ``` -#### Configuring the cy.checkAxe() helper method +#### Configuring `cy.checkAxe()` -The `cy.checkAxe()` method has two optional parameters: +The `cy.checkAxe()` helper method has four optional parameters: + - `skipFailures` - Set to `true` to enable report-only mode. Report-only will run the entire suite without causing tests to return early. - `context` - This could be the document or a selector such as a class name, id, or element. The `context` default is `div#__cy_root`. - - `axeConfig` - The [axe.run API](https://www.deque.com/axe/core-documentation/api-documentation/#api-name-axerun) can be modified for elements to include or exclude, individual rules to exclude, and rulesets to include or exclude. + - `axeConfig` - The [axe.run API](https://www.deque.com/axe/core-documentation/api-documentation/#api-name-axerun) can be modified to include or exclude elements, individual rules, or entire rulesets. + - `callback` - Define a [custom violations callbac](https://github.com/component-driven/cypress-axe#violationcallback-optional) if you need to add side effects or change the reporting structure ```jsx + // Create a custom ruleset by extending EUI defaults + import { defaultAxeConfig } from '../../cypress/support/a11y/axeCheck'; const customAxeConfig = { @@ -122,7 +135,8 @@ const customAxeConfig = { }, }; -cy.checkAxe(undefined, customAxeConfig); +// Violations will use the custome ruleset and cause tests to fail +cy.checkAxe(false, customAxeConfig); ``` ### Cypress Real Events