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

Implement minimal accessibility snapshots #236

Merged
merged 11 commits into from Sep 8, 2021
5 changes: 5 additions & 0 deletions .changeset/thick-singers-roll.md
@@ -0,0 +1,5 @@
---
'pleasantest': minor
---

Add experimental accessibility snapshots feature
2 changes: 1 addition & 1 deletion babel.config.cjs
Expand Up @@ -10,7 +10,7 @@ module.exports = (api) => {
loose: true,
},
],
'@babel/preset-typescript',
['@babel/preset-typescript', { optimizeConstEnums: true }],
],
plugins: isRollup ? ['babel-plugin-un-cjs'] : [],
};
Expand Down
53 changes: 41 additions & 12 deletions examples/menu/index.test.ts
@@ -1,5 +1,10 @@
import type { PleasantestUtils } from 'pleasantest';
import { withBrowser, devices } from 'pleasantest';
import {
experimentalGetAccessibilityTree,
withBrowser,
devices,
} from 'pleasantest';

import { Liquid } from 'liquidjs';
import * as path from 'path';
const iPhone = devices['iPhone 11'];
Expand Down Expand Up @@ -75,23 +80,46 @@ test(
await expect(await screen.getByText(aboutText)).not.toBeVisible();

// Before JS initializes the menus should be links
let aboutBtn = await screen.getByRole('link', { name: /about/i });
await expect(aboutBtn).toHaveAttribute('href');
await expect(
await screen.queryByRole('button', { name: /about/i }),
).not.toBeInTheDocument();
expect(
await experimentalGetAccessibilityTree(
await screen.getByRole('navigation'),
),
).toMatchInlineSnapshot(`
navigation
heading "Company"
link "Company"
list
listitem
link "Products"
listitem
link "About"
listitem
link "Log In"
`);

await utils.runJS(`
import { init } from '.'
init()
`);

// The menus should be upgraded to buttons
aboutBtn = await screen.getByRole('button', { name: /about/i });
await expect(
await screen.queryByRole('link', { name: /about/i }),
).not.toBeInTheDocument();

// The menus should be upgraded to buttons,
const aboutBtn = await screen.getByRole('button', { name: /about/i });
expect(
await experimentalGetAccessibilityTree(
await screen.getByRole('navigation'),
),
).toMatchInlineSnapshot(`
navigation
heading "Company"
link "Company"
list
listitem
button "Products"
listitem
button "About"
listitem
link "Log In"
`);
// Login should still be a link, since it does not trigger a menu to open
const loginBtn = await screen.getByRole('link', { name: /log in/i });
await expect(loginBtn).toHaveAttribute('href');
Expand All @@ -110,6 +138,7 @@ test(
const productsBtn = await screen.getByRole('button', { name: /products/i });
// First click: opens about menu
await user.click(aboutBtn);

await expect(await screen.getByText(aboutText)).toBeVisible();
// Second click: closes about menu
await user.click(aboutBtn);
Expand Down
32 changes: 16 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -29,6 +29,7 @@
"ansi-regex": "6.0.0",
"aria-query": "*",
"babel-plugin-un-cjs": "2.5.0",
"dom-accessibility-api": "0.5.7",
"errorstacks": "2.3.2",
"esbuild-jest": "0.5.0",
"eslint": "7.32.0",
Expand All @@ -53,7 +54,7 @@
"sass": "1.38.2",
"simple-code-frame": "1.1.1",
"smoldash": "0.11.0",
"typescript": "4.3.5",
"typescript": "4.4.2",
"vue": "3.2.6"
},
"dependencies": {
Expand Down
2 changes: 2 additions & 0 deletions rollup.config.js
@@ -1,6 +1,7 @@
import jestDomConfig from './src/jest-dom/rollup.config';
import pptrTestingLibraryConfig from './src/pptr-testing-library-client/rollup.config';
import userUtilsConfig from './src/user-util/rollup.config';
import accessibilityConfig from './src/accessibility/rollup.config';

import dts from 'rollup-plugin-dts';
import babel from '@rollup/plugin-babel';
Expand Down Expand Up @@ -61,6 +62,7 @@ const typesConfig = {
export default [
mainConfig,
userUtilsConfig,
accessibilityConfig,
jestDomConfig,
pptrTestingLibraryConfig,
typesConfig,
Expand Down
77 changes: 77 additions & 0 deletions src/accessibility/browser.ts
@@ -0,0 +1,77 @@
import {
getRole,
computeAccessibleName,
computeAccessibleDescription,
} from 'dom-accessibility-api';
import type { AccessibilityTreeOptions } from '.';

const indent = (text: string, indenter = ' ') =>
indenter + text.split('\n').join(`\n${indenter}`);

const enum AccessibilityState {
/** This element is accessible and its descendents could be accessible */
Accessible,
/** This element is innaccessible, but its descendents could be accessible */
InaccessibleSelf,
/** Both this element and its descendents are inaccessible */
InaccessibleSelfAndDescendents,
}
calebeby marked this conversation as resolved.
Show resolved Hide resolved

// TODO in PR: what about role="presentation" or role="none"? Should they be excluded?
Copy link
Member Author

Choose a reason for hiding this comment

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

Flagging this for review.

My brain has only parsed this like 20% so I don't really understand it yet. The current implementation in this PR is definitely wrong. I will either make it match the spec in this PR or in a follow-up.

If somebody understands this well (w.r.t. how to treat child elements) feel free to explain it to me!

https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion

The following elements are not exposed via the accessibility API and user agents MUST NOT include them in the accessibility tree:
...

  • Elements with none or presentation as the first role in the role attribute. However, their exclusion is conditional. In addition, the element's descendants and text content are generally included. These exceptions and conditions are documented in the presentation (role) section.

https://www.w3.org/TR/wai-aria-1.2/#presentation

The intended use is when an element is used to change the look of the page but does not have all the functional, interactive, or structural relevance implied by the element type, or may be used to provide for an accessible fallback in older browsers that do not support WAI-ARIA.
For any element with a role of presentation and which is not focusable, the user agent MUST NOT expose the implicit native semantics of the element (the role and its states and properties) to accessibility APIs. However, the user agent MUST expose content and descendant elements that do not have an explicit or inherited role of presentation. Thus, the presentation role causes a given element to be treated as having no role or to be removed from the accessibility tree, but does not cause the content contained within the element to be removed from the accessibility tree.

Copy link
Member

Choose a reason for hiding this comment

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

Oh yeah, weird. MDN makes it even more complicated:

The presentation role is used to remove semantic meaning from an element and any of its related child elements. For example, a table used for layout purposes could have the presentation role applied to the table element to remove any semantic meaning from the table element and any of its table related children elements, such as table headers and table data elements. Non-table related elements should retain their semantic meaning, however.

Happy to chat more about this, though your quotes made me realize I've been misunderstanding role="presentation" (and made me grateful for just using aria-hidden instead 😅 )

Copy link
Member

Choose a reason for hiding this comment

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

You may want to flag this in our internal #a11y channel or the A11y slack to get some more expertise

Copy link
Member Author

Choose a reason for hiding this comment

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

FWIW I'm planning to research/ask about this and address it in a follow-up PR

Copy link
Member

Choose a reason for hiding this comment

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

A question I have is what is the goal here?

For example, is the goal of the accessibility tree test to validate I purposefully added a presentation role to an HTML element? If yes, then it seems it'd be helpful to have this information included in the snapshot. 🤔

Did I make sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

@gerardo-rodriguez Good point. Here is how I think about it:

If role="presentation" worked the same way as role="none" (which I don't know if they are but just for the sake of explanation), I would consider which one I was using to be an implementation detail, so they snapshots should be the same regardless of which I'm using. So this makes me reluctant to display a presentation role for role="presentation" like we do for other roles:

region
  presentation
    heading "hello"
    button "click"

It's unclear to me exactly what role="presentation" does to descendent elements. From the MDN page Paul quoted, it seems like descendents that are related to the element with role="presentation" get their roles removed? And maybe other things happen too? Ideally the snapshot would encompass all the changes to the accessibility tree that are described in the spec

Copy link
Member

Choose a reason for hiding this comment

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

FWIW I'm planning to research/ask about this and address it in a follow-up PR

I am wondering if we should have a quick Slack call to better understand and/or resolve this with @cloudfour/dev? Or, @calebeby, do you feel your original request/questions have been answered?

Copy link
Member

Choose a reason for hiding this comment

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

I'd be happy to chat. I'm also curious how the presentation role affects its children, and how we should display that info

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I think a chat would be good. I could do tomorrow afternoon anytime?

Copy link
Member

Choose a reason for hiding this comment

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

Same 👍

// The spec says "it depends on the implementation" basically I think
const getAccessibilityState = (element: Element): AccessibilityState => {
calebeby marked this conversation as resolved.
Show resolved Hide resolved
const computedStyle = getComputedStyle(element);
if (
(element as HTMLElement).hidden ||
element.getAttribute('aria-hidden') === 'true' ||
computedStyle.display === 'none'
)
return AccessibilityState.InaccessibleSelfAndDescendents;

// An element can have visibility: 'hidden' but its descendents can override visibility
calebeby marked this conversation as resolved.
Show resolved Hide resolved
if (computedStyle.visibility === 'hidden')
return AccessibilityState.InaccessibleSelf;

return AccessibilityState.Accessible;
};

export const getAccessibilityTree = (
element: Element,
opts: AccessibilityTreeOptions,
): string => {
const accessibilityState = getAccessibilityState(element);
if (accessibilityState === AccessibilityState.InaccessibleSelfAndDescendents)
return '';
const { includeDescriptions = true, includeText = false } = opts;
calebeby marked this conversation as resolved.
Show resolved Hide resolved
const role = getRole(element);
const printSelf =
role && accessibilityState === AccessibilityState.Accessible;
let text = (printSelf && role) || '';
if (printSelf) {
const name = computeAccessibleName(element);
if (name) text += ` "${name}"`;
if (includeDescriptions) {
const description = computeAccessibleDescription(element);
if (description) text += `\n ↳ description: "${description}"`;
}
}
const printedChildren = [];
for (const node of element.childNodes) {
let printedChild;
if (node instanceof Element) {
printedChild = getAccessibilityTree(node, opts);
} else if (includeText) {
const trimmedText = node.nodeValue?.trim();
if (!trimmedText) continue;
printedChild = `text "${trimmedText}"`;
}
if (printedChild) printedChildren.push(printedChild);
}
if (printedChildren.length > 0) {
if (text.length > 0) text += '\n';
text += printSelf
? indent(printedChildren.join('\n'))
: printedChildren.join('\n');
}
return text;
};
57 changes: 57 additions & 0 deletions src/accessibility/index.ts
@@ -0,0 +1,57 @@
import type { ElementHandle } from 'puppeteer';
import { createClientRuntimeServer } from '../module-server/client-runtime-server';
import { assertElementHandle } from '../utils';

const accessibilityTreeSymbol: unique symbol = Symbol('PT Accessibility Tree');

export interface AccessibilityTreeOptions {
/**
* Whether the accessibile description of elements should be included in the tree.
calebeby marked this conversation as resolved.
Show resolved Hide resolved
* https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description
* (default: true)
*/
includeDescriptions?: boolean;
/**
* Whether to include text that is not part of an element's accessible name/description
* (default: false)
*/
includeText?: boolean;
}

export const getAccessibilityTree = async (
element: ElementHandle,
options: AccessibilityTreeOptions = {},
) => {
const serverPromise = createClientRuntimeServer();
assertElementHandle(element, getAccessibilityTree);

const { port } = await serverPromise;

const result: string = await element.evaluate(
// Using new Function to avoid babel transpiling the import
// @ts-expect-error pptr's types don't like new Function
new Function(
'element',
'options',
`return import("http://localhost:${port}/@pleasantest/accessibility")
.then(accessibility => accessibility.getAccessibilityTree(element, options))`,
),
options,
);

return {
[accessibilityTreeSymbol]: result,
toString: () => result,
};
};

expect.addSnapshotSerializer({
calebeby marked this conversation as resolved.
Show resolved Hide resolved
serialize: (val, config, indentation, depth, refs, printer) => {
calebeby marked this conversation as resolved.
Show resolved Hide resolved
const v = val[accessibilityTreeSymbol];
return typeof v === 'string'
? v
: printer(v, config, indentation, depth, refs);
},
test: (val) =>
val && typeof val === 'object' && accessibilityTreeSymbol in val,
});
20 changes: 20 additions & 0 deletions src/accessibility/rollup.config.js
@@ -0,0 +1,20 @@
import babel from '@rollup/plugin-babel';
import nodeResolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import { rollupPluginDomAccessibilityApi } from '../rollup-plugin-dom-accessibility-api';

const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'];

/** @type {import('rollup').RollupOptions} */
const config = {
input: ['src/accessibility/browser.ts'],
plugins: [
babel({ babelHelpers: 'bundled', extensions }),
nodeResolve({ extensions }),
rollupPluginDomAccessibilityApi(),
terser({ ecma: 2019 }),
],
output: { file: 'dist/accessibility.js' },
};

export default config;
2 changes: 2 additions & 0 deletions src/index.ts
Expand Up @@ -450,3 +450,5 @@ export const devices = puppeteer.devices;
afterAll(async () => {
await cleanupClientRuntimeServer();
});

export { getAccessibilityTree as experimentalGetAccessibilityTree } from './accessibility';
2 changes: 2 additions & 0 deletions src/jest-dom/rollup.config.js
@@ -1,6 +1,7 @@
import babel from '@rollup/plugin-babel';
import nodeResolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import { rollupPluginDomAccessibilityApi } from '../rollup-plugin-dom-accessibility-api';

const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'];

Expand Down Expand Up @@ -73,6 +74,7 @@ const config = {
babel({ babelHelpers: 'bundled', extensions }),
nodeResolve({ extensions }),
removeCloneNodePlugin,
rollupPluginDomAccessibilityApi(),
terser({
ecma: 2019,
// Jest-dom uses function names for error messages
Expand Down
2 changes: 2 additions & 0 deletions src/module-server/client-runtime-server.ts
Expand Up @@ -31,6 +31,8 @@ const clientRuntimeMiddleware =
? 'jest-dom.js'
: req.path === '/@pleasantest/user-util'
? 'user-util.js'
: req.path === '/@pleasantest/accessibility'
? 'accessibility.js'
: 'pptr-testing-library-client.js',
);
const text = await fs.readFile(filePath, 'utf8');
Expand Down