Advise users about potential accessibility failings in their posts - RFC #58 (#13745)

* initial rough version

* link to relevant rules, extract image text, only show if more than 0 errors

* jsdoc and tweaks

* refactors

* add tests

* open info links in new tab

* add new window link icon
This commit is contained in:
Suzanne Aitchison 2021-06-03 10:27:12 +01:00 committed by GitHub
parent 7e322e4f7b
commit 573c78d032
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 9581 additions and 3 deletions

6009
app/assets/javascripts/markdown-it.min.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,12 @@ import { KeyboardShortcuts } from '../shared/components/useKeyboardShortcuts';
import { submitArticle, previewArticle } from './actions';
import { EditorActions, Form, Header, Help, Preview } from './components';
import { Button, Modal } from '@crayons';
import {
noDefaultAltTextRule,
noEmptyAltTextRule,
noLevelOneHeadingsRule,
headingIncrement,
} from '@utilities/markdown/markdownLintCustomRules';
/* global activateRunkitTags */
@ -14,6 +20,26 @@ import { Button, Modal } from '@crayons';
editing are not used in this file, they are important to the
editor.
*/
/**
* Settings for the markdownlint library we use to identify potential accessibility failings in posts
*/
const LINT_OPTIONS = {
customRules: [
noDefaultAltTextRule,
noLevelOneHeadingsRule,
headingIncrement,
noEmptyAltTextRule,
],
config: {
default: false, // disable all default rules
[noDefaultAltTextRule.names[0]]: true,
[noLevelOneHeadingsRule.names[0]]: true,
[headingIncrement.names[0]]: true,
[noEmptyAltTextRule.names[0]]: true,
},
};
export class ArticleForm extends Component {
static handleGistPreview() {
const els = document.getElementsByClassName('ltag_gist-liquid-tag');
@ -99,6 +125,7 @@ export class ArticleForm extends Component {
helpFor: null,
helpPosition: null,
isModalOpen: false,
markdownLintErrors: [],
...previousContentState,
};
}
@ -160,7 +187,41 @@ export class ArticleForm extends Component {
}
};
lintMarkdown = () => {
const options = {
...LINT_OPTIONS,
strings: {
content: this.state.bodyMarkdown,
},
};
const { content: markdownLintErrors } = window.markdownlint.sync(options);
this.setState({ markdownLintErrors });
};
fetchMarkdownLint = async () => {
if (!window.markdownlint) {
const markdownItScript = document.createElement('script');
markdownItScript.setAttribute('src', '/assets/markdown-it.min.js');
document.body.appendChild(markdownItScript);
// The markdownlint script needs the first script to have finished loading first
markdownItScript.addEventListener('load', () => {
const markdownLintScript = document.createElement('script');
markdownLintScript.setAttribute(
'src',
'/assets/markdownlint-browser.min.js',
);
document.body.appendChild(markdownLintScript);
markdownLintScript.addEventListener('load', this.lintMarkdown);
});
} else {
this.lintMarkdown();
}
};
showPreview = (response) => {
this.fetchMarkdownLint();
this.setState({
...this.setCommonProps({ previewShowing: true }),
previewResponse: response,
@ -303,6 +364,7 @@ export class ArticleForm extends Component {
helpFor,
helpPosition,
siteLogo,
markdownLintErrors,
} = this.state;
return (
@ -328,6 +390,7 @@ export class ArticleForm extends Component {
previewResponse={previewResponse}
articleState={this.state}
errors={errors}
markdownLintErrors={markdownLintErrors}
/>
) : (
<Form

View file

@ -0,0 +1,109 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
// Limit the number of suggestions shown so that the UI isn't overwhelmed
const MAX_SUGGESTIONS = 3;
const ExternalUrlSVG = () => (
<svg
aria-hidden="true"
width="15"
height="15"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10 6v2H5v11h11v-5h2v6a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1h6zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8z" />
</svg>
);
const extractRelevantErrors = (lintErrors) => {
const imageErrors = [];
const otherErrors = [];
lintErrors.forEach((lintError) => {
if (
lintError.ruleNames.includes('no-default-alt-text') ||
lintError.ruleNames.includes('no-empty-alt-text')
) {
imageErrors.push({ ...lintError, errorType: 'image' });
} else {
otherErrors.push({ ...lintError, errorType: 'other' });
}
});
// Truncate the errors, favouring image errors (as these accessibility suggestions are more impactful)
if (imageErrors.length > MAX_SUGGESTIONS) {
imageErrors.length = MAX_SUGGESTIONS;
}
const remainingErrors = MAX_SUGGESTIONS - imageErrors.length;
if (otherErrors.length > remainingErrors) {
otherErrors.length = remainingErrors;
}
return [...imageErrors, ...otherErrors];
};
/**
* An information notice displayed to users in the Preview window when accessibility improvements could be made to their post.
* This component displays a maximum of 3 suggestions, favouring image-related suggestions (as these changes are more impactful).
*
* @param {Object} props
* @param {Object[]} props.markdownLintErrors The array of error objects returned from the markdownlint library
*
* @example
* <AccessibilitySuggestions
* markdownLintErrors={[
* {
* errorContext: "Consider adding an image description in the square brackets of the image ![](http://example.png)",
* errorDetail: "/p/editor_guide#alt-text-for-images"
* ruleNames: ["no-empty-alt-text"]
* }
* ]}
* />
*/
export const AccessibilitySuggestions = ({ markdownLintErrors }) => {
return (
<div
className="crayons-notice crayons-notice--info mb-6"
aria-live="polite"
>
<h2 className="fs-l mb-2 fw-bold">
Improve the accessibility of your post
</h2>
<ul>
{extractRelevantErrors(markdownLintErrors).map((lintError, index) => {
return (
<li key={`linterror-${index}`}>
{lintError.errorContext}
<span className="fs-s">
{' '}
<a
href={lintError.errorDetail}
aria-label={`Learn more about accessible ${
lintError.errorType === 'image' ? 'images' : 'headings'
}`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <ExternalUrlSVG />
</a>
</span>
</li>
);
})}
</ul>
</div>
);
};
AccessibilitySuggestions.propTypes = {
markdownLintErrors: PropTypes.arrayOf(
PropTypes.shape({
errorContext: PropTypes.string,
errorDetail: PropTypes.string,
ruleNames: PropTypes.arrayOf(PropTypes.string),
}),
),
};

View file

@ -2,8 +2,14 @@ import { h } from 'preact';
import PropTypes from 'prop-types';
import { useEffect } from 'preact/hooks';
import { ErrorList } from './ErrorList';
import { AccessibilitySuggestions } from './AccessibilitySuggestions';
function titleArea(previewResponse, articleState, errors) {
function titleArea({
previewResponse,
articleState,
errors,
markdownLintErrors,
}) {
const tagArray = previewResponse.tags || articleState.tagList.split(', ');
let tags = '';
if (tagArray.length > 0 && tagArray[0].length > 0) {
@ -51,6 +57,9 @@ function titleArea(previewResponse, articleState, errors) {
)}
<div className="crayons-article__header__meta">
{errors && <ErrorList errors={errors} />}
{!errors && markdownLintErrors?.length > 0 && (
<AccessibilitySuggestions markdownLintErrors={markdownLintErrors} />
)}
<h1 className="fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight mb-6 spec-article__title">
{previewTitle}
</h1>
@ -68,7 +77,12 @@ const previewResponsePropTypes = PropTypes.shape({
cover_image: PropTypes.string,
});
export const Preview = ({ previewResponse, articleState, errors }) => {
export const Preview = ({
previewResponse,
articleState,
errors,
markdownLintErrors,
}) => {
useEffect(() => {
if (previewResponse.processed_html.includes('twitter-timeline')) {
attachTwitterTimelineScript();
@ -78,7 +92,12 @@ export const Preview = ({ previewResponse, articleState, errors }) => {
return (
<div className="crayons-article-form__content crayons-card">
<article className="crayons-article">
{titleArea(previewResponse, articleState, errors)}
{titleArea({
previewResponse,
articleState,
errors,
markdownLintErrors,
})}
<div className="crayons-article__main">
<div
className="crayons-article__body text-styles"
@ -104,6 +123,7 @@ function attachTwitterTimelineScript() {
Preview.propTypes = {
previewResponse: previewResponsePropTypes.isRequired,
errors: PropTypes.string.isRequired,
markdownLintErrors: PropTypes.arrayOf(PropTypes.object),
articleState: PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,

View file

@ -0,0 +1,157 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import '@testing-library/jest-dom';
import { AccessibilitySuggestions } from '../AccessibilitySuggestions';
describe('<AccessibilitySuggestions />', () => {
it('should have no a11y violations', async () => {
const { container } = render(
<AccessibilitySuggestions
markdownLintErrors={[
{
errorDetail: '/detailUrl',
ruleNames: ['example-rule'],
errorContext: 'Example suggestion',
},
]}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should display a list of maximum 3 suggestions', () => {
const lintErrors = [1, 2, 3, 4].map((item) => ({
errorDetail: '/detailUrl',
ruleNames: ['no-empty-alt-text'],
errorContext: `Suggestion ${item}`,
}));
const { getAllByRole, getByText, queryByText } = render(
<AccessibilitySuggestions markdownLintErrors={lintErrors} />,
);
const listItems = getAllByRole('listitem');
expect(listItems).toHaveLength(3);
expect(getByText('Suggestion 1')).toBeInTheDocument();
expect(getByText('Suggestion 2')).toBeInTheDocument();
expect(getByText('Suggestion 3')).toBeInTheDocument();
expect(queryByText('Suggestion 4')).not.toBeInTheDocument();
const detailLinks = getAllByRole('link', {
name: 'Learn more about accessible images',
});
expect(detailLinks).toHaveLength(3);
});
it('should only show image errors if there are 3 or more', () => {
const imageErrors = [
{
errorDetail: '/detailUrl',
ruleNames: ['no-empty-alt-text'],
errorContext: 'No empty alt text 1',
},
{
errorDetail: '/detailUrl',
ruleNames: ['no-default-alt-text'],
errorContext: 'No default alt text 1',
},
{
errorDetail: '/detailUrl',
ruleNames: ['no-empty-alt-text'],
errorContext: 'No empty alt text 2',
},
];
const otherErrors = [
{
errorDetail: '/detailUrl',
ruleNames: ['other'],
errorContext: 'Other 1',
},
{
errorDetail: '/detailUrl',
ruleNames: ['other'],
errorContext: 'Other 2',
},
];
const { getAllByRole, getByText, queryByText } = render(
<AccessibilitySuggestions
markdownLintErrors={[...otherErrors, ...imageErrors]}
/>,
);
const listItems = getAllByRole('listitem');
expect(listItems).toHaveLength(3);
imageErrors.forEach((imageError) => {
expect(getByText(imageError.errorContext)).toBeInTheDocument();
});
const imageDetailLinks = getAllByRole('link', {
name: 'Learn more about accessible images',
});
expect(imageDetailLinks).toHaveLength(3);
otherErrors.forEach((otherError) => {
expect(queryByText(otherError.errorContext)).not.toBeInTheDocument();
});
});
it('should show other errors if there are fewer than 3 image errors', () => {
const imageErrors = [
{
errorDetail: '/detailUrl',
ruleNames: ['no-empty-alt-text'],
errorContext: 'No empty alt text 1',
},
{
errorDetail: '/detailUrl',
ruleNames: ['no-default-alt-text'],
errorContext: 'No default alt text 1',
},
];
const otherErrors = [
{
errorDetail: '/detailUrl',
ruleNames: ['other'],
errorContext: 'Other 1',
},
{
errorDetail: '/detailUrl',
ruleNames: ['other'],
errorContext: 'Other 2',
},
];
const { getAllByRole, getByText, queryByText } = render(
<AccessibilitySuggestions
markdownLintErrors={[...otherErrors, ...imageErrors]}
/>,
);
const listItems = getAllByRole('listitem');
expect(listItems).toHaveLength(3);
imageErrors.forEach((imageError) => {
expect(getByText(imageError.errorContext)).toBeInTheDocument();
});
expect(
getAllByRole('link', {
name: 'Learn more about accessible images',
}),
).toHaveLength(2);
expect(getByText('Other 1')).toBeInTheDocument();
expect(queryByText('Other 2')).not.toBeInTheDocument();
expect(
getAllByRole('link', {
name: 'Learn more about accessible headings',
}),
).toHaveLength(1);
});
});

View file

@ -0,0 +1,146 @@
/**
* Helper function for the image markdown lint rules.
*
* It takes a full line of text which includes an image with empty or default alt text (i.e. format "![]()") and returns the image portion only.
* This allows us to point users towards the exact image markdown text that triggered the rule.
*
* @param {string} contentLine The full line of content as provided by markdownlint
* @returns {string} a substring containing only the image text - e.g. "![alt text]()"
*/
const getImageTextString = (contentLine) => {
let indexOfImageStart = contentLine.indexOf('!');
while (contentLine.charAt(indexOfImageStart + 1) !== '[') {
// It's possible for an image to be inserted on a line with text preceding it,
// this check helps ensure that the '!' is actually the image start
indexOfImageStart = contentLine.indexOf('!', indexOfImageStart + 1);
if (indexOfImageStart === -1) {
return;
}
}
// Find the next closing bracket from the image start
// We don't need to worry about brackets inside the alt text as this check is only run on images with default or no alt text
const indexOfImageEnd = contentLine.indexOf(')', indexOfImageStart);
return contentLine.substring(indexOfImageStart, indexOfImageEnd + 1);
};
/**
* Custom markdown lint rule that detects if a user has uploaded an image, but not changed the default alt text
*/
export const noDefaultAltTextRule = {
names: ['no-default-alt-text'],
description: 'Images should not have the default alt text',
tags: ['images'],
function: (params, onError) => {
params.tokens
.filter((token) => token.type === 'inline')
.forEach(({ children }) => {
children.forEach((contentChild) => {
if (
contentChild.type === 'image' &&
contentChild.line.toLowerCase().includes('![alt text]')
) {
onError({
lineNumber: contentChild.lineNumber,
detail: '/p/editor_guide#alt-text-for-images',
context: `Consider replacing the 'alt text' in square brackets at ${getImageTextString(
contentChild.line,
)} with a description of the image`,
});
}
});
});
},
};
/**
* A custom rule that mirrors the default "no-alt-text" rule, but with a more helpful error message
*/
export const noEmptyAltTextRule = {
names: ['no-empty-alt-text'],
description: 'Images should not have empty alt text',
tags: ['images'],
function: (params, onError) => {
params.tokens
.filter((token) => token.type === 'inline')
.forEach((inlineToken) => {
inlineToken.children.forEach((contentChild) => {
if (
contentChild.type === 'image' &&
contentChild.line.toLowerCase().includes('![]')
) {
onError({
lineNumber: inlineToken.lineNumber,
detail: '/p/editor_guide#alt-text-for-images',
context: `Consider adding an image description in the square brackets at ${getImageTextString(
contentChild.line,
)}`,
});
}
});
});
},
};
/**
* Custom markdown lint rule that detects if a level one heading has been used in a post
*/
export const noLevelOneHeadingsRule = {
names: ['no-level-one-heading'],
description: 'Heading level one should not be used in posts',
tags: ['headings'],
function: (params, onError) => {
const levelOneHeadings = [];
params.tokens.filter((token, index) => {
const isHeadingOneStart =
token.type === 'heading_open' && token.tag === 'h1';
if (isHeadingOneStart) {
// The next token is the actual content of the heading
levelOneHeadings.push(params.tokens[index + 1]);
}
});
levelOneHeadings.forEach((heading) => {
onError({
lineNumber: heading.lineNumber,
context: `Consider changing "${heading.line}" to a level two heading by using "##"`,
detail: '/p/editor_guide#accessible-headings',
});
});
},
};
/**
* A custom rule that mirrors the default "heading-increment" rule, but with a more helpful error message
*/
export const headingIncrement = {
names: ['custom-heading-increment'],
description: 'Heading levels should only increment by one level at a time',
tags: ['headings', 'headers'],
function: (params, onError) => {
let prevLevel = 0;
const headings = params.tokens.filter(
(token) => token.type === 'heading_open',
);
headings.forEach((heading) => {
const level = Number.parseInt(heading.tag.slice(1), 10);
if (prevLevel && level > prevLevel) {
// Heading level has increased
const suggestedHeadingLevel = prevLevel + 1;
if (suggestedHeadingLevel !== level) {
const suggestedHeadingStart = Array(suggestedHeadingLevel)
.fill('#')
.join('');
onError({
detail: '/p/editor_guide#accessible-headings',
lineNumber: heading.lineNumber,
context: `Consider changing the heading "${heading.line}" to a level ${suggestedHeadingLevel} heading by using "${suggestedHeadingStart}"`,
});
}
}
prevLevel = level;
});
},
};

View file

@ -45,6 +45,24 @@ describe('Post Editor', () => {
cy.findByTestId('error-message').should('be.visible');
});
it('should show the accessibility suggestions notice', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
// Add a heading level one which should cause an accessibility lint error
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('# Heading level one');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByRole('heading', {
name: 'Improve the accessibility of your post',
}).should('exist');
});
it('should show the Edit tab by default', () => {
cy.findByRole('form', { name: /^Edit post$/i })
.findByRole('navigation', {
@ -110,6 +128,24 @@ describe('Post Editor', () => {
cy.findByTestId('error-message').should('be.visible');
});
it('should show the accessibility suggestions notice', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
// Add a heading level one which should cause an accessibility lint error
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('# Heading level one');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByRole('heading', {
name: 'Improve the accessibility of your post',
}).should('exist');
});
it('should show the Edit tab by default', () => {
cy.findByRole('form', { name: /^Edit post$/i })
.findByRole('navigation', {
@ -128,4 +164,215 @@ describe('Post Editor', () => {
});
});
});
describe('Accessibility suggestions', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/articleEditorV2User.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.visit('/new');
});
});
});
it("shouldn't show accessibility suggestions if an error notice is present", () => {
const postTextWithError = '# Heading level one';
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
// Cause an error by having a non tag liquid tag without a tag name in the post body.
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type(`${postTextWithError}\n{%tag %}`, {
parseSpecialCharSequences: false,
});
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByTestId('error-message').should('be.visible');
cy.findByRole('heading', {
name: 'Improve the accessibility of your post',
}).should('not.exist');
});
it('should show a maximum of 3 accessibility suggestions', () => {
const postTextWithFourErrors =
'# Heading level 1\n![](http://imagewithoutalt.png)\n![alt text](http://imagewithdefaultalt.png)\n#### Heading level 4';
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type(postTextWithFourErrors);
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
// Check the notice has appeared
cy.findByRole('heading', {
name: 'Improve the accessibility of your post',
});
// Check each expected description has appeared
cy.findByText(
"Consider replacing the 'alt text' in square brackets at ![alt text](http://imagewithdefaultalt.png) with a description of the image",
);
cy.findByText(
'Consider adding an image description in the square brackets at ![](http://imagewithoutalt.png)',
);
cy.findByText(
'Consider changing "# Heading level 1" to a level two heading by using "##"',
);
// Check details links are shown for 3 errors
cy.findAllByRole('link', {
name: 'Learn more about accessible images',
}).should('have.length', 2);
cy.findAllByRole('link', {
name: 'Learn more about accessible headings',
}).should('have.length', 1);
});
it('should display image suggestions over heading suggestions', () => {
const textWithThreeImageErrors =
'![](http://imageerror1.png)\n![Alt Text](http://imageerror2.png)\n![Alt Text](http://imageerror3.png)';
const textWithHeadingErrors = '# Heading level 1\n #### Heading level 4';
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type(`${textWithHeadingErrors}\n${textWithThreeImageErrors}`);
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
// Verify the image suggestions are the only ones shown
cy.findByRole('link', {
name: 'Learn more about accessible headings',
}).should('not.exist');
cy.findAllByRole('link', {
name: 'Learn more about accessible images',
}).should('have.length', 3);
});
it('should show a suggestion for level one headings', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('# Level one heading');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByText(
'Consider changing "# Level one heading" to a level two heading by using "##"',
);
cy.findByRole('link', {
name: 'Learn more about accessible headings',
}).should('have.attr', 'href', '/p/editor_guide#accessible-headings');
});
it('should show a suggestion when heading level increases by more than one', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('## Level two heading\n#### Level four heading');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByText(
'Consider changing the heading "#### Level four heading" to a level 3 heading by using "###"',
);
cy.findByRole('link', {
name: 'Learn more about accessible headings',
}).should('have.attr', 'href', '/p/editor_guide#accessible-headings');
});
it('should show a suggestion for empty alt text on images', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('![](http://image1.png)');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByText(
'Consider adding an image description in the square brackets at ![](http://image1.png)',
);
cy.findByRole('link', {
name: 'Learn more about accessible images',
}).should('have.attr', 'href', '/p/editor_guide#alt-text-for-images');
});
it('should show a suggestion for default alt text on images', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('![alt text](http://image1.png)\n![Alt Text](http://image2.png)');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByText(
"Consider replacing the 'alt text' in square brackets at ![alt text](http://image1.png) with a description of the image",
);
cy.findByText(
"Consider replacing the 'alt text' in square brackets at ![Alt Text](http://image2.png) with a description of the image",
);
cy.findAllByRole('link', {
name: 'Learn more about accessible images',
}).should('have.length', 2);
cy.findAllByRole('link', {
name: 'Learn more about accessible images',
})
.first()
.should('have.attr', 'href', '/p/editor_guide#alt-text-for-images');
});
it('should show the correct suggestion for alt text when other text exists on the same line', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
cy.get('@articleForm')
.findByLabelText('Post Content')
.clear()
.type('Some text ![alt text](http://image1.png) Some more text');
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.click();
cy.findByText(
"Consider replacing the 'alt text' in square brackets at ![alt text](http://image1.png) with a description of the image",
);
});
});
});