Merge pull request #8883 from donaldpipowitch/8126-addon-a11y--allow-manual-run

Addon-a11y: Support manual run
This commit is contained in:
Gaëtan Maisse 2020-01-13 17:14:13 +01:00 committed by GitHub
commit f486faaceb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 193 additions and 1347 deletions

View File

@ -69,6 +69,8 @@ export default {
config: {},
// axe-core optionsParameter (https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter)
options: {},
// optional flag to prevent the automatic check
manual: true,
},
},
};

View File

@ -1,19 +1,18 @@
import React from 'react';
import { mount } from 'enzyme';
import { EventEmitter } from 'events';
import { ThemeProvider, themes, convert } from '@storybook/theming';
import { STORY_RENDERED } from '@storybook/core-events';
import { ScrollArea } from '@storybook/components';
import { A11YPanel } from './A11YPanel';
import { EVENTS } from '../constants';
function createApi() {
return {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
const emitter = new EventEmitter();
jest.spyOn(emitter, 'emit');
jest.spyOn(emitter, 'on');
jest.spyOn(emitter, 'off');
return emitter;
}
const axeResult = {
@ -63,7 +62,7 @@ function ThemedA11YPanel(props) {
}
describe('A11YPanel', () => {
it('should register STORY_RENDERED, RESULT and ERROR updater on mount', () => {
it('should register event listener on mount', () => {
// given
const api = createApi();
expect(api.on).not.toHaveBeenCalled();
@ -73,59 +72,75 @@ describe('A11YPanel', () => {
// then
expect(api.on.mock.calls.length).toBe(3);
expect(api.on.mock.calls[0][0]).toBe(STORY_RENDERED);
expect(api.on.mock.calls[1][0]).toBe(EVENTS.RESULT);
expect(api.on.mock.calls[2][0]).toBe(EVENTS.ERROR);
expect(api.on.mock.calls[0][0]).toBe(EVENTS.RESULT);
expect(api.on.mock.calls[1][0]).toBe(EVENTS.ERROR);
expect(api.on.mock.calls[2][0]).toBe(EVENTS.MANUAL);
});
it('should request a run on tab activation', () => {
it('should deregister event listener on unmount', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} />);
expect(api.emit).not.toHaveBeenCalled();
// when
wrapper.setProps({ active: true });
wrapper.update();
// then
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
expect(wrapper.find(ScrollArea).length).toBe(0);
});
it('should deregister STORY_RENDERED, RESULT and ERROR updater on unmount', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} />);
expect(api.off).not.toHaveBeenCalled();
// when
const wrapper = mount(<ThemedA11YPanel api={api} />);
wrapper.unmount();
// then
expect(api.off.mock.calls.length).toBe(3);
expect(api.off.mock.calls[0][0]).toBe(STORY_RENDERED);
expect(api.off.mock.calls[1][0]).toBe(EVENTS.RESULT);
expect(api.off.mock.calls[2][0]).toBe(EVENTS.ERROR);
expect(api.off.mock.calls[0][0]).toBe(EVENTS.RESULT);
expect(api.off.mock.calls[1][0]).toBe(EVENTS.ERROR);
expect(api.off.mock.calls[2][0]).toBe(EVENTS.MANUAL);
});
it('should update run result', () => {
it('should handle "initial" status', () => {
// given
const api = createApi();
// when
const wrapper = mount(<ThemedA11YPanel api={api} active />);
// then
expect(api.emit).not.toHaveBeenCalled();
expect(wrapper.text()).toMatch(/Initializing/);
});
it('should handle "manual" status', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const onUpdate = api.on.mock.calls.find(([event]) => event === EVENTS.RESULT)[1];
expect(
wrapper
.find('button')
.last()
.text()
.trim()
).toBe('Rerun tests');
// when
onUpdate(axeResult);
api.emit(EVENTS.MANUAL, true);
wrapper.update();
// then
expect(wrapper.text()).toMatch(/Manually run the accessibility scan/);
expect(api.emit).not.toHaveBeenCalledWith(EVENTS.REQUEST);
});
it('should handle "running" status', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
// when
api.emit(EVENTS.MANUAL, false);
wrapper.update();
// then
expect(wrapper.text()).toMatch(/Please wait while the accessibility scan is running/);
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
});
it('should handle "ran" status', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
// when
api.emit(EVENTS.RESULT, axeResult);
wrapper.update();
// then
expect(
@ -135,82 +150,13 @@ describe('A11YPanel', () => {
.text()
.trim()
).toBe('Tests completed');
expect(wrapper.find('Tabs').prop('tabs').length).toBe(3);
expect(wrapper.find('Tabs').prop('tabs')[0].label.props.children).toEqual([1, ' Violations']);
expect(wrapper.find('Tabs').prop('tabs')[1].label.props.children).toEqual([1, ' Passes']);
expect(wrapper.find('Tabs').prop('tabs')[2].label.props.children).toEqual([1, ' Incomplete']);
});
it('should request run', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
expect(
wrapper
.find('button')
.last()
.text()
.trim()
).toBe('Rerun tests');
expect(api.emit).not.toHaveBeenCalled();
// when
request();
// then
expect(
wrapper
.find('button')
.last()
.text()
.trim()
).toBe('Running test');
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
});
it('should NOT request run on inactive tab', () => {
// given
const api = createApi();
mount(<ThemedA11YPanel api={api} active={false} />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
expect(api.emit).not.toHaveBeenCalled();
// when
request();
// then
expect(api.emit).not.toHaveBeenCalled();
});
it('should render report', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const onUpdate = api.on.mock.calls.find(([event]) => event === EVENTS.RESULT)[1];
// when
onUpdate(axeResult);
// then
expect(wrapper.find(A11YPanel)).toMatchSnapshot();
});
it("should render loader when it's running", () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
// when
request();
wrapper.update();
// then
expect(wrapper.find('ScrollArea').length).toBe(0);
expect(wrapper.find('Loader').length).toBe(1);
expect(wrapper.find('ActionBar').length).toBe(1);
expect(wrapper.find('Loader')).toMatchSnapshot();
});
it('should NOT anything when tab is not active', () => {
it('should handle inactive state', () => {
// given
const api = createApi();
@ -218,7 +164,7 @@ describe('A11YPanel', () => {
const wrapper = mount(<ThemedA11YPanel api={api} active={false} />);
// then
expect(wrapper.find('ScrollArea').length).toBe(0);
expect(wrapper.find('ActionBar').length).toBe(0);
expect(wrapper.text()).toBe('');
expect(api.emit).not.toHaveBeenCalled();
});
});

View File

@ -1,8 +1,8 @@
import React, { Component, Fragment, ComponentProps } from 'react';
/* eslint-disable react/destructuring-assignment,default-case,consistent-return,no-case-declarations */
import React, { Component, Fragment } from 'react';
import { styled } from '@storybook/theming';
import { STORY_RENDERED } from '@storybook/core-events';
import { ActionBar, Icons, ScrollArea } from '@storybook/components';
import { AxeResults, Result } from 'axe-core';
@ -20,21 +20,15 @@ export enum RuleType {
INCOMPLETION,
}
type IconProps = ComponentProps<typeof Icons> & { status?: string; inline?: boolean };
const Icon = styled(Icons)({
height: 12,
width: 12,
marginRight: 4,
});
const Icon = styled(Icons)<IconProps>(
{
height: 12,
width: 12,
marginRight: 4,
},
({ status, theme }) =>
status === 'running'
? {
animation: `${theme.animation.rotate360} 1s linear infinite;`,
}
: {}
);
const RotatingIcon = styled(Icon)(({ theme }) => ({
animation: `${theme.animation.rotate360} 1s linear infinite;`,
}));
const Passes = styled.span<{}>(({ theme }) => ({
color: theme.color.positive,
@ -48,34 +42,51 @@ const Incomplete = styled.span<{}>(({ theme }) => ({
color: theme.color.warning,
}));
const centeredStyle = {
const Centered = styled.span<{}>({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
};
});
const Loader = styled(({ className }) => (
<div className={className}>
<Icon inline icon="sync" status="running" /> Please wait while the accessibility scan is running
...
</div>
))(centeredStyle);
Loader.displayName = 'Loader';
interface InitialState {
status: 'initial';
}
interface A11YPanelNormalState {
status: 'ready' | 'ran' | 'running';
interface ManualState {
status: 'manual';
}
interface RunningState {
status: 'running';
}
interface RanState {
status: 'ran';
passes: Result[];
violations: Result[];
incomplete: Result[];
}
interface A11YPanelErrorState {
interface ReadyState {
status: 'ready';
passes: Result[];
violations: Result[];
incomplete: Result[];
}
interface ErrorState {
status: 'error';
error: unknown;
}
type A11YPanelState = A11YPanelNormalState | A11YPanelErrorState;
type A11YPanelState =
| InitialState
| ManualState
| RunningState
| RanState
| ReadyState
| ErrorState;
interface A11YPanelProps {
active: boolean;
@ -84,18 +95,15 @@ interface A11YPanelProps {
export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
state: A11YPanelState = {
status: 'ready',
passes: [],
violations: [],
incomplete: [],
status: 'initial',
};
componentDidMount() {
const { api } = this.props;
api.on(STORY_RENDERED, this.request);
api.on(EVENTS.RESULT, this.onUpdate);
api.on(EVENTS.RESULT, this.onResult);
api.on(EVENTS.ERROR, this.onError);
api.on(EVENTS.MANUAL, this.onManual);
}
componentDidUpdate(prevProps: A11YPanelProps) {
@ -105,18 +113,18 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
if (!prevProps.active && active) {
// removes all elements from the redux map in store from the previous panel
store.dispatch(clearElements());
this.request();
}
}
componentWillUnmount() {
const { api } = this.props;
api.off(STORY_RENDERED, this.request);
api.off(EVENTS.RESULT, this.onUpdate);
api.off(EVENTS.RESULT, this.onResult);
api.off(EVENTS.ERROR, this.onError);
api.off(EVENTS.MANUAL, this.onManual);
}
onUpdate = ({ passes, violations, incomplete }: AxeResults) => {
onResult = ({ passes, violations, incomplete }: AxeResults) => {
this.setState(
{
status: 'ran',
@ -144,64 +152,67 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
});
};
request = () => {
const { api, active } = this.props;
if (active) {
this.setState(
{
status: 'running',
},
() => {
api.emit(EVENTS.REQUEST);
// removes all elements from the redux map in store from the previous panel
store.dispatch(clearElements());
}
);
onManual = (manual: boolean) => {
if (manual) {
this.setState({
status: 'manual',
});
} else {
this.request();
}
};
request = () => {
const { api } = this.props;
this.setState(
{
status: 'running',
},
() => {
api.emit(EVENTS.REQUEST);
// removes all elements from the redux map in store from the previous panel
store.dispatch(clearElements());
}
);
};
render() {
const { active } = this.props;
if (!active) return null;
// eslint-disable-next-line react/destructuring-assignment
if (this.state.status === 'error') {
const { error } = this.state;
return (
<div style={centeredStyle}>
The accessibility scan encountered an error.
<br />
{error}
</div>
);
}
const { passes, violations, incomplete, status } = this.state;
let actionTitle: string | JSX.Element = 'Rerun tests';
if (status === 'ready') {
actionTitle = 'Rerun tests';
} else if (status === 'running') {
actionTitle = (
<Fragment>
<Icon inline icon="sync" status={status} /> Running test
</Fragment>
);
} else if (status === 'ran') {
actionTitle = (
<Fragment>
<Icon inline icon="check" /> Tests completed
</Fragment>
);
}
return (
<Fragment>
<Provider store={store}>
{status === 'running' ? (
<Loader />
switch (this.state.status) {
case 'initial':
return <Centered>Initializing...</Centered>;
case 'manual':
return (
<Fragment>
<Centered>Manually run the accessibility scan.</Centered>
<ActionBar
key="actionbar"
actionItems={[{ title: 'Run test', onClick: this.request }]}
/>
</Fragment>
);
case 'running':
return (
<Centered>
<RotatingIcon inline icon="sync" /> Please wait while the accessibility scan is running
...
</Centered>
);
case 'ready':
case 'ran':
const { passes, violations, incomplete, status } = this.state;
const actionTitle =
status === 'ready' ? (
'Rerun tests'
) : (
<Fragment>
<Icon inline icon="check" /> Tests completed
</Fragment>
);
return (
<Provider store={store}>
<ScrollArea vertical horizontal>
<Tabs
key="tabs"
@ -245,13 +256,21 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
]}
/>
</ScrollArea>
)}
<ActionBar
key="actionbar"
actionItems={[{ title: actionTitle, onClick: this.request }]}
/>
</Provider>
</Fragment>
);
<ActionBar
key="actionbar"
actionItems={[{ title: actionTitle, onClick: this.request }]}
/>
</Provider>
);
case 'error':
const { error } = this.state;
return (
<Centered>
The accessibility scan encountered an error.
<br />
{error}
</Centered>
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,5 +7,6 @@ export const CLEAR_ELEMENTS = 'CLEAR_ELEMENTS';
const RESULT = `${ADDON_ID}/result`;
const REQUEST = `${ADDON_ID}/request`;
const ERROR = `${ADDON_ID}/error`;
const MANUAL = `${ADDON_ID}/manual`;
export const EVENTS = { RESULT, REQUEST, ERROR };
export const EVENTS = { RESULT, REQUEST, ERROR, MANUAL };

View File

@ -11,8 +11,10 @@ interface Setup {
element?: ElementContext;
config: Spec;
options: RunOptions;
manual: boolean;
}
let setup: Setup = { element: undefined, config: {}, options: {} };
let setup: Setup = { element: undefined, config: {}, options: {}, manual: false };
const getElement = () => {
const storyRoot = document.getElementById('story-root');
@ -58,14 +60,16 @@ export const withA11y = makeDecorator({
if (storedDefaultSetup === null) {
storedDefaultSetup = { ...setup };
}
Object.assign(setup, parameters as Setup);
Object.assign(setup, parameters as Partial<Setup>);
} else if (storedDefaultSetup !== null) {
Object.assign(setup, storedDefaultSetup);
storedDefaultSetup = null;
}
addons
.getChannel()
.on(EVENTS.REQUEST, () => run(setup.element as ElementContext, setup.config, setup.options));
addons.getChannel().emit(EVENTS.MANUAL, setup.manual);
return getStory(context);
},

View File

@ -2,13 +2,13 @@ import React, { FunctionComponent } from 'react';
import { styled } from '@storybook/theming';
import icons, { IconKey } from './icons';
import Svg from './svg';
import Svg, { SvgProps } from './svg';
const Path = styled.path({
fill: 'currentColor',
});
export interface IconsProps {
export interface IconsProps extends SvgProps {
icon: IconKey;
}