diff --git a/a11y_report_generator.py b/a11y_report_generator.py new file mode 100644 index 0000000..8858a9e --- /dev/null +++ b/a11y_report_generator.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python +""" +Build ACCESSIBILITY_REPORT.md (and .html) from the findings written by the +accessibility suite into reports/a11y/. + +Run automatically by conftest.pytest_sessionfinish after an accessibility run, +or by hand: + + python a11y_report_generator.py + +The report is organised around the two checklists the suite is written against: + - WCAG 2.1 success criteria https://www.w3.org/TR/WCAG21/ + - The A11Y Project checklist https://www.a11yproject.com/checklist/ +""" + +import html +import json +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent +FINDINGS_DIR = REPO_ROOT / "reports" / "a11y" +MD_OUT = REPO_ROOT / "ACCESSIBILITY_REPORT.md" +HTML_OUT = REPO_ROOT / "ACCESSIBILITY_REPORT.html" + +STATUS_ICON = { + "pass": "โœ…", + "fail": "โŒ", + "not_implemented": "๐Ÿšง", + "skipped": "โญ๏ธ", +} + + +def load_findings() -> list: + if not FINDINGS_DIR.exists(): + return [] + out = [] + for f in sorted(FINDINGS_DIR.glob("*.json")): + try: + out.append(json.loads(f.read_text(encoding="utf-8"))) + except Exception as e: # a truncated file shouldn't kill the report + print(f"โš ๏ธ skipping unreadable finding {f.name}: {e}") + return out + + +def build_markdown(findings: list) -> str: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + by_status = defaultdict(list) + for f in findings: + by_status[f["status"]].append(f) + + passed = len(by_status["pass"]) + failed = len(by_status["fail"]) + pending = len(by_status["not_implemented"]) + total = len(findings) + + L = [] + L.append("# CivicDataSpace โ€” Accessibility Report") + L.append("") + L.append(f"_Generated {now}_") + L.append("") + L.append("Standards this suite is written against:") + L.append("") + L.append("- [WCAG 2.1](https://www.w3.org/TR/WCAG21/) โ€” Level A and AA") + L.append("- [The A11Y Project checklist](https://www.a11yproject.com/checklist/)") + L.append("- Accessibility Options widget specification (13 controls)") + L.append("") + + # โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + L.append("## Summary") + L.append("") + L.append("| Result | Count |") + L.append("|---|---:|") + L.append(f"| โœ… Passed | {passed} |") + L.append(f"| โŒ Failed | {failed} |") + L.append(f"| ๐Ÿšง Not implemented | {pending} |") + L.append(f"| **Total checks** | **{total}** |") + L.append("") + + if total == 0: + L.append("> No findings recorded. Run the suite first:") + L.append("> `pytest tests/accessibility -m accessibility`") + L.append("") + return "\n".join(L) + + if failed: + L.append(f"**{failed} check(s) failed.** See _Failures_ below.") + else: + L.append("**No conformance failures recorded.**") + L.append("") + + # โ”€โ”€ Failures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if by_status["fail"]: + L.append("## Failures") + L.append("") + for f in sorted(by_status["fail"], key=lambda x: (x["check"], x["page"])): + L.append(f"### โŒ {f['title']} โ€” `{f['page']}`") + L.append("") + L.append(f"- **WCAG:** {', '.join(f['wcag'])}") + L.append(f"- **A11Y Project:** {f['a11y_project']}") + detail = f.get("detail") or {} + if detail.get("url"): + L.append(f"- **Page:** `{detail['url']}`") + for p in detail.get("problems", []) or []: + L.append(f"- โš ๏ธ {p}") + if f.get("violations"): + L.append("") + L.append("| Rule | Impact | Nodes | WCAG tags | Help |") + L.append("|---|---|---:|---|---|") + for v in f["violations"]: + tags = ", ".join(v.get("tags", [])) or "โ€”" + L.append( + f"| `{v['id']}` | {v.get('impact') or 'โ€”'} | {v.get('node_count', 0)} " + f"| {tags} | [{v.get('help','')}]({v.get('helpUrl','')}) |" + ) + L.append("") + + # โ”€โ”€ Outstanding / not implemented โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if by_status["not_implemented"]: + L.append("## Outstanding โ€” not yet implemented") + L.append("") + L.append( + "These are **not test errors**. The functionality does not exist on the " + "deployment under test, so the checks are recorded as pending rather than " + "passed. They become live checks as soon as the feature ships." + ) + L.append("") + note = next( + (f["detail"].get("note") for f in by_status["not_implemented"] + if (f.get("detail") or {}).get("note")), + None, + ) + if note: + L.append(f"> {note}") + L.append("") + L.append("| Check | Item | WCAG |") + L.append("|---|---|---|") + for f in sorted(by_status["not_implemented"], key=lambda x: x["page"]): + detail = f.get("detail") or {} + item = detail.get("control") or f["page"] + L.append(f"| {f['title']} | {item} | {', '.join(f['wcag'])} |") + L.append("") + + # โ”€โ”€ Coverage by WCAG criterion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + L.append("## Coverage by WCAG 2.1 success criterion") + L.append("") + by_sc = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0, "skipped": 0}) + for f in findings: + for sc in f["wcag"]: + by_sc[sc][f["status"]] = by_sc[sc].get(f["status"], 0) + 1 + + L.append("| Success criterion | โœ… | โŒ | ๐Ÿšง |") + L.append("|---|---:|---:|---:|") + for sc in sorted(by_sc): + c = by_sc[sc] + L.append(f"| {sc} | {c['pass']} | {c['fail']} | {c['not_implemented']} |") + L.append("") + + # โ”€โ”€ Coverage by A11Y Project item โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + L.append("## Coverage by A11Y Project checklist item") + L.append("") + by_item = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0, "skipped": 0}) + for f in findings: + by_item[f["a11y_project"]][f["status"]] += 1 + + L.append("| Checklist item | โœ… | โŒ | ๐Ÿšง |") + L.append("|---|---:|---:|---:|") + for item in sorted(by_item): + c = by_item[item] + L.append(f"| {item} | {c['pass']} | {c['fail']} | {c['not_implemented']} |") + L.append("") + + # โ”€โ”€ Per-page matrix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + L.append("## Results by page") + L.append("") + pages = sorted({f["page"] for f in findings}) + checks = sorted({f["check"] for f in findings}) + matrix = {(f["page"], f["check"]): f["status"] for f in findings} + + L.append("| Page | " + " | ".join(checks) + " |") + L.append("|---" * (len(checks) + 1) + "|") + for page in pages: + row = [page] + for check in checks: + st = matrix.get((page, check)) + row.append(STATUS_ICON.get(st, "ยท") if st else "ยท") + L.append("| " + " | ".join(row) + " |") + L.append("") + L.append("Legend: โœ… pass ยท โŒ fail ยท ๐Ÿšง not implemented ยท ยท not applicable to this page") + L.append("") + + # โ”€โ”€ Passed detail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + L.append("## Passed checks") + L.append("") + L.append("| Check | Page | WCAG |") + L.append("|---|---|---|") + for f in sorted(by_status["pass"], key=lambda x: (x["check"], x["page"])): + L.append(f"| {f['title']} | `{f['page']}` | {', '.join(f['wcag'])} |") + L.append("") + + return "\n".join(L) + + +STATUS_LABEL = { + "pass": "Pass", + "fail": "Fail", + "not_implemented": "Not implemented", + "skipped": "Skipped", +} + +# axe impact -> badge class, for the per-violation rows inside a failure card. +IMPACT_CLASS = { + "critical": "sev-critical", + "serious": "sev-serious", + "moderate": "sev-moderate", + "minor": "sev-minor", +} + + +def _e(s) -> str: + """HTML-escape, tolerating None.""" + return html.escape(str(s)) if s is not None else "" + + +def _bar(passed: int, failed: int, pending: int) -> str: + """Inline three-segment progress bar (pass/fail/pending) for coverage rows.""" + total = passed + failed + pending + if total == 0: + return '
' + seg = lambda n, cls: f'
' if n else "" + return ( + '" + ) + + +def _violations_table(violations: list) -> str: + if not violations: + return "" + rows = [] + for v in violations: + impact = (v.get("impact") or "").lower() + badge_cls = IMPACT_CLASS.get(impact, "sev-minor") + tags = ", ".join(v.get("tags", [])) or "โ€”" + samples = v.get("sample_targets") or [] + sample_html = ( + "
".join(f'{_e(s)}' for s in samples[:3]) + if samples else "" + ) + help_url = v.get("helpUrl") or "#" + rows.append(f""" + + {_e(v.get('id'))} + {_e(v.get('impact') or 'โ€”')} + {v.get('node_count', 0)} + {_e(tags)} + {_e(v.get('help',''))} + {f'
{sample_html}
' if sample_html else ''} + """) + return f""" + + + {''.join(rows)} +
RuleImpactNodesWCAG tagsHelp
""" + + +def _failure_card(f: dict) -> str: + detail = f.get("detail") or {} + problems = detail.get("problems") or [] + page_url = detail.get("url") + bullets = "".join(f"
  • {_e(p)}
  • " for p in problems) + return f""" +
    +
    + FAIL +

    {_e(f['title'])}

    + {_e(f['page'])} +
    +
    +
    WCAG
    {_e(', '.join(f['wcag']))}
    +
    A11Y Project
    {_e(f['a11y_project'])}
    + {f'
    Page
    {_e(page_url)}
    ' if page_url else ''} +
    + {f'' if bullets else ''} + {_violations_table(f.get('violations') or [])} +
    """ + + +def build_html(findings: list) -> str: + """Self-contained HTML dashboard built directly from the findings data.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + by_status = defaultdict(list) + for f in findings: + by_status[f["status"]].append(f) + + passed = len(by_status["pass"]) + failed = len(by_status["fail"]) + pending = len(by_status["not_implemented"]) + total = len(findings) + pass_rate = (passed / total * 100) if total else 0 + + if total == 0: + body = """ +
    +

    No findings recorded yet.

    +

    Run the suite first: pytest tests/accessibility -m accessibility

    +
    """ + return _page_shell(now, body, sticky_nav="") + + # โ”€โ”€ Failures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + failures_html = "" + if by_status["fail"]: + cards = "".join( + _failure_card(f) + for f in sorted(by_status["fail"], key=lambda x: (x["check"], x["page"])) + ) + failures_html = f""" +
    +

    Failures {failed}

    + {cards} +
    """ + + # โ”€โ”€ Outstanding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + outstanding_html = "" + if by_status["not_implemented"]: + note = next( + (f["detail"].get("note") for f in by_status["not_implemented"] + if (f.get("detail") or {}).get("note")), + None, + ) + rows = "".join( + f"""{_e(f['title'])} + {_e((f.get('detail') or {}).get('control') or f['page'])} + {_e(', '.join(f['wcag']))}""" + for f in sorted(by_status["not_implemented"], key=lambda x: x["page"]) + ) + outstanding_html = f""" +
    +

    Outstanding โ€” not yet implemented {pending}

    +

    These are not test errors. The functionality does not + exist on the deployment under test, so the checks are recorded as pending rather + than passed. They become live checks as soon as the feature ships.

    + {f'
    {_e(note)}
    ' if note else ''} + + + {rows} +
    CheckItemWCAG
    +
    """ + + # โ”€โ”€ Coverage by WCAG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + by_sc = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0}) + for f in findings: + for sc in f["wcag"]: + by_sc[sc][f["status"]] = by_sc[sc].get(f["status"], 0) + 1 + sc_rows = "".join( + f"""{_e(sc)} + {c['pass']}{c['fail']} + {c['not_implemented']} + {_bar(c['pass'], c['fail'], c['not_implemented'])}""" + for sc, c in sorted(by_sc.items()) + ) + + # โ”€โ”€ Coverage by A11Y Project item โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + by_item = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0}) + for f in findings: + by_item[f["a11y_project"]][f["status"]] += 1 + item_rows = "".join( + f"""{_e(item)} + {c['pass']}{c['fail']} + {c['not_implemented']} + {_bar(c['pass'], c['fail'], c['not_implemented'])}""" + for item, c in sorted(by_item.items()) + ) + + # โ”€โ”€ Per-page matrix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + pages = sorted({f["page"] for f in findings}) + checks = sorted({f["check"] for f in findings}) + matrix = {(f["page"], f["check"]): f["status"] for f in findings} + STATUS_CELL = { + "pass": 'โœ“', + "fail": 'โœ•', + "not_implemented": 'โ€ฆ', + } + header_cells = "".join(f"{_e(c)}" for c in checks) + page_rows = "".join( + f"{_e(page)}" + + "".join( + f"{STATUS_CELL.get(matrix.get((page, check)), 'ยท')}" + for check in checks + ) + + "" + for page in pages + ) + + # โ”€โ”€ Passed checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + passed_rows = "".join( + f"""{_e(f['title'])}{_e(f['page'])} + {_e(', '.join(f['wcag']))}""" + for f in sorted(by_status["pass"], key=lambda x: (x["check"], x["page"])) + ) + + body = f""" +
    +
    +
    {passed}
    Passed
    +
    {failed}
    Failed
    +
    {pending}
    Not implemented
    +
    {total}
    Total checks
    +
    +
    +
    {_bar(passed, failed, pending)}
    + {pass_rate:.0f}% passing +
    +

    { + f'{failed} check(s) failed. See Failures below.' + if failed else 'No conformance failures recorded.' + }

    +
    + + {failures_html} + {outstanding_html} + +
    +

    Coverage by WCAG 2.1 success criterion

    + + + {sc_rows} +
    Success criterionโœ“โœ•โ€ฆ
    +
    + +
    +

    Coverage by A11Y Project checklist item

    + + + {item_rows} +
    Checklist itemโœ“โœ•โ€ฆ
    +
    + +
    +

    Results by page

    +
    + + {header_cells} + {page_rows} +
    Page
    +
    +

    + โœ“ pass   + โœ• fail   + โ€ฆ not implemented   + ยท not applicable +

    +
    + +
    +
    +

    Passed checks {passed}

    + + + {passed_rows} +
    CheckPageWCAG
    +
    +
    + """ + + nav = f""" + Failures {failed} + Outstanding {pending} + WCAG coverage + A11Y Project coverage + By page + Passed {passed} + """ + return _page_shell(now, body, sticky_nav=nav) + + +def _page_shell(generated_at: str, body: str, sticky_nav: str) -> str: + return f""" + + + + +CivicDataSpace โ€” Accessibility Report + + + + + {f'' if sticky_nav else ''} +
    + {body} +
    + +""" + + +_CSS = """ + :root { + color-scheme: light dark; + --bg: #ffffff; --fg: #1a1d23; --muted: #6b7280; --border: #e2e5ea; + --card-bg: #f8f9fb; --code-bg: #eef0f3; + --pass: #1a8f4c; --pass-bg: #e7f7ee; + --fail: #d1293d; --fail-bg: #fdeced; + --pending: #b8710a; --pending-bg: #fdf3e2; + --link: #2563eb; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #14161a; --fg: #e7e9ec; --muted: #9aa1ac; --border: #2a2e35; + --card-bg: #1b1e24; --code-bg: #232830; + --pass: #3ddc84; --pass-bg: #103822; + --fail: #ff6b7a; --fail-bg: #3a1418; + --pending: #f5b352; --pending-bg: #3a2a0d; + --link: #7aa2ff; + } + } + * { box-sizing: border-box; } + body { + background: var(--bg); color: var(--fg); margin: 0; + font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; } + a { color: var(--link); } + code, .mono { font: 12.5px/1.4 "SF Mono", Menlo, Consolas, monospace; } + code { background: var(--code-bg); padding: .1rem .35rem; border-radius: 4px; } + .small { font-size: 12.5px; } + .muted { color: var(--muted); } + + .page-header { + background: var(--card-bg); border-bottom: 1px solid var(--border); + padding: 1.5rem 1.25rem; text-align: center; + } + .page-header h1 { margin: 0 0 .25rem; font-size: 1.5rem; } + .generated, .standards { margin: .2rem 0; color: var(--muted); font-size: 13.5px; } + + .toc { + position: sticky; top: 0; z-index: 10; display: flex; gap: 1.25rem; + flex-wrap: wrap; background: var(--bg); border-bottom: 1px solid var(--border); + padding: .6rem 1.25rem; font-size: 13.5px; justify-content: center; + } + .toc a { text-decoration: none; color: var(--fg); display: inline-flex; align-items: center; gap: .3rem; } + .toc a:hover { color: var(--link); } + + h2 { font-size: 1.15rem; margin: 2rem 0 .75rem; display: flex; align-items: center; gap: .5rem; } + section { scroll-margin-top: 3rem; } + + .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin-top: 1rem; } + .stat-card { border: 1px solid var(--border); border-radius: 10px; padding: 1rem; text-align: center; background: var(--card-bg); } + .stat-num { font-size: 2rem; font-weight: 700; line-height: 1; } + .stat-label { color: var(--muted); font-size: 13px; margin-top: .35rem; } + .stat-card.pass .stat-num { color: var(--pass); } + .stat-card.fail .stat-num { color: var(--fail); } + .stat-card.pending .stat-num { color: var(--pending); } + + .pass-rate-row { display: flex; align-items: center; gap: .75rem; margin-top: 1rem; } + .pass-rate-bar { flex: 1; } + .pass-rate-label { font-size: 13px; color: var(--muted); white-space: nowrap; } + .verdict { margin-top: .75rem; } + .fail-text { color: var(--fail); } + .pass-text { color: var(--pass); } + + .bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); width: 100%; min-width: 60px; } + .bar-pass { background: var(--pass); } + .bar-fail { background: var(--fail); } + .bar-pending { background: var(--pending); } + .bar-empty { background: var(--border); width: 100%; } + + .count-pill { + display: inline-block; min-width: 1.4em; text-align: center; padding: .05rem .45rem; + border-radius: 999px; font-size: 12.5px; font-weight: 600; + } + .count-pill.pass { background: var(--pass-bg); color: var(--pass); } + .count-pill.fail { background: var(--fail-bg); color: var(--fail); } + .count-pill.pending { background: var(--pending-bg); color: var(--pending); } + + .badge { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; } + .badge.sev-fail { background: var(--fail-bg); color: var(--fail); } + .badge.sev-critical { background: var(--fail-bg); color: var(--fail); } + .badge.sev-serious { background: var(--pending-bg); color: var(--pending); } + .badge.sev-moderate { background: var(--code-bg); color: var(--muted); } + .badge.sev-minor { background: var(--code-bg); color: var(--muted); } + + .card { border: 1px solid var(--border); border-left: 4px solid var(--fail); border-radius: 10px; + background: var(--card-bg); padding: 1rem 1.1rem; margin-bottom: 1rem; } + .card header { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } + .card h3 { margin: 0; font-size: 1rem; flex: 1; min-width: 200px; } + .page-chip { background: var(--code-bg); border-radius: 6px; padding: .1rem .5rem; font-size: 12px; font-family: monospace; } + + .meta { display: grid; grid-template-columns: max-content 1fr; gap: .2rem .75rem; margin: .6rem 0; font-size: 13.5px; } + .meta dt { color: var(--muted); } + .meta dd { margin: 0; } + + .problems { margin: .5rem 0 0; padding-left: 1.2rem; font-size: 13.5px; } + .problems li { margin: .2rem 0; } + + table { border-collapse: collapse; width: 100%; margin: .5rem 0; } + table.plain th, table.plain td, table.violations th, table.violations td, table.matrix th, table.matrix td { + border: 1px solid var(--border); padding: .45rem .6rem; text-align: left; vertical-align: top; + } + table th { background: var(--card-bg); font-weight: 600; font-size: 13px; } + /* .cov = the two coverage-by-criterion tables only (Check/Item text tables + must NOT get this narrow right-aligned numeric-column treatment). */ + table.cov td.num, table.cov th:nth-child(2), table.cov th:nth-child(3), table.cov th:nth-child(4) { text-align: right; width: 3rem; } + table.cov th:first-child, table.cov td:first-child { width: auto; } + table.cov td:last-child { width: 140px; } + /* Outstanding/Passed tables are 3 columns (Check/Item/WCAG); explicit + proportions since content length gives the browser's auto layout no + useful signal โ€” a long WCAG list otherwise gets squeezed to ~15% width + and wraps to 5+ lines per row. */ + table.plain:not(.cov) { table-layout: fixed; } + table.plain:not(.cov) th:nth-child(1), table.plain:not(.cov) td:nth-child(1) { width: 52%; } + table.plain:not(.cov) th:nth-child(2), table.plain:not(.cov) td:nth-child(2) { width: 16%; } + table.plain:not(.cov) th:nth-child(3), table.plain:not(.cov) td:nth-child(3) { width: 32%; font-size: 12.5px; } + table.violations { margin-top: .75rem; font-size: 13px; } + + .table-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } + table.matrix { margin: 0; font-size: 13px; } + table.matrix th:first-child, table.matrix td:first-child { position: sticky; left: 0; background: var(--card-bg); text-align: left; } + table.matrix th { white-space: nowrap; } + table.matrix td { text-align: center; } + .cell { display: inline-flex; align-items: center; justify-content: center; width: 1.4em; height: 1.4em; border-radius: 5px; font-weight: 700; } + .cell.pass { background: var(--pass-bg); color: var(--pass); } + .cell.fail { background: var(--fail-bg); color: var(--fail); } + .cell.pending { background: var(--pending-bg); color: var(--pending); } + .cell.na { color: var(--muted); } + .legend { font-size: 13px; color: var(--muted); margin-top: .5rem; } + + .note { color: var(--muted); font-size: 13.5px; } + blockquote { border-left: 3px solid var(--pending); margin: .75rem 0; padding: .3rem .9rem; background: var(--pending-bg); border-radius: 0 8px 8px 0; font-size: 13.5px; } + + details summary { cursor: pointer; } + details[open] summary { margin-bottom: .5rem; } + + .empty { text-align: center; padding: 4rem 1rem; color: var(--muted); } +""" + + +def main(): + findings = load_findings() + md = build_markdown(findings) + MD_OUT.write_text(md, encoding="utf-8") + HTML_OUT.write_text(build_html(findings), encoding="utf-8") + + failed = sum(1 for f in findings if f["status"] == "fail") + pending = sum(1 for f in findings if f["status"] == "not_implemented") + print(f"โœ”๏ธ ACCESSIBILITY_REPORT.md generated ({len(findings)} findings, " + f"{failed} failed, {pending} not implemented)") + print(f"โœ”๏ธ ACCESSIBILITY_REPORT.html generated") + + +if __name__ == "__main__": + main() diff --git a/conftest.py b/conftest.py index 10800a7..d6ddd4c 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/locators/accessibility_locators.py b/locators/accessibility_locators.py new file mode 100644 index 0000000..fb05aa1 --- /dev/null +++ b/locators/accessibility_locators.py @@ -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