Browse Source

test(replay): Create Acceptance tests for Replay Details and List pages (#38724)

Can run tests individually with ` pytest tests/acceptance/test_replay_detail.py --no-headless=true`

More Testing Tips: https://develop.sentry.dev/testing/#acceptance-tests
Ryan Albrecht 2 years ago
parent
commit
0e19363d7d

+ 159 - 0
src/sentry/replays/testutils.py

@@ -1,9 +1,26 @@
 import datetime
 import typing
+from enum import Enum
 
 from sentry.utils import json
 
 
+# This __must__ match the EventType enum in RRWeb, for the version of rrweb that we are using.
+# https://github.com/rrweb-io/rrweb/blob/master/packages/rrweb/src/types.ts#L18-L26
+class EventType(Enum):
+    DomContentLoaded = 0
+    Load = 1
+    FullSnapshot = 2
+    IncrementalSnapshot = 3
+    Meta = 4
+    Custom = 5
+    Plugin = 6
+
+
+SegmentList = typing.Iterable[typing.Dict[str, typing.Any]]
+RRWebNode = typing.Dict[str, typing.Any]
+
+
 def assert_expected_response(
     response: typing.Dict[str, typing.Any], expected_response: typing.Dict[str, typing.Any]
 ) -> None:
@@ -168,3 +185,145 @@ def mock_replay(
             )
         ),
     }
+
+
+def mock_segment_init(
+    timestamp: datetime.datetime,
+    href: str = "http://localhost/",
+    width: int = 800,
+    height: int = 600,
+) -> SegmentList:
+    return [
+        {
+            "type": EventType.DomContentLoaded,
+            "timestamp": int(timestamp.timestamp()),
+        },
+        {
+            "type": EventType.Load,
+            "timestamp": int(timestamp.timestamp()),
+        },
+        {
+            "type": EventType.Meta,
+            "data": {"href": href, "width": width, "height": height},
+            "timestamp": int(timestamp.timestamp()),
+        },
+    ]
+
+
+def mock_segment_fullsnapshot(timestamp: datetime.datetime, bodyChildNodes) -> SegmentList:
+    bodyNode = mock_rrweb_node(
+        tagName="body",
+        attributes={
+            "style": 'margin:0; font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu;',
+        },
+        childNodes=bodyChildNodes,
+    )
+    htmlNode = mock_rrweb_node(
+        tagName="html",
+        childNodes=[bodyNode],
+    )
+    return [
+        {
+            "type": EventType.FullSnapshot,
+            "data": {
+                "timestamp": int(timestamp.timestamp()),
+                "node": {
+                    "type": EventType.DomContentLoaded,
+                    "childNodes": [htmlNode],
+                },
+            },
+        }
+    ]
+
+
+def mock_segment_console(timestamp: datetime.datetime) -> SegmentList:
+    return [
+        {
+            "type": EventType.Custom,
+            "timestamp": int(timestamp.timestamp()),
+            "data": {
+                "tag": "breadcrumb",
+                "payload": {
+                    "timestamp": int(timestamp.timestamp()) / 1000,
+                    "type": "default",
+                    "category": "console",
+                    "data": {
+                        "arguments": [
+                            "./src/pages/template/Header.js\n  Line 14:  The href attribute requires a valid value to be accessible. Provide a valid, navigable address as the href value."
+                        ],
+                        "logger": "console",
+                    },
+                    "level": "warning",
+                    "message": "./src/pages/template/Header.js\n  Line 14:  The href attribute requires a valid value to be accessible. Provide a valid, navigable address as the href value.",
+                },
+            },
+        }
+    ]
+
+
+def mock_segment_breadcrumb(timestamp: datetime.datetime, payload) -> SegmentList:
+    return [
+        {
+            "type": 5,
+            "timestamp": int(timestamp.timestamp()),
+            "data": {
+                "tag": "breadcrumb",
+                "payload": payload,
+            },
+        }
+    ]
+
+
+def mock_segment_nagivation(
+    timestamp: datetime.datetime, hrefFrom: str = "/", hrefTo: str = "/profile/"
+) -> SegmentList:
+    return mock_segment_breadcrumb(
+        timestamp,
+        {
+            "timestamp": int(timestamp.timestamp()) / 1000,
+            "type": "default",
+            "category": "navigation",
+            "data": {"from": hrefFrom, "to": hrefTo},
+        },
+    )
+
+
+__rrweb_id = 0
+
+
+def next_rrweb_id():
+    global __rrweb_id
+    __rrweb_id += 1
+    return __rrweb_id
+
+
+def mock_rrweb_node(**kwargs: typing.Dict[str, typing.Any]) -> RRWebNode:
+    id = kwargs.pop("id", next_rrweb_id())
+    tagName = kwargs.pop("tagName", None)
+    if tagName:
+        return {
+            "type": EventType.FullSnapshot,
+            "id": id,
+            "tagName": tagName,
+            "attributes": kwargs.pop("attributes", {}),
+            "childNodes": kwargs.pop("childNodes", []),
+        }
+    else:
+        return {
+            "type": EventType.IncrementalSnapshot,
+            "id": id,
+            "textContent": kwargs.pop("textContent", ""),
+        }
+
+
+def mock_rrweb_div_helloworld() -> RRWebNode:
+    return mock_rrweb_node(
+        tagName="div",
+        childNodes=[
+            mock_rrweb_node(
+                tagName="h1",
+                attributes={"style": "text-align: center;"},
+                childNodes=[mock_rrweb_node(textContent="Hello World")],
+            ),
+        ],
+    )

+ 44 - 0
src/sentry/testutils/cases.py

@@ -26,6 +26,7 @@ __all__ = (
     "MetricsEnhancedPerformanceTestCase",
     "MetricsAPIBaseTestCase",
     "OrganizationMetricMetaIntegrationTestCase",
+    "ReplaysAcceptanceTestCase",
     "ReplaysSnubaTestCase",
 )
 
@@ -36,13 +37,16 @@ import os.path
 import time
 from contextlib import contextmanager
 from datetime import datetime
+from io import BytesIO
 from typing import Dict, List, Optional, Union
 from unittest import mock
 from unittest.mock import patch
 from urllib.parse import urlencode
 from uuid import uuid4
+from zlib import compress
 
 import pytest
+import pytz
 import requests
 from click.testing import CliRunner
 from django.conf import settings
@@ -87,6 +91,7 @@ from sentry.models import (
     DashboardWidgetQuery,
     DeletedOrganization,
     Deploy,
+    File,
     GroupMeta,
     Identity,
     IdentityProvider,
@@ -102,6 +107,7 @@ from sentry.models import (
 )
 from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
 from sentry.plugins.base import plugins
+from sentry.replays.models import ReplayRecordingSegment
 from sentry.search.events.constants import (
     METRIC_FRUSTRATED_TAG_VALUE,
     METRIC_SATISFACTION_TAG_KEY,
@@ -118,6 +124,7 @@ from sentry.testutils.helpers.slack import install_slack
 from sentry.types.integrations import ExternalProviders
 from sentry.utils import json
 from sentry.utils.auth import SsoSession
+from sentry.utils.json import dumps_htmlsafe
 from sentry.utils.pytest.selenium import Browser
 from sentry.utils.retries import TimedRetryPolicy
 from sentry.utils.snuba import _snuba_pool
@@ -1355,6 +1362,43 @@ class ReplaysSnubaTestCase(TestCase):
         assert response.status_code == 200
 
 
+# AcceptanceTestCase and TestCase are mutually exclusive base classses
+class ReplaysAcceptanceTestCase(AcceptanceTestCase, SnubaTestCase):
+    def setUp(self):
+        self.now = datetime.utcnow().replace(tzinfo=pytz.utc)
+        super().setUp()
+        self.drop_replays()
+        patcher = patch("django.utils.timezone.now", return_value=self.now)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def drop_replays(self):
+        assert requests.post(settings.SENTRY_SNUBA + "/tests/replays/drop").status_code == 200
+
+    def store_replays(self, replays):
+        assert (
+            len(replays) >= 2
+        ), "You need to store at least 2 replay events for the replay to be considered valid"
+        response = requests.post(settings.SENTRY_SNUBA + "/tests/replays/insert", json=replays)
+        assert response.status_code == 200
+
+    def store_replay_segments(
+        self,
+        replay_id: str,
+        project_id: str,
+        segment_id: int,
+        segment,
+    ):
+        f = File.objects.create(name="rr:{segment_id}", type="replay.recording")
+        f.putfile(BytesIO(compress(dumps_htmlsafe(segment).encode())))
+        ReplayRecordingSegment.objects.create(
+            replay_id=replay_id,
+            project_id=project_id,
+            segment_id=segment_id,
+            file_id=f.id,
+        )
+
+
 class IntegrationRepositoryTestCase(APITestCase):
     def setUp(self):
         super().setUp()

+ 7 - 2
static/app/utils/replays/replayDataUtils.tsx

@@ -154,22 +154,27 @@ export function spansFactory(spans: ReplaySpan[]) {
  * @deprecated Once the backend returns the corrected timestamps, this is not needed.
  */
 export function replayTimestamps(
+  replayRecord: ReplayRecord,
   rrwebEvents: RecordingEvent[],
   rawCrumbs: ReplayCrumb[],
   rawSpanData: ReplaySpan[]
 ) {
-  const rrwebTimestamps = rrwebEvents.map(event => event.timestamp);
+  const rrwebTimestamps = rrwebEvents.map(event => event.timestamp).filter(Boolean);
   const breadcrumbTimestamps = (
     rawCrumbs.map(rawCrumb => rawCrumb.timestamp).filter(Boolean) as number[]
-  ).map(timestamp => +new Date(timestamp * 1000));
+  )
+    .map(timestamp => +new Date(timestamp * 1000))
+    .filter(Boolean);
   const spanStartTimestamps = rawSpanData.map(span => span.startTimestamp * 1000);
   const spanEndTimestamps = rawSpanData.map(span => span.endTimestamp * 1000);
 
   return {
     startTimestampMs: Math.min(
+      replayRecord.startedAt.getTime(),
       ...[...rrwebTimestamps, ...breadcrumbTimestamps, ...spanStartTimestamps]
     ),
     endTimestampMs: Math.max(
+      replayRecord.finishedAt.getTime(),
       ...[...rrwebTimestamps, ...breadcrumbTimestamps, ...spanEndTimestamps]
     ),
   };

+ 1 - 0
static/app/utils/replays/replayReader.tsx

@@ -61,6 +61,7 @@ export default class ReplayReader {
     // TODO(replays): We should get correct timestamps from the backend instead
     // of having to fix them up here.
     const {startTimestampMs, endTimestampMs} = replayTimestamps(
+      replayRecord,
       rrwebEvents,
       breadcrumbs,
       spans

+ 83 - 0
tests/acceptance/test_replay_detail.py

@@ -0,0 +1,83 @@
+from datetime import datetime, timedelta
+from uuid import uuid4
+
+from sentry.replays.testutils import (
+    mock_replay,
+    mock_rrweb_div_helloworld,
+    mock_segment_console,
+    mock_segment_fullsnapshot,
+    mock_segment_init,
+    mock_segment_nagivation,
+)
+from sentry.testutils import ReplaysAcceptanceTestCase
+
+FEATURE_NAME = ["organizations:session-replay", "organizations:session-replay-ui"]
+
+
+class ReplayDetailTest(ReplaysAcceptanceTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.user = self.create_user("foo@example.com")
+        self.org = self.create_organization(name="Rowdy Tiger", owner=None)
+        self.team = self.create_team(organization=self.org, name="Mariachi Band 1")
+        self.project = self.create_project(
+            organization=self.org,
+            teams=[self.team],
+            name="Bengal",
+        )
+        self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team])
+
+        replay_id = uuid4().hex
+        seq1_timestamp = datetime.now() - timedelta(seconds=52)
+        seq2_timestamp = datetime.now() - timedelta(seconds=35)
+        self.store_replays(
+            [
+                mock_replay(
+                    seq1_timestamp,
+                    self.project.id,
+                    replay_id,
+                    segment_id=0,
+                    urls=[
+                        "http://localhost/",
+                        "http://localhost/home/",
+                        "http://localhost/profile/",
+                    ],
+                ),
+                mock_replay(seq2_timestamp, self.project.id, replay_id, segment_id=1),
+            ]
+        )
+        segments = [
+            mock_segment_init(seq2_timestamp),
+            mock_segment_fullsnapshot(seq2_timestamp, [mock_rrweb_div_helloworld()]),
+            mock_segment_console(seq2_timestamp),
+            mock_segment_nagivation(
+                seq2_timestamp + timedelta(seconds=1), hrefFrom="/", hrefTo="/home/"
+            ),
+            mock_segment_nagivation(
+                seq2_timestamp + timedelta(seconds=2), hrefFrom="/home/", hrefTo="/profile/"
+            ),
+        ]
+        for (segment_id, segment) in enumerate(segments):
+            self.store_replay_segments(replay_id, self.project.id, segment_id, segment)
+
+        self.login_as(self.user)
+
+        slug = f"{self.project.slug}:{replay_id}"
+        self.path = f"/organizations/{self.org.slug}/replays/{slug}/"
+
+    def test_not_found(self):
+        with self.feature(FEATURE_NAME):
+            slug = f"{self.project.slug}:abcdef"
+            self.path = f"/organizations/{self.org.slug}/replays/{slug}/"
+
+            self.browser.get(self.path)
+            self.browser.wait_until_not('[data-test-id="loading-indicator"]')
+            self.browser.snapshot("replay detail not found")
+
+    def test_simple(self):
+        with self.feature(FEATURE_NAME):
+            self.browser.get(self.path)
+            self.browser.wait_until_not('[data-test-id="loading-indicator"]')
+            self.browser.wait_until_not('[data-test-id="loading-placeholder"]')
+            self.browser.snapshot("replay detail")

+ 53 - 0
tests/acceptance/test_replay_list.py

@@ -0,0 +1,53 @@
+from datetime import datetime, timedelta
+from uuid import uuid4
+
+from sentry.replays.testutils import mock_replay
+from sentry.testutils import ReplaysAcceptanceTestCase
+
+FEATURE_NAME = ["organizations:session-replay", "organizations:session-replay-ui"]
+
+
+class ReplayListTest(ReplaysAcceptanceTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.user = self.create_user("foo@example.com")
+        self.org = self.create_organization(name="Rowdy Tiger", owner=None)
+        self.team = self.create_team(organization=self.org, name="Mariachi Band 1")
+        self.project = self.create_project(
+            organization=self.org,
+            teams=[self.team],
+            name="Bengal",
+        )
+        self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team])
+
+        seq1_timestamp = datetime.now() - timedelta(seconds=52)
+        seq2_timestamp = datetime.now() - timedelta(seconds=35)
+        for replay_id in [uuid4().hex, uuid4().hex, uuid4().hex]:
+            self.store_replays(
+                [
+                    mock_replay(
+                        seq1_timestamp,
+                        self.project.id,
+                        replay_id,
+                        segment_id=0,
+                        urls=[
+                            "http://localhost/",
+                            "http://localhost/home/",
+                            "http://localhost/profile/",
+                        ],
+                    ),
+                    mock_replay(seq2_timestamp, self.project.id, replay_id, segment_id=1),
+                ]
+            )
+
+        self.login_as(self.user)
+
+        self.path = f"/organizations/{self.org.slug}/replays/"
+
+    def test_simple(self):
+        with self.feature(FEATURE_NAME):
+            self.browser.get(self.path)
+            self.browser.wait_until_not('[data-test-id="loading-indicator"]')
+            self.browser.wait_until_not('[data-test-id="loading-placeholder"]')
+            self.browser.snapshot("replay list")