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

Focus visible test #33

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
7 changes: 6 additions & 1 deletion .github/actions/link-package/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,13 @@ runs:
working-directory: external-pkg

- name: Link JupyterLab and external package 🔗
# Note (--all): you must add the `--all` suffix to the yarn (jlpm) link
# command to link in all sub-packages of a monorepo. When this action was
# run without the --all option, a test failed that should have passed
# because some changes in a Lumino sub-package were not incorporated into
# the JupyterLab build.
run: |
conda run --prefix $CONDA_PREFIX yarn link "${{ github.workspace }}/external-pkg" -p
conda run --prefix $CONDA_PREFIX jlpm link "${{ github.workspace }}/external-pkg" --private --all
conda run --prefix $CONDA_PREFIX jlpm run build
shell: bash -l {0}
working-directory: jupyterlab
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Visible Focus Indicator on Initial Page

## Description

When a user loads JupyterLab, if they use the tab key to navigate the page, each
tab-focussable element on the page should have a visible indicator when it
receives focus.

## Applicability

Which apps does this test currently apply to?

- JupyterLab

## Related GitHub PRs

These PRs were needed to make this test pass:

[Make focus visible (mostly CSS)
#13415](https://github.com/jupyterlab/jupyterlab/pull/13415)

## Related GitHub Issues

[JupyterLab #9399](https://github.com/jupyterlab/jupyterlab/pull/9399) - in the
PR description, look under "Focus", Issue Area #2.

## Related Accessibility Guidelines

[Understanding WCAG 2.4.7: Focus
Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html)

## How to Interpret Test Results

The test only checks for the existence of a focus indicator on each tab-focussed
element. It does not check whether or not the focus indicator is a **good**
focus indicator. For example, the test does not check the color contrast of the
focus indicator.

The test checks for a focus indicator by taking screenshots of each
tab-focussable element both before and after the element is focussed, then
comparing the screenshots. A single pixel difference will cause the test to
pass. This is why the test cannot tell you if the focus indicator is good; it
can only tell you if the visual area around the element changes when it has
focus versus when it does not.

So a test failure means that the app under test fails the WCAG 2.4.7 success
criteria (assuming no bugs in the test itself), but a test success does not mean
that the app fulfills the spirit of the guideline, which is to make it easy for
all sighted users to know which element on the page has browser focus.

## How to Perform the Test Manually

1. Open JupyterLab in a fresh environment. Another way to say this is that you
should have the following parts visible: top menu bar, left side panel with
file browser open, the launcher in the main area, right side panel closed,
and status bar. Here's a screenshot: ![screenshot of JupyterLab initial
page](assets/no-tab-trap-initial-page/jupyterlab-initial-page.png)
2. Press the <kbd>TAB</kbd> key repeatedly.
3. Each time you press the <kbd>TAB</kbd> key, you should be able to clearly see
which element (whether it is a link, a button, or some other element), has
focus.
4. Continue pressing the <kbd>TAB</kbd> key and checking for focus indicator
until you cycle back around.
5. If at any point, you lost track of which element had focus, then the test
fails. Another way to say this is that if at any point you pressed the
<kbd>TAB</kbd> key and you could not find the element which had focus, then
it means that the focus indicator was not visible (or maybe not visible
enough).
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) Jupyter Accessibility Development Team.
// Distributed under the terms of the Modified BSD License.

import { ElementHandle, expect, Page } from "@playwright/test";
import { test } from "@jupyterlab/galata";

/**
* Press the tab key, return the focussed node
* @param page Playwright page instance
* @returns ElementHandle, a reference to the focussed node
*/
async function nextFocusNode(page: Page) {
await page.keyboard.press("Tab");
const node = await page.evaluateHandle(() => document.activeElement);
if ((await node.jsonValue()) === null) {
throw new Error("Could not get next focus node from page");
}
// If node.jsonValue() is not null, then we should have an ElementHandle.
return node as ElementHandle;
}

/**
* Generator function to iterate through the tab-focussable nodes on the page in
* tab-focus order.
*
* Note: when the function yields a node, that node currently has the browser
* focus.
*
* @param page Playwright page instance
* @returns an AsyncGenerator instance for iterating over the tab-focussable
* nodes on the page
*/
async function* getFocusNodes(page: Page) {
// Get the first node in the page's focus order.
const start: ElementHandle = await nextFocusNode(page);
let node: ElementHandle = start;

// Start a loop in order to cycle through all of the tab-focussable nodes on
// the page.
while (true) {
// Yield the current node to the code requesting it.
yield node;

// The caller may blur the node, so refocus it before getting the next node.
// That way we focus the nodes in the right order.
await node.evaluate((node: HTMLElement) => node.focus());

// Get the next node in the page's focus order.
const nextNode = await nextFocusNode(page);

// Break out of the loop if we have cycled back to the start.
if (
await page.evaluate(
([nextNode, start]) => nextNode === start,
[nextNode, start],
)
) {
break;
} else {
// Otherwise do another turn of the loop.
node = nextNode;
}
}
}

test.describe("every tab-focusable element on initial app page", () => {
test("should have visible focus indicator", async ({ page }, testInfo) => {
test.info().annotations.push({
type: "Manual testing script",
description: "visible-focus-indicator-initial-page.md",
});

// For each tab-focussable node, take a screenshot of the node while
// focussed then not focussed and then compare the screenshots.
for await (const node of getFocusNodes(page)) {
// Skip if node is body node. (This is a discrepancy between the real and
// test environments. Under normal usage, the body element of the
// JupyterLab UI is not tab-focussable.)
if (await node.evaluate((node) => node === document.body)) {
continue;
}

// Calculate where on the page to take the screenshot in order to capture
// the node. Note: we cannot use node.screenshot() because it does not
// reliably capture CSS-applied outlines across browsers.
const box = await node.boundingBox();
if (box === null) {
throw new Error("Could not get node bounding box");
}
const { x, y, width, height } = box;
const pad = 3; // this value is just from trial-and-error
const clip = {
x: x - pad,
y: y - pad,
width: pad + width + pad,
height: pad + height + pad,
};

// Screenshot the node; this time it's focussed.
const focus = await page.screenshot({ clip });

// Blur the node to remove focus.
await node.evaluate((node) => (node as HTMLElement).blur());

// Screenshot the node again, this time it's not focussed.
const noFocus = await page.screenshot({ clip });

// Attach the screenshots to the test report (can help with debugging if
// the test fails, among other things)
await testInfo.attach("focussed", {
body: focus,
contentType: "image/png",
});
await testInfo.attach("unfocussed", {
body: noFocus,
contentType: "image/png",
});

// Compare the screenshots. If they are equal, the test fails. Use
// expect.soft so that the test will iterate through all of the
// tab-focussable nodes on the page instead of bailing on the first node
// that fails the test.
expect
.soft(
// Buffer.equals uses bit-for-bit equality, equivalent to comparing
// both screenshots pixel for pixel. If the screenshots are exactly
// the same, we know for sure that there was no visible focus
// indicator, so the test fails.
focus.equals(noFocus),
`focus visible comparison failed on\n\t${node.toString()}`,
)
.toBe(false);
}
});
});