Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
668 changes: 668 additions & 0 deletions a11y_report_generator.py

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,13 @@ def pytest_sessionfinish(session, exitstatus):
else:
print("\n\n⚠️ report.json not found; skipping report generation.")

# Accessibility runs leave structured findings behind; turn them into
# ACCESSIBILITY_REPORT.md/.html. Skipped when the run had no a11y tests so
# an unrelated run never overwrites the last accessibility report.
if any((Path(os.getcwd()) / "reports" / "a11y").glob("*.json")):
print("\n📄 Generating ACCESSIBILITY_REPORT.md + .html …")
subprocess.run([sys.executable, "a11y_report_generator.py"], check=False)

# --- pending_pr: tests written for an open product PR run only once it merges ---
import functools as _functools
import json as _json
Expand Down
90 changes: 90 additions & 0 deletions locators/accessibility_locators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# locators/accessibility_locators.py
"""
Locators for the "Accessibility Options" widget.

NOTE: as of 2026-08-24 this widget is not present on either
dev.civicdataspace.in or civicdataspace.in. These locators describe the agreed
target design, and the tests in tests/accessibility/test_a11y_006_widget.py act
as its acceptance criteria — they report NOT IMPLEMENTED until it ships.

Each control is matched by its visible label plus common alternatives, so the
tests keep working whether the implementation renders <button>, <a role=button>
or an <input> toggle.
"""

from selenium.webdriver.common.by import By


def _control(*labels: str) -> tuple:
"""Match a widget control by any of its accepted visible/accessible names."""
conditions = []
for label in labels:
norm = label.lower().replace(" ", "")
conditions.append(
"translate(normalize-space(.), "
"'ABCDEFGHIJKLMNOPQRSTUVWXYZ ', 'abcdefghijklmnopqrstuvwxyz')"
f"={norm!r}"
)
conditions.append(
"translate(normalize-space(@aria-label), "
"'ABCDEFGHIJKLMNOPQRSTUVWXYZ ', 'abcdefghijklmnopqrstuvwxyz')"
f"={norm!r}"
)
predicate = " or ".join(conditions)
return (
By.XPATH,
f"//*[self::button or self::a or self::input or @role='button'][{predicate}]",
)


class AccessibilityWidgetLocators:
# Widget container / launcher
PANEL = (
By.XPATH,
"//*[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',"
" 'abcdefghijklmnopqrstuvwxyz'), 'accessibility options')]",
)
LAUNCHER = (
By.XPATH,
"//*[self::button or self::a or @role='button']"
"[contains(translate(normalize-space(@aria-label),"
" 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accessibility')]",
)

# Text sizing
LARGE_TEXT = _control("Large Text", "Increase Text", "Bigger Text")
SMALL_TEXT = _control("Small Text", "Decrease Text", "Smaller Text")
RESET_TEXT = _control("Reset Text")

# Visual presentation
LIGHT_DARK = _control("Light/Dark", "Light Dark", "Toggle Theme", "Dark Mode")
GRAYSCALE = _control("Grayscale", "Greyscale")
HIGH_CONTRAST = _control("High Contrast")
DYSLEXIA = _control("Dyslexia", "Dyslexia Friendly", "Dyslexic Font")

# Speech
READ_ALOUD = _control("Read Aloud")
SPEAK = _control("Speak")
SPEED_UP = _control("Speed Up")
SPEED_DOWN = _control("Speed Down")
RESET_SPEED = _control("Reset Speed")
WHOLE_PAGE = _control("Whole Page")


# Control inventory used to drive the parametrised widget tests.
# (id, locator attribute name, human label, what it must demonstrably change)
WIDGET_CONTROLS = [
("large-text", "LARGE_TEXT", "Large Text", "root font size increases"),
("small-text", "SMALL_TEXT", "Small Text", "root font size decreases"),
("reset-text", "RESET_TEXT", "Reset Text", "root font size returns to default"),
("light-dark", "LIGHT_DARK", "Light/Dark", "background/foreground colours invert"),
("grayscale", "GRAYSCALE", "Grayscale", "a grayscale filter is applied"),
("high-contrast", "HIGH_CONTRAST", "High Contrast", "contrast ratio increases"),
("dyslexia", "DYSLEXIA", "Dyslexia", "a dyslexia-friendly font is applied"),
("read-aloud", "READ_ALOUD", "Read Aloud", "speech synthesis starts"),
("speak", "SPEAK", "Speak", "speech synthesis starts"),
("speed-up", "SPEED_UP", "Speed Up", "speech rate increases"),
("speed-down", "SPEED_DOWN", "Speed Down", "speech rate decreases"),
("reset-speed", "RESET_SPEED", "Reset Speed", "speech rate returns to default"),
("whole-page", "WHOLE_PAGE", "Whole Page", "read-aloud scope covers the page"),
]
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ markers =
mobile: viewport/mobile-specific tests
api: REST and GraphQL API tests (no browser required)
seo: sitemap/robots.txt/analytics regression checks
accessibility: WCAG 2.1 AA / A11Y Project checks (axe-core + keyboard + structure)
widget: Accessibility Options widget acceptance tests
readonly: verified to make no writes; the ONLY tests allowed to run against production. Opt-in per file, never by default.
timeout: limit test duration (requires pytest-timeout)
Empty file added tests/accessibility/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions tests/accessibility/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Fixtures and page inventory for the accessibility suite.

The root conftest.py supplies `driver`, `base_url`, `test_credentials` and the
provider login fixtures; this file only adds what the a11y tests need on top.
"""

import pytest

from utils import a11y

# ─── Page inventory ────────────────────────────────────────────────────────────
#
# Public routes verified live against dev.civicdataspace.in. Kept as (id, path)
# so parametrised test ids read as the page name rather than "page0/page1".

PUBLIC_PAGES = [
("home", "/"),
("datasets", "/datasets"),
("sectors", "/sectors"),
("usecases", "/usecases"),
("collaboratives", "/collaboratives"),
("publishers", "/publishers"),
("about-us", "/about-us"),
]

# Provider sections live under /dashboard/<entityType>/<slug>/<path>. The slug
# differs per account, so tests derive the base from the logged-in URL.
PROVIDER_SECTIONS = [
("datasets", "dataset"),
("usecases", "usecases"),
("charts", "charts"),
("profile", "profile"),
]


@pytest.fixture
def goto(driver, base_url):
"""Navigate to a public path and wait for the app shell to settle.

Next.js streams content in, so a bare driver.get() can hand back a page
whose main region is still a loading placeholder — scanning that produces
meaningless axe results.
"""
from selenium.webdriver.support.ui import WebDriverWait

def _goto(path: str):
url = base_url.rstrip("/") + path
driver.get(url)
try:
WebDriverWait(driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
WebDriverWait(driver, 30).until(
lambda d: not d.execute_script(
"return /^\\s*Loading\\s*$/i.test((document.body.innerText||'').trim())"
)
)
except Exception:
# A slow/degraded backend shouldn't turn into a confusing timeout
# deep inside a scan; let the test assert on what actually rendered.
pass
return url

return _goto


@pytest.fixture(scope="session", autouse=True)
def _reset_a11y_findings(request):
"""Clear stale findings once per session, on the controller only.

Under xdist each worker also runs session fixtures; if every worker cleared
the directory they would delete each other's findings mid-run.
"""
if not hasattr(request.config, "workerinput"):
a11y.clear_findings()
return None
65 changes: 65 additions & 0 deletions tests/accessibility/test_a11y_001_axe_public.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Automated axe-core WCAG 2.1 AA scans across the public (unauthenticated) pages.

One test per page so a failure names the page, and so a single bad page doesn't
mask the rest. Only critical/serious violations fail the test; minor/moderate
findings are still recorded and appear in ACCESSIBILITY_REPORT.md.

A11Y Project: "Run an automated accessibility checker"
WCAG 2.1: broad — each violation carries its own success-criterion tags.
"""

import pytest

from utils import a11y
from .conftest import PUBLIC_PAGES

pytestmark = [pytest.mark.accessibility]


@pytest.mark.parametrize("page_id,path", PUBLIC_PAGES, ids=[p[0] for p in PUBLIC_PAGES])
def test_public_page_has_no_critical_axe_violations(driver, goto, page_id, path):
"""Each public page must pass an axe WCAG 2.1 A/AA scan cleanly."""
goto(path)

results = a11y.run_axe(driver)
blocking = a11y.blocking_violations(results)
all_violations = results.get("violations", [])

a11y.record(
check="axe-scan",
page=page_id,
status="pass" if not blocking else "fail",
detail={
"url": path,
"axe_version": results.get("testEngine", {}).get("version"),
"passes": len(results.get("passes", [])),
"violations_total": len(all_violations),
"violations_blocking": len(blocking),
"incomplete": len(results.get("incomplete", [])),
},
violations=all_violations,
)

assert not blocking, (
f"{page_id} ({path}) has {len(blocking)} critical/serious "
f"accessibility violation(s):\n {a11y.summarize_violations(blocking)}"
)


@pytest.mark.parametrize("page_id,path", PUBLIC_PAGES, ids=[p[0] for p in PUBLIC_PAGES])
def test_public_page_axe_scan_actually_ran(driver, goto, page_id, path):
"""Guard against a vacuously green scan.

An axe run against a blank or error page returns zero violations and looks
like a pass. Requiring a healthy number of passed rules proves real content
was present and evaluated.
"""
goto(path)
results = a11y.run_axe(driver)

passes = len(results.get("passes", []))
assert passes >= 5, (
f"{page_id} ({path}): axe only evaluated {passes} passing rule(s), which "
"suggests the page did not render. Scan results for this page cannot be trusted."
)
99 changes: 99 additions & 0 deletions tests/accessibility/test_a11y_002_axe_authenticated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
axe-core scans behind login — the provider dashboard.

These are the densest, most form-heavy screens in the product and the ones a
public scan can never reach, so they get their own module. They reuse the root
conftest's `provider_dashboard` fixture for login.

WCAG 2.1 A/AA. A11Y Project: "Run an automated accessibility checker" (authenticated views)
"""

import re
from urllib.parse import urlparse

import pytest
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support.ui import WebDriverWait

from utils import a11y
from .conftest import PROVIDER_SECTIONS

pytestmark = [pytest.mark.accessibility]

# https://host[/locale]/dashboard/<entityType>/<slug>
_ENTITY_ROOT = re.compile(r"^https?://[^/]+(?:/[a-z]{2})?/dashboard/[^/]+/[^/?#]+")


def _scan_and_record(driver, page_id: str, url_hint: str):
results = a11y.run_axe(driver)
blocking = a11y.blocking_violations(results)
a11y.record(
check="axe-scan",
page=f"provider-{page_id}",
status="pass" if not blocking else "fail",
detail={
"url": url_hint,
"authenticated": True,
"axe_version": results.get("testEngine", {}).get("version"),
"passes": len(results.get("passes", [])),
"violations_total": len(results.get("violations", [])),
"violations_blocking": len(blocking),
},
violations=results.get("violations", []),
)
return blocking


def _wait_for_content(driver):
WebDriverWait(driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
# Let the page's data query resolve so the scan sees real content.
try:
WebDriverWait(driver, 20).until(
lambda d: not d.execute_script(
"return /^\\s*Loading\\s*$/i.test((document.body.innerText||'').trim())"
)
)
except TimeoutException:
pass


def test_provider_dashboard_has_no_critical_axe_violations(provider_dashboard, driver):
"""The User Dashboard hub (/dashboard: My Dashboard + organisation cards) must scan clean."""
u = urlparse(driver.current_url)
driver.get(f"{u.scheme}://{u.netloc}/dashboard")
WebDriverWait(driver, 20).until(lambda d: urlparse(d.current_url).path.rstrip("/").endswith("/dashboard"))
_wait_for_content(driver)
blocking = _scan_and_record(driver, "dashboard", driver.current_url)
assert not blocking, (
f"Provider dashboard has {len(blocking)} critical/serious violation(s):\n "
f"{a11y.summarize_violations(blocking)}"
)


@pytest.mark.parametrize(
"section_id,path", PROVIDER_SECTIONS, ids=[s[0] for s in PROVIDER_SECTIONS]
)
def test_provider_section_has_no_critical_axe_violations(
provider_dashboard, driver, section_id, path
):
"""Each provider dashboard section must scan clean.

Navigates by URL: a JS click on the sidebar never left the datasets view, so
every section used to scan the same page (/dashboard/self/<slug>/dataset).
"""
root = _ENTITY_ROOT.match(driver.current_url)
assert root, f"Not on an entity dashboard after login: {driver.current_url}"
driver.get(f"{root.group(0)}/{path}")
WebDriverWait(driver, 20).until(
lambda d: urlparse(d.current_url).path.rstrip("/").endswith(f"/{path}"),
message=f"Did not land on the {path!r} section (at {driver.current_url})",
)
_wait_for_content(driver)

blocking = _scan_and_record(driver, section_id, driver.current_url)
assert not blocking, (
f"Provider '{section_id}' section has {len(blocking)} critical/serious "
f"violation(s):\n {a11y.summarize_violations(blocking)}"
)
Loading
Loading