"""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)