Skip to content

an enhanced html magic with the ability to test for violations¤

i want to ship some tools that make it easier for interactive accessibility testing and analysis. typically, accessibility is a challenge to integrate with python because of the need for multiple run times. to run axe we need javascript and to run the vnu validator we need java. after we've run our accessibility tests we need to reason with them, and working in a python environment is ideal for data analysis.

in this post we'll explore standard testing with explicit and implicit accessibility testing invocations.

import playwright.async_api, playwright.sync_api, nbconvert_a11y.axe.async_axe
%%
    files:\
is a collection of html documents we want to test.\

    = Index(Path("../../../nbconvert-a11y/tests/exports/html/").glob("*.html"), name="file").to_series().head(5)

<details>
<summary>in <code>IPython</code> we must use the async api because the sync api raises an error.</summary>

    &gt;&gt;&gt; playwright.sync_api.sync_playwright().__enter__()
    Traceback (most recent call last):
    ...
    playwright._impl._errors.Error: It looks like you are using Playwright Sync API inside the asyncio loop.
    Please use the Async API instead.

</details>

therefore we need to write a lot more code to manage the async state.
files:\

is a collection of html documents we want to test.\

= Index(Path("../../../nbconvert-a11y/tests/exports/html/").glob("*.html"), name="file").to_series().head(5)
in IPython we must use the async api because the sync api raises an error.
    >>> playwright.sync_api.sync_playwright().__enter__()
    Traceback (most recent call last):
    ...
    playwright._impl._errors.Error: It looks like you are using Playwright Sync API inside the asyncio loop.
    Please use the Async API instead.

therefore we need to write a lot more code to manage the async state.

%%
## testing axe and vnu with nbnbconvert_a11y

    async with playwright.async_api.async_playwright() as pw:
1. enter an asynchronous playwright context

        browser = await pw.chromium.launch()
2. navigation to an html document

        page = await browser.new_page()
    we need to use the uri format of the file

        await page.goto(file := files.iloc[0].absolute().as_uri())
3. `nbconvert_a11y` now provides convenience functions for testing these pages

        axe, vnu, aom = await asyncio.gather(
            nbconvert_a11y.axe.async_axe.pw_axe(page),
            nbconvert_a11y.axe.async_axe.pw_validate_html(page),
            nbconvert_a11y.axe.async_axe.pw_accessibility_tree(page)
        )

the example above represents the analysis of single file's accessibility, html formatting, and accessibility tree.
there are times where we might have to measure many pages or perhaps only part of a page. 
the next sections will look at scaling testing to multiple files then parts of a html document.

testing axe and vnu with nbnbconvert_a11y

async with playwright.async_api.async_playwright() as pw:
  1. enter an asynchronous playwright context

     browser = await pw.chromium.launch()
    
  2. navigation to an html document

     page = await browser.new_page()
    

    we need to use the uri format of the file

     await page.goto(file := files.iloc[0].absolute().as_uri())
    
  3. nbconvert_a11y now provides convenience functions for testing these pages

     axe, vnu, aom = await asyncio.gather(
         nbconvert_a11y.axe.async_axe.pw_axe(page),
         nbconvert_a11y.axe.async_axe.pw_validate_html(page),
         nbconvert_a11y.axe.async_axe.pw_accessibility_tree(page)
     )
    

the example above represents the analysis of single file's accessibility, html formatting, and accessibility tree. there are times where we might have to measure many pages or perhaps only part of a page. the next sections will look at scaling testing to multiple files then parts of a html document.

analyzing many files as dataframes¤

aggregating accessibility violations across many files has been a challenge. this reasoning is often required when venturing to retrofit accessibility of exists sites. we'll want to figure out how to reason with common and uncommon violations in documents. the dataframe approach that follows brings accessibility violations nearer to data analytics.

%%
    async def goto(browser, file):
`goto` is a covenience function for creating a new browser page and populating it with the contents of a file.

        page = await browser.new_page()
        await page.goto(file.absolute().as_uri())
        return page


    async with playwright.async_api.async_playwright() as pw:
we can scale the analysis by applying our per file analysis to many files.
the outcome of this are three different dataframes that are ripe for data analysis.

        browser = await pw.chromium.launch()
        pages = await files.apply(partial(goto, browser)).gather()
        axe, vnu, aom = await asyncio.gather(
            pages.apply(nbconvert_a11y.axe.async_axe.pw_axe).gather(),
            pages.apply(nbconvert_a11y.axe.async_axe.pw_validate_html).gather(),
            pages.apply(nbconvert_a11y.axe.async_axe.pw_accessibility_tree).gather(),
        )


at this point, we've generated a larger dataframe that aggregates the `results` of each analysis
that we can reason with.

    (results := pandas.concat(dict(axe=axe, vnu=vnu, aom=aom), axis=1))
axe vnu aom
file
../../../nbconvert-a11y/tests/exports/html/lorenz-executed-default.html {'testEngine': {'name': 'axe-core', 'version':... {'messages': [{'type': 'error', 'lastLine': 1,... {'role': 'WebArea', 'name': 'lorenz-executed',...
../../../nbconvert-a11y/tests/exports/html/lorenz-a11y.html {'testEngine': {'name': 'axe-core', 'version':... {'messages': [{'type': 'error', 'lastLine': 1,... {'role': 'WebArea', 'name': 'lorenz', 'childre...
../../../nbconvert-a11y/tests/exports/html/lorenz-executed-a11y.html {'testEngine': {'name': 'axe-core', 'version':... {'messages': [{'type': 'error', 'lastLine': 1,... {'role': 'WebArea', 'name': 'lorenz-executed',...
../../../nbconvert-a11y/tests/exports/html/lorenz-executed-section.html {'testEngine': {'name': 'axe-core', 'version':... {'messages': [{'type': 'error', 'lastLine': 1,... {'role': 'WebArea', 'name': 'lorenz-executed',...
../../../nbconvert-a11y/tests/exports/html/lorenz-default.html {'testEngine': {'name': 'axe-core', 'version':... {'messages': [{'type': 'error', 'lastLine': 1,... {'role': 'WebArea', 'name': 'lorenz', 'childre...
async def goto(browser, file):

goto is a covenience function for creating a new browser page and populating it with the contents of a file.

    page = await browser.new_page()
    await page.goto(file.absolute().as_uri())
    return page


async with playwright.async_api.async_playwright() as pw:

we can scale the analysis by applying our per file analysis to many files. the outcome of this are three different dataframes that are ripe for data analysis.

    browser = await pw.chromium.launch()
    pages = await files.apply(partial(goto, browser)).gather()
    axe, vnu, aom = await asyncio.gather(
        pages.apply(nbconvert_a11y.axe.async_axe.pw_axe).gather(),
        pages.apply(nbconvert_a11y.axe.async_axe.pw_validate_html).gather(),
        pages.apply(nbconvert_a11y.axe.async_axe.pw_accessibility_tree).gather(),
    )

at this point, we've generated a larger dataframe that aggregates the results of each analysis that we can reason with.

(results := pandas.concat(dict(axe=axe, vnu=vnu, aom=aom), axis=1))

example analyses¤

%%
the tabular format makes it easier to query results from the testing

    violations = results.axe.series().violations.explode().series()
    violations["num_nodes"] = violations.nodes.apply(len)

for example, we can discover an overview of the violations in different files.

    violations.groupby(["file", "id"]).num_nodes.sum().unstack(0).fillna("")
file ../../../nbconvert-a11y/tests/exports/html/lorenz-a11y.html ../../../nbconvert-a11y/tests/exports/html/lorenz-default.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-a11y.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-default.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-section.html
id
aria-input-field-name 3.0 3.0 3.0
color-contrast 13.0 16.0
color-contrast-enhanced 10.0 10.0
focus-order-semantics 7.0 23.0 7.0 28.0 7.0
image-alt 1.0 1.0 1.0

the tabular format makes it easier to query results from the testing

violations = results.axe.series().violations.explode().series()
violations["num_nodes"] = violations.nodes.apply(len)

for example, we can discover an overview of the violations in different files.

violations.groupby(["file", "id"]).num_nodes.sum().unstack(0).fillna("")
%%
we can manipulate the same `results` to extact the html violations from the vnu validator.

    results.vnu.series().messages.explode().series().groupby("file type".split()).message.count().unstack(0)
file ../../../nbconvert-a11y/tests/exports/html/lorenz-a11y.html ../../../nbconvert-a11y/tests/exports/html/lorenz-default.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-a11y.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-default.html ../../../nbconvert-a11y/tests/exports/html/lorenz-executed-section.html
type
error 40 7 40 18 42
info 14 13 14 14 31

we can manipulate the same results to extact the html violations from the vnu validator.

results.vnu.series().messages.explode().series().groupby("file type".split()).message.count().unstack(0)

analyzing components individual¤

when developing components we tend to not want a firehose like we get from the files approach. rather we'll report one at a time.

%reload_ext nbconvert_a11y.axe

the magic exposes a persistent browser that we can use to generate pages from from.

%%html
<section role="list"></section>
accessibility violations for about:blank (1 sub-exception)
5 html violations (5 sub-exceptions)
  | nbconvert_a11y.axe.base_axe_exceptions.AxeExceptions: accessibility violations for about:blank (1 sub-exception)
  +-+---------------- 1 ----------------
    | nbconvert_a11y.axe.base_axe_exceptions.aria_allowed_role_minor: {'description': 'Ensures role attribute has an appropriate value for the '
    |                 'element',
    |  'help': 'ARIA role should be appropriate for the element',
    |  'helpUrl': 'https://dequeuniversity.com/rules/axe/4.8/aria-allowed-role?application=axeAPI',
    |  'id': 'aria-allowed-role',
    |  'impact': 'minor',
    |  'nodes': [{'all': [],
    |             'any': [{'data': ['list'],
    |                      'id': 'aria-allowed-role',
    |                      'impact': 'minor',
    |                      'message': 'ARIA role list is not allowed for given '
    |                                 'element',
    |                      'relatedNodes': []}],
    |             'failureSummary': 'Fix any of the following:\n'
    |                               '  ARIA role list is not allowed for given '
    |                               'element',
    |             'html': '<section role="list"></section>',
    |             'impact': 'minor',
    |             'none': [],
    |             'target': ['section']}],
    |  'tags': ['cat.aria', 'best-practice']}
    +------------------------------------
  | ExceptionGroup: 5 html violations (5 sub-exceptions)
  +-+---------------- 1 ----------------
    | nbconvert_a11y.pytest_w3c.error-Start tag seen without seeing a doctype first. Expected “<!DOCTYPE html>”.
    +---------------- 2 ----------------
    | nbconvert_a11y.pytest_w3c.error-Element “head” is missing a required instance of child element “title”.
    +---------------- 3 ----------------
    | nbconvert_a11y.pytest_w3c.error-Bad value “list” for attribute “role” on element “section”.
    +---------------- 4 ----------------
    | nbconvert_a11y.pytest_w3c.info-Section lacks heading. Consider using “h2”-“h6” elements to add identifying headings to all sections, or else use a “div” element instead for any cases where no heading is needed.
    +---------------- 5 ----------------
    | nbconvert_a11y.pytest_w3c.info-Consider adding a “lang” attribute to the “html” start tag to declare the language of this document.
    +------------------------------------
%%html
<form aria-label="">
<input aria-label="numbers/" type="number"/>
</form>
accessibility violations for about:blank (1 sub-exception)
3 html violations (3 sub-exceptions)
  | nbconvert_a11y.axe.base_axe_exceptions.AxeExceptions: accessibility violations for about:blank (1 sub-exception)
  +-+---------------- 1 ----------------
    | nbconvert_a11y.axe.base_axe_exceptions.region_moderate: {'description': 'Ensures all page content is contained by landmarks',
    |  'help': 'All page content should be contained by landmarks',
    |  'helpUrl': 'https://dequeuniversity.com/rules/axe/4.8/region?application=axeAPI',
    |  'id': 'region',
    |  'impact': 'moderate',
    |  'nodes': [{'all': [],
    |             'any': [{'data': {'isIframe': False},
    |                      'id': 'region',
    |                      'impact': 'moderate',
    |                      'message': 'Some page content is not contained by '
    |                                 'landmarks',
    |                      'relatedNodes': []}],
    |             'failureSummary': 'Fix any of the following:\n'
    |                               '  Some page content is not contained by '
    |                               'landmarks',
    |             'html': '<form aria-label="">\n'
    |                     '    <input type="number" aria-label="numbers/">\n'
    |                     '</form>',
    |             'impact': 'moderate',
    |             'none': [],
    |             'target': ['form']}],
    |  'tags': ['cat.keyboard', 'best-practice']}
    +------------------------------------
  | ExceptionGroup: 3 html violations (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | nbconvert_a11y.pytest_w3c.error-Start tag seen without seeing a doctype first. Expected “<!DOCTYPE html>”.
    +---------------- 2 ----------------
    | nbconvert_a11y.pytest_w3c.error-Element “head” is missing a required instance of child element “title”.
    +---------------- 3 ----------------
    | nbconvert_a11y.pytest_w3c.info-Consider adding a “lang” attribute to the “html” start tag to declare the language of this document.
    +------------------------------------