Skip to content
Draft
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
9 changes: 9 additions & 0 deletions docs/a11y_findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ Each row links to the WCAG criterion and the axe rule id (helpful when filing ti
| a2 | color-contrast | 1.4.3 (AA) | serious | Keycloak login (`opub-kc.civicdatalab.in/auth/realms/DataSpace/...`) | form labels / placeholder text | Foreground/background contrast below 4.5:1 for body text. Third-party (Keycloak) — see bug #5. | open (third-party) | 2026-05-08 |
| a3 | link-name | 4.1.2 (A) / 2.4.4 (A) | serious | Keycloak login (footer) | `<a>` containing only `<svg>` (4 social media links) | Icon-only links with no `aria-label` or visible text. Affects GitHub, LinkedIn, Twitter, Facebook links. Third-party — see bug #5. | open (third-party) | 2026-05-08 |
| a4 | heading-order / page-has-heading-one | 1.3.1 (A) | serious | `/dashboard/ai-maker/1` (auth) | `<main>` (no h1/h2/h3 anywhere) | No heading elements at all — the Overview widget that would host the page heading never mounts because its data fetch hangs indefinitely. Linked to bug #14 (and bug #13's backend root cause). | open | 2026-07-10 |
| a5 | color-contrast | 1.4.3 (AA) | serious | `/` (anonymous, hydrated) | `button.bg-[#E8E4FF]` | Active feature tab text #6849EE on #E8E4FF = 4.49:1. Linked to bug #31. | open | 2026-10-31 |
| a6 | link-name | 4.1.2 (A) / 2.4.4 (A) | serious | `/` (anonymous, hydrated) | `footer a.w-10.h-10` x4 | Footer social icons unnamed; only visible after hydration. Same as bug #9. | open | 2026-10-31 |
| a7 | (manual) name-role-value | 4.1.2 (A) / 1.4.1 (A) | serious | `/` | feature tab `<button>`s | Selected tab not exposed via aria-pressed/selected/current. Bug #32. | open | 2026-10-31 |
| a8 | (manual) language of parts | 3.1.2 (AA) | moderate | `/` | hero `h2 span.block` | Hindi/Odia/Assamese words with no `lang`. Bug #33. | open | 2026-10-31 |
| a9 | image-alt | 1.1.1 (A) | critical | `/api/auth/signin` | `img#provider-logo` | NextAuth provider logo has no alt. Bug #34. | open | 2026-10-31 |
| a10 | aria-dialog-name | 4.1.2 (A) | serious | `/` at 390px, menu open | `[role=dialog]` | Mobile menu sheet unnamed. Bug #38. | open | 2026-10-31 |
| a11 | button-name | 4.1.2 (A) | critical | `/` at 390px, menu open | `.Button-module_kindTertiary__p3qgr` | Close (X) button unnamed. Bug #39. | open | 2026-10-31 |
| a12 | (manual) focus order | 2.4.3 (A) | moderate | `/` at 390px | hamburger button | Focus drops to `<body>` after the menu closes. Bug #40. | open | 2026-10-31 |

## How to interpret

Expand All @@ -23,3 +31,4 @@ Each row links to the WCAG criterion and the axe rule id (helpful when filing ti

- **Phase 2 run 2026-05-08:** 11 a11y tests → 3 fail (1 flake fixed in test code, 2 real bugs filed → bugs #4, #5). 4 pass / 5 skip (env / no-creds) / 2 xfail / 0 fail after fixes.
- **Phase 7 run 2026-07-10:** 34 a11y tests (public + authenticated) → 1 fail, reproduced consistently (not flaky). Root-caused to dashboard overview widget hang; filed bug #14 / finding a4 and xfailed. 14 pass / 9 skip / 10 xfail / 0 fail after triage.
- **Phase 14 run 2026-10-31:** anonymous review of the hydrated homepage, sign-in page and mobile menu. a5-a12 added (bugs #31-#34, #38-#40). The pre-existing homepage axe test scans the pre-hydration skeleton, so a5/a6 were invisible to it.
13 changes: 12 additions & 1 deletion docs/app_bugs.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions locators/home_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class HomeLocators:
"[data-testid='mobile-menu-button']"
)
MOBILE_NAV_ITEMS = "nav a, [role='navigation'] a"
# The hamburger opens a Radix Sheet rendered as role="dialog".
MOBILE_MENU_DIALOG = "[role='dialog']"
# Icon-only (X) button inside the sheet: it holds an <svg> and no text.
MOBILE_MENU_CLOSE_BUTTON = "[role='dialog'] button:has(svg)"

# ── Hero section ──────────────────────────────────────────────────────────
# Include h2 as fallback — some builds promote h2 as the visual hero heading.
Expand Down Expand Up @@ -101,6 +105,15 @@ class HomeLocators:
"div.flex.flex-col.items-center:has(img)"
)

# ── Hydration markers ─────────────────────────────────────────────────────
# MainNav / HeroSection / FeatureTabs / MainFooter are loaded with
# next/dynamic({ ssr: false }), so at the `load` event the page is only a
# skeleton (no <h1>, no footer links). These three nodes only exist once the
# client bundles have mounted every section a whole-page scan should cover.
HYDRATED_HERO_H1 = "main h1"
HYDRATED_FOOTER_LINK = "footer a[href]"
HYDRATED_ACTIVE_TAB = "button[class*='E8E4FF']"

# ── Footer ────────────────────────────────────────────────────────────────
FOOTER = "footer"
SOCIAL_LINKS = (
Expand Down
5 changes: 5 additions & 0 deletions locators/login_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ class LoginLocators:
"[class*='provider']:has-text('Keycloak'), "
"[class*='social']:has-text('Keycloak')"
)
# NextAuth's built-in sign-in page renders the provider logo as
# <img id="provider-logo"> (+ a dark-mode twin). The page has no lang-less
# variants of this markup, so the id is a stable anchor.
PROVIDER_LOGO_IMAGES = "form img[id^='provider-logo']"
PROVIDER_SIGNIN_PATH = "/api/auth/signin"
114 changes: 114 additions & 0 deletions pages/home_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class HomePage(BasePage):
FOOTER = HomeLocators.FOOTER
SOCIAL_LINKS = HomeLocators.SOCIAL_LINKS
CIVICDATALAB_LINK = HomeLocators.CIVICDATALAB_LINK
MOBILE_MENU_DIALOG = HomeLocators.MOBILE_MENU_DIALOG
MOBILE_MENU_CLOSE_BUTTON = HomeLocators.MOBILE_MENU_CLOSE_BUTTON
HYDRATED_HERO_H1 = HomeLocators.HYDRATED_HERO_H1
HYDRATED_FOOTER_LINK = HomeLocators.HYDRATED_FOOTER_LINK
HYDRATED_ACTIVE_TAB = HomeLocators.HYDRATED_ACTIVE_TAB

def __init__(self, page: Page) -> None:
super().__init__(page)
Expand Down Expand Up @@ -83,6 +88,23 @@ def go_to_home(self) -> "HomePage":

return self

def wait_until_hydrated(self, timeout: int = 20_000) -> "HomePage":
"""Wait until every client-rendered homepage section has mounted.

`go_to_home()` returns at the `load` event, when only the server-rendered
skeleton exists. Whole-page scans (axe, language, tab state) must run
after hero, feature tabs and footer are in the DOM, otherwise they
inspect a page no user ever sees. Raises a Playwright TimeoutError that
names the missing marker rather than letting the caller scan a skeleton.
"""
for marker in (
self.HYDRATED_HERO_H1,
self.HYDRATED_ACTIVE_TAB,
self.HYDRATED_FOOTER_LINK,
):
self.page.locator(marker).first.wait_for(state="visible", timeout=timeout)
return self

def click_get_started(self) -> None:
self.click(self.GET_STARTED_BUTTON)

Expand Down Expand Up @@ -129,6 +151,61 @@ def get_all_tab_names(self) -> list[str]:
tabs = self.page.locator(self.TAB_BUTTON)
return [tabs.nth(i).inner_text().strip() for i in range(tabs.count())]

def get_tab_selected_flags(self) -> dict[str, bool]:
"""Map each feature-tab label to whether its *selected* state is exposed
programmatically (aria-selected / aria-pressed / aria-current), which is
what assistive technology reads. The lavender background is not enough."""
return self.page.locator(self.TAB_BUTTON).evaluate_all(
"""els => Object.fromEntries(els.map(e => [
e.innerText.trim(),
e.getAttribute('aria-selected') === 'true'
|| e.getAttribute('aria-pressed') === 'true'
|| ['true', 'page', 'step', 'location'].includes(e.getAttribute('aria-current'))
]))"""
)

def get_non_latin_text_without_own_language(self) -> list[str]:
"""Return rendered text nodes that contain Indic-script characters but whose
nearest `lang` attribute is still the page language (English).

WCAG 3.1.2 (Language of Parts): passages in another language need their
own `lang`, otherwise screen readers pronounce them with English rules.
"""
return self.page.evaluate(
r"""() => {
const indic = /[\u0900-\u0DFF]/;
const pageLang = (document.documentElement.lang || 'en').split('-')[0].toLowerCase();
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const el = n.parentElement;
if (!el || el.closest('script,style,noscript') || !indic.test(n.nodeValue)) continue;
const own = el.closest('[lang]');
const lang = own ? own.lang.split('-')[0].toLowerCase() : pageLang;
if (lang === pageLang) out.push(n.nodeValue.trim());
}
return out;
}"""
)

def get_horizontal_overflow(self) -> dict:
"""Document scroll width vs viewport width, plus the first few elements that
stick out past the right edge (WCAG 1.4.10 Reflow)."""
return self.page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
offenders: [...document.querySelectorAll('body *')]
.filter(e => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.right > window.innerWidth + 1
&& getComputedStyle(e).position !== 'fixed';
})
.slice(0, 5)
.map(e => e.tagName.toLowerCase() + '.' + String(e.className).slice(0, 40)),
})"""
)

def is_feature_content_visible(self) -> bool:
return self.is_visible(self.TAB_CONTENT)

Expand All @@ -143,6 +220,43 @@ def open_hamburger_menu(self) -> None:
def click_hamburger(self) -> None:
self.open_hamburger_menu()

def open_mobile_menu_and_wait(self) -> None:
"""Open the hamburger sheet and wait until its dialog is visible."""
self.page.locator(self.HAMBURGER_MENU).first.wait_for(state="visible")
self.open_hamburger_menu()
self.page.locator(self.MOBILE_MENU_DIALOG).first.wait_for(state="visible")

def get_mobile_menu_close_button_name(self) -> str:
"""Accessible name of the sheet's icon-only close button (empty if unnamed)."""
return self.page.locator(self.MOBILE_MENU_CLOSE_BUTTON).first.evaluate(
r"""el => {
const label = (el.getAttribute('aria-label') || '').trim();
if (label) return label;
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
const byRef = ids.map(id => (document.getElementById(id)?.textContent || '').trim())
.join(' ').trim();
return byRef || (el.textContent || '').trim() || (el.getAttribute('title') || '').trim();
}"""
)

def press_escape(self) -> None:
self.page.keyboard.press("Escape")

def is_mobile_menu_dialog_open(self, timeout: int = 3_000) -> bool:
"""True while the sheet's dialog is still visible after `timeout` ms."""
try:
self.page.locator(self.MOBILE_MENU_DIALOG).first.wait_for(
state="hidden", timeout=timeout
)
except Exception:
return True
return False

def is_hamburger_focused(self) -> bool:
return self.page.locator(self.HAMBURGER_MENU).first.evaluate(
"el => el === document.activeElement"
)

def is_mobile_menu_open(self) -> bool:
return self.is_visible(self.MOBILE_NAV_ITEMS, timeout=3_000)

Expand Down
13 changes: 13 additions & 0 deletions pages/login_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LoginPage(BasePage):
ERROR_MESSAGE = LoginLocators.ERROR_MESSAGE
FORM = LoginLocators.FORM
KEYCLOAK_PROVIDER_BUTTON = LoginLocators.KEYCLOAK_PROVIDER_BUTTON
PROVIDER_LOGO_IMAGES = LoginLocators.PROVIDER_LOGO_IMAGES

def __init__(self, page: Page) -> None:
super().__init__(page)
Expand Down Expand Up @@ -55,6 +56,18 @@ def click_provider_if_present(self, timeout: int = 3_000) -> bool:
return True
return False

def go_to_provider_signin(self) -> None:
"""Open NextAuth's provider-selection page directly (what every anonymous
deep link to a protected route lands on)."""
self.navigate_to_path(LoginLocators.PROVIDER_SIGNIN_PATH)
self.page.locator(self.KEYCLOAK_PROVIDER_BUTTON).first.wait_for(state="visible")

def get_provider_logo_alt_values(self) -> list[str | None]:
"""`alt` attribute of every provider logo image (None = attribute missing)."""
return self.page.locator(self.PROVIDER_LOGO_IMAGES).evaluate_all(
"els => els.map(e => e.getAttribute('alt'))"
)

def login(self, email: str, password: str) -> None:
"""Fill credentials and submit the login form."""
self.enter_email(email)
Expand Down
161 changes: 161 additions & 0 deletions tests/accessibility/test_accessibility_hydrated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""
Accessibility checks that run against the *hydrated* public homepage.

Why this file exists
--------------------
The homepage mounts its nav, hero, feature tabs and footer with
`next/dynamic({ ssr: false })`. `HomePage.go_to_home()` returns at the `load`
event, when the page is still a skeleton: no <h1>, no footer links, no tabs.
`test_accessibility.py::test_homepage_has_no_critical_axe_violations` therefore
scans that skeleton and passes, while the page a user actually sees has serious
axe violations (measured on dev, 2026-10-31: `color-contrast` x1 and
`link-name` x4). Every test below calls `wait_until_hydrated()` first.

Known defects are asserted as the *correct* behaviour under strict xfail and
are logged in docs/app_bugs.md; the suite goes red when one is fixed (XPASS).
"""

import json

import pytest
from playwright.sync_api import Page

from pages.home_page import HomePage

pytestmark = [pytest.mark.accessibility]

try:
from axe_playwright_python.sync_playwright import Axe
except ImportError: # pragma: no cover - requirements.txt installs it
Axe = None

_WCAG_AA_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]


def _serious_violations(page: Page) -> list[dict]:
"""Run axe (WCAG 2.0/2.1 A+AA rules) and return critical/serious violations."""
if Axe is None:
pytest.skip("axe-playwright-python not installed — run: pip install axe-playwright-python")
options = {"runOnly": {"type": "tag", "values": _WCAG_AA_TAGS}, "resultTypes": ["violations"]}
results = Axe().run(page, options=options).response
return [v for v in results.get("violations", []) if v.get("impact") in ("critical", "serious")]


def _summarise(violations: list[dict]) -> str:
return json.dumps(
[
{
"id": v["id"],
"impact": v["impact"],
"nodes": [n["target"] for n in v["nodes"][:5]],
}
for v in violations
],
indent=2,
)


class TestHydratedHomepageAxe:
# Rules that have their own dedicated test (or ledger row) and are therefore
# excluded here so one known defect cannot mask a different new one:
# link-name -> docs/app_bugs.md #9 (footer social icons)
# color-contrast -> docs/app_bugs.md #31 (active feature tab)
_TRACKED_ELSEWHERE = {"link-name", "color-contrast"}

def test_no_untracked_serious_axe_violations(self, page: Page):
"""The fully rendered homepage has no critical/serious WCAG 2.1 AA axe
violations beyond the two defects tracked in the ledger."""
HomePage(page).go_to_home().wait_until_hydrated()

unexpected = [
v for v in _serious_violations(page) if v["id"] not in self._TRACKED_ELSEWHERE
]
assert not unexpected, (
f"{len(unexpected)} new critical/serious axe violation(s) on the hydrated "
f"homepage:\n{_summarise(unexpected)}"
)

@pytest.mark.xfail(
strict=True,
reason="App bug #31: active feature tab text #6849EE on #E8E4FF is 4.49:1 (< 4.5:1) — see docs/app_bugs.md",
)
def test_color_contrast_meets_aa(self, page: Page):
"""Every rendered homepage text node meets the 4.5:1 AA contrast ratio (WCAG 1.4.3)."""
HomePage(page).go_to_home().wait_until_hydrated()

contrast = [v for v in _serious_violations(page) if v["id"] == "color-contrast"]
assert not contrast, (
"Insufficient colour contrast on the hydrated homepage:\n"
+ "\n".join(
f" {n['target']}: {n['any'][0]['message']}"
for v in contrast
for n in v["nodes"]
)
)


class TestHydratedHomepageFeatureTabs:
@pytest.mark.xfail(
strict=True,
reason="App bug #32: feature tabs are plain <button>s whose selected state is colour-only — see docs/app_bugs.md",
)
def test_selected_tab_is_exposed_to_assistive_tech(self, page: Page):
"""Selecting a feature tab exposes exactly that tab as selected (WCAG 4.1.2, 1.4.1).

The sibling `test_feature_tabs_have_aria_states` skips on this page
because there is no [role='tab']; this one asserts against the real
markup (buttons) so it cannot skip its way to green.
"""
home = HomePage(page).go_to_home().wait_until_hydrated()

for label in home.get_all_tab_names():
home.click_feature_tab(label)
flags = home.get_tab_selected_flags()
selected = [name for name, is_selected in flags.items() if is_selected]
assert selected == [label], (
f"After selecting the '{label}' tab, tabs exposed as selected via "
f"aria-selected/aria-pressed/aria-current: {selected or 'none'} "
f"(expected only '{label}')."
)


class TestHydratedHomepageLanguage:
@pytest.mark.xfail(
strict=True,
reason="App bug #33: Hindi/Odia/Assamese words in the hero sub-heading carry no lang attribute — see docs/app_bugs.md",
)
def test_non_english_passages_declare_their_language(self, page: Page):
"""Text in another script is marked up with its own lang (WCAG 3.1.2, GIGW 3.0)."""
home = HomePage(page).go_to_home().wait_until_hydrated()

untagged = home.get_non_latin_text_without_own_language()
assert not untagged, (
"Indic-script text rendered under the page language (en) with no lang attribute "
f"of its own: {untagged}"
)


@pytest.fixture
def narrow_page(browser) -> Page:
"""320 CSS px wide viewport — the WCAG 1.4.10 Reflow reference width."""
from utils.config import Config

context = browser.new_context(
viewport={"width": 320, "height": 640}, ignore_https_errors=True, locale="en-US"
)
context.set_default_timeout(Config.TIMEOUT)
yield context.new_page()
context.close()


class TestHydratedHomepageReflow:
def test_no_horizontal_scroll_at_320px(self, narrow_page: Page):
"""At 320px the whole hydrated homepage fits the viewport width (WCAG 1.4.10)."""
home = HomePage(narrow_page).go_to_home().wait_until_hydrated()

overflow = home.get_horizontal_overflow()
assert overflow["scrollWidth"] <= overflow["clientWidth"], (
f"Homepage scrolls horizontally at 320px: scrollWidth={overflow['scrollWidth']} > "
f"clientWidth={overflow['clientWidth']}; elements past the right edge: "
f"{overflow['offenders']}"
)
Loading
Loading