Automating Angular Accessibility Audits
Angular runs change detection inside a zone, so the reliable signal that a view is finished rendering is not a timeout or a network event — it is zone stability, exposed through Angular’s built-in Testability API. This page shows how to automate accessibility scans of an Angular application by waiting on getAllAngularTestabilities().isStable(), keying rescans off the router’s NavigationEnd event, and folding CDK overlays and the live-announcer region into scope before running axe-core.
This is the Angular-specific guide within the Framework-Specific Rule Mapping topic area, under the broader Automated Scanning & Dynamic Content Ingestion pipeline. The parent guide describes the shared settle-signal model; this page defines what “settled” means for Angular’s zone.js change detection, Router events, and Angular CDK overlays and LiveAnnouncer.
When This Applies
Reach for this pattern with any zone-based Angular application — the default for Angular apps that have not opted into zoneless change detection — routed with @angular/router and using Angular Material or the CDK for overlays. The symptoms are specific to zone timing:
- A scan launched after a route change reports the previous view, because change detection had not run to swap the router outlet.
- A CDK dialog or menu opened through the overlay never appears in the report, because it renders into the CDK overlay container appended to
document.body. - The settle wait times out on a route that looks idle, because a
setIntervalor long-poll keeps the zone perpetually unstable. - The
cdk-live-announcerregion’s messages are missing from the audited DOM, so status-message defects go unseen.
If the app has migrated to zoneless change detection with signals, the Testability queue behaves differently — fall back to the parent guide’s app-published flag pattern instead of the zone stability check below.
Minimal Reproducible Example
The naive scan navigates through the router and reads the DOM without waiting for Angular’s change-detection pass to run. Because Angular schedules change detection through the zone rather than synchronously, the router outlet still shows the old component when the engine looks.
# scan_angular_naive.py — scans before change detection swaps the outlet.
from playwright.async_api import async_playwright
async def scan(url):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(url, wait_until="load")
await page.click("a[routerLink='/reports']") # router starts navigating
# Angular has not yet run change detection to render <app-reports>.
# This scan captures the outgoing component, not the reports view.
await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return await page.evaluate("() => axe.run()")The click triggers a router navigation, but rendering the destination component happens on a later change-detection tick scheduled through zone.js. Reading the DOM synchronously after the click observes the router outlet before it has been swapped.
Correct Implementation
Wait on the Testability API, which Angular exposes precisely so test harnesses can block until the zone is stable — no application code change required. Combine it with a NavigationEnd marker so rescans are keyed to completed navigations, and fold the CDK overlay container and live-announcer region into scope.
# scan_angular.py — zone-stability-aware Angular scan.
from playwright.async_api import Page
STABLE_PREDICATE = """
() => {
// Angular publishes a testability per root element. whenStable resolves
// once the zone's microtask + macrotask queues are drained: change
// detection has run and no timers/XHR are pending in the zone.
const testabilities = window.getAllAngularTestabilities
? window.getAllAngularTestabilities() : [];
return testabilities.length > 0 && testabilities.every(t => t.isStable());
}
"""
async def scan_angular_route(page: Page, timeout_ms: int = 15000) -> dict:
"""Scan only once every Angular testability reports a stable zone."""
# 1. Block until the zone is stable — this is Angular's own readiness
# contract, the same one protractor/Playwright fixtures rely on.
await page.wait_for_function(STABLE_PREDICATE, timeout=timeout_ms)
# 2. Fold in CDK overlays. Angular Material dialogs, menus, and tooltips
# render into .cdk-overlay-container appended to <body>, outside the
# app root. Include it whenever it holds live overlay content.
overlay_open = await page.evaluate(
"() => !!document.querySelector('.cdk-overlay-container .cdk-overlay-pane *')"
)
include = [["app-root"]]
if overlay_open:
include.append([".cdk-overlay-container"])
# The live-announcer region carries status messages (SC 4.1.3); keep it
# in scope so aria-live defects are audited rather than skipped.
include.append([".cdk-live-announcer-element"])
await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return await page.evaluate(
"(ctx) => axe.run(ctx, {runOnly: {type: 'tag', values: ['wcag2a','wcag2aa','wcag22aa']}})",
{"include": include, "exclude": [["#intercom-frame"]]},
)
async def navigate_angular(page: Page, link_selector: str) -> dict:
"""Follow a routerLink and rescan once the zone restabilizes."""
# The click makes the zone unstable; wait_for_function inside
# scan_angular_route blocks until NavigationEnd's change detection settles.
await page.click(link_selector)
return await scan_angular_route(page)The key property is that isStable() returns false the instant the zone has pending work and true only once every queued task has drained — so a single predicate covers change detection completing, the router outlet swapping, and any in-zone XHR resolving. That is a far stronger guarantee than a fixed wait, and it is why folding an unstable background timer out of the zone matters so much, discussed in Gotchas.
Pipeline Integration Note
In a batched pipeline the zone-stability predicate lets Angular routes share the fleet-wide impact gate without special handling. The driver blocks on getAllAngularTestabilities().isStable(), scopes in the overlay and live-announcer, and emits the same payload shape as every other route, so it feeds the identical sharded aggregation and impact threshold covered in running Playwright accessibility checks in CI/CD. Which rules run and how a CDK dialog’s missing name maps to WCAG 4.1.2, or a live-announcer defect to 4.1.3, is set once in your axe-core enterprise configuration; this Angular handling only guarantees the stable, fully scoped DOM the engine reads.
Gotchas
- A background timer keeps the zone permanently unstable. A
setIntervalpolling loop or an RxJSintervalrunning inside the zone meansisStable()never returns true and every route times out. Run such work withNgZone.runOutsideAngular()so it does not enqueue zone tasks, or the scanner cannot distinguish a busy app from a broken one. - CDK overlays outlive their trigger and stack. Closing a dialog may leave a detached
.cdk-overlay-panein the container, and nested menus stack multiple panes. Scan only panes with live content and assert the expected overlay is present before trusting an empty-overlay result, so a stale pane is not mistaken for the real dialog. - Zoneless and hybrid apps break the Testability check. An app using
provideExperimentalZonelessChangeDetectionorNgZone: 'noop'has no stable-zone signal to wait on. Detect the mode and fall back to an application-published settled flag as described in the parent guide, rather than waiting forever on a testability that never destabilizes.
Frequently Asked Questions
What is the most reliable signal that an Angular view has finished rendering?
Zone stability, read through window.getAllAngularTestabilities() and checking that every testability reports isStable(). Angular exposes this API specifically so test harnesses can block until the zone’s task queues drain, which means change detection has run and no timers or XHR remain pending.
Why does my Angular Material dialog never show up in the scan?
CDK overlays render into .cdk-overlay-container, which the CDK appends to document.body outside your app-root, so an include scoped to the app never reaches them. Detect a live overlay pane at scan time and add the overlay container to the axe context so the dialog is audited.
My Angular route always times out on the stability wait. What is wrong?
Something is keeping the zone unstable, most often a setInterval, an RxJS interval, or a long-poll running inside the Angular zone. Move that work into NgZone.runOutsideAngular() so it stops enqueuing zone tasks, letting the queues drain and isStable() finally return true.
How do I audit live-announcer status messages?
Angular’s LiveAnnouncer writes into a .cdk-live-announcer-element region appended to the body. Keep that selector in the scan’s include scope so aria-live and status-message issues under SC 4.1.3 are evaluated rather than falling outside the app-root scope.