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 datetime
 import typing
 import typing
+from enum import Enum
 
 
 from sentry.utils import json
 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(
 def assert_expected_response(
     response: typing.Dict[str, typing.Any], expected_response: typing.Dict[str, typing.Any]
     response: typing.Dict[str, typing.Any], expected_response: typing.Dict[str, typing.Any]
 ) -> None:
 ) -> 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",
     "MetricsEnhancedPerformanceTestCase",
     "MetricsAPIBaseTestCase",
     "MetricsAPIBaseTestCase",
     "OrganizationMetricMetaIntegrationTestCase",
     "OrganizationMetricMetaIntegrationTestCase",
+    "ReplaysAcceptanceTestCase",
     "ReplaysSnubaTestCase",
     "ReplaysSnubaTestCase",
 )
 )
 
 
@@ -36,13 +37,16 @@ import os.path
 import time
 import time
 from contextlib import contextmanager
 from contextlib import contextmanager
 from datetime import datetime
 from datetime import datetime
+from io import BytesIO
 from typing import Dict, List, Optional, Union
 from typing import Dict, List, Optional, Union
 from unittest import mock
 from unittest import mock
 from unittest.mock import patch
 from unittest.mock import patch
 from urllib.parse import urlencode
 from urllib.parse import urlencode
 from uuid import uuid4
 from uuid import uuid4
+from zlib import compress
 
 
 import pytest
 import pytest
+import pytz
 import requests
 import requests
 from click.testing import CliRunner
 from click.testing import CliRunner
 from django.conf import settings
 from django.conf import settings
@@ -87,6 +91,7 @@ from sentry.models import (
     DashboardWidgetQuery,
     DashboardWidgetQuery,
     DeletedOrganization,
     DeletedOrganization,
     Deploy,
     Deploy,
+    File,
     GroupMeta,
     GroupMeta,
     Identity,
     Identity,
     IdentityProvider,
     IdentityProvider,
@@ -102,6 +107,7 @@ from sentry.models import (
 )
 )
 from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
 from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
 from sentry.plugins.base import plugins
 from sentry.plugins.base import plugins
+from sentry.replays.models import ReplayRecordingSegment
 from sentry.search.events.constants import (
 from sentry.search.events.constants import (
     METRIC_FRUSTRATED_TAG_VALUE,
     METRIC_FRUSTRATED_TAG_VALUE,
     METRIC_SATISFACTION_TAG_KEY,
     METRIC_SATISFACTION_TAG_KEY,
@@ -118,6 +124,7 @@ from sentry.testutils.helpers.slack import install_slack
 from sentry.types.integrations import ExternalProviders
 from sentry.types.integrations import ExternalProviders
 from sentry.utils import json
 from sentry.utils import json
 from sentry.utils.auth import SsoSession
 from sentry.utils.auth import SsoSession
+from sentry.utils.json import dumps_htmlsafe
 from sentry.utils.pytest.selenium import Browser
 from sentry.utils.pytest.selenium import Browser
 from sentry.utils.retries import TimedRetryPolicy
 from sentry.utils.retries import TimedRetryPolicy
 from sentry.utils.snuba import _snuba_pool
 from sentry.utils.snuba import _snuba_pool
@@ -1355,6 +1362,43 @@ class ReplaysSnubaTestCase(TestCase):
         assert response.status_code == 200
         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):
 class IntegrationRepositoryTestCase(APITestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         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.
  * @deprecated Once the backend returns the corrected timestamps, this is not needed.
  */
  */
 export function replayTimestamps(
 export function replayTimestamps(
+  replayRecord: ReplayRecord,
   rrwebEvents: RecordingEvent[],
   rrwebEvents: RecordingEvent[],
   rawCrumbs: ReplayCrumb[],
   rawCrumbs: ReplayCrumb[],
   rawSpanData: ReplaySpan[]
   rawSpanData: ReplaySpan[]
 ) {
 ) {
-  const rrwebTimestamps = rrwebEvents.map(event => event.timestamp);
+  const rrwebTimestamps = rrwebEvents.map(event => event.timestamp).filter(Boolean);
   const breadcrumbTimestamps = (
   const breadcrumbTimestamps = (
     rawCrumbs.map(rawCrumb => rawCrumb.timestamp).filter(Boolean) as number[]
     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 spanStartTimestamps = rawSpanData.map(span => span.startTimestamp * 1000);
   const spanEndTimestamps = rawSpanData.map(span => span.endTimestamp * 1000);
   const spanEndTimestamps = rawSpanData.map(span => span.endTimestamp * 1000);
 
 
   return {
   return {
     startTimestampMs: Math.min(
     startTimestampMs: Math.min(
+      replayRecord.startedAt.getTime(),
       ...[...rrwebTimestamps, ...breadcrumbTimestamps, ...spanStartTimestamps]
       ...[...rrwebTimestamps, ...breadcrumbTimestamps, ...spanStartTimestamps]
     ),
     ),
     endTimestampMs: Math.max(
     endTimestampMs: Math.max(
+      replayRecord.finishedAt.getTime(),
       ...[...rrwebTimestamps, ...breadcrumbTimestamps, ...spanEndTimestamps]
       ...[...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
     // TODO(replays): We should get correct timestamps from the backend instead
     // of having to fix them up here.
     // of having to fix them up here.
     const {startTimestampMs, endTimestampMs} = replayTimestamps(
     const {startTimestampMs, endTimestampMs} = replayTimestamps(
+      replayRecord,
       rrwebEvents,
       rrwebEvents,
       breadcrumbs,
       breadcrumbs,
       spans
       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")