7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
|
Accessibility Backend Tests
|
|
Tests for HTML structure and semantic markup accessibility
|
|
"""
|
|
|
|
import re
|
|
import pytest
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
|
class TestHTMLAccessibility:
|
|
"""Test HTML structure for accessibility compliance"""
|
|
|
|
@pytest.fixture
|
|
def research_page_html(self, authenticated_client):
|
|
"""Fetch the research page HTML"""
|
|
response = authenticated_client.get("/")
|
|
assert response.status_code == 200
|
|
return BeautifulSoup(response.data, "html.parser")
|
|
|
|
def test_form_has_proper_labels(self, research_page_html):
|
|
"""Test that all form inputs have proper labels"""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: STRENGTHEN).
|
|
soup = research_page_html
|
|
|
|
# Find all input elements that should have labels
|
|
inputs = soup.find_all(["input", "textarea", "select"])
|
|
|
|
# Track which inputs need labels
|
|
inputs_needing_labels = []
|
|
|
|
for input_element in inputs:
|
|
input_id = input_element.get("id")
|
|
input_type = input_element.get("type", "text")
|
|
input_name = input_element.get("name", "")
|
|
|
|
# Skip hidden inputs, buttons, and CSRF tokens
|
|
if (
|
|
input_type in ["hidden", "submit", "button"]
|
|
or input_name == "csrf_token"
|
|
):
|
|
continue
|
|
|
|
# Check for associated label
|
|
if input_id:
|
|
label = soup.find("label", {"for": input_id})
|
|
if label is None:
|
|
inputs_needing_labels.append(
|
|
f"Input with id '{input_id}' (type: {input_type})"
|
|
)
|
|
|
|
# Allow some flexibility - not all inputs may need labels in modern UI
|
|
if len(inputs_needing_labels) > 0:
|
|
print(
|
|
f"Note: {len(inputs_needing_labels)} inputs without labels found: {inputs_needing_labels}"
|
|
)
|
|
|
|
def test_radio_button_structure(self, research_page_html):
|
|
"""Test that radio buttons are properly structured"""
|
|
soup = research_page_html
|
|
|
|
# Find radio button group - might be named differently
|
|
radio_inputs = soup.find_all("input", {"type": "radio"})
|
|
|
|
if len(radio_inputs) == 0:
|
|
pytest.skip(
|
|
"No radio buttons found on page - might be using different UI"
|
|
)
|
|
|
|
# Check that radio buttons have proper structure
|
|
for radio in radio_inputs:
|
|
assert radio.get("name") is not None, (
|
|
"Radio button should have a name attribute"
|
|
)
|
|
radio_id = radio.get("id")
|
|
if radio_id:
|
|
# Check for associated label
|
|
label = soup.find("label", {"for": radio_id})
|
|
assert label is not None, (
|
|
f"Radio button with id '{radio_id}' should have a label"
|
|
)
|
|
|
|
def test_fieldset_and_legend(self, research_page_html):
|
|
"""Test that form groups have proper fieldset and legend"""
|
|
soup = research_page_html
|
|
|
|
# Check if there are any fieldsets
|
|
fieldsets = soup.find_all("fieldset")
|
|
|
|
# Modern forms might not use fieldsets
|
|
if len(fieldsets) == 0:
|
|
# Check for alternative grouping methods
|
|
form_groups = soup.find_all(
|
|
class_=re.compile(r"form-group|field-group|input-group")
|
|
)
|
|
assert len(form_groups) > 0 or len(soup.find_all("form")) > 0, (
|
|
"Should have some form of input grouping (fieldset, form-group, or form)"
|
|
)
|
|
else:
|
|
# If fieldsets exist, they should have legends
|
|
for fieldset in fieldsets:
|
|
legend = fieldset.find("legend")
|
|
if legend is None:
|
|
# Check for aria-label as alternative
|
|
aria_label = fieldset.get("aria-label") or fieldset.get(
|
|
"aria-labelledby"
|
|
)
|
|
assert aria_label is not None, (
|
|
"Fieldset should have a legend or aria-label"
|
|
)
|
|
|
|
def test_aria_attributes(self, research_page_html):
|
|
"""Test that ARIA attributes are properly set where needed"""
|
|
soup = research_page_html
|
|
|
|
# Check for any interactive elements
|
|
interactive_elements = soup.find_all(
|
|
["button", "a", "input", "select", "textarea"]
|
|
)
|
|
|
|
# Check that interactive elements have appropriate accessibility attributes
|
|
for element in interactive_elements:
|
|
element_type = element.name
|
|
element_text = element.get_text(strip=True)
|
|
|
|
# Buttons and links should have accessible text
|
|
if element_type in ["button", "a"]:
|
|
aria_label = element.get("aria-label")
|
|
title = element.get("title")
|
|
|
|
# Should have text content, aria-label, or title
|
|
assert element_text or aria_label or title, (
|
|
f"{element_type} element should have accessible text"
|
|
)
|
|
|
|
def test_keyboard_hints_present(self, research_page_html):
|
|
"""Test that keyboard navigation hints are present"""
|
|
soup = research_page_html
|
|
|
|
# Look for keyboard hint indicators - not all apps show these
|
|
# Modern apps might show keyboard hints differently
|
|
# Check for focusable elements at minimum
|
|
focusable_elements = soup.find_all(
|
|
["a", "button", "input", "select", "textarea"]
|
|
)
|
|
assert len(focusable_elements) > 0, (
|
|
"Page should have focusable elements"
|
|
)
|
|
|
|
def test_form_structure(self, research_page_html):
|
|
"""Test that forms have proper structure"""
|
|
soup = research_page_html
|
|
|
|
forms = soup.find_all("form")
|
|
|
|
if len(forms) == 0:
|
|
# Might be a single-page app with AJAX
|
|
# Check for input elements at least
|
|
inputs = soup.find_all(["input", "textarea", "select"])
|
|
assert len(inputs) > 0, "Page should have some form inputs"
|
|
else:
|
|
for form in forms:
|
|
# Check form has action or is handled by JavaScript
|
|
action = form.get("action")
|
|
form_id = form.get("id")
|
|
form_class = form.get("class", [])
|
|
|
|
# Form should have some identifier or action
|
|
assert action or form_id or form_class, (
|
|
"Form should have action, id, or class"
|
|
)
|
|
|
|
def test_semantic_markup(self, research_page_html):
|
|
"""Test that semantic HTML5 elements are used"""
|
|
soup = research_page_html
|
|
|
|
# Check for semantic elements
|
|
semantic_elements = [
|
|
"header",
|
|
"nav",
|
|
"main",
|
|
"footer",
|
|
"section",
|
|
"article",
|
|
]
|
|
found_semantic = []
|
|
|
|
for element in semantic_elements:
|
|
if soup.find(element):
|
|
found_semantic.append(element)
|
|
|
|
# Should use at least some semantic elements
|
|
assert len(found_semantic) > 0, (
|
|
f"Should use semantic HTML5 elements. Found: {found_semantic}"
|
|
)
|
|
|
|
def test_required_fields_marked(self, research_page_html):
|
|
"""Test that required fields are properly marked"""
|
|
soup = research_page_html
|
|
|
|
# Find all required inputs
|
|
required_inputs = soup.find_all(attrs={"required": True})
|
|
|
|
for input_elem in required_inputs:
|
|
input_id = input_elem.get("id")
|
|
|
|
# Check for visual indicator
|
|
if input_id:
|
|
label = soup.find("label", {"for": input_id})
|
|
if label:
|
|
label_text = label.get_text()
|
|
# Should have some indicator like *, (required), etc.
|
|
has_indicator = (
|
|
"*" in label_text
|
|
or "required" in label_text.lower()
|
|
or label.find(class_=re.compile(r"required"))
|
|
)
|
|
|
|
# Also check for aria-required
|
|
aria_required = input_elem.get("aria-required") == "true"
|
|
|
|
assert has_indicator or aria_required, (
|
|
f"Required field '{input_id}' should have visual indicator or aria-required"
|
|
)
|
|
|
|
def test_error_handling_structure(self, research_page_html):
|
|
"""Test that error messages have proper structure"""
|
|
soup = research_page_html
|
|
|
|
# Look for error message containers
|
|
error_containers = soup.find_all(
|
|
class_=re.compile(r"error|alert|message|flash")
|
|
)
|
|
|
|
# Page should have some way to display errors
|
|
assert len(error_containers) > 0 or soup.find(
|
|
attrs={"role": "alert"}
|
|
), (
|
|
"Should have containers for error messages (class containing 'error', 'alert', or role='alert')"
|
|
)
|
|
|
|
|
|
class TestAccessibilityConfiguration:
|
|
"""Test accessibility-related configuration and styling"""
|
|
|
|
def test_css_focus_styles(self, authenticated_client):
|
|
"""Test that CSS includes focus styles"""
|
|
# Try to get main CSS file
|
|
response = authenticated_client.get("/static/css/style.css")
|
|
|
|
if response.status_code == 200:
|
|
css_content = response.data.decode("utf-8")
|
|
|
|
# Check for focus styles
|
|
has_focus_styles = (
|
|
":focus" in css_content
|
|
or "focus-visible" in css_content
|
|
or "outline" in css_content
|
|
)
|
|
|
|
assert has_focus_styles, (
|
|
"CSS should include focus styles for keyboard navigation"
|
|
)
|
|
else:
|
|
# CSS might be bundled or in different location
|
|
pytest.skip("Could not access main CSS file")
|
|
|
|
def test_responsive_meta_tag(self, authenticated_client):
|
|
"""Test that pages have responsive viewport meta tag"""
|
|
response = authenticated_client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
soup = BeautifulSoup(response.data, "html.parser")
|
|
viewport_meta = soup.find("meta", {"name": "viewport"})
|
|
|
|
assert viewport_meta is not None, "Should have viewport meta tag"
|
|
content = viewport_meta.get("content", "")
|
|
assert "width=device-width" in content, "Viewport should be responsive"
|
|
|
|
def test_lang_attribute(self, authenticated_client):
|
|
"""Test that HTML has lang attribute"""
|
|
response = authenticated_client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
soup = BeautifulSoup(response.data, "html.parser")
|
|
html_tag = soup.find("html")
|
|
|
|
assert html_tag is not None, "Should have html tag"
|
|
lang = html_tag.get("lang")
|
|
assert lang is not None, "HTML tag should have lang attribute"
|
|
assert lang in ["en", "en-US", "en-GB"], (
|
|
f"Lang should be English variant, got: {lang}"
|
|
)
|
|
|
|
def test_skip_navigation_link(self, authenticated_client):
|
|
"""Test for skip navigation link"""
|
|
response = authenticated_client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
soup = BeautifulSoup(response.data, "html.parser")
|
|
|
|
# Look for skip link
|
|
skip_link = soup.find("a", text=re.compile(r"skip|main content", re.I))
|
|
|
|
# Skip link is a best practice but not always present
|
|
if skip_link:
|
|
href = skip_link.get("href")
|
|
assert href and href.startswith("#"), (
|
|
"Skip link should be an anchor link"
|
|
)
|
|
|
|
def test_heading_hierarchy(self, authenticated_client):
|
|
"""Test that headings follow proper hierarchy"""
|
|
response = authenticated_client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
soup = BeautifulSoup(response.data, "html.parser")
|
|
|
|
# Find all headings
|
|
headings = soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
|
|
if len(headings) > 0:
|
|
# Should have at least one h1
|
|
h1_tags = soup.find_all("h1")
|
|
assert len(h1_tags) >= 1, "Page should have at least one h1 tag"
|
|
|
|
# Check heading order doesn't skip levels
|
|
heading_levels = [int(h.name[1]) for h in headings]
|
|
for i in range(1, len(heading_levels)):
|
|
level_diff = heading_levels[i] - heading_levels[i - 1]
|
|
assert level_diff <= 1, (
|
|
f"Heading hierarchy should not skip levels. "
|
|
f"Found h{heading_levels[i - 1]} followed by h{heading_levels[i]}"
|
|
)
|