From e072b9d995516898e49136550705b4703dab4772 Mon Sep 17 00:00:00 2001 From: Saqib Date: Sat, 31 Oct 2026 11:34:41 +0000 Subject: [PATCH 01/15] test(a11y): scan the hydrated homepage with axe, track tab contrast defect --- locators/home_locators.py | 9 ++ pages/home_page.py | 20 ++++ .../test_accessibility_hydrated.py | 94 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 tests/accessibility/test_accessibility_hydrated.py diff --git a/locators/home_locators.py b/locators/home_locators.py index e5936d0..353fb2d 100644 --- a/locators/home_locators.py +++ b/locators/home_locators.py @@ -101,6 +101,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

, 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 = ( diff --git a/pages/home_page.py b/pages/home_page.py index e841ff3..5fac45e 100644 --- a/pages/home_page.py +++ b/pages/home_page.py @@ -32,6 +32,9 @@ class HomePage(BasePage): FOOTER = HomeLocators.FOOTER SOCIAL_LINKS = HomeLocators.SOCIAL_LINKS CIVICDATALAB_LINK = HomeLocators.CIVICDATALAB_LINK + 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) @@ -83,6 +86,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) diff --git a/tests/accessibility/test_accessibility_hydrated.py b/tests/accessibility/test_accessibility_hydrated.py new file mode 100644 index 0000000..ac13247 --- /dev/null +++ b/tests/accessibility/test_accessibility_hydrated.py @@ -0,0 +1,94 @@ +""" +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

, 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"] + ) + ) From 143d1d6cf617a5c1187903bd849f3bf4b688eac6 Mon Sep 17 00:00:00 2001 From: Saqib Date: Sat, 31 Oct 2026 11:35:49 +0000 Subject: [PATCH 02/15] test(a11y): feature tab selected state must be exposed programmatically --- pages/home_page.py | 13 ++++++++++ .../test_accessibility_hydrated.py | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pages/home_page.py b/pages/home_page.py index 5fac45e..7e8ab5c 100644 --- a/pages/home_page.py +++ b/pages/home_page.py @@ -149,6 +149,19 @@ 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 is_feature_content_visible(self) -> bool: return self.is_visible(self.TAB_CONTENT) diff --git a/tests/accessibility/test_accessibility_hydrated.py b/tests/accessibility/test_accessibility_hydrated.py index ac13247..9ace206 100644 --- a/tests/accessibility/test_accessibility_hydrated.py +++ b/tests/accessibility/test_accessibility_hydrated.py @@ -92,3 +92,28 @@ def test_color_contrast_meets_aa(self, page: Page): for n in v["nodes"] ) ) + + +class TestHydratedHomepageFeatureTabs: + @pytest.mark.xfail( + strict=True, + reason="App bug #32: feature tabs are plain