Browse Source

feat(replays): Add accessibility issues endpoint (#57945)

Colton Allen 1 year ago
parent
commit
e4acb6d32f

+ 8 - 0
src/sentry/api/urls.py

@@ -117,6 +117,9 @@ from sentry.replays.endpoints.organization_replay_index import OrganizationRepla
 from sentry.replays.endpoints.organization_replay_selector_index import (
     OrganizationReplaySelectorIndexEndpoint,
 )
+from sentry.replays.endpoints.project_replay_accessibility_issues import (
+    ProjectReplayAccessibilityIssuesEndpoint,
+)
 from sentry.replays.endpoints.project_replay_clicks_index import ProjectReplayClicksIndexEndpoint
 from sentry.replays.endpoints.project_replay_details import ProjectReplayDetailsEndpoint
 from sentry.replays.endpoints.project_replay_recording_segment_details import (
@@ -2269,6 +2272,11 @@ PROJECT_URLS: list[URLPattern | URLResolver] = [
         ProjectReplayDetailsEndpoint.as_view(),
         name="sentry-api-0-project-replay-details",
     ),
+    re_path(
+        r"^(?P<organization_slug>[^/]+)/(?P<project_slug>[^\/]+)/replays/(?P<replay_id>[\w-]+)/accessibility-issues/$",
+        ProjectReplayAccessibilityIssuesEndpoint.as_view(),
+        name="sentry-api-0-project-replay-accessibility-issues",
+    ),
     re_path(
         r"^(?P<organization_slug>[^/]+)/(?P<project_slug>[^\/]+)/replays/(?P<replay_id>[\w-]+)/clicks/$",
         ProjectReplayClicksIndexEndpoint.as_view(),

+ 134 - 0
src/sentry/replays/endpoints/project_replay_accessibility_issues.py

@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import Any
+
+# import requests
+# from rest_framework.exceptions import ParseError
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import features
+from sentry.api.api_owners import ApiOwner
+from sentry.api.api_publish_status import ApiPublishStatus
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases.project import ProjectEndpoint
+from sentry.models.project import Project
+from sentry.replays.lib.storage import make_filename
+from sentry.replays.usecases.reader import fetch_direct_storage_segments_meta
+from sentry.utils.cursors import Cursor, CursorResult
+
+REFERRER = "replays.query.query_replay_clicks_dataset"
+
+logger = logging.getLogger()
+
+
+@region_silo_endpoint
+class ProjectReplayAccessibilityIssuesEndpoint(ProjectEndpoint):
+    owner = ApiOwner.REPLAY
+    publish_status = {
+        "GET": ApiPublishStatus.EXPERIMENTAL,
+    }
+
+    def get(self, request: Request, project: Project, replay_id: str) -> Response:
+        if not features.has(
+            "organizations:session-replay", project.organization, actor=request.user
+        ):
+            return Response(status=404)
+
+        try:
+            replay_id = str(uuid.UUID(replay_id))
+        except ValueError:
+            return Response(status=404)
+
+        def data_fn(offset, limit):
+            # We only support direct-storage.  Filestore is deprecated and should be removed from
+            # the driver.
+            segments = fetch_direct_storage_segments_meta(project.id, replay_id, offset, limit)
+
+            # Make a POST request to the replay-analyzer service. The files will be downloaded
+            # and evaluated on the remote system. The accessibility output is then redirected to
+            # the client.
+            return request_accessibility_issues([make_filename(segment) for segment in segments])
+
+        return self.paginate(
+            request=request,
+            paginator=ReplayAccessibilityPaginator(data_fn=data_fn),
+        )
+
+
+class ReplayAccessibilityPaginator:
+    """Replay Analyzer service paginator class."""
+
+    def __init__(self, data_fn):
+        self.data_fn = data_fn
+
+    def get_result(self, limit, cursor=None):
+        offset = cursor.offset if cursor is not None else 0
+
+        data = self.data_fn(offset=offset, limit=limit)
+
+        return CursorResult(
+            data,
+            hits=data["meta"]["total"],
+            prev=Cursor(0, max(0, offset - limit), True, offset > 0),
+            next=Cursor(0, max(0, offset + limit), False, False),
+        )
+
+
+def request_accessibility_issues(filenames: list[str]) -> Any:
+    # TODO: Remove this once the service is ready.
+    return [
+        {
+            "meta": {"total": 1},
+            "data": [
+                [
+                    {
+                        "elements": [
+                            {
+                                "alternatives": [
+                                    {
+                                        "id": "button-has-visible-text",
+                                        "message": "Element does not have inner text that is visible to screen readers",
+                                    },
+                                    {
+                                        "id": "aria-label",
+                                        "message": "aria-label attribute does not exist or is empty",
+                                    },
+                                    {
+                                        "id": "aria-labelledby",
+                                        "message": "aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty",
+                                    },
+                                    {
+                                        "id": "non-empty-title",
+                                        "message": "Element has no title attribute",
+                                    },
+                                    {
+                                        "id": "presentational-role",
+                                        "message": 'Element\'s default semantics were not overridden with role="none" or role="presentation"',
+                                    },
+                                ],
+                                "element": '<button class="svelte-19ke1iv">',
+                                "target": ["button:nth-child(1)"],
+                            }
+                        ],
+                        "help_url": "https://dequeuniversity.com/rules/axe/4.8/button-name?application=playwright",
+                        "help": "Buttons must have discernible text",
+                        "id": "button-name",
+                        "impact": "critical",
+                        "timestamp": 1695967678108,
+                    }
+                ]
+            ],
+        }
+    ]
+    # TODO: When the service is deploy this should be the primary path.
+    # try:
+    #     return requests.post(
+    #         "/api/0/analyze/accessibility",
+    #         json={"data": {"filenames": filenames}},
+    #     ).json()
+    # except Exception:
+    #     logger.exception("replay accessibility analysis failed")
+    #     raise ParseError("Could not analyze accessibility issues at this time.")

+ 86 - 0
tests/sentry/replays/test_project_replay_accessibility_issues.py

@@ -0,0 +1,86 @@
+import datetime
+from unittest.mock import patch
+from uuid import uuid4
+
+from django.urls import reverse
+
+from sentry.replays.testutils import mock_replay
+from sentry.testutils.cases import APITestCase, ReplaysSnubaTestCase
+from sentry.testutils.silo import region_silo_test
+
+REPLAYS_FEATURES = {"organizations:session-replay": True}
+
+
+@region_silo_test(stable=True)
+class OrganizationReplayDetailsTest(APITestCase, ReplaysSnubaTestCase):
+    endpoint = "sentry-api-0-project-replay-accessibility-issues"
+
+    def setUp(self):
+        super().setUp()
+        self.login_as(user=self.user)
+        self.replay_id = uuid4().hex
+        self.url = reverse(
+            self.endpoint, args=(self.organization.slug, self.project.slug, self.replay_id)
+        )
+
+    def test_feature_flag_disabled(self):
+        response = self.client.get(self.url)
+        assert response.status_code == 404
+
+    def test_invalid_uuid_404s(self):
+        with self.feature(REPLAYS_FEATURES):
+            url = reverse(self.endpoint, args=(self.organization.slug, self.project.slug, "abc"))
+            response = self.client.get(url)
+            assert response.status_code == 404
+
+    @patch(
+        "sentry.replays.endpoints.project_replay_accessibility_issues.request_accessibility_issues"
+    )
+    def test_get_replay_accessibility_issues(self, request_accessibility_issues):
+        request_accessibility_issues.return_value = {
+            "meta": {"total": 1},
+            "data": [
+                {
+                    "elements": [
+                        {
+                            "alternatives": [{"id": "button-has-visible-text", "message": "m"}],
+                            "element": '<button class="svelte-19ke1iv">',
+                            "target": ["button:nth-child(1)"],
+                        }
+                    ],
+                    "help_url": "url",
+                    "help": "Buttons must have discernible text",
+                    "id": "button-name",
+                    "impact": "critical",
+                    "timestamp": 1695967678108,
+                }
+            ],
+        }
+
+        replay_id = self.replay_id
+        seq1_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=10)
+        seq2_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=5)
+
+        self.store_replays(mock_replay(seq1_timestamp, self.project.id, replay_id, segment_id=0))
+        self.store_replays(mock_replay(seq2_timestamp, self.project.id, replay_id, segment_id=1))
+
+        with self.feature(REPLAYS_FEATURES):
+            response = self.client.get(self.url)
+
+            assert request_accessibility_issues.called
+            assert response.status_code == 200
+            assert response.has_header("X-Hits")
+
+            response_data = response.json()
+            assert response_data["meta"]["total"] == 1
+            assert len(response_data["data"]) == 1
+            assert "elements" in response_data["data"][0]
+            assert "help_url" in response_data["data"][0]
+            assert "help" in response_data["data"][0]
+            assert "id" in response_data["data"][0]
+            assert "impact" in response_data["data"][0]
+            assert "timestamp" in response_data["data"][0]
+            assert len(response_data["data"][0]["elements"]) == 1
+            assert "alternatives" in response_data["data"][0]["elements"][0]
+            assert "element" in response_data["data"][0]["elements"][0]
+            assert "target" in response_data["data"][0]["elements"][0]