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

Addon-a11y: Support manual run #8883

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions addons/a11y/README.md
Expand Up @@ -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,
},
},
};
Expand Down
45 changes: 29 additions & 16 deletions addons/a11y/src/components/A11YPanel.test.js
Expand Up @@ -2,8 +2,6 @@ import React from 'react';
import { mount } from 'enzyme';

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';
Expand Down Expand Up @@ -63,7 +61,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();
Expand All @@ -73,12 +71,12 @@ 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 show initial state on tab activation', () => {
// given
const api = createApi();

Expand All @@ -90,11 +88,10 @@ describe('A11YPanel', () => {
wrapper.update();

// then
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
expect(wrapper.find(ScrollArea).length).toBe(0);
expect(wrapper.find(A11YPanel)).toMatchSnapshot();
});

it('should deregister STORY_RENDERED, RESULT and ERROR updater on unmount', () => {
it('should deregister event listener on unmount', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} />);
Expand All @@ -105,9 +102,25 @@ describe('A11YPanel', () => {

// 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 show manual state depending on config', () => {
// given
const api = createApi();

const wrapper = mount(<ThemedA11YPanel api={api} />);
expect(api.emit).not.toHaveBeenCalled();

// when
wrapper.setProps({ active: true });
api.emit(EVENTS.MANUAL, true);
wrapper.update();

// then
expect(wrapper.find(A11YPanel)).toMatchSnapshot();
});

it('should update run result', () => {
Expand Down Expand Up @@ -141,7 +154,7 @@ describe('A11YPanel', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
const request = api.on.mock.calls.find(([event]) => event === EVENTS.MANUAL)[1];

expect(
wrapper
Expand Down Expand Up @@ -170,7 +183,7 @@ describe('A11YPanel', () => {
// given
const api = createApi();
mount(<ThemedA11YPanel api={api} active={false} />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
const request = api.on.mock.calls.find(([event]) => event === EVENTS.MANUAL)[1];
expect(api.emit).not.toHaveBeenCalled();

// when
Expand All @@ -197,7 +210,7 @@ describe('A11YPanel', () => {
// given
const api = createApi();
const wrapper = mount(<ThemedA11YPanel api={api} active />);
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
const request = api.on.mock.calls.find(([event]) => event === EVENTS.MANUAL)[1];

// when
request();
Expand Down
194 changes: 107 additions & 87 deletions addons/a11y/src/components/A11YPanel.tsx
@@ -1,8 +1,8 @@
/* 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';
Expand All @@ -20,60 +20,70 @@ export enum RuleType {
INCOMPLETION,
}

const Icon = styled(Icons)(
{
height: '12px',
width: '12px',
marginRight: '4px',
},
({ status, theme }: any) =>
status === 'running'
? {
animation: `${theme.animation.rotate360} 1s linear infinite;`,
}
: {}
);
const RotatingIcons = styled(Icons)(({ theme }) => ({
height: '12px',
width: '12px',
marginRight: '4px',
animation: `${theme.animation.rotate360} 1s linear infinite;`,
}));

const Passes = styled.span<{}>(({ theme }) => ({
const Passes = styled.span(({ theme }) => ({
color: theme.color.positive,
}));

const Violations = styled.span<{}>(({ theme }) => ({
const Violations = styled.span(({ theme }) => ({
color: theme.color.negative,
}));

const Incomplete = styled.span<{}>(({ theme }) => ({
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 A11YPanelNormalState {
status: 'ready' | 'ran' | 'running';
});

interface InitialState {
status: 'initial';
}

interface ManualState {
status: 'manual';
}

interface RunningState {
status: 'running';
}

interface RanState {
status: 'ran';
passes: Result[];
violations: Result[];
incomplete: Result[];
}

interface ReadyState {
status: 'ready';
passes: Result[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn’t these 4 be made into a single interface with options for status? In other words, extending the old interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be merged, but I made this change on purpose. I wanted to use a state machine like approach (without introducing a dedicated lib like xstate) to make the code more easy to reason about.

The old interface looked simple, because it was just one interface, but it was (imho) harder to understand, because it was not very explicit. There was no differentiation between the initial state and the ready state for example, so there was the need to mock a proper result. But inside the component you never knew, if the result was mocked or not. (Also the user briefly saw "invalid" results, because of this.)

I could have added mocked results to the initial, manual and running states. They wouldn't "hurt", but they also wouldn't have any purpose. But it get's harder if you introduce new states which need other data (like error).

tl;dr: Yes, the interface became more verbose. But it also became more correct and explicit and I hope this will make it easier to understand and extend the logic of this component.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long term, this addon should start using the useAddonState tbh.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ndelangen Sorry, just saw your comment now. Did I understand you correctly that the approach is fine for now and useAddonState should be used in a future PR? Or should I add useAddonState to this PR?

violations: Result[];
incomplete: Result[];
}

interface A11YPanelErrorState {
interface ErrorState {
status: 'error';
error: unknown;
}

type A11YPanelState = A11YPanelNormalState | A11YPanelErrorState;
type A11YPanelState =
| InitialState
| ManualState
| RunningState
| RanState
| ReadyState
| ErrorState;

interface A11YPanelProps {
active: boolean;
Expand All @@ -82,18 +92,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) {
Expand All @@ -103,18 +110,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',
Expand Down Expand Up @@ -142,9 +149,18 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
});
};

onManual = (manual: boolean) => {
if (manual) {
this.setState({
status: 'manual',
});
} else {
this.request();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that the story has been rendered before calling this.request()? 🤷‍♂ I'm just asking because STORY_RENDERED event was used before

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seemed to work for me without problems, but I don't know the internals. Maybe @ndelangen could confirm if events emitted inside makeDecorator -> wrapper (see https://github.com/storybookjs/storybook/pull/8883/files/d73733d99e2250d840f35e16044e8de69bdc9b6c#diff-135e5fd1db00e7a987038977b6cb9148R70) happen after rendering? If not, do you know an example I could add to the tests to see when it would fail, so I can fix it?

Copy link
Member

@ndelangen ndelangen Jan 8, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STORY_RENDERED is called after the awaited render function resolved/returned:
https://github.com/storybookjs/storybook/blob/next/lib/core/src/client/preview/start.js#L240-L241

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I do believe it's called later. Whether or not this impacts the a11y execution, I don't know.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There actually seems to be a problem - not sure if it's because of the missing STORY_RENDERED or some other change I made. If another panel is initially active and you switch to the a11y panel you only see the "Initializing..." state.

E.g. if you visit https://monorepo-51csjv7sk.now.sh/cra-kitchen-sink/?path=/story/app--full-app for example (taken from a different pull request) and switch to the a11y you see results. While you only see the "Initializing..." state (at least on first try - if you reload it works) if you run my pull request locally.

I probably need to fix that 😭

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @donaldpipowitch, yes we need to fix this, I will try to take a look tonight if I have the time ;)

}
};

request = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@donaldpipowitch I succeeded to fix locally the issue we talk about a few days ago (https://github.com/storybookjs/storybook/pull/8883/files?file-filters%5B%5D=.snap&file-filters%5B%5D=.ts&file-filters%5B%5D=.tsx#r364712456) by removing the if-check in the request function. Can you try on your side? and add a commit in your branch if it works?

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());
    }
  );
};

I don't well understand why this active check is done, I played with SB examples locally and didn't find any new issue 🤷‍♂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaetanmaisse thank you for having a look. This seems to fix the issue - it now works for me locally.

I'm unsure about active as well. I mostly left this untouched. But there is an old comment which says that it might can be removed completely?

// TODO: might be able to remove this

For now I just added your fix and kept other usages of active.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to simply remove all active usages and it breaks everything so I think we need to keep it for now.

const { api, active } = this.props;

if (active) {
this.setState(
{
Expand All @@ -163,43 +179,39 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
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;
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>
<RotatingIcons 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>
<Icons inline icon="check" /> Tests completed
</Fragment>
);
return (
<Provider store={store}>
<ScrollArea vertical horizontal>
<Tabs
key="tabs"
Expand Down Expand Up @@ -243,13 +255,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>
);
}
}
}