diff --git a/.github/actions/link-package/action.yml b/.github/actions/link-package/action.yml index 39eb2c3..2871d26 100644 --- a/.github/actions/link-package/action.yml +++ b/.github/actions/link-package/action.yml @@ -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 diff --git a/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md b/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md new file mode 100644 index 0000000..d615b2c --- /dev/null +++ b/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md @@ -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 TAB key repeatedly. +3. Each time you press the TAB 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 TAB 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 + TAB 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). diff --git a/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts b/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts new file mode 100644 index 0000000..669dc5a --- /dev/null +++ b/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts @@ -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); + } + }); +});