144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
|
|
"""Accessibility (axe, light and dark), CSP and keyboard checks in a real browser."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from django.conf import settings
|
||
|
|
from django.urls import reverse
|
||
|
|
|
||
|
|
from intent.comments.services import start_feature_thread
|
||
|
|
|
||
|
|
from ..test_a11y import _pages, populated # noqa: F401 (fixture)
|
||
|
|
from .conftest import axe_violations, share_session
|
||
|
|
|
||
|
|
pytestmark = [pytest.mark.browser, pytest.mark.django_db(transaction=True)]
|
||
|
|
|
||
|
|
|
||
|
|
def _signed_out_pages(org, project, feature):
|
||
|
|
pk = {"org_slug": org.slug, "project_slug": project.slug}
|
||
|
|
return [
|
||
|
|
reverse("home"),
|
||
|
|
reverse("account_login"),
|
||
|
|
reverse("account_signup"),
|
||
|
|
reverse("orgs:detail", kwargs={"org_slug": org.slug}),
|
||
|
|
reverse("projects:home", kwargs=pk),
|
||
|
|
reverse("features:detail", kwargs={**pk, "feature_key": feature.key}),
|
||
|
|
reverse("issues:detail", kwargs={**pk, "number": 1}),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("scheme", ["light", "dark"])
|
||
|
|
def test_axe_signed_in(axe_page, client, live_server, owner, org, project, populated, scheme): # noqa: F811
|
||
|
|
share_session(client, axe_page, live_server, owner)
|
||
|
|
failures = {}
|
||
|
|
for path in _pages(org, project, populated):
|
||
|
|
axe_page.goto(path)
|
||
|
|
if violations := axe_violations(axe_page, scheme):
|
||
|
|
failures[path] = violations
|
||
|
|
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("scheme", ["light", "dark"])
|
||
|
|
def test_axe_signed_out(axe_page, org, project, populated, scheme): # noqa: F811
|
||
|
|
project.visibility, project.open_contribution = "public", True
|
||
|
|
project.save()
|
||
|
|
failures = {}
|
||
|
|
for path in _signed_out_pages(org, project, populated):
|
||
|
|
axe_page.goto(path)
|
||
|
|
if violations := axe_violations(axe_page, scheme):
|
||
|
|
failures[path] = violations
|
||
|
|
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_csp_violations_or_script_errors(page, sign_in, owner, org, project, populated): # noqa: F811
|
||
|
|
sign_in(owner)
|
||
|
|
for path in _pages(org, project, populated):
|
||
|
|
page.goto(path)
|
||
|
|
page.wait_for_load_state("networkidle")
|
||
|
|
assert not page.problems, "\n".join(page.problems)
|
||
|
|
|
||
|
|
|
||
|
|
def _wait_for_focus(page, selector, timeout_ms=3000):
|
||
|
|
# Not wait_for_function: it evaluates a string, which the page's CSP rightly forbids.
|
||
|
|
for _ in range(timeout_ms // 50):
|
||
|
|
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
|
||
|
|
return
|
||
|
|
page.wait_for_timeout(50)
|
||
|
|
raise AssertionError(f"Focus never reached {selector}")
|
||
|
|
|
||
|
|
|
||
|
|
def _tab_to(page, selector, limit=80):
|
||
|
|
"""Press Tab until `selector` has focus, like a keyboard user would."""
|
||
|
|
for _ in range(limit):
|
||
|
|
page.keyboard.press("Tab")
|
||
|
|
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
|
||
|
|
return
|
||
|
|
raise AssertionError(f"Couldn't reach {selector} with Tab")
|
||
|
|
|
||
|
|
|
||
|
|
def test_keyboard_only_line_comment_and_reply(page, sign_in, owner, open_project):
|
||
|
|
from intent.features.services import create_feature
|
||
|
|
|
||
|
|
feature = create_feature(open_project, owner, title="Sign in", description="One\n\nTwo\n")
|
||
|
|
thread = start_feature_thread(feature, owner, body="First")
|
||
|
|
sign_in(owner)
|
||
|
|
page.goto(
|
||
|
|
reverse(
|
||
|
|
"features:detail",
|
||
|
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": feature.key},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# The first Tab reaches the skip link, which moves focus into the main content.
|
||
|
|
page.keyboard.press("Tab")
|
||
|
|
assert page.evaluate("document.activeElement.className") == "skip-link"
|
||
|
|
page.keyboard.press("Enter")
|
||
|
|
assert page.evaluate("document.activeElement.id") == "main"
|
||
|
|
|
||
|
|
# Open the comment form under the second paragraph and post, all from the keyboard.
|
||
|
|
_tab_to(page, "#L3-3 summary")
|
||
|
|
page.keyboard.press("Enter")
|
||
|
|
page.keyboard.press("Tab")
|
||
|
|
assert page.evaluate("document.activeElement.id") == "body-L3-3"
|
||
|
|
page.keyboard.type("About two")
|
||
|
|
page.keyboard.press("Tab")
|
||
|
|
page.keyboard.press("Enter")
|
||
|
|
page.wait_for_selector("#L3-3 >> text=About two")
|
||
|
|
|
||
|
|
# Reply to the general thread with HTMX; focus lands on the new comment.
|
||
|
|
_tab_to(page, f"#reply-{thread.pk}")
|
||
|
|
page.keyboard.type("Keyboard reply")
|
||
|
|
page.keyboard.press("Tab")
|
||
|
|
page.keyboard.press("Enter")
|
||
|
|
page.wait_for_selector("text=Keyboard reply")
|
||
|
|
_wait_for_focus(page, ".comment")
|
||
|
|
assert "Keyboard reply" in page.evaluate("document.activeElement.textContent")
|
||
|
|
assert not page.problems, "\n".join(page.problems)
|
||
|
|
|
||
|
|
|
||
|
|
def test_theme_choice_applies_and_persists(page):
|
||
|
|
page.goto(reverse("home"))
|
||
|
|
page.select_option("#theme-select", "dark")
|
||
|
|
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
|
||
|
|
page.reload()
|
||
|
|
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
|
||
|
|
assert page.evaluate("getComputedStyle(document.documentElement).colorScheme") == "dark"
|
||
|
|
page.select_option("#theme-select", "system")
|
||
|
|
assert page.evaluate("document.documentElement.dataset.theme") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_admin_pages_work_under_csp(page, client, live_server, verified_admin, populated): # noqa: F811
|
||
|
|
verified_admin() # Signs `client` in with a verified device; the browser borrows it.
|
||
|
|
cookie = client.cookies[settings.SESSION_COOKIE_NAME].value
|
||
|
|
page.context.add_cookies(
|
||
|
|
[{"name": settings.SESSION_COOKIE_NAME, "value": cookie, "url": live_server.url}]
|
||
|
|
)
|
||
|
|
for name in [
|
||
|
|
"admin:index",
|
||
|
|
"admin:accounts_user_changelist",
|
||
|
|
"admin:orgs_organization_add",
|
||
|
|
"admin:issues_issue_changelist",
|
||
|
|
]:
|
||
|
|
page.goto(reverse(name))
|
||
|
|
page.wait_for_load_state("networkidle")
|
||
|
|
assert "/login/" not in page.url, name
|
||
|
|
assert not page.problems, "\n".join(page.problems)
|